# 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
