Coverage for astrocyte/identity.py: 94%
77 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""Identity resolution, OBO permission intersection, and bank resolution (M1 / v0.5.0 with M2).
3See ``docs/_design/adr/adr-002-identity-model.md``.
4"""
6from __future__ import annotations
8from fnmatch import fnmatchcase
10from astrocyte.types import AccessGrant, ActorIdentity, AstrocyteContext
13def format_principal(actor: ActorIdentity) -> str:
14 """Serialize actor to the grant-matching principal form ``{type}:{id}``."""
15 return f"{actor.type}:{actor.id}"
18def parse_principal(principal: str) -> ActorIdentity:
19 """Parse ``principal`` string into :class:`ActorIdentity` (ADR-002).
21 - ``agent:X`` / ``user:X`` / ``service:X`` / ``team:X`` → typed id ``X``
22 - no prefix → treated as ``user:{principal}``
23 """
24 for prefix, typ in (("agent:", "agent"), ("user:", "user"), ("service:", "service"), ("team:", "team")):
25 if principal.startswith(prefix):
26 return ActorIdentity(type=typ, id=principal[len(prefix) :])
27 return ActorIdentity(type="user", id=principal)
30def resolve_actor(context: AstrocyteContext) -> ActorIdentity | None:
31 """Resolved actor: explicit ``context.actor``, else parse ``context.principal``."""
32 if context.actor is not None:
33 return context.actor
34 if context.principal:
35 return parse_principal(context.principal)
36 return None
39def context_principal_label(context: AstrocyteContext) -> str:
40 """Short label for logs and :class:`~astrocyte.errors.AccessDenied` messages."""
41 actor = resolve_actor(context)
42 base = format_principal(actor) if actor else "anonymous"
43 if context.on_behalf_of is not None:
44 return f"{base} obo {format_principal(context.on_behalf_of)}"
45 return base
48_GLOB_CHARS = "*?["
51def _is_pattern(value: str) -> bool:
52 return any(c in value for c in _GLOB_CHARS)
55def _grant_field_matches(pattern: str, value: str) -> bool:
56 """Match a grant's ``bank_id`` or ``principal`` against a concrete value.
58 ``*`` and exact strings behave as they always have. Any other value with a
59 glob character is an fnmatch pattern (``project:*``, ``user:*``), matched
60 case-sensitively on every platform. Bank ids cannot contain glob characters
61 (``validate_bank_id``), so a pattern never collides with a real id.
62 """
63 if pattern == "*" or pattern == value:
64 return True
65 return _is_pattern(pattern) and fnmatchcase(value, pattern)
68def _permissions_for_principal_on_bank(
69 grants: list[AccessGrant],
70 bank_id: str,
71 principal_str: str,
72 groups: list[str] | None = None,
73) -> set[str]:
74 principals = [principal_str, *(groups or ())]
75 out: set[str] = set()
76 for g in grants:
77 if not _grant_field_matches(g.bank_id, bank_id):
78 continue
79 if any(_grant_field_matches(g.principal, p) for p in principals):
80 out.update(g.permissions)
81 return out
84def _with_context_grants(context: AstrocyteContext, grants: list[AccessGrant]) -> list[AccessGrant]:
85 return [*grants, *context.grants] if context.grants else grants
88def effective_permissions(
89 context: AstrocyteContext,
90 grants: list[AccessGrant],
91 bank_id: str,
92) -> set[str]:
93 """Effective permission set for ``bank_id``, including OBO intersection.
95 Grants carried on the context (``context.grants``, e.g. bound to a gateway
96 token) are added to ``grants``. ``context.groups`` are extra principals the
97 actor holds, so a grant to ``team:api`` applies to its members; they do not
98 extend the on-behalf-of identity.
99 """
100 actor = resolve_actor(context)
101 if actor is None:
102 return set()
103 all_grants = _with_context_grants(context, grants)
104 actor_perms = _permissions_for_principal_on_bank(
105 all_grants, bank_id, format_principal(actor), groups=context.groups
106 )
107 if context.on_behalf_of is None:
108 return actor_perms
109 obo_perms = _permissions_for_principal_on_bank(all_grants, bank_id, format_principal(context.on_behalf_of))
110 return actor_perms & obo_perms
113def accessible_read_banks(
114 context: AstrocyteContext,
115 grants: list[AccessGrant],
116 *,
117 known_bank_ids: list[str] | None = None,
118 resolver: BankResolver | None = None,
119) -> list[str]:
120 """Bank IDs where ``context`` has effective ``read`` after OBO intersection.
122 Candidates are: non-pattern ``bank_id`` values from ``grants`` and
123 ``context.grants``, optional ``known_bank_ids``, and (when no other
124 candidates) a convention bank from ``resolver`` for the resolved actor so
125 wildcard-only ACL rows can still resolve.
126 """
127 candidates: set[str] = {g.bank_id for g in _with_context_grants(context, grants) if not _is_pattern(g.bank_id)}
128 if known_bank_ids:
129 candidates.update(known_bank_ids)
131 actor = resolve_actor(context)
132 if not candidates and resolver is not None and actor is not None:
133 suggested = resolver.default_bank_id(actor)
134 if suggested is not None:
135 candidates.add(suggested)
137 out: list[str] = []
138 for bid in sorted(candidates):
139 if "read" in effective_permissions(context, grants, bid):
140 out.append(bid)
141 return out
144class BankResolver:
145 """Convention-based default bank id from a resolved :class:`ActorIdentity`."""
147 def __init__(
148 self,
149 *,
150 user_prefix: str = "user-",
151 agent_prefix: str = "agent-",
152 service_prefix: str = "service-",
153 ) -> None:
154 self._user_prefix = user_prefix
155 self._agent_prefix = agent_prefix
156 self._service_prefix = service_prefix
158 def default_bank_id(self, actor: ActorIdentity) -> str | None:
159 if actor.type == "user":
160 return f"{self._user_prefix}{actor.id}"
161 if actor.type == "agent":
162 return f"{self._agent_prefix}{actor.id}"
163 if actor.type == "service":
164 return f"{self._service_prefix}{actor.id}"
165 return None