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,212 @@
1
+ """What Keeper's auth code needs from persistence.
2
+
3
+ `lorebase_store.auth.SqlAuthStore` implements these protocols; tests substitute an in-memory
4
+ store. Only the methods Keeper calls are listed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Collection, Sequence
10
+ from contextlib import AbstractAsyncContextManager
11
+ from datetime import date, datetime, timedelta
12
+ from typing import Protocol
13
+
14
+ from lorebase_store.auth import (
15
+ ApiKeyRecord,
16
+ AuditRecord,
17
+ ChainVerification,
18
+ GrantRecord,
19
+ NewAuditEntry,
20
+ PrincipalRecord,
21
+ SessionRecord,
22
+ UsageRecord,
23
+ )
24
+ from lorebase_store.models.enums import GrantScope, PrincipalType
25
+
26
+
27
+ class PrincipalsPort(Protocol):
28
+ """Principals."""
29
+
30
+ async def get(self, principal_id: str) -> PrincipalRecord | None: ...
31
+
32
+ async def get_by_subject(self, external_subject: str) -> PrincipalRecord | None: ...
33
+
34
+ async def add(
35
+ self,
36
+ type_: PrincipalType,
37
+ display_name: str,
38
+ *,
39
+ external_subject: str | None = None,
40
+ principal_id: str | None = None,
41
+ ) -> PrincipalRecord: ...
42
+
43
+ async def get_or_create_user(
44
+ self, external_subject: str, display_name: str
45
+ ) -> tuple[PrincipalRecord, bool]: ...
46
+
47
+ async def list(
48
+ self,
49
+ *,
50
+ type_: PrincipalType | None = None,
51
+ limit: int,
52
+ after: tuple[datetime, str] | None = None,
53
+ ) -> list[PrincipalRecord]: ...
54
+
55
+
56
+ class GroupsPort(Protocol):
57
+ """Groups."""
58
+
59
+ async def group_ids_for(
60
+ self, principal_id: str, external_groups: Collection[str] = ()
61
+ ) -> list[str]: ...
62
+
63
+ async def subject_exists(self, subject_id: str) -> bool: ...
64
+
65
+
66
+ class ApiKeysPort(Protocol):
67
+ """API keys."""
68
+
69
+ async def add(
70
+ self,
71
+ *,
72
+ principal_id: str,
73
+ prefix: str,
74
+ key_hash: bytes,
75
+ scopes: Sequence[str] = (),
76
+ expires_at: datetime | None = None,
77
+ allowed_networks: Sequence[str] = (),
78
+ ) -> ApiKeyRecord: ...
79
+
80
+ async def get(self, key_id: str) -> ApiKeyRecord | None: ...
81
+
82
+ async def get_by_prefix(self, prefix: str) -> ApiKeyRecord | None: ...
83
+
84
+ async def touch(self, key_id: str, at: datetime, *, min_interval: timedelta) -> bool: ...
85
+
86
+ async def revoke(self, key_id: str, at: datetime) -> bool: ...
87
+
88
+ async def list_for_scope(
89
+ self, scope: str, *, limit: int, after: tuple[datetime, str] | None = None
90
+ ) -> list[ApiKeyRecord]: ...
91
+
92
+ async def list_for_principals(self, principal_ids: Collection[str]) -> list[ApiKeyRecord]: ...
93
+
94
+
95
+ class GrantsPort(Protocol):
96
+ """Grants."""
97
+
98
+ async def add(
99
+ self,
100
+ *,
101
+ domain_id: str,
102
+ subject: str,
103
+ role: str,
104
+ created_by: str,
105
+ scope_type: GrantScope = GrantScope.DOMAIN,
106
+ scope_ref: str | None = None,
107
+ ) -> GrantRecord: ...
108
+
109
+ async def get(self, domain_id: str, grant_id: str) -> GrantRecord | None: ...
110
+
111
+ async def delete(self, domain_id: str, grant_id: str) -> bool: ...
112
+
113
+ async def for_subjects(
114
+ self, domain_id: str, subjects: Collection[str]
115
+ ) -> list[GrantRecord]: ...
116
+
117
+ async def for_subjects_anywhere(self, subjects: Collection[str]) -> list[GrantRecord]: ...
118
+
119
+ async def domain_exists(self, domain_id: str) -> bool: ...
120
+
121
+ async def list_domain(
122
+ self, domain_id: str, *, limit: int, after: tuple[datetime, str] | None = None
123
+ ) -> list[GrantRecord]: ...
124
+
125
+
126
+ class AuditPort(Protocol):
127
+ """Audit log."""
128
+
129
+ async def append(self, entry: NewAuditEntry) -> AuditRecord: ...
130
+
131
+ async def list(
132
+ self, *, domain_id: str | None, limit: int, before_seq: int | None = None
133
+ ) -> list[AuditRecord]: ...
134
+
135
+ async def verify(self, *, after_seq: int = 0, domain_id: str | None) -> ChainVerification: ...
136
+
137
+
138
+ class UsagePort(Protocol):
139
+ """Daily usage counters of API keys."""
140
+
141
+ async def get(self, api_key_id: str, day: date) -> UsageRecord: ...
142
+
143
+ async def add(
144
+ self, api_key_id: str, day: date, *, requests: int, context_tokens: int, at: datetime
145
+ ) -> UsageRecord: ...
146
+
147
+
148
+ class SessionsPort(Protocol):
149
+ """Browser sessions of the web app."""
150
+
151
+ async def add(
152
+ self,
153
+ *,
154
+ id_hash: bytes,
155
+ principal_id: str,
156
+ subject: str,
157
+ external_groups: Sequence[str],
158
+ tokens: bytes | None,
159
+ created_at: datetime,
160
+ expires_at: datetime,
161
+ refresh_at: datetime | None,
162
+ ) -> SessionRecord: ...
163
+
164
+ async def get(self, id_hash: bytes) -> SessionRecord | None: ...
165
+
166
+ async def touch(self, id_hash: bytes, at: datetime, *, min_interval: timedelta) -> bool: ...
167
+
168
+ async def claim_refresh(self, id_hash: bytes, now: datetime, until: datetime) -> bool: ...
169
+
170
+ async def update_tokens(
171
+ self,
172
+ id_hash: bytes,
173
+ *,
174
+ tokens: bytes | None,
175
+ external_groups: Sequence[str],
176
+ refresh_at: datetime | None,
177
+ ) -> bool: ...
178
+
179
+ async def delete(self, id_hash: bytes) -> bool: ...
180
+
181
+ async def purge_expired(self, now: datetime, idle_before: datetime, *, limit: int) -> int: ...
182
+
183
+
184
+ class AuthUnitPort(Protocol):
185
+ """Repositories sharing one transaction."""
186
+
187
+ @property
188
+ def principals(self) -> PrincipalsPort: ...
189
+
190
+ @property
191
+ def groups(self) -> GroupsPort: ...
192
+
193
+ @property
194
+ def api_keys(self) -> ApiKeysPort: ...
195
+
196
+ @property
197
+ def grants(self) -> GrantsPort: ...
198
+
199
+ @property
200
+ def audit(self) -> AuditPort: ...
201
+
202
+ @property
203
+ def usage(self) -> UsagePort: ...
204
+
205
+ @property
206
+ def sessions(self) -> SessionsPort: ...
207
+
208
+
209
+ class AuthStore(Protocol):
210
+ """Opens transactions; leaving the block normally commits, an exception rolls back."""
211
+
212
+ def unit(self) -> AbstractAsyncContextManager[AuthUnitPort]: ...
@@ -0,0 +1,377 @@
1
+ """Who is calling: resolving a request's credentials to a principal (design 13.2).
2
+
3
+ A principal is a user (an OIDC subject, provisioned on first sign-in when settings allow) or a
4
+ service principal (owner of API keys, created by an administrator). Every request carries its
5
+ credential: an API key, a bearer token or, from the web app, a session cookie
6
+ (`auth/sessions.py`). Headers win over the cookie, so API key and bearer requests behave the
7
+ same whether sessions are enabled or not.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import dataclass, replace
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Literal
18
+
19
+ from opentelemetry import metrics
20
+ from opentelemetry.metrics import MeterProvider
21
+
22
+ from lorebase_keeper.auth.api_keys import ApiKeyCodec
23
+ from lorebase_keeper.auth.cache import TtlCache
24
+ from lorebase_keeper.auth.config import AuthSettings
25
+ from lorebase_keeper.auth.oidc import (
26
+ InvalidTokenError,
27
+ OidcClaims,
28
+ OidcVerifier,
29
+ ProviderUnavailableError,
30
+ )
31
+ from lorebase_keeper.auth.ports import AuthStore
32
+ from lorebase_keeper.auth.sessions import SessionManager, cookie_value
33
+ from lorebase_keeper.client_address import IPAddress, in_networks
34
+ from lorebase_keeper.errors import FORBIDDEN, UNAUTHORIZED, UNAVAILABLE, ProblemError
35
+ from lorebase_store.auth import ApiKeyRecord
36
+ from lorebase_store.models.enums import PrincipalType
37
+
38
+ logger = logging.getLogger("lorebase.keeper.auth")
39
+
40
+ AuthMethod = Literal["none", "api_key", "oidc", "session"]
41
+
42
+ ANONYMOUS_ID = "anonymous"
43
+ _MISSING_HASH = bytes(32)
44
+ _CHALLENGE = "Bearer"
45
+ _INVALID_CHALLENGE = 'Bearer error="invalid_token"'
46
+ _REJECTED_KEY_REASONS = {
47
+ "unknown prefix": "unknown_key",
48
+ "wrong secret": "wrong_secret",
49
+ "revoked": "revoked",
50
+ "expired": "expired",
51
+ }
52
+ """Metric reason of each API key rejection logged by `_reject_reason`."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Principal:
57
+ """The authenticated caller of a request.
58
+
59
+ `subjects` are the ids grants may name: the principal's own id and its groups.
60
+ `key_scopes` limit an API key to some Domains (`domain:<id>`); empty means no extra limit.
61
+ """
62
+
63
+ id: str
64
+ type: PrincipalType
65
+ display_name: str
66
+ method: AuthMethod
67
+ subjects: tuple[str, ...]
68
+ key_scopes: tuple[str, ...] = ()
69
+ key_id: str | None = None
70
+ installation_admin: bool = False
71
+ session_expires_at: datetime | None = None
72
+ """End of the absolute lifetime of the browser session (method `session`)."""
73
+
74
+
75
+ def anonymous_principal() -> Principal:
76
+ """The caller when authentication is disabled: an administrator of everything."""
77
+ return Principal(
78
+ id=ANONYMOUS_ID,
79
+ type=PrincipalType.USER,
80
+ display_name="Anonymous (authentication disabled)",
81
+ method="none",
82
+ subjects=(ANONYMOUS_ID,),
83
+ installation_admin=True,
84
+ )
85
+
86
+
87
+ def _unauthorized(detail: str, *, presented: bool) -> ProblemError:
88
+ return ProblemError(
89
+ UNAUTHORIZED,
90
+ detail,
91
+ headers={"WWW-Authenticate": _INVALID_CHALLENGE if presented else _CHALLENGE},
92
+ )
93
+
94
+
95
+ class Authenticator:
96
+ """Turns request headers into a `Principal` or a 401."""
97
+
98
+ def __init__(
99
+ self,
100
+ settings: AuthSettings,
101
+ *,
102
+ store: AuthStore | None,
103
+ codec: ApiKeyCodec | None = None,
104
+ oidc: OidcVerifier | None = None,
105
+ sessions: SessionManager | None = None,
106
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
107
+ monotonic: Callable[[], float] = time.monotonic,
108
+ meter_provider: MeterProvider | None = None,
109
+ ) -> None:
110
+ if settings.mode != "none" and store is None:
111
+ raise ValueError("authentication needs a store")
112
+ self._settings = settings
113
+ self._store = store
114
+ self._codec = codec
115
+ self._oidc = oidc
116
+ self._sessions = sessions
117
+ self._clock = clock
118
+ self._oidc_cache: TtlCache[tuple[str, tuple[str, ...]], Principal] = TtlCache(
119
+ settings.oidc.principal_cache_ttl_s,
120
+ settings.oidc.principal_cache_max_entries,
121
+ monotonic,
122
+ )
123
+ self._session_cache: TtlCache[tuple[str, tuple[str, ...]], Principal] = TtlCache(
124
+ settings.oidc.principal_cache_ttl_s,
125
+ settings.oidc.principal_cache_max_entries,
126
+ monotonic,
127
+ )
128
+ self._admins = frozenset(settings.installation_admins)
129
+ self._failures = (
130
+ (meter_provider or metrics.get_meter_provider())
131
+ .get_meter("lorebase.keeper")
132
+ .create_counter(
133
+ "lorebase.auth.failures",
134
+ unit="{request}",
135
+ description="Failed authentications, by reason.",
136
+ )
137
+ )
138
+
139
+ def _failed(self, reason: str, error: ProblemError) -> ProblemError:
140
+ """Count a failed authentication and return its problem, for the caller to raise."""
141
+ self._failures.add(1, {"reason": reason})
142
+ return error
143
+
144
+ async def authenticate(
145
+ self, headers: Mapping[str, str], client: IPAddress | None = None, method: str = "GET"
146
+ ) -> Principal:
147
+ """The principal behind the request headers (lower-case names), sent from `client`
148
+ (the client address; None when unknown) with HTTP `method` (for the CSRF check of
149
+ session requests)."""
150
+ if self._settings.mode == "none":
151
+ return anonymous_principal()
152
+ try:
153
+ bearer, api_key = self._credentials(headers)
154
+ except ProblemError as error:
155
+ raise self._failed("malformed", error) from None
156
+ token = api_key or bearer
157
+ if token is None:
158
+ session_id = self.session_id(headers)
159
+ if session_id is not None:
160
+ return await self._with_session(session_id, headers, method)
161
+ raise self._failed(
162
+ "missing", _unauthorized("Authentication required.", presented=False)
163
+ )
164
+ if api_key is not None or (self._codec and self._codec.looks_like_key(token)):
165
+ if self._codec is None:
166
+ raise self._failed(
167
+ "method_not_accepted",
168
+ _unauthorized("API keys are not accepted.", presented=True),
169
+ )
170
+ return await self._with_api_key(token, client)
171
+ if self._oidc is None:
172
+ raise self._failed(
173
+ "method_not_accepted",
174
+ _unauthorized("Bearer tokens are not accepted.", presented=True),
175
+ )
176
+ return await self._with_token(token)
177
+
178
+ def presents_credentials(self, headers: Mapping[str, str]) -> bool:
179
+ """Whether the request carries a credential to check (lower-case header names)."""
180
+ if self._settings.mode == "none":
181
+ return False
182
+ return bool(
183
+ headers.get("authorization")
184
+ or headers.get(self._settings.api_keys.header.lower())
185
+ or self.session_id(headers)
186
+ )
187
+
188
+ def session_id(self, headers: Mapping[str, str]) -> str | None:
189
+ """The session cookie of the request (lower-case header names), when sessions are
190
+ enabled."""
191
+ if self._sessions is None:
192
+ return None
193
+ return cookie_value(headers, self._sessions.settings.secure_cookie_name)
194
+
195
+ def key_prefix(self, headers: Mapping[str, str]) -> str | None:
196
+ """The public prefix of the API key the request presents, when it is shaped like one.
197
+ Nothing is looked up."""
198
+ if self._codec is None:
199
+ return None
200
+ try:
201
+ bearer, api_key = self._credentials(headers)
202
+ except ProblemError:
203
+ return None
204
+ token = api_key or bearer
205
+ parsed = self._codec.parse(token) if token else None
206
+ return parsed.prefix if parsed else None
207
+
208
+ def _credentials(self, headers: Mapping[str, str]) -> tuple[str | None, str | None]:
209
+ bearer: str | None = None
210
+ authorization = headers.get("authorization")
211
+ if authorization:
212
+ scheme, _, value = authorization.partition(" ")
213
+ if scheme.lower() != "bearer" or not value.strip():
214
+ raise _unauthorized("Unsupported Authorization header.", presented=True)
215
+ bearer = value.strip()
216
+ key = headers.get(self._settings.api_keys.header.lower())
217
+ return bearer, key.strip() if key else None
218
+
219
+ async def _with_api_key(self, token: str, client: IPAddress | None) -> Principal:
220
+ assert self._codec is not None
221
+ assert self._store is not None
222
+ parsed = self._codec.parse(token)
223
+ if parsed is None:
224
+ raise self._failed("malformed", _unauthorized("Invalid credentials.", presented=True))
225
+ now = self._clock()
226
+ async with self._store.unit() as unit:
227
+ key = await unit.api_keys.get_by_prefix(parsed.prefix)
228
+ secret_matches = self._codec.matches(parsed.secret, key.hash if key else _MISSING_HASH)
229
+ if key is None or not secret_matches or not key.is_usable(now):
230
+ reason = _reject_reason(key, secret_matches, now)
231
+ logger.info(
232
+ "api key rejected", extra={"key_prefix": parsed.prefix, "reason": reason}
233
+ )
234
+ raise self._failed(
235
+ _REJECTED_KEY_REASONS[reason],
236
+ _unauthorized("Invalid credentials.", presented=True),
237
+ )
238
+ if key.allowed_networks and not in_networks(client, key.allowed_networks):
239
+ # Same answer as a wrong key: the caller learns nothing about the key.
240
+ logger.info(
241
+ "api key rejected",
242
+ extra={
243
+ "key_prefix": parsed.prefix,
244
+ "reason": "client address not allowed",
245
+ "client_address": str(client) if client else None,
246
+ },
247
+ )
248
+ raise self._failed("network", _unauthorized("Invalid credentials.", presented=True))
249
+ record = await unit.principals.get(key.principal_id)
250
+ if record is None:
251
+ raise self._failed(
252
+ "principal_missing", _unauthorized("Invalid credentials.", presented=True)
253
+ )
254
+ groups = await unit.groups.group_ids_for(record.id)
255
+ await self._record_use(key, now)
256
+ subjects = (record.id, *groups)
257
+ return Principal(
258
+ id=record.id,
259
+ type=record.type,
260
+ display_name=record.display_name,
261
+ method="api_key",
262
+ subjects=subjects,
263
+ key_scopes=key.scopes,
264
+ key_id=key.id,
265
+ installation_admin=self._is_installation_admin(subjects),
266
+ )
267
+
268
+ async def _record_use(self, key: ApiKeyRecord, now: datetime) -> None:
269
+ """Update the key's last-used time; a failure never fails the request."""
270
+ assert self._store is not None
271
+ interval = timedelta(seconds=self._settings.api_keys.last_used_interval_s)
272
+ if key.last_used_at is not None and now - key.last_used_at < interval:
273
+ return
274
+ try:
275
+ async with self._store.unit() as unit:
276
+ await unit.api_keys.touch(key.id, now, min_interval=interval)
277
+ except Exception:
278
+ logger.warning("cannot record api key use", exc_info=True)
279
+
280
+ async def _with_token(self, token: str) -> Principal:
281
+ assert self._oidc is not None
282
+ assert self._store is not None
283
+ try:
284
+ claims = await self._oidc.verify(token)
285
+ except InvalidTokenError as exc:
286
+ logger.info("bearer token rejected: %s", exc)
287
+ raise self._failed(
288
+ "invalid_token", _unauthorized("Invalid or expired token.", presented=True)
289
+ ) from None
290
+ except ProviderUnavailableError:
291
+ raise ProblemError(
292
+ UNAVAILABLE, "The identity provider's keys are unavailable."
293
+ ) from None
294
+ return await self.user_principal(claims)
295
+
296
+ async def user_principal(self, claims: OidcClaims) -> Principal:
297
+ """The user principal of verified provider claims (method `oidc`), provisioned on first
298
+ sign-in when `oidc.auto_provision` allows; 403 when no account exists for them."""
299
+ assert self._store is not None
300
+ cache_key = (claims.subject, claims.groups)
301
+ cached = self._oidc_cache.get(cache_key)
302
+ if cached is not None:
303
+ return cached
304
+ async with self._store.unit() as unit:
305
+ record = await unit.principals.get_by_subject(claims.subject)
306
+ if record is None:
307
+ if not self._settings.oidc.auto_provision:
308
+ raise self._failed(
309
+ "no_account",
310
+ ProblemError(FORBIDDEN, "No account exists for this identity."),
311
+ )
312
+ record, created = await unit.principals.get_or_create_user(
313
+ claims.subject, claims.display_name
314
+ )
315
+ if created:
316
+ logger.info("user provisioned", extra={"principal": record.id})
317
+ groups = await unit.groups.group_ids_for(record.id, claims.groups)
318
+ subjects = (record.id, *groups)
319
+ principal = Principal(
320
+ id=record.id,
321
+ type=record.type,
322
+ display_name=record.display_name,
323
+ method="oidc",
324
+ subjects=subjects,
325
+ installation_admin=self._is_installation_admin((*subjects, claims.subject)),
326
+ )
327
+ self._oidc_cache.set(cache_key, principal)
328
+ return principal
329
+
330
+ async def _with_session(
331
+ self, session_id: str, headers: Mapping[str, str], method: str
332
+ ) -> Principal:
333
+ """The user of a browser session: the same principal, groups and grants as the user's
334
+ bearer tokens; unsafe methods must pass the session's CSRF check (403)."""
335
+ assert self._sessions is not None
336
+ assert self._store is not None
337
+ # CSRF first: a forged request must not reach storage, extend or refresh the session.
338
+ self._sessions.check_request(session_id, method, headers)
339
+ record = await self._sessions.resolve(session_id)
340
+ if record is None:
341
+ raise self._failed(
342
+ "invalid_session", _unauthorized("Session expired or unknown.", presented=True)
343
+ )
344
+ cache_key = (record.principal_id, record.external_groups)
345
+ principal = self._session_cache.get(cache_key)
346
+ if principal is None:
347
+ async with self._store.unit() as unit:
348
+ user = await unit.principals.get(record.principal_id)
349
+ if user is None:
350
+ await unit.sessions.delete(record.id_hash)
351
+ raise self._failed(
352
+ "principal_missing",
353
+ _unauthorized("Session expired or unknown.", presented=True),
354
+ )
355
+ groups = await unit.groups.group_ids_for(user.id, record.external_groups)
356
+ subjects = (user.id, *groups)
357
+ principal = Principal(
358
+ id=user.id,
359
+ type=user.type,
360
+ display_name=user.display_name,
361
+ method="session",
362
+ subjects=subjects,
363
+ installation_admin=self._is_installation_admin((*subjects, record.subject)),
364
+ )
365
+ self._session_cache.set(cache_key, principal)
366
+ return replace(principal, session_expires_at=record.expires_at)
367
+
368
+ def _is_installation_admin(self, identities: tuple[str, ...]) -> bool:
369
+ return not self._admins.isdisjoint(identities)
370
+
371
+
372
+ def _reject_reason(key: ApiKeyRecord | None, secret_matches: bool, now: datetime) -> str:
373
+ if key is None:
374
+ return "unknown prefix"
375
+ if not secret_matches:
376
+ return "wrong secret"
377
+ return "revoked" if key.revoked_at is not None else "expired"