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