"""Audience-bound machine principal mapping and bounded revocation cache.""" from __future__ import annotations import re import threading import time from collections.abc import Callable, Mapping from dataclasses import dataclass from uuid import UUID from .errors import InvalidToken, RevokedMachinePrincipal, UnknownMachinePrincipal from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, ValidatedClaims from .provider import OIDCProviderClient @dataclass(frozen=True) class MachineClientBinding: subject: str client_id: UUID enabled: bool = True class PrincipalMapper: """Maps trusted identity claims to roles, never to project/environment bindings.""" def __init__( self, *, role_group_map: Mapping[str, ManagementRole] | None = None, machine_subjects: Mapping[str, MachineClientBinding] | None = None, ) -> None: self.role_group_map = dict(role_group_map or {}) self.machine_subjects = dict(machine_subjects or {}) def map_claims(self, claims: ValidatedClaims) -> AuthenticatedPrincipal: machine = self.machine_subjects.get(claims.subject) if machine is not None: if not machine.enabled: raise UnknownMachinePrincipal() return AuthenticatedPrincipal( subject=claims.subject, issuer=claims.issuer, audience=claims.audience, kind=PrincipalKind.MACHINE_CLIENT, client_id=machine.client_id, machine_subject=claims.subject, expires_at=claims.expires_at, issued_at=claims.issued_at, ) roles = frozenset( role for group in claims.groups for role in [self.role_group_map.get(group)] if role is not None ) return AuthenticatedPrincipal( subject=claims.subject, issuer=claims.issuer, audience=claims.audience, kind=PrincipalKind.MANAGEMENT_USER, groups=claims.groups, roles=roles, expires_at=claims.expires_at, issued_at=claims.issued_at, ) class RevocationCache: """Caches revocation truth for at most 60 seconds; invalidation is immediate.""" def __init__(self, lookup: Callable[[UUID], bool], *, ttl_seconds: int = 60) -> None: self.lookup = lookup self.ttl_seconds = max(1, min(ttl_seconds, 60)) self._values: dict[UUID, tuple[bool, float]] = {} self._lock = threading.RLock() def is_revoked(self, client_id: UUID) -> bool: now = time.monotonic() with self._lock: cached = self._values.get(client_id) if cached is not None and cached[1] > now: return cached[0] value = bool(self.lookup(client_id)) with self._lock: self._values[client_id] = (value, now + self.ttl_seconds) return value def invalidate(self, client_id: UUID) -> None: with self._lock: self._values.pop(client_id, None) class MachinePrincipalAuthenticator: """Validates a Hub-audience bearer and enforces client registration/revocation.""" _bearer = re.compile(r"^Bearer ([A-Za-z0-9._~+/=-]{1,65536})$") def __init__( self, provider: OIDCProviderClient, mapper: PrincipalMapper, revocations: RevocationCache, ) -> None: self.provider = provider self.mapper = mapper self.revocations = revocations def authenticate( self, authorization_header: str | None, ) -> AuthenticatedPrincipal: if not authorization_header: raise InvalidToken() match = self._bearer.fullmatch(authorization_header.strip()) if match is None: raise InvalidToken() principal = self.mapper.map_claims(self.provider.validate_token(match.group(1))) if principal.kind is not PrincipalKind.MACHINE_CLIENT or principal.client_id is None: raise InvalidToken() if self.revocations.is_revoked(principal.client_id): raise RevokedMachinePrincipal() return principal