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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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))