sonnet-auth 0.1.0__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.
- sonnet_auth/__init__.py +92 -0
- sonnet_auth/authz.py +221 -0
- sonnet_auth/context.py +133 -0
- sonnet_auth/jwt_validator.py +136 -0
- sonnet_auth/policy_engine.py +230 -0
- sonnet_auth/settings.py +254 -0
- sonnet_auth-0.1.0.dist-info/METADATA +124 -0
- sonnet_auth-0.1.0.dist-info/RECORD +10 -0
- sonnet_auth-0.1.0.dist-info/WHEEL +5 -0
- sonnet_auth-0.1.0.dist-info/top_level.txt +1 -0
sonnet_auth/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""sonnet-auth -- JWT/JWKS authentication and Cedar authorization for sonnet-server.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
AuthN (JWT/JWKS):
|
|
6
|
+
AuthSettings -- settings model with resolve(env_prefix, seed)
|
|
7
|
+
AuthnConfig -- JWT/JWKS config (Pydantic, frozen)
|
|
8
|
+
AuthMode -- jwt_verifier | oauth_proxy
|
|
9
|
+
JwtCredentialValidator -- implements sonnet-server CredentialValidator protocol
|
|
10
|
+
build_auth_context -- JWT claims -> AuthContext
|
|
11
|
+
resolve_attributes -- JWT claims -> Cedar principal attributes
|
|
12
|
+
resolve_dot_path -- nested claim extraction
|
|
13
|
+
|
|
14
|
+
AuthZ (Cedar, requires sonnet-auth[cedar]):
|
|
15
|
+
AuthzDeniedError -- raised by check_authz on deny; transports map to 403
|
|
16
|
+
check_authz -- raises AuthzDeniedError on deny, no-op if disabled
|
|
17
|
+
filter_authz -- returns permitted resource IDs
|
|
18
|
+
resolve_principal_attrs -- resolves JWT claims + enrichment -> Cedar attrs
|
|
19
|
+
set_resource_attribute_resolver -- register domain-specific resolver
|
|
20
|
+
set_principal_enricher -- register principal enricher (org hierarchy, etc.)
|
|
21
|
+
ResourceAttributeResolver -- protocol for resource attr lookup
|
|
22
|
+
PrincipalEnricher -- protocol for principal attr enrichment
|
|
23
|
+
PolicyEngine -- Cedar policy evaluator (check, filter, reload)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
# AuthN -- always available
|
|
31
|
+
from sonnet_auth.context import build_auth_context, resolve_attributes, resolve_dot_path
|
|
32
|
+
from sonnet_auth.jwt_validator import JwtCredentialValidator
|
|
33
|
+
from sonnet_auth.settings import AuthMode, AuthnConfig, AuthSettings
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
# Expose Cedar types for static analysis tools (mypy, pyright, IDEs).
|
|
37
|
+
# These are lazily imported at runtime to avoid cedarpy dependency
|
|
38
|
+
# when only authn is used.
|
|
39
|
+
from sonnet_auth.authz import (
|
|
40
|
+
AuthzDeniedError,
|
|
41
|
+
PrincipalEnricher,
|
|
42
|
+
ResourceAttributeResolver,
|
|
43
|
+
check_authz,
|
|
44
|
+
filter_authz,
|
|
45
|
+
resolve_principal_attrs,
|
|
46
|
+
set_principal_enricher,
|
|
47
|
+
set_resource_attribute_resolver,
|
|
48
|
+
)
|
|
49
|
+
from sonnet_auth.policy_engine import PolicyEngine
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# AuthN
|
|
53
|
+
"AuthMode",
|
|
54
|
+
"AuthnConfig",
|
|
55
|
+
"AuthSettings",
|
|
56
|
+
"JwtCredentialValidator",
|
|
57
|
+
"build_auth_context",
|
|
58
|
+
"resolve_attributes",
|
|
59
|
+
"resolve_dot_path",
|
|
60
|
+
# AuthZ (lazy -- imported on use to avoid cedarpy import at module level)
|
|
61
|
+
"AuthzDeniedError",
|
|
62
|
+
"PolicyEngine",
|
|
63
|
+
"PrincipalEnricher",
|
|
64
|
+
"ResourceAttributeResolver",
|
|
65
|
+
"check_authz",
|
|
66
|
+
"filter_authz",
|
|
67
|
+
"resolve_principal_attrs",
|
|
68
|
+
"set_principal_enricher",
|
|
69
|
+
"set_resource_attribute_resolver",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def __getattr__(name: str):
|
|
74
|
+
"""Lazy imports for Cedar authz -- avoids cedarpy import when only using authn."""
|
|
75
|
+
if name == "PolicyEngine":
|
|
76
|
+
from sonnet_auth.policy_engine import PolicyEngine
|
|
77
|
+
|
|
78
|
+
return PolicyEngine
|
|
79
|
+
if name in (
|
|
80
|
+
"AuthzDeniedError",
|
|
81
|
+
"PrincipalEnricher",
|
|
82
|
+
"ResourceAttributeResolver",
|
|
83
|
+
"check_authz",
|
|
84
|
+
"filter_authz",
|
|
85
|
+
"resolve_principal_attrs",
|
|
86
|
+
"set_principal_enricher",
|
|
87
|
+
"set_resource_attribute_resolver",
|
|
88
|
+
):
|
|
89
|
+
from sonnet_auth import authz
|
|
90
|
+
|
|
91
|
+
return getattr(authz, name)
|
|
92
|
+
raise AttributeError(f"module 'sonnet_auth' has no attribute {name!r}")
|
sonnet_auth/authz.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""AuthZ helpers — one-liner authorization for REST handlers and MCP tools.
|
|
2
|
+
|
|
3
|
+
This module is the single entry point for authorization checks. It hides
|
|
4
|
+
all internal plumbing (claim resolution, principal enrichment, Cedar
|
|
5
|
+
evaluation) behind two stable functions:
|
|
6
|
+
|
|
7
|
+
check_authz() — raises 403 on deny, no-op if authz disabled
|
|
8
|
+
filter_authz() — returns permitted resource IDs, returns all if disabled
|
|
9
|
+
|
|
10
|
+
Resource attribute resolution is pluggable via ``ResourceAttributeResolver``.
|
|
11
|
+
Register the consumer's implementation once at startup:
|
|
12
|
+
|
|
13
|
+
from sonnet_auth.authz import set_resource_attribute_resolver
|
|
14
|
+
set_resource_attribute_resolver(PropertiesCacheResolver())
|
|
15
|
+
|
|
16
|
+
The caller API (check_authz / filter_authz) never changes regardless of the
|
|
17
|
+
resolver, enrichment, or Cedar evaluation happening behind the scenes.
|
|
18
|
+
|
|
19
|
+
Internal flow:
|
|
20
|
+
1. get_policy_engine() → engine or None (no-op if disabled)
|
|
21
|
+
2. get_current_auth() → AuthContext (identity from JWT)
|
|
22
|
+
3. resolve_attributes(claims, claim_map) → base attrs from token
|
|
23
|
+
4. PrincipalEnricher.enrich() → enriched attrs (if registered)
|
|
24
|
+
5. resolver.resolve(type, id) → resource attrs
|
|
25
|
+
6. engine.check() / engine.filter() → Cedar evaluation
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, Protocol
|
|
31
|
+
|
|
32
|
+
from loguru import logger
|
|
33
|
+
|
|
34
|
+
from sonnet_auth.context import resolve_attributes
|
|
35
|
+
from sonnet_auth.policy_engine import PolicyEngine
|
|
36
|
+
from sonnet_server.guards import get_current_auth
|
|
37
|
+
from sonnet_server.services.registry import get_service_registry
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# ResourceAttributeResolver — pluggable resource attribute lookup
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResourceAttributeResolver(Protocol):
|
|
45
|
+
"""Maps a resource type + ID to Cedar entity attributes.
|
|
46
|
+
|
|
47
|
+
Implement this protocol in each consumer and register it at startup
|
|
48
|
+
via ``set_resource_attribute_resolver()``. coco-rag's implementation
|
|
49
|
+
uses ``PropertiesCache`` backed by the PostgreSQL settings table.
|
|
50
|
+
|
|
51
|
+
Returns an empty dict when no attributes are found — Cedar policies
|
|
52
|
+
that require ABAC attributes will deny by default.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def resolve(self, resource_type: str, resource_id: str) -> dict[str, Any]: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_resource_attribute_resolver: ResourceAttributeResolver | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_resource_attribute_resolver(resolver: ResourceAttributeResolver) -> None:
|
|
62
|
+
"""Register the resource attribute resolver. Call once at startup."""
|
|
63
|
+
global _resource_attribute_resolver # noqa: PLW0603
|
|
64
|
+
_resource_attribute_resolver = resolver
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# PrincipalEnricher — pluggable principal attribute enrichment
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PrincipalEnricher(Protocol):
|
|
73
|
+
"""Enriches resolved JWT claim attributes before Cedar evaluation.
|
|
74
|
+
|
|
75
|
+
Implement this protocol to add org hierarchy, group expansion, or remote
|
|
76
|
+
attribute lookups. Register it via ``set_principal_enricher()``.
|
|
77
|
+
|
|
78
|
+
The default (no enricher registered) is a no-op — attrs pass through
|
|
79
|
+
unchanged.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def enrich(self, user_id: str, attrs: dict[str, Any]) -> dict[str, Any]: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_principal_enricher: PrincipalEnricher | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_principal_enricher(enricher: PrincipalEnricher) -> None:
|
|
89
|
+
"""Register the principal enricher. Call once at startup."""
|
|
90
|
+
global _principal_enricher # noqa: PLW0603
|
|
91
|
+
_principal_enricher = enricher
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Engine resolution
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_policy_engine() -> PolicyEngine | None:
|
|
100
|
+
"""Return PolicyEngine if authz enabled, None if not configured."""
|
|
101
|
+
try:
|
|
102
|
+
return get_service_registry().get(PolicyEngine)
|
|
103
|
+
except KeyError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_principal_attrs(engine: PolicyEngine, user_id: str, claims: dict) -> dict[str, Any]:
|
|
108
|
+
"""Resolve claim_map attrs and run principal enrichment.
|
|
109
|
+
|
|
110
|
+
Both ``claim_map`` and ``auto_map_claims`` come from the engine (set at
|
|
111
|
+
construction from AuthnConfig). No DB access — safe in tests and CI.
|
|
112
|
+
|
|
113
|
+
Public — used by the /authz/check dry-run endpoint.
|
|
114
|
+
"""
|
|
115
|
+
attrs = resolve_attributes(claims, engine.claim_map, auto_map_claims=engine.auto_map_claims)
|
|
116
|
+
if _principal_enricher is not None:
|
|
117
|
+
attrs = _principal_enricher.enrich(user_id, attrs)
|
|
118
|
+
logger.trace("Principal attrs resolved: user={} attrs={}", user_id, attrs)
|
|
119
|
+
return attrs
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Resource attribute resolution
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_resource_attrs(resource_type: str, resource_id: str, resource_attrs: dict[str, Any] | None) -> dict[str, Any]:
|
|
128
|
+
"""Resolve resource attributes — explicit attrs take priority, then resolver."""
|
|
129
|
+
if resource_attrs is not None:
|
|
130
|
+
return resource_attrs
|
|
131
|
+
if _resource_attribute_resolver is not None:
|
|
132
|
+
return _resource_attribute_resolver.resolve(resource_type, resource_id)
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Public API — stable forever
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AuthzDeniedError(Exception):
|
|
142
|
+
"""Raised when Cedar policy evaluation denies an action.
|
|
143
|
+
|
|
144
|
+
Transport layers (REST, MCP) catch this and convert it to the appropriate
|
|
145
|
+
response -- ``HTTPException(403)`` for REST, a tool error for MCP.
|
|
146
|
+
|
|
147
|
+
In practice, sonnet-server's REST handlers register an exception handler
|
|
148
|
+
that converts ``AuthzDeniedError`` to 403, so callers that use
|
|
149
|
+
``check_authz`` in REST handlers need not catch it explicitly.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, action: str, resource_type: str, resource_id: str) -> None:
|
|
153
|
+
super().__init__(f"Forbidden: {action} on {resource_type}/{resource_id}")
|
|
154
|
+
self.action = action
|
|
155
|
+
self.resource_type = resource_type
|
|
156
|
+
self.resource_id = resource_id
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_authz(
|
|
160
|
+
action: str,
|
|
161
|
+
resource_type: str,
|
|
162
|
+
resource_id: str,
|
|
163
|
+
resource_attrs: dict[str, Any] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Check Cedar policy — raises AuthzDeniedError on deny, no-op if authz disabled.
|
|
166
|
+
|
|
167
|
+
Call at the top of any REST handler or MCP tool that accesses a resource.
|
|
168
|
+
Resource properties are resolved automatically via the registered
|
|
169
|
+
``ResourceAttributeResolver`` unless ``resource_attrs`` is explicitly provided.
|
|
170
|
+
|
|
171
|
+
Callers in REST handlers do not need to catch ``AuthzDeniedError`` —
|
|
172
|
+
the transport layer converts it to HTTP 403 via the registered exception
|
|
173
|
+
handler. MCP tool handlers should catch it and return a tool error.
|
|
174
|
+
"""
|
|
175
|
+
engine = get_policy_engine()
|
|
176
|
+
if not engine:
|
|
177
|
+
return
|
|
178
|
+
auth = get_current_auth()
|
|
179
|
+
principal_attrs = resolve_principal_attrs(engine, auth.user_id, auth.claims)
|
|
180
|
+
resolved_attrs = _resolve_resource_attrs(resource_type, resource_id, resource_attrs)
|
|
181
|
+
if not engine.check(auth.user_id, principal_attrs, action, resource_type, resource_id, resolved_attrs):
|
|
182
|
+
logger.info(
|
|
183
|
+
"AuthZ denied: user={} action={} resource={}/{}{}",
|
|
184
|
+
auth.user_id,
|
|
185
|
+
action,
|
|
186
|
+
resource_type,
|
|
187
|
+
resource_id,
|
|
188
|
+
f" resource_attrs={resolved_attrs}" if resolved_attrs else "",
|
|
189
|
+
)
|
|
190
|
+
raise AuthzDeniedError(action, resource_type, resource_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def filter_authz(
|
|
194
|
+
action: str,
|
|
195
|
+
resource_type: str,
|
|
196
|
+
resources: list[tuple[str, dict[str, Any]]] | list[str],
|
|
197
|
+
) -> set[str]:
|
|
198
|
+
"""Filter to permitted resource IDs — returns all IDs if authz disabled.
|
|
199
|
+
|
|
200
|
+
Accepts either ``[(id, attrs), ...]`` or ``[id, ...]``. When attrs are
|
|
201
|
+
not provided, they are resolved automatically via the registered
|
|
202
|
+
``ResourceAttributeResolver``. Enrichment is applied once per call.
|
|
203
|
+
"""
|
|
204
|
+
if not resources:
|
|
205
|
+
return set()
|
|
206
|
+
|
|
207
|
+
engine = get_policy_engine()
|
|
208
|
+
if not engine:
|
|
209
|
+
if isinstance(resources[0], str):
|
|
210
|
+
return set(resources)
|
|
211
|
+
return {rid for rid, _ in resources}
|
|
212
|
+
|
|
213
|
+
# Normalise input to [(id, attrs)] pairs
|
|
214
|
+
if isinstance(resources[0], str):
|
|
215
|
+
pairs = [(rid, _resolve_resource_attrs(resource_type, rid, None)) for rid in resources]
|
|
216
|
+
else:
|
|
217
|
+
pairs = [(rid, _resolve_resource_attrs(resource_type, rid, attrs)) for rid, attrs in resources]
|
|
218
|
+
|
|
219
|
+
auth = get_current_auth()
|
|
220
|
+
principal_attrs = resolve_principal_attrs(engine, auth.user_id, auth.claims)
|
|
221
|
+
return set(engine.filter(auth.user_id, principal_attrs, action, resource_type, pairs))
|
sonnet_auth/context.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Shared claim-to-AuthContext mapping used by both REST and MCP transports.
|
|
2
|
+
|
|
3
|
+
Both ``JwtCredentialValidator`` (REST) and ``AuthBridgeMiddleware`` (MCP)
|
|
4
|
+
produce an ``AuthContext`` from a validated JWT's claim dict. This module
|
|
5
|
+
centralises that mapping so any future field change requires a single edit.
|
|
6
|
+
|
|
7
|
+
The OIDC-standard ``sub`` claim is always mapped to ``user_id``/``username``.
|
|
8
|
+
Additional claims are extracted via ``claim_map`` (dot-path → attribute name)
|
|
9
|
+
from ``AuthConfig``. Mapped attributes named ``email`` or ``tenant_id``
|
|
10
|
+
populate the corresponding ``AuthContext`` fields; all others are available
|
|
11
|
+
to the Cedar policy engine via ``resolve_attributes()``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from sonnet_server.guards import AuthContext
|
|
21
|
+
|
|
22
|
+
# JWT standard/registered claims excluded from auto-mapping — these are
|
|
23
|
+
# protocol plumbing, not domain attributes meaningful to Cedar policies.
|
|
24
|
+
_JWT_PLUMBING: frozenset[str] = frozenset({"sub", "iat", "exp", "nbf", "iss", "aud", "jti"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_dot_path(data: Mapping, path: str) -> Any | None:
|
|
28
|
+
"""Walk a dot-delimited path into a nested mapping, returning None on miss.
|
|
29
|
+
|
|
30
|
+
Accepts any ``Mapping`` (dict, MappingProxyType, etc.) so callers can
|
|
31
|
+
pass raw JWT dicts or the frozen ``AuthContext.claims`` proxy directly.
|
|
32
|
+
|
|
33
|
+
**Limitation:** returns ``None`` both when a path is absent *and* when a
|
|
34
|
+
leaf value is explicitly ``None``. Callers cannot distinguish the two cases.
|
|
35
|
+
A claim value that is legitimately ``None`` in the JWT will be treated as
|
|
36
|
+
absent. This is an accepted constraint — JWT claims used as Cedar attributes
|
|
37
|
+
should carry a meaningful value; ``None`` is not a valid Cedar attribute value.
|
|
38
|
+
|
|
39
|
+
>>> resolve_dot_path({"a": {"b": [1, 2]}}, "a.b")
|
|
40
|
+
[1, 2]
|
|
41
|
+
>>> resolve_dot_path({"a": 1}, "a.b.c") is None
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
current: Any = data
|
|
45
|
+
for segment in path.split("."):
|
|
46
|
+
if not isinstance(current, Mapping):
|
|
47
|
+
return None
|
|
48
|
+
current = current.get(segment)
|
|
49
|
+
if current is None:
|
|
50
|
+
return None
|
|
51
|
+
return current
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SENTINEL = object()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_attributes(
|
|
58
|
+
claims: Mapping,
|
|
59
|
+
claim_map: dict[str, str],
|
|
60
|
+
auto_map_claims: bool = False,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Resolve JWT claims to Cedar principal attributes.
|
|
63
|
+
|
|
64
|
+
When ``auto_map_claims`` is True, all top-level claims are included
|
|
65
|
+
as attributes using their original name (except ``sub``, ``iat``,
|
|
66
|
+
``exp``, ``nbf``, ``iss``, ``aud``, ``jti`` which are JWT plumbing).
|
|
67
|
+
Falsy but present values (``False``, ``0``, ``[]``, ``""``) are included.
|
|
68
|
+
Explicit ``claim_map`` entries take priority over auto-mapped names,
|
|
69
|
+
allowing renames and dot-path extraction of nested claims.
|
|
70
|
+
|
|
71
|
+
When ``auto_map_claims`` is False (default), only claims listed in
|
|
72
|
+
``claim_map`` are visible.
|
|
73
|
+
"""
|
|
74
|
+
attrs: dict[str, Any] = {}
|
|
75
|
+
|
|
76
|
+
# Auto-map: pass through all top-level claims (except JWT plumbing).
|
|
77
|
+
# Include falsy-but-present values (e.g. active=False, count=0).
|
|
78
|
+
# Skip only None and absent keys.
|
|
79
|
+
if auto_map_claims:
|
|
80
|
+
for key, value in claims.items():
|
|
81
|
+
if key not in _JWT_PLUMBING and value is not None:
|
|
82
|
+
attrs[key] = value
|
|
83
|
+
|
|
84
|
+
# Explicit claim_map: overrides auto-mapped names, supports dot-paths.
|
|
85
|
+
# resolve_dot_path returns None for both absent keys and None values;
|
|
86
|
+
# we use a sentinel to distinguish "key present but falsy" from "key absent".
|
|
87
|
+
for attr_name, dot_path in claim_map.items():
|
|
88
|
+
# Walk the path manually to detect absent vs None leaf
|
|
89
|
+
current: Any = claims
|
|
90
|
+
found = True
|
|
91
|
+
for segment in dot_path.split("."):
|
|
92
|
+
if not isinstance(current, Mapping):
|
|
93
|
+
found = False
|
|
94
|
+
break
|
|
95
|
+
current = current.get(segment, _SENTINEL)
|
|
96
|
+
if current is _SENTINEL:
|
|
97
|
+
found = False
|
|
98
|
+
break
|
|
99
|
+
if found and current is not None:
|
|
100
|
+
attrs[attr_name] = current
|
|
101
|
+
|
|
102
|
+
return attrs
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_auth_context(
|
|
106
|
+
claims: dict,
|
|
107
|
+
claim_map: dict[str, str] | None = None,
|
|
108
|
+
auto_map_claims: bool = False,
|
|
109
|
+
) -> AuthContext:
|
|
110
|
+
"""Build an ``AuthContext`` from a validated JWT claim dict.
|
|
111
|
+
|
|
112
|
+
The OIDC-standard ``sub`` claim is always mapped to ``user_id`` and
|
|
113
|
+
``username``. Additional claims are extracted via ``claim_map`` and
|
|
114
|
+
``auto_map_claims``:
|
|
115
|
+
|
|
116
|
+
- ``email`` → ``AuthContext.email`` (when mapped)
|
|
117
|
+
- ``tenant_id`` → ``AuthContext.tenant_id`` (when mapped)
|
|
118
|
+
- All others remain available to Cedar via ``resolve_attributes()``
|
|
119
|
+
at evaluation time (re-derived from ``claims`` + ``claim_map``).
|
|
120
|
+
|
|
121
|
+
Precondition: ``claims`` must come from a token that has already been
|
|
122
|
+
validated (signature, expiry, issuer, audience).
|
|
123
|
+
"""
|
|
124
|
+
sub = claims["sub"]
|
|
125
|
+
attrs = resolve_attributes(claims, claim_map or {}, auto_map_claims=auto_map_claims)
|
|
126
|
+
|
|
127
|
+
return AuthContext(
|
|
128
|
+
user_id=sub,
|
|
129
|
+
username=sub,
|
|
130
|
+
email=attrs.get("email"),
|
|
131
|
+
tenant_id=attrs.get("tenant_id"),
|
|
132
|
+
claims=MappingProxyType(claims),
|
|
133
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""JwtCredentialValidator — validates JWT bearer tokens against a JWKS endpoint.
|
|
2
|
+
|
|
3
|
+
Uses joserfc (the successor to authlib.jose) — the same crypto library as
|
|
4
|
+
FastMCP's JWTVerifier, ensuring consistent token validation across both
|
|
5
|
+
REST and MCP transports.
|
|
6
|
+
|
|
7
|
+
JWKS keys are fetched synchronously at construction time. If the JWKS
|
|
8
|
+
endpoint is unreachable at startup, the validator starts with no keys
|
|
9
|
+
and returns None (→ 401) for every request until the TTL-based refresh
|
|
10
|
+
succeeds. This avoids a hard startup failure when the IdP is temporarily
|
|
11
|
+
unavailable during rolling deploys.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from joserfc import jwt as joserfc_jwt
|
|
21
|
+
from joserfc.errors import JoseError
|
|
22
|
+
from joserfc.jwk import KeySet
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
from sonnet_auth.context import build_auth_context
|
|
26
|
+
from sonnet_server.guards import AuthContext, CredentialValidator
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from sonnet_auth.settings import AuthConfig
|
|
30
|
+
|
|
31
|
+
# JWKS cache TTL in seconds — keys are re-fetched after this interval
|
|
32
|
+
_JWKS_CACHE_TTL = 3600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JwtCredentialValidator(CredentialValidator):
|
|
36
|
+
"""Validates JWT bearer tokens (RS256/ES256) against a JWKS endpoint.
|
|
37
|
+
|
|
38
|
+
Uses joserfc for consistent crypto with FastMCP's JWTVerifier.
|
|
39
|
+
The ``jwks_client`` parameter is injectable for testing.
|
|
40
|
+
|
|
41
|
+
Precondition: ``auth_config.jwks_uri`` must be non-None and HTTPS.
|
|
42
|
+
This is enforced by ``AuthSettings`` validation before construction.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
auth_config: AuthConfig,
|
|
48
|
+
*,
|
|
49
|
+
key_set: KeySet | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not auth_config.jwks_uri:
|
|
52
|
+
raise ValueError("JwtCredentialValidator requires auth_config.jwks_uri to be set")
|
|
53
|
+
self._jwks_uri = auth_config.jwks_uri
|
|
54
|
+
self._issuer = auth_config.issuer
|
|
55
|
+
self._audience = auth_config.audience
|
|
56
|
+
self._claim_map: dict[str, str] = auth_config.claim_map
|
|
57
|
+
self._auto_map_claims: bool = auth_config.auto_map_claims
|
|
58
|
+
|
|
59
|
+
if key_set is not None:
|
|
60
|
+
# Injected key set (tests) — skip network fetch
|
|
61
|
+
self._key_set: KeySet | None = key_set
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
self._key_set = self._fetch_key_set()
|
|
65
|
+
except RuntimeError as exc:
|
|
66
|
+
# JWKS endpoint unreachable at startup — start with no keys.
|
|
67
|
+
# All requests return 401 until the TTL refresh succeeds.
|
|
68
|
+
logger.warning("JWKS fetch failed at startup — all requests will return 401 until resolved: {}", exc)
|
|
69
|
+
self._key_set = None
|
|
70
|
+
|
|
71
|
+
self._key_set_fetched_at = time.monotonic()
|
|
72
|
+
|
|
73
|
+
def _fetch_key_set(self) -> KeySet:
|
|
74
|
+
"""Fetch JWKS from the configured endpoint synchronously."""
|
|
75
|
+
try:
|
|
76
|
+
response = httpx.get(self._jwks_uri, timeout=10.0)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
key_set = KeySet.import_key_set(response.json())
|
|
79
|
+
logger.trace("JWKS fetched successfully — {} key(s)", len(key_set.keys))
|
|
80
|
+
return key_set
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
raise RuntimeError(f"Failed to fetch JWKS from {self._jwks_uri}: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
def _get_key_set(self) -> KeySet | None:
|
|
85
|
+
"""Return cached key set, refreshing if TTL has expired or no keys yet."""
|
|
86
|
+
if self._key_set is None or time.monotonic() - self._key_set_fetched_at > _JWKS_CACHE_TTL:
|
|
87
|
+
logger.debug("Fetching JWKS from {} ({})", self._jwks_uri, "retry" if self._key_set is None else "TTL expired")
|
|
88
|
+
try:
|
|
89
|
+
self._key_set = self._fetch_key_set()
|
|
90
|
+
self._key_set_fetched_at = time.monotonic()
|
|
91
|
+
except RuntimeError as exc:
|
|
92
|
+
if self._key_set is not None:
|
|
93
|
+
logger.warning("JWKS refresh failed, using cached keys: {}", exc)
|
|
94
|
+
else:
|
|
95
|
+
logger.warning("JWKS fetch failed — all requests will return 401: {}", exc)
|
|
96
|
+
return self._key_set
|
|
97
|
+
|
|
98
|
+
def validate(self, bearer_token: str) -> AuthContext | None:
|
|
99
|
+
"""Validate a bearer token. Returns AuthContext on success, None on failure.
|
|
100
|
+
|
|
101
|
+
Returns None (→ 401) if no JWKS keys are available yet.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
key_set = self._get_key_set()
|
|
105
|
+
if key_set is None:
|
|
106
|
+
logger.debug("JWT validation skipped — no JWKS keys available")
|
|
107
|
+
return None
|
|
108
|
+
token = joserfc_jwt.decode(bearer_token, key_set, algorithms=["RS256", "ES256"])
|
|
109
|
+
|
|
110
|
+
claims_registry = joserfc_jwt.JWTClaimsRegistry(
|
|
111
|
+
sub={"essential": True},
|
|
112
|
+
exp={"essential": True},
|
|
113
|
+
**({} if self._issuer is None else {"iss": {"essential": True, "value": self._issuer}}),
|
|
114
|
+
**({} if self._audience is None else {"aud": {"essential": True, "value": self._audience}}),
|
|
115
|
+
)
|
|
116
|
+
claims_registry.validate(token.claims)
|
|
117
|
+
|
|
118
|
+
ctx = build_auth_context(token.claims, self._claim_map, auto_map_claims=self._auto_map_claims)
|
|
119
|
+
logger.trace(
|
|
120
|
+
"JWT validated: user={} iss={} aud={}",
|
|
121
|
+
ctx.user_id,
|
|
122
|
+
token.claims.get("iss"),
|
|
123
|
+
token.claims.get("aud"),
|
|
124
|
+
)
|
|
125
|
+
return ctx
|
|
126
|
+
except JoseError as exc:
|
|
127
|
+
logger.debug("JWT validation failed: {}", exc)
|
|
128
|
+
return None
|
|
129
|
+
except RuntimeError as exc:
|
|
130
|
+
logger.warning("JWKS error during token validation: {}", exc)
|
|
131
|
+
return None
|
|
132
|
+
except (KeyError, AttributeError) as exc:
|
|
133
|
+
# Configuration error (e.g. malformed claim_map, unexpected claims shape).
|
|
134
|
+
# Re-raise so misconfigurations surface clearly rather than silently
|
|
135
|
+
# producing 401 responses that look like invalid tokens.
|
|
136
|
+
raise RuntimeError(f"Auth configuration error during token validation: {exc}") from exc
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""PolicyEngine — Cedar-based authorization evaluation.
|
|
2
|
+
|
|
3
|
+
Pure evaluator: receives validated policies + schema at construction,
|
|
4
|
+
performs no I/O. Loading from the settings table is the caller's
|
|
5
|
+
responsibility (see services/di.py).
|
|
6
|
+
|
|
7
|
+
The engine exposes two operations:
|
|
8
|
+
|
|
9
|
+
check() — permit/deny a single (principal, action, resource) triple
|
|
10
|
+
filter() — from a list of (resource_id, resource_attrs) pairs, return
|
|
11
|
+
only the IDs the principal is permitted to act on.
|
|
12
|
+
Uses is_authorized_batch() internally — one Cedar call for
|
|
13
|
+
the entire list, not N individual calls.
|
|
14
|
+
|
|
15
|
+
Principal attributes and resource attributes are always provided by the
|
|
16
|
+
caller (authz helpers). The engine never resolves claims, never runs
|
|
17
|
+
enrichers, never fetches anything — it is a pure function of its inputs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from cedarpy import is_authorized, is_authorized_batch, validate_policies
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
# Cedar entity type name for principals — must match the schema
|
|
28
|
+
_TYPE_USER = "User"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PolicyEngine:
|
|
32
|
+
"""Cedar policy evaluator.
|
|
33
|
+
|
|
34
|
+
Construction validates policies against the schema and raises
|
|
35
|
+
``ValueError`` immediately if validation fails — fail fast at startup,
|
|
36
|
+
not at first request.
|
|
37
|
+
|
|
38
|
+
Stores ``claim_map`` as a read-only property for the authz helpers to
|
|
39
|
+
read — the engine itself never uses it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
policies: str,
|
|
45
|
+
schema: str,
|
|
46
|
+
claim_map: dict[str, str] | None = None,
|
|
47
|
+
auto_map_claims: bool = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
result = validate_policies(policies, schema)
|
|
50
|
+
if not result.validation_passed:
|
|
51
|
+
raise ValueError(f"Cedar policy validation failed: {result.errors}")
|
|
52
|
+
|
|
53
|
+
self._policies = policies
|
|
54
|
+
self._claim_map: dict[str, str] = claim_map or {}
|
|
55
|
+
self._auto_map_claims: bool = auto_map_claims
|
|
56
|
+
logger.info("PolicyEngine initialised — Cedar policies validated successfully")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def claim_map(self) -> dict[str, str]:
|
|
60
|
+
"""JWT claim dot-paths → Cedar attribute names. Read by authz helpers."""
|
|
61
|
+
return self._claim_map
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def auto_map_claims(self) -> bool:
|
|
65
|
+
"""Whether to pass all top-level JWT claims as Cedar attributes. Read by authz helpers."""
|
|
66
|
+
return self._auto_map_claims
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Lifecycle
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def reload(self, policies: str, schema: str) -> None:
|
|
73
|
+
"""Replace policies and schema. Validates before swapping — old policies
|
|
74
|
+
stay active if validation fails.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If the new policies fail validation against the schema.
|
|
78
|
+
"""
|
|
79
|
+
result = validate_policies(policies, schema)
|
|
80
|
+
if not result.validation_passed:
|
|
81
|
+
raise ValueError(f"Cedar policy validation failed: {result.errors}")
|
|
82
|
+
self._policies = policies
|
|
83
|
+
logger.info("PolicyEngine reloaded — new Cedar policies validated and active")
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Public API
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def check(
|
|
90
|
+
self,
|
|
91
|
+
user_id: str,
|
|
92
|
+
principal_attrs: dict[str, Any],
|
|
93
|
+
action: str,
|
|
94
|
+
resource_type: str,
|
|
95
|
+
resource_id: str,
|
|
96
|
+
resource_attrs: dict[str, Any] | None = None,
|
|
97
|
+
) -> bool:
|
|
98
|
+
"""Return True if the principal is permitted to perform action on resource.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
user_id: Principal entity ID (from AuthContext.user_id).
|
|
102
|
+
principal_attrs: Pre-built principal attributes (from authz helpers).
|
|
103
|
+
action: Cedar action name (e.g. "search", "configure").
|
|
104
|
+
resource_type: Cedar entity type (e.g. "Topic", "Source").
|
|
105
|
+
resource_id: Resource identifier (e.g. "backend", "my-repo").
|
|
106
|
+
resource_attrs: Resource attributes for ABAC. Empty dict or None for RBAC.
|
|
107
|
+
"""
|
|
108
|
+
entities = [
|
|
109
|
+
_build_principal_entity(user_id, principal_attrs),
|
|
110
|
+
_build_resource_entity(resource_type, resource_id, resource_attrs or {}),
|
|
111
|
+
]
|
|
112
|
+
request = _build_request(user_id, action, resource_type, resource_id)
|
|
113
|
+
logger.trace(
|
|
114
|
+
"Cedar check: principal={} principal_attrs={} action={} resource={}/{} resource_attrs={}",
|
|
115
|
+
user_id,
|
|
116
|
+
principal_attrs,
|
|
117
|
+
action,
|
|
118
|
+
resource_type,
|
|
119
|
+
resource_id,
|
|
120
|
+
resource_attrs or {},
|
|
121
|
+
)
|
|
122
|
+
result = is_authorized(request, self._policies, entities)
|
|
123
|
+
|
|
124
|
+
decision = "ALLOW" if result.allowed else "DENY"
|
|
125
|
+
logger.debug(
|
|
126
|
+
"PolicyEngine.check: user={} action={} resource={}/{} → {}",
|
|
127
|
+
user_id,
|
|
128
|
+
action,
|
|
129
|
+
resource_type,
|
|
130
|
+
resource_id,
|
|
131
|
+
decision,
|
|
132
|
+
)
|
|
133
|
+
if not result.allowed:
|
|
134
|
+
logger.trace("PolicyEngine deny diagnostics: {}", result.diagnostics)
|
|
135
|
+
return result.allowed
|
|
136
|
+
|
|
137
|
+
def filter(
|
|
138
|
+
self,
|
|
139
|
+
user_id: str,
|
|
140
|
+
principal_attrs: dict[str, Any],
|
|
141
|
+
action: str,
|
|
142
|
+
resource_type: str,
|
|
143
|
+
resources: list[tuple[str, dict[str, Any]]],
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
"""Return the subset of resource IDs the principal is permitted to act on.
|
|
146
|
+
|
|
147
|
+
Uses is_authorized_batch() for efficiency — one Cedar evaluation
|
|
148
|
+
call regardless of list length.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
user_id: Principal entity ID.
|
|
152
|
+
principal_attrs: Pre-built principal attributes.
|
|
153
|
+
action: Cedar action name.
|
|
154
|
+
resource_type: Cedar entity type ("Topic" or "Source").
|
|
155
|
+
resources: List of (resource_id, resource_attrs) pairs.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of permitted resource IDs (preserves input order).
|
|
159
|
+
"""
|
|
160
|
+
if not resources:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
entities = [_build_principal_entity(user_id, principal_attrs)] + [
|
|
164
|
+
_build_resource_entity(resource_type, rid, attrs) for rid, attrs in resources
|
|
165
|
+
]
|
|
166
|
+
requests = [_build_request(user_id, action, resource_type, rid) for rid, _ in resources]
|
|
167
|
+
logger.trace(
|
|
168
|
+
"Cedar filter: principal={} principal_attrs={} action={} type={} resources={}",
|
|
169
|
+
user_id,
|
|
170
|
+
principal_attrs,
|
|
171
|
+
action,
|
|
172
|
+
resource_type,
|
|
173
|
+
[(rid, attrs) for rid, attrs in resources],
|
|
174
|
+
)
|
|
175
|
+
results = is_authorized_batch(requests=requests, policies=self._policies, entities=entities)
|
|
176
|
+
permitted = [rid for (rid, _), result in zip(resources, results, strict=True) if result.allowed]
|
|
177
|
+
|
|
178
|
+
logger.debug(
|
|
179
|
+
"PolicyEngine.filter: user={} action={} type={} total={} permitted={}",
|
|
180
|
+
user_id,
|
|
181
|
+
action,
|
|
182
|
+
resource_type,
|
|
183
|
+
len(resources),
|
|
184
|
+
len(permitted),
|
|
185
|
+
)
|
|
186
|
+
if len(permitted) < len(resources):
|
|
187
|
+
denied = [rid for rid in (r[0] for r in resources) if rid not in set(permitted)]
|
|
188
|
+
logger.trace("PolicyEngine filter denied resources: {}", denied)
|
|
189
|
+
return permitted
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Entity / request builders (module-level — no state, easy to test)
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _build_principal_entity(user_id: str, attrs: dict[str, Any]) -> dict:
|
|
198
|
+
"""Build a Cedar User entity from pre-built attributes."""
|
|
199
|
+
return {
|
|
200
|
+
"uid": {"__entity": {"type": _TYPE_USER, "id": user_id}},
|
|
201
|
+
"attrs": attrs,
|
|
202
|
+
"parents": [],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_resource_entity(resource_type: str, resource_id: str, attrs: dict[str, Any]) -> dict:
|
|
207
|
+
"""Build a Cedar resource entity (Topic, Source, etc.)."""
|
|
208
|
+
return {
|
|
209
|
+
"uid": {"__entity": {"type": resource_type, "id": resource_id}},
|
|
210
|
+
"attrs": attrs,
|
|
211
|
+
"parents": [],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _build_request(user_id: str, action: str, resource_type: str, resource_id: str) -> dict:
|
|
216
|
+
"""Build a Cedar authorization request dict.
|
|
217
|
+
|
|
218
|
+
Cedar entity references use ``Type::"id"`` syntax — embedded double
|
|
219
|
+
quotes would break parsing. User-controlled values (user_id from JWT
|
|
220
|
+
``sub``, resource_id from tool args) are checked defensively here.
|
|
221
|
+
"""
|
|
222
|
+
for name, value in (("user_id", user_id), ("resource_id", resource_id)):
|
|
223
|
+
if '"' in value:
|
|
224
|
+
raise ValueError(f"Cedar entity ID must not contain double quotes: {name}={value!r}")
|
|
225
|
+
return {
|
|
226
|
+
"principal": f'{_TYPE_USER}::"{user_id}"',
|
|
227
|
+
"action": f'Action::"{action}"',
|
|
228
|
+
"resource": f'{resource_type}::"{resource_id}"',
|
|
229
|
+
"context": {},
|
|
230
|
+
}
|
sonnet_auth/settings.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""AuthSettings — typed auth configuration with env var + optional seed resolution.
|
|
2
|
+
|
|
3
|
+
Resolution per field (example with prefix ``COCO_RAG_``):
|
|
4
|
+
env var COCO_RAG_AUTHN_ENABLED > seed value (e.g. from DB) > default.
|
|
5
|
+
|
|
6
|
+
The env prefix is passed explicitly to ``AuthSettings.resolve()`` so the
|
|
7
|
+
same model works for any consumer (COCO_RAG_, COCO_GRAPH_, ATLAS_, …).
|
|
8
|
+
No module-level mutable state — safe to call from any context.
|
|
9
|
+
|
|
10
|
+
Seed key contract (what callers pass to resolve()):
|
|
11
|
+
``authn_enabled`` (bool) — AuthN toggle
|
|
12
|
+
``authn_config`` (dict) — AuthN config blob (validated by AuthnConfig)
|
|
13
|
+
``authz_enabled`` (bool) — AuthZ toggle
|
|
14
|
+
|
|
15
|
+
DB storage keys (coco-rag settings table):
|
|
16
|
+
authn_enabled — flat boolean (AuthN toggle / emergency kill switch)
|
|
17
|
+
authn_config — JSON object: AuthN config (jwks_uri, issuer, audience,
|
|
18
|
+
mode, claim_map, auto_map_claims, mcp_base_url,
|
|
19
|
+
upstream_* fields)
|
|
20
|
+
authz_enabled — flat boolean (AuthZ / Cedar toggle)
|
|
21
|
+
authz_cedar_policies — text: Cedar policy source
|
|
22
|
+
authz_cedar_schema — text: Cedar schema source
|
|
23
|
+
|
|
24
|
+
CLI usage:
|
|
25
|
+
coco-rag settings set authn_enabled true
|
|
26
|
+
coco-rag settings set authn_config '{"jwks_uri": "...", "issuer": "...", "audience": "..."}'
|
|
27
|
+
coco-rag settings set authz_enabled true
|
|
28
|
+
coco-rag settings set-text authz_cedar_policies --file policies.cedar
|
|
29
|
+
coco-rag settings set-text authz_cedar_schema --file schema.cedarschema
|
|
30
|
+
|
|
31
|
+
Future (Phase 4 — atlas bundle pull):
|
|
32
|
+
``authz_config`` (dict) — AuthZ config blob (AuthzConfig, when added)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
from typing import Any, ClassVar
|
|
41
|
+
|
|
42
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_https_url(v: str | None, *, env_prefix: str = "") -> str | None:
|
|
46
|
+
"""Validate URL uses HTTPS with a non-empty host. Preserves value as-is (no normalisation).
|
|
47
|
+
|
|
48
|
+
``{env_prefix}AUTHN_ALLOW_HTTP=true`` relaxes to HTTP for test environments
|
|
49
|
+
(localhost only). For example: ``COCO_RAG_AUTHN_ALLOW_HTTP=true``.
|
|
50
|
+
"""
|
|
51
|
+
if v is None:
|
|
52
|
+
return None
|
|
53
|
+
from urllib.parse import urlparse
|
|
54
|
+
|
|
55
|
+
parsed = urlparse(v)
|
|
56
|
+
allow_http = os.environ.get(f"{env_prefix}AUTHN_ALLOW_HTTP", "").lower() in ("true", "1")
|
|
57
|
+
|
|
58
|
+
if allow_http:
|
|
59
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
60
|
+
raise ValueError(f"URL must use HTTP(S) with a valid host: {v!r}")
|
|
61
|
+
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
|
|
62
|
+
raise ValueError(f"HTTP is only allowed for localhost in test mode: {v!r}")
|
|
63
|
+
return v
|
|
64
|
+
|
|
65
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
66
|
+
raise ValueError(f"URL must use HTTPS with a valid host: {v!r}")
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthMode(StrEnum):
|
|
71
|
+
JWT_VERIFIER = "jwt_verifier"
|
|
72
|
+
OAUTH_PROXY = "oauth_proxy"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuthnConfig(BaseModel):
|
|
76
|
+
"""JWT/JWKS authentication configuration.
|
|
77
|
+
|
|
78
|
+
Stored as the ``auth_config`` settings key. All fields that must be
|
|
79
|
+
consistent live here. The enable/disable toggle is a separate flat key
|
|
80
|
+
(``auth_enabled``) so it can be flipped independently.
|
|
81
|
+
|
|
82
|
+
MCP-specific fields (``mcp_base_url``, ``upstream_*``, ``mode``) are
|
|
83
|
+
included here because the MCP auth provider is constructed from this
|
|
84
|
+
config. Services without MCP leave these fields at their defaults.
|
|
85
|
+
|
|
86
|
+
Instances are frozen — post-construction mutation raises ValidationError.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
90
|
+
|
|
91
|
+
jwks_uri: str | None = None
|
|
92
|
+
issuer: str | None = None
|
|
93
|
+
audience: str | None = None
|
|
94
|
+
mode: AuthMode = AuthMode.JWT_VERIFIER
|
|
95
|
+
|
|
96
|
+
# Claim mapping — controls how JWT claims become AuthContext fields and
|
|
97
|
+
# Cedar principal attributes. Shared by both authn and authz layers.
|
|
98
|
+
auto_map_claims: bool = False
|
|
99
|
+
claim_map: dict[str, str] = Field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
# MCP auth provider — public base URL for RFC 9728 metadata endpoint.
|
|
102
|
+
# Required when mode=jwt_verifier and an MCP server is active.
|
|
103
|
+
# Required when mode=oauth_proxy.
|
|
104
|
+
mcp_base_url: str | None = None
|
|
105
|
+
|
|
106
|
+
# OAuthProxy-only (when mode="oauth_proxy")
|
|
107
|
+
upstream_authorization_endpoint: str | None = None
|
|
108
|
+
upstream_token_endpoint: str | None = None
|
|
109
|
+
upstream_client_id: str | None = None
|
|
110
|
+
upstream_client_secret: SecretStr | None = None
|
|
111
|
+
|
|
112
|
+
# Env var suffixes (without prefix) mapped to field names.
|
|
113
|
+
# Used by AuthSettings.resolve() to apply per-field env var overrides.
|
|
114
|
+
# Full env var: {prefix}AUTHN_{SUFFIX} e.g. COCO_RAG_AUTHN_JWKS_URI
|
|
115
|
+
_ENV_SUFFIXES: ClassVar[dict[str, str]] = {
|
|
116
|
+
"AUTHN_JWKS_URI": "jwks_uri",
|
|
117
|
+
"AUTHN_ISSUER": "issuer",
|
|
118
|
+
"AUTHN_AUDIENCE": "audience",
|
|
119
|
+
"AUTHN_MODE": "mode",
|
|
120
|
+
"AUTHN_AUTO_MAP_CLAIMS": "auto_map_claims",
|
|
121
|
+
"AUTHN_CLAIM_MAP": "claim_map",
|
|
122
|
+
"AUTHN_MCP_BASE_URL": "mcp_base_url",
|
|
123
|
+
"AUTHN_UPSTREAM_AUTHORIZATION_ENDPOINT": "upstream_authorization_endpoint",
|
|
124
|
+
"AUTHN_UPSTREAM_TOKEN_ENDPOINT": "upstream_token_endpoint",
|
|
125
|
+
"AUTHN_UPSTREAM_CLIENT_ID": "upstream_client_id",
|
|
126
|
+
"AUTHN_UPSTREAM_CLIENT_SECRET": "upstream_client_secret",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@field_validator(
|
|
130
|
+
"jwks_uri", "issuer", "mcp_base_url", "upstream_authorization_endpoint", "upstream_token_endpoint", mode="before"
|
|
131
|
+
)
|
|
132
|
+
@classmethod
|
|
133
|
+
def _require_https(cls, v: str | None) -> str | None:
|
|
134
|
+
# The field validator has no access to the env prefix. It enforces
|
|
135
|
+
# HTTPS strictly and respects the bare AUTHN_ALLOW_HTTP env var (no
|
|
136
|
+
# prefix). Callers that need prefix-aware allow-HTTP (e.g. test
|
|
137
|
+
# environments using COCO_RAG_AUTHN_ALLOW_HTTP) must use
|
|
138
|
+
# AuthSettings.resolve(), which applies the prefix before building
|
|
139
|
+
# the model. Direct construction (e.g. in tests) should either use
|
|
140
|
+
# HTTPS URLs or set the unprefixed AUTHN_ALLOW_HTTP env var.
|
|
141
|
+
return _validate_https_url(v)
|
|
142
|
+
|
|
143
|
+
@model_validator(mode="after")
|
|
144
|
+
def _validate_oauth_proxy_fields(self) -> AuthnConfig:
|
|
145
|
+
if self.mode == AuthMode.OAUTH_PROXY:
|
|
146
|
+
missing = [
|
|
147
|
+
f
|
|
148
|
+
for f in ("mcp_base_url", "upstream_authorization_endpoint", "upstream_token_endpoint", "upstream_client_id")
|
|
149
|
+
if not getattr(self, f)
|
|
150
|
+
]
|
|
151
|
+
if missing:
|
|
152
|
+
raise ValueError(f"oauth_proxy mode requires: {', '.join(missing)}")
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AuthSettings(BaseModel):
|
|
157
|
+
"""Complete auth settings: authn toggle + config, authz toggle.
|
|
158
|
+
|
|
159
|
+
Instances are frozen — post-construction mutation raises ValidationError.
|
|
160
|
+
|
|
161
|
+
Note: AuthzConfig will be added in Phase 4 when atlas bundle pull
|
|
162
|
+
fields (atlas_url, bundle_ttl) exist. Adding an empty model now would
|
|
163
|
+
be structural noise with no current value.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(frozen=True)
|
|
167
|
+
|
|
168
|
+
enabled: bool = False
|
|
169
|
+
config: AuthnConfig = Field(default_factory=AuthnConfig)
|
|
170
|
+
authz_enabled: bool = False
|
|
171
|
+
|
|
172
|
+
@model_validator(mode="after")
|
|
173
|
+
def _validate_enabled_requires_config(self) -> AuthSettings:
|
|
174
|
+
"""When auth is enabled, jwks_uri, issuer, and audience are all required."""
|
|
175
|
+
if self.enabled:
|
|
176
|
+
missing = [f for f in ("jwks_uri", "issuer", "audience") if not getattr(self.config, f)]
|
|
177
|
+
if missing:
|
|
178
|
+
raise ValueError(f"auth_enabled=true requires auth_config fields: {', '.join(missing)}")
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def resolve(cls, *, env_prefix: str = "", seed: dict[str, Any] | None = None) -> AuthSettings:
|
|
183
|
+
"""Build AuthSettings from env vars with optional seed values.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
env_prefix: Env var prefix for this consumer, e.g. ``"COCO_RAG_"``.
|
|
187
|
+
seed: Optional pre-loaded values (e.g. from a DB settings table).
|
|
188
|
+
Expected keys:
|
|
189
|
+
``authn_enabled`` (bool) — AuthN toggle
|
|
190
|
+
``authn_config`` (dict) — AuthN config blob (AuthnConfig)
|
|
191
|
+
``authz_enabled`` (bool) — AuthZ toggle
|
|
192
|
+
Per-field resolution: env var > seed value > default.
|
|
193
|
+
Unexpected keys are ignored with a warning logged.
|
|
194
|
+
"""
|
|
195
|
+
from loguru import logger
|
|
196
|
+
|
|
197
|
+
seed = seed or {}
|
|
198
|
+
|
|
199
|
+
unexpected = set(seed) - {"authn_enabled", "authn_config", "authz_enabled"}
|
|
200
|
+
if unexpected:
|
|
201
|
+
logger.warning("AuthSettings.resolve: unexpected seed keys ignored: {}", sorted(unexpected))
|
|
202
|
+
|
|
203
|
+
enabled = _resolve(env_prefix, "AUTHN_ENABLED", seed.get("authn_enabled"), default=False)
|
|
204
|
+
authz_enabled = _resolve(env_prefix, "AUTHZ_ENABLED", seed.get("authz_enabled"), default=False)
|
|
205
|
+
|
|
206
|
+
seed_config = seed.get("authn_config")
|
|
207
|
+
if seed_config is None:
|
|
208
|
+
seed_config = {}
|
|
209
|
+
elif not isinstance(seed_config, dict):
|
|
210
|
+
logger.warning("AuthSettings.resolve: authn_config is not a dict ({}), ignoring", type(seed_config).__name__)
|
|
211
|
+
seed_config = {}
|
|
212
|
+
|
|
213
|
+
config_data = dict(seed_config)
|
|
214
|
+
for suffix, field in AuthnConfig._ENV_SUFFIXES.items():
|
|
215
|
+
_override_from_env(config_data, field, env_prefix, suffix)
|
|
216
|
+
|
|
217
|
+
return cls(
|
|
218
|
+
enabled=enabled,
|
|
219
|
+
config=AuthnConfig.model_validate(config_data),
|
|
220
|
+
authz_enabled=authz_enabled,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve(env_prefix: str, env_suffix: str, seed_value: Any, *, default: Any = None) -> Any:
|
|
225
|
+
"""Return env var value if set, else seed value, else default."""
|
|
226
|
+
env_val = os.environ.get(f"{env_prefix}{env_suffix}")
|
|
227
|
+
if env_val is not None:
|
|
228
|
+
return _parse_env_value(env_val)
|
|
229
|
+
if seed_value is not None:
|
|
230
|
+
return seed_value
|
|
231
|
+
return default
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _override_from_env(config_data: dict, field: str, env_prefix: str, env_suffix: str) -> None:
|
|
235
|
+
"""Override a single field in config_data if the env var is set."""
|
|
236
|
+
env_val = os.environ.get(f"{env_prefix}{env_suffix}")
|
|
237
|
+
if env_val is not None:
|
|
238
|
+
config_data[field] = _parse_env_value(env_val)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_env_value(val: str) -> Any:
|
|
242
|
+
"""Parse an env var string into a Python value."""
|
|
243
|
+
lower = val.lower()
|
|
244
|
+
if lower in ("true", "1", "yes"):
|
|
245
|
+
return True
|
|
246
|
+
if lower in ("false", "0", "no"):
|
|
247
|
+
return False
|
|
248
|
+
try:
|
|
249
|
+
result = json.loads(val)
|
|
250
|
+
if result is None:
|
|
251
|
+
raise ValueError("Env var value 'null' is not supported — unset the variable to use the default")
|
|
252
|
+
return result
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
return val
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonnet-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT/JWKS authentication and Cedar authorization for sonnet-server applications
|
|
5
|
+
Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Programming Language :: Python
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Python: <4.0,>=3.14
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: sonnet-server>=0.1.9
|
|
14
|
+
Requires-Dist: joserfc>=1.0.0
|
|
15
|
+
Requires-Dist: httpx>=0.28.0
|
|
16
|
+
Provides-Extra: cedar
|
|
17
|
+
Requires-Dist: cedarpy>=4.0.0; extra == "cedar"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: sonnet-auth[cedar]; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
23
|
+
Requires-Dist: testcontainers[keycloak]>=4.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# Sonnet Auth
|
|
26
|
+
|
|
27
|
+
JWT/JWKS authentication and Cedar authorization for sonnet-server
|
|
28
|
+
applications. No FastMCP dependency -- MCP auth wiring lives in each
|
|
29
|
+
domain service.
|
|
30
|
+
|
|
31
|
+
## What it provides
|
|
32
|
+
|
|
33
|
+
- **JWT/JWKS validation** -- `JwtCredentialValidator` implements
|
|
34
|
+
sonnet-server's `CredentialValidator` protocol. RS256/ES256 with
|
|
35
|
+
TTL-based JWKS key refresh and graceful degradation on IdP outages.
|
|
36
|
+
- **Claim mapping** -- configurable dot-path extraction from JWT claims
|
|
37
|
+
to `AuthContext` fields and Cedar principal attributes.
|
|
38
|
+
`auto_map_claims` mode passes all non-plumbing claims automatically.
|
|
39
|
+
- **Cedar policy evaluation** (optional `[cedar]` extra) -- `PolicyEngine`
|
|
40
|
+
wraps cedarpy for in-process RBAC/ABAC. `check_authz()` and
|
|
41
|
+
`filter_authz()` are one-liner authorization for REST and MCP handlers.
|
|
42
|
+
- **Pluggable resource resolution** -- `ResourceAttributeResolver` protocol
|
|
43
|
+
for domain-specific Cedar resource attributes.
|
|
44
|
+
- **Settings model** -- `AuthSettings.resolve(env_prefix, seed)` with
|
|
45
|
+
per-field env var overrides and optional DB seeding. No global state.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# JWT authentication only
|
|
51
|
+
pip install sonnet-auth
|
|
52
|
+
|
|
53
|
+
# JWT + Cedar authorization
|
|
54
|
+
pip install sonnet-auth[cedar]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Prerequisites
|
|
58
|
+
|
|
59
|
+
- Python 3.14+
|
|
60
|
+
- sonnet-server >= 0.1.9
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### AuthN only (JWT/JWKS)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sonnet_auth import AuthSettings, JwtCredentialValidator
|
|
68
|
+
from sonnet_server.guards import set_credential_validator
|
|
69
|
+
|
|
70
|
+
auth = AuthSettings.resolve(env_prefix="MY_APP_")
|
|
71
|
+
if auth.enabled:
|
|
72
|
+
set_credential_validator(JwtCredentialValidator(auth.config))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Configuration via env vars:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
MY_APP_AUTHN_ENABLED=true
|
|
79
|
+
MY_APP_AUTHN_JWKS_URI=https://idp.example.com/.well-known/jwks.json
|
|
80
|
+
MY_APP_AUTHN_ISSUER=https://idp.example.com
|
|
81
|
+
MY_APP_AUTHN_AUDIENCE=my-app
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or with DB seeding (coco-rag pattern):
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
seed = {
|
|
88
|
+
"authn_enabled": svc.get("authn_enabled"),
|
|
89
|
+
"authn_config": svc.get("authn_config"),
|
|
90
|
+
"authz_enabled": svc.get("authz_enabled"),
|
|
91
|
+
}
|
|
92
|
+
auth = AuthSettings.resolve(env_prefix="COCO_RAG_", seed=seed)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### AuthZ (Cedar policies)
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from sonnet_auth import check_authz, filter_authz, set_resource_attribute_resolver
|
|
99
|
+
|
|
100
|
+
# Register domain-specific resource resolver
|
|
101
|
+
set_resource_attribute_resolver(MyResolver())
|
|
102
|
+
|
|
103
|
+
# In any REST handler or MCP tool
|
|
104
|
+
check_authz("search", "Source", source_name) # raises AuthzDeniedError on deny
|
|
105
|
+
|
|
106
|
+
# For list operations
|
|
107
|
+
permitted = filter_authz("list", "Source", source_names)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Package structure
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
src/sonnet_auth/
|
|
114
|
+
__init__.py # public API with lazy Cedar imports
|
|
115
|
+
settings.py # AuthSettings, AuthnConfig
|
|
116
|
+
context.py # JWT claim -> AuthContext mapping
|
|
117
|
+
jwt_validator.py # JwtCredentialValidator (JWKS)
|
|
118
|
+
policy_engine.py # Cedar PolicyEngine [cedar extra]
|
|
119
|
+
authz.py # check_authz, filter_authz [cedar extra]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
Apache License 2.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sonnet_auth/__init__.py,sha256=q-E3Zf6i7PAxUIQ_6-56jqwcm66qe5qpOYWEVqIqsHs,3335
|
|
2
|
+
sonnet_auth/authz.py,sha256=TVoNBGZjQ4NtDgT2CapL_8BRaWTN4QtUkxCxvGwMuXg,8797
|
|
3
|
+
sonnet_auth/context.py,sha256=RTtOfMDsVIForXipFKnCCvfVZXLy81eZzMqhPoqNO-4,5156
|
|
4
|
+
sonnet_auth/jwt_validator.py,sha256=zU0Xfr9dAHVHe7fQEljQjxnJv5Pqkxm94mtgpAXV_dg,5920
|
|
5
|
+
sonnet_auth/policy_engine.py,sha256=EuLFbQnWuiLkpQRM7SAUod_npJjLlL5cJNc4JsH0Qcs,8829
|
|
6
|
+
sonnet_auth/settings.py,sha256=vVHHEUPmcEOXZEBPkZo4iftO8RgslcQjkVLQaThQqEI,10595
|
|
7
|
+
sonnet_auth-0.1.0.dist-info/METADATA,sha256=oSf9SjnukRBuH0QuId8hg1CsWrLQoEhgdBGDpunO3Z0,3827
|
|
8
|
+
sonnet_auth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
sonnet_auth-0.1.0.dist-info/top_level.txt,sha256=7FcXKvjjxrmHdmUjoKgHEN6l8Of_LVsT1jieOYMtgWM,12
|
|
10
|
+
sonnet_auth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sonnet_auth
|