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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- 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
|