From 82be8b88a3009fc7ec5decb9055305f8967674ef Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 22 Jul 2026 19:39:40 +0800 Subject: [PATCH 1/5] feat(control): register file context sources (#21) --- ...er-file-sources-through-context-control.md | 107 +++++ docs/decisions/README.md | 2 + engine/control/__init__.py | 52 +++ engine/control/authority.py | 343 ++++++++++++++ engine/control/contracts.py | 264 +++++++++++ engine/control/module.py | 125 ++++++ engine/persistence/__init__.py | 2 + engine/persistence/control_sources.py | 292 ++++++++++++ .../persistence/schema_security_manifest.yaml | 207 ++++++++- eval/catalogs/m0-security-evidence.yaml | 5 +- .../20260722_0010_file_source_registration.py | 224 +++++++++ scripts/security_gate/rls.py | 2 + .../test_file_source_registration.py | 424 ++++++++++++++++++ .../integration/test_m0_security_gate_rls.py | 20 +- tests/integration/test_migrations.py | 54 ++- tests/unit/test_context_control.py | 242 ++++++++++ tests/unit/test_m0_rls_inventory.py | 10 +- tests/unit/test_schema_security_manifest.py | 105 ++++- 18 files changed, 2448 insertions(+), 32 deletions(-) create mode 100644 docs/decisions/0035-register-file-sources-through-context-control.md create mode 100644 engine/control/__init__.py create mode 100644 engine/control/authority.py create mode 100644 engine/control/contracts.py create mode 100644 engine/control/module.py create mode 100644 engine/persistence/control_sources.py create mode 100644 migrations/versions/20260722_0010_file_source_registration.py create mode 100644 tests/integration/test_file_source_registration.py create mode 100644 tests/unit/test_context_control.py diff --git a/docs/decisions/0035-register-file-sources-through-context-control.md b/docs/decisions/0035-register-file-sources-through-context-control.md new file mode 100644 index 00000000..e386e41b --- /dev/null +++ b/docs/decisions/0035-register-file-sources-through-context-control.md @@ -0,0 +1,107 @@ +--- +name: adr-0035-context-control-file-source-registration +version: "1.0.0" +description: > + Register one Organization-owned File ContextSource and immutable first + SourceVersion through a trusted ContextControl call without activating File + acquisition or a new wire API. +--- + +# 0035. Register File sources through one trusted ContextControl transaction + +- Status: accepted +- Date: 2026-07-22 +- Refines: ADR-0015, ADR-0017, ADR-0018 + +## Context + +M1 needs a stable Organization-owned `ContextSource` before File acquisition, +Markdown compilation, durable jobs, or publication can begin. Registration is +a Control-plane operation, but the current HTTP ingress is deliberately only +the Runtime `resolve` surface and the generated wire contract is not frozen +until M2. Treating the database Control login, a caller-supplied Organization +reference, or a host filesystem path as operator authority would create a new +trust bypass. Advertising future Provider operations merely because a File +source was registered would also make capability coverage false-green. + +The first `SourceVersion` must be immutable and selected by one active pointer. +Registration retries need one Organization-local identity without allowing the +same key to alias different configuration, and another Organization must not +learn whether a source reference exists. + +## Decision + +`ContextControl` gains its first in-process public behavior: +`register_source(TrustedControlCall, RegisterFileSource) -> SourceManifest` and +`read_source(TrustedControlCall, SourceRef) -> SourceManifest`. A +`ControlOperatorAuthority` authenticates an opaque Control credential and +constructs a lifetime-bound, operation-bound, one-use trusted call. The +untrusted commands contain no Organization, source mode, ACL mode, or other +trusted selector. HTTP Control routes and SDK contracts remain inactive in this +slice. + +`RegisterFileSource` accepts bounded display metadata, an Organization-local +idempotency key, and an opaque logical `FileRootRef`. A root reference is a +single identifier, not a path: separators, traversal segments, URI schemes, +drive prefixes, home expansion, and absolute host locations are rejected. +Registration never resolves, opens, stats, lists, or otherwise accesses it. + +One PostgreSQL Control-role transaction binds the trusted Organization +transaction-locally and atomically creates: + +1. one `context_source` row containing stable identity, display metadata, + registration operation/key, and the active version pointer; and +2. one `source_version` row containing immutable File configuration and the + exact capability declaration. + +The two rows use Organization-inclusive primary and foreign keys. The active +pointer is a deferred same-Organization, same-source composite foreign key, and +the reverse SourceVersion ownership foreign key is also deferred so the first +pair can be inserted atomically. `source_version` updates are rejected by a +database trigger. The non-owner Control role receives only the required +Organization-RLS-scoped `SELECT` and `INSERT` privileges; Runtime, worker, +Learning, and PUBLIC receive none. + +The registration key is unique over `(Organization, +register_file_source, idempotency key)`. An exact retry returns the original +manifest; reuse for a different request fails generically. Identical keys in +different Organizations are independent. Source read-back always derives its +Organization from the trusted call. Cross-Organization and unknown source +references therefore share one `SourceNotAvailable` result. + +The File declaration fixes `materialized` source mode, Markdown content, and +Mirrored ACL policy while marking every Provider carrier not implemented in +Issue #21 unavailable: capability-description dispatch, change reading, +discovery, authorization/projection, checkpoint, deletion, ingestion jobs, and +FileSourceAccess activation. A +registered source is configuration only and is not acquisition-ready. + +## Rationale + +The in-process deep Module is the highest stable Control seam today; inventing +an HTTP route before the M2 wire decision would create a premature second +contract. A sealed trusted call prevents source or Organization references from +becoming authority, while Control-role RLS remains an independent database +boundary. An exact immutable declaration lets later File issues activate one +capability at a time without rewriting the meaning of the first version. + +## Consequences + +Issue #21 creates no filesystem read, Provider call, job, outbox row, +`ContextResource`, `ContextRevision`, or `ContextFragment`. It does not activate +Runtime source selection or File authorization. Later source configuration +changes must create a new `SourceVersion`; later issues own FileSourceAccess, +acquisition, publication, update, disable, and offboarding transactions. + +The active source pointer and both source tables join the schema security +manifest and the live RLS denominator. Any future HTTP Control ingress must +redeem its own authenticated operator evidence into the same trusted call; it +may not place Organization or mode authority in a request body. + +## Revisit trigger + +Revisit when an activated Control wire protocol, a second source kind, or a +measured source-registration workflow requires a broader command. Any +replacement must preserve trusted Organization derivation, exact retry +semantics, immutable versioning, same-Organization pointer integrity, +denied/not-found equivalence, and zero acquisition work during registration. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index fae35dd1..dba63f9a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -36,6 +36,7 @@ kernel, capability separation, and publication visibility model. | Publication visibility | [0018 — Immutable ContextRevision publication](0018-immutable-revision-publication.md) | `ContextResource` content is immutable `ContextRevision`/`ContextFragment` lineage; one transaction changes the active pointer | In-place content mutation, mixed old/new reads, or cleanup-defined visibility | | Release security catalog | [0019 — Security catalog normalization](0019-security-catalog-normalization.md) | One machine catalog contains exactly fifteen stable release IDs; overlapping labels and derived scenarios keep their safeguards without inflating the count | Parallel prose catalogs, renumbering, or treating inactive cache behavior as a canonical release family | | Executable M0 security veto | [0034 — Registered executable security evidence](0034-execute-the-m0-security-veto-from-registered-evidence.md) | Exact current tests, explicit hard-oracle observations, and live all-table RLS facts produce provenance-bearing independent gate artifacts | Planned IDs presented as executed proof, skip/retry-to-green, manifest-only RLS claims, or aggregate scoring | +| First File source registration | [0035 — Trusted File source registration](0035-register-file-sources-through-context-control.md) | One operation-bound trusted Control call atomically creates an Organization-owned source plus immutable active first version; all acquisition carriers remain unavailable | Caller-authored Organization/mode, host paths, registration-time File I/O, future capability claims, or cross-tenant idempotency | Each baseline ADR is `accepted` and contains Context, Decision, Rationale, Consequences, and Revisit trigger sections. A revisit trigger permits review; it @@ -117,3 +118,4 @@ touched: - [0032 — Membership-bound materialized fields](0032-bind-materialized-fields-to-membership-projection-rights.md) - [0033 — Organization release promotion owner](0033-promote-organization-releases-through-one-learning-owner.md) - [0034 — Registered executable security evidence](0034-execute-the-m0-security-veto-from-registered-evidence.md) +- [0035 — Trusted File source registration](0035-register-file-sources-through-context-control.md) diff --git a/engine/control/__init__.py b/engine/control/__init__.py new file mode 100644 index 00000000..b1296825 --- /dev/null +++ b/engine/control/__init__.py @@ -0,0 +1,52 @@ +"""Public ContextControl Module contracts.""" + +from engine.control.authority import ( + ControlOperation, + ControlOperatorAuthenticationRejected, + ControlOperatorAuthenticator, + ControlOperatorAuthority, + ControlOperatorAuthorityUnavailable, + TrustedControlCall, + VerifiedControlOperatorIdentity, +) +from engine.control.contracts import ( + FILE_CAPABILITY_MANIFEST, + CapabilityStatus, + FileCapabilityManifest, + RegisterFileSource, + SourceAclEvidenceMode, + SourceContentKind, + SourceControlUnavailable, + SourceKind, + SourceManifest, + SourceMode, + SourceNotAvailable, + SourceRef, + SourceVersion, +) +from engine.control.module import ContextControl, ControlStorePort + +__all__ = [ + "FILE_CAPABILITY_MANIFEST", + "CapabilityStatus", + "ContextControl", + "ControlOperation", + "ControlOperatorAuthenticationRejected", + "ControlOperatorAuthenticator", + "ControlOperatorAuthority", + "ControlOperatorAuthorityUnavailable", + "ControlStorePort", + "FileCapabilityManifest", + "RegisterFileSource", + "SourceAclEvidenceMode", + "SourceControlUnavailable", + "SourceContentKind", + "SourceKind", + "SourceManifest", + "SourceMode", + "SourceRef", + "SourceNotAvailable", + "SourceVersion", + "TrustedControlCall", + "VerifiedControlOperatorIdentity", +] diff --git a/engine/control/authority.py b/engine/control/authority.py new file mode 100644 index 00000000..57d1c23d --- /dev/null +++ b/engine/control/authority.py @@ -0,0 +1,343 @@ +"""Lifetime- and operation-bound trusted ContextControl operator calls.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import StrEnum +from threading import Lock +from typing import NoReturn, Protocol +from uuid import UUID + +from engine.control.contracts import _require_bounded_text, _require_utc + + +class ControlOperation(StrEnum): + REGISTER_SOURCE = "register_source" + READ_SOURCE = "read_source" + + +class ControlOperatorAuthenticationRejected(Exception): + """Opaque credentials did not establish the requested Control authority.""" + + def __init__(self) -> None: + super().__init__("control operator authentication rejected") + + +class ControlOperatorAuthorityUnavailable(RuntimeError): + """The configured operator authenticator could not complete safely.""" + + +@dataclass(frozen=True, slots=True) +class VerifiedControlOperatorIdentity: + """Trusted current source-administration facts from one authenticator.""" + + organization_id: UUID = field(repr=False) + operator_ref: str = field(repr=False) + authentication_binding_ref: str = field(repr=False) + authority_ref: str = field(repr=False) + allowed_operations: frozenset[ControlOperation] = field(repr=False) + valid_from: datetime = field(repr=False) + expires_at: datetime = field(repr=False) + + def __post_init__(self) -> None: + if type(self.organization_id) is not UUID: + raise TypeError("control operator organization_id must be UUID") + for name in ( + "operator_ref", + "authentication_binding_ref", + "authority_ref", + ): + _require_bounded_text( + f"control operator {name}", getattr(self, name), 256 + ) + if ( + type(self.allowed_operations) is not frozenset + or not self.allowed_operations + or any( + type(value) is not ControlOperation + for value in self.allowed_operations + ) + ): + raise ValueError( + "control operator operations must be a closed nonempty set" + ) + valid_from = _require_utc("control operator valid_from", self.valid_from) + expires_at = _require_utc("control operator expires_at", self.expires_at) + if expires_at <= valid_from: + raise ValueError("control operator lifetime must be positive") + + def __reduce__(self) -> NoReturn: + raise TypeError("verified control operator identity is not serializable") + + +class ControlOperatorAuthenticator(Protocol): + def authenticate( + self, opaque_credential: str + ) -> VerifiedControlOperatorIdentity: ... + + +class _ControlAuthorityScope: + __slots__ = ("issuer_seal", "nonce", "seal") + issuer_seal: object + nonce: bytes + seal: object + + def __init__(self) -> None: + raise TypeError("control authority scopes are not constructible") + + +_CONTROL_SCOPE_SEAL = object() + + +@dataclass(frozen=True, slots=True, init=False, repr=False) +class TrustedControlCall: + """Construction-sealed one-operation call with no ambient authority.""" + + organization_id: UUID = field(repr=False) + operator_ref: str = field(repr=False) + authentication_binding_ref: str = field(repr=False) + authority_ref: str = field(repr=False) + operation: ControlOperation + request_id: str + issued_at: datetime + expires_at: datetime + _digest: bytes = field(repr=False) + _scope: _ControlAuthorityScope = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("TrustedControlCall is authority-constructed") + + def __repr__(self) -> str: + return "TrustedControlCall()" + + def __reduce__(self) -> NoReturn: + raise TypeError("TrustedControlCall is not serializable") + + +class ControlOperatorAuthority: + """Authenticate one operator and retain one exact Control operation.""" + + __slots__ = ( + "_active_calls", + "_authenticator", + "_call_key", + "_call_ttl", + "_clock", + "_issuer_seal", + "_state_lock", + ) + + def __init__( + self, + authenticator: ControlOperatorAuthenticator, + *, + call_ttl: timedelta, + clock: Callable[[], datetime], + ) -> None: + if not callable(getattr(authenticator, "authenticate", None)): + raise TypeError("control operator authenticator is incomplete") + if type(call_ttl) is not timedelta or call_ttl <= timedelta(0): + raise ValueError("control call TTL must be positive") + if not callable(clock): + raise TypeError("control authority clock must be callable") + self._authenticator = authenticator + self._active_calls: dict[bytes, bool] = {} + self._call_key = secrets.token_bytes(32) + self._call_ttl = call_ttl + self._clock = clock + self._issuer_seal = object() + self._state_lock = Lock() + + def authorize( + self, + *, + opaque_credential: str, + operation: ControlOperation, + request_id: str, + ) -> AbstractContextManager[TrustedControlCall]: + """Authenticate and retain one trusted call only for its context.""" + + if ( + type(opaque_credential) is not str + or not opaque_credential + or opaque_credential.isspace() + ): + raise ControlOperatorAuthenticationRejected + if type(operation) is not ControlOperation: + raise ControlOperatorAuthenticationRejected + _require_bounded_text("control request_id", request_id, 256) + return self._authorized_call( + opaque_credential=opaque_credential, + operation=operation, + request_id=request_id, + ) + + @contextmanager + def _authorized_call( + self, + *, + opaque_credential: str, + operation: ControlOperation, + request_id: str, + ) -> Iterator[TrustedControlCall]: + try: + identity = self._authenticator.authenticate(opaque_credential) + except ControlOperatorAuthenticationRejected: + raise ControlOperatorAuthenticationRejected from None + except Exception: + raise ControlOperatorAuthorityUnavailable( + "control operator authority is unavailable" + ) from None + if type(identity) is not VerifiedControlOperatorIdentity: + raise ControlOperatorAuthorityUnavailable( + "control operator authority is unavailable" + ) + now = _require_utc("control call issued_at", self._clock()) + if ( + operation not in identity.allowed_operations + or now < identity.valid_from + or now >= identity.expires_at + ): + raise ControlOperatorAuthenticationRejected + expires_at = min(identity.expires_at, now + self._call_ttl) + scope = object.__new__(_ControlAuthorityScope) + scope.issuer_seal = self._issuer_seal + scope.nonce = secrets.token_bytes(32) + scope.seal = _CONTROL_SCOPE_SEAL + with self._state_lock: + self._active_calls[scope.nonce] = False + call = object.__new__(TrustedControlCall) + values: dict[str, object] = { + "organization_id": identity.organization_id, + "operator_ref": identity.operator_ref, + "authentication_binding_ref": identity.authentication_binding_ref, + "authority_ref": identity.authority_ref, + "operation": operation, + "request_id": request_id, + "issued_at": now, + "expires_at": expires_at, + "_scope": scope, + } + values["_digest"] = _control_call_digest( + key=self._call_key, + organization_id=identity.organization_id, + operator_ref=identity.operator_ref, + authentication_binding_ref=identity.authentication_binding_ref, + authority_ref=identity.authority_ref, + operation=operation, + request_id=request_id, + issued_at=now, + expires_at=expires_at, + nonce=scope.nonce, + ) + for name, value in values.items(): + object.__setattr__(call, name, value) + try: + yield call + finally: + with self._state_lock: + self._active_calls.pop(scope.nonce, None) + + +def _validate_and_consume_control_call( + call: TrustedControlCall, + *, + authority: ControlOperatorAuthority, + expected_operation: ControlOperation, + checked_at: datetime, +) -> None: + if type(call) is not TrustedControlCall: + raise ControlOperatorAuthenticationRejected + try: + scope = call._scope + if type(scope) is not _ControlAuthorityScope: + raise ControlOperatorAuthenticationRejected + digest = call._digest + expected_digest = _control_call_digest( + key=authority._call_key, + organization_id=call.organization_id, + operator_ref=call.operator_ref, + authentication_binding_ref=call.authentication_binding_ref, + authority_ref=call.authority_ref, + operation=call.operation, + request_id=call.request_id, + issued_at=call.issued_at, + expires_at=call.expires_at, + nonce=scope.nonce, + ) + except (AttributeError, TypeError, ValueError): + raise ControlOperatorAuthenticationRejected from None + if ( + scope.seal is not _CONTROL_SCOPE_SEAL + or scope.issuer_seal is not authority._issuer_seal + or call.operation is not expected_operation + or type(digest) is not bytes + or not hmac.compare_digest(digest, expected_digest) + ): + raise ControlOperatorAuthenticationRejected + now = _require_utc("control call checked_at", checked_at) + if now < call.issued_at or now >= call.expires_at: + raise ControlOperatorAuthenticationRejected + with authority._state_lock: + if authority._active_calls.get(scope.nonce) is not False: + raise ControlOperatorAuthenticationRejected + authority._active_calls[scope.nonce] = True + + +def _control_call_digest( + *, + key: bytes, + organization_id: UUID, + operator_ref: str, + authentication_binding_ref: str, + authority_ref: str, + operation: ControlOperation, + request_id: str, + issued_at: datetime, + expires_at: datetime, + nonce: bytes, +) -> bytes: + if type(key) is not bytes or len(key) != 32: + raise ValueError("control call signing key is invalid") + if type(organization_id) is not UUID or type(operation) is not ControlOperation: + raise TypeError("control call claims are invalid") + for field_name, value in ( + ("operator_ref", operator_ref), + ("authentication_binding_ref", authentication_binding_ref), + ("authority_ref", authority_ref), + ("request_id", request_id), + ): + _require_bounded_text(f"control call {field_name}", value, 256) + _require_utc("control call issued_at", issued_at) + _require_utc("control call expires_at", expires_at) + if type(nonce) is not bytes or len(nonce) != 32: + raise ValueError("control call nonce is invalid") + document = { + "authenticationBindingRef": authentication_binding_ref, + "authorityRef": authority_ref, + "expiresAt": expires_at.isoformat(), + "issuedAt": issued_at.isoformat(), + "nonce": nonce.hex(), + "operation": operation.value, + "operatorRef": operator_ref, + "organizationId": str(organization_id), + "requestId": request_id, + } + payload = json.dumps( + document, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("ascii") + return hmac.new( + key, + b"context-engine.control-call.v1\x00" + payload, + hashlib.sha256, + ).digest() diff --git a/engine/control/contracts.py b/engine/control/contracts.py new file mode 100644 index 00000000..ddddae8b --- /dev/null +++ b/engine/control/contracts.py @@ -0,0 +1,264 @@ +"""Public ContextControl contracts for the first File source registration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import StrEnum +from typing import NoReturn +from uuid import UUID + +MAX_SOURCE_DISPLAY_NAME_LENGTH = 200 +MAX_SOURCE_TOKEN_LENGTH = 128 + + +def _require_bounded_text(field_name: str, value: object, maximum: int) -> str: + if ( + type(value) is not str + or not value + or value.isspace() + or value != value.strip() + or len(value) > maximum + or any(ord(character) < 0x20 for character in value) + or any(0xD800 <= ord(character) <= 0xDFFF for character in value) + ): + raise ValueError(f"{field_name} must be bounded nonblank Unicode") + return value + + +def _require_token(field_name: str, value: object) -> str: + token = _require_bounded_text(field_name, value, MAX_SOURCE_TOKEN_LENGTH) + if not (token[0].isascii() and token[0].isalnum()) or any( + not (character.isascii() and (character.isalnum() or character in "._-")) + for character in token + ): + raise ValueError(f"{field_name} must be a bounded opaque token") + return token + + +def _require_utc(field_name: str, value: object) -> datetime: + if ( + type(value) is not datetime + or value.tzinfo is None + or value.utcoffset() != timedelta(0) + ): + raise ValueError(f"{field_name} must be an aware UTC datetime") + return value + + +class SourceKind(StrEnum): + FILE = "file" + + +class SourceMode(StrEnum): + MATERIALIZED = "materialized" + + +class SourceContentKind(StrEnum): + MARKDOWN = "markdown" + + +class SourceAclEvidenceMode(StrEnum): + MIRRORED = "mirrored" + + +class CapabilityStatus(StrEnum): + AVAILABLE = "available" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True, slots=True) +class FileCapabilityManifest: + """Exact Issue #21 declaration; registration is not acquisition readiness.""" + + declaration_version: str = "file-capabilities-v1" + source_mode: SourceMode = SourceMode.MATERIALIZED + content_kinds: tuple[SourceContentKind, ...] = (SourceContentKind.MARKDOWN,) + acl_evidence_mode: SourceAclEvidenceMode = SourceAclEvidenceMode.MIRRORED + describe_capabilities: CapabilityStatus = CapabilityStatus.UNAVAILABLE + read_changes: CapabilityStatus = CapabilityStatus.UNAVAILABLE + discover: CapabilityStatus = CapabilityStatus.UNAVAILABLE + authorize_and_project: CapabilityStatus = CapabilityStatus.UNAVAILABLE + checkpoint: CapabilityStatus = CapabilityStatus.UNAVAILABLE + deletion: CapabilityStatus = CapabilityStatus.UNAVAILABLE + file_source_access: CapabilityStatus = CapabilityStatus.UNAVAILABLE + ingestion_jobs: CapabilityStatus = CapabilityStatus.UNAVAILABLE + + def __post_init__(self) -> None: + if ( + self.declaration_version != "file-capabilities-v1" + or self.source_mode is not SourceMode.MATERIALIZED + or self.content_kinds != (SourceContentKind.MARKDOWN,) + or self.acl_evidence_mode is not SourceAclEvidenceMode.MIRRORED + or any( + status is not CapabilityStatus.UNAVAILABLE + for status in ( + self.describe_capabilities, + self.read_changes, + self.discover, + self.authorize_and_project, + self.checkpoint, + self.deletion, + self.file_source_access, + self.ingestion_jobs, + ) + ) + ): + raise ValueError("File capability manifest is closed at Issue #21") + + def document(self) -> dict[str, object]: + """Return the exact persisted/public declaration without activation claims.""" + + return { + "aclEvidenceMode": self.acl_evidence_mode.value, + "authorizeAndProject": self.authorize_and_project.value, + "checkpoint": self.checkpoint.value, + "contentKinds": [value.value for value in self.content_kinds], + "declarationVersion": self.declaration_version, + "deletion": self.deletion.value, + "describeCapabilities": self.describe_capabilities.value, + "discover": self.discover.value, + "fileSourceAccess": self.file_source_access.value, + "ingestionJobs": self.ingestion_jobs.value, + "readChanges": self.read_changes.value, + "sourceMode": self.source_mode.value, + } + + +FILE_CAPABILITY_MANIFEST = FileCapabilityManifest() + + +@dataclass(frozen=True, slots=True) +class RegisterFileSource: + """Untrusted registration values; trusted identity and mode are absent.""" + + display_name: str + root_ref: str = field(repr=False) + idempotency_key: str = field(repr=False) + + def __post_init__(self) -> None: + _require_bounded_text( + "File source display_name", + self.display_name, + MAX_SOURCE_DISPLAY_NAME_LENGTH, + ) + try: + root_ref = _require_token("File root_ref", self.root_ref) + except ValueError: + raise ValueError( + "root_ref must be an opaque logical File root reference" + ) from None + if root_ref in {".", ".."}: + raise ValueError("root_ref must be an opaque logical File root reference") + _require_token("File registration idempotency_key", self.idempotency_key) + + def __reduce__(self) -> NoReturn: + raise TypeError("File source registration command is not serializable") + + +@dataclass(frozen=True, slots=True) +class SourceRef: + """Opaque source locator; it carries no Organization or read authority.""" + + value: UUID = field(repr=False) + + def __post_init__(self) -> None: + if type(self.value) is not UUID: + raise TypeError("SourceRef value must be UUID") + + +@dataclass(frozen=True, slots=True) +class SourceVersion: + """Immutable active source-configuration snapshot returned by Control.""" + + source_ref: SourceRef + version_ref: UUID = field(repr=False) + kind: SourceKind + root_ref: str = field(repr=False) + capabilities: FileCapabilityManifest + created_at: datetime + + def __post_init__(self) -> None: + if type(self.source_ref) is not SourceRef: + raise TypeError("SourceVersion source_ref must be SourceRef") + if type(self.version_ref) is not UUID: + raise TypeError("SourceVersion version_ref must be UUID") + if self.kind is not SourceKind.FILE: + raise ValueError("SourceVersion kind must be file") + try: + _require_token("SourceVersion root_ref", self.root_ref) + except ValueError: + raise ValueError( + "SourceVersion root_ref must be a logical File root reference" + ) from None + if type(self.capabilities) is not FileCapabilityManifest: + raise TypeError("SourceVersion requires FileCapabilityManifest") + _require_utc("SourceVersion created_at", self.created_at) + + +@dataclass(frozen=True, slots=True) +class SourceManifest: + """Control read model; its references are locators, never trusted identity.""" + + source_ref: SourceRef + display_name: str + kind: SourceKind + active_version: SourceVersion + created_at: datetime + + def __post_init__(self) -> None: + if type(self.source_ref) is not SourceRef: + raise TypeError("SourceManifest source_ref must be SourceRef") + _require_bounded_text( + "SourceManifest display_name", + self.display_name, + MAX_SOURCE_DISPLAY_NAME_LENGTH, + ) + if self.kind is not SourceKind.FILE: + raise ValueError("SourceManifest kind must be file") + if ( + type(self.active_version) is not SourceVersion + or self.active_version.source_ref != self.source_ref + or self.active_version.kind is not self.kind + ): + raise ValueError("active SourceVersion must belong to its source") + _require_utc("SourceManifest created_at", self.created_at) + + @classmethod + def issue_21_file( + cls, + *, + source_ref: SourceRef, + version_ref: UUID, + display_name: str, + root_ref: str, + created_at: datetime, + ) -> SourceManifest: + """Construct the exact first File manifest from trusted stored facts.""" + + version = SourceVersion( + source_ref=source_ref, + version_ref=version_ref, + kind=SourceKind.FILE, + root_ref=root_ref, + capabilities=FILE_CAPABILITY_MANIFEST, + created_at=created_at, + ) + return cls( + source_ref=source_ref, + display_name=display_name, + kind=SourceKind.FILE, + active_version=version, + created_at=created_at, + ) + + +class SourceNotAvailable(Exception): + """One generic result for unauthorized, unknown, or unavailable sources.""" + + def __init__(self) -> None: + super().__init__("source is not available") + + +class SourceControlUnavailable(RuntimeError): + """The trusted Control persistence boundary could not complete safely.""" diff --git a/engine/control/module.py b/engine/control/module.py new file mode 100644 index 00000000..41d8e1bc --- /dev/null +++ b/engine/control/module.py @@ -0,0 +1,125 @@ +"""Public in-process ContextControl deep Module boundary.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Protocol + +from engine.control.authority import ( + ControlOperation, + ControlOperatorAuthenticationRejected, + ControlOperatorAuthority, + TrustedControlCall, + _validate_and_consume_control_call, +) +from engine.control.contracts import ( + RegisterFileSource, + SourceControlUnavailable, + SourceManifest, + SourceNotAvailable, + SourceRef, +) + + +class ControlStorePort(Protocol): + """Persistence operations visible only behind ContextControl.""" + + def register_file_source( + self, + call: TrustedControlCall, + command: RegisterFileSource, + ) -> SourceManifest: ... + + def read_source( + self, + call: TrustedControlCall, + source_ref: SourceRef, + ) -> SourceManifest: ... + + +class ContextControl: + """Own File source enrollment and read-back, but no acquisition behavior.""" + + __slots__ = ("_authority", "_clock", "_store") + + def __init__( + self, + *, + store: ControlStorePort, + authority: ControlOperatorAuthority, + clock: Callable[[], datetime], + ) -> None: + for method_name in ("register_file_source", "read_source"): + if not callable(getattr(store, method_name, None)): + raise TypeError("ContextControl store is incomplete") + if type(authority) is not ControlOperatorAuthority: + raise TypeError("ContextControl requires ControlOperatorAuthority") + if not callable(clock): + raise TypeError("ContextControl clock must be callable") + self._store = store + self._authority = authority + self._clock = clock + + def register_source( + self, + call: TrustedControlCall, + command: RegisterFileSource, + ) -> SourceManifest: + """Register one exact File source or expose one generic refusal.""" + + if type(command) is not RegisterFileSource: + raise TypeError("register_source requires RegisterFileSource") + try: + _validate_and_consume_control_call( + call, + authority=self._authority, + expected_operation=ControlOperation.REGISTER_SOURCE, + checked_at=self._clock(), + ) + manifest = self._store.register_file_source(call, command) + self._require_manifest(manifest) + return manifest + except (ControlOperatorAuthenticationRejected, SourceNotAvailable): + raise SourceNotAvailable from None + except SourceControlUnavailable: + raise + except Exception: + raise SourceControlUnavailable( + "source registration is unavailable" + ) from None + + def read_source( + self, + call: TrustedControlCall, + source_ref: SourceRef, + ) -> SourceManifest: + """Read one source in the trusted Organization or refuse generically.""" + + if type(source_ref) is not SourceRef: + raise TypeError("read_source requires SourceRef") + try: + _validate_and_consume_control_call( + call, + authority=self._authority, + expected_operation=ControlOperation.READ_SOURCE, + checked_at=self._clock(), + ) + manifest = self._store.read_source(call, source_ref) + self._require_manifest(manifest) + if manifest.source_ref != source_ref: + raise SourceControlUnavailable( + "source store returned a mismatched manifest" + ) + return manifest + except (ControlOperatorAuthenticationRejected, SourceNotAvailable): + raise SourceNotAvailable from None + except SourceControlUnavailable: + raise + except Exception: + raise SourceControlUnavailable("source read is unavailable") from None + + @staticmethod + def _require_manifest(manifest: object) -> None: + if type(manifest) is not SourceManifest: + raise SourceControlUnavailable("source store returned an invalid manifest") diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py index 1a837d3e..54f5db6b 100644 --- a/engine/persistence/__init__.py +++ b/engine/persistence/__init__.py @@ -31,6 +31,7 @@ PostgreSQLContextRunReader, VerifiedContextRunOperatorIdentity, ) +from engine.persistence.control_sources import PostgreSQLControlStore from engine.persistence.database import create_database_engine from engine.persistence.membership_context import ( MembershipAuthorityUnavailable, @@ -90,6 +91,7 @@ "ContextRunView", "OperatorAuthorizationProvenance", "PostgreSQLContextRunReader", + "PostgreSQLControlStore", "VerifiedContextRunOperatorIdentity", "ResourceAccessRevocation", "OrganizationContextBindingError", diff --git a/engine/persistence/control_sources.py b/engine/persistence/control_sources.py new file mode 100644 index 00000000..09d71417 --- /dev/null +++ b/engine/persistence/control_sources.py @@ -0,0 +1,292 @@ +"""PostgreSQL store for trusted ContextControl File source registration.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Mapping +from datetime import datetime +from typing import Any, cast +from uuid import UUID, uuid4 + +import rfc8785 +from sqlalchemy import Engine, text +from sqlalchemy.exc import DBAPIError, SQLAlchemyError + +from engine.control import ( + FILE_CAPABILITY_MANIFEST, + RegisterFileSource, + SourceControlUnavailable, + SourceManifest, + SourceNotAvailable, + SourceRef, + TrustedControlCall, +) +from engine.persistence.role_guard import assert_control_role + +_REGISTRATION_OPERATION = "register_source" + + +def _capability_document() -> dict[str, object]: + return FILE_CAPABILITY_MANIFEST.document() + + +_CAPABILITY_DOCUMENT = _capability_document() + + +def _registration_digest(command: RegisterFileSource) -> str: + document = { + "display_name": command.display_name, + "idempotency_key": command.idempotency_key, + "operation": _REGISTRATION_OPERATION, + "root_ref": command.root_ref, + "source_kind": "file", + } + return hashlib.sha256( + b"context-engine.register-file-source.v1\x00" + + rfc8785.dumps(document) + ).hexdigest() + + +def _set_organization_context(connection: Any, organization_id: UUID) -> None: + observed = connection.execute( + text( + "SELECT set_config('app.organization_id', :organization_id, true), " + "current_setting('app.organization_id', true)" + ), + {"organization_id": str(organization_id)}, + ).one() + if tuple(observed) != (str(organization_id), str(organization_id)): + raise SourceControlUnavailable( + "source Control Organization context could not be bound" + ) + + +class PostgreSQLControlStore: + """Register/read File source manifests under the exact non-owner Control role.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime], + uuid_factory: Callable[[], UUID] = uuid4, + ) -> None: + if not callable(clock) or not callable(uuid_factory): + raise TypeError("PostgreSQLControlStore requires clock and UUID factory") + self._engine = engine + self._clock = clock + self._uuid_factory = uuid_factory + + def register_file_source( + self, + call: TrustedControlCall, + command: RegisterFileSource, + ) -> SourceManifest: + if ( + type(call) is not TrustedControlCall + or type(command) is not RegisterFileSource + ): + raise SourceNotAvailable + digest = _registration_digest(command) + source_id = self._uuid_factory() + version_id = self._uuid_factory() + created_at = self._clock() + try: + with self._engine.begin() as connection: + assert_control_role(connection) + _set_organization_context(connection, call.organization_id) + inserted = connection.execute( + text( + """ + INSERT INTO context_source ( + organization_id, source_id, display_name, source_kind, + registration_operation, idempotency_key, + registration_digest, active_version_id, created_at + ) VALUES ( + :organization_id, :source_id, :display_name, 'file', + :registration_operation, :idempotency_key, + :registration_digest, :active_version_id, :created_at + ) + ON CONFLICT ( + organization_id, + registration_operation, + idempotency_key + ) DO NOTHING + RETURNING source_id + """ + ), + { + "organization_id": call.organization_id, + "source_id": source_id, + "display_name": command.display_name, + "registration_operation": _REGISTRATION_OPERATION, + "idempotency_key": command.idempotency_key, + "registration_digest": digest, + "active_version_id": version_id, + "created_at": created_at, + }, + ).scalar_one_or_none() + if inserted is not None: + connection.execute( + text( + """ + INSERT INTO source_version ( + organization_id, source_id, version_id, + source_kind, root_ref, capability_manifest, + created_at + ) VALUES ( + :organization_id, :source_id, :version_id, + 'file', :root_ref, CAST(:capabilities AS jsonb), + :created_at + ) + """ + ), + { + "organization_id": call.organization_id, + "source_id": source_id, + "version_id": version_id, + "root_ref": command.root_ref, + "capabilities": rfc8785.dumps( + cast(Any, _CAPABILITY_DOCUMENT) + ).decode("utf-8"), + "created_at": created_at, + }, + ) + row = self._select_registration( + connection, + organization_id=call.organization_id, + idempotency_key=command.idempotency_key, + ) + if row is None or row["registration_digest"] != digest: + raise SourceNotAvailable + return self._manifest(row) + except SourceNotAvailable: + raise + except (DBAPIError, SQLAlchemyError, AssertionError): + raise SourceControlUnavailable( + "File source registration database authority is unavailable" + ) from None + + def read_source( + self, + call: TrustedControlCall, + source_ref: SourceRef, + ) -> SourceManifest: + if type(call) is not TrustedControlCall or type(source_ref) is not SourceRef: + raise SourceNotAvailable + try: + with self._engine.begin() as connection: + assert_control_role(connection) + _set_organization_context(connection, call.organization_id) + row = connection.execute( + text( + """ + SELECT + source.source_id, + source.display_name, + source.source_kind, + source.created_at AS source_created_at, + version.version_id, + version.source_kind AS version_source_kind, + version.root_ref, + version.capability_manifest, + version.created_at AS version_created_at + FROM context_source AS source + JOIN source_version AS version + ON version.organization_id = source.organization_id + AND version.source_id = source.source_id + AND version.version_id = source.active_version_id + WHERE source.organization_id = :organization_id + AND source.source_id = :source_id + """ + ), + { + "organization_id": call.organization_id, + "source_id": source_ref.value, + }, + ).mappings().one_or_none() + if row is None: + raise SourceNotAvailable + return self._manifest(cast(Mapping[str, object], row)) + except SourceNotAvailable: + raise + except (DBAPIError, SQLAlchemyError, AssertionError): + raise SourceControlUnavailable( + "File source read database authority is unavailable" + ) from None + + @staticmethod + def _select_registration( + connection: Any, + *, + organization_id: UUID, + idempotency_key: str, + ) -> Mapping[str, object] | None: + row = connection.execute( + text( + """ + SELECT + source.source_id, + source.display_name, + source.source_kind, + source.created_at AS source_created_at, + source.registration_digest, + version.version_id, + version.source_kind AS version_source_kind, + version.root_ref, + version.capability_manifest, + version.created_at AS version_created_at + FROM context_source AS source + JOIN source_version AS version + ON version.organization_id = source.organization_id + AND version.source_id = source.source_id + AND version.version_id = source.active_version_id + WHERE source.organization_id = :organization_id + AND source.registration_operation = :registration_operation + AND source.idempotency_key = :idempotency_key + """ + ), + { + "organization_id": organization_id, + "registration_operation": _REGISTRATION_OPERATION, + "idempotency_key": idempotency_key, + }, + ).mappings().one_or_none() + if row is None: + return None + return cast(Mapping[str, object], row) + + @staticmethod + def _manifest(row: Mapping[str, object]) -> SourceManifest: + capabilities = row["capability_manifest"] + if capabilities != _CAPABILITY_DOCUMENT: + raise SourceControlUnavailable( + "stored File capability declaration is not recognized" + ) + source_id = row["source_id"] + version_id = row["version_id"] + display_name = row["display_name"] + source_kind = row["source_kind"] + version_source_kind = row["version_source_kind"] + root_ref = row["root_ref"] + source_created_at = row["source_created_at"] + version_created_at = row["version_created_at"] + if ( + type(source_id) is not UUID + or type(version_id) is not UUID + or type(display_name) is not str + or source_kind != "file" + or version_source_kind != "file" + or type(root_ref) is not str + or type(source_created_at) is not datetime + or type(version_created_at) is not datetime + or source_created_at != version_created_at + ): + raise SourceControlUnavailable("stored File source manifest is invalid") + return SourceManifest.issue_21_file( + source_ref=SourceRef(source_id), + version_ref=version_id, + display_name=display_name, + root_ref=root_ref, + created_at=source_created_at, + ) diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index 04a5b8dc..f74ddac9 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,6 +1,17 @@ { - "manifestVersion": "9.0.0", + "manifestVersion": "10.0.0", "controlOperations": [ + { + "name": "register_file_source", + "role": "context_engine_control", + "directTableMutationAllowed": true, + "trustedOrganizationSource": "TrustedControlCall", + "transactionLocalOrganizationSetting": "app.organization_id", + "organizationScopedIdempotency": true, + "filesystemAccessAllowed": false, + "durableJobCreationAllowed": false, + "atomicWrites": ["context_source", "source_version"] + }, { "name": "change_resource_access", "databaseFunction": "context_control_revoke_resource_access", @@ -303,6 +314,200 @@ "MIG-002" ] }, + { + "name": "context_source", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-FILE-SOURCE-RLS-021", + "selector": {"table": "context_source"} + }, + "purpose": "Stable Organization-owned File source identity and active immutable SourceVersion pointer", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_context_source", + "kind": "primary_key", + "columns": ["organization_id", "source_id"] + }, + { + "name": "uq_context_source_registration_idempotency", + "kind": "unique", + "columns": ["organization_id", "registration_operation", "idempotency_key"] + } + ], + "foreignKeys": [ + { + "name": "fk_context_source_organization", + "columns": ["organization_id"], + "references": { + "table": "organization", + "columns": ["organization_id"] + }, + "onDelete": "CASCADE" + }, + { + "name": "fk_context_source_active_version_same_organization", + "columns": ["organization_id", "source_id", "active_version_id"], + "references": { + "table": "source_version", + "columns": ["organization_id", "source_id", "version_id"] + }, + "onDelete": "RESTRICT", + "deferrable": true, + "initially": "DEFERRED" + } + ], + "checkConstraints": [ + { + "name": "ck_context_source_kind_file", + "expression": "source_kind = 'file'" + }, + { + "name": "ck_context_source_registration_operation", + "expression": "registration_operation = 'register_source'" + }, + { + "name": "ck_context_source_display_name", + "expression": "btrim(display_name) <> '' AND char_length(display_name) <= 200 AND display_name !~ '[[:cntrl:]]'" + }, + { + "name": "ck_context_source_idempotency_key", + "expression": "idempotency_key ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'" + }, + { + "name": "ck_context_source_registration_digest", + "expression": "registration_digest ~ '^[0-9a-f]{64}$'" + } + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + { + "name": "context_source_control_insert", + "command": "INSERT", + "roles": ["context_engine_control"], + "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "context_source_control_select", + "command": "SELECT", + "roles": ["context_engine_control"], + "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "context_source_migrator_administration", + "command": "ALL", + "roles": ["context_engine_migrator"], + "using": "true", + "withCheck": "true" + } + ] + }, + "permittedOperations": { + "context_engine_control": ["SELECT", "INSERT"], + "context_engine_learning": [], + "context_engine_runtime": [], + "context_engine_security_operator": [], + "context_engine_worker": [] + }, + "partitions": [], + "securityInvariantIds": [ + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003" + ], + "negativeTestIds": ["DB-001", "DB-003", "DB-004", "DB-008"] + }, + { + "name": "source_version", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-FILE-SOURCE-RLS-021", + "selector": {"table": "source_version"} + }, + "purpose": "Immutable Organization-owned File source configuration and exact capability declaration", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_source_version", + "kind": "primary_key", + "columns": ["organization_id", "source_id", "version_id"] + } + ], + "foreignKeys": [ + { + "name": "fk_source_version_source_same_organization", + "columns": ["organization_id", "source_id"], + "references": { + "table": "context_source", + "columns": ["organization_id", "source_id"] + }, + "onDelete": "CASCADE", + "deferrable": true, + "initially": "DEFERRED" + } + ], + "checkConstraints": [ + { + "name": "ck_source_version_kind_file", + "expression": "source_kind = 'file'" + }, + { + "name": "ck_source_version_logical_root_ref", + "expression": "root_ref ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND root_ref NOT IN ('.', '..')" + }, + { + "name": "ck_source_version_issue_21_capabilities", + "expression": "capability_manifest = '{\"aclEvidenceMode\": \"mirrored\", \"authorizeAndProject\": \"unavailable\", \"checkpoint\": \"unavailable\", \"contentKinds\": [\"markdown\"], \"declarationVersion\": \"file-capabilities-v1\", \"deletion\": \"unavailable\", \"describeCapabilities\": \"unavailable\", \"discover\": \"unavailable\", \"fileSourceAccess\": \"unavailable\", \"ingestionJobs\": \"unavailable\", \"readChanges\": \"unavailable\", \"sourceMode\": \"materialized\"}'::jsonb" + } + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + { + "name": "source_version_control_insert", + "command": "INSERT", + "roles": ["context_engine_control"], + "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "source_version_control_select", + "command": "SELECT", + "roles": ["context_engine_control"], + "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "source_version_migrator_administration", + "command": "ALL", + "roles": ["context_engine_migrator"], + "using": "true", + "withCheck": "true" + } + ] + }, + "immutableRows": { + "trigger": "source_version_immutable", + "function": "source_version_reject_mutation", + "events": ["UPDATE", "DELETE"], + "sqlstate": "55000" + }, + "permittedOperations": { + "context_engine_control": ["SELECT", "INSERT"], + "context_engine_learning": [], + "context_engine_runtime": [], + "context_engine_security_operator": [], + "context_engine_worker": [] + }, + "partitions": [], + "securityInvariantIds": [ + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003" + ], + "negativeTestIds": ["DB-001", "DB-003", "DB-004", "DB-008"] + }, { "name": "context_resource", "classification": "tenant_owned", diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index 44f60943..ab55810c 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -42,7 +42,8 @@ } ], "evidence": [ - {"id": "PROP-TENANT-OWNERSHIP-001", "layer": "property", "selector": "tests/unit/test_schema_security_manifest.py::test_manifest_classifies_the_exact_issue_49_release_schema"}, + {"id": "PROP-TENANT-OWNERSHIP-001", "layer": "property", "selector": "tests/unit/test_schema_security_manifest.py::test_manifest_classifies_the_exact_current_release_schema"}, + {"id": "PG-FILE-SOURCE-RLS-021", "layer": "postgres", "selector": "tests/integration/test_file_source_registration.py::test_control_registers_reads_and_idempotently_isolates_file_sources"}, {"id": "PG-RLS-ALL-TENANT-TABLES", "layer": "postgres", "selector": "tests/integration/test_m0_security_gate_rls.py::test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit"}, {"id": "RUNTIME-TENANT-OWNERSHIP-001", "layer": "runtime", "selector": "tests/integration/test_runtime_authorized_evidence_integration.py::test_real_postgres_http_delivers_only_exact_authorized_evidence_bidirectionally"}, {"id": "PROP-TENANT-FK-002", "layer": "property", "selector": "tests/unit/test_schema_security_manifest.py::test_content_manifest_preserves_lineage_visibility_and_immutability"}, @@ -102,7 +103,7 @@ {"id": "FIXTURE-ACCEPT-012", "layer": "runtime", "selector": "tests/unit/test_ticket_audience_separation.py::test_accept_012_context_read_ticket_cannot_create_an_action_effect"} ], "invariantMappings": [ - {"invariantRef": "TENANT-OWNERSHIP-001", "evidenceRefs": {"property": ["PROP-TENANT-OWNERSHIP-001"], "postgres": ["PG-RLS-ALL-TENANT-TABLES"], "runtime": ["RUNTIME-TENANT-OWNERSHIP-001"]}}, + {"invariantRef": "TENANT-OWNERSHIP-001", "evidenceRefs": {"property": ["PROP-TENANT-OWNERSHIP-001"], "postgres": ["PG-FILE-SOURCE-RLS-021", "PG-RLS-ALL-TENANT-TABLES"], "runtime": ["RUNTIME-TENANT-OWNERSHIP-001"]}}, {"invariantRef": "TENANT-FK-002", "evidenceRefs": {"property": ["PROP-TENANT-FK-002"], "postgres": ["PG-TENANT-FK-002"], "runtime": ["RUNTIME-TENANT-FK-002"]}}, {"invariantRef": "RLS-FAIL-CLOSED-003", "evidenceRefs": {"property": ["PROP-RLS-FAIL-CLOSED-003"], "postgres": ["PG-RLS-FAIL-CLOSED-003", "PG-RLS-ALL-TENANT-TABLES"], "runtime": ["RUNTIME-RLS-FAIL-CLOSED-003"]}}, {"invariantRef": "SCOPE-INTERSECTION-004", "evidenceRefs": {"property": ["PROP-SCOPE-INTERSECTION-004"], "postgres": ["PG-SCOPE-INTERSECTION-004", "PG-FIELD-PROJECTION-RLS-048"], "runtime": ["RUNTIME-SCOPE-INTERSECTION-004"]}}, diff --git a/migrations/versions/20260722_0010_file_source_registration.py b/migrations/versions/20260722_0010_file_source_registration.py new file mode 100644 index 00000000..1f3aa2c9 --- /dev/null +++ b/migrations/versions/20260722_0010_file_source_registration.py @@ -0,0 +1,224 @@ +"""Register one Organization-owned File ContextSource and SourceVersion. + +Revision ID: 20260722_0010 +Revises: 20260722_0009 +Create Date: 2026-07-22 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260722_0010" +down_revision: str | None = "20260722_0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_MIGRATOR_ROLE = "context_engine_migrator" +_CONTROL_ROLE = "context_engine_control" +_RUNTIME_ROLE = "context_engine_runtime" +_WORKER_ROLE = "context_engine_worker" +_LEARNING_ROLE = "context_engine_learning" +_OPERATOR_ROLE = "context_engine_security_operator" +_SOURCE_TABLE = "context_source" +_VERSION_TABLE = "source_version" +_IMMUTABILITY_FUNCTION = "public.source_version_reject_mutation" +_IMMUTABILITY_TRIGGER = "source_version_immutable" +_TENANT_EXPRESSION = ( + "organization_id = NULLIF(" + "current_setting('app.organization_id', true), ''" + ")::uuid" +) + + +def _secure_tenant_table(table_name: str) -> None: + for role in ( + "PUBLIC", + _CONTROL_ROLE, + _RUNTIME_ROLE, + _WORKER_ROLE, + _LEARNING_ROLE, + _OPERATOR_ROLE, + ): + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM {role}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY {table_name}_migrator_administration " + f"ON {table_name} AS PERMISSIVE FOR ALL TO {_MIGRATOR_ROLE} " + "USING (true) WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY {table_name}_control_select " + f"ON {table_name} AS PERMISSIVE FOR SELECT TO {_CONTROL_ROLE} " + f"USING ({_TENANT_EXPRESSION})" + ) + op.execute( + f"CREATE POLICY {table_name}_control_insert " + f"ON {table_name} AS PERMISSIVE FOR INSERT TO {_CONTROL_ROLE} " + f"WITH CHECK ({_TENANT_EXPRESSION})" + ) + op.execute( + f"GRANT SELECT, INSERT ON TABLE {table_name} TO {_CONTROL_ROLE}" + ) + + +def upgrade() -> None: + """Create the atomic immutable File source-registration boundary.""" + + op.create_table( + _SOURCE_TABLE, + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("display_name", sa.Text(), nullable=False), + sa.Column("source_kind", sa.Text(), nullable=False), + sa.Column("registration_operation", sa.Text(), nullable=False), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.Column("registration_digest", sa.Text(), nullable=False), + sa.Column( + "active_version_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", "source_id", name="pk_context_source" + ), + sa.UniqueConstraint( + "organization_id", + "registration_operation", + "idempotency_key", + name="uq_context_source_registration_idempotency", + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.organization_id"], + name="fk_context_source_organization", + ondelete="CASCADE", + ), + sa.CheckConstraint( + "source_kind = 'file'", name="ck_context_source_kind_file" + ), + sa.CheckConstraint( + "registration_operation = 'register_source'", + name="ck_context_source_registration_operation", + ), + sa.CheckConstraint( + "btrim(display_name) <> '' AND char_length(display_name) <= 200 " + "AND display_name !~ '[[:cntrl:]]'", + name="ck_context_source_display_name", + ), + sa.CheckConstraint( + "idempotency_key ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'", + name="ck_context_source_idempotency_key", + ), + sa.CheckConstraint( + "registration_digest ~ '^[0-9a-f]{64}$'", + name="ck_context_source_registration_digest", + ), + ) + op.create_table( + _VERSION_TABLE, + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("version_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_kind", sa.Text(), nullable=False), + sa.Column("root_ref", sa.Text(), nullable=False), + sa.Column( + "capability_manifest", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", + "source_id", + "version_id", + name="pk_source_version", + ), + sa.ForeignKeyConstraint( + ["organization_id", "source_id"], + ["context_source.organization_id", "context_source.source_id"], + name="fk_source_version_source_same_organization", + ondelete="CASCADE", + deferrable=True, + initially="DEFERRED", + ), + sa.CheckConstraint( + "source_kind = 'file'", name="ck_source_version_kind_file" + ), + sa.CheckConstraint( + "root_ref ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' " + "AND root_ref NOT IN ('.', '..')", + name="ck_source_version_logical_root_ref", + ), + sa.CheckConstraint( + "capability_manifest = " + "'{\"aclEvidenceMode\": \"mirrored\", " + "\"authorizeAndProject\": \"unavailable\", " + "\"checkpoint\": \"unavailable\", " + "\"contentKinds\": [\"markdown\"], " + "\"declarationVersion\": \"file-capabilities-v1\", " + "\"deletion\": \"unavailable\", " + "\"describeCapabilities\": \"unavailable\", " + "\"discover\": \"unavailable\", " + "\"fileSourceAccess\": \"unavailable\", " + "\"ingestionJobs\": \"unavailable\", " + "\"readChanges\": \"unavailable\", " + "\"sourceMode\": \"materialized\"}'::jsonb", + name="ck_source_version_issue_21_capabilities", + ), + ) + op.create_foreign_key( + "fk_context_source_active_version_same_organization", + _SOURCE_TABLE, + _VERSION_TABLE, + ["organization_id", "source_id", "active_version_id"], + ["organization_id", "source_id", "version_id"], + ondelete="RESTRICT", + deferrable=True, + initially="DEFERRED", + ) + + op.execute( + f""" + CREATE FUNCTION {_IMMUTABILITY_FUNCTION}() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog + AS $function$ + BEGIN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'SourceVersion is immutable'; + END; + $function$ + """ + ) + op.execute(f"REVOKE ALL ON FUNCTION {_IMMUTABILITY_FUNCTION}() FROM PUBLIC") + op.execute( + f"GRANT EXECUTE ON FUNCTION {_IMMUTABILITY_FUNCTION}() " + f"TO {_MIGRATOR_ROLE}" + ) + op.execute( + f"CREATE TRIGGER {_IMMUTABILITY_TRIGGER} " + f"BEFORE UPDATE OR DELETE ON {_VERSION_TABLE} " + f"FOR EACH ROW EXECUTE FUNCTION {_IMMUTABILITY_FUNCTION}()" + ) + + for table_name in (_SOURCE_TABLE, _VERSION_TABLE): + _secure_tenant_table(table_name) + + +def downgrade() -> None: + """Remove only the Issue #21 source-registration schema.""" + + op.drop_constraint( + "fk_context_source_active_version_same_organization", + _SOURCE_TABLE, + type_="foreignkey", + ) + op.drop_table(_VERSION_TABLE) + op.execute(f"DROP FUNCTION {_IMMUTABILITY_FUNCTION}()") + op.drop_table(_SOURCE_TABLE) diff --git a/scripts/security_gate/rls.py b/scripts/security_gate/rls.py index ac52c17a..7767f8d7 100644 --- a/scripts/security_gate/rls.py +++ b/scripts/security_gate/rls.py @@ -18,6 +18,7 @@ {"alembic_version", "organization", "user_account"} ) NON_OWNER_EVIDENCE_BY_TABLE: Mapping[str, str] = { + "context_source": "PG-FILE-SOURCE-RLS-021", "membership": "PG-SCOPE-INTERSECTION-004", "organization_record": "PG-TENANT-FK-002", "context_resource": "PG-INDEX-NOT-AUTHORITY-005", @@ -29,6 +30,7 @@ "context_run_operator_read_ticket": "PG-TRACE-REDACTION-012", "decision_audit": "PG-TRACE-REDACTION-012", "service_principal": "PG-WORKER-LEASE-007", + "source_version": "PG-FILE-SOURCE-RLS-021", "worker_noop_job": "PG-WORKER-LEASE-007", "context_fragment_field": "PG-FIELD-PROJECTION-RLS-048", "membership_resource_field_right": "PG-FIELD-PROJECTION-RLS-048", diff --git a/tests/integration/test_file_source_registration.py b/tests/integration/test_file_source_registration.py new file mode 100644 index 00000000..ec8d4221 --- /dev/null +++ b/tests/integration/test_file_source_registration.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import Engine, text +from sqlalchemy.exc import DBAPIError + +from engine.control import ( + ContextControl, + ControlOperation, + ControlOperatorAuthority, + RegisterFileSource, + SourceManifest, + SourceNotAvailable, + SourceRef, + VerifiedControlOperatorIdentity, +) +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLControlStore, + create_database_engine, +) + +pytestmark = pytest.mark.integration +NOW = datetime(2026, 7, 22, 19, 30, tzinfo=UTC) + + +class _Authenticator: + def __init__(self, organization_id: UUID) -> None: + self.organization_id = organization_id + + def authenticate(self, opaque_credential: str) -> VerifiedControlOperatorIdentity: + if opaque_credential != f"credential:{self.organization_id}": + raise AssertionError("unexpected test credential") + return VerifiedControlOperatorIdentity( + organization_id=self.organization_id, + operator_ref=f"operator:{self.organization_id}", + authentication_binding_ref=f"binding:{self.organization_id}", + authority_ref=f"source-admin:{self.organization_id}", + allowed_operations=frozenset( + {ControlOperation.REGISTER_SOURCE, ControlOperation.READ_SOURCE} + ), + valid_from=NOW - timedelta(minutes=1), + expires_at=NOW + timedelta(hours=1), + ) + + +def _control(engine: Engine, organization_id: UUID) -> tuple[ + ContextControl, ControlOperatorAuthority +]: + authority = ControlOperatorAuthority( + _Authenticator(organization_id), + call_ttl=timedelta(minutes=5), + clock=lambda: NOW, + ) + return ( + ContextControl( + store=PostgreSQLControlStore(engine, clock=lambda: NOW), + authority=authority, + clock=lambda: NOW, + ), + authority, + ) + + +def _register( + control: ContextControl, + authority: ControlOperatorAuthority, + organization_id: UUID, + command: RegisterFileSource, + *, + request_id: str, +) -> SourceManifest: + with authority.authorize( + opaque_credential=f"credential:{organization_id}", + operation=ControlOperation.REGISTER_SOURCE, + request_id=request_id, + ) as call: + return control.register_source(call, command) + + +def _read( + control: ContextControl, + authority: ControlOperatorAuthority, + organization_id: UUID, + source_ref: SourceRef, + *, + request_id: str, +) -> SourceManifest: + with authority.authorize( + opaque_credential=f"credential:{organization_id}", + operation=ControlOperation.READ_SOURCE, + request_id=request_id, + ) as call: + return control.read_source(call, source_ref) + + +@pytest.fixture +def organizations( + migration_configuration: DatabaseConfiguration, +) -> tuple[UUID, UUID]: + organization_a, organization_b = uuid4(), uuid4() + engine = create_database_engine(migration_configuration) + try: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO organization (organization_id) " + "VALUES (:organization_a), (:organization_b)" + ), + { + "organization_a": organization_a, + "organization_b": organization_b, + }, + ) + finally: + engine.dispose() + return organization_a, organization_b + + +@pytest.mark.security_evidence(id="PG-FILE-SOURCE-RLS-021", layer="postgres") +def test_control_registers_reads_and_idempotently_isolates_file_sources( + guarded_control_engine: Engine, + migration_configuration: DatabaseConfiguration, + organizations: tuple[UUID, UUID], + monkeypatch: pytest.MonkeyPatch, +) -> None: + organization_a, organization_b = organizations + control_a, authority_a = _control(guarded_control_engine, organization_a) + control_b, authority_b = _control(guarded_control_engine, organization_b) + command = RegisterFileSource( + display_name="Engineering handbook", + root_ref="engineering-handbook", + idempotency_key="shared-registration-key", + ) + + filesystem_calls: list[object] = [] + + def reject_filesystem(*args: object, **kwargs: object) -> None: + filesystem_calls.append((args, kwargs)) + raise AssertionError("registration touched the filesystem") + + monkeypatch.setattr(Path, "open", reject_filesystem) + monkeypatch.setattr(os, "scandir", reject_filesystem) + + first = _register( + control_a, + authority_a, + organization_a, + command, + request_id="register-a-1", + ) + retry = _register( + control_a, + authority_a, + organization_a, + command, + request_id="register-a-2", + ) + other = _register( + control_b, + authority_b, + organization_b, + command, + request_id="register-b-1", + ) + + assert retry == first + assert other.source_ref != first.source_ref + assert _read( + control_a, + authority_a, + organization_a, + first.source_ref, + request_id="read-a", + ) == first + assert filesystem_calls == [] + + failures: list[tuple[type[Exception], str]] = [] + for source_ref in (first.source_ref, type(first.source_ref)(uuid4())): + with pytest.raises(SourceNotAvailable) as error: + _read( + control_b, + authority_b, + organization_b, + source_ref, + request_id=f"read-b-{len(failures)}", + ) + failures.append((type(error.value), str(error.value))) + assert failures[0] == failures[1] + + with pytest.raises(SourceNotAvailable): + _register( + control_a, + authority_a, + organization_a, + RegisterFileSource( + display_name="Different request", + root_ref="different-root", + idempotency_key=command.idempotency_key, + ), + request_id="register-a-conflict", + ) + + migration_engine = create_database_engine(migration_configuration) + try: + with migration_engine.connect() as connection: + counts = { + table: connection.execute( + text( + f"SELECT count(*) FROM {table} " # noqa: S608 - fixed list + "WHERE organization_id = :organization_id" + ), + {"organization_id": organization_a}, + ).scalar_one() + for table in ( + "worker_noop_job", + "context_resource", + "context_revision", + "context_fragment", + ) + } + source_count = connection.execute( + text( + "SELECT count(*) FROM context_source " + "WHERE organization_id = :organization_id" + ), + {"organization_id": organization_a}, + ).scalar_one() + version_count = connection.execute( + text( + "SELECT count(*) FROM source_version " + "WHERE organization_id = :organization_id" + ), + {"organization_id": organization_a}, + ).scalar_one() + finally: + migration_engine.dispose() + assert counts == { + "worker_noop_job": 0, + "context_resource": 0, + "context_revision": 0, + "context_fragment": 0, + } + assert (source_count, version_count) == (1, 1) + + +def test_file_source_tables_fail_closed_for_non_owner_role_matrix( + guarded_control_engine: Engine, + guarded_runtime_engine: Engine, + organizations: tuple[UUID, UUID], +) -> None: + organization_a, organization_b = organizations + + for table_name in ("context_source", "source_version"): + with guarded_control_engine.connect() as connection: + assert connection.execute( + text(f"SELECT count(*) FROM {table_name}") # noqa: S608 + ).scalar_one() == 0 + with pytest.raises(DBAPIError), guarded_control_engine.begin() as connection: + connection.execute(text(f"DELETE FROM {table_name}")) # noqa: S608 + with pytest.raises(DBAPIError), guarded_runtime_engine.connect() as connection: + connection.execute( + text(f"SELECT count(*) FROM {table_name}") # noqa: S608 + ).scalar_one() + + with pytest.raises(DBAPIError), guarded_control_engine.begin() as connection: + connection.execute( + text("SELECT set_config('app.organization_id', :organization_b, true)"), + {"organization_b": str(organization_b)}, + ) + connection.execute( + text( + """ + INSERT INTO context_source ( + organization_id, source_id, display_name, source_kind, + registration_operation, idempotency_key, + registration_digest, active_version_id, created_at + ) VALUES ( + :organization_a, :source_id, 'Forbidden', 'file', + 'register_source', 'forbidden-key', :digest, + :version_id, :created_at + ) + """ + ), + { + "organization_a": organization_a, + "source_id": uuid4(), + "digest": "0" * 64, + "version_id": uuid4(), + "created_at": NOW, + }, + ) + + with guarded_control_engine.connect() as connection: + connection.execute( + text("SELECT set_config('app.organization_id', :organization_a, true)"), + {"organization_a": str(organization_a)}, + ) + assert connection.execute( + text( + "SELECT count(*) FROM context_source " + "WHERE organization_id = :organization_a" + ), + {"organization_a": organization_a}, + ).scalar_one() == 0 + + +def test_source_version_is_immutable_and_active_pointer_stays_in_organization( + guarded_control_engine: Engine, + migration_configuration: DatabaseConfiguration, + organizations: tuple[UUID, UUID], +) -> None: + organization_a, organization_b = organizations + control_a, authority_a = _control(guarded_control_engine, organization_a) + control_b, authority_b = _control(guarded_control_engine, organization_b) + source_a = _register( + control_a, + authority_a, + organization_a, + RegisterFileSource("A", "root-a", "key-a"), + request_id="register-a", + ) + source_b = _register( + control_b, + authority_b, + organization_b, + RegisterFileSource("B", "root-b", "key-b"), + request_id="register-b", + ) + + engine = create_database_engine(migration_configuration) + try: + with pytest.raises(DBAPIError), engine.begin() as connection: + connection.execute( + text( + "UPDATE source_version SET root_ref = 'changed' " + "WHERE organization_id = :organization_id " + "AND source_id = :source_id" + ), + { + "organization_id": organization_a, + "source_id": source_a.source_ref.value, + }, + ) + with pytest.raises(DBAPIError), engine.begin() as connection: + connection.execute( + text( + "UPDATE context_source SET active_version_id = :version_id " + "WHERE organization_id = :organization_id " + "AND source_id = :source_id" + ), + { + "organization_id": organization_a, + "source_id": source_a.source_ref.value, + "version_id": source_b.active_version.version_ref, + }, + ) + finally: + engine.dispose() + + +def test_source_registration_retry_matrix_is_atomic_under_concurrency( + guarded_control_engine: Engine, + migration_configuration: DatabaseConfiguration, + organizations: tuple[UUID, UUID], +) -> None: + organization_a, _ = organizations + command = RegisterFileSource( + "Concurrent handbook", + "concurrent-handbook", + "concurrent-handbook-v1", + ) + + def register(request_index: int) -> SourceManifest: + control, authority = _control(guarded_control_engine, organization_a) + return _register( + control, + authority, + organization_a, + command, + request_id=f"concurrent-register-{request_index}", + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(register, range(8))) + + assert len({manifest.source_ref for manifest in results}) == 1 + assert len( + {manifest.active_version.version_ref for manifest in results} + ) == 1 + + engine = create_database_engine(migration_configuration) + try: + with engine.connect() as connection: + assert connection.execute( + text( + "SELECT count(*) FROM context_source " + "WHERE organization_id = :organization_id " + "AND idempotency_key = :idempotency_key" + ), + { + "organization_id": organization_a, + "idempotency_key": command.idempotency_key, + }, + ).scalar_one() == 1 + assert connection.execute( + text( + "SELECT count(*) FROM source_version " + "WHERE organization_id = :organization_id " + "AND source_id = :source_id" + ), + { + "organization_id": organization_a, + "source_id": results[0].source_ref.value, + }, + ).scalar_one() == 1 + finally: + engine.dispose() diff --git a/tests/integration/test_m0_security_gate_rls.py b/tests/integration/test_m0_security_gate_rls.py index bbba3493..ebc032c6 100644 --- a/tests/integration/test_m0_security_gate_rls.py +++ b/tests/integration/test_m0_security_gate_rls.py @@ -30,7 +30,7 @@ def _manifest() -> dict[str, object]: def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( guarded_runtime_engine: Engine, ) -> None: - """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 20/20.""" + """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 22/22.""" with guarded_runtime_engine.connect() as connection: report = audit_live_rls( @@ -41,13 +41,13 @@ def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( assert report["passed"] is True assert report["denominator"] == { - "allTables": 23, - "tenantOwned": 20, + "allTables": 25, + "tenantOwned": 22, "global": 3, } assert report["coverage"] == { - "numerator": 20, - "denominator": 20, + "numerator": 22, + "denominator": 22, "percent": 100.0, } assert report["failures"] == [] @@ -89,9 +89,9 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( assert mutated["passed"] is False assert mutated["coverage"] == { - "numerator": 19, - "denominator": 20, - "percent": 95.0, + "numerator": 21, + "denominator": 22, + "percent": 95.45, } tenant_tables = cast( list[dict[str, Any]], mutated["tenantTables"] @@ -116,8 +116,8 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( ) assert restored["passed"] is True assert restored["coverage"] == { - "numerator": 20, - "denominator": 20, + "numerator": 22, + "denominator": 22, "percent": 100.0, } diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 25686877..2d88d511 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -67,7 +67,7 @@ def test_empty_baseline_remains_a_reversible_historical_revision( assert _application_tables(migration_configuration) == ["alembic_version"] finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( @@ -82,7 +82,7 @@ def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert _application_tables(migration_configuration) == [ "active_release_manifest", "alembic_version", @@ -92,6 +92,7 @@ def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -105,6 +106,7 @@ def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", ] @@ -126,7 +128,7 @@ def test_membership_revision_downgrades_to_issue_8_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] def test_content_schema_revision_downgrades_to_membership_and_reapplies_cleanly( @@ -147,7 +149,7 @@ def test_content_schema_revision_downgrades_to_membership_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert _application_tables(migration_configuration) == [ "active_release_manifest", "alembic_version", @@ -157,6 +159,7 @@ def test_content_schema_revision_downgrades_to_membership_and_reapplies_cleanly( "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -170,6 +173,7 @@ def test_content_schema_revision_downgrades_to_membership_and_reapplies_cleanly( "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", ] @@ -198,7 +202,7 @@ def test_policy_epoch_revision_downgrades_to_content_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert _application_tables(migration_configuration) == [ "active_release_manifest", "alembic_version", @@ -208,6 +212,7 @@ def test_policy_epoch_revision_downgrades_to_content_and_reapplies_cleanly( "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -221,6 +226,7 @@ def test_policy_epoch_revision_downgrades_to_content_and_reapplies_cleanly( "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", ] @@ -251,7 +257,7 @@ def test_worker_lease_revision_downgrades_to_policy_epoch_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert _application_tables(migration_configuration) == [ "active_release_manifest", "alembic_version", @@ -261,6 +267,7 @@ def test_worker_lease_revision_downgrades_to_policy_epoch_and_reapplies_cleanly( "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -274,6 +281,7 @@ def test_worker_lease_revision_downgrades_to_policy_epoch_and_reapplies_cleanly( "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", ] @@ -297,7 +305,7 @@ def test_decision_lineage_revision_downgrades_to_worker_lease_and_reapplies_clea finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert _application_tables(migration_configuration) == [ "active_release_manifest", "alembic_version", @@ -307,6 +315,7 @@ def test_decision_lineage_revision_downgrades_to_worker_lease_and_reapplies_clea "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -320,6 +329,7 @@ def test_decision_lineage_revision_downgrades_to_worker_lease_and_reapplies_clea "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", ] @@ -359,13 +369,35 @@ def test_field_projection_revision_downgrades_to_decision_lineage_and_reapplies_ finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] assert "context_fragment_field" in _application_tables(migration_configuration) assert "membership_resource_field_right" in _application_tables( migration_configuration ) +def test_file_source_revision_downgrades_to_learning_release_and_reapplies_cleanly( + migration_configuration: DatabaseConfiguration, +) -> None: + """Issue #21 source registration is one reversible schema revision.""" + + alembic_configuration = Config(ROOT / "alembic.ini") + + try: + command.downgrade(alembic_configuration, "20260722_0009") + assert _revision_rows(migration_configuration) == ["20260722_0009"] + tables = _application_tables(migration_configuration) + assert "context_source" not in tables + assert "source_version" not in tables + finally: + command.upgrade(alembic_configuration, "head") + + assert _revision_rows(migration_configuration) == ["20260722_0010"] + tables = _application_tables(migration_configuration) + assert "context_source" in tables + assert "source_version" in tables + + def test_empty_content_downgrade_preserves_v2_context_run_history( migration_configuration: DatabaseConfiguration, ) -> None: @@ -598,7 +630,7 @@ def test_field_projection_downgrade_refuses_populated_content_atomically( ): command.downgrade(alembic_configuration, "20260722_0007") - assert _revision_rows(migration_configuration) == ["20260722_0009"] + assert _revision_rows(migration_configuration) == ["20260722_0010"] with engine.connect() as connection: assert connection.execute( text( @@ -660,7 +692,7 @@ def test_field_projection_downgrade_refuses_populated_content_atomically( ): connection.execute(text(statement), parameters) except SQLAlchemyError: - if _revision_rows(migration_configuration) != ["20260722_0009"]: + if _revision_rows(migration_configuration) != ["20260722_0010"]: command.upgrade(alembic_configuration, "head") raise finally: @@ -811,7 +843,7 @@ def test_field_projection_downgrade_serializes_with_concurrent_fragment_insert( parameters, ).scalar_one() == "concurrent-private-body" finally: - if _revision_rows(migration_configuration) != ["20260722_0009"]: + if _revision_rows(migration_configuration) != ["20260722_0010"]: command.upgrade(alembic_configuration, "head") with engine.begin() as connection: connection.execute( diff --git a/tests/unit/test_context_control.py b/tests/unit/test_context_control.py new file mode 100644 index 00000000..6586dc77 --- /dev/null +++ b/tests/unit/test_context_control.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from dataclasses import fields +from datetime import UTC, datetime, timedelta +from typing import cast +from uuid import UUID + +import pytest + +from engine.control import ( + FILE_CAPABILITY_MANIFEST, + CapabilityStatus, + ContextControl, + ControlOperation, + ControlOperatorAuthenticationRejected, + ControlOperatorAuthority, + ControlStorePort, + RegisterFileSource, + SourceManifest, + SourceNotAvailable, + SourceRef, + TrustedControlCall, + VerifiedControlOperatorIdentity, +) + +ORGANIZATION_ID = UUID("a6776454-3a24-4c1c-998c-3a69a1d3de23") +NOW = datetime(2026, 7, 22, 18, 50, tzinfo=UTC) + + +class _Authenticator: + def authenticate(self, opaque_credential: str) -> VerifiedControlOperatorIdentity: + if opaque_credential != "control-credential-a": + raise ControlOperatorAuthenticationRejected + return VerifiedControlOperatorIdentity( + organization_id=ORGANIZATION_ID, + operator_ref="control-operator-a", + authentication_binding_ref="control-binding-a", + authority_ref="source-admin-a", + allowed_operations=frozenset( + {ControlOperation.REGISTER_SOURCE, ControlOperation.READ_SOURCE} + ), + valid_from=NOW - timedelta(minutes=1), + expires_at=NOW + timedelta(hours=1), + ) + + +class _Store(ControlStorePort): + def __init__(self) -> None: + self.manifest: SourceManifest | None = None + + def register_file_source( + self, call: TrustedControlCall, command: RegisterFileSource + ) -> SourceManifest: + assert call.organization_id == ORGANIZATION_ID + assert call.operation is ControlOperation.REGISTER_SOURCE + self.manifest = SourceManifest.issue_21_file( + source_ref=SourceRef( + UUID("5d37f20a-6a2b-4534-8909-e0118bbc4b47") + ), + version_ref=UUID("54ae2c20-02a1-44e7-98bf-4034841fb7ac"), + display_name=command.display_name, + root_ref=command.root_ref, + created_at=NOW, + ) + return self.manifest + + def read_source( + self, call: TrustedControlCall, source_ref: SourceRef + ) -> SourceManifest: + assert call.organization_id == ORGANIZATION_ID + assert call.operation is ControlOperation.READ_SOURCE + if self.manifest is None or source_ref != self.manifest.source_ref: + raise SourceNotAvailable + return self.manifest + + +def _authority() -> ControlOperatorAuthority: + return ControlOperatorAuthority( + _Authenticator(), + call_ttl=timedelta(minutes=5), + clock=lambda: NOW, + ) + + +def test_file_registration_command_has_no_identity_mode_or_host_path_input() -> None: + command = RegisterFileSource( + display_name="Engineering handbook", + root_ref="engineering-handbook", + idempotency_key="register-handbook-v1", + ) + + assert [field.name for field in fields(command)] == [ + "display_name", + "root_ref", + "idempotency_key", + ] + assert command.root_ref == "engineering-handbook" + + for host_path in ( + "/srv/knowledge", + "../knowledge", + "~/knowledge", + "C:\\knowledge", + "file:///srv/knowledge", + "folder/knowledge", + "folder\\knowledge", + "知识库", + ): + with pytest.raises(ValueError, match="logical File root reference"): + RegisterFileSource( + display_name="Engineering handbook", + root_ref=host_path, + idempotency_key="register-handbook-v1", + ) + + +def test_authorized_operator_registers_and_reads_one_honest_file_manifest() -> None: + store = _Store() + authority = _authority() + control = ContextControl(store=store, authority=authority, clock=lambda: NOW) + command = RegisterFileSource( + display_name="Engineering handbook", + root_ref="engineering-handbook", + idempotency_key="register-handbook-v1", + ) + + with authority.authorize( + opaque_credential="control-credential-a", + operation=ControlOperation.REGISTER_SOURCE, + request_id="register-request-a", + ) as call: + registered = control.register_source(call, command) + with pytest.raises(SourceNotAvailable): + control.register_source(call, command) + + assert registered.active_version.capabilities == FILE_CAPABILITY_MANIFEST + assert registered.active_version.capabilities.source_mode.value == "materialized" + assert registered.active_version.capabilities.content_kinds[0].value == "markdown" + assert registered.active_version.capabilities.acl_evidence_mode.value == "mirrored" + assert all( + status is CapabilityStatus.UNAVAILABLE + for status in ( + registered.active_version.capabilities.describe_capabilities, + registered.active_version.capabilities.read_changes, + registered.active_version.capabilities.discover, + registered.active_version.capabilities.authorize_and_project, + registered.active_version.capabilities.checkpoint, + registered.active_version.capabilities.deletion, + registered.active_version.capabilities.file_source_access, + registered.active_version.capabilities.ingestion_jobs, + ) + ) + assert registered.active_version.capabilities.document() == { + "aclEvidenceMode": "mirrored", + "authorizeAndProject": "unavailable", + "checkpoint": "unavailable", + "contentKinds": ["markdown"], + "declarationVersion": "file-capabilities-v1", + "deletion": "unavailable", + "describeCapabilities": "unavailable", + "discover": "unavailable", + "fileSourceAccess": "unavailable", + "ingestionJobs": "unavailable", + "readChanges": "unavailable", + "sourceMode": "materialized", + } + + with authority.authorize( + opaque_credential="control-credential-a", + operation=ControlOperation.READ_SOURCE, + request_id="read-request-a", + ) as call: + assert control.read_source(call, registered.source_ref) == registered + + +def test_source_ref_and_forged_or_wrong_operation_calls_never_authorize_control() -> ( + None +): + store = _Store() + authority = _authority() + control = ContextControl(store=store, authority=authority, clock=lambda: NOW) + command = RegisterFileSource( + display_name="Engineering handbook", + root_ref="engineering-handbook", + idempotency_key="register-handbook-v1", + ) + + with pytest.raises(TypeError, match="authority-constructed"): + TrustedControlCall() + with pytest.raises(SourceNotAvailable): + control.register_source(cast(TrustedControlCall, object()), command) + with pytest.raises(SourceNotAvailable): + control.register_source( + cast(TrustedControlCall, SourceRef(ORGANIZATION_ID)), command + ) + + with authority.authorize( + opaque_credential="control-credential-a", + operation=ControlOperation.READ_SOURCE, + request_id="wrong-operation-request", + ) as read_call, pytest.raises(SourceNotAvailable): + control.register_source(read_call, command) + + with authority.authorize( + opaque_credential="control-credential-a", + operation=ControlOperation.REGISTER_SOURCE, + request_id="scope-state-request", + ) as scoped_call: + scope = object.__getattribute__(scoped_call, "_scope") + assert not hasattr(scope, "active") + assert not hasattr(scope, "consumed") + control.register_source(scoped_call, command) + with pytest.raises(SourceNotAvailable): + control.register_source(scoped_call, command) + + +@pytest.mark.parametrize( + ("field_name", "replacement"), + [ + ("organization_id", UUID("629a286b-34b5-41f4-a7d4-7793b3b5b013")), + ("operator_ref", "substituted-operator"), + ("request_id", "substituted-request"), + ("expires_at", NOW + timedelta(days=1)), + ], +) +def test_trusted_control_call_rejects_claim_tampering( + field_name: str, + replacement: object, +) -> None: + store = _Store() + authority = _authority() + control = ContextControl(store=store, authority=authority, clock=lambda: NOW) + command = RegisterFileSource("Handbook", "handbook", "handbook-v1") + + with authority.authorize( + opaque_credential="control-credential-a", + operation=ControlOperation.REGISTER_SOURCE, + request_id="register-request-a", + ) as call: + object.__setattr__(call, field_name, replacement) + with pytest.raises(SourceNotAvailable): + control.register_source(call, command) diff --git a/tests/unit/test_m0_rls_inventory.py b/tests/unit/test_m0_rls_inventory.py index a112f4e5..d3331ca3 100644 --- a/tests/unit/test_m0_rls_inventory.py +++ b/tests/unit/test_m0_rls_inventory.py @@ -21,6 +21,7 @@ "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -33,6 +34,7 @@ "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "worker_noop_job", } @@ -118,7 +120,7 @@ def test_manifest_declares_exact_live_table_denominator_and_rls_evidence() -> No assert global_tables == GLOBAL_TABLES assert tenant_tables == TENANT_TABLES - assert len(tables) == 23 + assert len(tables) == 25 for name in sorted(GLOBAL_TABLES): rationale = tables[name]["classificationRationale"] @@ -141,8 +143,8 @@ def test_rls_auditor_requires_every_live_control_and_non_owner_evidence() -> Non assert report["passed"] is True assert report["coverage"] == { - "numerator": 20, - "denominator": 20, + "numerator": 22, + "denominator": 22, "percent": 100.0, } inventory = cast(dict[str, object], report["inventory"]) @@ -167,7 +169,7 @@ def test_rls_auditor_does_not_count_force_rls_or_evidence_gaps() -> None: assert report["passed"] is False assert report["coverage"] == { "numerator": 0, - "denominator": 20, + "denominator": 22, "percent": 0.0, } tenant_reports = cast(list[dict[str, Any]], report["tenantTables"]) diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 7086e430..784d1db2 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -26,13 +26,13 @@ def table_entries(document: dict[str, Any]) -> dict[str, dict[str, Any]]: @pytest.mark.security_evidence(id="PROP-TENANT-OWNERSHIP-001", layer="property") -def test_manifest_classifies_the_exact_issue_49_release_schema() -> None: +def test_manifest_classifies_the_exact_current_release_schema() -> None: """PROP-TENANT-OWNERSHIP-001: no current table is left unclassified.""" document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "9.0.0" + assert document["manifestVersion"] == "10.0.0" assert set(tables) == { "active_release_manifest", "alembic_version", @@ -42,6 +42,7 @@ def test_manifest_classifies_the_exact_issue_49_release_schema() -> None: "context_revision", "context_run", "context_run_operator_read_ticket", + "context_source", "decision_audit", "membership", "membership_resource_field_right", @@ -55,6 +56,7 @@ def test_manifest_classifies_the_exact_issue_49_release_schema() -> None: "release_promotion_audit", "resource_access_policy", "service_principal", + "source_version", "user_account", "worker_noop_job", } @@ -79,6 +81,8 @@ def test_manifest_classifies_the_exact_issue_49_release_schema() -> None: assert tables["decision_audit"]["classification"] == "tenant_owned" assert tables["service_principal"]["classification"] == "tenant_owned" assert tables["worker_noop_job"]["classification"] == "tenant_owned" + assert tables["context_source"]["classification"] == "tenant_owned" + assert tables["source_version"]["classification"] == "tenant_owned" for release_table in ( "active_release_manifest", "release_candidate", @@ -90,6 +94,88 @@ def test_manifest_classifies_the_exact_issue_49_release_schema() -> None: assert tables[release_table]["classification"] == "tenant_owned" +def test_issue_21_file_source_manifest_is_closed_and_role_separated() -> None: + entries = table_entries(manifest()) + source = entries["context_source"] + version = entries["source_version"] + + assert source["organizationInclusiveKeys"] == [ + { + "name": "pk_context_source", + "kind": "primary_key", + "columns": ["organization_id", "source_id"], + }, + { + "name": "uq_context_source_registration_idempotency", + "kind": "unique", + "columns": [ + "organization_id", + "registration_operation", + "idempotency_key", + ], + }, + ] + source_foreign_keys = { + foreign_key["name"]: foreign_key for foreign_key in source["foreignKeys"] + } + assert source_foreign_keys[ + "fk_context_source_active_version_same_organization" + ]["columns"] == ["organization_id", "source_id", "active_version_id"] + assert version["organizationInclusiveKeys"] == [ + { + "name": "pk_source_version", + "kind": "primary_key", + "columns": ["organization_id", "source_id", "version_id"], + } + ] + assert version["immutableRows"] == { + "trigger": "source_version_immutable", + "function": "source_version_reject_mutation", + "events": ["UPDATE", "DELETE"], + "sqlstate": "55000", + } + capability_constraint = next( + constraint + for constraint in version["checkConstraints"] + if constraint["name"] == "ck_source_version_issue_21_capabilities" + ) + assert "materialized" in capability_constraint["expression"] + assert "markdown" in capability_constraint["expression"] + assert "mirrored" in capability_constraint["expression"] + assert "\"describeCapabilities\": \"unavailable\"" in ( + capability_constraint["expression"] + ) + assert capability_constraint["expression"].count("unavailable") == 8 + + for entry in (source, version): + assert entry["permittedOperations"] == { + "context_engine_control": ["SELECT", "INSERT"], + "context_engine_learning": [], + "context_engine_runtime": [], + "context_engine_security_operator": [], + "context_engine_worker": [], + } + assert entry["rowLevelSecurity"]["enabled"] is True + assert entry["rowLevelSecurity"]["forced"] is True + + operation = next( + operation + for operation in manifest()["controlOperations"] + if operation["name"] == "register_file_source" + ) + assert operation == { + "name": "register_file_source", + "role": "context_engine_control", + "directTableMutationAllowed": True, + "trustedOrganizationSource": "TrustedControlCall", + "transactionLocalOrganizationSetting": "app.organization_id", + "organizationScopedIdempotency": True, + "filesystemAccessAllowed": False, + "durableJobCreationAllowed": False, + "atomicWrites": ["context_source", "source_version"], + } + + def test_issue_19_lineage_manifest_is_closed_and_role_separated() -> None: """TRACE-REDACTION-012: durable lineage exposes no denial detail.""" @@ -517,7 +603,13 @@ def test_worker_lease_manifest_requires_exact_receiver_and_job() -> None: assert receiver_value in update_policy["using"] operations = manifest()["controlOperations"] - assert operations[1:3] == [ + worker_operations = [ + operation + for operation in operations + if operation["name"] + in {"issue_noop_worker_lease", "complete_noop_worker_job"} + ] + assert worker_operations == [ { "name": "issue_noop_worker_lease", "databaseFunction": "context_worker_issue_noop_lease", @@ -1135,7 +1227,12 @@ def test_policy_epoch_manifest_seals_runtime_reads_and_control_mutation() -> Non "app.organization_id" in policy["using"] for policy in definer_policies ) - assert document["controlOperations"][0] == { + change_access = next( + operation + for operation in document["controlOperations"] + if operation["name"] == "change_resource_access" + ) + assert change_access == { "name": "change_resource_access", "databaseFunction": "context_control_revoke_resource_access", "role": "context_engine_control", From 621d303a256b59239d7e35331ab2330dd90dcf78 Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 22 Jul 2026 19:54:58 +0800 Subject: [PATCH 2/5] fix(control): close file source capability declaration --- ...er-file-sources-through-context-control.md | 14 ++-- engine/control/__init__.py | 6 +- engine/control/contracts.py | 70 ++++++++++++++----- engine/persistence/control_sources.py | 62 +++++++--------- .../persistence/schema_security_manifest.yaml | 2 +- .../20260722_0010_file_source_registration.py | 7 ++ .../test_file_source_registration.py | 11 +-- tests/unit/test_context_control.py | 31 +++++--- tests/unit/test_schema_security_manifest.py | 16 ++++- 9 files changed, 142 insertions(+), 77 deletions(-) diff --git a/docs/decisions/0035-register-file-sources-through-context-control.md b/docs/decisions/0035-register-file-sources-through-context-control.md index e386e41b..75e81554 100644 --- a/docs/decisions/0035-register-file-sources-through-context-control.md +++ b/docs/decisions/0035-register-file-sources-through-context-control.md @@ -69,12 +69,14 @@ different Organizations are independent. Source read-back always derives its Organization from the trusted call. Cross-Organization and unknown source references therefore share one `SourceNotAvailable` result. -The File declaration fixes `materialized` source mode, Markdown content, and -Mirrored ACL policy while marking every Provider carrier not implemented in -Issue #21 unavailable: capability-description dispatch, change reading, -discovery, authorization/projection, checkpoint, deletion, ingestion jobs, and -FileSourceAccess activation. A -registered source is configuration only and is not acquisition-ready. +The File declaration fixes `materialized` source mode, Markdown content, the +`markdown_document` resource kind, and Mirrored ACL policy. It declares no +projectable fields and marks cursor semantics, checkpoint semantics, batch +limits, freshness, consistency guarantees, and every Provider carrier not +implemented in Issue #21 unavailable: capability-description dispatch, change +reading, discovery, authorization/projection, checkpoint, deletion, ingestion +jobs, and FileSourceAccess activation. A registered source is configuration +only and is not acquisition-ready. ## Rationale diff --git a/engine/control/__init__.py b/engine/control/__init__.py index b1296825..0e4e5eb4 100644 --- a/engine/control/__init__.py +++ b/engine/control/__init__.py @@ -13,6 +13,7 @@ FILE_CAPABILITY_MANIFEST, CapabilityStatus, FileCapabilityManifest, + FileRootRef, RegisterFileSource, SourceAclEvidenceMode, SourceContentKind, @@ -22,6 +23,7 @@ SourceMode, SourceNotAvailable, SourceRef, + SourceResourceKind, SourceVersion, ) from engine.control.module import ContextControl, ControlStorePort @@ -37,6 +39,7 @@ "ControlOperatorAuthorityUnavailable", "ControlStorePort", "FileCapabilityManifest", + "FileRootRef", "RegisterFileSource", "SourceAclEvidenceMode", "SourceControlUnavailable", @@ -44,8 +47,9 @@ "SourceKind", "SourceManifest", "SourceMode", - "SourceRef", "SourceNotAvailable", + "SourceRef", + "SourceResourceKind", "SourceVersion", "TrustedControlCall", "VerifiedControlOperatorIdentity", diff --git a/engine/control/contracts.py b/engine/control/contracts.py index ddddae8b..6f754c34 100644 --- a/engine/control/contracts.py +++ b/engine/control/contracts.py @@ -58,6 +58,10 @@ class SourceContentKind(StrEnum): MARKDOWN = "markdown" +class SourceResourceKind(StrEnum): + MARKDOWN_DOCUMENT = "markdown_document" + + class SourceAclEvidenceMode(StrEnum): MIRRORED = "mirrored" @@ -74,7 +78,16 @@ class FileCapabilityManifest: declaration_version: str = "file-capabilities-v1" source_mode: SourceMode = SourceMode.MATERIALIZED content_kinds: tuple[SourceContentKind, ...] = (SourceContentKind.MARKDOWN,) + resource_kinds: tuple[SourceResourceKind, ...] = ( + SourceResourceKind.MARKDOWN_DOCUMENT, + ) acl_evidence_mode: SourceAclEvidenceMode = SourceAclEvidenceMode.MIRRORED + projection_fields: tuple[str, ...] = () + cursor_semantics: CapabilityStatus = CapabilityStatus.UNAVAILABLE + checkpoint_semantics: CapabilityStatus = CapabilityStatus.UNAVAILABLE + batch_limits: CapabilityStatus = CapabilityStatus.UNAVAILABLE + freshness: CapabilityStatus = CapabilityStatus.UNAVAILABLE + consistency_guarantees: CapabilityStatus = CapabilityStatus.UNAVAILABLE describe_capabilities: CapabilityStatus = CapabilityStatus.UNAVAILABLE read_changes: CapabilityStatus = CapabilityStatus.UNAVAILABLE discover: CapabilityStatus = CapabilityStatus.UNAVAILABLE @@ -89,10 +102,17 @@ def __post_init__(self) -> None: self.declaration_version != "file-capabilities-v1" or self.source_mode is not SourceMode.MATERIALIZED or self.content_kinds != (SourceContentKind.MARKDOWN,) + or self.resource_kinds != (SourceResourceKind.MARKDOWN_DOCUMENT,) or self.acl_evidence_mode is not SourceAclEvidenceMode.MIRRORED + or self.projection_fields != () or any( status is not CapabilityStatus.UNAVAILABLE for status in ( + self.cursor_semantics, + self.checkpoint_semantics, + self.batch_limits, + self.freshness, + self.consistency_guarantees, self.describe_capabilities, self.read_changes, self.discover, @@ -112,15 +132,22 @@ def document(self) -> dict[str, object]: return { "aclEvidenceMode": self.acl_evidence_mode.value, "authorizeAndProject": self.authorize_and_project.value, + "batchLimits": self.batch_limits.value, "checkpoint": self.checkpoint.value, + "checkpointSemantics": self.checkpoint_semantics.value, "contentKinds": [value.value for value in self.content_kinds], + "consistencyGuarantees": self.consistency_guarantees.value, + "cursorSemantics": self.cursor_semantics.value, "declarationVersion": self.declaration_version, "deletion": self.deletion.value, "describeCapabilities": self.describe_capabilities.value, "discover": self.discover.value, "fileSourceAccess": self.file_source_access.value, + "freshness": self.freshness.value, "ingestionJobs": self.ingestion_jobs.value, + "projectionFields": list(self.projection_fields), "readChanges": self.read_changes.value, + "resourceKinds": [value.value for value in self.resource_kinds], "sourceMode": self.source_mode.value, } @@ -128,12 +155,31 @@ def document(self) -> dict[str, object]: FILE_CAPABILITY_MANIFEST = FileCapabilityManifest() +@dataclass(frozen=True, slots=True) +class FileRootRef: + """Opaque logical File root identity; never a host filesystem path.""" + + value: str = field(repr=False) + + def __post_init__(self) -> None: + try: + value = _require_token("FileRootRef", self.value) + except ValueError: + raise ValueError( + "FileRootRef must be an opaque logical File root reference" + ) from None + if value in {".", ".."}: + raise ValueError( + "FileRootRef must be an opaque logical File root reference" + ) + + @dataclass(frozen=True, slots=True) class RegisterFileSource: """Untrusted registration values; trusted identity and mode are absent.""" display_name: str - root_ref: str = field(repr=False) + root_ref: FileRootRef = field(repr=False) idempotency_key: str = field(repr=False) def __post_init__(self) -> None: @@ -142,14 +188,8 @@ def __post_init__(self) -> None: self.display_name, MAX_SOURCE_DISPLAY_NAME_LENGTH, ) - try: - root_ref = _require_token("File root_ref", self.root_ref) - except ValueError: - raise ValueError( - "root_ref must be an opaque logical File root reference" - ) from None - if root_ref in {".", ".."}: - raise ValueError("root_ref must be an opaque logical File root reference") + if type(self.root_ref) is not FileRootRef: + raise TypeError("root_ref must be FileRootRef") _require_token("File registration idempotency_key", self.idempotency_key) def __reduce__(self) -> NoReturn: @@ -174,7 +214,7 @@ class SourceVersion: source_ref: SourceRef version_ref: UUID = field(repr=False) kind: SourceKind - root_ref: str = field(repr=False) + root_ref: FileRootRef = field(repr=False) capabilities: FileCapabilityManifest created_at: datetime @@ -185,12 +225,8 @@ def __post_init__(self) -> None: raise TypeError("SourceVersion version_ref must be UUID") if self.kind is not SourceKind.FILE: raise ValueError("SourceVersion kind must be file") - try: - _require_token("SourceVersion root_ref", self.root_ref) - except ValueError: - raise ValueError( - "SourceVersion root_ref must be a logical File root reference" - ) from None + if type(self.root_ref) is not FileRootRef: + raise TypeError("SourceVersion root_ref must be FileRootRef") if type(self.capabilities) is not FileCapabilityManifest: raise TypeError("SourceVersion requires FileCapabilityManifest") _require_utc("SourceVersion created_at", self.created_at) @@ -231,7 +267,7 @@ def issue_21_file( source_ref: SourceRef, version_ref: UUID, display_name: str, - root_ref: str, + root_ref: FileRootRef, created_at: datetime, ) -> SourceManifest: """Construct the exact first File manifest from trusted stored facts.""" diff --git a/engine/persistence/control_sources.py b/engine/persistence/control_sources.py index 09d71417..ad3d4394 100644 --- a/engine/persistence/control_sources.py +++ b/engine/persistence/control_sources.py @@ -14,6 +14,7 @@ from engine.control import ( FILE_CAPABILITY_MANIFEST, + FileRootRef, RegisterFileSource, SourceControlUnavailable, SourceManifest, @@ -24,6 +25,24 @@ from engine.persistence.role_guard import assert_control_role _REGISTRATION_OPERATION = "register_source" +_ACTIVE_SOURCE_SELECT = """ + SELECT + source.source_id, + source.display_name, + source.source_kind, + source.created_at AS source_created_at, + source.registration_digest, + version.version_id, + version.source_kind AS version_source_kind, + version.root_ref, + version.capability_manifest, + version.created_at AS version_created_at + FROM context_source AS source + JOIN source_version AS version + ON version.organization_id = source.organization_id + AND version.source_id = source.source_id + AND version.version_id = source.active_version_id +""" def _capability_document() -> dict[str, object]: @@ -38,7 +57,7 @@ def _registration_digest(command: RegisterFileSource) -> str: "display_name": command.display_name, "idempotency_key": command.idempotency_key, "operation": _REGISTRATION_OPERATION, - "root_ref": command.root_ref, + "root_ref": command.root_ref.value, "source_kind": "file", } return hashlib.sha256( @@ -145,7 +164,7 @@ def register_file_source( "organization_id": call.organization_id, "source_id": source_id, "version_id": version_id, - "root_ref": command.root_ref, + "root_ref": command.root_ref.value, "capabilities": rfc8785.dumps( cast(Any, _CAPABILITY_DOCUMENT) ).decode("utf-8"), @@ -180,22 +199,8 @@ def read_source( _set_organization_context(connection, call.organization_id) row = connection.execute( text( - """ - SELECT - source.source_id, - source.display_name, - source.source_kind, - source.created_at AS source_created_at, - version.version_id, - version.source_kind AS version_source_kind, - version.root_ref, - version.capability_manifest, - version.created_at AS version_created_at - FROM context_source AS source - JOIN source_version AS version - ON version.organization_id = source.organization_id - AND version.source_id = source.source_id - AND version.version_id = source.active_version_id + _ACTIVE_SOURCE_SELECT + + """ WHERE source.organization_id = :organization_id AND source.source_id = :source_id """ @@ -224,23 +229,8 @@ def _select_registration( ) -> Mapping[str, object] | None: row = connection.execute( text( - """ - SELECT - source.source_id, - source.display_name, - source.source_kind, - source.created_at AS source_created_at, - source.registration_digest, - version.version_id, - version.source_kind AS version_source_kind, - version.root_ref, - version.capability_manifest, - version.created_at AS version_created_at - FROM context_source AS source - JOIN source_version AS version - ON version.organization_id = source.organization_id - AND version.source_id = source.source_id - AND version.version_id = source.active_version_id + _ACTIVE_SOURCE_SELECT + + """ WHERE source.organization_id = :organization_id AND source.registration_operation = :registration_operation AND source.idempotency_key = :idempotency_key @@ -287,6 +277,6 @@ def _manifest(row: Mapping[str, object]) -> SourceManifest: source_ref=SourceRef(source_id), version_ref=version_id, display_name=display_name, - root_ref=root_ref, + root_ref=FileRootRef(root_ref), created_at=source_created_at, ) diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index f74ddac9..97c7abd0 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -459,7 +459,7 @@ }, { "name": "ck_source_version_issue_21_capabilities", - "expression": "capability_manifest = '{\"aclEvidenceMode\": \"mirrored\", \"authorizeAndProject\": \"unavailable\", \"checkpoint\": \"unavailable\", \"contentKinds\": [\"markdown\"], \"declarationVersion\": \"file-capabilities-v1\", \"deletion\": \"unavailable\", \"describeCapabilities\": \"unavailable\", \"discover\": \"unavailable\", \"fileSourceAccess\": \"unavailable\", \"ingestionJobs\": \"unavailable\", \"readChanges\": \"unavailable\", \"sourceMode\": \"materialized\"}'::jsonb" + "expression": "capability_manifest = '{\"aclEvidenceMode\": \"mirrored\", \"authorizeAndProject\": \"unavailable\", \"batchLimits\": \"unavailable\", \"checkpoint\": \"unavailable\", \"checkpointSemantics\": \"unavailable\", \"contentKinds\": [\"markdown\"], \"consistencyGuarantees\": \"unavailable\", \"cursorSemantics\": \"unavailable\", \"declarationVersion\": \"file-capabilities-v1\", \"deletion\": \"unavailable\", \"describeCapabilities\": \"unavailable\", \"discover\": \"unavailable\", \"fileSourceAccess\": \"unavailable\", \"freshness\": \"unavailable\", \"ingestionJobs\": \"unavailable\", \"projectionFields\": [], \"readChanges\": \"unavailable\", \"resourceKinds\": [\"markdown_document\"], \"sourceMode\": \"materialized\"}'::jsonb" } ], "rowLevelSecurity": { diff --git a/migrations/versions/20260722_0010_file_source_registration.py b/migrations/versions/20260722_0010_file_source_registration.py index 1f3aa2c9..c6b7fff1 100644 --- a/migrations/versions/20260722_0010_file_source_registration.py +++ b/migrations/versions/20260722_0010_file_source_registration.py @@ -156,15 +156,22 @@ def upgrade() -> None: "capability_manifest = " "'{\"aclEvidenceMode\": \"mirrored\", " "\"authorizeAndProject\": \"unavailable\", " + "\"batchLimits\": \"unavailable\", " "\"checkpoint\": \"unavailable\", " + "\"checkpointSemantics\": \"unavailable\", " "\"contentKinds\": [\"markdown\"], " + "\"consistencyGuarantees\": \"unavailable\", " + "\"cursorSemantics\": \"unavailable\", " "\"declarationVersion\": \"file-capabilities-v1\", " "\"deletion\": \"unavailable\", " "\"describeCapabilities\": \"unavailable\", " "\"discover\": \"unavailable\", " "\"fileSourceAccess\": \"unavailable\", " + "\"freshness\": \"unavailable\", " "\"ingestionJobs\": \"unavailable\", " + "\"projectionFields\": [], " "\"readChanges\": \"unavailable\", " + "\"resourceKinds\": [\"markdown_document\"], " "\"sourceMode\": \"materialized\"}'::jsonb", name="ck_source_version_issue_21_capabilities", ), diff --git a/tests/integration/test_file_source_registration.py b/tests/integration/test_file_source_registration.py index ec8d4221..972b9d14 100644 --- a/tests/integration/test_file_source_registration.py +++ b/tests/integration/test_file_source_registration.py @@ -14,6 +14,7 @@ ContextControl, ControlOperation, ControlOperatorAuthority, + FileRootRef, RegisterFileSource, SourceManifest, SourceNotAvailable, @@ -135,7 +136,7 @@ def test_control_registers_reads_and_idempotently_isolates_file_sources( control_b, authority_b = _control(guarded_control_engine, organization_b) command = RegisterFileSource( display_name="Engineering handbook", - root_ref="engineering-handbook", + root_ref=FileRootRef("engineering-handbook"), idempotency_key="shared-registration-key", ) @@ -201,7 +202,7 @@ def reject_filesystem(*args: object, **kwargs: object) -> None: organization_a, RegisterFileSource( display_name="Different request", - root_ref="different-root", + root_ref=FileRootRef("different-root"), idempotency_key=command.idempotency_key, ), request_id="register-a-conflict", @@ -323,14 +324,14 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( control_a, authority_a, organization_a, - RegisterFileSource("A", "root-a", "key-a"), + RegisterFileSource("A", FileRootRef("root-a"), "key-a"), request_id="register-a", ) source_b = _register( control_b, authority_b, organization_b, - RegisterFileSource("B", "root-b", "key-b"), + RegisterFileSource("B", FileRootRef("root-b"), "key-b"), request_id="register-b", ) @@ -373,7 +374,7 @@ def test_source_registration_retry_matrix_is_atomic_under_concurrency( organization_a, _ = organizations command = RegisterFileSource( "Concurrent handbook", - "concurrent-handbook", + FileRootRef("concurrent-handbook"), "concurrent-handbook-v1", ) diff --git a/tests/unit/test_context_control.py b/tests/unit/test_context_control.py index 6586dc77..7b5d3afb 100644 --- a/tests/unit/test_context_control.py +++ b/tests/unit/test_context_control.py @@ -15,6 +15,7 @@ ControlOperatorAuthenticationRejected, ControlOperatorAuthority, ControlStorePort, + FileRootRef, RegisterFileSource, SourceManifest, SourceNotAvailable, @@ -85,7 +86,7 @@ def _authority() -> ControlOperatorAuthority: def test_file_registration_command_has_no_identity_mode_or_host_path_input() -> None: command = RegisterFileSource( display_name="Engineering handbook", - root_ref="engineering-handbook", + root_ref=FileRootRef("engineering-handbook"), idempotency_key="register-handbook-v1", ) @@ -94,7 +95,7 @@ def test_file_registration_command_has_no_identity_mode_or_host_path_input() -> "root_ref", "idempotency_key", ] - assert command.root_ref == "engineering-handbook" + assert command.root_ref == FileRootRef("engineering-handbook") for host_path in ( "/srv/knowledge", @@ -107,11 +108,7 @@ def test_file_registration_command_has_no_identity_mode_or_host_path_input() -> "知识库", ): with pytest.raises(ValueError, match="logical File root reference"): - RegisterFileSource( - display_name="Engineering handbook", - root_ref=host_path, - idempotency_key="register-handbook-v1", - ) + FileRootRef(host_path) def test_authorized_operator_registers_and_reads_one_honest_file_manifest() -> None: @@ -120,7 +117,7 @@ def test_authorized_operator_registers_and_reads_one_honest_file_manifest() -> N control = ContextControl(store=store, authority=authority, clock=lambda: NOW) command = RegisterFileSource( display_name="Engineering handbook", - root_ref="engineering-handbook", + root_ref=FileRootRef("engineering-handbook"), idempotency_key="register-handbook-v1", ) @@ -140,6 +137,11 @@ def test_authorized_operator_registers_and_reads_one_honest_file_manifest() -> N assert all( status is CapabilityStatus.UNAVAILABLE for status in ( + registered.active_version.capabilities.cursor_semantics, + registered.active_version.capabilities.checkpoint_semantics, + registered.active_version.capabilities.batch_limits, + registered.active_version.capabilities.freshness, + registered.active_version.capabilities.consistency_guarantees, registered.active_version.capabilities.describe_capabilities, registered.active_version.capabilities.read_changes, registered.active_version.capabilities.discover, @@ -153,15 +155,22 @@ def test_authorized_operator_registers_and_reads_one_honest_file_manifest() -> N assert registered.active_version.capabilities.document() == { "aclEvidenceMode": "mirrored", "authorizeAndProject": "unavailable", + "batchLimits": "unavailable", "checkpoint": "unavailable", + "checkpointSemantics": "unavailable", "contentKinds": ["markdown"], + "consistencyGuarantees": "unavailable", + "cursorSemantics": "unavailable", "declarationVersion": "file-capabilities-v1", "deletion": "unavailable", "describeCapabilities": "unavailable", "discover": "unavailable", "fileSourceAccess": "unavailable", + "freshness": "unavailable", "ingestionJobs": "unavailable", + "projectionFields": [], "readChanges": "unavailable", + "resourceKinds": ["markdown_document"], "sourceMode": "materialized", } @@ -181,7 +190,7 @@ def test_source_ref_and_forged_or_wrong_operation_calls_never_authorize_control( control = ContextControl(store=store, authority=authority, clock=lambda: NOW) command = RegisterFileSource( display_name="Engineering handbook", - root_ref="engineering-handbook", + root_ref=FileRootRef("engineering-handbook"), idempotency_key="register-handbook-v1", ) @@ -230,7 +239,9 @@ def test_trusted_control_call_rejects_claim_tampering( store = _Store() authority = _authority() control = ContextControl(store=store, authority=authority, clock=lambda: NOW) - command = RegisterFileSource("Handbook", "handbook", "handbook-v1") + command = RegisterFileSource( + "Handbook", FileRootRef("handbook"), "handbook-v1" + ) with authority.authorize( opaque_credential="control-credential-a", diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 784d1db2..5cc6e206 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -142,10 +142,24 @@ def test_issue_21_file_source_manifest_is_closed_and_role_separated() -> None: assert "materialized" in capability_constraint["expression"] assert "markdown" in capability_constraint["expression"] assert "mirrored" in capability_constraint["expression"] + assert '"resourceKinds": ["markdown_document"]' in ( + capability_constraint["expression"] + ) + assert '"projectionFields": []' in capability_constraint["expression"] + for dimension in ( + "batchLimits", + "checkpointSemantics", + "consistencyGuarantees", + "cursorSemantics", + "freshness", + ): + assert f'"{dimension}": "unavailable"' in ( + capability_constraint["expression"] + ) assert "\"describeCapabilities\": \"unavailable\"" in ( capability_constraint["expression"] ) - assert capability_constraint["expression"].count("unavailable") == 8 + assert capability_constraint["expression"].count("unavailable") == 13 for entry in (source, version): assert entry["permittedOperations"] == { From 7989efa9aecd87db8c1757ed0a255083d5c497cc Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 22 Jul 2026 20:12:29 +0800 Subject: [PATCH 3/5] test(security): gate file source tenant FKs --- eval/catalogs/m0-security-evidence.yaml | 3 ++- .../test_validate_m0_security_evidence.py | 10 ++++++++ .../test_file_source_registration.py | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index ab55810c..9d81d510 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -48,6 +48,7 @@ {"id": "RUNTIME-TENANT-OWNERSHIP-001", "layer": "runtime", "selector": "tests/integration/test_runtime_authorized_evidence_integration.py::test_real_postgres_http_delivers_only_exact_authorized_evidence_bidirectionally"}, {"id": "PROP-TENANT-FK-002", "layer": "property", "selector": "tests/unit/test_schema_security_manifest.py::test_content_manifest_preserves_lineage_visibility_and_immutability"}, {"id": "PG-TENANT-FK-002", "layer": "postgres", "selector": "tests/integration/test_organization_isolation.py::test_composite_ownership_accepts_same_org_and_rejects_cross_org_parent"}, + {"id": "PG-FILE-SOURCE-FK-021", "layer": "postgres", "selector": "tests/integration/test_file_source_registration.py::test_source_version_is_immutable_and_active_pointer_stays_in_organization"}, {"id": "RUNTIME-TENANT-FK-002", "layer": "runtime", "selector": "tests/integration/test_runtime_authorized_evidence_integration.py::test_real_postgres_http_delivers_only_exact_authorized_evidence_bidirectionally"}, {"id": "PROP-RLS-FAIL-CLOSED-003", "layer": "property", "selector": "tests/unit/test_schema_security_manifest.py::test_tenant_owned_manifest_entry_preserves_every_security_property"}, {"id": "PG-RLS-FAIL-CLOSED-003", "layer": "postgres", "selector": "tests/integration/test_organization_isolation.py::test_missing_tenant_context_is_fail_closed_for_every_operation"}, @@ -104,7 +105,7 @@ ], "invariantMappings": [ {"invariantRef": "TENANT-OWNERSHIP-001", "evidenceRefs": {"property": ["PROP-TENANT-OWNERSHIP-001"], "postgres": ["PG-FILE-SOURCE-RLS-021", "PG-RLS-ALL-TENANT-TABLES"], "runtime": ["RUNTIME-TENANT-OWNERSHIP-001"]}}, - {"invariantRef": "TENANT-FK-002", "evidenceRefs": {"property": ["PROP-TENANT-FK-002"], "postgres": ["PG-TENANT-FK-002"], "runtime": ["RUNTIME-TENANT-FK-002"]}}, + {"invariantRef": "TENANT-FK-002", "evidenceRefs": {"property": ["PROP-TENANT-FK-002"], "postgres": ["PG-TENANT-FK-002", "PG-FILE-SOURCE-FK-021"], "runtime": ["RUNTIME-TENANT-FK-002"]}}, {"invariantRef": "RLS-FAIL-CLOSED-003", "evidenceRefs": {"property": ["PROP-RLS-FAIL-CLOSED-003"], "postgres": ["PG-RLS-FAIL-CLOSED-003", "PG-RLS-ALL-TENANT-TABLES"], "runtime": ["RUNTIME-RLS-FAIL-CLOSED-003"]}}, {"invariantRef": "SCOPE-INTERSECTION-004", "evidenceRefs": {"property": ["PROP-SCOPE-INTERSECTION-004"], "postgres": ["PG-SCOPE-INTERSECTION-004", "PG-FIELD-PROJECTION-RLS-048"], "runtime": ["RUNTIME-SCOPE-INTERSECTION-004"]}}, {"invariantRef": "INDEX-NOT-AUTHORITY-005", "evidenceRefs": {"property": ["PROP-INDEX-NOT-AUTHORITY-005"], "postgres": ["PG-INDEX-NOT-AUTHORITY-005"], "runtime": ["RUNTIME-INDEX-NOT-AUTHORITY-005"]}}, diff --git a/tests/catalog/test_validate_m0_security_evidence.py b/tests/catalog/test_validate_m0_security_evidence.py index a1ffa999..59718752 100644 --- a/tests/catalog/test_validate_m0_security_evidence.py +++ b/tests/catalog/test_validate_m0_security_evidence.py @@ -148,6 +148,16 @@ def test_m0_registry_uses_honest_unavailable_carrier_and_learning_evidence() -> "tests/integration/test_authorized_field_schema.py::" "test_cross_organization_field_authority_and_values_fail_closed" ) + assert evidence["PG-FILE-SOURCE-FK-021"] == ( + "tests/integration/test_file_source_registration.py::" + "test_source_version_is_immutable_and_active_pointer_stays_in_organization" + ) + tenant_fk = next( + mapping + for mapping in registry["invariantMappings"] + if mapping["invariantRef"] == "TENANT-FK-002" + ) + assert "PG-FILE-SOURCE-FK-021" in tenant_fk["evidenceRefs"]["postgres"] def test_registry_requires_every_invariant_evidence_layer() -> None: diff --git a/tests/integration/test_file_source_registration.py b/tests/integration/test_file_source_registration.py index 972b9d14..840e9b4b 100644 --- a/tests/integration/test_file_source_registration.py +++ b/tests/integration/test_file_source_registration.py @@ -312,6 +312,7 @@ def test_file_source_tables_fail_closed_for_non_owner_role_matrix( ).scalar_one() == 0 +@pytest.mark.security_evidence(id="PG-FILE-SOURCE-FK-021", layer="postgres") def test_source_version_is_immutable_and_active_pointer_stays_in_organization( guarded_control_engine: Engine, migration_configuration: DatabaseConfiguration, @@ -337,6 +338,30 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( engine = create_database_engine(migration_configuration) try: + with pytest.raises(DBAPIError), engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO source_version ( + organization_id, source_id, version_id, source_kind, + root_ref, capability_manifest, created_at + ) + SELECT + :organization_a, :source_b_id, :version_id, source_kind, + root_ref, capability_manifest, :created_at + FROM source_version + WHERE organization_id = :organization_b + AND source_id = :source_b_id + """ + ), + { + "organization_a": organization_a, + "organization_b": organization_b, + "source_b_id": source_b.source_ref.value, + "version_id": uuid4(), + "created_at": NOW, + }, + ) with pytest.raises(DBAPIError), engine.begin() as connection: connection.execute( text( From 5313512d4238fe9caf53b04d1489ece6c6006aa9 Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 22 Jul 2026 20:16:14 +0800 Subject: [PATCH 4/5] fix(persistence): preserve source version immutability --- engine/persistence/schema_security_manifest.yaml | 1 - .../20260722_0010_file_source_registration.py | 1 - tests/integration/test_file_source_registration.py | 13 +++++++++++++ tests/unit/test_schema_security_manifest.py | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index 97c7abd0..e2f78685 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -443,7 +443,6 @@ "table": "context_source", "columns": ["organization_id", "source_id"] }, - "onDelete": "CASCADE", "deferrable": true, "initially": "DEFERRED" } diff --git a/migrations/versions/20260722_0010_file_source_registration.py b/migrations/versions/20260722_0010_file_source_registration.py index c6b7fff1..ad863579 100644 --- a/migrations/versions/20260722_0010_file_source_registration.py +++ b/migrations/versions/20260722_0010_file_source_registration.py @@ -140,7 +140,6 @@ def upgrade() -> None: ["organization_id", "source_id"], ["context_source.organization_id", "context_source.source_id"], name="fk_source_version_source_same_organization", - ondelete="CASCADE", deferrable=True, initially="DEFERRED", ), diff --git a/tests/integration/test_file_source_registration.py b/tests/integration/test_file_source_registration.py index 840e9b4b..74b8ff05 100644 --- a/tests/integration/test_file_source_registration.py +++ b/tests/integration/test_file_source_registration.py @@ -338,6 +338,19 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( engine = create_database_engine(migration_configuration) try: + with engine.connect() as connection: + delete_action = connection.execute( + text( + """ + SELECT constraint_record.confdeltype + FROM pg_constraint AS constraint_record + WHERE constraint_record.conname = + 'fk_source_version_source_same_organization' + """ + ) + ).scalar_one() + assert delete_action == "a" + with pytest.raises(DBAPIError), engine.begin() as connection: connection.execute( text( diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 5cc6e206..6977e204 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -128,6 +128,11 @@ def test_issue_21_file_source_manifest_is_closed_and_role_separated() -> None: "columns": ["organization_id", "source_id", "version_id"], } ] + version_foreign_key = version["foreignKeys"][0] + assert version_foreign_key["name"] == ( + "fk_source_version_source_same_organization" + ) + assert "onDelete" not in version_foreign_key assert version["immutableRows"] == { "trigger": "source_version_immutable", "function": "source_version_reject_mutation", From 288ab92903ca5f2776d1cf28415878b87f5f2d42 Mon Sep 17 00:00:00 2001 From: stone Date: Wed, 22 Jul 2026 20:22:20 +0800 Subject: [PATCH 5/5] test(security): prove source FKs as control role --- .../test_file_source_registration.py | 102 +++++++++++------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/tests/integration/test_file_source_registration.py b/tests/integration/test_file_source_registration.py index 74b8ff05..df7ad6fc 100644 --- a/tests/integration/test_file_source_registration.py +++ b/tests/integration/test_file_source_registration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime, timedelta @@ -11,6 +12,7 @@ from sqlalchemy.exc import DBAPIError from engine.control import ( + FILE_CAPABILITY_MANIFEST, ContextControl, ControlOperation, ControlOperatorAuthority, @@ -336,6 +338,69 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( request_id="register-b", ) + with pytest.raises(DBAPIError), guarded_control_engine.begin() as connection: + assert connection.execute(text("SELECT current_user")).scalar_one() == ( + "context_engine_control" + ) + connection.execute( + text("SELECT set_config('app.organization_id', :value, true)"), + {"value": str(organization_a)}, + ) + connection.execute( + text( + """ + INSERT INTO source_version ( + organization_id, source_id, version_id, source_kind, + root_ref, capability_manifest, created_at + ) VALUES ( + :organization_a, :source_b_id, :version_id, 'file', + 'cross-organization-root', CAST(:capabilities AS jsonb), + :created_at + ) + """ + ), + { + "organization_a": organization_a, + "source_b_id": source_b.source_ref.value, + "version_id": uuid4(), + "capabilities": json.dumps( + FILE_CAPABILITY_MANIFEST.document(), + separators=(",", ":"), + sort_keys=True, + ), + "created_at": NOW, + }, + ) + + with pytest.raises(DBAPIError), guarded_control_engine.begin() as connection: + connection.execute( + text("SELECT set_config('app.organization_id', :value, true)"), + {"value": str(organization_a)}, + ) + connection.execute( + text( + """ + INSERT INTO context_source ( + organization_id, source_id, display_name, source_kind, + registration_operation, idempotency_key, + registration_digest, active_version_id, created_at + ) VALUES ( + :organization_a, :source_id, 'Broken pointer', 'file', + 'register_source', :idempotency_key, :digest, + :active_version_id, :created_at + ) + """ + ), + { + "organization_a": organization_a, + "source_id": uuid4(), + "idempotency_key": f"invalid-pointer-{uuid4().hex}", + "digest": "0" * 64, + "active_version_id": source_b.active_version.version_ref, + "created_at": NOW, + }, + ) + engine = create_database_engine(migration_configuration) try: with engine.connect() as connection: @@ -351,30 +416,6 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( ).scalar_one() assert delete_action == "a" - with pytest.raises(DBAPIError), engine.begin() as connection: - connection.execute( - text( - """ - INSERT INTO source_version ( - organization_id, source_id, version_id, source_kind, - root_ref, capability_manifest, created_at - ) - SELECT - :organization_a, :source_b_id, :version_id, source_kind, - root_ref, capability_manifest, :created_at - FROM source_version - WHERE organization_id = :organization_b - AND source_id = :source_b_id - """ - ), - { - "organization_a": organization_a, - "organization_b": organization_b, - "source_b_id": source_b.source_ref.value, - "version_id": uuid4(), - "created_at": NOW, - }, - ) with pytest.raises(DBAPIError), engine.begin() as connection: connection.execute( text( @@ -387,19 +428,6 @@ def test_source_version_is_immutable_and_active_pointer_stays_in_organization( "source_id": source_a.source_ref.value, }, ) - with pytest.raises(DBAPIError), engine.begin() as connection: - connection.execute( - text( - "UPDATE context_source SET active_version_id = :version_id " - "WHERE organization_id = :organization_id " - "AND source_id = :source_id" - ), - { - "organization_id": organization_a, - "source_id": source_a.source_ref.value, - "version_id": source_b.active_version.version_ref, - }, - ) finally: engine.dispose()