Skip to content

py-abac integration

The adapter routes py-abac's policy lookups through cached find_one, find, and aggregate, while policy creation and updates remain on the raw MongoDB storage. It consumes query cursors through the policy iterators expected by py-abac.

The program checks hits for all three retrieval shapes, authorizes a request, updates the policy from allow to deny, and polls until invalidation changes the authorization result. This demonstrates eventual invalidation. Applications that must re-authorize against current policy on every request should use direct reads; see consistency and security.

See the example catalogue for prerequisites, run commands, expected evidence, and the database-reset warning.

Complete program

The code below is included from the canonical Python source.

# /// script
# requires-python = ">=3.14"
# dependencies = ["client-query-cache", "py-abac>=0.4.1"]
#
# [tool.uv.sources]
# client-query-cache = { path = "..", editable = true }
# ///

import os
from time import monotonic, sleep
from typing import TYPE_CHECKING, Any

from py_abac import PDP, AccessRequest, Policy  # type: ignore[import-untyped]
from py_abac.storage.mongo import MongoStorage  # type: ignore[import-untyped]
from py_abac.storage.mongo.model import PolicyModel  # type: ignore[import-untyped]
from pymongo import MongoClient

from client_query_cache import CacheManager

if TYPE_CHECKING:
    from collections.abc import Iterator

DATABASE_NAME = "client_query_cache_example_py_abac"
DEFAULT_MONGODB_URI = "mongodb://localhost:27017/?directConnection=true"
POLICY_ID = "read-policy"


class CachedMongoStorage(MongoStorage):  # type: ignore[misc]
    def __init__(self, manager: CacheManager[dict[str, Any]]) -> None:
        super().__init__(manager.client, db_name=DATABASE_NAME)
        self.cached_collection = manager.get_cached_collection(self.collection)

    def get(self, uid: str) -> Policy | None:
        document = self.cached_collection.find_one(uid)
        if not document:
            return None
        return PolicyModel.from_doc(document).to_policy()

    def get_all(  # pytriage: TR4 -- upstream storage method name
        self, limit: int, offset: int
    ) -> Iterator[Policy]:
        self._check_limit_and_offset(limit, offset)
        for document in self.cached_collection.find({}, limit=limit, skip=offset):
            yield PolicyModel.from_doc(document).to_policy()

    def get_for_target(  # pytriage: TR4 -- upstream storage method name
        self, subject_id: str, resource_id: str, action_id: str
    ) -> Iterator[Policy]:
        pipeline = PolicyModel.get_aggregate_pipeline(
            subject_id, resource_id, action_id
        )
        for document in self.cached_collection.aggregate(pipeline):
            yield PolicyModel.from_doc(document).to_policy()


def read_policies(storage: CachedMongoStorage, shape: str) -> tuple[Policy, ...]:
    if shape == "get":
        retrieved = storage.get(POLICY_ID)
        return () if retrieved is None else (retrieved,)
    if shape == "get_all":
        return tuple(storage.get_all(limit=1, offset=0))
    return tuple(storage.get_for_target("alice", "document", "read"))


def check_policy_reads(storage: CachedMongoStorage, shape: str) -> None:
    for _ in range(5):
        policies = read_policies(storage, shape)
        if len(policies) != 1 or policies[0].uid != POLICY_ID:
            message = f"policy retrieval failed for {shape}"
            raise SystemExit(message)


def main() -> None:
    mongodb_uri = os.getenv("MONGODB_URI", DEFAULT_MONGODB_URI)
    with MongoClient[dict[str, Any]](mongodb_uri) as client:
        client.drop_database(DATABASE_NAME)
        with CacheManager(client) as manager:
            storage = CachedMongoStorage(manager)
            policy = Policy.from_json(
                {
                    "uid": POLICY_ID,
                    "description": "Allow Alice to read the document",
                    "effect": "allow",
                    "rules": {},
                    "targets": {
                        "subject_id": "alice",
                        "resource_id": "document",
                        "action_id": "read",
                    },
                    "priority": 0,
                }
            )
            storage.add(policy)
            for shape in ("get", "get_all", "get_for_target"):
                hits_before = manager.snapshot().hits
                check_policy_reads(storage, shape)
                hits = manager.snapshot().hits - hits_before
                if hits < 4:
                    message = f"no cache hits for {shape}"
                    raise SystemExit(message)
                print(f"{shape} cache hits: {hits}")
            request = AccessRequest.from_json(
                {
                    "subject": {"id": "alice", "attributes": {}},
                    "resource": {"id": "document", "attributes": {}},
                    "action": {"id": "read", "attributes": {}},
                    "context": {},
                }
            )
            pdp = PDP(storage)
            if not pdp.is_allowed(request):
                raise SystemExit("allow policy did not authorize the request")
            policy.effect = "deny"
            storage.update(policy)
            started = monotonic()
            while pdp.is_allowed(request):
                if monotonic() - started >= 5:
                    raise SystemExit("invalidation not observed for the deny policy")
                sleep(0.05)
            snapshot = manager.snapshot()
            print("authorization changed: allow -> deny")
            invalidation_ms = (monotonic() - started) * 1000
            print(f"invalidation observed after: {invalidation_ms:.0f} ms")
            print(
                f"cache hits: {snapshot.hits}, misses: {snapshot.misses}, "
                f"bypasses: {snapshot.bypasses}"
            )


if __name__ == "__main__":
    main()