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>
245 lines
8.2 KiB
Python
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
|