sonnet-auth 0.1.0__tar.gz
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-0.1.0/PKG-INFO +124 -0
- sonnet_auth-0.1.0/README.md +100 -0
- sonnet_auth-0.1.0/pyproject.toml +56 -0
- sonnet_auth-0.1.0/setup.cfg +4 -0
- sonnet_auth-0.1.0/src/sonnet_auth/__init__.py +92 -0
- sonnet_auth-0.1.0/src/sonnet_auth/authz.py +221 -0
- sonnet_auth-0.1.0/src/sonnet_auth/context.py +133 -0
- sonnet_auth-0.1.0/src/sonnet_auth/jwt_validator.py +136 -0
- sonnet_auth-0.1.0/src/sonnet_auth/policy_engine.py +230 -0
- sonnet_auth-0.1.0/src/sonnet_auth/settings.py +254 -0
- sonnet_auth-0.1.0/src/sonnet_auth.egg-info/PKG-INFO +124 -0
- sonnet_auth-0.1.0/src/sonnet_auth.egg-info/SOURCES.txt +18 -0
- sonnet_auth-0.1.0/src/sonnet_auth.egg-info/dependency_links.txt +1 -0
- sonnet_auth-0.1.0/src/sonnet_auth.egg-info/requires.txt +13 -0
- sonnet_auth-0.1.0/src/sonnet_auth.egg-info/top_level.txt +1 -0
- sonnet_auth-0.1.0/tests/test_auth_context.py +200 -0
- sonnet_auth-0.1.0/tests/test_auth_settings.py +271 -0
- sonnet_auth-0.1.0/tests/test_authz_helpers.py +190 -0
- sonnet_auth-0.1.0/tests/test_jwt_validator.py +262 -0
- sonnet_auth-0.1.0/tests/test_policy_engine.py +329 -0
|
@@ -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,100 @@
|
|
|
1
|
+
# Sonnet Auth
|
|
2
|
+
|
|
3
|
+
JWT/JWKS authentication and Cedar authorization for sonnet-server
|
|
4
|
+
applications. No FastMCP dependency -- MCP auth wiring lives in each
|
|
5
|
+
domain service.
|
|
6
|
+
|
|
7
|
+
## What it provides
|
|
8
|
+
|
|
9
|
+
- **JWT/JWKS validation** -- `JwtCredentialValidator` implements
|
|
10
|
+
sonnet-server's `CredentialValidator` protocol. RS256/ES256 with
|
|
11
|
+
TTL-based JWKS key refresh and graceful degradation on IdP outages.
|
|
12
|
+
- **Claim mapping** -- configurable dot-path extraction from JWT claims
|
|
13
|
+
to `AuthContext` fields and Cedar principal attributes.
|
|
14
|
+
`auto_map_claims` mode passes all non-plumbing claims automatically.
|
|
15
|
+
- **Cedar policy evaluation** (optional `[cedar]` extra) -- `PolicyEngine`
|
|
16
|
+
wraps cedarpy for in-process RBAC/ABAC. `check_authz()` and
|
|
17
|
+
`filter_authz()` are one-liner authorization for REST and MCP handlers.
|
|
18
|
+
- **Pluggable resource resolution** -- `ResourceAttributeResolver` protocol
|
|
19
|
+
for domain-specific Cedar resource attributes.
|
|
20
|
+
- **Settings model** -- `AuthSettings.resolve(env_prefix, seed)` with
|
|
21
|
+
per-field env var overrides and optional DB seeding. No global state.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# JWT authentication only
|
|
27
|
+
pip install sonnet-auth
|
|
28
|
+
|
|
29
|
+
# JWT + Cedar authorization
|
|
30
|
+
pip install sonnet-auth[cedar]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Prerequisites
|
|
34
|
+
|
|
35
|
+
- Python 3.14+
|
|
36
|
+
- sonnet-server >= 0.1.9
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### AuthN only (JWT/JWKS)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from sonnet_auth import AuthSettings, JwtCredentialValidator
|
|
44
|
+
from sonnet_server.guards import set_credential_validator
|
|
45
|
+
|
|
46
|
+
auth = AuthSettings.resolve(env_prefix="MY_APP_")
|
|
47
|
+
if auth.enabled:
|
|
48
|
+
set_credential_validator(JwtCredentialValidator(auth.config))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Configuration via env vars:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
MY_APP_AUTHN_ENABLED=true
|
|
55
|
+
MY_APP_AUTHN_JWKS_URI=https://idp.example.com/.well-known/jwks.json
|
|
56
|
+
MY_APP_AUTHN_ISSUER=https://idp.example.com
|
|
57
|
+
MY_APP_AUTHN_AUDIENCE=my-app
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or with DB seeding (coco-rag pattern):
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
seed = {
|
|
64
|
+
"authn_enabled": svc.get("authn_enabled"),
|
|
65
|
+
"authn_config": svc.get("authn_config"),
|
|
66
|
+
"authz_enabled": svc.get("authz_enabled"),
|
|
67
|
+
}
|
|
68
|
+
auth = AuthSettings.resolve(env_prefix="COCO_RAG_", seed=seed)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### AuthZ (Cedar policies)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from sonnet_auth import check_authz, filter_authz, set_resource_attribute_resolver
|
|
75
|
+
|
|
76
|
+
# Register domain-specific resource resolver
|
|
77
|
+
set_resource_attribute_resolver(MyResolver())
|
|
78
|
+
|
|
79
|
+
# In any REST handler or MCP tool
|
|
80
|
+
check_authz("search", "Source", source_name) # raises AuthzDeniedError on deny
|
|
81
|
+
|
|
82
|
+
# For list operations
|
|
83
|
+
permitted = filter_authz("list", "Source", source_names)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Package structure
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
src/sonnet_auth/
|
|
90
|
+
__init__.py # public API with lazy Cedar imports
|
|
91
|
+
settings.py # AuthSettings, AuthnConfig
|
|
92
|
+
context.py # JWT claim -> AuthContext mapping
|
|
93
|
+
jwt_validator.py # JwtCredentialValidator (JWKS)
|
|
94
|
+
policy_engine.py # Cedar PolicyEngine [cedar extra]
|
|
95
|
+
authz.py # check_authz, filter_authz [cedar extra]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
Apache License 2.0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonnet-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Wolfgang Miller", email = "wolfgang.miller@petrarca-labs.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "JWT/JWKS authentication and Cedar authorization for sonnet-server applications"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "Apache-2.0"
|
|
14
|
+
requires-python = ">=3.14,<4.0"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"sonnet-server>=0.1.9",
|
|
23
|
+
"joserfc>=1.0.0",
|
|
24
|
+
"httpx>=0.28.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
cedar = [
|
|
29
|
+
"cedarpy>=4.0.0",
|
|
30
|
+
]
|
|
31
|
+
dev = [
|
|
32
|
+
"sonnet-auth[cedar]",
|
|
33
|
+
"ruff>=0.3.0",
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-asyncio>=0.23.0",
|
|
36
|
+
"testcontainers[keycloak]>=4.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
markers = [
|
|
44
|
+
"unit: marks tests as unit tests (default)",
|
|
45
|
+
"integration: marks tests as integration tests",
|
|
46
|
+
]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = [
|
|
49
|
+
"-m unit",
|
|
50
|
+
"--strict-markers",
|
|
51
|
+
]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
filterwarnings = [
|
|
54
|
+
"ignore:.*wait_container_is_ready.*:DeprecationWarning",
|
|
55
|
+
"ignore:.*wait_for_logs.*:DeprecationWarning",
|
|
56
|
+
]
|
|
@@ -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}")
|
|
@@ -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))
|