Adds the seven §10.2 hook-point modules + five env vars so Astrolabe Cloud can offload document processing to the external document-processor / embedding gateway. Purely additive: with every setting unset the server behaves exactly as today, so self-hosters are unaffected (Deck #92). Hook points (all default to current monolith behavior): - config: EMBEDDING_PROVIDER, INGEST_MODE, STATUS_BACKEND, COLLECTION_METADATA_SOURCE, FACT_EVENT_EMITTER (+ supporting settings), validated in Settings.__post_init__ (fail-fast STATUS_BACKEND=local with INGEST_MODE=external); shared canonical.py. - vector/payload_keys.py + acl_hash.py: cross-impl NAMESPACE/point_id (§2.2) and BLAKE2b-128 ACL hash (§11), pinned by fixtures shared with the document-processor repo. - embedding/gateway_client.py: OpenAI-compatible GatewayProvider authenticating via M2M OIDC client-credentials (separate realm); manual-only registry entry. - vector/collection_metadata.py: sentinel-point / API metadata source with env fallback. - vector/queue/: hexagonal ingest producer ports + memory/NATS adapters (Postgres seam); INGEST_MODE=external publishes mcp.ingest.requested.{tenant} instead of the in-memory stream and skips the in-process processor pool. The lifespan becomes a composition root across both deployment branches. - vector/queue/status.py: STATUS_BACKEND=bus subscriber feeding a StatusStore the vector-sync status endpoint reads. - admin/payload_backfill.py: POST /api/v1/admin/payload-backfill (admin scope); processor writes the new payload keys; query-side ACL pre-filter gated behind ACL_PREFILTER_ENABLED (default off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
67 lines
2.8 KiB
Python
67 lines
2.8 KiB
Python
"""Canonical ACL hash (design §11) — a cross-implementation contract.
|
||
|
||
The external document-processor and the local processor inside this server both
|
||
write the ``acl_hash`` Qdrant payload independently; the query path
|
||
(``search/verification.py``) builds the matching accessible set. All three must
|
||
agree byte-for-byte, so the canonicalization, hash, and accessible-set rules are
|
||
pinned here and exercised by an identical ``tests/fixtures/acl_hash_corpus.json``
|
||
in both repos (§11.5).
|
||
|
||
Key invariants:
|
||
- ``principal_id`` is NFC-normalized; **case is preserved, not folded** (§11.2).
|
||
- ``BLAKE2b-128`` (``digest_size=16`` → 32 hex chars), NOT the 64-byte default
|
||
(§11.3).
|
||
- Public-link shares are excluded by the caller — they are bearer secrets not
|
||
bound to an identity and never reachable via authenticated MCP queries (§11.1).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import unicodedata
|
||
from collections.abc import Iterable
|
||
|
||
from nextcloud_mcp_server.canonical import canonical_json
|
||
|
||
PRINCIPAL_TYPES = frozenset({"user", "group", "public"})
|
||
|
||
# The world-readable principal, included in every requester's accessible set
|
||
# (§11.4) and written on any document Nextcloud marks world-readable.
|
||
PUBLIC_PRINCIPAL: tuple[str, str] = ("public", "public")
|
||
|
||
|
||
def compute_principal_hash(principal_type: str, principal_id: str) -> str:
|
||
"""Hash one ``(principal_type, principal_id)`` tuple (§11.2–11.3)."""
|
||
if principal_type not in PRINCIPAL_TYPES:
|
||
raise ValueError(
|
||
f"principal_type must be one of {sorted(PRINCIPAL_TYPES)}; "
|
||
f"got {principal_type!r}"
|
||
)
|
||
# principal_type is ASCII by construction; only the id is normalized.
|
||
normalized_id = unicodedata.normalize("NFC", principal_id)
|
||
canonical = canonical_json([principal_type, normalized_id])
|
||
return hashlib.blake2b(canonical, digest_size=16).hexdigest()
|
||
|
||
|
||
def compute_acl_hash(share_set: Iterable[tuple[str, str]]) -> list[str]:
|
||
"""Per-principal hash array for a document's share-set (§11.2).
|
||
|
||
One element per share-set entry, in input order (Qdrant treats the array
|
||
with set-semantics, so order is irrelevant at query time). The caller must
|
||
have already dropped public-link shares (§11.1).
|
||
"""
|
||
return [compute_principal_hash(ptype, pid) for ptype, pid in share_set]
|
||
|
||
|
||
def accessible_hash_set(username: str, groups: Iterable[str] = ()) -> set[str]:
|
||
"""Accessible hash set for an authenticated requester (§11.4).
|
||
|
||
Derived entirely from OIDC claims: the requester's own ``(user, username)``,
|
||
each ``(group, <name>)`` they hold, and unconditionally ``(public, public)``.
|
||
"""
|
||
hashes = {compute_principal_hash("user", username)}
|
||
for group in groups:
|
||
hashes.add(compute_principal_hash("group", group))
|
||
hashes.add(compute_principal_hash(*PUBLIC_PRINCIPAL))
|
||
return hashes
|