feat(authorize): add Intune compliance-gated AC issuance

AC issuance can now require device compliance via Intune.
Configurable per-accord and globally. Disabled by default
for backward compatibility. Emits DEVICE_COMPLIANCE_CHECKED
Chronicle event. Adds device_id, device_compliant, and
compliance_checked_at fields to AuthorizationContext.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-14 05:24:03 -04:00
parent 871541f0eb
commit 03a99b4aff
3 changed files with 299 additions and 1 deletions

View file

@ -58,6 +58,9 @@ class AuthorizationContext(BaseModel):
identity_proof: IdentityProof identity_proof: IdentityProof
broker: dict = Field(default_factory=dict) broker: dict = Field(default_factory=dict)
signature: Optional[dict] = None signature: Optional[dict] = None
device_id: Optional[str] = None
device_compliant: Optional[bool] = None
compliance_checked_at: Optional[datetime] = None
class ChronicleEvidence(BaseModel): class ChronicleEvidence(BaseModel):
session_id: Optional[str] = None session_id: Optional[str] = None

View file

@ -1,6 +1,8 @@
"""POST /governance/authorize/ — GSAP §5.2""" """POST /governance/authorize/ — GSAP §5.2"""
import logging
import secrets, uuid import secrets, uuid
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from gsap_broker.db import get_session from gsap_broker.db import get_session
@ -12,6 +14,15 @@ from gsap_broker.models import (
from gsap_broker.settings import settings from gsap_broker.settings import settings
from gsap_broker import chronicle from gsap_broker import chronicle
logger = logging.getLogger(__name__)
# Accord templates that require device compliance.
# In production these would come from a database or config file.
_ACCORD_COMPLIANCE = {
"infrastructure-operations": {"device_compliance_required": True},
"device-management": {"device_compliance_required": True},
}
router = APIRouter() router = APIRouter()
@ -71,6 +82,56 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
if not auth_result.is_authorized: if not auth_result.is_authorized:
raise HTTPException(status_code=403, detail=auth_result.denial_reason) raise HTTPException(status_code=403, detail=auth_result.denial_reason)
# ── Compliance gate ──────────────────────────────────────────
device_id: Optional[str] = auth_result.device_id
device_compliant: Optional[bool] = None
compliance_checked_at: Optional[datetime] = None
if settings.intune_enabled:
accord_policy = _ACCORD_COMPLIANCE.get(request.accord_template, {})
compliance_required = accord_policy.get(
"device_compliance_required", settings.intune_compliance_required
)
if compliance_required:
if not device_id:
if settings.intune_compliance_strict:
await chronicle.emit("DEVICE_COMPLIANCE_CHECKED", {
"event_code": "0x2801",
"principal_did": auth_result.principal_did,
"accord_template": request.accord_template,
"device_id": None,
"decision": "denied",
"reason": "no_device_identity",
})
raise HTTPException(
status_code=403,
detail="Device identity required for this accord template.",
)
# Permissive mode: allow without compliance fields
logger.info("Compliance required but no device_id — permissive mode, allowing")
else:
# Check compliance via Intune connector cache/API
compliance_state = await _check_device_compliance(device_id)
compliance_checked_at = datetime.now(UTC)
device_compliant = compliance_state
await chronicle.emit("DEVICE_COMPLIANCE_CHECKED", {
"event_code": "0x2801",
"principal_did": auth_result.principal_did,
"accord_template": request.accord_template,
"device_id": device_id,
"compliant": compliance_state,
"decision": "allowed" if compliance_state else "denied",
})
if not compliance_state:
raise HTTPException(
status_code=403,
detail="Device is not compliant. AC issuance denied.",
)
# ── End compliance gate ───────────────────────────────────────
now = datetime.now(UTC) now = datetime.now(UTC)
expires = now + timedelta(minutes=settings.ac_ttl_minutes) expires = now + timedelta(minutes=settings.ac_ttl_minutes)
ctx_id = uuid.uuid4() ctx_id = uuid.uuid4()
@ -85,7 +146,9 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess
accord=Accord(template=request.accord_template), accord=Accord(template=request.accord_template),
operation=Operation(playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid, parameters_cid=request.parameters_cid), operation=Operation(playbook=request.playbook, corpus_entry_cid=request.corpus_entry_cid, parameters_cid=request.parameters_cid),
identity_proof=IdentityProof(token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active, mfa_satisfied=auth_result.mfa_satisfied), identity_proof=IdentityProof(token_jti=auth_result.token_jti, elevation_active=auth_result.elevation_active, mfa_satisfied=auth_result.mfa_satisfied),
broker={"did": settings.broker_did, "name": settings.broker_name}) broker={"did": settings.broker_did, "name": settings.broker_name},
device_id=device_id, device_compliant=device_compliant,
compliance_checked_at=compliance_checked_at)
ac_db = AuthorizationContextDB( ac_db = AuthorizationContextDB(
context_id=ctx_id, principal_did=principal_did, driver_id=request.driver_id, context_id=ctx_id, principal_did=principal_did, driver_id=request.driver_id,
@ -108,3 +171,25 @@ async def authorize_poll(poll_token: str, db: AsyncSession = Depends(get_session
ac_db = result.first() ac_db = result.first()
if not ac_db: raise HTTPException(status_code=404, detail="Not found.") if not ac_db: raise HTTPException(status_code=404, detail="Not found.")
return AuthorizeResponse(status=ac_db.status, poll_token=poll_token) return AuthorizeResponse(status=ac_db.status, poll_token=poll_token)
async def _check_device_compliance(device_id: str) -> bool:
"""Check device compliance via the Intune connector cache or Graph API.
Returns True if compliant, False otherwise.
"""
try:
from gsap_broker.routers.connectors import _registry
intune = _registry.get("intune")
if intune is None:
logger.warning("Intune connector not registered — defaulting to compliant")
return True
from gsap_broker.connectors.base import ConnectorContext
ctx = ConnectorContext(gsap_context_id="compliance-gate")
result = await intune.invoke("get_compliance", {"device_id": device_id}, ctx)
if result.success and result.data:
return result.data.get("compliant", False)
return False
except Exception as e:
logger.error("Compliance check failed: %s", e)
return False

View file

@ -0,0 +1,210 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for compliance-gated AC issuance."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from httpx import AsyncClient, ASGITransport
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine
from gsap_broker.app import app
from gsap_broker import db as db_module
from gsap_broker.drivers.base import AuthResult
@pytest.fixture(autouse=True)
async def test_db():
engine = create_async_engine("sqlite+aiosqlite:///./test_compliance.db")
db_module.engine = engine
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def _mock_auth_result(device_id=None):
"""Return a mock auth result with optional device_id."""
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did="did:web:test/p/alice",
display_name="Alice",
token_jti="jti-test",
mfa_satisfied=True,
device_id=device_id,
)
def _authorize_body(accord_template="test-ops"):
return {
"playbook": "test",
"corpus_entry_cid": "sha256:" + "a" * 64,
"parameters_cid": "sha256:" + "b" * 64,
"accord_template": accord_template,
"driver_id": "keycloak",
}
# ── TEST 13: Compliance disabled by default ───────────────────
@pytest.mark.asyncio
async def test_compliance_disabled_by_default(client, mocker):
"""With default settings (intune_enabled=False), no compliance check runs."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(),
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_id"] is None
assert ac["device_compliant"] is None
# ── TEST 9: Compliant device → AC issued ──────────────────────
@pytest.mark.asyncio
async def test_compliance_required_compliant_device(client, mocker):
"""Compliance required + compliant device → AC issued with device metadata."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-123"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_id"] == "dev-123"
assert ac["device_compliant"] is True
assert ac["compliance_checked_at"] is not None
# ── TEST 10: Non-compliant device → 403 ──────────────────────
@pytest.mark.asyncio
async def test_compliance_required_noncompliant_device(client, mocker):
"""Compliance required + non-compliant device → 403."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-bad"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=False,
)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 403
assert "not compliant" in resp.json()["detail"].lower()
# ── TEST 11: No device_id + strict mode → 403 ────────────────
@pytest.mark.asyncio
async def test_compliance_strict_no_device_id(client, mocker):
"""Strict mode + no device_id → 403."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id=None),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_strict", True)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 403
assert "device identity required" in resp.json()["detail"].lower()
# ── TEST 12: No device_id + permissive mode → AC issued ──────
@pytest.mark.asyncio
async def test_compliance_permissive_no_device_id(client, mocker):
"""Permissive mode + no device_id → AC issued without compliance fields."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id=None),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_strict", False)
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_compliant"] is None
# ── TEST: Per-accord compliance override ──────────────────────
@pytest.mark.asyncio
async def test_per_accord_compliance_override(client, mocker):
"""Accord template 'infrastructure-operations' requires compliance even when global default is False."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-infra"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", False)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
resp = await client.post(
"/governance/authorize/",
json=_authorize_body(accord_template="infrastructure-operations"),
)
assert resp.status_code == 200
ac = resp.json()["authorization_context"]
assert ac["device_compliant"] is True
# ── TEST 14: Chronicle event emitted ─────────────────────────
@pytest.mark.asyncio
async def test_chronicle_event_on_compliance_check(client, mocker):
"""Compliance check emits DEVICE_COMPLIANCE_CHECKED Chronicle event."""
mocker.patch(
"gsap_broker.drivers.keycloak.KeycloakDriver.authenticate",
return_value=_mock_auth_result(device_id="dev-chron"),
)
mocker.patch("gsap_broker.routers.authorize.settings.intune_enabled", True)
mocker.patch("gsap_broker.routers.authorize.settings.intune_compliance_required", True)
mocker.patch(
"gsap_broker.routers.authorize._check_device_compliance",
new_callable=AsyncMock,
return_value=True,
)
chronicle_mock = mocker.patch("gsap_broker.routers.authorize.chronicle.emit", new_callable=AsyncMock, return_value="")
resp = await client.post("/governance/authorize/", json=_authorize_body())
assert resp.status_code == 200
# Find the DEVICE_COMPLIANCE_CHECKED call
compliance_calls = [
call for call in chronicle_mock.call_args_list
if call.args[0] == "DEVICE_COMPLIANCE_CHECKED"
]
assert len(compliance_calls) == 1
event_data = compliance_calls[0].args[1]
assert event_data["device_id"] == "dev-chron"
assert event_data["decision"] == "allowed"