From 043693652af26f12d3ea69e79e67a462083703b4a40faf81edd6073636fd49a6 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Tue, 14 Apr 2026 05:57:00 -0400 Subject: [PATCH] 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 --- gsap_broker/credentials/__init__.py | 2 + gsap_broker/credentials/resolver.py | 245 ++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 gsap_broker/credentials/__init__.py create mode 100644 gsap_broker/credentials/resolver.py diff --git a/gsap_broker/credentials/__init__.py b/gsap_broker/credentials/__init__.py new file mode 100644 index 0000000..db326d9 --- /dev/null +++ b/gsap_broker/credentials/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 diff --git a/gsap_broker/credentials/resolver.py b/gsap_broker/credentials/resolver.py new file mode 100644 index 0000000..105518f --- /dev/null +++ b/gsap_broker/credentials/resolver.py @@ -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>``. +""" + +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