Files
ITWorx_MCP_Hub-Public/services/control_api/auth/machine.py
T

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