267 lines
10 KiB
Python
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)
|