fastapi-gsap/gsap_broker/credentials/resolver.py
Tyler J King e744336385 fix: capability enforcement, credential safety, atomic delegations, input validation
C-6: ConnectorRuntime enforces capability_mask per operation.
     READ-only ACs cannot invoke MUTATE operations (wipe, lock, retire).
C-7: AC validated against database (exists, active, not expired)
     before connector invocation.
C-9: Delegated AC capability bounded by delegator's capability.
C-10: Command counter uses atomic SQL increment with limit check.
M-23: expire_stale() uses same atomic SQL pattern.

H-1: Sensitive credential fields hidden from repr/logs via repr=False.
H-2: Stub backend requires ALLOW_STUB_CREDENTIALS=true to activate.
H-3: Kerberos backend raises CredentialResolutionError instead of
     returning stub ticket.
H-4: Chronicle INTENT emitted before execution, RESULT after.
H-5: device_id validated as UUID before Graph API URL interpolation.
H-8: ConnectorRuntime enforces governance for all connector invocations.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 08:13:27 -04:00

235 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)
# Fix H-1: repr=False prevents AC data leaking into logs
authorization_context: dict = field(default_factory=dict, repr=False)
@dataclass
class KerberosCredential(Credential):
"""Short-lived Kerberos ticket for WinRM / PSRemoting."""
credential_type: str = field(default="kerberos", init=False)
# Fix H-1: repr=False prevents ticket bytes leaking into logs
ticket: bytes = field(default=b"", repr=False)
@dataclass
class OAuthCredential(Credential):
"""Short-lived OAuth token for API access."""
credential_type: str = field(default="oauth", init=False)
# Fix H-1: repr=False prevents access token leaking into logs
access_token: str = field(default="", repr=False)
@dataclass
class SSHCertCredential(Credential):
"""Short-lived SSH certificate."""
credential_type: str = field(default="ssh_cert", init=False)
# Fix H-1: repr=False prevents key material leaking into logs
certificate: str = field(default="", repr=False)
private_key: str = field(default="", repr=False)
# ── 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