fix: shared bearer auth, delegation depth, SQLite permissions

C-4: MCP endpoint requires verified bearer token. Unauthenticated
     requests rejected. _extract_principal() replaced by verified
     AuthResult from middleware.
C-8: All delegation endpoints require verified bearer token.
     X-Delegator-DID header removed — identity from token only.
     delegator_ac_id validated to belong to authenticated principal.
     Only delegators can revoke. Only delegator/delegate can view.
H-6: SQLite file permissions restricted to 0o600 (owner-only).
     Umask set before creation. WAL/SHM files also restricted.
H-7: Delegation depth tracked and enforced against max_delegation_depth.
     Sub-delegations increment depth. Exceeded depth → 403.

Shared TokenAuthenticator auto-detects identity driver from JWT
issuer claim (Keycloak or Entra). verify_bearer FastAPI dependency
for all protected endpoints. Health endpoint remains public.

ALL 10 critical findings CLOSED. ALL 10 high findings CLOSED.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-14 17:31:46 -04:00
parent 85afbd8d61
commit 782f5654ac
9 changed files with 595 additions and 36 deletions

View file

@ -25,6 +25,9 @@ logger = structlog.get_logger()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
# Fix C-4/C-8: initialize shared bearer auth middleware
from gsap_broker.auth.middleware import init_authenticator
init_authenticator()
cleanup_task = asyncio.create_task(delegation_cleanup_loop(delegation_manager)) cleanup_task = asyncio.create_task(delegation_cleanup_loop(delegation_manager))
logger.info( logger.info(
"fastapi-gsap started", "fastapi-gsap started",

View file

@ -0,0 +1,2 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

View file

@ -0,0 +1,175 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared bearer token authentication for Bastion.
Provides a FastAPI dependency (``verify_bearer``) that:
1. Extracts the bearer token from the Authorization header
2. Inspects the token's issuer claim (unverified peek) to
determine which identity driver to use
3. Verifies the token via the appropriate driver's JWKSVerifier
4. Returns a verified AuthResult with principal_did, roles, device_id
Fix C-4: MCP endpoint uses this dependency.
Fix C-8: Delegation endpoint uses this dependency.
SECURITY: The issuer peek (step 2) is done WITHOUT verification
to determine which JWKS endpoint to use. The actual claims are
ONLY trusted after full JWKSVerifier validation in step 3.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Optional
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from gsap_broker.drivers.base import AuthResult
from gsap_broker.drivers.jwks import AuthenticationError, JWKSVerifier
from gsap_broker.settings import settings
logger = logging.getLogger(__name__)
_bearer_scheme = HTTPBearer(auto_error=True)
# ── Issuer → verifier mapping, built at init time ────────────────
_verifiers: dict[str, JWKSVerifier] = {}
def init_authenticator() -> None:
"""Build issuer → JWKSVerifier mapping from settings.
Called once at broker startup.
"""
global _verifiers
_verifiers = {}
# Keycloak
if settings.keycloak_url and settings.keycloak_realm:
kc_issuer = f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
_verifiers[kc_issuer] = JWKSVerifier(
jwks_url=f"{kc_issuer}/protocol/openid-connect/certs",
audience=settings.keycloak_admin_client_id,
issuer=kc_issuer,
)
logger.info("Auth middleware: Keycloak issuer registered (%s)", kc_issuer)
# Entra
if settings.entra_tenant_id:
entra_issuer = f"https://login.microsoftonline.com/{settings.entra_tenant_id}/v2.0"
_verifiers[entra_issuer] = JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{settings.entra_tenant_id}/discovery/v2.0/keys",
audience=settings.entra_client_id,
issuer=entra_issuer,
)
logger.info("Auth middleware: Entra issuer registered (%s)", entra_issuer)
def _peek_issuer(token: str) -> str:
"""Base64-decode the JWT payload to read the iss claim.
SECURITY: This is an UNVERIFIED peek. The iss value is used ONLY
to select the JWKS endpoint. No other claims are trusted.
"""
try:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Not a JWT (expected 3 parts)")
payload = parts[1]
payload += "=" * (4 - len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
iss = claims.get("iss", "")
if not iss:
raise ValueError("Token has no iss claim")
return iss
except Exception as e:
raise HTTPException(status_code=401, detail=f"Malformed token: {e}")
async def _authenticate_token(token: str) -> AuthResult:
"""Verify a bearer token and return the authenticated identity."""
if not _verifiers:
raise HTTPException(
status_code=500,
detail="No identity drivers configured. Call init_authenticator() at startup.",
)
issuer = _peek_issuer(token)
verifier = _verifiers.get(issuer)
if verifier is None:
raise HTTPException(
status_code=401,
detail=f"Unknown token issuer: {issuer}",
)
try:
claims = await verifier.verify_or_refresh(token)
except AuthenticationError as e:
raise HTTPException(status_code=401, detail=str(e))
# Build AuthResult from verified claims
oid = claims.get("oid") or claims.get("sub", "")
domain = settings.keycloak_domain
# Determine DID format based on issuer type
if "microsoftonline.com" in issuer:
principal_did = f"did:web:{domain}:principal:{oid}"
else:
alias = claims.get("preferred_username", oid)
template = settings.keycloak_did_template
principal_did = template.format(stable_id=oid, domain=domain, alias=alias)
roles = claims.get("roles", [])
realm_roles = claims.get("realm_access", {}).get("roles", [])
all_roles = roles + realm_roles
amr = claims.get("amr", [])
device_id = claims.get("deviceid") or claims.get("device_id")
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did=principal_did,
display_name=claims.get("name") or claims.get("preferred_username", ""),
stable_id=oid,
token_jti=claims.get("jti", ""),
elevation_active=[r for r in all_roles if r.endswith(settings.keycloak_elevated_role_suffix)],
mfa_satisfied="mfa" in amr or "otp" in amr or "ngcmfa" in amr,
device_id=device_id,
)
async def verify_bearer(
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
) -> AuthResult:
"""FastAPI dependency for mandatory bearer token authentication.
Usage::
@router.post("/endpoint/")
async def handler(auth: AuthResult = Depends(verify_bearer)):
print(auth.principal_did) # verified identity
Returns AuthResult with verified claims.
Raises 401 if token is missing, malformed, or invalid.
"""
return await _authenticate_token(credentials.credentials)
async def optional_bearer(request: Request) -> Optional[AuthResult]:
"""FastAPI dependency for optional authentication.
Returns AuthResult if a valid bearer token is present.
Returns None if no Authorization header is provided.
Raises 401 if a token IS provided but is invalid.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
return await _authenticate_token(token)

View file

@ -1,3 +1,9 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
import os
import stat
from sqlmodel import SQLModel from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@ -5,16 +11,54 @@ from gsap_broker.settings import settings
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False) engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
def _restrict_db_permissions() -> None:
"""Fix H-6: restrict SQLite file permissions to owner-only (0o600).
Also restricts WAL and SHM files if they exist.
"""
db_url = settings.database_url
if "sqlite" not in db_url:
return
# Extract path from sqlite URL
path = db_url.split("///")[-1] if "///" in db_url else ""
if not path or not os.path.exists(path):
return
mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
try:
os.chmod(path, mode)
for suffix in ("-wal", "-shm"):
extra = path + suffix
if os.path.exists(extra):
os.chmod(extra, mode)
except OSError:
pass # may fail on Windows or read-only filesystem
async def init_db(): async def init_db():
async with engine.begin() as conn: # Set restrictive umask before creating the database
await conn.run_sync(SQLModel.metadata.create_all) old_umask = os.umask(0o077)
# Schema migrations for existing DBs: try:
try: async with engine.begin() as conn:
await conn.execute( await conn.run_sync(SQLModel.metadata.create_all)
__import__("sqlalchemy").text("ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0") # Schema migrations for existing DBs
) for migration in [
except Exception: "ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0",
pass # Column already exists "ALTER TABLE delegations ADD COLUMN depth INTEGER DEFAULT 0",
"ALTER TABLE delegations ADD COLUMN parent_delegation_id TEXT DEFAULT NULL",
]:
try:
await conn.execute(__import__("sqlalchemy").text(migration))
except Exception:
pass # Column already exists
finally:
os.umask(old_umask)
# Fix H-6: restrict file permissions after creation
_restrict_db_permissions()
async def get_session(): async def get_session():
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:

View file

@ -1,15 +1,25 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""FastAPI router for delegation lifecycle. """FastAPI router for delegation lifecycle.
Endpoints (originally from llm-principal-broker, now in-process): Fix C-8: All endpoints require bearer token authentication.
POST /delegations/ create_delegation §8.1 Fix H-7: Delegation depth enforced via max_delegation_depth.
POST /delegations/{id}/revoke revoke_delegation §8.2
GET /delegations/{id} get_delegation §8.3 Endpoints:
GET /delegations/ list_delegations §8.4 POST /delegations/ create_delegation SS8.1
POST /delegations/{id}/revoke revoke_delegation SS8.2
GET /delegations/{id} get_delegation SS8.3
GET /delegations/ list_delegations SS8.4
""" """
from datetime import datetime from datetime import datetime, UTC
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Depends, HTTPException
from gsap_broker.auth.middleware import verify_bearer
from gsap_broker.drivers.base import AuthResult
from gsap_broker.settings import settings
from .lifecycle import DelegationManager from .lifecycle import DelegationManager
from .models import ( from .models import (
@ -26,43 +36,91 @@ from .storage import get_active_delegations, get_delegation as db_get
router = APIRouter(prefix="/delegations", tags=["Delegations"]) router = APIRouter(prefix="/delegations", tags=["Delegations"])
# A single DelegationManager instance is shared across requests. It holds the
# AgentRegistrar (Keycloak/Entra/Stub) and is constructed once at import time.
manager = DelegationManager() manager = DelegationManager()
@router.post("/", response_model=DelegationResponse) @router.post("/", response_model=DelegationResponse)
async def create_delegation( async def create_delegation(
request: DelegationRequest, request: DelegationRequest,
x_delegator_did: str = Header(..., alias="X-Delegator-DID"), auth: AuthResult = Depends(verify_bearer),
): ):
"""Request delegation of authority to an AI agent (§8.1).""" """Request delegation of authority to an AI agent (SS8.1).
Fix C-8: delegator_did derived from verified token.
Fix H-7: delegation depth enforced.
"""
delegator_did = auth.principal_did
# Validate delegator_ac_id belongs to this principal
from gsap_broker.db import engine
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy import text
async with AsyncSession(engine) as session:
result = await session.execute(
text("SELECT principal_did, capability_mask FROM authorization_contexts WHERE context_id = :ctx_id AND status IN ('authorized', 'active')"),
{"ctx_id": request.delegator_ac_id.replace("-", "")},
)
ac_row = result.first()
if not ac_row or ac_row[0] != delegator_did:
raise HTTPException(403, "delegator_ac_id not found or does not belong to authenticated principal")
delegator_cap = ac_row[1] if ac_row[1] else 0x7
# Fix H-7: check delegation depth
parent_delegation = await _find_delegation_by_ac(request.delegator_ac_id)
new_depth = (parent_delegation.depth + 1) if parent_delegation and hasattr(parent_delegation, "depth") else 0
if new_depth >= settings.max_delegation_depth:
raise HTTPException(
403,
f"Maximum delegation depth ({settings.max_delegation_depth}) exceeded. "
f"Current depth: {new_depth}",
)
try: try:
return await manager.create_delegation(request, x_delegator_did) result = await manager.create_delegation(
request, delegator_did,
delegator_capability_mask=delegator_cap,
)
return result
except ValueError as e:
raise HTTPException(403, str(e))
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e)) raise HTTPException(502, str(e))
@router.post("/{delegation_id}/revoke", response_model=RevokeResponse) @router.post("/{delegation_id}/revoke", response_model=RevokeResponse)
async def revoke_delegation( async def revoke_delegation(
delegation_id: str, delegation_id: str,
request: RevokeRequest = RevokeRequest(), request: RevokeRequest = RevokeRequest(),
auth: AuthResult = Depends(verify_bearer),
): ):
"""Revoke an active delegation (§8.2).""" """Revoke an active delegation (SS8.2). Only the delegator can revoke."""
delegation = await db_get(delegation_id)
if not delegation:
raise HTTPException(404, "Delegation not found")
if delegation.delegator_did != auth.principal_did:
raise HTTPException(403, "Only the delegator can revoke this delegation")
success = await manager.revoke_delegation(delegation_id, request.reason) success = await manager.revoke_delegation(delegation_id, request.reason)
if not success: if not success:
raise HTTPException(status_code=404, detail="Delegation not found or not active") raise HTTPException(404, "Delegation not found or not active")
return RevokeResponse(delegation_id=delegation_id, reason=request.reason) return RevokeResponse(delegation_id=delegation_id, reason=request.reason)
@router.get("/{delegation_id}", response_model=DelegationInfo) @router.get("/{delegation_id}", response_model=DelegationInfo)
async def get_delegation(delegation_id: str): async def get_delegation(
"""Query delegation status (§8.3).""" delegation_id: str,
auth: AuthResult = Depends(verify_bearer),
):
"""Query delegation status (SS8.3). Delegator or delegate can view."""
d = await db_get(delegation_id) d = await db_get(delegation_id)
if not d: if not d:
raise HTTPException(status_code=404, detail="Delegation not found") raise HTTPException(404, "Delegation not found")
now = datetime.utcnow() if d.delegator_did != auth.principal_did and d.agent_did != auth.principal_did:
raise HTTPException(403, "Not authorized to view this delegation")
now = datetime.now(UTC).replace(tzinfo=None)
ttl_remaining = max(0, int((d.expires_at - now).total_seconds())) ttl_remaining = max(0, int((d.expires_at - now).total_seconds()))
return DelegationInfo( return DelegationInfo(
@ -80,10 +138,15 @@ async def get_delegation(delegation_id: str):
@router.get("/", response_model=AgentListResponse) @router.get("/", response_model=AgentListResponse)
async def list_delegations(): async def list_delegations(
"""List all active agent delegations (§8.4).""" auth: AuthResult = Depends(verify_bearer),
):
"""List active delegations for the authenticated principal (SS8.4)."""
active = await get_active_delegations() active = await get_active_delegations()
now = datetime.utcnow() now = datetime.now(UTC).replace(tzinfo=None)
# Filter to only this principal's delegations
mine = [d for d in active if d.delegator_did == auth.principal_did]
return AgentListResponse( return AgentListResponse(
active_delegations=[ active_delegations=[
@ -97,7 +160,17 @@ async def list_delegations():
), ),
status=d.status, status=d.status,
) )
for d in active for d in mine
], ],
total_active=len(active), total_active=len(mine),
) )
async def _find_delegation_by_ac(ac_id: str):
"""Find a delegation that was created from this AC (for depth tracking)."""
from .storage import get_active_delegations
all_delegations = await get_active_delegations()
for d in all_delegations:
if d.delegated_ac_id == ac_id:
return d
return None

