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,250 @@
1
+ """The OpenID Connect provider as seen by Keeper's sign-in flow (design 13.2).
2
+
3
+ Provider metadata (discovery), the authorization request with PKCE (RFC 7636, S256 only), the
4
+ token endpoint for the code exchange and refreshes (RFC 6749), and the RP-initiated logout URL
5
+ (OpenID Connect RP-Initiated Logout 1.0). Plain HTTP with httpx, as for the signing keys in
6
+ `oidc.py`: no provider SDK and no new dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import logging
14
+ import secrets
15
+ import time
16
+ from collections.abc import Callable, Mapping
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+ from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
20
+
21
+ import httpx
22
+
23
+ from lorebase_keeper.auth.config import OidcSettings, SessionSettings, insecure_url_problem
24
+ from lorebase_keeper.auth.oidc import DISCOVERY_PATH, ProviderUnavailableError
25
+
26
+ logger = logging.getLogger("lorebase.keeper.auth.provider")
27
+
28
+ PKCE_METHOD = "S256"
29
+ _VERIFIER_BYTES = 48 # 64 characters of base64url, within RFC 7636's 43 to 128
30
+
31
+
32
+ class TokenRefusedError(Exception):
33
+ """The token endpoint refused the grant (expired or revoked code or refresh token, or a
34
+ client misconfiguration). The message is for logs only."""
35
+
36
+
37
+ def random_token(nbytes: int = 32) -> str:
38
+ """A URL-safe random string of `nbytes` bytes of entropy."""
39
+ return secrets.token_urlsafe(nbytes)
40
+
41
+
42
+ def pkce_pair() -> tuple[str, str]:
43
+ """A PKCE code verifier and its S256 challenge."""
44
+ verifier = secrets.token_urlsafe(_VERIFIER_BYTES)
45
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
46
+ return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ProviderMetadata:
51
+ """The endpoints the sign-in flow uses."""
52
+
53
+ authorization_endpoint: str
54
+ token_endpoint: str
55
+ end_session_endpoint: str | None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class TokenResponse:
60
+ """What the token endpoint returned that Keeper keeps or checks."""
61
+
62
+ id_token: str | None
63
+ refresh_token: str | None
64
+ expires_in: int | None
65
+
66
+
67
+ class ProviderClient:
68
+ """Metadata, authorization URL, token requests and logout URL of the configured provider."""
69
+
70
+ def __init__(
71
+ self,
72
+ settings: SessionSettings,
73
+ oidc: OidcSettings,
74
+ http: httpx.AsyncClient,
75
+ monotonic: Callable[[], float] = time.monotonic,
76
+ ) -> None:
77
+ self._settings = settings
78
+ self._oidc = oidc
79
+ self._http = http
80
+ self._monotonic = monotonic
81
+ self._metadata: ProviderMetadata | None = None
82
+ self._fetched_at = 0.0
83
+
84
+ @property
85
+ def client_id(self) -> str:
86
+ """The client id registered at the provider."""
87
+ return self._settings.client_id or ""
88
+
89
+ async def metadata(self) -> ProviderMetadata:
90
+ """The provider's endpoints, cached for `metadata_cache_ttl_s`.
91
+
92
+ `ProviderUnavailableError` when the document cannot be fetched or is not acceptable:
93
+ another issuer, endpoints over plain http off loopback, or no S256 PKCE support.
94
+ """
95
+ now = self._monotonic()
96
+ if self._metadata and now - self._fetched_at < self._settings.metadata_cache_ttl_s:
97
+ return self._metadata
98
+ url = self._settings.discovery_url or (self._oidc.issuer or "").rstrip("/") + DISCOVERY_PATH
99
+ try:
100
+ response = await self._http.get(url, headers={"Accept": "application/json"})
101
+ response.raise_for_status()
102
+ document = response.json()
103
+ except (httpx.HTTPError, ValueError) as exc:
104
+ logger.warning("cannot fetch provider metadata: %s", type(exc).__name__)
105
+ raise ProviderUnavailableError("provider metadata unavailable") from None
106
+ self._metadata = self._parse(document)
107
+ self._fetched_at = now
108
+ return self._metadata
109
+
110
+ def _parse(self, document: object) -> ProviderMetadata:
111
+ if not isinstance(document, dict) or document.get("issuer") != self._oidc.issuer:
112
+ logger.warning("provider metadata refused: issuer differs from oidc.issuer")
113
+ raise ProviderUnavailableError("provider metadata does not match the issuer")
114
+ methods = document.get("code_challenge_methods_supported")
115
+ if isinstance(methods, list) and PKCE_METHOD not in methods:
116
+ logger.warning("provider metadata refused: no S256 PKCE support")
117
+ raise ProviderUnavailableError("provider does not support S256 PKCE")
118
+ endpoints: dict[str, str | None] = {}
119
+ for name in ("authorization_endpoint", "token_endpoint", "end_session_endpoint"):
120
+ value = document.get(name)
121
+ if value is None and name == "end_session_endpoint":
122
+ endpoints[name] = None
123
+ continue
124
+ problem = (
125
+ insecure_url_problem(value, self._oidc.allow_insecure)
126
+ if isinstance(value, str)
127
+ else "is missing"
128
+ )
129
+ if problem:
130
+ logger.warning("provider metadata refused: %s %s", name, problem)
131
+ raise ProviderUnavailableError(f"provider {name} is not acceptable")
132
+ endpoints[name] = value
133
+ return ProviderMetadata(
134
+ authorization_endpoint=endpoints["authorization_endpoint"] or "",
135
+ token_endpoint=endpoints["token_endpoint"] or "",
136
+ end_session_endpoint=endpoints["end_session_endpoint"],
137
+ )
138
+
139
+ def authorization_url(
140
+ self, metadata: ProviderMetadata, *, state: str, nonce: str, challenge: str
141
+ ) -> str:
142
+ """The authorization request: code flow, PKCE S256, the configured redirect URI."""
143
+ settings = self._settings
144
+ return _with_query(
145
+ metadata.authorization_endpoint,
146
+ {
147
+ "response_type": "code",
148
+ "client_id": self.client_id,
149
+ "redirect_uri": settings.redirect_uri or "",
150
+ "scope": " ".join(settings.scopes),
151
+ "state": state,
152
+ "nonce": nonce,
153
+ "code_challenge": challenge,
154
+ "code_challenge_method": PKCE_METHOD,
155
+ },
156
+ )
157
+
158
+ async def exchange_code(self, code: str, verifier: str) -> TokenResponse:
159
+ """Redeem an authorization code with its PKCE verifier."""
160
+ return await self._token_request(
161
+ {
162
+ "grant_type": "authorization_code",
163
+ "code": code,
164
+ "redirect_uri": self._settings.redirect_uri or "",
165
+ "code_verifier": verifier,
166
+ }
167
+ )
168
+
169
+ async def refresh(self, refresh_token: str) -> TokenResponse:
170
+ """Use a refresh token; `TokenRefusedError` when the provider refuses it."""
171
+ return await self._token_request(
172
+ {"grant_type": "refresh_token", "refresh_token": refresh_token}
173
+ )
174
+
175
+ def end_session_url(self, metadata: ProviderMetadata, id_token: str | None) -> str | None:
176
+ """Where to send the browser to sign out at the provider; None without an endpoint."""
177
+ if metadata.end_session_endpoint is None:
178
+ return None
179
+ settings = self._settings
180
+ params = {
181
+ "client_id": self.client_id,
182
+ "post_logout_redirect_uri": settings.post_logout_redirect_uri or settings.app_url or "",
183
+ }
184
+ if id_token:
185
+ params["id_token_hint"] = id_token
186
+ return _with_query(metadata.end_session_endpoint, params)
187
+
188
+ async def _token_request(self, form: dict[str, str]) -> TokenResponse:
189
+ metadata = await self.metadata()
190
+ settings = self._settings
191
+ headers = {"Accept": "application/json"}
192
+ secret = settings.client_secret.get_secret_value() if settings.client_secret else ""
193
+ if settings.token_endpoint_auth_method == "client_secret_basic":
194
+ # RFC 6749 2.3.1: both parts form-encoded before base64.
195
+ pair = f"{quote(self.client_id, safe='')}:{quote(secret, safe='')}"
196
+ headers["Authorization"] = "Basic " + base64.b64encode(pair.encode()).decode()
197
+ else:
198
+ form["client_id"] = self.client_id
199
+ if settings.token_endpoint_auth_method == "client_secret_post":
200
+ form["client_secret"] = secret
201
+ try:
202
+ response = await self._http.post(metadata.token_endpoint, data=form, headers=headers)
203
+ except httpx.HTTPError as exc:
204
+ logger.warning("token endpoint unreachable: %s", type(exc).__name__)
205
+ raise ProviderUnavailableError("token endpoint unreachable") from None
206
+ if 400 <= response.status_code < 500:
207
+ raise TokenRefusedError(
208
+ f"{form['grant_type']} refused with {response.status_code} "
209
+ f"({_error_code(response)})"
210
+ )
211
+ if response.status_code != 200:
212
+ logger.warning("token endpoint answered %s", response.status_code)
213
+ raise ProviderUnavailableError("token endpoint failed")
214
+ try:
215
+ body = response.json()
216
+ except ValueError:
217
+ body = None
218
+ if not isinstance(body, dict):
219
+ raise ProviderUnavailableError("token endpoint answered no JSON object")
220
+ return _token_response(body)
221
+
222
+
223
+ def _token_response(body: Mapping[str, Any]) -> TokenResponse:
224
+ id_token = body.get("id_token")
225
+ refresh_token = body.get("refresh_token")
226
+ expires_in = body.get("expires_in")
227
+ if isinstance(expires_in, str) and expires_in.isdecimal():
228
+ expires_in = int(expires_in)
229
+ return TokenResponse(
230
+ id_token=id_token if isinstance(id_token, str) and id_token else None,
231
+ refresh_token=refresh_token if isinstance(refresh_token, str) and refresh_token else None,
232
+ expires_in=expires_in if isinstance(expires_in, int) and expires_in > 0 else None,
233
+ )
234
+
235
+
236
+ def _error_code(response: httpx.Response) -> str:
237
+ """The OAuth error code of a refusal, for logs; never the description."""
238
+ try:
239
+ body = response.json()
240
+ except ValueError:
241
+ return "no JSON body"
242
+ code = body.get("error") if isinstance(body, dict) else None
243
+ return code if isinstance(code, str) and code.isascii() and len(code) <= 64 else "unknown"
244
+
245
+
246
+ def _with_query(url: str, params: Mapping[str, str]) -> str:
247
+ """`url` with `params` added to the query it already has."""
248
+ parts = urlsplit(url)
249
+ query = [*parse_qsl(parts.query, keep_blank_values=True), *params.items()]
250
+ return urlunsplit(parts._replace(query=urlencode(query)))
@@ -0,0 +1,247 @@
1
+ """Authorization: Domain roles, scoped grants and the permission check (design 10.2).
2
+
3
+ A grant gives a subject (a principal or a group) a role in a Domain. By default it applies to
4
+ the whole Domain; a grant may be narrowed to a Kind, an Operation or a branch name pattern
5
+ (`agent/*`), and then counts only for requests that name that Kind, Operation or branch.
6
+ Deny by default: a permission is allowed when some grant of the caller's subjects has a role
7
+ containing it and its scope covers the request.
8
+
9
+ Grants are cached per (Domain, subjects) for `authz.grants_cache_ttl_s`; changes made through
10
+ this process call `invalidate`, other processes converge within the time to live.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import time
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+
20
+ from lorebase_keeper.auth.cache import TtlCache
21
+ from lorebase_keeper.auth.config import AuthzSettings
22
+ from lorebase_keeper.auth.permissions import (
23
+ INSTALLATION_ADMIN_PERMISSIONS,
24
+ INSTALLATION_PERMISSIONS,
25
+ Permission,
26
+ role_catalog,
27
+ )
28
+ from lorebase_keeper.auth.ports import AuthStore
29
+ from lorebase_keeper.auth.principals import Principal
30
+ from lorebase_store.auth import GrantRecord
31
+ from lorebase_store.models.enums import GrantScope
32
+
33
+ DOMAIN_SCOPE_PREFIX = "domain:"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AccessContext:
38
+ """What a request is about, for grants narrowed to a Kind, Operation or branch."""
39
+
40
+ kind: str | None = None
41
+ operation: str | None = None
42
+ branch: str | None = None
43
+
44
+
45
+ NO_CONTEXT = AccessContext()
46
+
47
+
48
+ def domain_scope(domain_id: str) -> str:
49
+ """The API key scope naming a Domain."""
50
+ return f"{DOMAIN_SCOPE_PREFIX}{domain_id}"
51
+
52
+
53
+ class Authorizer:
54
+ """Decides whether a principal holds a permission."""
55
+
56
+ def __init__(
57
+ self,
58
+ settings: AuthzSettings,
59
+ *,
60
+ store: AuthStore | None,
61
+ monotonic: Callable[[], float] = time.monotonic,
62
+ ) -> None:
63
+ self._roles = role_catalog(settings.custom_roles)
64
+ self.invisible_kinds = settings.invisible_kinds
65
+ """Whether Kinds left out by Kind-narrowed grants are invisible (design 13.3)."""
66
+ self._store = store
67
+ self._cache: TtlCache[tuple[str | None, tuple[str, ...]], tuple[GrantRecord, ...]] = (
68
+ TtlCache(settings.grants_cache_ttl_s, settings.grants_cache_max_entries, monotonic)
69
+ )
70
+
71
+ @property
72
+ def role_names(self) -> frozenset[str]:
73
+ """Roles that can be granted: built in and custom."""
74
+ return frozenset(self._roles)
75
+
76
+ def invalidate(self) -> None:
77
+ """Drop cached grants after a grant changed through this process."""
78
+ self._cache.clear()
79
+
80
+ async def allows(
81
+ self,
82
+ principal: Principal,
83
+ permission: Permission,
84
+ domain_id: str,
85
+ context: AccessContext = NO_CONTEXT,
86
+ ) -> bool:
87
+ """Whether `principal` holds `permission` in the Domain for this request context."""
88
+ if principal.method == "none":
89
+ return True
90
+ if principal.key_scopes and domain_scope(domain_id) not in principal.key_scopes:
91
+ return False
92
+ grants = await self._grants(principal, domain_id)
93
+ return any(self._grant_allows(grant, permission, context) for grant in grants)
94
+
95
+ async def allows_anywhere(
96
+ self, principal: Principal, permission: Permission, domain_id: str
97
+ ) -> bool:
98
+ """Whether some grant of the principal in the Domain has a role containing
99
+ `permission`, whatever its scope. Lets a route refuse callers without any such grant
100
+ before it looks anything up."""
101
+ if principal.method == "none":
102
+ return True
103
+ if principal.key_scopes and domain_scope(domain_id) not in principal.key_scopes:
104
+ return False
105
+ grants = await self._grants(principal, domain_id)
106
+ return any(permission in self._roles.get(grant.role, frozenset()) for grant in grants)
107
+
108
+ async def roles_of(
109
+ self, principal: Principal, domain_id: str, context: AccessContext = NO_CONTEXT
110
+ ) -> frozenset[str]:
111
+ """Roles the principal holds in the Domain through grants covering `context`. With
112
+ authentication off the caller holds every role."""
113
+ if principal.method == "none":
114
+ return self.role_names
115
+ if principal.key_scopes and domain_scope(domain_id) not in principal.key_scopes:
116
+ return frozenset()
117
+ grants = await self._grants(principal, domain_id)
118
+ return frozenset(g.role for g in grants if _covers(g, context))
119
+
120
+ async def reveals_missing_domain(self, principal: Principal, domain_id: str) -> bool:
121
+ """Whether `domain_id` names no Domain and `principal` may be told so (404, not 403).
122
+
123
+ Installation administrators and holders of a Domain-wide grant on that id may. Callers
124
+ without grants there, or with grants narrowed to a Kind, Operation or branch, may not:
125
+ for them a missing Domain and one they cannot read answer alike, so the answer does not
126
+ tell whether the Domain exists. A key limited to other Domains never may.
127
+ """
128
+ if principal.key_scopes and domain_scope(domain_id) not in principal.key_scopes:
129
+ return False
130
+ if not principal.installation_admin:
131
+ grants = await self._grants(principal, domain_id)
132
+ if not any(grant.scope_type is GrantScope.DOMAIN for grant in grants):
133
+ return False
134
+ return not await self._domain_exists(domain_id)
135
+
136
+ async def _domain_exists(self, domain_id: str) -> bool:
137
+ assert self._store is not None, "authorization needs a store"
138
+ async with self._store.unit() as unit:
139
+ return await unit.grants.domain_exists(domain_id)
140
+
141
+ async def granted_kinds(
142
+ self, principal: Principal, permission: Permission, domain_id: str
143
+ ) -> frozenset[str]:
144
+ """Kinds that grants narrowed to a Kind give `permission` on, on every branch and
145
+ Revision of the Domain. Grants not narrowed to a Kind are not listed."""
146
+ if principal.method == "none":
147
+ return frozenset()
148
+ if principal.key_scopes and domain_scope(domain_id) not in principal.key_scopes:
149
+ return frozenset()
150
+ grants = await self._grants(principal, domain_id)
151
+ return frozenset(
152
+ grant.scope_ref
153
+ for grant in grants
154
+ if grant.scope_type is GrantScope.KIND
155
+ and grant.scope_ref is not None
156
+ and permission in self._roles.get(grant.role, frozenset())
157
+ )
158
+
159
+ async def domains_granting(
160
+ self, principal: Principal, permission: Permission
161
+ ) -> frozenset[str] | None:
162
+ """Domains where a Domain-wide grant of the principal has a role containing
163
+ `permission`; None means every Domain (authentication off, or an installation
164
+ administrator whose key is not limited to Domains)."""
165
+ if principal.method == "none":
166
+ return None
167
+ if principal.installation_admin and not principal.key_scopes:
168
+ return None
169
+ grants = await self._grants(principal, None)
170
+ found = {
171
+ grant.domain_id
172
+ for grant in grants
173
+ if grant.scope_type is GrantScope.DOMAIN
174
+ and permission in self._roles.get(grant.role, frozenset())
175
+ }
176
+ if principal.key_scopes:
177
+ found = {d for d in found if domain_scope(d) in principal.key_scopes}
178
+ return frozenset(found)
179
+
180
+ async def allows_installation(self, principal: Principal, permission: Permission) -> bool:
181
+ """Whether `principal` holds an installation-level permission.
182
+
183
+ API keys limited to Domains never do, whoever holds them: no Domain scope covers the
184
+ installation, and the scope check comes before the administrator one. Otherwise
185
+ installation administrators do, and others do when a Domain-wide grant of theirs has a
186
+ role containing it.
187
+ """
188
+ if permission not in INSTALLATION_PERMISSIONS:
189
+ raise ValueError(f"{permission.value} is not an installation-level permission")
190
+ if principal.method == "none":
191
+ return True
192
+ if principal.key_scopes:
193
+ return False
194
+ if principal.installation_admin:
195
+ return True
196
+ if permission in INSTALLATION_ADMIN_PERMISSIONS:
197
+ return False
198
+ grants = await self._grants(principal, None)
199
+ return any(
200
+ grant.scope_type is GrantScope.DOMAIN
201
+ and self._grant_allows(grant, permission, AccessContext())
202
+ for grant in grants
203
+ )
204
+
205
+ async def _grants(self, principal: Principal, domain_id: str | None) -> tuple[GrantRecord, ...]:
206
+ """Grants of the principal in a Domain, or in every Domain when `domain_id` is None.
207
+
208
+ Only None selects every Domain: a Domain id comes from the request and is compared as
209
+ it is, so no value of it (such as "*") can widen the lookup.
210
+ """
211
+ key = (domain_id, principal.subjects)
212
+ cached = self._cache.get(key)
213
+ if cached is not None:
214
+ return cached
215
+ assert self._store is not None, "authorization needs a store"
216
+ async with self._store.unit() as unit:
217
+ found = (
218
+ await unit.grants.for_subjects_anywhere(principal.subjects)
219
+ if domain_id is None
220
+ else await unit.grants.for_subjects(domain_id, principal.subjects)
221
+ )
222
+ grants = tuple(found)
223
+ self._cache.set(key, grants)
224
+ return grants
225
+
226
+ def _grant_allows(
227
+ self, grant: GrantRecord, permission: Permission, context: AccessContext
228
+ ) -> bool:
229
+ return permission in self._roles.get(grant.role, frozenset()) and _covers(grant, context)
230
+
231
+
232
+ def _covers(grant: GrantRecord, context: AccessContext) -> bool:
233
+ ref = grant.scope_ref
234
+ match grant.scope_type:
235
+ case GrantScope.DOMAIN:
236
+ return True
237
+ case GrantScope.KIND:
238
+ return ref is not None and context.kind == ref
239
+ case GrantScope.OPERATION:
240
+ return ref is not None and context.operation == ref
241
+ case GrantScope.BRANCH:
242
+ return (
243
+ ref is not None
244
+ and context.branch is not None
245
+ and fnmatch.fnmatchcase(context.branch, ref)
246
+ )
247
+ return False # pragma: no cover - exhaustive over GrantScope