dbb6b7abde
- 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)
260 lines
7.8 KiB
Python
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
|