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>
This commit is contained in:
parent
1d24019544
commit
043693652a
2 changed files with 247 additions and 0 deletions
2
gsap_broker/credentials/__init__.py
Normal file
2
gsap_broker/credentials/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
245
gsap_broker/credentials/resolver.py
Normal file
245
gsap_broker/credentials/resolver.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# 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
|
||||
Loading…
Reference in a new issue