from __future__ import annotations import asyncio import unittest from datetime import UTC, datetime, timedelta from uuid import uuid4 from services.control_api.auth.config import OIDCSettings from services.control_api.auth.errors import InvalidToken from services.control_api.auth.fake_provider import FakeOIDCProvider from services.control_api.auth.provider import OIDCProviderClient from services.runtime.ingestion_auth import ( ActorContext, authorize_actor, authorize_workspace_actor, build_ingestion_oidc_provider, create_actor_context, sanitize_ingestion_audit, ) class IngestionAuthTests(unittest.TestCase): def setUp(self) -> None: self.fake = FakeOIDCProvider( issuer="https://auth.example.test/application/o/ingestion/", audience="knowledge-ingestion", ) self.settings = OIDCSettings( issuer=self.fake.issuer, audience=self.fake.audience, client_id="knowledge-ingestion", redirect_uri="https://mcp.example.test/callback", ) self.provider = OIDCProviderClient(self.settings, transport=self.fake.transport()) self.project_id = str(uuid4()) self.now = datetime.now(UTC) def tearDown(self) -> None: self.provider.close() def token(self, **overrides: object) -> str: claims: dict[str, object] = { "sub": "external-actor-42", "iss": self.fake.issuer, "aud": self.fake.audience, "iat": int(self.now.timestamp()), "exp": int((self.now + timedelta(minutes=5)).timestamp()), "projects": [self.project_id], "scope": "knowledge:read ingestion:write", } claims.update(overrides) return self.fake._encode(claims) def test_actor_context_comes_from_a_signature_issuer_and_audience_validated_token(self) -> None: actor = asyncio.run( create_actor_context( f"Bearer {self.token()}", provider=self.provider, correlation_id="trace-2026-01", now=self.now, ) ) self.assertEqual(actor.subject, "external-actor-42") self.assertEqual(actor.projects, frozenset({self.project_id})) self.assertEqual(actor.scopes, frozenset({"knowledge:read", "ingestion:write"})) self.assertEqual(actor.correlation_id, "trace-2026-01") self.assertNotIn("Bearer", repr(actor)) def test_invalid_signature_issuer_and_audience_are_rejected(self) -> None: valid = self.token() with self.assertRaises(InvalidToken): asyncio.run( create_actor_context( f"Bearer {valid[:-3]}bad", provider=self.provider, correlation_id="trace-1" ) ) with self.assertRaises(InvalidToken): asyncio.run( create_actor_context( f"Bearer {self.token(iss='https://foreign.example.test/')}", provider=self.provider, correlation_id="trace-1", ) ) with self.assertRaises(InvalidToken): asyncio.run( create_actor_context( f"Bearer {self.token(aud='another-service')}", provider=self.provider, correlation_id="trace-1", ) ) def test_missing_or_malformed_project_and_scope_claims_fail_closed(self) -> None: for claims in ( {"projects": []}, {"projects": "not-an-array"}, {"scope": ["ingestion:write"]}, {"projects": ["../other-project"]}, {"scope": "ingestion:write unexpected scope\nforged"}, ): with self.subTest(claims=claims), self.assertRaises(InvalidToken): asyncio.run( create_actor_context( f"Bearer {self.token(**claims)}", provider=self.provider, correlation_id="trace-1", ) ) def test_authorization_requires_the_requested_scope_and_project(self) -> None: actor = ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", ) authorize_actor(actor, capability="knowledge:read", project=self.project_id) with self.assertRaises(PermissionError): authorize_actor(actor, capability="ingestion:write", project=self.project_id) with self.assertRaises(PermissionError): authorize_actor(actor, capability="knowledge:read", project=str(uuid4())) def test_workspace_identity_is_optional_and_preserved_only_as_explicit_trusted_context( self, ) -> None: ordinary = ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", ) trusted = ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", connector_project="knowledge-hub", connector_environment="prod", ) self.assertIsNone(ordinary.connector_project) self.assertIsNone(ordinary.connector_environment) self.assertEqual(trusted.connector_project, "knowledge-hub") self.assertEqual(trusted.connector_environment, "prod") def test_workspace_actor_requires_read_scope_and_trusted_connector_environment(self) -> None: trusted = ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", connector_project="knowledge-hub", connector_environment="prod", ) authorize_workspace_actor(trusted) untrusted_actors = ( ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", ), ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", connector_project="another-project", connector_environment="prod", ), ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"knowledge:read"}), correlation_id="trace-1", connector_project="knowledge-hub", connector_environment="dev", ), ActorContext( subject="external-actor-42", projects=frozenset({self.project_id}), scopes=frozenset({"ingestion:write"}), correlation_id="trace-1", connector_project="knowledge-hub", connector_environment="prod", ), ) for actor in untrusted_actors: with self.subTest(actor=actor), self.assertRaises(PermissionError): authorize_workspace_actor(actor) with self.assertRaises(PermissionError): authorize_workspace_actor(None) # type: ignore[arg-type] def test_additional_provider_settings_are_complete_and_exact(self) -> None: env = { "HUB_MCP_ADDITIONAL_OIDC_ISSUER": self.fake.issuer, "HUB_MCP_ADDITIONAL_OIDC_AUDIENCE": self.fake.audience, "HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID": "knowledge-ingestion", "HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI": "https://mcp.example.test/callback", } provider = build_ingestion_oidc_provider(environ=env, transport=self.fake.transport()) try: self.assertEqual(str(provider.settings.issuer), self.fake.issuer) self.assertEqual(provider.settings.audience, self.fake.audience) finally: provider.close() with self.assertRaises(ValueError): build_ingestion_oidc_provider( environ={"HUB_MCP_ADDITIONAL_OIDC_ISSUER": self.fake.issuer} ) def test_ingestion_audit_accepts_only_known_file_error_codes(self) -> None: audit = sanitize_ingestion_audit( { "tool": "extract_knowledge_files", "result": "failure", "status": 503, "latency_bucket": "lt_100ms", "correlation_id": "trace-safe", "error_code": "invalid_input", "input_reason": "attachment_file_id_invalid", "file_id": "private-file-reference", "download_url": "private-download-reference", "content": "private source content", } ) self.assertEqual( audit, { "tool": "extract_knowledge_files", "result": "failure", "status": 503, "latency_bucket": "lt_100ms", "correlation_id": "trace-safe", "error_code": "invalid_input", "input_reason": "attachment_file_id_invalid", }, ) for reason in ( "attachment_file_id_empty", "attachment_file_id_too_long", "attachment_file_id_non_printable", ): self.assertEqual( sanitize_ingestion_audit( {"tool": "extract_knowledge_files", "input_reason": reason} )["input_reason"], reason, ) unrecognized = sanitize_ingestion_audit( {"tool": "extract_knowledge_files", "error_code": "private-file-reference"} ) self.assertNotIn("error_code", unrecognized) private_reason = sanitize_ingestion_audit( {"tool": "extract_knowledge_files", "input_reason": "private-file-reference"} ) self.assertNotIn("input_reason", private_reason)