Files
ITWorx_MCP_Hub-Public/tests/test_ingestion_auth.py
T

267 lines
10 KiB
Python

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)