# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Zero-credential-storage architecture for Bastion connectors. The broker holds authorization decisions (ACs). A pluggable secrets backend holds credentials. Transports acquire short-lived, scoped credentials at invocation time and discard them after use. **The broker is NEVER a credential store.** Why this matters: A compromised broker leaks authorization metadata — who is authorized to do what — but NEVER leaks the credentials needed to actually do it. Credentials live in Entra, Vault, SPIRE, or whatever secrets backend the deployment uses, and they come into existence only for the duration of a single connector invocation. Credential lifecycle: 1. Operator obtains an AC via ``/governance/authorize/``. 2. Operator (or MCP agent) invokes a connector. 3. The connector's ``SessionConnector.invoke()`` calls ``CredentialResolver.resolve()`` with the AC context. 4. The resolver dispatches to the correct ``CredentialBackend`` (Entra, Vault, Stub, etc.). 5. The backend issues a short-lived credential (max 5 min TTL) scoped to the target. 6. The transport uses the credential to establish a session. 7. After the operation completes (or fails), the credential is discarded. No reference is stored anywhere in the broker. Rust port note: The ``Credential`` hierarchy maps to a Rust enum with per-variant fields. ``CredentialBackend`` maps to an async trait. ``CredentialResolver`` maps to a struct holding a ``Vec>``. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime, timedelta, UTC from typing import Any, Optional # ── Credential types ───────────────────────────────────────────── @dataclass class Credential: """Base for short-lived, scoped credentials. Every credential has a ``credential_type``, a ``target`` it is valid for, an ``expires_at`` wall-clock deadline, and a human- readable ``scoped_to`` description of what it permits. """ credential_type: str target: str expires_at: datetime scoped_to: str = "" @property def expired(self) -> bool: return datetime.now(UTC) >= self.expires_at @dataclass class BasculeCredential(Credential): """The AC itself — Bascule validates ACs natively. Bascule (the governed-shell runtime) already knows how to validate ACs, so no separate secrets backend is involved. The credential IS the authorization context. """ credential_type: str = field(default="bascule_ac", init=False) authorization_context: dict = field(default_factory=dict) @dataclass class KerberosCredential(Credential): """Short-lived Kerberos ticket for WinRM / PSRemoting. Acquired via Entra Kerberos proxy (cloud trust) or via on-behalf-of flow to an on-prem KDC. The ticket bytes are an opaque blob consumed by pypsrp or similar. """ credential_type: str = field(default="kerberos", init=False) ticket: bytes = b"" @dataclass class OAuthCredential(Credential): """Short-lived OAuth token for API access. Acquired via MSAL on-behalf-of flow, scoped to a specific resource (e.g. ``https://graph.microsoft.com``). """ credential_type: str = field(default="oauth", init=False) access_token: str = "" @dataclass class SSHCertCredential(Credential): """Short-lived SSH certificate. The private key is ephemeral — generated per session, signed by the CA (Vault, SPIRE, or Entra), and discarded after disconnect. """ credential_type: str = field(default="ssh_cert", init=False) certificate: str = "" private_key: str = "" # ── Errors ─────────────────────────────────────────────────────── class NoBackendAvailable(Exception): """No registered backend supports the requested credential type.""" def __init__(self, credential_type: str): self.credential_type = credential_type super().__init__( f"No credential backend supports type '{credential_type}'" ) class CredentialResolutionError(Exception): """A backend was found but failed to resolve the credential.""" # ── Backend protocol ───────────────────────────────────────────── class CredentialBackend(ABC): """Secrets backend that resolves credentials from ACs. Implementations talk to external secrets sources: Entra ID, HashiCorp Vault, SPIRE, AWS STS, etc. They MUST return credentials with an enforced TTL (max 5 minutes by default). """ @abstractmethod async def resolve( self, credential_type: str, target: str, ac_context: dict, ) -> Credential: """Acquire a short-lived credential for the target. Args: credential_type: One of ``bascule_ac``, ``kerberos``, ``oauth``, ``ssh_cert``. target: Where the credential will be used (hostname, URL, SPIFFE ID). ac_context: The serialized AuthorizationContext dict from AC issuance — contains principal_did, capability_mask, accord_template, etc. Returns: A ``Credential`` subclass with ``expires_at`` set. Raises: CredentialResolutionError: if the backend cannot issue a credential for this request. """ ... @abstractmethod async def revoke(self, credential: Credential) -> None: """Best-effort revoke before natural expiry. The credential's short TTL is the primary revocation mechanism. This call is for defense-in-depth (e.g. deleting a Vault lease, revoking an Entra token). Implementations MUST NOT raise on failure. """ ... @property @abstractmethod def supported_types(self) -> list[str]: """Credential types this backend can resolve.""" ... # ── Resolver ───────────────────────────────────────────────────── class CredentialResolver: """Routes credential requests to the first capable backend. Multiple backends can be registered. Resolution tries them in registration order and returns the first successful result. The resolver enforces two invariants that no backend can bypass: 1. Every returned credential MUST have ``expires_at`` set. 2. Every returned credential MUST NOT already be expired. """ MAX_TTL = timedelta(minutes=5) def __init__(self) -> None: self._backends: list[CredentialBackend] = [] def register(self, backend: CredentialBackend) -> None: self._backends.append(backend) async def resolve( self, credential_type: str, target: str, ac_context: dict, ) -> Credential: for backend in self._backends: if credential_type in backend.supported_types: credential = await backend.resolve( credential_type, target, ac_context ) if credential.expires_at is None: raise CredentialResolutionError( f"Backend {backend.__class__.__name__} returned " f"credential without expires_at" ) if credential.expired: raise CredentialResolutionError( f"Backend {backend.__class__.__name__} returned " f"already-expired credential" ) return credential raise NoBackendAvailable(credential_type) async def revoke(self, credential: Credential) -> None: """Delegate revocation to the backend that can handle this type.""" for backend in self._backends: if credential.credential_type in backend.supported_types: await backend.revoke(credential) return