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,328 @@
|
|
|
1
|
+
"""Schema suggestions from document extraction (design 9.2 step 10, 12.3 Review).
|
|
2
|
+
|
|
3
|
+
| Method and path | Permission | Notes |
|
|
4
|
+
|-----------------|------------|-------|
|
|
5
|
+
| `GET .../schema/suggestions` | `document.read` | `status`, `type`, `all` |
|
|
6
|
+
| `POST .../schema/suggestions:decide` | `schema.write` | accepting: see below |
|
|
7
|
+
| `POST .../schema/suggestions:merge` | `schema.write` | folds stored variants |
|
|
8
|
+
|
|
9
|
+
Suggestions quote their documents, so listing them needs `document.read` (Domain-wide). Pending
|
|
10
|
+
suggestions are listed once they reach `schema.suggestions.min_support` observations, the most
|
|
11
|
+
supported first, at most `schema.suggestions.max_suggestions` in all.
|
|
12
|
+
|
|
13
|
+
A decision rejects a suggestion, resets it to `pending`, or accepts it. All suggestions accepted
|
|
14
|
+
by one request become one schema change set, committed on a new branch
|
|
15
|
+
(`keeper.schema_suggestions.branch_prefix` and the first suggestion's id) and proposed into the
|
|
16
|
+
default branch: the answer names the Proposal, which reviewers merge as any other. Accepting
|
|
17
|
+
needs `schema.write` on that branch, `branch.create` and `proposal.create`; when the change set
|
|
18
|
+
is invalid the answer is 422 with the issues and nothing is decided. An accepted suggestion
|
|
19
|
+
cannot be decided again. Decisions are audited (`schema.suggestions.decide`).
|
|
20
|
+
|
|
21
|
+
`:merge` folds the pending Attribute suggestions stored before variants were merged on arrival
|
|
22
|
+
into the best supported one of each set (`schema.suggestions.merge_similarity`); it is safe to
|
|
23
|
+
repeat and audited (`schema.suggestions.merge`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import Annotated, Any, Literal
|
|
30
|
+
|
|
31
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
33
|
+
|
|
34
|
+
from lorebase_keeper.auth.audit import annotate
|
|
35
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
|
|
36
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
37
|
+
from lorebase_keeper.auth.principals import Principal
|
|
38
|
+
from lorebase_keeper.auth.rbac import AccessContext
|
|
39
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
40
|
+
from lorebase_keeper.context import StoreDep, require_domain
|
|
41
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
42
|
+
from lorebase_keeper.errors import Problem, ValidationProblem
|
|
43
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
44
|
+
from lorebase_keeper.reading.sensitivity import Masking, default_schema, masking_for
|
|
45
|
+
from lorebase_keeper.routes.schema import ElementChangeOut
|
|
46
|
+
from lorebase_keeper.services import schema_suggestions as service
|
|
47
|
+
from lorebase_keeper.services.errors import commit_problem
|
|
48
|
+
from lorebase_store.commit import CommitError
|
|
49
|
+
|
|
50
|
+
router = APIRouter(tags=["schema"])
|
|
51
|
+
|
|
52
|
+
SUGGESTIONS = "/domains/{domain_id}/schema/suggestions"
|
|
53
|
+
StatusName = Literal["pending", "accepted", "rejected"]
|
|
54
|
+
TypeName = Literal["kind", "attribute", "relation"]
|
|
55
|
+
|
|
56
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
57
|
+
403: {"model": Problem, "description": "The caller lacks the permission."},
|
|
58
|
+
404: {"model": Problem, "description": "Domain or suggestion not found."},
|
|
59
|
+
409: {"model": Problem, "description": "A suggestion was accepted already."},
|
|
60
|
+
422: {
|
|
61
|
+
"model": ValidationProblem,
|
|
62
|
+
"description": "Invalid request, a suggestion the schema has meanwhile, or accepted "
|
|
63
|
+
"suggestions that do not form a valid schema.",
|
|
64
|
+
},
|
|
65
|
+
503: {"model": Problem, "description": "A branch is locked by another writer."},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Read = Annotated[Access, Depends(require(Permission.DOCUMENT_READ))]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TypeGuessOut(BaseModel):
|
|
72
|
+
type: str = Field(description="Attribute type inferred from the samples, e.g. `date`.")
|
|
73
|
+
constraints: dict[str, JsonValue]
|
|
74
|
+
confidence: float
|
|
75
|
+
reasons: list[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DuplicateOut(BaseModel):
|
|
79
|
+
section: Literal["kinds", "attributes", "relations"]
|
|
80
|
+
api_name: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SuggestionOut(BaseModel):
|
|
84
|
+
id: str
|
|
85
|
+
type: TypeName
|
|
86
|
+
name: str = Field(description="Proposed api name.")
|
|
87
|
+
kind: str | None = Field(
|
|
88
|
+
default=None,
|
|
89
|
+
description="Owner Kind of an Attribute, from Kind of a Relation: an api name, or "
|
|
90
|
+
"`+Name` for a Kind another suggestion proposes.",
|
|
91
|
+
)
|
|
92
|
+
target: str | None = Field(default=None, description="To Kind of a Relation.")
|
|
93
|
+
labels: list[str] = Field(description="Labels as the documents wrote them.")
|
|
94
|
+
support: int = Field(description="Observations, over every document.")
|
|
95
|
+
documents: int = Field(description="Documents it was observed in.")
|
|
96
|
+
confidence: float = Field(description="Mean confidence of the observations.")
|
|
97
|
+
status: StatusName
|
|
98
|
+
type_guess: TypeGuessOut | None = Field(
|
|
99
|
+
default=None, description="For Attributes: the type the samples suggest."
|
|
100
|
+
)
|
|
101
|
+
samples: list[str]
|
|
102
|
+
examples: list[dict[str, Any]] = Field(
|
|
103
|
+
description="Evidence: document, chunk, offsets into the parsed text, quote."
|
|
104
|
+
)
|
|
105
|
+
sources: list[str] = Field(description="Techniques that observed it (rules, models).")
|
|
106
|
+
duplicate_of: DuplicateOut | None = Field(
|
|
107
|
+
default=None, description="The element of the current schema it duplicates."
|
|
108
|
+
)
|
|
109
|
+
withheld: bool = Field(
|
|
110
|
+
default=False,
|
|
111
|
+
description="Examples and samples are left out: it matches a sensitive Attribute the "
|
|
112
|
+
"caller may not read.",
|
|
113
|
+
)
|
|
114
|
+
proposal_id: str | None
|
|
115
|
+
branch: str | None
|
|
116
|
+
decided_by: str | None
|
|
117
|
+
decided_at: datetime | None
|
|
118
|
+
created_at: datetime | None
|
|
119
|
+
updated_at: datetime | None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SuggestionDecisionIn(BaseModel):
|
|
123
|
+
model_config = ConfigDict(extra="forbid")
|
|
124
|
+
|
|
125
|
+
suggestion: str = Field(min_length=1)
|
|
126
|
+
decision: StatusName
|
|
127
|
+
name: str | None = Field(
|
|
128
|
+
default=None, max_length=100, description="Api name to use instead (accepted only)."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DecideSuggestionsIn(BaseModel):
|
|
133
|
+
model_config = ConfigDict(extra="forbid")
|
|
134
|
+
|
|
135
|
+
decisions: list[SuggestionDecisionIn] = Field(min_length=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MergeSuggestionsOut(BaseModel):
|
|
139
|
+
merged: int = Field(description="Suggestions folded into another and removed.")
|
|
140
|
+
kept: int = Field(description="Suggestions that absorbed others.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SuggestionProposalOut(BaseModel):
|
|
144
|
+
id: str
|
|
145
|
+
title: str
|
|
146
|
+
branch: str
|
|
147
|
+
changes: list[ElementChangeOut]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DecideSuggestionsOut(BaseModel):
|
|
151
|
+
suggestions: list[SuggestionOut]
|
|
152
|
+
proposal: SuggestionProposalOut | None = Field(
|
|
153
|
+
default=None, description="The Proposal opened for the accepted suggestions."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _out(view: service.SuggestionView) -> SuggestionOut:
|
|
158
|
+
row = view.row
|
|
159
|
+
guess = view.guess
|
|
160
|
+
return SuggestionOut(
|
|
161
|
+
id=row.id,
|
|
162
|
+
type=row.suggestion_type,
|
|
163
|
+
name=row.name,
|
|
164
|
+
kind=view.kind,
|
|
165
|
+
target=view.target,
|
|
166
|
+
labels=list(row.labels),
|
|
167
|
+
support=row.support,
|
|
168
|
+
documents=row.documents,
|
|
169
|
+
confidence=row.confidence,
|
|
170
|
+
status=row.status,
|
|
171
|
+
type_guess=None
|
|
172
|
+
if guess is None
|
|
173
|
+
else TypeGuessOut(
|
|
174
|
+
type=guess.type.describe(),
|
|
175
|
+
constraints=guess.constraints.model_dump(mode="json", exclude_none=True),
|
|
176
|
+
confidence=guess.confidence,
|
|
177
|
+
reasons=list(guess.reasons),
|
|
178
|
+
),
|
|
179
|
+
samples=[] if view.withheld else list(row.samples),
|
|
180
|
+
examples=[] if view.withheld else list(row.examples),
|
|
181
|
+
sources=list(row.sources),
|
|
182
|
+
duplicate_of=None
|
|
183
|
+
if view.duplicate is None
|
|
184
|
+
else DuplicateOut(
|
|
185
|
+
section=view.duplicate.section,
|
|
186
|
+
api_name=view.duplicate.api_name,
|
|
187
|
+
),
|
|
188
|
+
withheld=view.withheld,
|
|
189
|
+
proposal_id=row.proposal_id,
|
|
190
|
+
branch=row.branch,
|
|
191
|
+
decided_by=row.decided_by,
|
|
192
|
+
decided_at=row.decided_at,
|
|
193
|
+
created_at=row.created_at,
|
|
194
|
+
updated_at=row.updated_at,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _masking(
|
|
199
|
+
store: StoreDep, auth: AuthRuntime, principal: Principal, domain_id: str, settings: Any
|
|
200
|
+
) -> Masking:
|
|
201
|
+
async with store.sessions() as session, session.begin():
|
|
202
|
+
await require_domain(session, domain_id)
|
|
203
|
+
reference = await default_schema(session, domain_id, store.runtime)
|
|
204
|
+
masking = await masking_for(auth.authorizer, principal, domain_id, reference, settings)
|
|
205
|
+
return masking if reference is None else masking.over(reference)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get(SUGGESTIONS, summary="List schema suggestions", responses=_ERRORS)
|
|
209
|
+
async def list_suggestions(
|
|
210
|
+
domain_id: str,
|
|
211
|
+
access: Read,
|
|
212
|
+
store: StoreDep,
|
|
213
|
+
params: PageParamsDep,
|
|
214
|
+
auth: AuthDep,
|
|
215
|
+
keeper: KeeperSettingsDep,
|
|
216
|
+
status: Annotated[list[StatusName] | None, Query()] = None,
|
|
217
|
+
type_: Annotated[list[TypeName] | None, Query(alias="type")] = None,
|
|
218
|
+
all_: Annotated[
|
|
219
|
+
bool,
|
|
220
|
+
Query(alias="all", description="Also those below `schema.suggestions.min_support`."),
|
|
221
|
+
] = False,
|
|
222
|
+
) -> Page[SuggestionOut]:
|
|
223
|
+
"""Kinds, Attributes and Relations extraction found outside the schema, with support,
|
|
224
|
+
examples, samples and, for Attributes, the type their samples suggest."""
|
|
225
|
+
masking = await _masking(store, auth, access.principal, domain_id, keeper.reads.sensitivity)
|
|
226
|
+
found = await service.page(
|
|
227
|
+
store,
|
|
228
|
+
domain_id,
|
|
229
|
+
masking,
|
|
230
|
+
statuses=status or [],
|
|
231
|
+
types=type_ or [],
|
|
232
|
+
all_support=all_,
|
|
233
|
+
limit=params.limit,
|
|
234
|
+
position=params.position,
|
|
235
|
+
)
|
|
236
|
+
following = None if found.next_position is None else encode_cursor(found.next_position)
|
|
237
|
+
return Page[SuggestionOut](items=[_out(v) for v in found.views], next_cursor=following)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@router.post(
|
|
241
|
+
SUGGESTIONS + ":decide", summary="Accept or reject schema suggestions", responses=_ERRORS
|
|
242
|
+
)
|
|
243
|
+
async def decide_suggestions(
|
|
244
|
+
domain_id: str,
|
|
245
|
+
body: DecideSuggestionsIn,
|
|
246
|
+
request: Request,
|
|
247
|
+
principal: PrincipalDep,
|
|
248
|
+
auth: AuthDep,
|
|
249
|
+
store: StoreDep,
|
|
250
|
+
keeper: KeeperSettingsDep,
|
|
251
|
+
) -> DecideSuggestionsOut:
|
|
252
|
+
"""Reject or reset suggestions; accept some as one schema change Proposal."""
|
|
253
|
+
settings = keeper.schema_suggestions
|
|
254
|
+
accepting = [d for d in body.decisions if d.decision == "accepted"]
|
|
255
|
+
if accepting:
|
|
256
|
+
branch = f"{settings.branch_prefix}{accepting[0].suggestion}"
|
|
257
|
+
for permission, context in (
|
|
258
|
+
(Permission.BRANCH_CREATE, AccessContext()),
|
|
259
|
+
(Permission.SCHEMA_WRITE, AccessContext(branch=branch)),
|
|
260
|
+
(Permission.PROPOSAL_CREATE, AccessContext()),
|
|
261
|
+
):
|
|
262
|
+
await authorize(request, auth, principal, permission, domain_id, context)
|
|
263
|
+
else:
|
|
264
|
+
await authorize(request, auth, principal, Permission.SCHEMA_WRITE, domain_id)
|
|
265
|
+
masking = await _masking(store, auth, principal, domain_id, keeper.reads.sensitivity)
|
|
266
|
+
try:
|
|
267
|
+
outcome = await service.decide(
|
|
268
|
+
store,
|
|
269
|
+
domain_id,
|
|
270
|
+
[service.DecisionInput(d.suggestion, d.decision, d.name) for d in body.decisions],
|
|
271
|
+
principal,
|
|
272
|
+
settings=settings,
|
|
273
|
+
branches=keeper.branches,
|
|
274
|
+
proposals=keeper.proposals,
|
|
275
|
+
masking=masking,
|
|
276
|
+
)
|
|
277
|
+
except CommitError as error:
|
|
278
|
+
raise commit_problem(
|
|
279
|
+
error,
|
|
280
|
+
max_issues=keeper.schema_changes.max_issues,
|
|
281
|
+
retry_after_s=keeper.branches.lock_retry_after_s,
|
|
282
|
+
) from None
|
|
283
|
+
annotate(
|
|
284
|
+
request,
|
|
285
|
+
action="schema.suggestions.decide",
|
|
286
|
+
target=domain_id,
|
|
287
|
+
accepted=len(accepting),
|
|
288
|
+
rejected=sum(1 for d in body.decisions if d.decision == "rejected"),
|
|
289
|
+
reset=sum(1 for d in body.decisions if d.decision == "pending"),
|
|
290
|
+
proposal=outcome.proposal_id,
|
|
291
|
+
)
|
|
292
|
+
proposal = None
|
|
293
|
+
if outcome.proposal_id is not None and outcome.branch is not None:
|
|
294
|
+
proposal = SuggestionProposalOut(
|
|
295
|
+
id=outcome.proposal_id,
|
|
296
|
+
title=outcome.proposal_title or "",
|
|
297
|
+
branch=outcome.branch,
|
|
298
|
+
changes=[
|
|
299
|
+
ElementChangeOut.model_validate(c, from_attributes=True) for c in outcome.changes
|
|
300
|
+
],
|
|
301
|
+
)
|
|
302
|
+
return DecideSuggestionsOut(suggestions=[_out(v) for v in outcome.views], proposal=proposal)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.post(
|
|
306
|
+
SUGGESTIONS + ":merge",
|
|
307
|
+
summary="Merge stored variants of schema suggestions",
|
|
308
|
+
responses={k: v for k, v in _ERRORS.items() if k in (403, 404)},
|
|
309
|
+
)
|
|
310
|
+
async def merge_suggestions(
|
|
311
|
+
domain_id: str,
|
|
312
|
+
request: Request,
|
|
313
|
+
principal: PrincipalDep,
|
|
314
|
+
auth: AuthDep,
|
|
315
|
+
store: StoreDep,
|
|
316
|
+
) -> MergeSuggestionsOut:
|
|
317
|
+
"""Fold pending Attribute suggestions that are variants of one another, with the same
|
|
318
|
+
similarity rules as new observations (idempotent)."""
|
|
319
|
+
await authorize(request, auth, principal, Permission.SCHEMA_WRITE, domain_id)
|
|
320
|
+
outcome = await service.merge_variants(store, domain_id)
|
|
321
|
+
annotate(
|
|
322
|
+
request,
|
|
323
|
+
action="schema.suggestions.merge",
|
|
324
|
+
target=domain_id,
|
|
325
|
+
merged=outcome.merged,
|
|
326
|
+
kept=outcome.kept,
|
|
327
|
+
)
|
|
328
|
+
return MergeSuggestionsOut(merged=outcome.merged, kept=outcome.kept)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Browser sign-in for the web app: Keeper as backend-for-frontend (design 13.2).
|
|
2
|
+
|
|
3
|
+
`GET /auth/login` sends the browser to the provider, `GET /auth/callback` turns the answer into
|
|
4
|
+
a session cookie, `GET /auth/session` gives the app what it needs while signed in (who, until
|
|
5
|
+
when, the CSRF token) and `POST /auth/logout` ends the session. The logic lives in
|
|
6
|
+
`lorebase_keeper.auth.sessions`; every route answers 404 while `keeper.auth.sessions.enabled`
|
|
7
|
+
is false.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Annotated, Any, Literal
|
|
15
|
+
from urllib.parse import urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Query, Request, Response
|
|
18
|
+
from fastapi.responses import RedirectResponse
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from lorebase_keeper.auth.audit import annotate
|
|
22
|
+
from lorebase_keeper.auth.config import SessionSettings, is_local_path, url_origin
|
|
23
|
+
from lorebase_keeper.auth.deps import AuthDep, PrincipalDep
|
|
24
|
+
from lorebase_keeper.auth.oidc import ProviderUnavailableError
|
|
25
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
26
|
+
from lorebase_keeper.auth.sessions import (
|
|
27
|
+
CALLBACK_PATH,
|
|
28
|
+
LOGIN_PATH,
|
|
29
|
+
LOGOUT_PATH,
|
|
30
|
+
LoginError,
|
|
31
|
+
LoginErrorCode,
|
|
32
|
+
SessionManager,
|
|
33
|
+
)
|
|
34
|
+
from lorebase_keeper.errors import (
|
|
35
|
+
BAD_REQUEST,
|
|
36
|
+
FORBIDDEN,
|
|
37
|
+
NOT_FOUND,
|
|
38
|
+
UNAUTHORIZED,
|
|
39
|
+
UNAVAILABLE,
|
|
40
|
+
Problem,
|
|
41
|
+
ProblemError,
|
|
42
|
+
)
|
|
43
|
+
from lorebase_store.auth import NewAuditEntry
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("lorebase.keeper.auth.sessions")
|
|
46
|
+
|
|
47
|
+
router = APIRouter(tags=["auth"])
|
|
48
|
+
|
|
49
|
+
_NO_STORE = {"Cache-Control": "no-store"}
|
|
50
|
+
_CALLBACK_PARAMS = ("code", "state", "error")
|
|
51
|
+
_DISABLED: dict[int | str, dict[str, Any]] = {
|
|
52
|
+
404: {"model": Problem, "description": "Web sign-in is not enabled."}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SessionInfo(BaseModel):
|
|
57
|
+
"""The browser session of the caller."""
|
|
58
|
+
|
|
59
|
+
principal_id: str
|
|
60
|
+
display_name: str
|
|
61
|
+
csrf_header: str = Field(description="Header to send the CSRF token in on unsafe requests.")
|
|
62
|
+
csrf_token: str = Field(description="CSRF token of this session.")
|
|
63
|
+
expires_at: datetime = Field(description="End of the session's absolute lifetime.")
|
|
64
|
+
idle_timeout_s: int = Field(description="The session ends after this long without use.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LogoutResult(BaseModel):
|
|
68
|
+
"""Where to send the browser to sign out at the identity provider too."""
|
|
69
|
+
|
|
70
|
+
logout_url: str | None = Field(
|
|
71
|
+
description="The provider's end-session URL; null when it offers none or no session "
|
|
72
|
+
"was open."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _manager(auth: AuthRuntime) -> SessionManager:
|
|
77
|
+
if auth.sessions is None:
|
|
78
|
+
raise ProblemError(
|
|
79
|
+
NOT_FOUND, "Web sign-in is not enabled (keeper.auth.sessions.enabled is false)."
|
|
80
|
+
)
|
|
81
|
+
return auth.sessions
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _set_cookie(
|
|
85
|
+
response: Response,
|
|
86
|
+
settings: SessionSettings,
|
|
87
|
+
name: str,
|
|
88
|
+
value: str,
|
|
89
|
+
*,
|
|
90
|
+
max_age: int,
|
|
91
|
+
same_site: Literal["lax", "strict"],
|
|
92
|
+
) -> None:
|
|
93
|
+
response.set_cookie(
|
|
94
|
+
name,
|
|
95
|
+
value,
|
|
96
|
+
max_age=max_age,
|
|
97
|
+
path="/",
|
|
98
|
+
secure=settings.cookie_secure,
|
|
99
|
+
httponly=True,
|
|
100
|
+
samesite=same_site,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _clear_cookie(response: Response, settings: SessionSettings, name: str) -> None:
|
|
105
|
+
response.delete_cookie(
|
|
106
|
+
name, path="/", secure=settings.cookie_secure, httponly=True, samesite="lax"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _app_location(settings: SessionSettings, path: str) -> str:
|
|
111
|
+
return url_origin(settings.app_url or "") + path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _error_redirect(settings: SessionSettings, code: LoginErrorCode) -> RedirectResponse:
|
|
115
|
+
separator = "&" if "?" in settings.error_path else "?"
|
|
116
|
+
location = _app_location(settings, settings.error_path) + separator + urlencode({"error": code})
|
|
117
|
+
response = RedirectResponse(location, status_code=302, headers=_NO_STORE)
|
|
118
|
+
_clear_cookie(response, settings, settings.secure_login_cookie_name)
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get(
|
|
123
|
+
LOGIN_PATH,
|
|
124
|
+
summary="Start a browser sign-in",
|
|
125
|
+
status_code=302,
|
|
126
|
+
response_class=RedirectResponse,
|
|
127
|
+
responses={
|
|
128
|
+
302: {"description": "Redirect to the identity provider."},
|
|
129
|
+
400: {"model": Problem, "description": "return_to is not a path of the web app."},
|
|
130
|
+
**_DISABLED,
|
|
131
|
+
503: {"model": Problem, "description": "The identity provider is unavailable."},
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
async def login(
|
|
135
|
+
auth: AuthDep,
|
|
136
|
+
return_to: Annotated[
|
|
137
|
+
str | None,
|
|
138
|
+
Query(
|
|
139
|
+
max_length=2048,
|
|
140
|
+
description="Path on the web app's origin to return to after signing in.",
|
|
141
|
+
),
|
|
142
|
+
] = None,
|
|
143
|
+
) -> RedirectResponse:
|
|
144
|
+
"""Send the browser to the identity provider (authorization code flow with PKCE). The
|
|
145
|
+
sign-in state travels in a short-lived, encrypted, HttpOnly cookie."""
|
|
146
|
+
sessions = _manager(auth)
|
|
147
|
+
settings = sessions.settings
|
|
148
|
+
path = return_to or urlsplit(settings.app_url or "").path or "/"
|
|
149
|
+
if not is_local_path(path):
|
|
150
|
+
raise ProblemError(BAD_REQUEST, "return_to must be a path on the web app's origin.")
|
|
151
|
+
try:
|
|
152
|
+
start = await sessions.begin_login(path)
|
|
153
|
+
except ProviderUnavailableError:
|
|
154
|
+
raise ProblemError(UNAVAILABLE, "The identity provider is unavailable.") from None
|
|
155
|
+
response = RedirectResponse(start.url, status_code=302, headers=_NO_STORE)
|
|
156
|
+
# Lax whatever `same_site` says: the provider's redirect back is a cross-site navigation.
|
|
157
|
+
_set_cookie(
|
|
158
|
+
response,
|
|
159
|
+
settings,
|
|
160
|
+
settings.secure_login_cookie_name,
|
|
161
|
+
start.state_cookie,
|
|
162
|
+
max_age=settings.login_timeout_s,
|
|
163
|
+
same_site="lax",
|
|
164
|
+
)
|
|
165
|
+
return response
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get(
|
|
169
|
+
CALLBACK_PATH,
|
|
170
|
+
summary="Finish a browser sign-in",
|
|
171
|
+
status_code=302,
|
|
172
|
+
response_class=RedirectResponse,
|
|
173
|
+
responses={
|
|
174
|
+
302: {
|
|
175
|
+
"description": "Signed in: redirect to the return path with the session cookie. "
|
|
176
|
+
"Failed: redirect to the app's error path with ?error=<code>."
|
|
177
|
+
},
|
|
178
|
+
**_DISABLED,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
async def callback(request: Request, auth: AuthDep) -> RedirectResponse:
|
|
182
|
+
"""The provider's redirect after sign-in. Checks state, redeems the code with the PKCE
|
|
183
|
+
verifier, verifies the ID token and its nonce, maps the user to a principal as for bearer
|
|
184
|
+
tokens, and starts a session with a new id."""
|
|
185
|
+
sessions = _manager(auth)
|
|
186
|
+
settings = sessions.settings
|
|
187
|
+
query = request.query_params
|
|
188
|
+
try:
|
|
189
|
+
if any(len(query.getlist(name)) > 1 for name in _CALLBACK_PARAMS):
|
|
190
|
+
raise LoginError("login_failed", "repeated callback parameter")
|
|
191
|
+
params = {name: query[name] for name in _CALLBACK_PARAMS if name in query}
|
|
192
|
+
result = await sessions.finish_login(
|
|
193
|
+
params, request.cookies.get(settings.secure_login_cookie_name)
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
principal = await auth.authenticator.user_principal(result.claims)
|
|
197
|
+
except ProblemError as exc:
|
|
198
|
+
code: LoginErrorCode = "no_account" if exc.problem is FORBIDDEN else "login_failed"
|
|
199
|
+
raise LoginError(code, exc.problem_detail or exc.problem.code) from None
|
|
200
|
+
except LoginError as exc:
|
|
201
|
+
logger.info("sign-in failed (%s): %s", exc.code, exc)
|
|
202
|
+
return _error_redirect(settings, exc.code)
|
|
203
|
+
created = await sessions.create(
|
|
204
|
+
principal.id, result, replaces=request.cookies.get(settings.secure_cookie_name)
|
|
205
|
+
)
|
|
206
|
+
logger.info("session started", extra={"principal": principal.id})
|
|
207
|
+
annotate(request, actor=principal.id)
|
|
208
|
+
if auth.audit_writer is not None:
|
|
209
|
+
await auth.audit_writer.write(
|
|
210
|
+
NewAuditEntry(
|
|
211
|
+
actor=principal.id,
|
|
212
|
+
action="auth.session.create",
|
|
213
|
+
details={"outcome": "success", "method": "GET", "route": CALLBACK_PATH},
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
response = RedirectResponse(
|
|
217
|
+
_app_location(settings, result.return_to), status_code=302, headers=_NO_STORE
|
|
218
|
+
)
|
|
219
|
+
_set_cookie(
|
|
220
|
+
response,
|
|
221
|
+
settings,
|
|
222
|
+
settings.secure_cookie_name,
|
|
223
|
+
created.session_id,
|
|
224
|
+
max_age=settings.absolute_timeout_s,
|
|
225
|
+
same_site=settings.same_site,
|
|
226
|
+
)
|
|
227
|
+
_clear_cookie(response, settings, settings.secure_login_cookie_name)
|
|
228
|
+
return response
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@router.get(
|
|
232
|
+
"/auth/session",
|
|
233
|
+
summary="Describe the browser session",
|
|
234
|
+
responses={
|
|
235
|
+
401: {"model": Problem, "description": "No valid session cookie."},
|
|
236
|
+
**_DISABLED,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
async def session_info(
|
|
240
|
+
request: Request, response: Response, principal: PrincipalDep, auth: AuthDep
|
|
241
|
+
) -> SessionInfo:
|
|
242
|
+
"""Who is signed in, until when, and the CSRF token the web app sends with unsafe
|
|
243
|
+
requests. Requests authenticated by an API key or bearer token get 401."""
|
|
244
|
+
sessions = _manager(auth)
|
|
245
|
+
session_id = auth.authenticator.session_id(request.headers)
|
|
246
|
+
if principal.method != "session" or session_id is None or not principal.session_expires_at:
|
|
247
|
+
raise ProblemError(UNAUTHORIZED, "No browser session: sign in through /auth/login.")
|
|
248
|
+
response.headers.update(_NO_STORE)
|
|
249
|
+
return SessionInfo(
|
|
250
|
+
principal_id=principal.id,
|
|
251
|
+
display_name=principal.display_name,
|
|
252
|
+
csrf_header=sessions.settings.csrf_header,
|
|
253
|
+
csrf_token=sessions.csrf_token(session_id),
|
|
254
|
+
expires_at=principal.session_expires_at,
|
|
255
|
+
idle_timeout_s=sessions.settings.idle_timeout_s,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@router.post(
|
|
260
|
+
LOGOUT_PATH,
|
|
261
|
+
summary="Sign out of the browser session",
|
|
262
|
+
response_model=LogoutResult,
|
|
263
|
+
responses={
|
|
264
|
+
403: {"model": Problem, "description": "CSRF check failed."},
|
|
265
|
+
**_DISABLED,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
async def logout(request: Request, auth: AuthDep) -> Response:
|
|
269
|
+
"""End the session of the cookie (CSRF token required), clear the cookie and return the
|
|
270
|
+
provider's end-session URL for RP-initiated logout. Without a session it only clears the
|
|
271
|
+
cookie."""
|
|
272
|
+
sessions = _manager(auth)
|
|
273
|
+
settings = sessions.settings
|
|
274
|
+
session_id = auth.authenticator.session_id(request.headers)
|
|
275
|
+
logout_url: str | None = None
|
|
276
|
+
if session_id is not None:
|
|
277
|
+
sessions.check_request(session_id, request.method, request.headers)
|
|
278
|
+
ended = await sessions.end(session_id)
|
|
279
|
+
if ended.principal_id is not None:
|
|
280
|
+
annotate(request, actor=ended.principal_id, action="auth.session.delete")
|
|
281
|
+
logger.info("session ended by sign-out", extra={"principal": ended.principal_id})
|
|
282
|
+
logout_url = ended.logout_url
|
|
283
|
+
body = LogoutResult(logout_url=logout_url).model_dump_json()
|
|
284
|
+
response = Response(body, media_type="application/json", headers=_NO_STORE)
|
|
285
|
+
_clear_cookie(response, settings, settings.secure_cookie_name)
|
|
286
|
+
return response
|