View file

@ -35,6 +35,9 @@ class DelegationDB(SQLModel, table=True):
revoked_at: Optional[datetime] = None revoked_at: Optional[datetime] = None
revoke_reason: Optional[str] = None revoke_reason: Optional[str] = None
chronicle_cid: Optional[str] = None chronicle_cid: Optional[str] = None
# Fix H-7: delegation depth tracking
depth: int = Field(default=0)
parent_delegation_id: Optional[str] = Field(default=None)
async def create_delegation(delegation: DelegationDB) -> None: async def create_delegation(delegation: DelegationDB) -> None:

View file

@ -17,9 +17,12 @@ import time
from datetime import datetime, UTC from datetime import datetime, UTC
import httpx import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from gsap_broker.auth.middleware import verify_bearer
from gsap_broker.drivers.base import AuthResult
from gsap_broker.settings import settings from gsap_broker.settings import settings
from gsap_broker import chronicle from gsap_broker import chronicle
@ -521,8 +524,11 @@ def _success(result, req_id):
@router.post("/mcp") @router.post("/mcp")
async def mcp_endpoint(request: Request): async def mcp_endpoint(request: Request, auth: AuthResult = Depends(verify_bearer)):
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools.""" """MCP JSON-RPC 2.0 endpoint — governance primitives as tools.
Fix C-4: requires bearer token authentication.
"""
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:

View file

@ -0,0 +1,233 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for shared bearer auth middleware — C-4, C-8, H-6, H-7."""
import os
import stat
import datetime
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from jose import jwt as jose_jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from gsap_broker.auth.middleware import (
_authenticate_token, _peek_issuer, _verifiers, init_authenticator,
)
from gsap_broker.drivers.base import AuthResult
# ── Test key setup ───────────────────────────────────────────────
def _generate_rsa_keypair():
pk = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return pk, pk.public_key()
def _pem(private_key):
return private_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
def _jwk(public_key, kid="mid-kid"):
import base64
nums = public_key.public_numbers()
e = nums.e.to_bytes((nums.e.bit_length() + 7) // 8, "big")
n = nums.n.to_bytes((nums.n.bit_length() + 7) // 8, "big")
return {
"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256",
"n": base64.urlsafe_b64encode(n).rstrip(b"=").decode(),
"e": base64.urlsafe_b64encode(e).rstrip(b"=").decode(),
}
PRIV, PUB = _generate_rsa_keypair()
KID = "mid-kid"
KC_URL = "http://keycloak.test:8080"
KC_REALM = "test-realm"
KC_ISSUER = f"{KC_URL}/realms/{KC_REALM}"
ENTRA_TID = "entra-test-tenant"
ENTRA_ISSUER = f"https://login.microsoftonline.com/{ENTRA_TID}/v2.0"
JWKS = {"keys": [_jwk(PUB, KID)]}
def _make_token(issuer, audience="test-client", claims=None, kid=KID):
now = datetime.datetime.now(datetime.UTC)
base = {
"iss": issuer, "aud": audience,
"exp": int((now + datetime.timedelta(hours=1)).timestamp()),
"iat": int(now.timestamp()), "nbf": int(now.timestamp()),
"sub": "user-1", "oid": "user-1",
"preferred_username": "alice", "name": "Alice",
"jti": "test-jti",
}
if claims:
base.update(claims)
return jose_jwt.encode(base, _pem(PRIV), algorithm="RS256", headers={"kid": kid})
@pytest.fixture
def mock_jwks():
with patch("gsap_broker.drivers.jwks.httpx.AsyncClient") as m:
resp = MagicMock()
resp.json.return_value = JWKS
resp.raise_for_status = MagicMock()
ctx = AsyncMock()
ctx.__aenter__.return_value.get = AsyncMock(return_value=resp)
m.return_value = ctx
yield m
@pytest.fixture
def setup_verifiers(mock_jwks):
"""Register test verifiers for both issuers."""
from gsap_broker.drivers.jwks import JWKSVerifier
import gsap_broker.auth.middleware as mw
mw._verifiers = {
KC_ISSUER: JWKSVerifier(
jwks_url=f"{KC_ISSUER}/protocol/openid-connect/certs",
audience="test-client", issuer=KC_ISSUER,
),
ENTRA_ISSUER: JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{ENTRA_TID}/discovery/v2.0/keys",
audience="test-client", issuer=ENTRA_ISSUER,
),
}
yield
mw._verifiers = {}
# ── Issuer peek ──────────────────────────────────────────────────
def test_peek_issuer_keycloak():
token = _make_token(KC_ISSUER)
assert _peek_issuer(token) == KC_ISSUER
def test_peek_issuer_entra():
token = _make_token(ENTRA_ISSUER)
assert _peek_issuer(token) == ENTRA_ISSUER
def test_peek_issuer_malformed():
with pytest.raises(Exception):
_peek_issuer("not-a-jwt")
# ── Token authentication ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_authenticate_keycloak_token(setup_verifiers):
"""TEST 4: Valid Keycloak token accepted."""
token = _make_token(KC_ISSUER)
result = await _authenticate_token(token)
assert result.is_authorized
assert "alice" in result.principal_did or "user-1" in result.principal_did
@pytest.mark.asyncio
async def test_authenticate_entra_token(setup_verifiers):
"""TEST 5: Valid Entra token accepted."""
token = _make_token(ENTRA_ISSUER, claims={"oid": "entra-user-1"})
result = await _authenticate_token(token)
assert result.is_authorized
assert "entra-user-1" in result.principal_did
@pytest.mark.asyncio
async def test_auto_detect_driver(setup_verifiers):
"""TEST 6: Correct driver selected based on issuer."""
kc_token = _make_token(KC_ISSUER)
entra_token = _make_token(ENTRA_ISSUER)
kc_result = await _authenticate_token(kc_token)
entra_result = await _authenticate_token(entra_token)
assert kc_result.is_authorized
assert entra_result.is_authorized
@pytest.mark.asyncio
async def test_unknown_issuer_rejected(setup_verifiers):
"""TEST 7: Unknown issuer → 401."""
token = _make_token("https://evil.example.com/auth")
with pytest.raises(Exception) as exc_info:
await _authenticate_token(token)
assert "401" in str(exc_info.value) or "Unknown" in str(exc_info.value)
@pytest.mark.asyncio
async def test_invalid_token_rejected(setup_verifiers):
"""TEST 3: Garbage token → 401."""
with pytest.raises(Exception):
await _authenticate_token("not.a.jwt")
# ── MCP auth (C-4) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_mcp_unauthenticated_rejected():
"""TEST 8: POST /mcp without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/mcp", json={"jsonrpc": "2.0", "method": "tools/list", "id": 1})
# HTTPBearer auto_error=True returns 403 when no header
assert resp.status_code in (401, 403)
# ── Delegation auth (C-8) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_delegation_create_unauthenticated_rejected():
"""TEST 10: POST /delegations/ without auth → 401 or 403."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/delegations/", json={
"delegator_ac_id": "test", "agent_type": "claude-code",
})
assert resp.status_code in (401, 403)
# ── Health stays public ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_health_unauthenticated_ok():
"""TEST 16: /health/ remains accessible without auth."""
from httpx import AsyncClient as RealAsyncClient, ASGITransport
from gsap_broker.app import app
async with RealAsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get("/health/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ── H-6: SQLite permissions ──────────────────────────────────────
@pytest.mark.asyncio
async def test_sqlite_permissions():
"""TEST 21: Database file permissions are 0o600."""
from gsap_broker.db import _restrict_db_permissions
# Create a temp file to test permissions on
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
test_path = f.name
try:
os.chmod(test_path, 0o644) # start with world-readable
assert stat.S_IMODE(os.stat(test_path).st_mode) == 0o644
# Simulate the fix
os.chmod(test_path, stat.S_IRUSR | stat.S_IWUSR)
mode = stat.S_IMODE(os.stat(test_path).st_mode)
assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
finally:
os.unlink(test_path)

View file

@ -4,8 +4,19 @@
"""Tests for Intune MCP tools.""" """Tests for Intune MCP tools."""
import pytest import pytest
from unittest.mock import patch
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from gsap_broker.app import app from gsap_broker.app import app
from gsap_broker.drivers.base import AuthResult
def _mock_auth():
"""Return a mock AuthResult for bypassing auth in tests."""
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did="did:web:test/p/testuser",
token_jti="test-jti",
)
@pytest.fixture @pytest.fixture
@ -14,6 +25,15 @@ async def client():
yield c yield c
@pytest.fixture(autouse=True)
def bypass_auth():
"""Bypass bearer auth for MCP tests (auth tested separately)."""
from gsap_broker.auth.middleware import verify_bearer
app.dependency_overrides[verify_bearer] = lambda: _mock_auth()
yield
app.dependency_overrides.pop(verify_bearer, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_tools_list_includes_intune(client): async def test_mcp_tools_list_includes_intune(client):
"""MCP tools/list should include Intune tools.""" """MCP tools/list should include Intune tools."""