diff --git a/gsap_broker/models/gsap.py b/gsap_broker/models/gsap.py index 598eb48..d0f2602 100644 --- a/gsap_broker/models/gsap.py +++ b/gsap_broker/models/gsap.py @@ -58,6 +58,9 @@ class AuthorizationContext(BaseModel): identity_proof: IdentityProof broker: dict = Field(default_factory=dict) signature: Optional[dict] = None + device_id: Optional[str] = None + device_compliant: Optional[bool] = None + compliance_checked_at: Optional[datetime] = None class ChronicleEvidence(BaseModel): session_id: Optional[str] = None diff --git a/gsap_broker/routers/authorize.py b/gsap_broker/routers/authorize.py index 5628f82..13a684e 100644 --- a/gsap_broker/routers/authorize.py +++ b/gsap_broker/routers/authorize.py @@ -1,6 +1,8 @@ """POST /governance/authorize/ — GSAP §5.2""" +import logging import secrets, uuid from datetime import datetime, timedelta, UTC +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel.ext.asyncio.session import AsyncSession 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 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() @@ -71,6 +82,56 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess if not auth_result.is_authorized: 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) expires = now + timedelta(minutes=settings.ac_ttl_minutes) ctx_id = uuid.uuid4() @@ -85,7 +146,9 @@ async def authorize(body: AuthorizeRequest, http_request: Request, db: AsyncSess accord=Accord(template=request.accord_template), 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), - 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( 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() if not ac_db: raise HTTPException(status_code=404, detail="Not found.") 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 diff --git a/tests/test_compliance_gate.py b/tests/test_compliance_gate.py new file mode 100644 index 0000000..6ef43cd --- /dev/null +++ b/tests/test_compliance_gate.py @@ -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"