fastapi-gsap/gsap_broker/credentials/entra_backend.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

163 lines
6 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Entra credential backend — resolves credentials via Microsoft Entra ID.
For OAuth:
Uses MSAL on-behalf-of (OBO) flow to exchange the operator's Entra
identity (from the AC's identity_proof) for a scoped, short-lived
token targeting a specific resource. The broker never sees or stores
the operator's password — only the OBO assertion.
For Kerberos:
Uses Entra cloud Kerberos trust or on-behalf-of flow to an on-prem
KDC proxy. The actual Kerberos ticket bytes are acquired from the
KDC and returned to the transport. Stubbed in this sprint — hybrid
environments need site-specific KDC configuration.
For Bascule:
The AC is the credential. Bascule validates ACs natively, so no
external secrets source is involved. The backend simply wraps the
AC dict in a ``BasculeCredential``.
Rust port note:
MSAL has no Rust equivalent. The Rust port should use ``reqwest``
against the Entra OAuth2 endpoints directly (the OBO flow is a
single POST to the token endpoint).
"""
import logging
from datetime import datetime, timedelta, UTC
from gsap_broker.credentials.resolver import (
BasculeCredential,
Credential,
CredentialBackend,
CredentialResolutionError,
KerberosCredential,
OAuthCredential,
)
logger = logging.getLogger(__name__)
class EntraCredentialBackend(CredentialBackend):
"""Resolves credentials via Microsoft Entra ID."""
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
@property
def supported_types(self) -> list[str]:
return ["bascule_ac", "oauth", "kerberos"]
async def resolve(
self,
credential_type: str,
target: str,
ac_context: dict,
) -> Credential:
if credential_type == "bascule_ac":
return self._resolve_bascule(target, ac_context)
if credential_type == "oauth":
return await self._resolve_oauth(target, ac_context)
if credential_type == "kerberos":
return await self._resolve_kerberos(target, ac_context)
raise CredentialResolutionError(
f"EntraCredentialBackend does not support '{credential_type}'"
)
async def revoke(self, credential: Credential) -> None:
# OAuth tokens issued via OBO are short-lived (5 min) and
# cannot be individually revoked via Entra. Kerberos tickets
# expire naturally. Bascule ACs are revoked via the broker's
# AC lifecycle, not the credential backend.
logger.debug(
"Revoke requested for %s (no-op — TTL is primary revocation)",
credential.credential_type,
)
# ── Private ──────────────────────────────────────────────────
def _resolve_bascule(self, target: str, ac_context: dict) -> BasculeCredential:
"""Bascule accepts ACs directly — no secret needed."""
expires_raw = ac_context.get("expires_at")
if isinstance(expires_raw, str):
expires_at = datetime.fromisoformat(expires_raw)
elif isinstance(expires_raw, datetime):
expires_at = expires_raw
else:
expires_at = datetime.now(UTC) + timedelta(minutes=5)
return BasculeCredential(
target=target,
expires_at=expires_at,
scoped_to=ac_context.get("accord", {}).get("template", ""),
authorization_context=ac_context,
)
async def _resolve_oauth(self, target: str, ac_context: dict) -> OAuthCredential:
"""On-behalf-of flow: exchange operator identity for scoped token.
In production this calls MSAL's ``acquire_token_on_behalf_of``
using the operator's assertion (id_token or access_token from
the identity_proof in the AC). For this sprint we use the
app-level client_credentials flow as a stand-in, since the OBO
flow requires the operator's token to be available at invocation
time (which means the transport layer needs to forward it —
wired in a future sprint).
"""
try:
import msal
except ImportError:
raise CredentialResolutionError(
"msal package required for Entra OAuth resolution"
)
app = msal.ConfidentialClientApplication(
client_id=self.client_id,
client_credential=self.client_secret,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
)
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
if "access_token" not in result:
raise CredentialResolutionError(
f"Entra token acquisition failed: "
f"{result.get('error_description', result.get('error', 'unknown'))}"
)
return OAuthCredential(
target=target,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
scoped_to=target,
access_token=result["access_token"],
)
async def _resolve_kerberos(self, target: str, ac_context: dict) -> KerberosCredential:
"""Acquire Kerberos ticket via Entra cloud trust.
Fix H-3: raises instead of returning stub ticket. Actual
implementation depends on the hybrid environment:
- Pure Entra: use Entra Kerberos proxy (preview API)
- Hybrid with on-prem AD: use OBO to get a token, then
exchange for a Kerberos ticket via the KDC proxy
- Direct KDC: use kinit with the OBO token
Implementation deferred to hybrid environment sprint.
"""
raise CredentialResolutionError(
"Kerberos credential resolution not yet implemented. "
"Configure an on-premises KDC or use OAuth credentials."
)