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 <tking@guildhouse.dev>
174 lines
6.3 KiB
Python
174 lines
6.3 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.
|
|
|
|
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",
|
|
)
|