311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Caller authentication and least-privilege policy for trusted ingestion."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
import jwt
|
|
|
|
from services.control_api.auth.config import OIDCSettings
|
|
from services.control_api.auth.errors import InvalidToken, ProviderUnavailable
|
|
from services.control_api.auth.provider import OIDCProviderClient
|
|
|
|
Capability = Literal["knowledge:read", "ingestion:write"]
|
|
_CAPABILITIES = frozenset({"knowledge:read", "ingestion:write"})
|
|
_SCOPE = re.compile(r"^[A-Za-z][A-Za-z0-9:._-]{0,63}$")
|
|
_SCOPES_CLAIM = re.compile(r"^[A-Za-z][A-Za-z0-9:._-]{0,63}(?: [A-Za-z][A-Za-z0-9:._-]{0,63})*$")
|
|
_CORRELATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
_MAX_TOKEN_LENGTH = 65_536
|
|
_MAX_PROJECTS = 256
|
|
_MAX_SCOPES = 128
|
|
|
|
SAFE_AUDIT_KEYS = frozenset(
|
|
{
|
|
"tool",
|
|
"result",
|
|
"status",
|
|
"latency_bucket",
|
|
"correlation_id",
|
|
"error_code",
|
|
"input_reason",
|
|
}
|
|
)
|
|
_INGESTION_TOOLS = frozenset(
|
|
{
|
|
"search_knowledge",
|
|
"get_knowledge_context",
|
|
"analyze_knowledge_files",
|
|
"extract_knowledge_files",
|
|
"get_workspace_roots",
|
|
"get_workspace_children",
|
|
"get_workspace_page",
|
|
"create_text_source_proposal",
|
|
"submit_ingestion_proposal",
|
|
"get_ingestion_status",
|
|
}
|
|
)
|
|
_SAFE_AUDIT_LABEL = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
|
_SAFE_INGESTION_ERROR_CODES = frozenset(
|
|
{
|
|
"invalid_input",
|
|
"url_not_allowed",
|
|
"source_limit",
|
|
"download_failed",
|
|
"download_limit",
|
|
"download_timeout",
|
|
"scan_unavailable",
|
|
"scan_failed",
|
|
"malware_detected",
|
|
"unsupported_format",
|
|
"mime_mismatch",
|
|
"extraction_failed",
|
|
"extraction_limit",
|
|
"extraction_timeout",
|
|
"configuration",
|
|
}
|
|
)
|
|
_SAFE_INGESTION_INPUT_REASONS = frozenset(
|
|
{
|
|
"actor_context_missing",
|
|
"attachment_not_object",
|
|
"attachment_unknown_fields",
|
|
"attachment_required_fields_missing",
|
|
"attachment_file_id_invalid",
|
|
"attachment_file_id_empty",
|
|
"attachment_file_id_too_long",
|
|
"attachment_file_id_non_printable",
|
|
"attachment_mime_type_invalid",
|
|
"attachment_file_name_invalid",
|
|
"files_container_invalid",
|
|
"duplicate_file_ids",
|
|
"acquired_file_id_mismatch",
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActorContext:
|
|
"""Small, immutable projection of a validated caller token."""
|
|
|
|
subject: str
|
|
projects: frozenset[str]
|
|
scopes: frozenset[str]
|
|
correlation_id: str
|
|
connector_project: str | None = None
|
|
connector_environment: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.subject, str) or not 1 <= len(self.subject) <= 512:
|
|
raise ValueError("actor subject is invalid")
|
|
if not isinstance(self.projects, frozenset) or len(self.projects) > _MAX_PROJECTS:
|
|
raise ValueError("actor projects are invalid")
|
|
for project in self.projects:
|
|
if not isinstance(project, str) or str(UUID(project)) != project.lower():
|
|
raise ValueError("actor projects are invalid")
|
|
if not isinstance(self.scopes, frozenset) or len(self.scopes) > _MAX_SCOPES:
|
|
raise ValueError("actor scopes are invalid")
|
|
if any(not isinstance(scope, str) or not _SCOPE.fullmatch(scope) for scope in self.scopes):
|
|
raise ValueError("actor scopes are invalid")
|
|
if not isinstance(self.correlation_id, str) or not _CORRELATION_ID.fullmatch(
|
|
self.correlation_id
|
|
):
|
|
raise ValueError("actor correlation identifier is invalid")
|
|
if self.connector_project is not None and (
|
|
not isinstance(self.connector_project, str)
|
|
or not 1 <= len(self.connector_project) <= 128
|
|
or any(
|
|
ord(character) < 0x21 or ord(character) > 0x7E
|
|
for character in self.connector_project
|
|
)
|
|
):
|
|
raise ValueError("actor connector project is invalid")
|
|
if self.connector_environment is not None and (
|
|
not isinstance(self.connector_environment, str)
|
|
or not 1 <= len(self.connector_environment) <= 128
|
|
or any(
|
|
ord(character) < 0x21 or ord(character) > 0x7E
|
|
for character in self.connector_environment
|
|
)
|
|
):
|
|
raise ValueError("actor connector environment is invalid")
|
|
|
|
|
|
def build_ingestion_oidc_provider(
|
|
*,
|
|
environ: Mapping[str, str] | None = None,
|
|
transport: httpx.BaseTransport | None = None,
|
|
) -> OIDCProviderClient:
|
|
"""Build the validator for the dedicated MCP resource's configured issuer."""
|
|
|
|
values = environ if environ is not None else os.environ
|
|
names = {
|
|
"issuer": "HUB_MCP_ADDITIONAL_OIDC_ISSUER",
|
|
"audience": "HUB_MCP_ADDITIONAL_OIDC_AUDIENCE",
|
|
"client_id": "HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID",
|
|
"redirect_uri": "HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI",
|
|
}
|
|
selected = {key: values.get(name, "").strip() for key, name in names.items()}
|
|
if not all(selected.values()):
|
|
raise ValueError("ingestion OIDC configuration is incomplete")
|
|
settings = OIDCSettings.from_env(
|
|
{
|
|
"HUB_OIDC_ISSUER": selected["issuer"],
|
|
"HUB_OIDC_AUDIENCE": selected["audience"],
|
|
"HUB_OIDC_CLIENT_ID": selected["client_id"],
|
|
"HUB_OIDC_REDIRECT_URI": selected["redirect_uri"],
|
|
"HUB_OIDC_ALLOWED_ALGORITHMS": values.get("HUB_OIDC_ALLOWED_ALGORITHMS", "RS256"),
|
|
"HUB_OIDC_CLOCK_SKEW_SECONDS": values.get("HUB_OIDC_CLOCK_SKEW_SECONDS", "60"),
|
|
}
|
|
)
|
|
return OIDCProviderClient(settings, transport=transport)
|
|
|
|
|
|
async def create_actor_context(
|
|
authorization_header: str | None,
|
|
*,
|
|
provider: OIDCProviderClient,
|
|
correlation_id: str,
|
|
now: datetime | None = None,
|
|
) -> ActorContext:
|
|
"""Validate the caller bearer before projecting delegated project/scope claims.
|
|
|
|
The provider performs issuer, audience, time and asymmetric-signature
|
|
validation. Additional signed claims are read only after that validation.
|
|
"""
|
|
|
|
if (
|
|
not isinstance(authorization_header, str)
|
|
or len(authorization_header) > _MAX_TOKEN_LENGTH + 7
|
|
or not authorization_header.startswith("Bearer ")
|
|
):
|
|
raise InvalidToken()
|
|
token = authorization_header[7:].strip()
|
|
if not token or len(token) > _MAX_TOKEN_LENGTH or " " in token:
|
|
raise InvalidToken()
|
|
if not isinstance(correlation_id, str) or not _CORRELATION_ID.fullmatch(correlation_id):
|
|
raise InvalidToken()
|
|
|
|
try:
|
|
claims = await asyncio.to_thread(provider.validate_token, token, now=now)
|
|
except ProviderUnavailable:
|
|
# Avoid retaining provider URLs or transport details in exception chains.
|
|
raise ProviderUnavailable() from None
|
|
try:
|
|
raw_claims = jwt.decode(
|
|
token,
|
|
options={
|
|
"verify_signature": False,
|
|
"verify_exp": False,
|
|
"verify_iat": False,
|
|
"verify_nbf": False,
|
|
"verify_iss": False,
|
|
"verify_aud": False,
|
|
},
|
|
algorithms=list(provider.settings.allowed_algorithms),
|
|
)
|
|
except (jwt.PyJWTError, TypeError, ValueError) as exc:
|
|
raise InvalidToken() from exc
|
|
if not isinstance(raw_claims, dict):
|
|
raise InvalidToken()
|
|
|
|
projects_claim = raw_claims.get("projects")
|
|
scopes_claim = raw_claims.get("scope")
|
|
if (
|
|
not isinstance(projects_claim, list)
|
|
or not 1 <= len(projects_claim) <= _MAX_PROJECTS
|
|
or any(not isinstance(value, str) for value in projects_claim)
|
|
or not isinstance(scopes_claim, str)
|
|
or not _SCOPES_CLAIM.fullmatch(scopes_claim)
|
|
or len(scopes_claim) > 4096
|
|
):
|
|
raise InvalidToken()
|
|
try:
|
|
projects = frozenset(str(UUID(value)) for value in projects_claim)
|
|
except (ValueError, TypeError, AttributeError) as exc:
|
|
raise InvalidToken() from exc
|
|
if len(projects) != len(projects_claim):
|
|
raise InvalidToken()
|
|
scope_values = scopes_claim.split(" ")
|
|
if not 1 <= len(scope_values) <= _MAX_SCOPES or any(
|
|
not _SCOPE.fullmatch(scope) for scope in scope_values
|
|
):
|
|
raise InvalidToken()
|
|
try:
|
|
return ActorContext(
|
|
subject=claims.subject,
|
|
projects=projects,
|
|
scopes=frozenset(scope_values),
|
|
correlation_id=correlation_id,
|
|
)
|
|
except (TypeError, ValueError) as exc:
|
|
raise InvalidToken() from exc
|
|
|
|
|
|
def authorize_actor(
|
|
actor: ActorContext,
|
|
*,
|
|
capability: Capability,
|
|
project: str | None = None,
|
|
) -> None:
|
|
"""Fail before HTTP unless the validated actor has both required grants."""
|
|
|
|
if not isinstance(actor, ActorContext) or capability not in _CAPABILITIES:
|
|
raise PermissionError("ingestion caller is not authorized")
|
|
if capability not in actor.scopes:
|
|
raise PermissionError("ingestion caller is not authorized")
|
|
if project is not None:
|
|
try:
|
|
selected = str(UUID(project))
|
|
except (TypeError, ValueError, AttributeError) as exc:
|
|
raise PermissionError("ingestion caller is not authorized") from exc
|
|
if selected not in actor.projects:
|
|
raise PermissionError("ingestion caller is not authorized")
|
|
|
|
|
|
def authorize_workspace_actor(actor: ActorContext) -> None:
|
|
"""Allow workspace-wide reads only for the trusted production connector."""
|
|
|
|
if (
|
|
not isinstance(actor, ActorContext)
|
|
or "knowledge:read" not in actor.scopes
|
|
or actor.connector_project != "knowledge-hub"
|
|
or actor.connector_environment != "prod"
|
|
):
|
|
raise PermissionError("ingestion caller is not authorized")
|
|
|
|
|
|
def sanitize_ingestion_audit(candidate: Mapping[str, object]) -> dict[str, object]:
|
|
"""Project telemetry onto a low-cardinality, content-free allow-list."""
|
|
|
|
safe: dict[str, object] = {}
|
|
tool = candidate.get("tool")
|
|
result = candidate.get("result")
|
|
status = candidate.get("status")
|
|
latency = candidate.get("latency_bucket")
|
|
correlation = candidate.get("correlation_id")
|
|
error_code = candidate.get("error_code")
|
|
input_reason = candidate.get("input_reason")
|
|
if isinstance(tool, str) and tool in _INGESTION_TOOLS:
|
|
safe["tool"] = tool
|
|
if isinstance(result, str) and _SAFE_AUDIT_LABEL.fullmatch(result):
|
|
safe["result"] = result
|
|
if isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599:
|
|
safe["status"] = status
|
|
if isinstance(latency, str) and _SAFE_AUDIT_LABEL.fullmatch(latency):
|
|
safe["latency_bucket"] = latency
|
|
if isinstance(correlation, str) and _CORRELATION_ID.fullmatch(correlation):
|
|
safe["correlation_id"] = correlation
|
|
if isinstance(error_code, str) and error_code in _SAFE_INGESTION_ERROR_CODES:
|
|
safe["error_code"] = error_code
|
|
if isinstance(input_reason, str) and input_reason in _SAFE_INGESTION_INPUT_REASONS:
|
|
safe["input_reason"] = input_reason
|
|
return safe
|