148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote_plus
|
|
from uuid import NAMESPACE_URL, UUID, uuid5
|
|
|
|
from packages.connector_kit import ConnectorContext
|
|
from packages.hub_runtime import read_secret_file
|
|
|
|
ENVIRONMENTS = frozenset({"dev", "test", "staging", "prod"})
|
|
PROJECT_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
IDENTITY_ASSERTION = re.compile(r"^v1:([0-9]{10}):([0-9a-f]{64})$")
|
|
TRACE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
|
|
|
|
def deterministic_id(kind: str, value: str) -> UUID:
|
|
return uuid5(NAMESPACE_URL, f"https://hub.example.com/{kind}/{value}")
|
|
|
|
|
|
def load_connector_identity_key() -> bytes:
|
|
path = Path(
|
|
os.environ.get("CONNECTOR_IDENTITY_KEY_FILE", "/run/secrets/connector_identity_signing_key")
|
|
)
|
|
key = read_secret_file(path).encode("utf-8")
|
|
if len(key) < 32:
|
|
raise ValueError("connector identity signing key must contain at least 32 bytes")
|
|
return key
|
|
|
|
|
|
def identity_assertion(
|
|
key: bytes,
|
|
*,
|
|
project: str,
|
|
environment: str,
|
|
client_id: str,
|
|
trace_id: str,
|
|
now: int | None = None,
|
|
) -> str:
|
|
timestamp = int(time.time()) if now is None else now
|
|
message = f"v1\n{timestamp}\n{project}\n{environment}\n{client_id}\n{trace_id}".encode()
|
|
signature = hmac.new(key, message, hashlib.sha256).hexdigest()
|
|
return f"v1:{timestamp}:{signature}"
|
|
|
|
|
|
def connector_context(
|
|
headers: Mapping[str, str],
|
|
projects: set[str],
|
|
*,
|
|
signing_key: bytes,
|
|
now: int | None = None,
|
|
) -> ConnectorContext:
|
|
project = headers.get("x-itworx-project", "").strip().lower()
|
|
environment = headers.get("x-itworx-environment", "").strip().lower()
|
|
client_id = headers.get("x-itworx-client-id", "").strip().lower()
|
|
trace_id = headers.get("x-itworx-trace-id", "").strip()
|
|
if project not in projects or not PROJECT_SLUG.fullmatch(project):
|
|
raise PermissionError("connector project context is not registered")
|
|
if environment not in ENVIRONMENTS:
|
|
raise PermissionError("connector environment context is not registered")
|
|
try:
|
|
UUID(client_id)
|
|
except ValueError as exc:
|
|
raise PermissionError("connector client context is invalid") from exc
|
|
assertion = headers.get("x-itworx-identity-assertion", "").strip().lower()
|
|
match = IDENTITY_ASSERTION.fullmatch(assertion)
|
|
reference = int(time.time()) if now is None else now
|
|
if match is None or abs(reference - int(match.group(1))) > 60:
|
|
raise PermissionError("connector identity assertion is invalid or expired")
|
|
expected = identity_assertion(
|
|
signing_key,
|
|
project=project,
|
|
environment=environment,
|
|
client_id=client_id,
|
|
trace_id=trace_id,
|
|
now=int(match.group(1)),
|
|
)
|
|
if not hmac.compare_digest(assertion, expected):
|
|
raise PermissionError("connector identity assertion is invalid or expired")
|
|
return ConnectorContext(
|
|
workspace_id=deterministic_id("workspace", "itworx"),
|
|
project_id=deterministic_id("project", project),
|
|
project_slug=project,
|
|
environment=environment,
|
|
client_id=UUID(client_id),
|
|
# The trace id is already covered by the identity assertion above, so it is
|
|
# authenticated Hub input; the pattern check only keeps it a safe header value.
|
|
correlation_id=trace_id if TRACE_ID.fullmatch(trace_id) else None,
|
|
)
|
|
|
|
|
|
def request_headers(context: Any) -> Mapping[str, str]:
|
|
request = context.request_context.request
|
|
headers = getattr(request, "headers", None)
|
|
if headers is None:
|
|
raise PermissionError("connector transport context is unavailable")
|
|
return {str(key).lower(): str(value) for key, value in headers.items()}
|
|
|
|
|
|
def run_asgi(application: Any) -> None:
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
application,
|
|
host="0.0.0.0", # noqa: S104 - container network binding is intentional.
|
|
port=int(os.environ.get("PORT", "8080")),
|
|
access_log=False,
|
|
proxy_headers=False,
|
|
server_header=False,
|
|
)
|
|
|
|
|
|
def load_json_environment(name: str, expected: type[list[Any]] | type[dict[str, Any]]) -> Any:
|
|
raw = os.environ.get(name, "")
|
|
try:
|
|
value = json.loads(raw)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"{name} is invalid") from exc
|
|
if not isinstance(value, expected):
|
|
raise ValueError(f"{name} has an invalid shape")
|
|
return value
|
|
|
|
|
|
def read_regular_json(path: Path) -> dict[str, Any]:
|
|
if not path.is_file() or path.is_symlink():
|
|
raise ValueError("registered configuration file is unavailable")
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(value, dict):
|
|
raise ValueError("registered configuration file is invalid")
|
|
return value
|
|
|
|
|
|
def hub_database_url() -> str:
|
|
password = read_secret_file(
|
|
Path(os.environ.get("HUB_DATABASE_PASSWORD_FILE", "/run/secrets/hub_database_password"))
|
|
)
|
|
host = os.environ.get("HUB_DATABASE_HOST", "postgres")
|
|
database = os.environ.get("HUB_DATABASE_NAME", "itworx_mcp_hub")
|
|
user = os.environ.get("HUB_DATABASE_USER", "itworx_mcp_hub")
|
|
return f"postgresql+psycopg://{quote_plus(user)}:{quote_plus(password)}@{host}:5432/{database}"
|