Spaces:
Configuration error
Configuration error
| """Caller identity. | |
| This module is the single source of caller identity. No router may accept a | |
| caller-supplied user or tenant id: `current_principal` is the only way to learn | |
| who is making a request, and it reads that exclusively from a verified token. | |
| """ | |
| from __future__ import annotations | |
| import uuid | |
| from collections.abc import Awaitable, Callable | |
| from dataclasses import dataclass, field | |
| from typing import Annotated, Any, Final | |
| import jwt | |
| from fastapi import Depends, Request | |
| from app.config import Settings, get_settings | |
| from app.exceptions import AuthenticationError, AuthorizationError | |
| from app.logging import get_logger | |
| logger = get_logger(__name__) | |
| BEARER_PREFIX = "bearer" | |
| class Principal: | |
| """The verified identity behind a request. | |
| Attributes: | |
| user_id: Subject of the access token. | |
| tenant_id: Owning tenant. Every tenant-scoped query filters on this. | |
| roles: Roles granted to this user within the tenant. | |
| """ | |
| user_id: uuid.UUID | |
| tenant_id: uuid.UUID | |
| roles: tuple[str, ...] = field(default=()) | |
| def require_role(self, *allowed: str) -> None: | |
| """Assert that this principal holds at least one of `allowed`. | |
| Args: | |
| *allowed: Role names, any one of which satisfies the check. | |
| Raises: | |
| AuthorizationError: If the principal holds none of them. | |
| """ | |
| if not set(allowed) & set(self.roles): | |
| raise AuthorizationError() | |
| def decode_access_token(token: str, settings: Settings | None = None) -> Principal: | |
| """Verify a JWT and project it into a ``Principal``. | |
| When ``supabase_auth_jwks_url`` is set (revised stack), validation uses | |
| Supabase's public JWKS endpoint. Otherwise falls back to a shared secret | |
| (current custom-JWT mode). | |
| Signature, expiry, issuer, and audience are all verified. The accepted | |
| algorithm list comes from configuration and never includes ``none``. | |
| Args: | |
| token: The raw JWT, without the "Bearer " prefix. | |
| settings: Optional override; defaults to the process settings. | |
| Returns: | |
| The verified principal. | |
| Raises: | |
| AuthenticationError: If the token is malformed, expired, signed with an | |
| unexpected key or algorithm, issued by an unexpected issuer, or is | |
| missing a claim required to establish tenancy. | |
| """ | |
| cfg = settings or get_settings() | |
| # Supabase Auth path — asymmetric JWKS validation | |
| if cfg.supabase_auth_jwks_url: | |
| return _decode_supabase_token(token, cfg) | |
| # Legacy shared-secret path | |
| return _decode_shared_secret_token(token, cfg) | |
| def _decode_shared_secret_token(token: str, cfg: Settings) -> Principal: | |
| """Verify a JWT against a shared HMAC secret.""" | |
| try: | |
| claims: dict[str, Any] = jwt.decode( | |
| token, | |
| cfg.jwt_secret, | |
| algorithms=list(cfg.jwt_algorithms), | |
| issuer=cfg.jwt_issuer, | |
| audience=cfg.jwt_audience, | |
| options={"require": ["exp", "sub"]}, | |
| ) | |
| except jwt.PyJWTError as err: | |
| logger.warning("auth_token_rejected", reason=type(err).__name__) | |
| raise AuthenticationError() from err | |
| try: | |
| user_id = uuid.UUID(str(claims["sub"])) | |
| tenant_id = uuid.UUID(str(claims["tenant_id"])) | |
| except (KeyError, ValueError) as err: | |
| logger.warning("auth_claims_invalid", reason=str(err)) | |
| raise AuthenticationError() from err | |
| raw_roles = claims.get("roles") or () | |
| roles = tuple(str(r) for r in raw_roles) if isinstance(raw_roles, list | tuple) else () | |
| return Principal(user_id=user_id, tenant_id=tenant_id, roles=roles) | |
| def _decode_supabase_token(token: str, cfg: Settings) -> Principal: | |
| """Verify a JWT against Supabase's JWKS endpoint. | |
| Supabase issues RS256-signed JWTs. The public key is fetched from | |
| the JWKS endpoint and cached. Claims structure: ``sub`` = user UUID, | |
| ``app_metadata.tenant_id`` custom claim for tenancy. | |
| """ | |
| from jwt import PyJWKClient | |
| try: | |
| jwks = PyJWKClient(cfg.supabase_auth_jwks_url, cache_keys=True) | |
| signing_key = jwks.get_signing_key_from_jwt(token) | |
| claims: dict[str, Any] = jwt.decode( | |
| token, | |
| signing_key.key, | |
| algorithms=["RS256"], | |
| audience=cfg.jwt_audience, | |
| options={"require": ["exp", "sub"]}, | |
| ) | |
| except jwt.PyJWTError as err: | |
| logger.warning("auth_supabase_token_rejected", reason=type(err).__name__) | |
| raise AuthenticationError() from err | |
| try: | |
| user_id = uuid.UUID(str(claims["sub"])) | |
| # tenant_id from custom claims set via Supabase Postgres hook | |
| app_meta = claims.get("app_metadata") or {} | |
| tenant_id = uuid.UUID(str(app_meta.get("tenant_id", claims.get("tenant_id", "")))) | |
| except (KeyError, ValueError) as err: | |
| logger.warning("auth_supabase_claims_invalid", reason=str(err)) | |
| raise AuthenticationError() from err | |
| raw_roles = claims.get("roles") or () | |
| roles = tuple(str(r) for r in raw_roles) if isinstance(raw_roles, list | tuple) else () | |
| return Principal(user_id=user_id, tenant_id=tenant_id, roles=roles) | |
| def _extract_bearer_token(request: Request) -> str: | |
| """Pull the bearer token out of the Authorization header. | |
| Args: | |
| request: The incoming request. | |
| Returns: | |
| The raw token. | |
| Raises: | |
| AuthenticationError: If the header is absent or not a Bearer credential. | |
| """ | |
| header = request.headers.get("Authorization") | |
| if not header: | |
| raise AuthenticationError() | |
| scheme, _, token = header.partition(" ") | |
| if scheme.lower() != BEARER_PREFIX or not token.strip(): | |
| # Truncate: the scheme is attacker-controlled and unbounded, and an | |
| # unbounded value written to logs is a flooding and injection vector. | |
| logger.warning("auth_bad_scheme", scheme=scheme[:32]) | |
| raise AuthenticationError() | |
| return token.strip() | |
| async def current_principal(request: Request) -> Principal: | |
| """FastAPI dependency yielding the verified caller. | |
| Args: | |
| request: The incoming request. | |
| Returns: | |
| The verified principal. | |
| Raises: | |
| AuthenticationError: If no valid credential is present. | |
| """ | |
| return decode_access_token(_extract_bearer_token(request)) | |
| CurrentPrincipal = Annotated[Principal, Depends(current_principal)] | |
| # Roles permitted to ingest documents and manage job descriptions. Read-only | |
| # roles (`viewer`, `auditor`) are deliberately excluded from write paths. | |
| WRITE_ROLES: Final = ("owner", "admin", "recruiter") | |
| READ_ROLES: Final = ("owner", "admin", "recruiter", "hiring_manager", "auditor", "viewer") | |
| def require_roles(*allowed: str) -> Callable[[Principal], Awaitable[Principal]]: | |
| """Build a dependency that admits only principals holding one of `allowed`. | |
| Authentication alone is not authorization. Without this, any validly signed | |
| token — regardless of the roles it carries — reaches every endpoint, and | |
| the `roles` claim is decorative. | |
| Args: | |
| *allowed: Role names, any one of which grants access. | |
| Returns: | |
| A FastAPI dependency yielding the principal when permitted. | |
| """ | |
| async def _guard(principal: CurrentPrincipal) -> Principal: | |
| """Admit the caller only if they hold a permitted role. | |
| Args: | |
| principal: The verified caller. | |
| Returns: | |
| The same principal, unchanged. | |
| Raises: | |
| AuthorizationError: If the caller holds none of the allowed roles. | |
| """ | |
| if not set(allowed) & set(principal.roles): | |
| logger.warning( | |
| "authorization_denied", | |
| user_id=str(principal.user_id), | |
| tenant_id=str(principal.tenant_id), | |
| required=list(allowed), | |
| ) | |
| raise AuthorizationError() | |
| return principal | |
| return _guard | |
| WritePrincipal = Annotated[Principal, Depends(require_roles(*WRITE_ROLES))] | |
| ReadPrincipal = Annotated[Principal, Depends(require_roles(*READ_ROLES))] | |