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,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)
|