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