Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,310 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user