lorebase-keeper 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,183 @@
1
+ """FastAPI dependencies for authentication and authorization.
2
+
3
+ Routes depend on `require(permission)` (a Domain permission, the Domain taken from the path) or
4
+ `require_installation(permission)`; both authenticate first and return what they decided on:
5
+
6
+ @router.get("/domains/{domain_id}/grants")
7
+ async def list_grants(access: Annotated[Access, Depends(require(Permission.GRANT_MANAGE))]): ...
8
+
9
+ Kind, Operation and branch scoped grants are evaluated when the route names the path parameter
10
+ that carries them (`kind_param`, `operation_param`, `branch_param`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Awaitable, Callable
16
+ from dataclasses import dataclass
17
+ from typing import Annotated, cast
18
+
19
+ from fastapi import Depends, Request
20
+ from fastapi.security import HTTPBearer
21
+
22
+ from lorebase_keeper.auth.audit import annotate
23
+ from lorebase_keeper.auth.permissions import Permission
24
+ from lorebase_keeper.auth.principals import Principal
25
+ from lorebase_keeper.auth.rbac import NO_CONTEXT, AccessContext, Authorizer
26
+ from lorebase_keeper.auth.runtime import AuthRuntime
27
+ from lorebase_keeper.client_address import client_address
28
+ from lorebase_keeper.errors import FORBIDDEN, NOT_FOUND, UNAVAILABLE, ProblemError
29
+
30
+ _PRINCIPAL_KEY = "principal"
31
+ _bearer_scheme = HTTPBearer(auto_error=False, description="API key (lbk_...) or OIDC access token.")
32
+
33
+
34
+ def get_auth(request: Request) -> AuthRuntime:
35
+ """The application's auth components."""
36
+ return cast(AuthRuntime, request.app.state.auth)
37
+
38
+
39
+ AuthDep = Annotated[AuthRuntime, Depends(get_auth)]
40
+
41
+
42
+ def require_store(auth: AuthRuntime) -> None:
43
+ """Raise 503 when no database is configured (administration needs one)."""
44
+ if auth.store is None:
45
+ raise ProblemError(UNAVAILABLE, "This operation needs a configured database.")
46
+
47
+
48
+ async def current_principal(
49
+ request: Request,
50
+ auth: AuthDep,
51
+ _credentials: Annotated[object, Depends(_bearer_scheme)] = None,
52
+ ) -> Principal:
53
+ """Authenticate the request (once) or answer 401."""
54
+ state = request.scope.setdefault("state", {})
55
+ principal = state.get(_PRINCIPAL_KEY)
56
+ if principal is None:
57
+ principal = await auth.authenticator.authenticate(
58
+ request.headers, client_address(request), request.method
59
+ )
60
+ state[_PRINCIPAL_KEY] = principal
61
+ annotate(request, actor=principal.id, principal_type=principal.type.value)
62
+ return cast(Principal, principal)
63
+
64
+
65
+ PrincipalDep = Annotated[Principal, Depends(current_principal)]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Access:
70
+ """An authorized request: who, in which Domain (none for installation-level), what for."""
71
+
72
+ principal: Principal
73
+ domain_id: str | None
74
+ permission: Permission
75
+ kinds: frozenset[str] | None = None
76
+ """Set when only grants narrowed to these Kinds allowed the request (`require` with
77
+ `kind_limited`): the response must not tell about other Kinds."""
78
+
79
+
80
+ def require(
81
+ permission: Permission,
82
+ *,
83
+ domain_param: str = "domain_id",
84
+ kind_param: str | None = None,
85
+ operation_param: str | None = None,
86
+ branch_param: str | None = None,
87
+ kind_limited: bool = False,
88
+ ) -> Callable[..., Awaitable[Access]]:
89
+ """Dependency: the caller holds `permission` in the Domain named by the path.
90
+
91
+ With `kind_limited`, grants narrowed to Kinds count too when Kinds are invisible to such
92
+ callers (`keeper.auth.authz.invisible_kinds`, design 13.3): for reads that tell nothing
93
+ about Kinds (the Domain, its branches and tags), which a caller limited to Kinds needs to
94
+ find its way. `Access.kinds` then names them.
95
+
96
+ Answers 401 without a valid credential and 403, naming the missing permission, otherwise.
97
+ """
98
+
99
+ async def dependency(request: Request, principal: PrincipalDep, auth: AuthDep) -> Access:
100
+ params = request.path_params
101
+ domain_id = str(params[domain_param])
102
+ context = AccessContext(
103
+ kind=_param(params, kind_param),
104
+ operation=_param(params, operation_param),
105
+ branch=_param(params, branch_param),
106
+ )
107
+ try:
108
+ return await authorize(request, auth, principal, permission, domain_id, context)
109
+ except ProblemError as error:
110
+ if not kind_limited or error.status_code != 403:
111
+ raise
112
+ kinds = await limited_kinds(auth.authorizer, principal, permission, domain_id)
113
+ if not kinds:
114
+ raise
115
+ return Access(principal, domain_id, permission, kinds)
116
+
117
+ return dependency
118
+
119
+
120
+ async def authorize(
121
+ request: Request,
122
+ auth: AuthRuntime,
123
+ principal: Principal,
124
+ permission: Permission,
125
+ domain_id: str,
126
+ context: AccessContext = NO_CONTEXT,
127
+ ) -> Access:
128
+ """Check `permission` in a Domain for a context known only inside the route (a branch or
129
+ Operation named in the body). Answers 403 naming the missing permission."""
130
+ annotate(request, domain_id=domain_id, permission=permission.value)
131
+ if not await auth.authorizer.allows(principal, permission, domain_id, context):
132
+ raise await refusal(auth.authorizer, principal, permission, domain_id)
133
+ return Access(principal=principal, domain_id=domain_id, permission=permission)
134
+
135
+
136
+ async def limited_kinds(
137
+ authorizer: Authorizer, principal: Principal, permission: Permission, domain_id: str
138
+ ) -> frozenset[str]:
139
+ """Kinds that grants narrowed to Kinds give `permission` on, when such Kinds are invisible
140
+ to the caller (design 13.3); empty otherwise."""
141
+ if not authorizer.invisible_kinds:
142
+ return frozenset()
143
+ return await authorizer.granted_kinds(principal, permission, domain_id)
144
+
145
+
146
+ async def refusal(
147
+ authorizer: Authorizer, principal: Principal, permission: Permission, domain_id: str
148
+ ) -> ProblemError:
149
+ """The problem for a caller who lacks `permission` in a Domain: 404 when the Domain does not
150
+ exist and the caller may be told so (see `Authorizer.reveals_missing_domain`), else 403."""
151
+ if await authorizer.reveals_missing_domain(principal, domain_id):
152
+ return ProblemError(NOT_FOUND, f"Domain {domain_id!r} not found.")
153
+ return missing_permission(permission, domain_id)
154
+
155
+
156
+ def missing_permission(permission: Permission, domain_id: str) -> ProblemError:
157
+ """The 403 problem naming the permission a caller lacks in a Domain."""
158
+ return ProblemError(
159
+ FORBIDDEN,
160
+ f"Missing permission {permission.value} in Domain {domain_id}.",
161
+ extensions={"permission": permission.value, "domain": domain_id},
162
+ )
163
+
164
+
165
+ def require_installation(permission: Permission) -> Callable[..., Awaitable[Access]]:
166
+ """Dependency: the caller holds an installation-level `permission`."""
167
+
168
+ async def dependency(request: Request, principal: PrincipalDep, auth: AuthDep) -> Access:
169
+ annotate(request, permission=permission.value)
170
+ if not await auth.authorizer.allows_installation(principal, permission):
171
+ raise ProblemError(
172
+ FORBIDDEN,
173
+ f"Missing permission {permission.value}.",
174
+ extensions={"permission": permission.value},
175
+ )
176
+ return Access(principal=principal, domain_id=None, permission=permission)
177
+
178
+ return dependency
179
+
180
+
181
+ def _param(params: dict[str, object], name: str | None) -> str | None:
182
+ value = params.get(name) if name else None
183
+ return None if value is None else str(value)
@@ -0,0 +1,14 @@
1
+ """Keys derived from `keeper.auth.sessions.encryption_key` (`lorebase_store.keyring`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lorebase_store.keyring import Keyring as _Keyring
6
+
7
+ NAMESPACE = "keeper"
8
+
9
+
10
+ class Keyring(_Keyring):
11
+ """The session keyring: keys derived under the `keeper` namespace."""
12
+
13
+ def __init__(self, secret: str) -> None:
14
+ super().__init__(secret, namespace=NAMESPACE)
@@ -0,0 +1,242 @@
1
+ """Bearer token validation against any OpenID Connect provider (design 13.2).
2
+
3
+ Tokens are JWTs checked against the provider's published signing keys (JWKS): signature,
4
+ issuer, audience, expiry and not-before, with the configured clock skew. Keys are fetched with
5
+ httpx and cached; an unknown key id triggers at most one refresh per `jwks_min_refresh_s`.
6
+
7
+ PyJWT (with `cryptography`) does the token work. python-jose was not chosen: it is barely
8
+ maintained and has had several security advisories; PyJWT is maintained, small and parses JWKS
9
+ natively. No provider SDK is involved: discovery and key download are plain HTTP.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import hmac
16
+ import logging
17
+ import time
18
+ from collections.abc import Callable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ import httpx
23
+ import jwt
24
+ from jwt import PyJWK
25
+
26
+ from lorebase_keeper.auth.config import OidcSettings, insecure_url_problem
27
+
28
+ logger = logging.getLogger("lorebase.keeper.auth.oidc")
29
+
30
+ DISCOVERY_PATH = "/.well-known/openid-configuration"
31
+
32
+
33
+ class InvalidTokenError(Exception):
34
+ """The token is not acceptable. The message is for logs, never for the client."""
35
+
36
+
37
+ class ProviderUnavailableError(Exception):
38
+ """The provider's keys cannot be obtained, so no token can be judged."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class OidcClaims:
43
+ """What Keeper needs from a validated token."""
44
+
45
+ subject: str
46
+ display_name: str
47
+ groups: tuple[str, ...]
48
+
49
+
50
+ class JwksCache:
51
+ """Signing keys of the provider, refreshed after a time to live or for an unknown key id."""
52
+
53
+ def __init__(
54
+ self,
55
+ settings: OidcSettings,
56
+ http: httpx.AsyncClient,
57
+ clock: Callable[[], float] = time.monotonic,
58
+ ) -> None:
59
+ self._settings = settings
60
+ self._http = http
61
+ self._clock = clock
62
+ self._keys: dict[str, PyJWK] = {}
63
+ self._fetched_at: float | None = None
64
+ self._attempted_at: float | None = None
65
+ self._jwks_uri = settings.jwks_uri
66
+ self._lock = asyncio.Lock()
67
+
68
+ async def key_for(self, kid: str | None) -> PyJWK:
69
+ """The signing key with this id (the only key when the token names none)."""
70
+ if self._is_stale():
71
+ await self._refresh()
72
+ key = self._find(kid)
73
+ if key is None:
74
+ await self._refresh() # the provider may have rotated its keys
75
+ key = self._find(kid)
76
+ if key is None:
77
+ if not self._keys:
78
+ raise ProviderUnavailableError("signing keys unavailable")
79
+ raise InvalidTokenError(f"no signing key {kid!r}")
80
+ return key
81
+
82
+ def _find(self, kid: str | None) -> PyJWK | None:
83
+ if kid is not None:
84
+ return self._keys.get(kid)
85
+ return next(iter(self._keys.values())) if len(self._keys) == 1 else None
86
+
87
+ def _is_stale(self) -> bool:
88
+ return (
89
+ self._fetched_at is None
90
+ or self._clock() - self._fetched_at >= self._settings.jwks_cache_ttl_s
91
+ )
92
+
93
+ async def _refresh(self) -> None:
94
+ """Fetch the keys, unless an attempt was made less than `jwks_min_refresh_s` ago.
95
+
96
+ On failure the previous keys stay in use and the next attempt waits for the same delay,
97
+ so an unreachable provider is not hammered.
98
+ """
99
+ async with self._lock:
100
+ now = self._clock()
101
+ if (
102
+ self._attempted_at is not None
103
+ and now - self._attempted_at < self._settings.jwks_min_refresh_s
104
+ ):
105
+ return
106
+ self._attempted_at = now
107
+ try:
108
+ document = await self._fetch_jwks()
109
+ except (httpx.HTTPError, ValueError) as exc:
110
+ logger.warning("cannot fetch signing keys: %s", type(exc).__name__)
111
+ return
112
+ self._keys = _parse_keys(document)
113
+ self._fetched_at = now
114
+
115
+ async def _fetch_jwks(self) -> Mapping[str, Any]:
116
+ if self._jwks_uri is None:
117
+ self._jwks_uri = await self._discover()
118
+ response = await self._http.get(self._jwks_uri)
119
+ response.raise_for_status()
120
+ document = response.json()
121
+ if not isinstance(document, dict) or not isinstance(document.get("keys"), list):
122
+ raise ValueError("malformed JWKS document")
123
+ return document
124
+
125
+ async def _discover(self) -> str:
126
+ issuer = self._settings.issuer or ""
127
+ response = await self._http.get(issuer.rstrip("/") + DISCOVERY_PATH)
128
+ response.raise_for_status()
129
+ document = response.json()
130
+ if not isinstance(document, dict) or document.get("issuer") != issuer:
131
+ raise ValueError("discovery document does not match the configured issuer")
132
+ uri = document.get("jwks_uri")
133
+ if not isinstance(uri, str) or not uri:
134
+ raise ValueError("discovery document has no jwks_uri")
135
+ problem = insecure_url_problem(uri, self._settings.allow_insecure)
136
+ if problem:
137
+ logger.warning("discovered jwks_uri %r refused: %s", uri, problem)
138
+ raise ValueError("discovered jwks_uri is not acceptable")
139
+ return uri
140
+
141
+
142
+ def _parse_keys(document: Mapping[str, Any]) -> dict[str, PyJWK]:
143
+ keys: dict[str, PyJWK] = {}
144
+ for index, entry in enumerate(document["keys"]):
145
+ if not isinstance(entry, dict) or entry.get("use", "sig") != "sig":
146
+ continue
147
+ try:
148
+ key = PyJWK(entry)
149
+ except jwt.PyJWTError:
150
+ logger.info("skipping unsupported signing key %s", entry.get("kid", index))
151
+ continue
152
+ keys[str(entry.get("kid", f"#{index}"))] = key
153
+ return keys
154
+
155
+
156
+ class OidcVerifier:
157
+ """Validates bearer tokens and extracts the claims Keeper uses."""
158
+
159
+ def __init__(self, settings: OidcSettings, jwks: JwksCache) -> None:
160
+ self._settings = settings
161
+ self._jwks = jwks
162
+
163
+ async def verify(self, token: str) -> OidcClaims:
164
+ """Claims of a valid token; `InvalidTokenError` or `ProviderUnavailableError` otherwise."""
165
+ claims = await self._decode(token, self._settings.audience, ("exp", "iss", "aud"))
166
+ return self._claims(claims)
167
+
168
+ async def verify_id_token(
169
+ self,
170
+ token: str,
171
+ *,
172
+ client_id: str,
173
+ nonce: str | None = None,
174
+ nonce_required: bool = True,
175
+ ) -> OidcClaims:
176
+ """Claims of a valid ID token issued to `client_id` (OpenID Connect Core 3.1.3.7).
177
+
178
+ The audience must contain the client id and, with several audiences, `azp` must name it.
179
+ `nonce`, when given, must equal the token's. At sign-in the token must carry it; a
180
+ refreshed ID token (`nonce_required=False`) may omit it but, if present, must carry the
181
+ sign-in's nonce (Core 12.2).
182
+ """
183
+ claims = await self._decode(token, (client_id,), ("exp", "iat", "iss", "aud"))
184
+ audience = claims.get("aud")
185
+ if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client_id:
186
+ raise InvalidTokenError("azp does not name this client")
187
+ if nonce is not None:
188
+ received = claims.get("nonce")
189
+ if received is None and not nonce_required:
190
+ return self._claims(claims)
191
+ if not isinstance(received, str) or not hmac.compare_digest(received, nonce):
192
+ raise InvalidTokenError("nonce mismatch")
193
+ return self._claims(claims)
194
+
195
+ async def _decode(
196
+ self, token: str, audience: Sequence[str], required: Sequence[str]
197
+ ) -> dict[str, Any]:
198
+ settings = self._settings
199
+ try:
200
+ header = jwt.get_unverified_header(token)
201
+ algorithm = header.get("alg")
202
+ if algorithm not in settings.algorithms:
203
+ raise InvalidTokenError(f"algorithm {algorithm!r} is not accepted")
204
+ key = await self._jwks.key_for(header.get("kid"))
205
+ claims: dict[str, Any] = jwt.decode(
206
+ token,
207
+ key.key,
208
+ algorithms=[algorithm],
209
+ audience=list(audience),
210
+ issuer=settings.issuer,
211
+ leeway=settings.clock_skew_s,
212
+ options={"require": [*required, settings.subject_claim]},
213
+ )
214
+ except jwt.PyJWTError as exc:
215
+ raise InvalidTokenError(f"{type(exc).__name__}: {exc}") from None
216
+ return claims
217
+
218
+ def _claims(self, claims: Mapping[str, Any]) -> OidcClaims:
219
+ settings = self._settings
220
+ subject = claims.get(settings.subject_claim)
221
+ if not isinstance(subject, str) or not subject:
222
+ raise InvalidTokenError("subject claim is not a non-empty string")
223
+ return OidcClaims(
224
+ subject=subject,
225
+ display_name=self._display_name(claims, subject),
226
+ groups=_groups(claims.get(settings.groups_claim)),
227
+ )
228
+
229
+ def _display_name(self, claims: Mapping[str, Any], subject: str) -> str:
230
+ for name in self._settings.name_claims:
231
+ value = claims.get(name)
232
+ if isinstance(value, str) and value.strip():
233
+ return value.strip()
234
+ return subject
235
+
236
+
237
+ def _groups(value: object) -> tuple[str, ...]:
238
+ if isinstance(value, str):
239
+ return (value,)
240
+ if isinstance(value, list):
241
+ return tuple(str(item) for item in value if isinstance(item, str))
242
+ return ()
@@ -0,0 +1,142 @@
1
+ """Permissions and the built-in Domain roles (design 10.2).
2
+
3
+ A role is a named set of permissions. Roles are granted per Domain (see `rbac`); installations
4
+ may add roles built from these permissions in `keeper.auth.authz.custom_roles`. The built-in
5
+ roles cannot be redefined.
6
+
7
+ Role semantics where the design table leaves room:
8
+
9
+ - every role except `admin` is additive on top of read access, so a principal holding only
10
+ `editor` can still read what it edits;
11
+ - `maintainer` includes `editor` and `reviewer`, not `modeler`;
12
+ - `editor` and `modeler` open Proposals (`proposal.create`) from the branches they change;
13
+ `editor` (and so `maintainer`) also triggers Source synchronizations (`source.sync`), whose
14
+ outcome goes through the Source's merge policy;
15
+ `editor` uploads documents for extraction (`document.write`) and reads them
16
+ (`document.read`); `reviewer` reads documents too, since extraction items quote them;
17
+ documents are raw, unmasked input, so `viewer`, `explorer` and `agent-reader` do not read
18
+ them;
19
+ `reviewer` reviews them and resolves Conflicts; merging needs `proposal.merge`
20
+ (`maintainer`), into protected and unprotected branches alike;
21
+ - `admin` holds every permission of the Domain (it is the Domain owner role); only it may
22
+ delete the Domain (`domain.delete`), read values of sensitive Attributes (`pii.read`,
23
+ `confidential.read`, `restricted.read`; other callers see them masked), erase vault
24
+ values (`pii.erase`) and feed values of vault Attributes through the built-in imports and
25
+ Source synchronizations (`pii.import`, see `services.pii_import`); custom roles grant the
26
+ clearances to others. `admin` also reads the agent traces of other principals in its Domain
27
+ (`trace.read`) and the query plans of traced requests (`trace.explain`); every principal
28
+ reads its own traces. `domain.create` is installation-level: installation administrators
29
+ hold it, and so do admins of any Domain;
30
+ - `vault.manage` (vault key rotation and its status) is held by installation administrators
31
+ only: no role, built in or custom, contains it.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections.abc import Iterable, Mapping
37
+ from enum import StrEnum
38
+ from types import MappingProxyType
39
+
40
+
41
+ class Permission(StrEnum):
42
+ """What a route may require. Names are stable: they appear in 403 responses."""
43
+
44
+ SCHEMA_READ = "schema.read"
45
+ DATA_READ = "data.read"
46
+ VIEWS_WRITE = "views.write"
47
+ BRANCH_CREATE = "branch.create"
48
+ OPERATION_EXECUTE = "operation.execute"
49
+ OPERATION_EXECUTE_CUSTOM = "operation.execute_custom"
50
+ SCHEMA_WRITE = "schema.write"
51
+ SOURCE_MAP = "source.map"
52
+ PROPOSAL_CREATE = "proposal.create"
53
+ PROPOSAL_REVIEW = "proposal.review"
54
+ CONFLICT_RESOLVE = "conflict.resolve"
55
+ PROPOSAL_MERGE = "proposal.merge"
56
+ SOURCE_MANAGE = "source.manage"
57
+ SOURCE_SYNC = "source.sync"
58
+ DOCUMENT_READ = "document.read"
59
+ DOCUMENT_WRITE = "document.write"
60
+ JOB_MANAGE = "job.manage"
61
+ GRANT_MANAGE = "grant.manage"
62
+ APIKEY_MANAGE = "apikey.manage"
63
+ DOMAIN_SETTINGS = "domain.settings"
64
+ DOMAIN_DELETE = "domain.delete"
65
+ DOMAIN_CREATE = "domain.create"
66
+ AUDIT_READ = "audit.read"
67
+ PRINCIPAL_READ = "principal.read"
68
+ PII_READ = "pii.read"
69
+ CONFIDENTIAL_READ = "confidential.read"
70
+ RESTRICTED_READ = "restricted.read"
71
+ PII_ERASE = "pii.erase"
72
+ PII_IMPORT = "pii.import"
73
+ TRACE_READ = "trace.read"
74
+ TRACE_EXPLAIN = "trace.explain"
75
+ VAULT_MANAGE = "vault.manage"
76
+
77
+
78
+ INSTALLATION_ADMIN_PERMISSIONS = frozenset({Permission.VAULT_MANAGE})
79
+ """Installation-level permissions only installation administrators hold: no role contains
80
+ them."""
81
+
82
+ INSTALLATION_PERMISSIONS = (
83
+ frozenset({Permission.PRINCIPAL_READ, Permission.DOMAIN_CREATE})
84
+ | INSTALLATION_ADMIN_PERMISSIONS
85
+ )
86
+ """Permissions that are not about one Domain: installation administrators hold them, and so do
87
+ principals who hold them through a role in at least one Domain (except
88
+ `INSTALLATION_ADMIN_PERMISSIONS`)."""
89
+
90
+ _VIEWER = frozenset({Permission.SCHEMA_READ, Permission.DATA_READ})
91
+ _EDITOR = _VIEWER | {
92
+ Permission.BRANCH_CREATE,
93
+ Permission.OPERATION_EXECUTE,
94
+ Permission.PROPOSAL_CREATE,
95
+ Permission.SOURCE_SYNC,
96
+ Permission.DOCUMENT_READ,
97
+ Permission.DOCUMENT_WRITE,
98
+ }
99
+ _REVIEWER = _VIEWER | {
100
+ Permission.PROPOSAL_REVIEW,
101
+ Permission.CONFLICT_RESOLVE,
102
+ Permission.DOCUMENT_READ,
103
+ }
104
+
105
+ BUILTIN_ROLES: Mapping[str, frozenset[Permission]] = MappingProxyType(
106
+ {
107
+ "viewer": _VIEWER,
108
+ "explorer": _VIEWER | {Permission.VIEWS_WRITE},
109
+ "editor": _EDITOR,
110
+ "operator": _VIEWER | {Permission.OPERATION_EXECUTE_CUSTOM},
111
+ "modeler": _VIEWER
112
+ | {Permission.SCHEMA_WRITE, Permission.SOURCE_MAP, Permission.PROPOSAL_CREATE},
113
+ "reviewer": _REVIEWER,
114
+ "maintainer": _EDITOR
115
+ | _REVIEWER
116
+ | {Permission.PROPOSAL_MERGE, Permission.SOURCE_MANAGE, Permission.JOB_MANAGE},
117
+ "admin": frozenset(Permission) - INSTALLATION_ADMIN_PERMISSIONS,
118
+ "agent-reader": _VIEWER,
119
+ }
120
+ )
121
+
122
+
123
+ def permission_names() -> frozenset[str]:
124
+ """Every permission name."""
125
+ return frozenset(p.value for p in Permission)
126
+
127
+
128
+ def role_catalog(
129
+ custom: Mapping[str, Iterable[str]] | None = None,
130
+ ) -> dict[str, frozenset[Permission]]:
131
+ """Built-in roles plus `custom` ones. Unknown permissions raise `ValueError`."""
132
+ catalog = dict(BUILTIN_ROLES)
133
+ for name, permissions in (custom or {}).items():
134
+ if name in BUILTIN_ROLES:
135
+ raise ValueError(f"role {name!r} is built in and cannot be redefined")
136
+ granted = frozenset(Permission(p) for p in permissions)
137
+ reserved = granted & INSTALLATION_ADMIN_PERMISSIONS
138
+ if reserved:
139
+ names = ", ".join(sorted(p.value for p in reserved))
140
+ raise ValueError(f"role {name!r}: {names} is held by installation administrators only")
141
+ catalog[name] = granted
142
+ return catalog