Spaces:
Running
Running
| from __future__ import annotations | |
| import base64 | |
| import hashlib | |
| from datetime import datetime, timedelta, timezone | |
| from pathlib import Path | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from sqlalchemy import select | |
| from app.container import build_container | |
| from app.core.config import Settings | |
| from app.mcp.registry import MCPRegistry | |
| from app.security.context import auth_context | |
| from app.security.errors import APIKeyConflictError, ForbiddenError, RateLimitError, UnauthorizedError | |
| from app.security.models import APIKey, AuditLog | |
| from app.security.schemas import APIKeyCreate | |
| from app.security.service import APIKeyService | |
| from main import create_app | |
| def security_settings(tmp_path: Path, **overrides: object) -> Settings: | |
| values: dict[str, object] = { | |
| "_env_file": None, | |
| "temp_dir": tmp_path / "temp", | |
| "output_dir": tmp_path / "outputs", | |
| "database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", | |
| "auth_enabled": True, | |
| "auth_last_used_update_seconds": 0, | |
| "cleanup_interval_seconds": 3600, | |
| "whisper_model": "tiny", | |
| "max_workers": 1, | |
| } | |
| values.update(overrides) | |
| return Settings(**values) | |
| async def security_container(tmp_path: Path): | |
| container = build_container(security_settings(tmp_path)) | |
| await container.security_database.initialize() | |
| try: | |
| yield container | |
| finally: | |
| await container.security_database.close() | |
| async def create_key(container, **overrides: object) -> tuple[APIKey, str]: | |
| values: dict[str, object] = { | |
| "name": "Automation", | |
| "environment": "test", | |
| "role": None, | |
| "scopes": ["templates:read"], | |
| } | |
| values.update(overrides) | |
| return await container.api_keys.create(APIKeyCreate(**values), created_by="tests") | |
| async def test_key_generation_has_256_bits_and_database_never_stores_secret( | |
| security_container, | |
| ) -> None: | |
| record, secret = await create_key(security_container) | |
| environment, encoded_secret = secret.split("_", 2)[1:] | |
| raw_secret = base64.urlsafe_b64decode(encoded_secret + "=") | |
| assert environment == "test" | |
| assert len(raw_secret) == 32 | |
| assert record.key_prefix == f"mp_test_{encoded_secret[:8]}" | |
| assert record.key_hash == hashlib.sha256(secret.encode()).hexdigest() | |
| async with security_container.security_database.session() as session: | |
| stored = await session.get(APIKey, record.id) | |
| assert stored is not None | |
| assert secret not in vars(stored).values() | |
| assert not hasattr(stored, "api_key") | |
| async def test_authentication_rejects_invalid_expired_disabled_and_revoked_keys( | |
| security_container, | |
| ) -> None: | |
| active, active_secret = await create_key(security_container) | |
| assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id | |
| replacement = "A" if active_secret[-1] != "A" else "B" | |
| with pytest.raises(UnauthorizedError): | |
| await security_container.api_keys.authenticate(active_secret[:-1] + replacement) | |
| _, expired_secret = await create_key( | |
| security_container, | |
| name="Expired", | |
| expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), | |
| ) | |
| with pytest.raises(UnauthorizedError): | |
| await security_container.api_keys.authenticate(expired_secret) | |
| await security_container.api_keys.set_status(active.id, "disabled") | |
| with pytest.raises(UnauthorizedError): | |
| await security_container.api_keys.authenticate(active_secret) | |
| await security_container.api_keys.set_status(active.id, "active") | |
| assert (await security_container.api_keys.authenticate(active_secret)).api_key_id == active.id | |
| await security_container.api_keys.set_status(active.id, "revoked") | |
| with pytest.raises(UnauthorizedError): | |
| await security_container.api_keys.authenticate(active_secret) | |
| with pytest.raises(APIKeyConflictError): | |
| await security_container.api_keys.set_status(active.id, "disabled") | |
| with pytest.raises(APIKeyConflictError): | |
| await security_container.api_keys.set_status(active.id, "active") | |
| async def test_scope_enforcement_and_rotation_grace_period(security_container) -> None: | |
| old, old_secret = await create_key(security_container) | |
| context = await security_container.api_keys.authenticate(old_secret) | |
| security_container.api_keys.authorize(context, "templates:read") | |
| with pytest.raises(ForbiddenError): | |
| security_container.api_keys.authorize(context, "operations:execute") | |
| replacement, replacement_secret = await security_container.api_keys.rotate( | |
| old.id, 60, created_by="tests" | |
| ) | |
| assert replacement.rotated_from_id == old.id | |
| assert (await security_container.api_keys.authenticate(old_secret)).api_key_id == old.id | |
| assert ( | |
| await security_container.api_keys.authenticate(replacement_secret) | |
| ).api_key_id == replacement.id | |
| with pytest.raises(APIKeyConflictError): | |
| await security_container.api_keys.set_status(old.id, "disabled") | |
| async with security_container.security_database.session() as session: | |
| rotating = await session.get(APIKey, old.id) | |
| assert rotating is not None | |
| rotating.grace_expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) | |
| await session.commit() | |
| with pytest.raises(UnauthorizedError): | |
| await security_container.api_keys.authenticate(old_secret) | |
| assert (await security_container.api_keys.get(old.id)).status == "revoked" | |
| async def test_per_key_request_and_concurrent_job_limits(security_container) -> None: | |
| _, request_secret = await create_key( | |
| security_container, name="RPM", requests_per_minute=1 | |
| ) | |
| request_context = await security_container.api_keys.authenticate(request_secret) | |
| lease = await security_container.rate_limiter.acquire( | |
| request_context, is_job=False, is_upload=False, uploaded_bytes=0 | |
| ) | |
| await lease.release() | |
| with pytest.raises(RateLimitError) as rate_error: | |
| await security_container.rate_limiter.acquire( | |
| request_context, is_job=False, is_upload=False, uploaded_bytes=0 | |
| ) | |
| assert rate_error.value.retry_after >= 1 | |
| _, job_secret = await create_key( | |
| security_container, name="Concurrency", concurrent_jobs=1 | |
| ) | |
| job_context = await security_container.api_keys.authenticate(job_secret) | |
| running = await security_container.rate_limiter.acquire( | |
| job_context, is_job=True, is_upload=False, uploaded_bytes=0 | |
| ) | |
| with pytest.raises(RateLimitError): | |
| await security_container.rate_limiter.acquire( | |
| job_context, is_job=True, is_upload=False, uploaded_bytes=0 | |
| ) | |
| await running.release() | |
| next_job = await security_container.rate_limiter.acquire( | |
| job_context, is_job=True, is_upload=False, uploaded_bytes=0 | |
| ) | |
| await next_job.release() | |
| async def test_stdio_mcp_uses_shared_context_scopes_rate_limits_and_audit( | |
| security_container, | |
| ) -> None: | |
| _, secret = await create_key( | |
| security_container, name="MCP Reader", scopes=["mcp:read"] | |
| ) | |
| context = await security_container.api_keys.authenticate(secret) | |
| registry = MCPRegistry(security_container) | |
| unauthorized = await registry.run_metadata_tool("system_info", registry.system_info_data) | |
| token = auth_context.set(context) | |
| try: | |
| resource = await registry.safe_resource("version", registry.version_data) | |
| forbidden = await registry.run_metadata_tool("system_info", registry.system_info_data) | |
| finally: | |
| auth_context.reset(token) | |
| assert unauthorized["success"] is False | |
| assert unauthorized["error"]["code"] == "UNAUTHORIZED" | |
| assert resource["success"] is True | |
| assert forbidden["success"] is False | |
| assert forbidden["error"]["code"] == "FORBIDDEN" | |
| async with security_container.security_database.session() as session: | |
| logs = list((await session.scalars(select(AuditLog))).all()) | |
| assert {log.endpoint for log in logs} >= { | |
| "mcp://tools/resource.version", | |
| "mcp://tools/system_info", | |
| } | |
| def test_http_middleware_public_and_authentication_contracts(tmp_path: Path) -> None: | |
| material = APIKeyService.generate_material("test") | |
| settings = security_settings( | |
| tmp_path, | |
| auth_bootstrap_key_hash=material.key_hash, | |
| auth_bootstrap_key_prefix=material.key_prefix, | |
| auth_bootstrap_environment="test", | |
| auth_default_requests_per_minute=1000, | |
| ) | |
| application = create_app(settings) | |
| authorization = {"Authorization": f"Bearer {material.api_key}"} | |
| with TestClient(application) as client: | |
| for path in ("/", "/health", "/version", "/docs", "/openapi.json", "/redoc"): | |
| assert client.get(path).status_code == 200 | |
| missing = client.get("/v1/auth/context") | |
| malformed = client.get( | |
| "/v1/auth/context", headers={"Authorization": "Basic not-a-mediarouter-key"} | |
| ) | |
| invalid = client.get( | |
| "/v1/auth/context", headers={"Authorization": "Bearer mp_test_invalid"} | |
| ) | |
| for response in (missing, malformed, invalid): | |
| assert response.status_code == 401 | |
| assert response.json() == { | |
| "error": "Unauthorized", | |
| "message": "Invalid or expired API key.", | |
| } | |
| assert response.headers["www-authenticate"] == "Bearer" | |
| mcp_missing = client.post("/mcp/", json={"jsonrpc": "2.0", "id": 1}) | |
| assert mcp_missing.status_code == 401 | |
| identity = client.get("/v1/auth/context", headers=authorization) | |
| assert identity.status_code == 200 | |
| assert identity.json()["key_prefix"] == material.key_prefix | |
| assert "admin" in identity.json()["scopes"] | |
| created = client.post( | |
| "/v1/api-keys", | |
| headers=authorization, | |
| json={ | |
| "name": "Template Reader", | |
| "environment": "test", | |
| "role": None, | |
| "scopes": ["templates:read"], | |
| }, | |
| ) | |
| assert created.status_code == 201 | |
| limited_authorization = { | |
| "Authorization": f"Bearer {created.json()['api_key']}" | |
| } | |
| assert client.get("/v1/auth/context", headers=limited_authorization).status_code == 200 | |
| forbidden = client.get("/v1/health", headers=limited_authorization) | |
| assert forbidden.status_code == 403 | |
| assert forbidden.json() == { | |
| "error": "Forbidden", | |
| "message": "Missing required scope.", | |
| } | |
| mcp_forbidden = client.post( | |
| "/mcp/", | |
| headers=limited_authorization, | |
| json={ | |
| "jsonrpc": "2.0", | |
| "id": 1, | |
| "method": "tools/call", | |
| "params": {"name": "health", "arguments": {}}, | |
| }, | |
| ) | |
| assert mcp_forbidden.status_code == 403 | |
| audit_logs = client.get("/v1/audit-logs", headers=authorization) | |
| assert audit_logs.status_code == 200 | |
| entries = audit_logs.json() | |
| assert any( | |
| entry["endpoint"] == "/v1/auth/context" | |
| and entry["api_key_id"] == identity.json()["id"] | |
| and entry["response_code"] == 200 | |
| for entry in entries | |
| ) | |
| def test_http_rate_limit_returns_retry_after(tmp_path: Path) -> None: | |
| material = APIKeyService.generate_material("test") | |
| application = create_app( | |
| security_settings( | |
| tmp_path, | |
| auth_bootstrap_key_hash=material.key_hash, | |
| auth_bootstrap_key_prefix=material.key_prefix, | |
| auth_bootstrap_environment="test", | |
| auth_default_requests_per_minute=1, | |
| ) | |
| ) | |
| headers = {"Authorization": f"Bearer {material.api_key}"} | |
| with TestClient(application) as client: | |
| assert client.get("/v1/auth/context", headers=headers).status_code == 200 | |
| limited = client.get("/v1/auth/context", headers=headers) | |
| assert limited.status_code == 429 | |
| assert limited.json() == { | |
| "error": "Rate limit exceeded", | |
| "message": "Retry later.", | |
| } | |
| assert int(limited.headers["retry-after"]) >= 1 | |