Files
EvoScientist/tests/test_delegation_auth.py
T
m4 dbb6b7abde feat(model-registry): add delegation-JWT auth and config/snapshot HTTP API
- BFF service token (constant-time, plaintext or SHA-256 hash) plus
  X-Evo-Actor delegation JWT verification (ES256/RS256, iss/aud, <=60s
  lifetime, required claims, thread binding) with atomic jti anti-replay
- Config API: GET/PUT /api/model-registry, credential rotation endpoint,
  GET /api/models selector; PUT runs the section 9.2 save-time checks
  inside the registry write transaction after credential writes
- Snapshot API: create/bind/delete routes delegating to SnapshotService
  with thread/deployment binding checks and 9.5 unified error payloads
- Platform security config loader (config.yaml fields), OpenAPI export
  (scripts/export_model_registry_schema.py -> model_registry/openapi.json)
- Mount new routes in langgraph_dev/http.py; retire the legacy
  GET /api/models and POST /api/runtime-snapshots handlers
- Declare PyJWT>=2.8 (previously transitive); extend the 9.5 error code
  table with the HTTP-layer codes (400/401/403/422/500)
2026-07-21 09:28:55 +08:00

260 lines
7.8 KiB
Python

"""Delegation JWT and BFF service-token authentication tests (design doc 7.3).
Covers the正反例 the brief requires: missing/invalid service token,
malformed/expired/wrongly-signed delegation JWTs, missing claims, lifetime
over 60 seconds, wrong issuer/audience, unregistered deployments, scope
escalation, jti replay, and the thread-claim rules for config vs
thread-level routes. ``GET /api/model-registry`` serves as the probe route.
"""
from __future__ import annotations
import hashlib
import time
import uuid
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from starlette.applications import Starlette
from starlette.testclient import TestClient
from EvoScientist.model_registry.auth import BffAuthenticator
from EvoScientist.model_registry.endpoint_policy import EndpointPolicy
from EvoScientist.model_registry.http_api import (
ApiServices,
model_registry_routes,
)
from EvoScientist.model_registry.platform import DelegationPublicKey
from EvoScientist.model_registry.resolver import ModelRegistryResolver
from EvoScientist.model_registry.snapshots import SnapshotService
from EvoScientist.model_registry.store import ModelRuntimeStore
SERVICE_TOKEN = "bff-service-token"
_PRIVATE_KEY = ec.generate_private_key(ec.SECP256R1())
_PRIVATE_PEM = _PRIVATE_KEY.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
_PUBLIC_PEM = _PRIVATE_KEY.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
_OTHER_KEY = ec.generate_private_key(ec.SECP256R1())
_OTHER_PRIVATE_PEM = _OTHER_KEY.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
def _services(store: ModelRuntimeStore, **auth_overrides) -> ApiServices:
resolver = ModelRegistryResolver(store)
auth_kwargs = {
"service_token": SERVICE_TOKEN,
"service_token_hash": None,
"delegation_keys": (
DelegationPublicKey(deployment_id="webui-1", public_key=_PUBLIC_PEM),
),
"jti_store": store,
}
auth_kwargs.update(auth_overrides)
return ApiServices(
store=store,
resolver=resolver,
snapshot_service=SnapshotService(store, resolver),
endpoint_policy=EndpointPolicy(),
authenticator=BffAuthenticator(**auth_kwargs),
)
@pytest.fixture
def store(tmp_path):
return ModelRuntimeStore(config_dir=tmp_path)
@pytest.fixture
def client(store):
services = _services(store)
app = Starlette(routes=model_registry_routes(lambda: services))
return TestClient(app)
def _claims(**overrides):
now = int(time.time())
claims = {
"iss": "WebUI",
"aud": "EvoScientist",
"sub": "admin-1",
"scopes": ["model_config:read"],
"deployment_id": "webui-1",
"iat": now,
"exp": now + 30,
"jti": uuid.uuid4().hex,
}
claims.update(overrides)
return claims
def _encode(claims, pem=_PRIVATE_PEM):
return jwt.encode(claims, pem, algorithm="ES256")
def _headers(claims=None, *, token=SERVICE_TOKEN, pem=_PRIVATE_PEM):
headers = {}
if token is not None:
headers["Authorization"] = f"Bearer {token}"
if claims is not None:
headers["X-Evo-Actor"] = _encode(claims, pem)
return headers
def test_valid_token_and_delegation_pass(client):
response = client.get("/api/model-registry", headers=_headers(_claims()))
assert response.status_code == 200
def test_config_route_does_not_require_thread_claim(client):
claims = _claims()
assert "thread_id" not in claims
response = client.get("/api/model-registry", headers=_headers(claims))
assert response.status_code == 200
def test_missing_authorization_header_is_401(client):
response = client.get("/api/model-registry", headers={"X-Evo-Actor": "x"})
assert response.status_code == 401
body = response.json()
assert body["code"] == "UNAUTHENTICATED"
assert set(body) == {"code", "message", "details", "request_id"}
assert body["request_id"]
def test_wrong_service_token_is_401(client):
response = client.get(
"/api/model-registry",
headers=_headers(_claims(), token="not-the-token"),
)
assert response.status_code == 401
assert response.json()["code"] == "UNAUTHENTICATED"
def test_service_token_hash_configuration_passes(store):
services = _services(
store,
service_token=None,
service_token_hash=hashlib.sha256(SERVICE_TOKEN.encode()).hexdigest(),
)
app = Starlette(routes=model_registry_routes(lambda: services))
hashed_client = TestClient(app)
assert (
hashed_client.get(
"/api/model-registry", headers=_headers(_claims())
).status_code
== 200
)
assert (
hashed_client.get(
"/api/model-registry", headers=_headers(_claims(), token="wrong")
).status_code
== 401
)
def test_missing_delegation_jwt_is_401(client):
response = client.get(
"/api/model-registry",
headers={"Authorization": f"Bearer {SERVICE_TOKEN}"},
)
assert response.status_code == 401
def test_malformed_delegation_jwt_is_401(client):
response = client.get(
"/api/model-registry",
headers=_headers(None) | {"X-Evo-Actor": "not-a-jwt"},
)
assert response.status_code == 401
@pytest.mark.parametrize(
"missing", ["sub", "scopes", "deployment_id", "iat", "exp", "jti"]
)
def test_missing_required_claim_is_401(client, missing):
claims = _claims()
del claims[missing]
response = client.get("/api/model-registry", headers=_headers(claims))
assert response.status_code == 401
def test_expired_delegation_jwt_is_401(client):
now = int(time.time())
claims = _claims(iat=now - 90, exp=now - 60)
response = client.get("/api/model-registry", headers=_headers(claims))
assert response.status_code == 401
def test_lifetime_over_60_seconds_is_401(client):
now = int(time.time())
claims = _claims(iat=now, exp=now + 120)
response = client.get("/api/model-registry", headers=_headers(claims))
assert response.status_code == 401
def test_wrong_signature_is_401(client):
response = client.get(
"/api/model-registry",
headers=_headers(_claims(), pem=_OTHER_PRIVATE_PEM),
)
assert response.status_code == 401
def test_wrong_issuer_is_401(client):
response = client.get(
"/api/model-registry", headers=_headers(_claims(iss="not-webui"))
)
assert response.status_code == 401
def test_wrong_audience_is_401(client):
response = client.get(
"/api/model-registry", headers=_headers(_claims(aud="someone-else"))
)
assert response.status_code == 401
def test_unregistered_deployment_is_401(client):
response = client.get(
"/api/model-registry", headers=_headers(_claims(deployment_id="webui-x"))
)
assert response.status_code == 401
def test_insufficient_scope_is_403(client):
claims = _claims(scopes=["model:select"])
response = client.get("/api/model-registry", headers=_headers(claims))
assert response.status_code == 403
body = response.json()
assert body["code"] == "FORBIDDEN"
assert set(body) == {"code", "message", "details", "request_id"}
def test_jti_replay_is_401_delegation_replayed(client):
headers = _headers(_claims())
first = client.get("/api/model-registry", headers=headers)
assert first.status_code == 200
second = client.get("/api/model-registry", headers=headers)
assert second.status_code == 401
assert second.json()["code"] == "DELEGATION_REPLAYED"
def test_invalid_scopes_claim_is_401(client):
response = client.get(
"/api/model-registry", headers=_headers(_claims(scopes="model_config:read"))
)
assert response.status_code == 401