fastapi-gsap/gsap_broker/credentials/resolver.py
Tyler J King 043693652a feat(credentials): add CredentialResolver and Credential types
Zero-credential-storage architecture. The broker holds ACs
(authorization). Pluggable CredentialBackend implementations
hold secrets. Transports acquire short-lived, scoped credentials
at invocation time and discard after use.

Credential types: BasculeCredential, KerberosCredential,
OAuthCredential, SSHCertCredential.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 05:57:00 -04:00

245 lines
8.2 KiB
Python

# 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<Box<dyn
CredentialBackend>>``.
"""
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