# 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. Stubbed — 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 (requires krb5.conf pointing to the right KDC) For now returns a placeholder ticket. The PowerShell connector's transport is also stubbed, so this is consistent — both stubs will be replaced together when PSRemoting integration lands. """ logger.warning( "Kerberos credential resolution is stubbed — " "returning placeholder for target=%s", target, ) return KerberosCredential( target=target, expires_at=datetime.now(UTC) + timedelta(minutes=5), scoped_to=target, ticket=b"STUB_KERBEROS_TICKET", )