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:
parent
85afbd8d61
commit
782f5654ac
9 changed files with 595 additions and 36 deletions
|
|
@ -25,6 +25,9 @@ logger = structlog.get_logger()
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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))
|
||||
logger.info(
|
||||
"fastapi-gsap started",
|
||||
|
|
|
|||
2
gsap_broker/auth/__init__.py
Normal file
2
gsap_broker/auth/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
175
gsap_broker/auth/middleware.py
Normal file
175
gsap_broker/auth/middleware.py
Normal 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)
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||
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)
|
||||
|
||||
|
||||
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():
|
||||
# Set restrictive umask before creating the database
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
# Schema migrations for existing DBs:
|
||||
# Schema migrations for existing DBs
|
||||
for migration in [
|
||||
"ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0",
|
||||
"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("ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0")
|
||||
)
|
||||
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 with AsyncSession(engine) as session:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""FastAPI router for delegation lifecycle.
|
||||
|
||||
Endpoints (originally from llm-principal-broker, now in-process):
|
||||
POST /delegations/ create_delegation §8.1
|
||||
POST /delegations/{id}/revoke revoke_delegation §8.2
|
||||
GET /delegations/{id} get_delegation §8.3
|
||||
GET /delegations/ list_delegations §8.4
|
||||
Fix C-8: All endpoints require bearer token authentication.
|
||||
Fix H-7: Delegation depth enforced via max_delegation_depth.
|
||||
|
||||
Endpoints:
|
||||
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 .models import (
|
||||
|
|
@ -26,43 +36,91 @@ from .storage import get_active_delegations, get_delegation as db_get
|
|||
|
||||
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()
|
||||
|
||||
|
||||
@router.post("/", response_model=DelegationResponse)
|
||||
async def create_delegation(
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
raise HTTPException(502, str(e))
|
||||
|
||||
|
||||
@router.post("/{delegation_id}/revoke", response_model=RevokeResponse)
|
||||
async def revoke_delegation(
|
||||
delegation_id: str,
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/{delegation_id}", response_model=DelegationInfo)
|
||||
async def get_delegation(delegation_id: str):
|
||||
"""Query delegation status (§8.3)."""
|
||||
async def get_delegation(
|
||||
delegation_id: str,
|
||||
auth: AuthResult = Depends(verify_bearer),
|
||||
):
|
||||
"""Query delegation status (SS8.3). Delegator or delegate can view."""
|
||||
d = await db_get(delegation_id)
|
||||
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()))
|
||||
|
||||
return DelegationInfo(
|
||||
|
|
@ -80,10 +138,15 @@ async def get_delegation(delegation_id: str):
|
|||
|
||||
|
||||
@router.get("/", response_model=AgentListResponse)
|
||||
async def list_delegations():
|
||||
"""List all active agent delegations (§8.4)."""
|
||||
async def list_delegations(
|
||||
auth: AuthResult = Depends(verify_bearer),
|
||||
):
|
||||
"""List active delegations for the authenticated principal (SS8.4)."""
|
||||
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(
|
||||
active_delegations=[
|
||||
|
|
@ -97,7 +160,17 @@ async def list_delegations():
|
|||
),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ class DelegationDB(SQLModel, table=True):
|
|||
revoked_at: Optional[datetime] = None
|
||||
revoke_reason: 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:
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ import time
|
|||
from datetime import datetime, UTC
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
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 import chronicle
|
||||
|
||||
|
|
@ -521,8 +524,11 @@ def _success(result, req_id):
|
|||
|
||||
|
||||
@router.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools."""
|
||||
async def mcp_endpoint(request: Request, auth: AuthResult = Depends(verify_bearer)):
|
||||
"""MCP JSON-RPC 2.0 endpoint — governance primitives as tools.
|
||||
|
||||
Fix C-4: requires bearer token authentication.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
|
|
|
|||
233
tests/test_auth_middleware.py
Normal file
233
tests/test_auth_middleware.py
Normal 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)
|
||||
|
|
@ -4,8 +4,19 @@
|
|||
"""Tests for Intune MCP tools."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
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
|
||||
|
|
@ -14,6 +25,15 @@ async def client():
|
|||
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
|
||||
async def test_mcp_tools_list_includes_intune(client):
|
||||
"""MCP tools/list should include Intune tools."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue