From 24eefe16999fab98edfbdae3d31aa154ac0cabd643cb1939da107c0983dd87ac Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Tue, 14 Apr 2026 05:57:52 -0400 Subject: [PATCH] feat(credentials): add Entra and Stub credential backends Entra backend resolves OAuth tokens via MSAL client_credentials (OBO flow wired in future sprint) and passes ACs through for Bascule. Kerberos stubbed pending hybrid environment config. Stub backend for dev/testing without real IdP. Signed-off-by: Tyler King --- gsap_broker/credentials/entra_backend.py | 174 +++++++++++++++++++++++ gsap_broker/credentials/stub_backend.py | 84 +++++++++++ 2 files changed, 258 insertions(+) create mode 100644 gsap_broker/credentials/entra_backend.py create mode 100644 gsap_broker/credentials/stub_backend.py diff --git a/gsap_broker/credentials/entra_backend.py b/gsap_broker/credentials/entra_backend.py new file mode 100644 index 0000000..d02da8c --- /dev/null +++ b/gsap_broker/credentials/entra_backend.py @@ -0,0 +1,174 @@ +# 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", + ) diff --git a/gsap_broker/credentials/stub_backend.py b/gsap_broker/credentials/stub_backend.py new file mode 100644 index 0000000..b2f456b --- /dev/null +++ b/gsap_broker/credentials/stub_backend.py @@ -0,0 +1,84 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 + +"""Stub credential backend for development and testing. + +Returns valid-looking credentials with short TTLs. NEVER use in +production — these credentials grant no actual access. + +Useful for: + - Running the connector framework locally without Entra/Vault + - Integration tests that verify the credential lifecycle + (acquire → use → discard) without real secrets infrastructure + - Verifying that transports handle credential types correctly +""" + +import logging +from datetime import datetime, timedelta, UTC + +from gsap_broker.credentials.resolver import ( + BasculeCredential, + Credential, + CredentialBackend, + KerberosCredential, + OAuthCredential, + SSHCertCredential, +) + +logger = logging.getLogger(__name__) + + +class StubCredentialBackend(CredentialBackend): + """Development/testing backend that returns mock credentials.""" + + @property + def supported_types(self) -> list[str]: + return ["bascule_ac", "kerberos", "oauth", "ssh_cert"] + + async def resolve( + self, + credential_type: str, + target: str, + ac_context: dict, + ) -> Credential: + expires_at = datetime.now(UTC) + timedelta(minutes=5) + scoped_to = ac_context.get("accord", {}).get("template", "stub") + + if credential_type == "bascule_ac": + return BasculeCredential( + target=target, + expires_at=expires_at, + scoped_to=scoped_to, + authorization_context=ac_context, + ) + + if credential_type == "kerberos": + return KerberosCredential( + target=target, + expires_at=expires_at, + scoped_to=scoped_to, + ticket=b"STUB_TICKET", + ) + + if credential_type == "oauth": + return OAuthCredential( + target=target, + expires_at=expires_at, + scoped_to=scoped_to, + access_token="stub-access-token", + ) + + if credential_type == "ssh_cert": + return SSHCertCredential( + target=target, + expires_at=expires_at, + scoped_to=scoped_to, + certificate="stub-cert", + private_key="stub-key", + ) + + # Should not happen if supported_types is checked first + raise ValueError(f"StubBackend: unsupported type '{credential_type}'") + + async def revoke(self, credential: Credential) -> None: + logger.debug("Stub revoke: %s for %s (no-op)", credential.credential_type, credential.target)