qx-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,51 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.so
8
+ *.egg
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ eggs/
13
+ .eggs/
14
+ sdist/
15
+ wheels/
16
+ *.egg-link
17
+
18
+ # Virtual environments
19
+ .venv/
20
+ venv/
21
+ env/
22
+ ENV/
23
+
24
+ # uv
25
+ .uv/
26
+
27
+ # Testing
28
+ .pytest_cache/
29
+ .coverage
30
+ htmlcov/
31
+ .tox/
32
+
33
+ # Type checking
34
+ .mypy_cache/
35
+ .ruff_cache/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.swp
41
+ *.swo
42
+
43
+ # OS
44
+ .DS_Store
45
+ Thumbs.db
46
+
47
+ # Docker
48
+ *.env.local
49
+
50
+ # Dist artifacts
51
+ dist/
qx_auth-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: qx-auth
3
+ Version: 0.1.0
4
+ Summary: Qx auth: JWT validation, OIDC client, RBAC primitives, policy engine, rate limiting
5
+ Author: Qx Engineering
6
+ License: MIT
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: cryptography>=43.0.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pyjwt[crypto]>=2.9.0
11
+ Requires-Dist: qx-cache
12
+ Requires-Dist: qx-core
13
+ Requires-Dist: qx-di
14
+ Requires-Dist: qx-http
15
+ Description-Content-Type: text/markdown
16
+
17
+ # qx-auth
18
+
19
+ JWT validation, OIDC discovery, RBAC primitives, policy engine, and token-bucket rate limiting for the Qx framework.
20
+
21
+ ## What lives here
22
+
23
+ - **`qx.auth.JwtValidator`** — validates and decodes JWT access tokens. Supports RS256/ES256 via JWKS endpoint, audience and issuer validation, and a pluggable `RevocationCheck`.
24
+ - **`qx.auth.JwtSettings`** — Pydantic settings: JWKS URI, issuer, audience, algorithm, leeway.
25
+ - **`qx.auth.Principal`** — decoded token claims: `subject`, `email`, `roles`, `permissions`, `tenant_id`, raw claims dict.
26
+ - **`qx.auth.OidcDiscovery`** — fetches and caches the OpenID Connect discovery document (`/.well-known/openid-configuration`). Populates `JwtSettings` from the discovery endpoint automatically.
27
+ - **`qx.auth.OidcConfiguration`** — parsed OIDC discovery document.
28
+ - **`qx.auth.Role` / `Permission`** — value objects for RBAC. `Role` contains a set of `Permission` strings with wildcard matching (`orders.*` matches `orders.read`).
29
+ - **`qx.auth.PolicyEvaluator`** — evaluates a list of `Policy` objects against a `Principal`. Policies are composable with `require_permission`, `require_any_permission`, and `require_all_permissions`.
30
+ - **`qx.auth.TokenBucket`** — in-memory token-bucket rate limiter. Returns a `TokenBucketResult` with `allowed`, `remaining`, and `retry_after` — no exceptions.
31
+
32
+ ## Usage
33
+
34
+ ### JWT validation in a FastAPI route
35
+
36
+ ```python
37
+ from qx.auth import JwtValidator, Principal
38
+ from fastapi import Depends, HTTPException
39
+ from fastapi.security import HTTPBearer
40
+
41
+ security = HTTPBearer()
42
+ validator = JwtValidator(settings.jwt)
43
+
44
+ async def current_principal(token=Depends(security)) -> Principal:
45
+ result = await validator.validate(token.credentials)
46
+ if not result.is_success:
47
+ raise HTTPException(status_code=401)
48
+ return result.value
49
+ ```
50
+
51
+ ### Policy evaluation
52
+
53
+ ```python
54
+ from qx.auth import PolicyEvaluator, require_permission
55
+
56
+ evaluator = PolicyEvaluator([require_permission("users.write")])
57
+ decision = evaluator.evaluate(principal)
58
+ if not decision.allowed:
59
+ return Result.failure(ForbiddenError(...))
60
+ ```
61
+
62
+ ### Token-bucket rate limiting
63
+
64
+ ```python
65
+ from qx.auth import TokenBucket
66
+
67
+ bucket = TokenBucket(capacity=100, refill_rate=10) # 10 tokens/sec
68
+
69
+ result = bucket.consume(principal.subject)
70
+ if not result.allowed:
71
+ return Result.failure(RateLimitedError(retry_after=result.retry_after))
72
+ ```
73
+
74
+ ## Design rules
75
+
76
+ - `JwtValidator.validate()` returns `Result[Principal]` — it never raises. Callers decide how to translate validation failures to HTTP responses.
77
+ - JWKS are cached and refreshed lazily on key-ID miss so a key rotation doesn't require a restart.
78
+ - Permission wildcards follow a simple dot-separated scheme: `"orders.*"` grants all permissions starting with `"orders."`. Policies compose with AND (`require_all_permissions`) or OR (`require_any_permission`) semantics.
@@ -0,0 +1,62 @@
1
+ # qx-auth
2
+
3
+ JWT validation, OIDC discovery, RBAC primitives, policy engine, and token-bucket rate limiting for the Qx framework.
4
+
5
+ ## What lives here
6
+
7
+ - **`qx.auth.JwtValidator`** — validates and decodes JWT access tokens. Supports RS256/ES256 via JWKS endpoint, audience and issuer validation, and a pluggable `RevocationCheck`.
8
+ - **`qx.auth.JwtSettings`** — Pydantic settings: JWKS URI, issuer, audience, algorithm, leeway.
9
+ - **`qx.auth.Principal`** — decoded token claims: `subject`, `email`, `roles`, `permissions`, `tenant_id`, raw claims dict.
10
+ - **`qx.auth.OidcDiscovery`** — fetches and caches the OpenID Connect discovery document (`/.well-known/openid-configuration`). Populates `JwtSettings` from the discovery endpoint automatically.
11
+ - **`qx.auth.OidcConfiguration`** — parsed OIDC discovery document.
12
+ - **`qx.auth.Role` / `Permission`** — value objects for RBAC. `Role` contains a set of `Permission` strings with wildcard matching (`orders.*` matches `orders.read`).
13
+ - **`qx.auth.PolicyEvaluator`** — evaluates a list of `Policy` objects against a `Principal`. Policies are composable with `require_permission`, `require_any_permission`, and `require_all_permissions`.
14
+ - **`qx.auth.TokenBucket`** — in-memory token-bucket rate limiter. Returns a `TokenBucketResult` with `allowed`, `remaining`, and `retry_after` — no exceptions.
15
+
16
+ ## Usage
17
+
18
+ ### JWT validation in a FastAPI route
19
+
20
+ ```python
21
+ from qx.auth import JwtValidator, Principal
22
+ from fastapi import Depends, HTTPException
23
+ from fastapi.security import HTTPBearer
24
+
25
+ security = HTTPBearer()
26
+ validator = JwtValidator(settings.jwt)
27
+
28
+ async def current_principal(token=Depends(security)) -> Principal:
29
+ result = await validator.validate(token.credentials)
30
+ if not result.is_success:
31
+ raise HTTPException(status_code=401)
32
+ return result.value
33
+ ```
34
+
35
+ ### Policy evaluation
36
+
37
+ ```python
38
+ from qx.auth import PolicyEvaluator, require_permission
39
+
40
+ evaluator = PolicyEvaluator([require_permission("users.write")])
41
+ decision = evaluator.evaluate(principal)
42
+ if not decision.allowed:
43
+ return Result.failure(ForbiddenError(...))
44
+ ```
45
+
46
+ ### Token-bucket rate limiting
47
+
48
+ ```python
49
+ from qx.auth import TokenBucket
50
+
51
+ bucket = TokenBucket(capacity=100, refill_rate=10) # 10 tokens/sec
52
+
53
+ result = bucket.consume(principal.subject)
54
+ if not result.allowed:
55
+ return Result.failure(RateLimitedError(retry_after=result.retry_after))
56
+ ```
57
+
58
+ ## Design rules
59
+
60
+ - `JwtValidator.validate()` returns `Result[Principal]` — it never raises. Callers decide how to translate validation failures to HTTP responses.
61
+ - JWKS are cached and refreshed lazily on key-ID miss so a key rotation doesn't require a restart.
62
+ - Permission wildcards follow a simple dot-separated scheme: `"orders.*"` grants all permissions starting with `"orders."`. Policies compose with AND (`require_all_permissions`) or OR (`require_any_permission`) semantics.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "qx-auth"
3
+ version = "0.1.0"
4
+ description = "Qx auth: JWT validation, OIDC client, RBAC primitives, policy engine, rate limiting"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Qx Engineering" }]
9
+ dependencies = [
10
+ "qx-core",
11
+ "qx-di",
12
+ "qx-cache",
13
+ "qx-http",
14
+ "pyjwt[crypto]>=2.9.0",
15
+ "httpx>=0.27.0",
16
+ "cryptography>=43.0.0",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/qx"]
@@ -0,0 +1,45 @@
1
+ """Qx auth: JWT validation, OIDC, RBAC, policies, rate limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from qx.auth.jwt import JwtSettings, JwtValidator, Principal, RevocationCheck
6
+ from qx.auth.oidc import OidcConfiguration, OidcDiscovery
7
+ from qx.auth.policy import (
8
+ Decision,
9
+ Policy,
10
+ PolicyEvaluator,
11
+ PolicyResult,
12
+ require_all_permissions,
13
+ require_any_permission,
14
+ require_permission,
15
+ )
16
+ from qx.auth.rate_limit import TokenBucket, TokenBucketResult
17
+ from qx.auth.rbac import Permission, Role
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ # Policy
23
+ "Decision",
24
+ # JWT
25
+ "JwtSettings",
26
+ "JwtValidator",
27
+ # OIDC
28
+ "OidcConfiguration",
29
+ "OidcDiscovery",
30
+ # RBAC
31
+ "Permission",
32
+ "Policy",
33
+ "PolicyEvaluator",
34
+ "PolicyResult",
35
+ "Principal",
36
+ "RevocationCheck",
37
+ "Role",
38
+ # Rate limit
39
+ "TokenBucket",
40
+ "TokenBucketResult",
41
+ "__version__",
42
+ "require_all_permissions",
43
+ "require_any_permission",
44
+ "require_permission",
45
+ ]
@@ -0,0 +1,249 @@
1
+ """JWT validation.
2
+
3
+ Validates inbound JWTs against a configured issuer (typically Qx's own
4
+ IdP, or any OIDC-compliant authority). Public keys come from the issuer's
5
+ JWKS endpoint with TTL caching to avoid hammering the IdP on every request.
6
+
7
+ What we validate:
8
+ - Signature (RS256/ES256 by default; configurable list).
9
+ - ``iss`` matches the configured issuer.
10
+ - ``aud`` includes the configured audience.
11
+ - ``exp`` is in the future (with a small clock-skew tolerance).
12
+ - ``nbf`` is in the past (likewise).
13
+ - Optional: ``azp`` (authorized party) for OIDC.
14
+
15
+ What we don't do here:
16
+ - Token revocation: that needs a separate check against a revocation store
17
+ (Redis-backed JTI deny-list, or token introspection). Plug it in via the
18
+ ``revocation_check`` hook.
19
+
20
+ Output: a ``Principal`` value object that downstream code consumes — never
21
+ the raw claims dict. Keeps the security boundary clean.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Awaitable, Callable
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, ClassVar
29
+ from uuid import UUID
30
+
31
+ import httpx
32
+ import jwt
33
+ from jwt import PyJWKClient
34
+ from pydantic import Field
35
+ from pydantic_settings import BaseSettings, SettingsConfigDict
36
+ from qx.core import (
37
+ InfrastructureError,
38
+ Result,
39
+ UnauthorizedError,
40
+ )
41
+
42
+ __all__ = [
43
+ "JwtSettings",
44
+ "JwtValidator",
45
+ "Principal",
46
+ "RevocationCheck",
47
+ ]
48
+
49
+
50
+ @dataclass(frozen=True, kw_only=True)
51
+ class Principal:
52
+ """Authenticated identity.
53
+
54
+ The result of validating a JWT — pure value object, no JWT internals leak
55
+ past this point. Downstream policy / RBAC code consumes the ``permissions``
56
+ and ``roles`` collections.
57
+ """
58
+
59
+ subject: UUID
60
+ issuer: str
61
+ tenant_id: UUID | None = None
62
+ email: str | None = None
63
+ name: str | None = None
64
+ roles: frozenset[str] = field(default_factory=frozenset)
65
+ permissions: frozenset[str] = field(default_factory=frozenset)
66
+ scopes: frozenset[str] = field(default_factory=frozenset)
67
+ raw_claims: dict[str, Any] = field(default_factory=dict)
68
+
69
+ def has_role(self, role: str) -> bool:
70
+ return role in self.roles
71
+
72
+ def has_permission(self, permission: str) -> bool:
73
+ return permission in self.permissions
74
+
75
+ def has_scope(self, scope: str) -> bool:
76
+ return scope in self.scopes
77
+
78
+
79
+ RevocationCheck = Callable[[str], Awaitable[bool]]
80
+ """Async function taking a JTI; returns True if revoked."""
81
+
82
+
83
+ class JwtSettings(BaseSettings):
84
+ model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
85
+ env_prefix="QX_AUTH__JWT__",
86
+ extra="ignore",
87
+ )
88
+
89
+ issuer: str = Field(default="https://qx.local")
90
+ jwks_uri: str | None = None # if None, fetch from {issuer}/.well-known/jwks.json
91
+ audience: str = "qx"
92
+ allowed_algorithms: tuple[str, ...] = ("RS256", "ES256")
93
+ leeway_seconds: int = 30
94
+ cache_keys_ttl_seconds: int = 3600
95
+
96
+
97
+ class JwtValidator:
98
+ """Validate JWTs against a configured OIDC-compatible issuer."""
99
+
100
+ def __init__(
101
+ self,
102
+ settings: JwtSettings,
103
+ *,
104
+ revocation_check: RevocationCheck | None = None,
105
+ http_client: httpx.AsyncClient | None = None,
106
+ ) -> None:
107
+ self._settings = settings
108
+ self._revocation_check = revocation_check
109
+ self._http = http_client or httpx.AsyncClient(timeout=5.0)
110
+ self._jwks_uri = settings.jwks_uri or f"{settings.issuer.rstrip('/')}/.well-known/jwks.json"
111
+ # PyJWKClient caches keys internally with built-in TTL semantics.
112
+ self._jwk_client = PyJWKClient(
113
+ self._jwks_uri,
114
+ cache_keys=True,
115
+ lifespan=settings.cache_keys_ttl_seconds,
116
+ )
117
+
118
+ async def validate(self, token: str) -> Result[Principal]: # noqa: PLR0911
119
+ # Pull the right key for this token's `kid` header.
120
+ try:
121
+ signing_key = self._jwk_client.get_signing_key_from_jwt(token).key
122
+ except jwt.exceptions.PyJWKClientError as exc:
123
+ return Result.failure(
124
+ InfrastructureError(
125
+ code="auth.jwks_unavailable",
126
+ message=f"could not fetch signing key: {exc}",
127
+ cause=exc,
128
+ )
129
+ )
130
+ except Exception as exc:
131
+ return Result.failure(
132
+ UnauthorizedError(
133
+ code="auth.invalid_token",
134
+ message="token signature could not be verified",
135
+ cause=exc,
136
+ )
137
+ )
138
+
139
+ try:
140
+ claims = jwt.decode(
141
+ token,
142
+ key=signing_key,
143
+ algorithms=list(self._settings.allowed_algorithms),
144
+ audience=self._settings.audience,
145
+ issuer=self._settings.issuer,
146
+ leeway=self._settings.leeway_seconds,
147
+ options={"require": ["exp", "iat", "iss", "aud", "sub"]},
148
+ )
149
+ except jwt.ExpiredSignatureError as exc:
150
+ return Result.failure(
151
+ UnauthorizedError(
152
+ code="auth.token_expired",
153
+ message="token expired",
154
+ cause=exc,
155
+ )
156
+ )
157
+ except jwt.InvalidAudienceError as exc:
158
+ return Result.failure(
159
+ UnauthorizedError(
160
+ code="auth.invalid_audience",
161
+ message="token audience does not include this service",
162
+ cause=exc,
163
+ )
164
+ )
165
+ except jwt.InvalidIssuerError as exc:
166
+ return Result.failure(
167
+ UnauthorizedError(
168
+ code="auth.invalid_issuer",
169
+ message="token issuer is not trusted",
170
+ cause=exc,
171
+ )
172
+ )
173
+ except jwt.InvalidTokenError as exc:
174
+ return Result.failure(
175
+ UnauthorizedError(
176
+ code="auth.invalid_token",
177
+ message=f"token rejected: {exc}",
178
+ cause=exc,
179
+ )
180
+ )
181
+
182
+ # Revocation hook.
183
+ jti = claims.get("jti")
184
+ if jti and self._revocation_check is not None:
185
+ try:
186
+ if await self._revocation_check(jti):
187
+ return Result.failure(
188
+ UnauthorizedError(
189
+ code="auth.token_revoked",
190
+ message="token has been revoked",
191
+ )
192
+ )
193
+ except Exception as exc:
194
+ # Fail closed on revocation-check infrastructure errors.
195
+ return Result.failure(
196
+ InfrastructureError(
197
+ code="auth.revocation_check_failed",
198
+ message=f"revocation check failed: {exc}",
199
+ cause=exc,
200
+ )
201
+ )
202
+
203
+ # Map claims to Principal. Convention follows OIDC + a few Qx
204
+ # extensions (qx:permissions, qx:tenant).
205
+ try:
206
+ sub = UUID(claims["sub"])
207
+ except (KeyError, ValueError) as exc:
208
+ return Result.failure(
209
+ UnauthorizedError(
210
+ code="auth.invalid_subject",
211
+ message="sub claim is missing or not a UUID",
212
+ cause=exc,
213
+ )
214
+ )
215
+
216
+ tenant_raw = claims.get("qx:tenant") or claims.get("tenant_id")
217
+ tenant_id: UUID | None = None
218
+ if tenant_raw:
219
+ try:
220
+ tenant_id = UUID(tenant_raw)
221
+ except ValueError:
222
+ # Tenant present but malformed — treat as unauth rather than ignore.
223
+ return Result.failure(
224
+ UnauthorizedError(
225
+ code="auth.invalid_tenant",
226
+ message="tenant_id claim is malformed",
227
+ )
228
+ )
229
+
230
+ roles = claims.get("qx:roles") or claims.get("roles") or []
231
+ permissions = claims.get("qx:permissions") or claims.get("permissions") or []
232
+ scopes_raw = claims.get("scope") or claims.get("scp") or ""
233
+ scopes = scopes_raw.split() if isinstance(scopes_raw, str) else list(scopes_raw)
234
+
235
+ principal = Principal(
236
+ subject=sub,
237
+ issuer=claims["iss"],
238
+ tenant_id=tenant_id,
239
+ email=claims.get("email"),
240
+ name=claims.get("name") or claims.get("preferred_username"),
241
+ roles=frozenset(roles),
242
+ permissions=frozenset(permissions),
243
+ scopes=frozenset(scopes),
244
+ raw_claims=claims,
245
+ )
246
+ return Result.success(principal)
247
+
248
+ async def aclose(self) -> None:
249
+ await self._http.aclose()
@@ -0,0 +1,103 @@
1
+ """OIDC discovery client.
2
+
3
+ For services that need full OIDC discovery (not just JWT validation against a
4
+ known JWKS URI), this fetches the issuer's well-known config and exposes
5
+ endpoints with sensible TTL caching.
6
+
7
+ Used by:
8
+
9
+ - ``JwtValidator`` when no ``jwks_uri`` is configured — fall back to discovery.
10
+ - Services that initiate authorization code flows (rare server-to-server, but
11
+ used by admin tooling and console BFFs).
12
+
13
+ Out of scope: the actual auth-code flow, PKCE state management, token storage.
14
+ Qx's hosted UI handles browser-flow auth; this module is just the
15
+ plumbing for backend services to validate the resulting tokens.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from qx.core import InfrastructureError, Result
26
+
27
+ if TYPE_CHECKING:
28
+ import httpx
29
+
30
+ __all__ = ["OidcConfiguration", "OidcDiscovery"]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OidcConfiguration:
35
+ issuer: str
36
+ authorization_endpoint: str | None
37
+ token_endpoint: str | None
38
+ userinfo_endpoint: str | None
39
+ jwks_uri: str
40
+ end_session_endpoint: str | None
41
+ introspection_endpoint: str | None
42
+ raw: dict[str, Any]
43
+
44
+
45
+ class OidcDiscovery:
46
+ """Fetches and caches OIDC ``.well-known`` configuration."""
47
+
48
+ def __init__(
49
+ self,
50
+ http_client: httpx.AsyncClient,
51
+ *,
52
+ cache_ttl_seconds: int = 3600,
53
+ ) -> None:
54
+ self._http = http_client
55
+ self._ttl = cache_ttl_seconds
56
+ self._cache: dict[str, tuple[float, OidcConfiguration]] = {}
57
+ self._lock = asyncio.Lock()
58
+
59
+ async def fetch(self, issuer: str) -> Result[OidcConfiguration]:
60
+ now = time.monotonic()
61
+ cached = self._cache.get(issuer)
62
+ if cached is not None and (now - cached[0]) < self._ttl:
63
+ return Result.success(cached[1])
64
+
65
+ async with self._lock:
66
+ # Double-check inside the lock.
67
+ cached = self._cache.get(issuer)
68
+ if cached is not None and (now - cached[0]) < self._ttl:
69
+ return Result.success(cached[1])
70
+ url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
71
+ try:
72
+ resp = await self._http.get(url, timeout=5.0)
73
+ resp.raise_for_status()
74
+ except Exception as exc:
75
+ return Result.failure(
76
+ InfrastructureError(
77
+ code="oidc.discovery_failed",
78
+ message=f"could not fetch {url}: {exc}",
79
+ cause=exc,
80
+ )
81
+ )
82
+ data = resp.json()
83
+ try:
84
+ config = OidcConfiguration(
85
+ issuer=data["issuer"],
86
+ authorization_endpoint=data.get("authorization_endpoint"),
87
+ token_endpoint=data.get("token_endpoint"),
88
+ userinfo_endpoint=data.get("userinfo_endpoint"),
89
+ jwks_uri=data["jwks_uri"],
90
+ end_session_endpoint=data.get("end_session_endpoint"),
91
+ introspection_endpoint=data.get("introspection_endpoint"),
92
+ raw=data,
93
+ )
94
+ except KeyError as exc:
95
+ return Result.failure(
96
+ InfrastructureError(
97
+ code="oidc.malformed_discovery",
98
+ message=f"required discovery field missing: {exc}",
99
+ cause=exc,
100
+ )
101
+ )
102
+ self._cache[issuer] = (now, config)
103
+ return Result.success(config)
@@ -0,0 +1,129 @@
1
+ """Policy evaluator.
2
+
3
+ The policy layer answers: "is this principal allowed to perform this action
4
+ on this resource?" Two flavors:
5
+
6
+ - ``require_permission(perm)`` — the bread-and-butter RBAC check.
7
+ - ``Policy`` — a composable predicate over (Principal, Resource) for cases
8
+ where pure RBAC isn't enough (e.g., "user can edit a comment if they own
9
+ it or have the ``moderate`` permission").
10
+
11
+ Decisions are ``Allow`` / ``Deny``. Deny wins on conflict — this is the
12
+ standard ABAC / Zanzibar convention; it's also less surprising for
13
+ developers reading auth code.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from qx.auth.rbac import Permission
23
+ from qx.core import ForbiddenError, Result
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Awaitable, Callable
27
+
28
+ from qx.auth.jwt import Principal
29
+
30
+ __all__ = [
31
+ "Decision",
32
+ "Policy",
33
+ "PolicyEvaluator",
34
+ "PolicyResult",
35
+ "require_all_permissions",
36
+ "require_any_permission",
37
+ "require_permission",
38
+ ]
39
+
40
+
41
+ class Decision(Enum):
42
+ ALLOW = "allow"
43
+ DENY = "deny"
44
+ NOT_APPLICABLE = "not_applicable"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PolicyResult:
49
+ decision: Decision
50
+ reason: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Policy:
55
+ """A named policy with an async predicate."""
56
+
57
+ name: str
58
+ rule: Callable[[Principal, Any], Awaitable[PolicyResult]]
59
+
60
+
61
+ def require_permission(permission: str) -> Policy:
62
+ """Policy that allows iff the principal has the named permission."""
63
+ perm = Permission(name=permission)
64
+
65
+ async def _rule(p: Principal, _resource: Any) -> PolicyResult:
66
+ if perm.matches(p.permissions):
67
+ return PolicyResult(Decision.ALLOW)
68
+ return PolicyResult(Decision.DENY, reason=f"missing required permission: {permission}")
69
+
70
+ return Policy(name=f"require_permission({permission})", rule=_rule)
71
+
72
+
73
+ def require_any_permission(*permissions: str) -> Policy:
74
+ perms = [Permission(name=p) for p in permissions]
75
+
76
+ async def _rule(p: Principal, _resource: Any) -> PolicyResult:
77
+ if any(perm.matches(p.permissions) for perm in perms):
78
+ return PolicyResult(Decision.ALLOW)
79
+ return PolicyResult(
80
+ Decision.DENY,
81
+ reason=f"missing any of: {', '.join(permissions)}",
82
+ )
83
+
84
+ return Policy(name=f"require_any({permissions})", rule=_rule)
85
+
86
+
87
+ def require_all_permissions(*permissions: str) -> Policy:
88
+ perms = [Permission(name=p) for p in permissions]
89
+
90
+ async def _rule(p: Principal, _resource: Any) -> PolicyResult:
91
+ missing = [perm.name for perm in perms if not perm.matches(p.permissions)]
92
+ if missing:
93
+ return PolicyResult(
94
+ Decision.DENY,
95
+ reason=f"missing required permissions: {', '.join(missing)}",
96
+ )
97
+ return PolicyResult(Decision.ALLOW)
98
+
99
+ return Policy(name=f"require_all({permissions})", rule=_rule)
100
+
101
+
102
+ class PolicyEvaluator:
103
+ """Compose policies and evaluate them against a principal."""
104
+
105
+ def __init__(self, *policies: Policy) -> None:
106
+ self._policies = policies
107
+
108
+ async def evaluate(
109
+ self,
110
+ principal: Principal,
111
+ resource: Any = None,
112
+ ) -> Result[None]:
113
+ """Return success if all policies allow; failure otherwise.
114
+
115
+ Deny semantics: as soon as any policy returns DENY, we short-circuit
116
+ with that reason. NOT_APPLICABLE policies are skipped (a policy that
117
+ doesn't have an opinion on this principal is treated as silent).
118
+ """
119
+ for policy in self._policies:
120
+ outcome = await policy.rule(principal, resource)
121
+ if outcome.decision is Decision.DENY:
122
+ return Result.failure(
123
+ ForbiddenError(
124
+ code="authz.denied",
125
+ message=outcome.reason or f"denied by {policy.name}",
126
+ details={"policy": policy.name},
127
+ )
128
+ )
129
+ return Result.success(None)
File without changes
@@ -0,0 +1,129 @@
1
+ """Token-bucket rate limiter backed by Redis.
2
+
3
+ A token bucket holds N tokens; consumers take a token to perform an action.
4
+ Tokens refill at rate R per second up to capacity N. If the bucket is empty,
5
+ the action is rejected with ``Result.failure(RateLimitedError(...))``.
6
+
7
+ Implementation: Lua script that atomically computes the current bucket
8
+ contents from a stored ``(last_refill_at, last_count)`` pair, decides whether
9
+ to take a token, and writes back. The Lua script avoids the read-then-write
10
+ race that plagues naive implementations.
11
+
12
+ Buckets are namespaced by ``(scope, key)`` — e.g., ``("login", user_id)`` or
13
+ ``("api", api_key)``. Same scope, same refill rate; different rates need
14
+ different bucket names.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING
22
+
23
+ from qx.core import RateLimitedError, Result
24
+
25
+ if TYPE_CHECKING:
26
+ import redis.asyncio as aioredis
27
+
28
+ __all__ = ["TokenBucket", "TokenBucketResult"]
29
+
30
+
31
+ _LUA_TAKE = """
32
+ -- KEYS[1] = bucket key
33
+ -- ARGV[1] = capacity, ARGV[2] = refill_per_second, ARGV[3] = now (float seconds)
34
+ -- ARGV[4] = take (int), ARGV[5] = ttl (int)
35
+ local capacity = tonumber(ARGV[1])
36
+ local refill = tonumber(ARGV[2])
37
+ local now = tonumber(ARGV[3])
38
+ local take = tonumber(ARGV[4])
39
+ local ttl = tonumber(ARGV[5])
40
+
41
+ local data = redis.call('HMGET', KEYS[1], 'count', 'last')
42
+ local count = tonumber(data[1])
43
+ local last = tonumber(data[2])
44
+
45
+ if count == nil then
46
+ count = capacity
47
+ last = now
48
+ end
49
+
50
+ local elapsed = now - last
51
+ if elapsed > 0 then
52
+ count = math.min(capacity, count + (elapsed * refill))
53
+ end
54
+
55
+ local allowed = 0
56
+ if count >= take then
57
+ count = count - take
58
+ allowed = 1
59
+ end
60
+
61
+ redis.call('HMSET', KEYS[1], 'count', count, 'last', now)
62
+ redis.call('EXPIRE', KEYS[1], ttl)
63
+ return {allowed, count}
64
+ """
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class TokenBucketResult:
69
+ allowed: bool
70
+ remaining: float
71
+
72
+
73
+ class TokenBucket:
74
+ """Token-bucket rate limiter for one (scope, key) pair."""
75
+
76
+ def __init__(
77
+ self,
78
+ client: aioredis.Redis,
79
+ *,
80
+ scope: str,
81
+ capacity: int,
82
+ refill_per_second: float,
83
+ namespace: str = "qx:rl",
84
+ ) -> None:
85
+ self._r = client
86
+ self._scope = scope
87
+ self._capacity = capacity
88
+ self._refill = refill_per_second
89
+ self._ns = namespace
90
+
91
+ def _key(self, key: str) -> str:
92
+ return f"{self._ns}:{self._scope}:{key}"
93
+
94
+ async def take(self, key: str, *, tokens: int = 1) -> TokenBucketResult:
95
+ # TTL should be long enough that an idle key stays around until it
96
+ # would fully refill — otherwise we'd lose state and grant new
97
+ # capacity. ``capacity / refill_per_second`` * 2 is a safe default.
98
+ ttl = max(int((self._capacity / max(self._refill, 0.001)) * 2), 60)
99
+ result = await self._r.eval( # type: ignore[misc]
100
+ _LUA_TAKE,
101
+ 1,
102
+ self._key(key),
103
+ str(self._capacity),
104
+ str(self._refill),
105
+ str(time.time()),
106
+ str(tokens),
107
+ str(ttl),
108
+ )
109
+ allowed = bool(result[0])
110
+ remaining = float(result[1])
111
+ return TokenBucketResult(allowed=allowed, remaining=remaining)
112
+
113
+ async def check(self, key: str, *, tokens: int = 1) -> Result[None]:
114
+ """Take a token and convert the outcome to a ``Result``."""
115
+ outcome = await self.take(key, tokens=tokens)
116
+ if outcome.allowed:
117
+ return Result.success(None)
118
+ return Result.failure(
119
+ RateLimitedError(
120
+ code="rate_limit.exceeded",
121
+ message=f"rate limit exceeded for {self._scope}:{key}",
122
+ details={
123
+ "scope": self._scope,
124
+ "remaining": outcome.remaining,
125
+ "capacity": self._capacity,
126
+ "refill_per_second": self._refill,
127
+ },
128
+ )
129
+ )
@@ -0,0 +1,74 @@
1
+ """RBAC primitives.
2
+
3
+ Permissions are dotted strings::
4
+
5
+ organization.read
6
+ organization.users.invite
7
+ billing.invoice.refund
8
+
9
+ Convention:
10
+ - First segment = resource scope (organization, billing, …)
11
+ - Last segment = action (read, write, delete, invite, refund, …)
12
+ - Wildcards allowed at any segment: ``billing.*`` (anything on billing),
13
+ ``organization.users.*`` (anything on users in an organization).
14
+
15
+ Roles are named bundles of permissions. The policy layer is permission-
16
+ centric, not role-centric — roles are user-facing labels; permissions are
17
+ what we actually check.
18
+
19
+ Matching:
20
+ - ``user.permissions`` resolves any wildcards at issue-time (JWT contains
21
+ expanded permissions, not roles → permissions; the IdP does that). The
22
+ matcher here just checks whether ``required`` is in the set.
23
+ - Policy code calls ``Permission.matches(required, granted_set)`` which
24
+ handles literal + wildcard matches.
25
+
26
+ Why not a generic ABAC engine here? ABAC is great but overkill for V1; you
27
+ implement it via a custom ``Behavior`` that pulls request attributes and runs
28
+ your own rules. The RBAC primitive is the 80% case.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+
35
+ __all__ = ["Permission", "Role"]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Permission:
40
+ """A required permission name."""
41
+
42
+ name: str
43
+
44
+ def matches(self, granted: frozenset[str]) -> bool:
45
+ """Check if any granted permission satisfies this requirement.
46
+
47
+ ``granted`` is the principal's expanded permission set. Wildcards in
48
+ granted permissions (``billing.*``) match any required permission
49
+ starting with the same prefix.
50
+ """
51
+ if self.name in granted:
52
+ return True
53
+ # Wildcard matching — walk granted set looking for ``prefix.*`` patterns.
54
+ for g in granted:
55
+ if g.endswith(".*"):
56
+ prefix = g[:-2]
57
+ if self.name == prefix or self.name.startswith(prefix + "."):
58
+ return True
59
+ if g == "*":
60
+ return True
61
+ return False
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Role:
66
+ """A named bundle of permissions.
67
+
68
+ The role-to-permissions mapping lives in the IdP (Qx itself). This
69
+ type is a convenience for downstream code that wants to reason about role
70
+ names — but actual authorization decisions check permissions, not roles.
71
+ """
72
+
73
+ name: str
74
+ permissions: frozenset[Permission] = frozenset()
@@ -0,0 +1,228 @@
1
+ """Auth unit tests.
2
+
3
+ Generates RSA key pair in-memory, mints test JWTs, exercises ``JwtValidator``
4
+ against them. No live IdP, no live Redis — pure logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ import jwt
14
+ import pytest
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+ from qx.auth import (
18
+ Decision,
19
+ JwtSettings,
20
+ JwtValidator,
21
+ Permission,
22
+ PolicyEvaluator,
23
+ Principal,
24
+ require_all_permissions,
25
+ require_any_permission,
26
+ require_permission,
27
+ )
28
+ from qx.auth.policy import Policy, PolicyResult
29
+
30
+ # ---- RSA key pair + JWKS fixture ----
31
+
32
+
33
+ @pytest.fixture(scope="module")
34
+ def rsa_key() -> Any:
35
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
36
+
37
+
38
+ @pytest.fixture(scope="module")
39
+ def private_pem(rsa_key: Any) -> bytes:
40
+ return rsa_key.private_bytes(
41
+ encoding=serialization.Encoding.PEM,
42
+ format=serialization.PrivateFormat.PKCS8,
43
+ encryption_algorithm=serialization.NoEncryption(),
44
+ )
45
+
46
+
47
+ @pytest.fixture(scope="module")
48
+ def public_pem(rsa_key: Any) -> bytes:
49
+ return rsa_key.public_key().public_bytes(
50
+ encoding=serialization.Encoding.PEM,
51
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
52
+ )
53
+
54
+
55
+ def _make_token(private_pem: bytes, **overrides: Any) -> str:
56
+ now = int(time.time())
57
+ claims = {
58
+ "iss": "https://test.qx",
59
+ "aud": "qx",
60
+ "sub": str(uuid4()),
61
+ "iat": now,
62
+ "exp": now + 60,
63
+ "email": "ada@example.com",
64
+ "name": "Ada Lovelace",
65
+ "qx:permissions": ["organization.read", "billing.invoice.read"],
66
+ "qx:roles": ["admin"],
67
+ "scope": "openid profile",
68
+ **overrides,
69
+ }
70
+ return jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": "test"})
71
+
72
+
73
+ class _StubJwkClient:
74
+ """Stand-in for PyJWKClient that just returns our static key."""
75
+
76
+ def __init__(self, signing_key: Any) -> None:
77
+ self._key = signing_key
78
+
79
+ def get_signing_key_from_jwt(self, _token: str) -> Any:
80
+ class _K:
81
+ pass
82
+
83
+ k = _K()
84
+ k.key = self._key
85
+ return k
86
+
87
+
88
+ # ---- JWT validation ----
89
+
90
+
91
+ async def test_valid_token_yields_principal(private_pem: bytes, public_pem: bytes) -> None:
92
+ settings = JwtSettings(issuer="https://test.qx", audience="qx")
93
+ validator = JwtValidator(settings)
94
+ validator._jwk_client = _StubJwkClient(public_pem)
95
+ token = _make_token(private_pem)
96
+ result = await validator.validate(token)
97
+ assert result.is_success
98
+ p = result.value
99
+ assert p.email == "ada@example.com"
100
+ assert "admin" in p.roles
101
+ assert Permission(name="organization.read").matches(p.permissions)
102
+
103
+
104
+ async def test_expired_token_rejected(private_pem: bytes, public_pem: bytes) -> None:
105
+ settings = JwtSettings(issuer="https://test.qx", audience="qx", leeway_seconds=0)
106
+ validator = JwtValidator(settings)
107
+ validator._jwk_client = _StubJwkClient(public_pem)
108
+ token = _make_token(private_pem, exp=int(time.time()) - 60, iat=int(time.time()) - 120)
109
+ result = await validator.validate(token)
110
+ assert result.is_failure
111
+ assert result.error.code == "auth.token_expired"
112
+
113
+
114
+ async def test_wrong_audience_rejected(private_pem: bytes, public_pem: bytes) -> None:
115
+ settings = JwtSettings(issuer="https://test.qx", audience="qx")
116
+ validator = JwtValidator(settings)
117
+ validator._jwk_client = _StubJwkClient(public_pem)
118
+ token = _make_token(private_pem, aud="someone-else")
119
+ result = await validator.validate(token)
120
+ assert result.is_failure
121
+ assert result.error.code == "auth.invalid_audience"
122
+
123
+
124
+ async def test_wrong_issuer_rejected(private_pem: bytes, public_pem: bytes) -> None:
125
+ settings = JwtSettings(issuer="https://test.qx", audience="qx")
126
+ validator = JwtValidator(settings)
127
+ validator._jwk_client = _StubJwkClient(public_pem)
128
+ token = _make_token(private_pem, iss="https://imposter")
129
+ result = await validator.validate(token)
130
+ assert result.is_failure
131
+ assert result.error.code == "auth.invalid_issuer"
132
+
133
+
134
+ async def test_invalid_signature_rejected(private_pem: bytes, public_pem: bytes) -> None:
135
+ # Generate a *different* keypair and sign with it; validator has the original public key.
136
+ rogue = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes(
137
+ encoding=serialization.Encoding.PEM,
138
+ format=serialization.PrivateFormat.PKCS8,
139
+ encryption_algorithm=serialization.NoEncryption(),
140
+ )
141
+ settings = JwtSettings(issuer="https://test.qx", audience="qx")
142
+ validator = JwtValidator(settings)
143
+ validator._jwk_client = _StubJwkClient(public_pem)
144
+ token = _make_token(rogue)
145
+ result = await validator.validate(token)
146
+ assert result.is_failure
147
+ assert result.error.code == "auth.invalid_token"
148
+
149
+
150
+ async def test_revoked_token_rejected(private_pem: bytes, public_pem: bytes) -> None:
151
+ async def revoked(_jti: str) -> bool:
152
+ return True
153
+
154
+ settings = JwtSettings(issuer="https://test.qx", audience="qx")
155
+ validator = JwtValidator(settings, revocation_check=revoked)
156
+ validator._jwk_client = _StubJwkClient(public_pem)
157
+ token = _make_token(private_pem, jti=str(uuid4()))
158
+ result = await validator.validate(token)
159
+ assert result.is_failure
160
+ assert result.error.code == "auth.token_revoked"
161
+
162
+
163
+ # ---- RBAC ----
164
+
165
+
166
+ def test_permission_literal_match() -> None:
167
+ assert Permission(name="billing.read").matches(frozenset({"billing.read"})) is True
168
+ assert Permission(name="billing.read").matches(frozenset({"billing.write"})) is False
169
+
170
+
171
+ def test_permission_wildcard_match() -> None:
172
+ granted = frozenset({"billing.*"})
173
+ assert Permission(name="billing.read").matches(granted) is True
174
+ assert Permission(name="billing.invoice.refund").matches(granted) is True
175
+ assert Permission(name="organization.read").matches(granted) is False
176
+
177
+
178
+ def test_permission_global_wildcard() -> None:
179
+ assert Permission(name="anything").matches(frozenset({"*"})) is True
180
+
181
+
182
+ # ---- Policy evaluator ----
183
+
184
+
185
+ def _principal(*permissions: str) -> Principal:
186
+ return Principal(
187
+ subject=uuid4(),
188
+ issuer="test",
189
+ permissions=frozenset(permissions),
190
+ )
191
+
192
+
193
+ async def test_require_permission_allows_when_present() -> None:
194
+ e = PolicyEvaluator(require_permission("billing.read"))
195
+ r = await e.evaluate(_principal("billing.read"))
196
+ assert r.is_success
197
+
198
+
199
+ async def test_require_permission_denies_when_absent() -> None:
200
+ e = PolicyEvaluator(require_permission("billing.read"))
201
+ r = await e.evaluate(_principal("organization.read"))
202
+ assert r.is_failure
203
+ assert r.error.code == "authz.denied"
204
+
205
+
206
+ async def test_require_any_permission() -> None:
207
+ e = PolicyEvaluator(require_any_permission("billing.read", "billing.read.limited"))
208
+ r = await e.evaluate(_principal("billing.read.limited"))
209
+ assert r.is_success
210
+
211
+
212
+ async def test_require_all_permissions() -> None:
213
+ e = PolicyEvaluator(require_all_permissions("billing.read", "billing.export"))
214
+ r = await e.evaluate(_principal("billing.read"))
215
+ assert r.is_failure
216
+ assert "billing.export" in (r.error.message or "")
217
+
218
+
219
+ async def test_custom_policy() -> None:
220
+ async def owner_only(p: Principal, resource: Any) -> PolicyResult:
221
+ if resource.get("owner_id") == str(p.subject):
222
+ return PolicyResult(Decision.ALLOW)
223
+ return PolicyResult(Decision.DENY, reason="not owner")
224
+
225
+ e = PolicyEvaluator(Policy(name="owner_only", rule=owner_only))
226
+ user = _principal()
227
+ assert (await e.evaluate(user, {"owner_id": str(user.subject)})).is_success
228
+ assert (await e.evaluate(user, {"owner_id": str(uuid4())})).is_failure