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,597 @@
1
+ """Administration: API keys, principals, grants and the audit log (design 11.1, 10.2, 13.2).
2
+
3
+ Domain administration (`apikey.manage`, `grant.manage`, `audit.read`) is allowed to holders of
4
+ the `admin` role in that Domain; `audit.read` also covers verifying the Domain's audit chain.
5
+ Listing principals is installation-level. Every change is written to the audit log with a
6
+ precise action name; secrets never are.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Mapping
12
+ from datetime import datetime, timedelta
13
+ from typing import Annotated, Any
14
+
15
+ from fastapi import APIRouter, Depends, Query, Request, Response
16
+ from pydantic import BaseModel, Field, field_validator
17
+ from sqlalchemy.exc import IntegrityError
18
+
19
+ from lorebase_keeper.auth.api_keys import issue_api_key
20
+ from lorebase_keeper.auth.audit import annotate
21
+ from lorebase_keeper.auth.config import ApiKeySettings
22
+ from lorebase_keeper.auth.deps import Access, AuthDep, require, require_installation, require_store
23
+ from lorebase_keeper.auth.permissions import Permission
24
+ from lorebase_keeper.auth.ports import AuthUnitPort
25
+ from lorebase_keeper.auth.rbac import domain_scope
26
+ from lorebase_keeper.client_address import normalize_networks
27
+ from lorebase_keeper.errors import (
28
+ BAD_REQUEST,
29
+ FORBIDDEN,
30
+ NOT_FOUND,
31
+ VALIDATION_FAILED,
32
+ Problem,
33
+ ProblemError,
34
+ ValidationProblem,
35
+ )
36
+ from lorebase_keeper.pagination import (
37
+ Page,
38
+ PageParams,
39
+ PageParamsDep,
40
+ encode_cursor,
41
+ )
42
+ from lorebase_store.auth import (
43
+ ApiKeyRecord,
44
+ AuditRecord,
45
+ ChainVerification,
46
+ GrantRecord,
47
+ PrincipalRecord,
48
+ )
49
+ from lorebase_store.models.enums import GrantScope, PrincipalType
50
+
51
+ router = APIRouter(tags=["admin"])
52
+
53
+ _ERRORS: dict[int | str, dict[str, Any]] = {
54
+ 403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
55
+ 404: {"model": Problem, "description": "Not found."},
56
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
57
+ }
58
+
59
+
60
+ class ApiKeyOut(BaseModel):
61
+ """An API key. The secret is never shown again after creation."""
62
+
63
+ id: str
64
+ principal_id: str
65
+ prefix: str
66
+ scopes: list[str]
67
+ expires_at: datetime | None
68
+ last_used_at: datetime | None
69
+ revoked_at: datetime | None
70
+ created_at: datetime
71
+ allowed_networks: list[str] = Field(
72
+ default_factory=list,
73
+ description="Client networks (CIDR) the key may be used from; empty: any address.",
74
+ )
75
+
76
+
77
+ class ApiKeyCreated(ApiKeyOut):
78
+ """A new API key; `key` is the only time the secret is returned."""
79
+
80
+ key: str
81
+ role: str | None = Field(
82
+ default=None, description="Role granted to the new service principal in the Domain."
83
+ )
84
+
85
+
86
+ class CreateApiKey(BaseModel):
87
+ """Request to create an API key for a Domain."""
88
+
89
+ name: str = Field(min_length=1, max_length=120, description="Name of the service principal.")
90
+ role: str = Field(
91
+ default="agent-reader",
92
+ description="Role granted in this Domain to the new service principal.",
93
+ )
94
+ ttl_days: int | None = Field(
95
+ default=None, ge=1, description="Lifetime in days; the configured default when omitted."
96
+ )
97
+ principal_id: str | None = Field(
98
+ default=None,
99
+ description="Add the key to an existing service principal instead of creating one; "
100
+ "its grants are left as they are. The principal must hold a grant in this Domain and "
101
+ "must not be an installation administrator.",
102
+ )
103
+ allowed_networks: list[str] | None = Field(
104
+ default=None,
105
+ description="Client networks the key may be used from, e.g. 203.0.113.0/24 or "
106
+ "2001:db8::/48; a single address is a network of one. Requests from other addresses "
107
+ "are refused as if the key were invalid. Empty or omitted: any address. At most "
108
+ "`keeper.auth.api_keys.max_allowed_networks` entries.",
109
+ )
110
+
111
+ @field_validator("allowed_networks")
112
+ @classmethod
113
+ def _networks(cls, value: list[str] | None) -> list[str] | None:
114
+ return list(normalize_networks(value)) if value else None
115
+
116
+
117
+ class PrincipalOut(BaseModel):
118
+ """A user or service principal."""
119
+
120
+ id: str
121
+ type: PrincipalType
122
+ display_name: str
123
+ external_subject: str | None
124
+ created_at: datetime
125
+
126
+
127
+ class GrantOut(BaseModel):
128
+ """A role of a principal or group in a Domain."""
129
+
130
+ id: str
131
+ domain_id: str
132
+ subject: str
133
+ role: str
134
+ scope_type: GrantScope
135
+ scope_ref: str | None
136
+ created_by: str
137
+ created_at: datetime
138
+
139
+
140
+ class CreateGrant(BaseModel):
141
+ """Request to grant a role."""
142
+
143
+ subject: str = Field(min_length=1, description="Principal or group id.")
144
+ role: str
145
+ scope_type: GrantScope = GrantScope.DOMAIN
146
+ scope_ref: str | None = Field(
147
+ default=None,
148
+ min_length=1,
149
+ description="Kind api name, Operation name or branch pattern; required unless the "
150
+ "scope is the whole Domain.",
151
+ )
152
+
153
+
154
+ class AuditEntryOut(BaseModel):
155
+ """An audit log entry."""
156
+
157
+ seq: int
158
+ at: datetime
159
+ domain_id: str | None
160
+ actor: str
161
+ action: str
162
+ target: str | None
163
+ details: dict[str, Any]
164
+ entry_hash: str
165
+
166
+
167
+ class AuditBreakOut(BaseModel):
168
+ """The first audit entry of a chain that does not verify."""
169
+
170
+ seq: int
171
+ reason: str = Field(
172
+ description="`chain link broken` (an entry was removed, reordered or inserted) or "
173
+ "`entry content changed` (an entry was edited)."
174
+ )
175
+
176
+
177
+ class AuditVerificationOut(BaseModel):
178
+ """Result of recomputing a Domain's audit chain."""
179
+
180
+ domain_id: str
181
+ intact: bool = Field(description="Every entry in the range verified.")
182
+ checked: int = Field(description="Entries that verified, before the first broken one.")
183
+ after_seq: int = Field(description="Entries up to this `seq` were not read.")
184
+ last_seq: int | None = Field(
185
+ description="`seq` of the last entry that verified; pass it as `after_seq` later to "
186
+ "verify only what was appended since."
187
+ )
188
+ first_broken: AuditBreakOut | None = None
189
+
190
+
191
+ def _invalid(pointer: str, message: str) -> ProblemError:
192
+ return ProblemError(
193
+ VALIDATION_FAILED,
194
+ "One or more request members are invalid.",
195
+ extensions={"errors": [{"pointer": pointer, "message": message, "code": "invalid"}]},
196
+ )
197
+
198
+
199
+ def _key_out(key: ApiKeyRecord) -> dict[str, Any]:
200
+ return {
201
+ "id": key.id,
202
+ "principal_id": key.principal_id,
203
+ "prefix": key.prefix,
204
+ "scopes": list(key.scopes),
205
+ "expires_at": key.expires_at,
206
+ "last_used_at": key.last_used_at,
207
+ "revoked_at": key.revoked_at,
208
+ "created_at": key.created_at,
209
+ "allowed_networks": list(key.allowed_networks),
210
+ }
211
+
212
+
213
+ def _principal_out(principal: PrincipalRecord) -> PrincipalOut:
214
+ return PrincipalOut(
215
+ id=principal.id,
216
+ type=principal.type,
217
+ display_name=principal.display_name,
218
+ external_subject=principal.external_subject,
219
+ created_at=principal.created_at,
220
+ )
221
+
222
+
223
+ def _grant_out(grant: GrantRecord) -> GrantOut:
224
+ return GrantOut(
225
+ id=grant.id,
226
+ domain_id=grant.domain_id,
227
+ subject=grant.subject,
228
+ role=grant.role,
229
+ scope_type=grant.scope_type,
230
+ scope_ref=grant.scope_ref,
231
+ created_by=grant.created_by,
232
+ created_at=grant.created_at,
233
+ )
234
+
235
+
236
+ def _audit_out(entry: AuditRecord) -> AuditEntryOut:
237
+ return AuditEntryOut(
238
+ seq=entry.seq,
239
+ at=entry.at,
240
+ domain_id=entry.domain_id,
241
+ actor=entry.actor,
242
+ action=entry.action,
243
+ target=entry.target,
244
+ details=dict(entry.details),
245
+ entry_hash=entry.entry_hash.hex(),
246
+ )
247
+
248
+
249
+ def _verification_out(
250
+ domain_id: str, after_seq: int, result: ChainVerification
251
+ ) -> AuditVerificationOut:
252
+ broken = (
253
+ None
254
+ if result.bad_seq is None
255
+ else AuditBreakOut(seq=result.bad_seq, reason=result.reason or "")
256
+ )
257
+ return AuditVerificationOut(
258
+ domain_id=domain_id,
259
+ intact=result.ok,
260
+ checked=result.checked,
261
+ after_seq=after_seq,
262
+ last_seq=result.last_seq,
263
+ first_broken=broken,
264
+ )
265
+
266
+
267
+ def _after(params: PageParams) -> tuple[datetime, str] | None:
268
+ """Keyset position `(created_at, id)` carried by a cursor."""
269
+ if params.position is None:
270
+ return None
271
+ try:
272
+ return datetime.fromisoformat(params.position["at"]), str(params.position["id"])
273
+ except (KeyError, ValueError, TypeError):
274
+ raise _invalid("/query/cursor", "is not a valid cursor") from None
275
+
276
+
277
+ def _slice[R, M](
278
+ rows: list[R],
279
+ limit: int,
280
+ convert: Callable[[R], M],
281
+ position: Callable[[R], Mapping[str, Any]],
282
+ ) -> tuple[list[M], str | None]:
283
+ """The first `limit` of `limit + 1` fetched rows, with a cursor when more remain."""
284
+ shown = rows[:limit]
285
+ next_cursor = encode_cursor(position(shown[-1])) if len(rows) > limit and shown else None
286
+ return [convert(row) for row in shown], next_cursor
287
+
288
+
289
+ def _created_position(row: ApiKeyRecord | PrincipalRecord | GrantRecord) -> dict[str, str]:
290
+ return {"at": row.created_at.isoformat(), "id": row.id}
291
+
292
+
293
+ def _expiry(settings: ApiKeySettings, ttl_days: int | None, now: datetime) -> datetime | None:
294
+ days = ttl_days if ttl_days is not None else settings.default_ttl_days
295
+ if settings.max_ttl_days is not None and (days is None or days > settings.max_ttl_days):
296
+ raise _invalid("/body/ttl_days", f"must not exceed {settings.max_ttl_days}")
297
+ return None if days is None else now + timedelta(days=days)
298
+
299
+
300
+ def _known_role(auth: AuthDep, role: str) -> None:
301
+ if role not in auth.authorizer.role_names:
302
+ raise _invalid("/body/role", f"unknown role {role!r}")
303
+
304
+
305
+ @router.post(
306
+ "/domains/{domain_id}/api-keys",
307
+ status_code=201,
308
+ summary="Create an API key",
309
+ responses=_ERRORS,
310
+ )
311
+ async def create_api_key(
312
+ domain_id: str,
313
+ body: CreateApiKey,
314
+ request: Request,
315
+ auth: AuthDep,
316
+ access: Annotated[Access, Depends(require(Permission.APIKEY_MANAGE))],
317
+ ) -> ApiKeyCreated:
318
+ """Create a key bound to a service principal and limited to this Domain.
319
+
320
+ Unless `principal_id` names an existing service principal, a new one is created and given
321
+ `role` in the Domain. The secret in `key` is shown once.
322
+ """
323
+ require_store(auth)
324
+ assert auth.store is not None
325
+ if auth.codec is None:
326
+ raise ProblemError(BAD_REQUEST, "API keys are not enabled (keeper.auth.mode).")
327
+ _known_role(auth, body.role)
328
+ networks = body.allowed_networks or []
329
+ if len(networks) > auth.settings.api_keys.max_allowed_networks:
330
+ raise _invalid(
331
+ "/body/allowed_networks",
332
+ f"must not list more than {auth.settings.api_keys.max_allowed_networks} networks",
333
+ )
334
+ now = auth.clock()
335
+ expires_at = _expiry(auth.settings.api_keys, body.ttl_days, now)
336
+ actor = access.principal.id
337
+ try:
338
+ async with auth.store.unit() as unit:
339
+ if body.principal_id is not None:
340
+ service = await unit.principals.get(body.principal_id)
341
+ if service is None or service.type is not PrincipalType.SERVICE:
342
+ raise ProblemError(NOT_FOUND, "Service principal not found.")
343
+ await _check_key_holder(auth, unit, domain_id, service)
344
+ role = None
345
+ else:
346
+ service = await unit.principals.add(PrincipalType.SERVICE, body.name)
347
+ await unit.grants.add(
348
+ domain_id=domain_id, subject=service.id, role=body.role, created_by=actor
349
+ )
350
+ role = body.role
351
+ token, key = await issue_api_key(
352
+ auth.codec,
353
+ unit.api_keys,
354
+ service.id,
355
+ scopes=[domain_scope(domain_id)],
356
+ expires_at=expires_at,
357
+ allowed_networks=networks,
358
+ )
359
+ except IntegrityError:
360
+ raise ProblemError(NOT_FOUND, "Domain not found.") from None
361
+ auth.authorizer.invalidate()
362
+ annotate(
363
+ request,
364
+ action="apikey.create",
365
+ target=key.id,
366
+ principal_id=service.id,
367
+ prefix=key.prefix,
368
+ role=role,
369
+ expires_at=expires_at.isoformat() if expires_at else None,
370
+ allowed_networks=networks or None,
371
+ )
372
+ return ApiKeyCreated(**_key_out(key), key=token, role=role)
373
+
374
+
375
+ async def _check_key_holder(
376
+ auth: AuthDep, unit: AuthUnitPort, domain_id: str, service: PrincipalRecord
377
+ ) -> None:
378
+ """Refuse a new key for a principal outside this Domain or an installation administrator.
379
+
380
+ A Domain admin may only extend principals that already act in the Domain (a grant to them
381
+ or one of their groups); otherwise a key could carry another Domain's access or
382
+ installation administration.
383
+ """
384
+ subjects = (service.id, *await unit.groups.group_ids_for(service.id))
385
+ identities = (*subjects, service.external_subject) if service.external_subject else subjects
386
+ if not set(auth.settings.installation_admins).isdisjoint(identities):
387
+ raise ProblemError(
388
+ FORBIDDEN, "API keys of installation administrators cannot be created here."
389
+ )
390
+ if not await unit.grants.for_subjects(domain_id, subjects):
391
+ raise ProblemError(FORBIDDEN, "The service principal holds no grant in this Domain.")
392
+
393
+
394
+ @router.get(
395
+ "/domains/{domain_id}/api-keys",
396
+ summary="List the API keys of a Domain",
397
+ responses=_ERRORS,
398
+ )
399
+ async def list_api_keys(
400
+ domain_id: str,
401
+ params: PageParamsDep,
402
+ auth: AuthDep,
403
+ access: Annotated[Access, Depends(require(Permission.APIKEY_MANAGE))],
404
+ ) -> Page[ApiKeyOut]:
405
+ """Keys limited to this Domain, oldest first, including revoked and expired ones."""
406
+ require_store(auth)
407
+ assert auth.store is not None
408
+ async with auth.store.unit() as unit:
409
+ rows = await unit.api_keys.list_for_scope(
410
+ domain_scope(domain_id), limit=params.limit + 1, after=_after(params)
411
+ )
412
+ items, next_cursor = _slice(
413
+ rows, params.limit, lambda key: ApiKeyOut(**_key_out(key)), _created_position
414
+ )
415
+ return Page[ApiKeyOut](items=items, next_cursor=next_cursor)
416
+
417
+
418
+ @router.delete(
419
+ "/domains/{domain_id}/api-keys/{key_id}",
420
+ status_code=204,
421
+ summary="Revoke an API key",
422
+ responses=_ERRORS,
423
+ )
424
+ async def revoke_api_key(
425
+ domain_id: str,
426
+ key_id: str,
427
+ request: Request,
428
+ auth: AuthDep,
429
+ access: Annotated[Access, Depends(require(Permission.APIKEY_MANAGE))],
430
+ ) -> Response:
431
+ """Revoke a key of this Domain. Revoking a revoked key succeeds."""
432
+ require_store(auth)
433
+ assert auth.store is not None
434
+ async with auth.store.unit() as unit:
435
+ key = await unit.api_keys.get(key_id)
436
+ if key is None or domain_scope(domain_id) not in key.scopes:
437
+ raise ProblemError(NOT_FOUND, "API key not found.")
438
+ await unit.api_keys.revoke(key_id, auth.clock())
439
+ annotate(request, action="apikey.revoke", target=key_id, principal_id=key.principal_id)
440
+ return Response(status_code=204)
441
+
442
+
443
+ @router.get("/principals", summary="List principals", responses=_ERRORS)
444
+ async def list_principals(
445
+ params: PageParamsDep,
446
+ auth: AuthDep,
447
+ access: Annotated[Access, Depends(require_installation(Permission.PRINCIPAL_READ))],
448
+ type_: Annotated[PrincipalType | None, Query(alias="type")] = None,
449
+ ) -> Page[PrincipalOut]:
450
+ """Users and service principals of the installation, oldest first."""
451
+ require_store(auth)
452
+ assert auth.store is not None
453
+ async with auth.store.unit() as unit:
454
+ rows = await unit.principals.list(type_=type_, limit=params.limit + 1, after=_after(params))
455
+ items, next_cursor = _slice(rows, params.limit, _principal_out, _created_position)
456
+ return Page[PrincipalOut](items=items, next_cursor=next_cursor)
457
+
458
+
459
+ @router.post(
460
+ "/domains/{domain_id}/grants", status_code=201, summary="Grant a role", responses=_ERRORS
461
+ )
462
+ async def create_grant(
463
+ domain_id: str,
464
+ body: CreateGrant,
465
+ request: Request,
466
+ auth: AuthDep,
467
+ access: Annotated[Access, Depends(require(Permission.GRANT_MANAGE))],
468
+ ) -> GrantOut:
469
+ """Give a principal or group a role in the Domain, optionally narrowed to a scope."""
470
+ require_store(auth)
471
+ assert auth.store is not None
472
+ _known_role(auth, body.role)
473
+ if (body.scope_type is GrantScope.DOMAIN) != (body.scope_ref is None):
474
+ raise _invalid("/body/scope_ref", "is required for kind, operation and branch scopes only")
475
+ try:
476
+ async with auth.store.unit() as unit:
477
+ if not await unit.groups.subject_exists(body.subject):
478
+ raise ProblemError(NOT_FOUND, "Subject not found.")
479
+ grant = await unit.grants.add(
480
+ domain_id=domain_id,
481
+ subject=body.subject,
482
+ role=body.role,
483
+ created_by=access.principal.id,
484
+ scope_type=body.scope_type,
485
+ scope_ref=body.scope_ref,
486
+ )
487
+ except IntegrityError:
488
+ raise ProblemError(NOT_FOUND, "Domain not found.") from None
489
+ auth.authorizer.invalidate()
490
+ annotate(
491
+ request,
492
+ action="grant.create",
493
+ target=grant.id,
494
+ subject=grant.subject,
495
+ role=grant.role,
496
+ scope_type=grant.scope_type.value,
497
+ scope_ref=grant.scope_ref,
498
+ )
499
+ return _grant_out(grant)
500
+
501
+
502
+ @router.get("/domains/{domain_id}/grants", summary="List grants", responses=_ERRORS)
503
+ async def list_grants(
504
+ domain_id: str,
505
+ params: PageParamsDep,
506
+ auth: AuthDep,
507
+ access: Annotated[Access, Depends(require(Permission.GRANT_MANAGE))],
508
+ ) -> Page[GrantOut]:
509
+ """Grants of the Domain, oldest first."""
510
+ require_store(auth)
511
+ assert auth.store is not None
512
+ async with auth.store.unit() as unit:
513
+ rows = await unit.grants.list_domain(
514
+ domain_id, limit=params.limit + 1, after=_after(params)
515
+ )
516
+ items, next_cursor = _slice(rows, params.limit, _grant_out, _created_position)
517
+ return Page[GrantOut](items=items, next_cursor=next_cursor)
518
+
519
+
520
+ @router.delete(
521
+ "/domains/{domain_id}/grants/{grant_id}",
522
+ status_code=204,
523
+ summary="Revoke a grant",
524
+ responses=_ERRORS,
525
+ )
526
+ async def revoke_grant(
527
+ domain_id: str,
528
+ grant_id: str,
529
+ request: Request,
530
+ auth: AuthDep,
531
+ access: Annotated[Access, Depends(require(Permission.GRANT_MANAGE))],
532
+ ) -> Response:
533
+ """Remove a grant from the Domain."""
534
+ require_store(auth)
535
+ assert auth.store is not None
536
+ async with auth.store.unit() as unit:
537
+ grant = await unit.grants.get(domain_id, grant_id)
538
+ if grant is None:
539
+ raise ProblemError(NOT_FOUND, "Grant not found.")
540
+ await unit.grants.delete(domain_id, grant_id)
541
+ auth.authorizer.invalidate()
542
+ annotate(
543
+ request, action="grant.revoke", target=grant_id, subject=grant.subject, role=grant.role
544
+ )
545
+ return Response(status_code=204)
546
+
547
+
548
+ @router.get("/domains/{domain_id}/audit", summary="Read the audit log", responses=_ERRORS)
549
+ async def list_audit(
550
+ domain_id: str,
551
+ params: PageParamsDep,
552
+ auth: AuthDep,
553
+ access: Annotated[Access, Depends(require(Permission.AUDIT_READ))],
554
+ ) -> Page[AuditEntryOut]:
555
+ """Audit entries of the Domain, newest first."""
556
+ require_store(auth)
557
+ assert auth.store is not None
558
+ before: int | None = None
559
+ if params.position is not None:
560
+ try:
561
+ before = int(params.position["before"])
562
+ except (KeyError, ValueError, TypeError):
563
+ raise _invalid("/query/cursor", "is not a valid cursor") from None
564
+ async with auth.store.unit() as unit:
565
+ rows = await unit.audit.list(domain_id=domain_id, limit=params.limit + 1, before_seq=before)
566
+ items, next_cursor = _slice(rows, params.limit, _audit_out, lambda row: {"before": row.seq})
567
+ return Page[AuditEntryOut](items=items, next_cursor=next_cursor)
568
+
569
+
570
+ @router.get(
571
+ "/domains/{domain_id}/audit/verify",
572
+ summary="Verify the audit chain",
573
+ responses=_ERRORS,
574
+ )
575
+ async def verify_audit(
576
+ domain_id: str,
577
+ auth: AuthDep,
578
+ access: Annotated[Access, Depends(require(Permission.AUDIT_READ))],
579
+ after_seq: Annotated[
580
+ int,
581
+ Query(
582
+ ge=0,
583
+ description="Start after this entry, trusting it as the anchor (a `last_seq` of an "
584
+ "earlier verification); 0 verifies the whole chain.",
585
+ ),
586
+ ] = 0,
587
+ ) -> AuditVerificationOut:
588
+ """Recompute the hash chain of the Domain's audit log and report the first broken entry.
589
+
590
+ Entries are read in batches, so a long chain costs time, not memory. The answer is 200
591
+ whether the chain is intact or not; `intact` and `first_broken` tell which.
592
+ """
593
+ require_store(auth)
594
+ assert auth.store is not None
595
+ async with auth.store.unit() as unit:
596
+ result = await unit.audit.verify(after_seq=after_seq, domain_id=domain_id)
597
+ return _verification_out(domain_id, after_seq, result)