124 lines
4.1 KiB
Python
124 lines
4.1 KiB
Python
"""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
|