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,183 @@
|
|
|
1
|
+
"""FastAPI dependencies for authentication and authorization.
|
|
2
|
+
|
|
3
|
+
Routes depend on `require(permission)` (a Domain permission, the Domain taken from the path) or
|
|
4
|
+
`require_installation(permission)`; both authenticate first and return what they decided on:
|
|
5
|
+
|
|
6
|
+
@router.get("/domains/{domain_id}/grants")
|
|
7
|
+
async def list_grants(access: Annotated[Access, Depends(require(Permission.GRANT_MANAGE))]): ...
|
|
8
|
+
|
|
9
|
+
Kind, Operation and branch scoped grants are evaluated when the route names the path parameter
|
|
10
|
+
that carries them (`kind_param`, `operation_param`, `branch_param`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Annotated, cast
|
|
18
|
+
|
|
19
|
+
from fastapi import Depends, Request
|
|
20
|
+
from fastapi.security import HTTPBearer
|
|
21
|
+
|
|
22
|
+
from lorebase_keeper.auth.audit import annotate
|
|
23
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
24
|
+
from lorebase_keeper.auth.principals import Principal
|
|
25
|
+
from lorebase_keeper.auth.rbac import NO_CONTEXT, AccessContext, Authorizer
|
|
26
|
+
from lorebase_keeper.auth.runtime import AuthRuntime
|
|
27
|
+
from lorebase_keeper.client_address import client_address
|
|
28
|
+
from lorebase_keeper.errors import FORBIDDEN, NOT_FOUND, UNAVAILABLE, ProblemError
|
|
29
|
+
|
|
30
|
+
_PRINCIPAL_KEY = "principal"
|
|
31
|
+
_bearer_scheme = HTTPBearer(auto_error=False, description="API key (lbk_...) or OIDC access token.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_auth(request: Request) -> AuthRuntime:
|
|
35
|
+
"""The application's auth components."""
|
|
36
|
+
return cast(AuthRuntime, request.app.state.auth)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
AuthDep = Annotated[AuthRuntime, Depends(get_auth)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_store(auth: AuthRuntime) -> None:
|
|
43
|
+
"""Raise 503 when no database is configured (administration needs one)."""
|
|
44
|
+
if auth.store is None:
|
|
45
|
+
raise ProblemError(UNAVAILABLE, "This operation needs a configured database.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def current_principal(
|
|
49
|
+
request: Request,
|
|
50
|
+
auth: AuthDep,
|
|
51
|
+
_credentials: Annotated[object, Depends(_bearer_scheme)] = None,
|
|
52
|
+
) -> Principal:
|
|
53
|
+
"""Authenticate the request (once) or answer 401."""
|
|
54
|
+
state = request.scope.setdefault("state", {})
|
|
55
|
+
principal = state.get(_PRINCIPAL_KEY)
|
|
56
|
+
if principal is None:
|
|
57
|
+
principal = await auth.authenticator.authenticate(
|
|
58
|
+
request.headers, client_address(request), request.method
|
|
59
|
+
)
|
|
60
|
+
state[_PRINCIPAL_KEY] = principal
|
|
61
|
+
annotate(request, actor=principal.id, principal_type=principal.type.value)
|
|
62
|
+
return cast(Principal, principal)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
PrincipalDep = Annotated[Principal, Depends(current_principal)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Access:
|
|
70
|
+
"""An authorized request: who, in which Domain (none for installation-level), what for."""
|
|
71
|
+
|
|
72
|
+
principal: Principal
|
|
73
|
+
domain_id: str | None
|
|
74
|
+
permission: Permission
|
|
75
|
+
kinds: frozenset[str] | None = None
|
|
76
|
+
"""Set when only grants narrowed to these Kinds allowed the request (`require` with
|
|
77
|
+
`kind_limited`): the response must not tell about other Kinds."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def require(
|
|
81
|
+
permission: Permission,
|
|
82
|
+
*,
|
|
83
|
+
domain_param: str = "domain_id",
|
|
84
|
+
kind_param: str | None = None,
|
|
85
|
+
operation_param: str | None = None,
|
|
86
|
+
branch_param: str | None = None,
|
|
87
|
+
kind_limited: bool = False,
|
|
88
|
+
) -> Callable[..., Awaitable[Access]]:
|
|
89
|
+
"""Dependency: the caller holds `permission` in the Domain named by the path.
|
|
90
|
+
|
|
91
|
+
With `kind_limited`, grants narrowed to Kinds count too when Kinds are invisible to such
|
|
92
|
+
callers (`keeper.auth.authz.invisible_kinds`, design 13.3): for reads that tell nothing
|
|
93
|
+
about Kinds (the Domain, its branches and tags), which a caller limited to Kinds needs to
|
|
94
|
+
find its way. `Access.kinds` then names them.
|
|
95
|
+
|
|
96
|
+
Answers 401 without a valid credential and 403, naming the missing permission, otherwise.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
async def dependency(request: Request, principal: PrincipalDep, auth: AuthDep) -> Access:
|
|
100
|
+
params = request.path_params
|
|
101
|
+
domain_id = str(params[domain_param])
|
|
102
|
+
context = AccessContext(
|
|
103
|
+
kind=_param(params, kind_param),
|
|
104
|
+
operation=_param(params, operation_param),
|
|
105
|
+
branch=_param(params, branch_param),
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
return await authorize(request, auth, principal, permission, domain_id, context)
|
|
109
|
+
except ProblemError as error:
|
|
110
|
+
if not kind_limited or error.status_code != 403:
|
|
111
|
+
raise
|
|
112
|
+
kinds = await limited_kinds(auth.authorizer, principal, permission, domain_id)
|
|
113
|
+
if not kinds:
|
|
114
|
+
raise
|
|
115
|
+
return Access(principal, domain_id, permission, kinds)
|
|
116
|
+
|
|
117
|
+
return dependency
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def authorize(
|
|
121
|
+
request: Request,
|
|
122
|
+
auth: AuthRuntime,
|
|
123
|
+
principal: Principal,
|
|
124
|
+
permission: Permission,
|
|
125
|
+
domain_id: str,
|
|
126
|
+
context: AccessContext = NO_CONTEXT,
|
|
127
|
+
) -> Access:
|
|
128
|
+
"""Check `permission` in a Domain for a context known only inside the route (a branch or
|
|
129
|
+
Operation named in the body). Answers 403 naming the missing permission."""
|
|
130
|
+
annotate(request, domain_id=domain_id, permission=permission.value)
|
|
131
|
+
if not await auth.authorizer.allows(principal, permission, domain_id, context):
|
|
132
|
+
raise await refusal(auth.authorizer, principal, permission, domain_id)
|
|
133
|
+
return Access(principal=principal, domain_id=domain_id, permission=permission)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def limited_kinds(
|
|
137
|
+
authorizer: Authorizer, principal: Principal, permission: Permission, domain_id: str
|
|
138
|
+
) -> frozenset[str]:
|
|
139
|
+
"""Kinds that grants narrowed to Kinds give `permission` on, when such Kinds are invisible
|
|
140
|
+
to the caller (design 13.3); empty otherwise."""
|
|
141
|
+
if not authorizer.invisible_kinds:
|
|
142
|
+
return frozenset()
|
|
143
|
+
return await authorizer.granted_kinds(principal, permission, domain_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def refusal(
|
|
147
|
+
authorizer: Authorizer, principal: Principal, permission: Permission, domain_id: str
|
|
148
|
+
) -> ProblemError:
|
|
149
|
+
"""The problem for a caller who lacks `permission` in a Domain: 404 when the Domain does not
|
|
150
|
+
exist and the caller may be told so (see `Authorizer.reveals_missing_domain`), else 403."""
|
|
151
|
+
if await authorizer.reveals_missing_domain(principal, domain_id):
|
|
152
|
+
return ProblemError(NOT_FOUND, f"Domain {domain_id!r} not found.")
|
|
153
|
+
return missing_permission(permission, domain_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def missing_permission(permission: Permission, domain_id: str) -> ProblemError:
|
|
157
|
+
"""The 403 problem naming the permission a caller lacks in a Domain."""
|
|
158
|
+
return ProblemError(
|
|
159
|
+
FORBIDDEN,
|
|
160
|
+
f"Missing permission {permission.value} in Domain {domain_id}.",
|
|
161
|
+
extensions={"permission": permission.value, "domain": domain_id},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def require_installation(permission: Permission) -> Callable[..., Awaitable[Access]]:
|
|
166
|
+
"""Dependency: the caller holds an installation-level `permission`."""
|
|
167
|
+
|
|
168
|
+
async def dependency(request: Request, principal: PrincipalDep, auth: AuthDep) -> Access:
|
|
169
|
+
annotate(request, permission=permission.value)
|
|
170
|
+
if not await auth.authorizer.allows_installation(principal, permission):
|
|
171
|
+
raise ProblemError(
|
|
172
|
+
FORBIDDEN,
|
|
173
|
+
f"Missing permission {permission.value}.",
|
|
174
|
+
extensions={"permission": permission.value},
|
|
175
|
+
)
|
|
176
|
+
return Access(principal=principal, domain_id=None, permission=permission)
|
|
177
|
+
|
|
178
|
+
return dependency
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _param(params: dict[str, object], name: str | None) -> str | None:
|
|
182
|
+
value = params.get(name) if name else None
|
|
183
|
+
return None if value is None else str(value)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Keys derived from `keeper.auth.sessions.encryption_key` (`lorebase_store.keyring`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lorebase_store.keyring import Keyring as _Keyring
|
|
6
|
+
|
|
7
|
+
NAMESPACE = "keeper"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Keyring(_Keyring):
|
|
11
|
+
"""The session keyring: keys derived under the `keeper` namespace."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, secret: str) -> None:
|
|
14
|
+
super().__init__(secret, namespace=NAMESPACE)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Bearer token validation against any OpenID Connect provider (design 13.2).
|
|
2
|
+
|
|
3
|
+
Tokens are JWTs checked against the provider's published signing keys (JWKS): signature,
|
|
4
|
+
issuer, audience, expiry and not-before, with the configured clock skew. Keys are fetched with
|
|
5
|
+
httpx and cached; an unknown key id triggers at most one refresh per `jwks_min_refresh_s`.
|
|
6
|
+
|
|
7
|
+
PyJWT (with `cryptography`) does the token work. python-jose was not chosen: it is barely
|
|
8
|
+
maintained and has had several security advisories; PyJWT is maintained, small and parses JWKS
|
|
9
|
+
natively. No provider SDK is involved: discovery and key download are plain HTTP.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import hmac
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
import jwt
|
|
24
|
+
from jwt import PyJWK
|
|
25
|
+
|
|
26
|
+
from lorebase_keeper.auth.config import OidcSettings, insecure_url_problem
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("lorebase.keeper.auth.oidc")
|
|
29
|
+
|
|
30
|
+
DISCOVERY_PATH = "/.well-known/openid-configuration"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidTokenError(Exception):
|
|
34
|
+
"""The token is not acceptable. The message is for logs, never for the client."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProviderUnavailableError(Exception):
|
|
38
|
+
"""The provider's keys cannot be obtained, so no token can be judged."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class OidcClaims:
|
|
43
|
+
"""What Keeper needs from a validated token."""
|
|
44
|
+
|
|
45
|
+
subject: str
|
|
46
|
+
display_name: str
|
|
47
|
+
groups: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JwksCache:
|
|
51
|
+
"""Signing keys of the provider, refreshed after a time to live or for an unknown key id."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
settings: OidcSettings,
|
|
56
|
+
http: httpx.AsyncClient,
|
|
57
|
+
clock: Callable[[], float] = time.monotonic,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._settings = settings
|
|
60
|
+
self._http = http
|
|
61
|
+
self._clock = clock
|
|
62
|
+
self._keys: dict[str, PyJWK] = {}
|
|
63
|
+
self._fetched_at: float | None = None
|
|
64
|
+
self._attempted_at: float | None = None
|
|
65
|
+
self._jwks_uri = settings.jwks_uri
|
|
66
|
+
self._lock = asyncio.Lock()
|
|
67
|
+
|
|
68
|
+
async def key_for(self, kid: str | None) -> PyJWK:
|
|
69
|
+
"""The signing key with this id (the only key when the token names none)."""
|
|
70
|
+
if self._is_stale():
|
|
71
|
+
await self._refresh()
|
|
72
|
+
key = self._find(kid)
|
|
73
|
+
if key is None:
|
|
74
|
+
await self._refresh() # the provider may have rotated its keys
|
|
75
|
+
key = self._find(kid)
|
|
76
|
+
if key is None:
|
|
77
|
+
if not self._keys:
|
|
78
|
+
raise ProviderUnavailableError("signing keys unavailable")
|
|
79
|
+
raise InvalidTokenError(f"no signing key {kid!r}")
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
def _find(self, kid: str | None) -> PyJWK | None:
|
|
83
|
+
if kid is not None:
|
|
84
|
+
return self._keys.get(kid)
|
|
85
|
+
return next(iter(self._keys.values())) if len(self._keys) == 1 else None
|
|
86
|
+
|
|
87
|
+
def _is_stale(self) -> bool:
|
|
88
|
+
return (
|
|
89
|
+
self._fetched_at is None
|
|
90
|
+
or self._clock() - self._fetched_at >= self._settings.jwks_cache_ttl_s
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _refresh(self) -> None:
|
|
94
|
+
"""Fetch the keys, unless an attempt was made less than `jwks_min_refresh_s` ago.
|
|
95
|
+
|
|
96
|
+
On failure the previous keys stay in use and the next attempt waits for the same delay,
|
|
97
|
+
so an unreachable provider is not hammered.
|
|
98
|
+
"""
|
|
99
|
+
async with self._lock:
|
|
100
|
+
now = self._clock()
|
|
101
|
+
if (
|
|
102
|
+
self._attempted_at is not None
|
|
103
|
+
and now - self._attempted_at < self._settings.jwks_min_refresh_s
|
|
104
|
+
):
|
|
105
|
+
return
|
|
106
|
+
self._attempted_at = now
|
|
107
|
+
try:
|
|
108
|
+
document = await self._fetch_jwks()
|
|
109
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
110
|
+
logger.warning("cannot fetch signing keys: %s", type(exc).__name__)
|
|
111
|
+
return
|
|
112
|
+
self._keys = _parse_keys(document)
|
|
113
|
+
self._fetched_at = now
|
|
114
|
+
|
|
115
|
+
async def _fetch_jwks(self) -> Mapping[str, Any]:
|
|
116
|
+
if self._jwks_uri is None:
|
|
117
|
+
self._jwks_uri = await self._discover()
|
|
118
|
+
response = await self._http.get(self._jwks_uri)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
document = response.json()
|
|
121
|
+
if not isinstance(document, dict) or not isinstance(document.get("keys"), list):
|
|
122
|
+
raise ValueError("malformed JWKS document")
|
|
123
|
+
return document
|
|
124
|
+
|
|
125
|
+
async def _discover(self) -> str:
|
|
126
|
+
issuer = self._settings.issuer or ""
|
|
127
|
+
response = await self._http.get(issuer.rstrip("/") + DISCOVERY_PATH)
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
document = response.json()
|
|
130
|
+
if not isinstance(document, dict) or document.get("issuer") != issuer:
|
|
131
|
+
raise ValueError("discovery document does not match the configured issuer")
|
|
132
|
+
uri = document.get("jwks_uri")
|
|
133
|
+
if not isinstance(uri, str) or not uri:
|
|
134
|
+
raise ValueError("discovery document has no jwks_uri")
|
|
135
|
+
problem = insecure_url_problem(uri, self._settings.allow_insecure)
|
|
136
|
+
if problem:
|
|
137
|
+
logger.warning("discovered jwks_uri %r refused: %s", uri, problem)
|
|
138
|
+
raise ValueError("discovered jwks_uri is not acceptable")
|
|
139
|
+
return uri
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_keys(document: Mapping[str, Any]) -> dict[str, PyJWK]:
|
|
143
|
+
keys: dict[str, PyJWK] = {}
|
|
144
|
+
for index, entry in enumerate(document["keys"]):
|
|
145
|
+
if not isinstance(entry, dict) or entry.get("use", "sig") != "sig":
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
key = PyJWK(entry)
|
|
149
|
+
except jwt.PyJWTError:
|
|
150
|
+
logger.info("skipping unsupported signing key %s", entry.get("kid", index))
|
|
151
|
+
continue
|
|
152
|
+
keys[str(entry.get("kid", f"#{index}"))] = key
|
|
153
|
+
return keys
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class OidcVerifier:
|
|
157
|
+
"""Validates bearer tokens and extracts the claims Keeper uses."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, settings: OidcSettings, jwks: JwksCache) -> None:
|
|
160
|
+
self._settings = settings
|
|
161
|
+
self._jwks = jwks
|
|
162
|
+
|
|
163
|
+
async def verify(self, token: str) -> OidcClaims:
|
|
164
|
+
"""Claims of a valid token; `InvalidTokenError` or `ProviderUnavailableError` otherwise."""
|
|
165
|
+
claims = await self._decode(token, self._settings.audience, ("exp", "iss", "aud"))
|
|
166
|
+
return self._claims(claims)
|
|
167
|
+
|
|
168
|
+
async def verify_id_token(
|
|
169
|
+
self,
|
|
170
|
+
token: str,
|
|
171
|
+
*,
|
|
172
|
+
client_id: str,
|
|
173
|
+
nonce: str | None = None,
|
|
174
|
+
nonce_required: bool = True,
|
|
175
|
+
) -> OidcClaims:
|
|
176
|
+
"""Claims of a valid ID token issued to `client_id` (OpenID Connect Core 3.1.3.7).
|
|
177
|
+
|
|
178
|
+
The audience must contain the client id and, with several audiences, `azp` must name it.
|
|
179
|
+
`nonce`, when given, must equal the token's. At sign-in the token must carry it; a
|
|
180
|
+
refreshed ID token (`nonce_required=False`) may omit it but, if present, must carry the
|
|
181
|
+
sign-in's nonce (Core 12.2).
|
|
182
|
+
"""
|
|
183
|
+
claims = await self._decode(token, (client_id,), ("exp", "iat", "iss", "aud"))
|
|
184
|
+
audience = claims.get("aud")
|
|
185
|
+
if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client_id:
|
|
186
|
+
raise InvalidTokenError("azp does not name this client")
|
|
187
|
+
if nonce is not None:
|
|
188
|
+
received = claims.get("nonce")
|
|
189
|
+
if received is None and not nonce_required:
|
|
190
|
+
return self._claims(claims)
|
|
191
|
+
if not isinstance(received, str) or not hmac.compare_digest(received, nonce):
|
|
192
|
+
raise InvalidTokenError("nonce mismatch")
|
|
193
|
+
return self._claims(claims)
|
|
194
|
+
|
|
195
|
+
async def _decode(
|
|
196
|
+
self, token: str, audience: Sequence[str], required: Sequence[str]
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
settings = self._settings
|
|
199
|
+
try:
|
|
200
|
+
header = jwt.get_unverified_header(token)
|
|
201
|
+
algorithm = header.get("alg")
|
|
202
|
+
if algorithm not in settings.algorithms:
|
|
203
|
+
raise InvalidTokenError(f"algorithm {algorithm!r} is not accepted")
|
|
204
|
+
key = await self._jwks.key_for(header.get("kid"))
|
|
205
|
+
claims: dict[str, Any] = jwt.decode(
|
|
206
|
+
token,
|
|
207
|
+
key.key,
|
|
208
|
+
algorithms=[algorithm],
|
|
209
|
+
audience=list(audience),
|
|
210
|
+
issuer=settings.issuer,
|
|
211
|
+
leeway=settings.clock_skew_s,
|
|
212
|
+
options={"require": [*required, settings.subject_claim]},
|
|
213
|
+
)
|
|
214
|
+
except jwt.PyJWTError as exc:
|
|
215
|
+
raise InvalidTokenError(f"{type(exc).__name__}: {exc}") from None
|
|
216
|
+
return claims
|
|
217
|
+
|
|
218
|
+
def _claims(self, claims: Mapping[str, Any]) -> OidcClaims:
|
|
219
|
+
settings = self._settings
|
|
220
|
+
subject = claims.get(settings.subject_claim)
|
|
221
|
+
if not isinstance(subject, str) or not subject:
|
|
222
|
+
raise InvalidTokenError("subject claim is not a non-empty string")
|
|
223
|
+
return OidcClaims(
|
|
224
|
+
subject=subject,
|
|
225
|
+
display_name=self._display_name(claims, subject),
|
|
226
|
+
groups=_groups(claims.get(settings.groups_claim)),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _display_name(self, claims: Mapping[str, Any], subject: str) -> str:
|
|
230
|
+
for name in self._settings.name_claims:
|
|
231
|
+
value = claims.get(name)
|
|
232
|
+
if isinstance(value, str) and value.strip():
|
|
233
|
+
return value.strip()
|
|
234
|
+
return subject
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _groups(value: object) -> tuple[str, ...]:
|
|
238
|
+
if isinstance(value, str):
|
|
239
|
+
return (value,)
|
|
240
|
+
if isinstance(value, list):
|
|
241
|
+
return tuple(str(item) for item in value if isinstance(item, str))
|
|
242
|
+
return ()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Permissions and the built-in Domain roles (design 10.2).
|
|
2
|
+
|
|
3
|
+
A role is a named set of permissions. Roles are granted per Domain (see `rbac`); installations
|
|
4
|
+
may add roles built from these permissions in `keeper.auth.authz.custom_roles`. The built-in
|
|
5
|
+
roles cannot be redefined.
|
|
6
|
+
|
|
7
|
+
Role semantics where the design table leaves room:
|
|
8
|
+
|
|
9
|
+
- every role except `admin` is additive on top of read access, so a principal holding only
|
|
10
|
+
`editor` can still read what it edits;
|
|
11
|
+
- `maintainer` includes `editor` and `reviewer`, not `modeler`;
|
|
12
|
+
- `editor` and `modeler` open Proposals (`proposal.create`) from the branches they change;
|
|
13
|
+
`editor` (and so `maintainer`) also triggers Source synchronizations (`source.sync`), whose
|
|
14
|
+
outcome goes through the Source's merge policy;
|
|
15
|
+
`editor` uploads documents for extraction (`document.write`) and reads them
|
|
16
|
+
(`document.read`); `reviewer` reads documents too, since extraction items quote them;
|
|
17
|
+
documents are raw, unmasked input, so `viewer`, `explorer` and `agent-reader` do not read
|
|
18
|
+
them;
|
|
19
|
+
`reviewer` reviews them and resolves Conflicts; merging needs `proposal.merge`
|
|
20
|
+
(`maintainer`), into protected and unprotected branches alike;
|
|
21
|
+
- `admin` holds every permission of the Domain (it is the Domain owner role); only it may
|
|
22
|
+
delete the Domain (`domain.delete`), read values of sensitive Attributes (`pii.read`,
|
|
23
|
+
`confidential.read`, `restricted.read`; other callers see them masked), erase vault
|
|
24
|
+
values (`pii.erase`) and feed values of vault Attributes through the built-in imports and
|
|
25
|
+
Source synchronizations (`pii.import`, see `services.pii_import`); custom roles grant the
|
|
26
|
+
clearances to others. `admin` also reads the agent traces of other principals in its Domain
|
|
27
|
+
(`trace.read`) and the query plans of traced requests (`trace.explain`); every principal
|
|
28
|
+
reads its own traces. `domain.create` is installation-level: installation administrators
|
|
29
|
+
hold it, and so do admins of any Domain;
|
|
30
|
+
- `vault.manage` (vault key rotation and its status) is held by installation administrators
|
|
31
|
+
only: no role, built in or custom, contains it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Iterable, Mapping
|
|
37
|
+
from enum import StrEnum
|
|
38
|
+
from types import MappingProxyType
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Permission(StrEnum):
|
|
42
|
+
"""What a route may require. Names are stable: they appear in 403 responses."""
|
|
43
|
+
|
|
44
|
+
SCHEMA_READ = "schema.read"
|
|
45
|
+
DATA_READ = "data.read"
|
|
46
|
+
VIEWS_WRITE = "views.write"
|
|
47
|
+
BRANCH_CREATE = "branch.create"
|
|
48
|
+
OPERATION_EXECUTE = "operation.execute"
|
|
49
|
+
OPERATION_EXECUTE_CUSTOM = "operation.execute_custom"
|
|
50
|
+
SCHEMA_WRITE = "schema.write"
|
|
51
|
+
SOURCE_MAP = "source.map"
|
|
52
|
+
PROPOSAL_CREATE = "proposal.create"
|
|
53
|
+
PROPOSAL_REVIEW = "proposal.review"
|
|
54
|
+
CONFLICT_RESOLVE = "conflict.resolve"
|
|
55
|
+
PROPOSAL_MERGE = "proposal.merge"
|
|
56
|
+
SOURCE_MANAGE = "source.manage"
|
|
57
|
+
SOURCE_SYNC = "source.sync"
|
|
58
|
+
DOCUMENT_READ = "document.read"
|
|
59
|
+
DOCUMENT_WRITE = "document.write"
|
|
60
|
+
JOB_MANAGE = "job.manage"
|
|
61
|
+
GRANT_MANAGE = "grant.manage"
|
|
62
|
+
APIKEY_MANAGE = "apikey.manage"
|
|
63
|
+
DOMAIN_SETTINGS = "domain.settings"
|
|
64
|
+
DOMAIN_DELETE = "domain.delete"
|
|
65
|
+
DOMAIN_CREATE = "domain.create"
|
|
66
|
+
AUDIT_READ = "audit.read"
|
|
67
|
+
PRINCIPAL_READ = "principal.read"
|
|
68
|
+
PII_READ = "pii.read"
|
|
69
|
+
CONFIDENTIAL_READ = "confidential.read"
|
|
70
|
+
RESTRICTED_READ = "restricted.read"
|
|
71
|
+
PII_ERASE = "pii.erase"
|
|
72
|
+
PII_IMPORT = "pii.import"
|
|
73
|
+
TRACE_READ = "trace.read"
|
|
74
|
+
TRACE_EXPLAIN = "trace.explain"
|
|
75
|
+
VAULT_MANAGE = "vault.manage"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
INSTALLATION_ADMIN_PERMISSIONS = frozenset({Permission.VAULT_MANAGE})
|
|
79
|
+
"""Installation-level permissions only installation administrators hold: no role contains
|
|
80
|
+
them."""
|
|
81
|
+
|
|
82
|
+
INSTALLATION_PERMISSIONS = (
|
|
83
|
+
frozenset({Permission.PRINCIPAL_READ, Permission.DOMAIN_CREATE})
|
|
84
|
+
| INSTALLATION_ADMIN_PERMISSIONS
|
|
85
|
+
)
|
|
86
|
+
"""Permissions that are not about one Domain: installation administrators hold them, and so do
|
|
87
|
+
principals who hold them through a role in at least one Domain (except
|
|
88
|
+
`INSTALLATION_ADMIN_PERMISSIONS`)."""
|
|
89
|
+
|
|
90
|
+
_VIEWER = frozenset({Permission.SCHEMA_READ, Permission.DATA_READ})
|
|
91
|
+
_EDITOR = _VIEWER | {
|
|
92
|
+
Permission.BRANCH_CREATE,
|
|
93
|
+
Permission.OPERATION_EXECUTE,
|
|
94
|
+
Permission.PROPOSAL_CREATE,
|
|
95
|
+
Permission.SOURCE_SYNC,
|
|
96
|
+
Permission.DOCUMENT_READ,
|
|
97
|
+
Permission.DOCUMENT_WRITE,
|
|
98
|
+
}
|
|
99
|
+
_REVIEWER = _VIEWER | {
|
|
100
|
+
Permission.PROPOSAL_REVIEW,
|
|
101
|
+
Permission.CONFLICT_RESOLVE,
|
|
102
|
+
Permission.DOCUMENT_READ,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
BUILTIN_ROLES: Mapping[str, frozenset[Permission]] = MappingProxyType(
|
|
106
|
+
{
|
|
107
|
+
"viewer": _VIEWER,
|
|
108
|
+
"explorer": _VIEWER | {Permission.VIEWS_WRITE},
|
|
109
|
+
"editor": _EDITOR,
|
|
110
|
+
"operator": _VIEWER | {Permission.OPERATION_EXECUTE_CUSTOM},
|
|
111
|
+
"modeler": _VIEWER
|
|
112
|
+
| {Permission.SCHEMA_WRITE, Permission.SOURCE_MAP, Permission.PROPOSAL_CREATE},
|
|
113
|
+
"reviewer": _REVIEWER,
|
|
114
|
+
"maintainer": _EDITOR
|
|
115
|
+
| _REVIEWER
|
|
116
|
+
| {Permission.PROPOSAL_MERGE, Permission.SOURCE_MANAGE, Permission.JOB_MANAGE},
|
|
117
|
+
"admin": frozenset(Permission) - INSTALLATION_ADMIN_PERMISSIONS,
|
|
118
|
+
"agent-reader": _VIEWER,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def permission_names() -> frozenset[str]:
|
|
124
|
+
"""Every permission name."""
|
|
125
|
+
return frozenset(p.value for p in Permission)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def role_catalog(
|
|
129
|
+
custom: Mapping[str, Iterable[str]] | None = None,
|
|
130
|
+
) -> dict[str, frozenset[Permission]]:
|
|
131
|
+
"""Built-in roles plus `custom` ones. Unknown permissions raise `ValueError`."""
|
|
132
|
+
catalog = dict(BUILTIN_ROLES)
|
|
133
|
+
for name, permissions in (custom or {}).items():
|
|
134
|
+
if name in BUILTIN_ROLES:
|
|
135
|
+
raise ValueError(f"role {name!r} is built in and cannot be redefined")
|
|
136
|
+
granted = frozenset(Permission(p) for p in permissions)
|
|
137
|
+
reserved = granted & INSTALLATION_ADMIN_PERMISSIONS
|
|
138
|
+
if reserved:
|
|
139
|
+
names = ", ".join(sorted(p.value for p in reserved))
|
|
140
|
+
raise ValueError(f"role {name!r}: {names} is held by installation administrators only")
|
|
141
|
+
catalog[name] = granted
|
|
142
|
+
return catalog
|