diff --git a/adapters/http/app.py b/adapters/http/app.py index 91f9a9c7..45513fd5 100644 --- a/adapters/http/app.py +++ b/adapters/http/app.py @@ -26,10 +26,12 @@ from adapters.http.contracts import ( AcquireWire, AuthenticationFailureWire, + ChannelEgressGrantWire, CitationNotAvailableWire, ContextPackageWire, ContinueWire, InvalidRequestWire, + ModelEgressGrantWire, OpenCitationWire, RequestNotAvailableWire, ResolutionOutcomeWire, @@ -98,6 +100,11 @@ private_delivery_audience_digest_for_binding, redeem_private_delivery_evidence, ) +from engine.runtime.egress import ( + ChannelEgressGrant, + EgressGrantIssuanceUnavailable, + ModelEgressGrant, +) from engine.runtime.invocation import ( _construct_authenticated_http_invocation, ) @@ -606,6 +613,8 @@ def resolve_context( raise TrustedAuthorityUnavailable from None except ContextRunPersistenceUnavailable: raise TrustedAuthorityUnavailable from None + except EgressGrantIssuanceUnavailable: + raise TrustedAuthorityUnavailable from None except ScopeAuthorityUnavailable: raise TrustedAuthorityUnavailable from None except InvalidTrustedScopeSnapshot: @@ -679,11 +688,23 @@ def _resolution_outcome_to_wire( def _resolved_to_wire(outcome: Resolved) -> ResolvedWire: + egress_grant: ModelEgressGrantWire | ChannelEgressGrantWire | None = None + if type(outcome.egress_grant) is ModelEgressGrant: + egress_grant = ModelEgressGrantWire( + kind="model", + value=outcome.egress_grant.value, + ) + elif type(outcome.egress_grant) is ChannelEgressGrant: + egress_grant = ChannelEgressGrantWire( + kind="channel", + value=outcome.egress_grant.value, + ) return ResolvedWire( kind=outcome.kind, package=ContextPackageWire.model_validate( context_package_public_document(outcome.package) ), + egressGrant=egress_grant, ) diff --git a/adapters/http/contracts.py b/adapters/http/contracts.py index d0738783..b8f811d3 100644 --- a/adapters/http/contracts.py +++ b/adapters/http/contracts.py @@ -65,6 +65,14 @@ str, Field(strict=True, pattern=r"^[0-9a-f]{64}$"), ] +OpaqueModelEgressGrant = Annotated[ + str, + Field(strict=True, pattern=r"^egrm_[0-9a-f]{64}$", repr=False), +] +OpaqueChannelEgressGrant = Annotated[ + str, + Field(strict=True, pattern=r"^egrc_[0-9a-f]{64}$", repr=False), +] BlockOutputRef = Annotated[ str, Field(strict=True, pattern=r"^block_[0-9a-f]{64}$"), @@ -313,11 +321,30 @@ def require_exact_authorized_content_closure(self) -> Self: return self +class ModelEgressGrantWire(ClosedWireModel): + """Opaque one-hop model grant; no trusted claim is exposed on the wire.""" + + kind: Literal["model"] + value: OpaqueModelEgressGrant = Field(repr=False) + + +class ChannelEgressGrantWire(ClosedWireModel): + """Opaque one-hop channel grant; it carries no write authority.""" + + kind: Literal["channel"] + value: OpaqueChannelEgressGrant = Field(repr=False) + + class ResolvedWire(ClosedWireModel): """Successful public resolution envelope.""" kind: Literal["resolved"] package: ContextPackageWire + egressGrant: ModelEgressGrantWire | ChannelEgressGrantWire | None = Field( + default=None, + discriminator="kind", + repr=False, + ) class RequestNotAvailableWire(ClosedWireModel): diff --git a/bot_delivery/__init__.py b/bot_delivery/__init__.py new file mode 100644 index 00000000..9d244a96 --- /dev/null +++ b/bot_delivery/__init__.py @@ -0,0 +1 @@ +"""Trusted BotDelivery module seams; the application process remains inactive.""" diff --git a/bot_delivery/egress.py b/bot_delivery/egress.py new file mode 100644 index 00000000..ea532d23 --- /dev/null +++ b/bot_delivery/egress.py @@ -0,0 +1,404 @@ +"""Grant-gated deterministic model and channel egress boundaries.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import NoReturn, Protocol +from uuid import UUID + +from engine.runtime.contracts import ContextPackage +from engine.runtime.egress import ( + ChannelEgressGrant, + ChannelEgressProfile, + EgressGrantNotAvailable, + EgressGrantRedemption, + EgressGrantRedemptionAuthority, + ModelEgressGrant, + ModelEgressProfile, +) +from engine.runtime.egress_payload import ( + canonical_package_payload, + channel_payload_bytes_digest, + channel_payload_digest, + model_input_digest, + model_payload_bytes_digest, +) + + +@dataclass(frozen=True, slots=True, init=False) +class AuthorizedModelInput: + """Nominal model payload derived only from one exact ContextPackage.""" + + package_digest: str + purpose: str + payload_digest: str + _payload: bytes = field(repr=False) + _grant_digest: bytes = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("AuthorizedModelInput can only be constructed by BotDelivery") + + def __reduce__(self) -> NoReturn: + raise TypeError("AuthorizedModelInput is not serializable") + + +@dataclass(frozen=True, slots=True, init=False) +class AuthorizedChannelPayload: + """Nominal exact Package payload for channel preflight, not write authority.""" + + package_digest: str + purpose: str + payload_digest: str + _payload: bytes = field(repr=False) + _grant_digest: bytes = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError( + "AuthorizedChannelPayload can only be constructed by BotDelivery" + ) + + def __reduce__(self) -> NoReturn: + raise TypeError("AuthorizedChannelPayload is not serializable") + + +@dataclass(frozen=True, slots=True, init=False) +class _ModelGatewayIdentity: + """Nominal identity minted only by the trusted BotDelivery composition.""" + + consumer_ref: str + provider_ref: str + model_ref: str + region_ref: str + + +@dataclass(frozen=True, slots=True, init=False) +class _SenderPreflightIdentity: + """Nominal identity minted only by the trusted BotDelivery composition.""" + + consumer_ref: str + channel_ref: str + destination_ref: str + region_ref: str + + +def _model_gateway_identity(profile: ModelEgressProfile) -> _ModelGatewayIdentity: + identity = object.__new__(_ModelGatewayIdentity) + for field_name in ("consumer_ref", "provider_ref", "model_ref", "region_ref"): + object.__setattr__(identity, field_name, getattr(profile, field_name)) + return identity + + +def _sender_preflight_identity( + profile: ChannelEgressProfile, +) -> _SenderPreflightIdentity: + identity = object.__new__(_SenderPreflightIdentity) + for field_name in ( + "consumer_ref", + "channel_ref", + "destination_ref", + "region_ref", + ): + object.__setattr__(identity, field_name, getattr(profile, field_name)) + return identity + + +def prepare_authorized_model_input( + package: ContextPackage, + grant: ModelEgressGrant, +) -> AuthorizedModelInput: + """Derive the deterministic tracer input from one Package and model grant.""" + + if type(package) is not ContextPackage: + raise TypeError("model input requires ContextPackage") + if type(grant) is not ModelEgressGrant: + raise TypeError("model input requires ModelEgressGrant") + payload = canonical_package_payload(package) + authorized = object.__new__(AuthorizedModelInput) + object.__setattr__(authorized, "package_digest", package.package_digest) + object.__setattr__(authorized, "purpose", package.purpose) + object.__setattr__( + authorized, + "payload_digest", + model_input_digest(package), + ) + object.__setattr__(authorized, "_payload", payload) + object.__setattr__(authorized, "_grant_digest", grant.digest) + return authorized + + +def prepare_authorized_channel_payload( + package: ContextPackage, + grant: ChannelEgressGrant, +) -> AuthorizedChannelPayload: + """Derive the exact tracer channel payload; this does not authorize an effect.""" + + if type(package) is not ContextPackage: + raise TypeError("channel payload requires ContextPackage") + if type(grant) is not ChannelEgressGrant: + raise TypeError("channel payload requires ChannelEgressGrant") + payload = canonical_package_payload(package) + authorized = object.__new__(AuthorizedChannelPayload) + object.__setattr__(authorized, "package_digest", package.package_digest) + object.__setattr__(authorized, "purpose", package.purpose) + object.__setattr__( + authorized, + "payload_digest", + channel_payload_digest(package), + ) + object.__setattr__(authorized, "_payload", payload) + object.__setattr__(authorized, "_grant_digest", grant.digest) + return authorized + + +class _ModelGatewayPort(Protocol): + def _egress_identity(self) -> _ModelGatewayIdentity: ... + + def _transmit(self, authorized_input: AuthorizedModelInput) -> None: ... + + +class _SenderPreflightPort(Protocol): + def _egress_identity(self) -> _SenderPreflightIdentity: ... + + def _preflight(self, payload: AuthorizedChannelPayload) -> None: ... + + +class DeterministicModelGatewaySpy: + """Network-free byte counter at the exact model boundary seam.""" + + def __init__(self, profile: ModelEgressProfile) -> None: + if type(profile) is not ModelEgressProfile: + raise TypeError("model gateway requires ModelEgressProfile") + self.__identity = _model_gateway_identity(profile) + self.request_count = 0 + self.outbound_bytes = 0 + + def _egress_identity(self) -> _ModelGatewayIdentity: + return self.__identity + + def _transmit(self, authorized_input: AuthorizedModelInput) -> None: + if type(authorized_input) is not AuthorizedModelInput: + raise TypeError("gateway requires AuthorizedModelInput") + self.request_count += 1 + self.outbound_bytes += len(authorized_input._payload) + + +class DeterministicSenderPreflightSpy: + """Network-free channel preflight counter with no effect method.""" + + def __init__(self, profile: ChannelEgressProfile) -> None: + if type(profile) is not ChannelEgressProfile: + raise TypeError("sender preflight requires ChannelEgressProfile") + self.__identity = _sender_preflight_identity(profile) + self.preflight_count = 0 + self.outbound_bytes = 0 + self.effect_count = 0 + + def _egress_identity(self) -> _SenderPreflightIdentity: + return self.__identity + + def _preflight(self, payload: AuthorizedChannelPayload) -> None: + if type(payload) is not AuthorizedChannelPayload: + raise TypeError("sender preflight requires AuthorizedChannelPayload") + self.preflight_count += 1 + self.outbound_bytes += len(payload._payload) + + +def _require_audience_digest(value: object) -> str: + if ( + type(value) is not str + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ValueError("egress audience_digest must be lowercase SHA-256") + return value + + +def _require_boundary( + *, + organization_id: UUID, + audience_digest: str, + policy_epoch: int, + authority: EgressGrantRedemptionAuthority, + port: object, + method: str, +) -> None: + if type(organization_id) is not UUID: + raise TypeError("egress boundary Organization must be UUID") + _require_audience_digest(audience_digest) + if type(policy_epoch) is not int or policy_epoch < 1: + raise ValueError("egress boundary Policy Epoch must be positive") + if not callable(getattr(authority, "redeem", None)): + raise TypeError("egress redemption authority is incomplete") + if not callable(getattr(port, method, None)): + raise TypeError("egress boundary port is incomplete") + if not callable(getattr(port, "_egress_identity", None)): + raise TypeError("egress boundary port has no trusted identity") + + +def _require_model_gateway_identity( + gateway: _ModelGatewayPort, + profile: ModelEgressProfile, +) -> None: + identity = gateway._egress_identity() + if type(identity) is not _ModelGatewayIdentity or identity != ( + _model_gateway_identity(profile) + ): + raise EgressGrantNotAvailable + + +def _require_sender_preflight_identity( + sender: _SenderPreflightPort, + profile: ChannelEgressProfile, +) -> None: + identity = sender._egress_identity() + if type(identity) is not _SenderPreflightIdentity or identity != ( + _sender_preflight_identity(profile) + ): + raise EgressGrantNotAvailable + + +class ModelEgressBoundary: + """Redeem exact model bindings before the first outbound byte.""" + + def __init__( + self, + *, + organization_id: UUID, + audience_digest: str, + policy_epoch: int, + profile: ModelEgressProfile, + authority: EgressGrantRedemptionAuthority, + gateway: _ModelGatewayPort, + ) -> None: + if type(profile) is not ModelEgressProfile: + raise TypeError("model boundary requires ModelEgressProfile") + _require_boundary( + organization_id=organization_id, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + authority=authority, + port=gateway, + method="_transmit", + ) + _require_model_gateway_identity(gateway, profile) + self._organization_id = organization_id + self._audience_digest = audience_digest + self._policy_epoch = policy_epoch + self._profile = profile + self._authority = authority + self._gateway = gateway + + def transmit( + self, + authorized_input: AuthorizedModelInput, + grant: ModelEgressGrant, + ) -> None: + if type(authorized_input) is not AuthorizedModelInput: + raise TypeError("model boundary requires AuthorizedModelInput") + if type(grant) is not ModelEgressGrant: + raise TypeError("model boundary requires ModelEgressGrant") + _require_model_gateway_identity(self._gateway, self._profile) + if authorized_input._grant_digest != grant.digest: + raise EgressGrantNotAvailable + if ( + model_payload_bytes_digest(authorized_input._payload) + != authorized_input.payload_digest + ): + raise EgressGrantNotAvailable + redemption = EgressGrantRedemption.for_model( + grant=grant, + organization_id=self._organization_id, + package_digest=authorized_input.package_digest, + payload_digest=authorized_input.payload_digest, + purpose=authorized_input.purpose, + audience_digest=self._audience_digest, + policy_epoch=self._policy_epoch, + profile=self._profile, + ) + try: + accepted = self._authority.redeem(redemption) + except EgressGrantNotAvailable: + raise + except Exception: + raise EgressGrantNotAvailable from None + if accepted is not True: + raise EgressGrantNotAvailable + self._gateway._transmit(authorized_input) + + +class ChannelEgressBoundary: + """Redeem exact channel bindings before preflight; no effect is exposed.""" + + def __init__( + self, + *, + organization_id: UUID, + audience_digest: str, + policy_epoch: int, + profile: ChannelEgressProfile, + authority: EgressGrantRedemptionAuthority, + sender: _SenderPreflightPort, + ) -> None: + if type(profile) is not ChannelEgressProfile: + raise TypeError("channel boundary requires ChannelEgressProfile") + _require_boundary( + organization_id=organization_id, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + authority=authority, + port=sender, + method="_preflight", + ) + _require_sender_preflight_identity(sender, profile) + self._organization_id = organization_id + self._audience_digest = audience_digest + self._policy_epoch = policy_epoch + self._profile = profile + self._authority = authority + self._sender = sender + + def preflight( + self, + payload: AuthorizedChannelPayload, + grant: ChannelEgressGrant, + ) -> None: + if type(payload) is not AuthorizedChannelPayload: + raise TypeError("channel boundary requires AuthorizedChannelPayload") + if type(grant) is not ChannelEgressGrant: + raise TypeError("channel boundary requires ChannelEgressGrant") + _require_sender_preflight_identity(self._sender, self._profile) + if payload._grant_digest != grant.digest: + raise EgressGrantNotAvailable + if channel_payload_bytes_digest(payload._payload) != payload.payload_digest: + raise EgressGrantNotAvailable + redemption = EgressGrantRedemption.for_channel( + grant=grant, + organization_id=self._organization_id, + package_digest=payload.package_digest, + payload_digest=payload.payload_digest, + purpose=payload.purpose, + audience_digest=self._audience_digest, + policy_epoch=self._policy_epoch, + profile=self._profile, + ) + try: + accepted = self._authority.redeem(redemption) + except EgressGrantNotAvailable: + raise + except Exception: + raise EgressGrantNotAvailable from None + if accepted is not True: + raise EgressGrantNotAvailable + self._sender._preflight(payload) + + +__all__ = [ + "AuthorizedChannelPayload", + "AuthorizedModelInput", + "ChannelEgressBoundary", + "DeterministicModelGatewaySpy", + "DeterministicSenderPreflightSpy", + "ModelEgressBoundary", + "prepare_authorized_channel_payload", + "prepare_authorized_model_input", +] diff --git a/compose.yaml b/compose.yaml index 1cf2e5f8..ab384c0c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,8 @@ services: CONTEXT_ENGINE_CONTROL_PASSWORD: ${CONTEXT_ENGINE_CONTROL_PASSWORD:?CONTEXT_ENGINE_CONTROL_PASSWORD is required} CONTEXT_ENGINE_IDENTITY_ROLE: ${CONTEXT_ENGINE_IDENTITY_ROLE:?CONTEXT_ENGINE_IDENTITY_ROLE is required} CONTEXT_ENGINE_IDENTITY_PASSWORD: ${CONTEXT_ENGINE_IDENTITY_PASSWORD:?CONTEXT_ENGINE_IDENTITY_PASSWORD is required} + CONTEXT_ENGINE_EGRESS_ROLE: ${CONTEXT_ENGINE_EGRESS_ROLE:?CONTEXT_ENGINE_EGRESS_ROLE is required} + CONTEXT_ENGINE_EGRESS_PASSWORD: ${CONTEXT_ENGINE_EGRESS_PASSWORD:?CONTEXT_ENGINE_EGRESS_PASSWORD is required} CONTEXT_ENGINE_RUNTIME_ROLE: ${CONTEXT_ENGINE_RUNTIME_ROLE:?CONTEXT_ENGINE_RUNTIME_ROLE is required} CONTEXT_ENGINE_RUNTIME_PASSWORD: ${CONTEXT_ENGINE_RUNTIME_PASSWORD:?CONTEXT_ENGINE_RUNTIME_PASSWORD is required} CONTEXT_ENGINE_WORKER_ROLE: ${CONTEXT_ENGINE_WORKER_ROLE:?CONTEXT_ENGINE_WORKER_ROLE is required} diff --git a/docs/decisions/0046-bind-egress-to-one-exact-package-hop.md b/docs/decisions/0046-bind-egress-to-one-exact-package-hop.md new file mode 100644 index 00000000..aa3490c7 --- /dev/null +++ b/docs/decisions/0046-bind-egress-to-one-exact-package-hop.md @@ -0,0 +1,109 @@ +--- +name: adr-0046-bind-egress-to-one-exact-package-hop +version: "1.0.0" +description: > + Issue and atomically redeem a digest-only EgressGrant for exactly one + audience-bound ContextPackage model or channel boundary. +--- + +# 0046. Bind egress to one exact Package hop + +- Status: accepted +- Date: 2026-07-23 +- Refines: ADR-0002, ADR-0007, ADR-0009, ADR-0013, ADR-0015, ADR-0017, ADR-0045 + +## Context + +An authorized `ContextPackage` is the only online deliverable, but authorization +to construct a Package is not authority to disclose it to a model provider or +channel. The boundary must prevent arbitrary text, raw `CandidateRef`, +`AuthorizedProjection`, a Package from another audience, or a grant for another +hop from reaching a content-bearing consumer. It must also distinguish channel +preflight from external write authority. + +Public repositories may inform clean-room behavior, interface shapes, and test +oracles. Their implementations do not supply this boundary; ContextEngine's +design, accepted ADRs, threat model, and four-repository evidence report remain +the implementation and public-provenance authorities. + +## Decision + +Runtime has a mandatory final `EgressGate` after Package construction, +provenance, budget, and current-epoch validation. Its server-owned profile is a +closed union: internal-only, one exact model hop, or one exact channel hop. The +caller cannot select the profile, and Runtime issues at most one variant for a +resolve. Internal-only remains the default. + +A grant binds the exact Organization, Package digest, canonical payload digest, +purpose, audience digest, Policy Epoch, hop variant, retention and sensitivity +profiles, issuer, consumer, provider and model or channel and destination, +region, issuance, expiry, and profile lineage. The random opaque locator is the +one-shot nonce. PostgreSQL retains only its SHA-256 digest, never the bearer. + +Direct authenticated delivery derives its audience digest from trusted +Organization, current Membership/version, application, and delivery binding. +Private delivery reuses the audience digest established by the redeemed +`DeliveryEvidenceRef`. Channel issuance additionally requires that exact +redeemed private context and rejects unless its trusted destination and +consumer exactly match the server-owned channel profile. Direct delivery can +issue a model grant but cannot issue a channel grant. Neither path invents an +`AudienceSnapshot`. + +Grant issuance stays in the retained current-UserActor transaction. A dedicated +non-owner egress login can execute only exact redemption; a separate NOLOGIN +definer owns the minimum table access and functions. Redemption uses +database-owned time, verifies current Organization Policy Epoch, performs one +atomic compare-and-set, and records only grant/payload digests plus restricted +issued, consumed, or not-available categories. Unknown and cross-Organization +locators remain non-enumerating. + +BotDelivery exposes nominal `AuthorizedModelInput` and +`AuthorizedChannelPayload` constructors. They derive only from one exact +`ContextPackage` and the matching grant variant. The model boundary revalidates +the canonical payload digest, requires the gateway's immutable nominal identity +to match the grant profile's consumer/provider/model/region, and consumes the +exact model grant before the first gateway byte. The channel boundary requires +the Sender preflight identity to match consumer/channel/destination/region and +then does the same before preflight, but exposes no write operation. An external +effect still requires a distinct `ActionPlane.prepare` then +`ActionPlane.perform` ticket. + +Issue #65 activates only opaque grant issuance, digest-only PostgreSQL +redemption/audit, and deterministic network-free model and channel boundary +spies. Real model/provider calls, a real Sender, ActionTicket effects, group +audience revalidation, the Bot application process, and generated SDK consumer +remain inactive. + +## Rationale + +The final gate makes egress authority a consequence of one already sealed +Package decision, while the independently privileged redemption boundary +prevents Runtime from treating its own locator as consumed. Exact variant and +payload bindings make grant swapping or mutation fail before bytes. One-shot +atomic state closes sequential and concurrent replay without using a cache as +authority. + +Forking or copying a general-purpose open-source RAG implementation was rejected +for this boundary because its trust topology would become an unreviewed runtime +foundation. Clean-room observations remain useful, while these exact security +contracts and executable oracles remain ContextEngine-owned. + +## Consequences + +- Runtime returns no egress authority by default and never returns both grant + variants for one resolve. +- A leaked database row cannot reconstruct or replay a bearer. +- Wrong Package, Organization, purpose, audience, epoch, hop, profile, + destination, provider/model, lifetime, or replay emits zero boundary bytes. +- A channel grant permits preflight only and cannot create an external effect. +- Production BotDelivery and external-provider conformance remain gated by + later issues. + +## Revisit trigger + +Revisit before grant delegation, multi-hop batches, provider token exchange, +group delivery, profile rotation, a remote grant store, production Sender use, +or ActionPlane activation. Any revision must preserve the sealed final gate, +exact one-hop binding, digest-only bearer persistence, independent least- +privilege redemption, atomic one-shot behavior, generic failure, and zero bytes +or effects on mismatch. diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py index d36d3f82..e47ce6de 100644 --- a/engine/persistence/__init__.py +++ b/engine/persistence/__init__.py @@ -9,6 +9,8 @@ ) from engine.persistence.configuration import ( DELIVERY_EVIDENCE_DEFINER_ROLE, + EGRESS_GRANT_DEFINER_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, OPERATOR_ROLE, @@ -39,6 +41,7 @@ PostgreSQLDeliveryEvidenceIssuerPort, PostgreSQLDeliveryEvidenceRetentionPort, ) +from engine.persistence.egress import PostgreSQLEgressGrantRedemptionAuthority from engine.persistence.file_imports import ( FileImportInterrupted, FileImportLeaseRedemption, @@ -56,6 +59,7 @@ from engine.persistence.releases import PostgreSQLReleaseStore from engine.persistence.role_guard import ( assert_control_role, + assert_egress_role, assert_identity_role, assert_learning_role, assert_runtime_role, @@ -90,6 +94,8 @@ "HarnessDatabaseConfigurations", "IDENTITY_ROLE", "DELIVERY_EVIDENCE_DEFINER_ROLE", + "EGRESS_GRANT_DEFINER_ROLE", + "EGRESS_ROLE", "LEARNING_ROLE", "OPERATOR_ROLE", "RELEASE_DEFINER_ROLE", @@ -111,6 +117,7 @@ "PostgreSQLControlStore", "PostgreSQLDeliveryEvidenceIssuerPort", "PostgreSQLDeliveryEvidenceRetentionPort", + "PostgreSQLEgressGrantRedemptionAuthority", "FileImportLeaseRedemption", "FileImportInterrupted", "FileImportUnavailable", @@ -137,6 +144,7 @@ "assert_runtime_role", "assert_learning_role", "assert_identity_role", + "assert_egress_role", "assert_security_operator_role", "assert_control_role", "assert_worker_role", diff --git a/engine/persistence/configuration.py b/engine/persistence/configuration.py index 899c82a9..dab55756 100644 --- a/engine/persistence/configuration.py +++ b/engine/persistence/configuration.py @@ -13,6 +13,8 @@ MIGRATOR_ROLE = "context_engine_migrator" CONTROL_ROLE = "context_engine_control" IDENTITY_ROLE = "context_engine_identity" +EGRESS_ROLE = "context_engine_egress" +EGRESS_GRANT_DEFINER_ROLE = "context_engine_egress_grant_definer" DELIVERY_EVIDENCE_DEFINER_ROLE = "context_engine_delivery_evidence_definer" ACCESS_POLICY_DEFINER_ROLE = "context_engine_access_policy_definer" WORKER_LEASE_DEFINER_ROLE = "context_engine_worker_lease_definer" @@ -30,6 +32,7 @@ class DatabasePurpose(Enum): MIGRATION = ("CONTEXT_ENGINE_MIGRATION_DATABASE_URL", MIGRATOR_ROLE) CONTROL_PLANE = ("CONTEXT_ENGINE_CONTROL_DATABASE_URL", CONTROL_ROLE) TRUSTED_IDENTITY = ("CONTEXT_ENGINE_IDENTITY_DATABASE_URL", IDENTITY_ROLE) + TRUSTED_EGRESS = ("CONTEXT_ENGINE_EGRESS_DATABASE_URL", EGRESS_ROLE) API_RUNTIME = ("CONTEXT_ENGINE_RUNTIME_DATABASE_URL", RUNTIME_ROLE) SUPPLY_WORKER = ("CONTEXT_ENGINE_WORKER_DATABASE_URL", WORKER_ROLE) LEARNING = ("CONTEXT_ENGINE_LEARNING_DATABASE_URL", LEARNING_ROLE) @@ -52,6 +55,7 @@ def expected_role(self) -> str: DatabasePurpose.MIGRATION: "CONTEXT_ENGINE_MIGRATOR_ROLE", DatabasePurpose.CONTROL_PLANE: "CONTEXT_ENGINE_CONTROL_ROLE", DatabasePurpose.TRUSTED_IDENTITY: "CONTEXT_ENGINE_IDENTITY_ROLE", + DatabasePurpose.TRUSTED_EGRESS: "CONTEXT_ENGINE_EGRESS_ROLE", DatabasePurpose.API_RUNTIME: "CONTEXT_ENGINE_RUNTIME_ROLE", DatabasePurpose.SUPPLY_WORKER: "CONTEXT_ENGINE_WORKER_ROLE", DatabasePurpose.LEARNING: "CONTEXT_ENGINE_LEARNING_ROLE", @@ -114,6 +118,7 @@ class HarnessDatabaseConfigurations: migration: DatabaseConfiguration control: DatabaseConfiguration identity: DatabaseConfiguration + egress: DatabaseConfiguration runtime: DatabaseConfiguration worker: DatabaseConfiguration learning: DatabaseConfiguration @@ -188,6 +193,7 @@ def load_harness_database_configurations( migration=load_database_configuration(DatabasePurpose.MIGRATION, source), control=load_database_configuration(DatabasePurpose.CONTROL_PLANE, source), identity=load_database_configuration(DatabasePurpose.TRUSTED_IDENTITY, source), + egress=load_database_configuration(DatabasePurpose.TRUSTED_EGRESS, source), runtime=load_database_configuration(DatabasePurpose.API_RUNTIME, source), worker=load_database_configuration(DatabasePurpose.SUPPLY_WORKER, source), learning=load_database_configuration(DatabasePurpose.LEARNING, source), @@ -200,14 +206,15 @@ def load_harness_database_configurations( configurations.migration.expected_role, configurations.control.expected_role, configurations.identity.expected_role, + configurations.egress.expected_role, configurations.runtime.expected_role, configurations.worker.expected_role, configurations.learning.expected_role, configurations.operator.expected_role, } - if len(distinct_roles) != 7: + if len(distinct_roles) != 8: raise DatabaseConfigurationError( - "migration, control, identity, runtime, worker, learning, and " + "migration, control, identity, egress, runtime, worker, learning, and " "security-operator " "database roles must be distinct" ) diff --git a/engine/persistence/egress.py b/engine/persistence/egress.py new file mode 100644 index 00000000..bacbe01a --- /dev/null +++ b/engine/persistence/egress.py @@ -0,0 +1,74 @@ +"""PostgreSQL one-shot redemption boundary for exact egress grants.""" + +from __future__ import annotations + +from sqlalchemy import Engine, text +from sqlalchemy.exc import SQLAlchemyError + +from engine.persistence.role_guard import assert_egress_role +from engine.runtime.egress import ( + EGRESS_GRANT_DIGEST_PROFILE, + EgressGrantAuthorityUnavailable, + EgressGrantRedemption, +) + + +class PostgreSQLEgressGrantRedemptionAuthority: + """Redeem through one function-only trusted egress login.""" + + def __init__(self, engine: Engine) -> None: + self._engine = engine + + def redeem(self, redemption: EgressGrantRedemption) -> bool: + if type(redemption) is not EgressGrantRedemption: + raise TypeError("egress redemption has the wrong nominal type") + try: + with self._engine.begin() as connection: + assert_egress_role(connection) + accepted = connection.execute( + text( + """ + SELECT context_egress_redeem_grant( + :organization_id, :grant_digest, :digest_profile, + :hop_kind, :package_digest, :payload_digest, + :purpose, :audience_digest, :policy_epoch, + :retention_policy_ref, :sensitivity_policy_ref, + :issuer_ref, :consumer_ref, :provider_ref, + :model_ref, :channel_ref, :destination_ref, + :region_ref, :profile_ref + ) + """ + ), + { + "organization_id": redemption.organization_id, + "grant_digest": redemption.grant_digest, + "digest_profile": EGRESS_GRANT_DIGEST_PROFILE, + "hop_kind": redemption.hop_kind, + "package_digest": bytes.fromhex( + redemption.package_digest + ), + "payload_digest": bytes.fromhex(redemption.payload_digest), + "purpose": redemption.purpose, + "audience_digest": bytes.fromhex( + redemption.audience_digest + ), + "policy_epoch": redemption.policy_epoch, + "retention_policy_ref": ( + redemption.retention_policy_ref + ), + "sensitivity_policy_ref": ( + redemption.sensitivity_policy_ref + ), + "issuer_ref": redemption.issuer_ref, + "consumer_ref": redemption.consumer_ref, + "provider_ref": redemption.provider_ref, + "model_ref": redemption.model_ref, + "channel_ref": redemption.channel_ref, + "destination_ref": redemption.destination_ref, + "region_ref": redemption.region_ref, + "profile_ref": redemption.profile_ref, + }, + ).scalar_one() + except (AssertionError, SQLAlchemyError): + raise EgressGrantAuthorityUnavailable from None + return accepted is True diff --git a/engine/persistence/membership_context.py b/engine/persistence/membership_context.py index 55947987..ba89f161 100644 --- a/engine/persistence/membership_context.py +++ b/engine/persistence/membership_context.py @@ -37,6 +37,14 @@ _construct_delivery_evidence_redemption_session, _open_delivery_evidence_redemption_scope, ) +from engine.runtime.egress import ( + EGRESS_GRANT_DIGEST_PROFILE, + EgressGrantIssuanceUnavailable, + EgressGrantIssue, + _close_egress_grant_issuance_scope, + _construct_egress_grant_issuance_session, + _open_egress_grant_issuance_scope, +) from engine.runtime.evidence import CandidateRef from engine.runtime.materialized import ( MaterializedFieldValue, @@ -628,6 +636,60 @@ def redeem_private( ) +class _PostgreSQLEgressGrantIssuancePort: + """Persist digest-only grant state on the retained Runtime transaction.""" + + def __init__(self, connection: Connection) -> None: + self._connection = connection + + def issue(self, request: EgressGrantIssue, grant_digest: bytes) -> bool: + try: + accepted = self._connection.execute( + text( + """ + SELECT context_runtime_issue_egress_grant( + :organization_id, :grant_digest, :digest_profile, + :hop_kind, :package_digest, :payload_digest, + :purpose, :audience_digest, :policy_epoch, + :retention_policy_ref, :sensitivity_policy_ref, + :issuer_ref, :consumer_ref, :provider_ref, + :model_ref, :channel_ref, :destination_ref, + :region_ref, :issued_at, :expires_at, + :profile_ref, :grant_profile_ref, :category + ) + """ + ), + { + "organization_id": request.organization_id, + "grant_digest": grant_digest, + "digest_profile": EGRESS_GRANT_DIGEST_PROFILE, + "hop_kind": request.hop_kind, + "package_digest": bytes.fromhex(request.package_digest), + "payload_digest": bytes.fromhex(request.payload_digest), + "purpose": request.purpose, + "audience_digest": bytes.fromhex(request.audience_digest), + "policy_epoch": request.policy_epoch, + "retention_policy_ref": request.retention_policy_ref, + "sensitivity_policy_ref": request.sensitivity_policy_ref, + "issuer_ref": request.issuer_ref, + "consumer_ref": request.consumer_ref, + "provider_ref": request.provider_ref, + "model_ref": request.model_ref, + "channel_ref": request.channel_ref, + "destination_ref": request.destination_ref, + "region_ref": request.region_ref, + "issued_at": request.issued_at, + "expires_at": request.expires_at, + "profile_ref": request.profile_ref, + "grant_profile_ref": request.grant_profile_ref, + "category": request.category.value, + }, + ).scalar_one() + except SQLAlchemyError: + raise EgressGrantIssuanceUnavailable from None + return accepted is True + + class PostgreSQLMembershipAuthority: """Open and retain the exact UserActor transaction through Runtime work.""" @@ -760,6 +822,7 @@ def _current_user_actor_transaction( policy_epoch_scope = _open_policy_epoch_authority_scope() context_run_scope = _open_context_run_persistence_scope() delivery_evidence_scope = _open_delivery_evidence_redemption_scope() + egress_issuance_scope = _open_egress_grant_issuance_scope() try: projection_session = _construct_materialized_projection_session( authority_scope=projection_scope, @@ -796,8 +859,15 @@ def _current_user_actor_transaction( port=_PostgreSQLDeliveryEvidenceRedemptionPort(connection), ) ), + egress_grant_issuance_session=( + _construct_egress_grant_issuance_session( + authority_scope=egress_issuance_scope, + port=_PostgreSQLEgressGrantIssuancePort(connection), + ) + ), ) finally: + _close_egress_grant_issuance_scope(egress_issuance_scope) _close_delivery_evidence_redemption_scope(delivery_evidence_scope) _close_context_run_persistence_scope(context_run_scope) _close_policy_epoch_authority_scope(policy_epoch_scope) diff --git a/engine/persistence/role_guard.py b/engine/persistence/role_guard.py index c189d8e8..92f3ca86 100644 --- a/engine/persistence/role_guard.py +++ b/engine/persistence/role_guard.py @@ -6,6 +6,7 @@ from engine.persistence.configuration import ( CONTROL_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, MIGRATOR_ROLE, @@ -110,6 +111,13 @@ def assert_identity_role(connection: Connection) -> None: _assert_no_owned_objects_or_role_members(connection) +def assert_egress_role(connection: Connection) -> None: + """Require the dedicated trusted cleartext-hop consumer login.""" + + _assert_non_owner_role(connection, EGRESS_ROLE) + _assert_no_owned_objects_or_role_members(connection) + + def assert_runtime_role(connection: Connection) -> None: """Reject owner, superuser, BYPASSRLS, inheriting, or CREATE-capable sessions.""" diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index d15cd6a4..779ac079 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,5 +1,5 @@ { - "manifestVersion": "19.0.0", + "manifestVersion": "20.0.0", "controlOperations": [ { "name": "register_file_source", @@ -388,6 +388,12 @@ "command": "SELECT", "roles": ["context_engine_delivery_evidence_definer"], "using": "true" + }, + { + "name": "membership_egress_definer_select", + "command": "SELECT", + "roles": ["context_engine_egress_grant_definer"], + "using": "true" } ] }, @@ -395,7 +401,8 @@ "context_engine_runtime": ["SELECT"], "context_engine_worker": [], "context_engine_worker_lease_definer": ["SELECT"], - "context_engine_delivery_evidence_definer": ["SELECT"] + "context_engine_delivery_evidence_definer": ["SELECT"], + "context_engine_egress_grant_definer": ["SELECT"] }, "partitions": [], "securityInvariantIds": [ @@ -1050,6 +1057,12 @@ "command": "SELECT", "roles": ["context_engine_delivery_evidence_definer"], "using": "true" + }, + { + "name": "organization_policy_epoch_egress_definer_select", + "command": "SELECT", + "roles": ["context_engine_egress_grant_definer"], + "using": "true" } ] }, @@ -1057,6 +1070,7 @@ "context_engine_access_policy_definer": ["SELECT", "UPDATE"], "context_engine_control": ["EXECUTE change_resource_access", "EXECUTE context_control_tombstone_file_resource", "EXECUTE context_control_offboard_file_source"], "context_engine_delivery_evidence_definer": ["SELECT"], + "context_engine_egress_grant_definer": ["SELECT"], "context_engine_runtime": ["SELECT"], "context_engine_worker": [] }, @@ -1596,6 +1610,97 @@ "PG-TRACE-REDACTION-012" ] }, + { + "name": "egress_grant", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-EGRESS-011", + "selector": {"table": "egress_grant"} + }, + "purpose": "Digest-only exact one-hop grant state with atomic replay prevention", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + {"name": "pk_egress_grant", "kind": "primary_key", "columns": ["organization_id", "grant_digest"]} + ], + "capabilityUniqueKeys": [ + {"name": "uq_egress_grant_digest_global", "kind": "unique", "columns": ["grant_digest"], "rationale": "one opaque locator digest identifies at most one Organization-bound hop"} + ], + "foreignKeys": [ + {"name": "fk_egress_grant_organization", "columns": ["organization_id"], "references": {"table": "organization", "columns": ["organization_id"]}, "onDelete": "CASCADE"} + ], + "checkConstraints": [ + {"name": "ck_egress_grant_sha256_digests", "expression": "grant, Package, payload, and audience digests are 32-byte SHA-256"}, + {"name": "ck_egress_grant_profiles", "expression": "locator and grant profile lineage are exact active versions"}, + {"name": "ck_egress_grant_positive_epoch", "expression": "policy_epoch > 0"}, + {"name": "ck_egress_grant_timestamp_order", "expression": "issued_at < expires_at and consumption, when present, is inside the grant lifetime"}, + {"name": "ck_egress_grant_exact_hop_variant", "expression": "exactly one model or channel hop variant is populated"}, + {"name": "ck_egress_grant_bindings_nonblank", "expression": "all common egress bindings are nonblank"} + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + {"name": "egress_grant_migrator_administration", "command": "ALL", "roles": ["context_engine_migrator"], "using": "true", "withCheck": "true"}, + {"name": "egress_grant_definer_all", "command": "ALL", "roles": ["context_engine_egress_grant_definer"], "using": "true", "withCheck": "true"} + ] + }, + "functionOnlyMutation": {"databaseFunctions": ["context_runtime_issue_egress_grant", "context_egress_redeem_grant"], "definerRole": "context_engine_egress_grant_definer", "directTableMutationAllowed": false}, + "retention": {"class": "short_lived_digest_only", "bearerStored": false, "payloadStored": false, "expirySource": "versioned egress profile"}, + "permittedOperations": { + "context_engine_runtime": ["EXECUTE context_runtime_issue_egress_grant"], + "context_engine_egress": ["EXECUTE context_egress_redeem_grant"], + "context_engine_egress_grant_definer": ["SELECT", "INSERT", "UPDATE", "DELETE"], + "context_engine_control": [], + "context_engine_worker": [], + "context_engine_learning": [], + "context_engine_security_operator": [] + }, + "partitions": [], + "securityInvariantIds": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "REVOCATION-006", "EGRESS-011", "NON-ENUMERATION-009", "TRACE-REDACTION-012", "ACTION-SEPARATION-014"], + "negativeTestIds": ["EGR-001", "EGR-004", "RUN-013", "DB-001", "DB-002", "DB-004", "DB-008", "DB-009", "DB-010", "OBS-004", "OBS-005"] + }, + { + "name": "egress_audit", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-EGRESS-011", + "selector": {"table": "egress_audit"} + }, + "purpose": "Restricted digest-only issued, consumed, or not-available egress decisions", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + {"name": "pk_egress_audit", "kind": "primary_key", "columns": ["organization_id", "audit_id"]} + ], + "foreignKeys": [ + {"name": "fk_egress_audit_exact_grant", "columns": ["organization_id", "grant_digest"], "references": {"table": "egress_grant", "columns": ["organization_id", "grant_digest"]}, "onDelete": "CASCADE"} + ], + "checkConstraints": [ + {"name": "ck_egress_audit_sha256_digests", "expression": "grant and payload digests are 32-byte SHA-256"}, + {"name": "ck_egress_audit_restricted_category", "expression": "category is issued, consumed, or not_available"} + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + {"name": "egress_audit_migrator_administration", "command": "ALL", "roles": ["context_engine_migrator"], "using": "true", "withCheck": "true"}, + {"name": "egress_audit_definer_all", "command": "ALL", "roles": ["context_engine_egress_grant_definer"], "using": "true", "withCheck": "true"} + ] + }, + "functionOnlyMutation": {"databaseFunctions": ["context_runtime_issue_egress_grant", "context_egress_redeem_grant"], "definerRole": "context_engine_egress_grant_definer", "directTableMutationAllowed": false}, + "retention": {"class": "restricted_digest_audit", "bearerStored": false, "payloadStored": false, "deniedContentStored": false}, + "permittedOperations": { + "context_engine_runtime": ["EXECUTE context_runtime_issue_egress_grant"], + "context_engine_egress": ["EXECUTE context_egress_redeem_grant"], + "context_engine_egress_grant_definer": ["SELECT", "INSERT", "UPDATE", "DELETE"], + "context_engine_control": [], + "context_engine_worker": [], + "context_engine_learning": [], + "context_engine_security_operator": [] + }, + "partitions": [], + "securityInvariantIds": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "EGRESS-011", "NON-ENUMERATION-009", "TRACE-REDACTION-012"], + "negativeTestIds": ["EGR-001", "EGR-004", "DB-001", "DB-002", "DB-004", "DB-008", "DB-009", "DB-010", "OBS-004", "OBS-005"] + }, { "name": "service_principal", "classification": "tenant_owned", diff --git a/engine/runtime/__init__.py b/engine/runtime/__init__.py index 30b4d594..16e24360 100644 --- a/engine/runtime/__init__.py +++ b/engine/runtime/__init__.py @@ -65,6 +65,13 @@ ScopeDecisionReceipt, TrustedDeliveryContext, ) +from engine.runtime.egress import ( + ChannelEgressGrant, + ChannelEgressProfile, + EgressGrant, + ModelEgressGrant, + ModelEgressProfile, +) from engine.runtime.evidence import ( AuthorizedProjection, CandidateRef, @@ -128,8 +135,13 @@ "MembershipVerificationProvenance", "MembershipRejectionAuditReceipt", "MembershipRejectionCategory", + "ModelEgressGrant", + "ModelEgressProfile", "Evidence", "EvidenceLineage", + "EgressGrant", + "ChannelEgressGrant", + "ChannelEgressProfile", "PackageBudget", "PackageBudgetRequest", "PackageBlock", diff --git a/engine/runtime/actor.py b/engine/runtime/actor.py index 51662c33..9872407b 100644 --- a/engine/runtime/actor.py +++ b/engine/runtime/actor.py @@ -14,6 +14,10 @@ DeliveryEvidenceRedemptionSession, _require_active_delivery_evidence_redemption_session, ) +from engine.runtime.egress import ( + EgressGrantIssuanceSession, + _require_active_egress_grant_issuance_session, +) from engine.runtime.materialized import ( MaterializedProjectionSession, _require_active_materialized_projection_session, @@ -108,6 +112,9 @@ class CurrentMembershipVerification: delivery_evidence_redemption_session: ( DeliveryEvidenceRedemptionSession | None ) = field(repr=False) + egress_grant_issuance_session: EgressGrantIssuanceSession | None = field( + repr=False + ) construction_provenance: MembershipVerificationProvenance _authority_scope: _MembershipAuthorityScope = field(repr=False) @@ -138,6 +145,7 @@ def _construct_current_membership_verification( delivery_evidence_redemption_session: ( DeliveryEvidenceRedemptionSession | None ) = None, + egress_grant_issuance_session: EgressGrantIssuanceSession | None = None, ) -> CurrentMembershipVerification: """Construct proof after the trusted authority verifies the durable row.""" @@ -186,6 +194,10 @@ def _construct_current_membership_verification( _require_active_delivery_evidence_redemption_session( delivery_evidence_redemption_session ) + if egress_grant_issuance_session is not None: + _require_active_egress_grant_issuance_session( + egress_grant_issuance_session + ) _require_active_policy_epoch_verification(policy_epoch_verification) if policy_epoch_verification.organization_id != organization_id: raise ValueError("current Membership Policy Epoch must stay in Organization") @@ -228,6 +240,11 @@ def _construct_current_membership_verification( "delivery_evidence_redemption_session", delivery_evidence_redemption_session, ) + object.__setattr__( + verification, + "egress_grant_issuance_session", + egress_grant_issuance_session, + ) object.__setattr__( verification, "construction_provenance", @@ -267,6 +284,10 @@ def _require_active_current_membership_verification( _require_active_delivery_evidence_redemption_session( verification.delivery_evidence_redemption_session ) + if verification.egress_grant_issuance_session is not None: + _require_active_egress_grant_issuance_session( + verification.egress_grant_issuance_session + ) _require_active_policy_epoch_verification(verification.policy_epoch_verification) if ( verification.policy_epoch != verification.policy_epoch_verification.policy_epoch @@ -301,6 +322,9 @@ class UserActor: delivery_evidence_redemption_session: ( DeliveryEvidenceRedemptionSession | None ) = field(repr=False) + egress_grant_issuance_session: EgressGrantIssuanceSession | None = field( + repr=False + ) current_membership_verification: CurrentMembershipVerification = field(repr=False) construction_provenance: UserActorConstructionProvenance @@ -349,6 +373,10 @@ def _construct_user_actor( "delivery_evidence_redemption_session", verification.delivery_evidence_redemption_session, ), + ( + "egress_grant_issuance_session", + verification.egress_grant_issuance_session, + ), ("current_membership_verification", verification), ( "construction_provenance", @@ -388,5 +416,7 @@ def _require_active_user_actor(actor: UserActor) -> None: is not verification.context_run_persistence_session or actor.delivery_evidence_redemption_session is not verification.delivery_evidence_redemption_session + or actor.egress_grant_issuance_session + is not verification.egress_grant_issuance_session ): raise ValueError("UserActor does not match its current Membership proof") diff --git a/engine/runtime/construction.py b/engine/runtime/construction.py index 052ff333..97d29dc7 100644 --- a/engine/runtime/construction.py +++ b/engine/runtime/construction.py @@ -50,8 +50,25 @@ RuntimeRequest, ScopeDecisionReceipt, _require_closed_opaque_ref, + context_package_digest_document, ) -from engine.runtime.delivery import TrustedDeliveryContext +from engine.runtime.delivery import ( + DeliveryConstructionProvenance, + TrustedDeliveryContext, +) +from engine.runtime.egress import ( + INTERNAL_ONLY_EGRESS_PROFILE, + ChannelEgressProfile, + EgressGrant, + EgressGrantIssuanceUnavailable, + EgressGrantIssue, + EgressProfile, + InternalOnlyEgressProfile, + ModelEgressProfile, + direct_egress_audience_digest, + issue_egress_grant, +) +from engine.runtime.egress_payload import channel_payload_digest, model_input_digest from engine.runtime.evidence import ( CandidateRef, EvidenceLineage, @@ -69,7 +86,7 @@ _locate_materialized_fragment, _project_materialized_fragment, ) -from engine.runtime.package_digest import QueryDigestKeyring +from engine.runtime.package_digest import QueryDigestKeyring, context_package_digest from engine.runtime.policy_epoch import ( PolicyEpochAuthorityUnavailable, PolicyEpochVerification, @@ -309,6 +326,107 @@ def issue( ) +@dataclass(frozen=True, slots=True) +class EgressGate: + """Concrete final Package/hop policy gate; no external profile is a closed deny.""" + + def finalize( + self, + *, + invocation: AuthenticatedInvocation, + delivery_context: TrustedDeliveryContext, + provenance: DecisionProvenanceReceipt, + package: ContextPackage, + profile: EgressProfile, + issued_at: datetime, + ) -> EgressGrant | None: + if type(profile) not in { + InternalOnlyEgressProfile, + ModelEgressProfile, + ChannelEgressProfile, + }: + raise RuntimeConfigurationError("egress profile has the wrong nominal type") + _require_active_user_actor(invocation.user_actor) + if ( + package.package_digest != context_package_digest( + context_package_digest_document(package) + ) + or provenance.organization_id != invocation.user_actor.organization_id + or provenance.package_organization_ref != package.organization_ref + or provenance.decision_ref != package.decision_ref + or provenance.purpose != package.purpose + or provenance.purpose != delivery_context.purpose + or provenance.policy_epoch + != invocation.policy_epoch + != invocation.user_actor.policy_epoch + or provenance.as_of != package.as_of + or issued_at != package.as_of + or package.expires_at <= issued_at + ): + raise EgressGrantIssuanceUnavailable( + "final egress policy could not bind the current Package" + ) + if type(profile) is InternalOnlyEgressProfile: + return None + assert isinstance(profile, ModelEgressProfile | ChannelEgressProfile) + if type(profile) is ChannelEgressProfile and ( + delivery_context.construction_provenance + is not DeliveryConstructionProvenance.REDEEMED_PRIVATE_DELIVERY_EVIDENCE + or delivery_context.destination_ref != profile.destination_ref + or delivery_context.consumer_ref != profile.consumer_ref + ): + raise EgressGrantIssuanceUnavailable( + "channel egress is not bound to the trusted private delivery" + ) + session = invocation.user_actor.egress_grant_issuance_session + if session is None: + raise EgressGrantIssuanceUnavailable( + "external egress requires durable one-shot issuance" + ) + audience_digest = delivery_context.audience_digest + if audience_digest is None: + audience_digest = direct_egress_audience_digest( + organization_id=invocation.user_actor.organization_id, + membership_id=invocation.user_actor.membership_id, + membership_version=invocation.user_actor.membership_version, + authenticated_application_ref=( + delivery_context.authenticated_application_ref + ), + delivery_binding_ref=delivery_context.delivery_binding_ref, + ) + expires_at = min( + package.expires_at, + issued_at + profile.maximum_ttl, + ) + if type(profile) is ModelEgressProfile: + issue = EgressGrantIssue.for_model( + organization_id=invocation.user_actor.organization_id, + package_digest=package.package_digest, + payload_digest=model_input_digest(package), + purpose=package.purpose, + audience_digest=audience_digest, + policy_epoch=invocation.policy_epoch, + issued_at=issued_at, + expires_at=expires_at, + profile=profile, + ) + elif type(profile) is ChannelEgressProfile: + issue = EgressGrantIssue.for_channel( + organization_id=invocation.user_actor.organization_id, + package_digest=package.package_digest, + payload_digest=channel_payload_digest(package), + purpose=package.purpose, + audience_digest=audience_digest, + policy_epoch=invocation.policy_epoch, + issued_at=issued_at, + expires_at=expires_at, + profile=profile, + ) + else: # pragma: no cover - closed nominal union above + raise RuntimeConfigurationError("egress profile variant is unavailable") + return issue_egress_grant(session, issue) + + class DecisionAuditGate: """Concrete safe in-memory audit gate; persistence belongs to Issue #19.""" @@ -372,6 +490,7 @@ def _unsupported_capability_snapshot( | DecisionAuditGate | PackageBudgetGate | ProvenanceGate + | EgressGate ) @@ -384,6 +503,7 @@ class KernelDependencies: audit: DecisionAuditGate budget: PackageBudgetGate provenance: ProvenanceGate + egress: EgressGate def _validate_kernel_dependencies(dependencies: object) -> KernelDependencies: @@ -397,6 +517,7 @@ def _validate_kernel_dependencies(dependencies: object) -> KernelDependencies: ("audit", DecisionAuditGate), ("budget", PackageBudgetGate), ("provenance", ProvenanceGate), + ("egress", EgressGate), ): if type(getattr(dependencies, field_name)) is not expected_type: raise RuntimeConfigurationError( @@ -415,6 +536,7 @@ def __init__(self, dependencies: KernelDependencies) -> None: self._audit = validated.audit self._budget = validated.budget self._provenance = validated.provenance + self._egress = validated.egress def authorize_acquire( self, @@ -603,6 +725,29 @@ def finalize_for_delivery( audit_receipt=audit_receipt, ) + def finalize_egress( + self, + *, + invocation: AuthenticatedInvocation, + delivery_context: TrustedDeliveryContext, + provenance: DecisionProvenanceReceipt, + package: ContextPackage, + profile: EgressProfile, + issued_at: datetime, + ) -> EgressGrant | None: + """Apply the mandatory final egress gate after Package construction.""" + + if type(self._egress) is not EgressGate: + raise RuntimeConfigurationError("mandatory final egress gate is invalid") + return self._egress.finalize( + invocation=invocation, + delivery_context=delivery_context, + provenance=provenance, + package=package, + profile=profile, + issued_at=issued_at, + ) + def _authorize_and_assemble( self, invocation: AuthenticatedInvocation, @@ -778,6 +923,7 @@ def __init__( ), clock: Callable[[], datetime] = _utc_now, query_digest_keyring: QueryDigestKeyring | None = None, + egress_profile: EgressProfile = INTERNAL_ONLY_EGRESS_PROFILE, ) -> None: validated = _validate_kernel_dependencies(dependencies) if type(package_ttl_seconds) is not int or package_ttl_seconds <= 0: @@ -830,6 +976,15 @@ def __init__( ): raise TypeError("query_digest_keyring must be QueryDigestKeyring") self._query_digest_keyring = query_digest_keyring + if type(egress_profile) not in { + InternalOnlyEgressProfile, + ModelEgressProfile, + ChannelEgressProfile, + }: + raise RuntimeConfigurationError( + "egress_profile must be one closed server-owned profile" + ) + self._egress_profile = egress_profile self._reference_issuer = _OpaqueReferenceIssuer() @overload @@ -956,6 +1111,14 @@ def resolve( reason=audit_receipt.reason, ), ) + egress_grant = self._kernel.finalize_egress( + invocation=invocation, + delivery_context=delivery_context, + provenance=provenance, + package=package, + profile=self._egress_profile, + issued_at=as_of, + ) persistence_session = invocation.user_actor.context_run_persistence_session if persistence_session is None: raise ContextRunPersistenceUnavailable( @@ -987,6 +1150,7 @@ def resolve( target_count=len(policy_receipt.effective_scope.targets), is_empty=not policy_receipt.effective_scope.targets, ), + egress_grant=egress_grant, ) def _required_capability(self, request: RuntimeRequest) -> RuntimeCapability: @@ -1025,4 +1189,5 @@ def required_kernel_dependencies() -> KernelDependencies: audit=DecisionAuditGate(), budget=PackageBudgetGate(), provenance=ProvenanceGate(), + egress=EgressGate(), ) diff --git a/engine/runtime/contracts.py b/engine/runtime/contracts.py index ac2621ce..e243e7b9 100644 --- a/engine/runtime/contracts.py +++ b/engine/runtime/contracts.py @@ -11,6 +11,7 @@ TrustedDeliveryContext, _construct_direct_delivery_context, ) +from engine.runtime.egress import ChannelEgressGrant, EgressGrant, ModelEgressGrant from engine.runtime.evidence import Evidence, PackageBlock, validate_package_content from engine.runtime.package_digest import context_package_digest @@ -447,6 +448,7 @@ class Resolved: package: ContextPackage effective_budget: PackageBudget scope_decision: ScopeDecisionReceipt = field(repr=False) + egress_grant: EgressGrant | None = field(default=None, repr=False) kind: Literal["resolved"] = "resolved" def __post_init__(self) -> None: @@ -456,6 +458,11 @@ def __post_init__(self) -> None: raise TypeError("resolved effective_budget must be PackageBudget") if type(self.scope_decision) is not ScopeDecisionReceipt: raise TypeError("resolved scope_decision must be ScopeDecisionReceipt") + if self.egress_grant is not None and type(self.egress_grant) not in { + ModelEgressGrant, + ChannelEgressGrant, + }: + raise TypeError("resolved egress_grant has the wrong nominal type") if self.kind != "resolved": raise ValueError("resolved outcome kind must be resolved") diff --git a/engine/runtime/egress.py b/engine/runtime/egress.py new file mode 100644 index 00000000..398abac7 --- /dev/null +++ b/engine/runtime/egress.py @@ -0,0 +1,677 @@ +"""Nominal one-hop egress grants and exact redemption bindings.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Any, NoReturn, Protocol, cast +from uuid import UUID + +import rfc8785 + +MODEL_EGRESS_GRANT_PREFIX = "egrm" +CHANNEL_EGRESS_GRANT_PREFIX = "egrc" +EGRESS_GRANT_DIGEST_PROFILE = "egress-grant-locator-sha256-v1" +EGRESS_GRANT_PROFILE_LINEAGE = "egress-grant-v1" + + +class EgressGrantNotAvailable(Exception): + """The opaque grant did not authorize the exact declared egress hop.""" + + def __init__(self) -> None: + super().__init__("egress grant not available") + + +class EgressGrantIssuanceUnavailable(RuntimeError): + """The final egress gate could not persist its one-shot grant.""" + + +class EgressGrantAuthorityUnavailable(RuntimeError): + """The independent one-shot redemption authority could not decide.""" + + +class EgressAuditCategory(StrEnum): + """Restricted durable categories that never contain denied details.""" + + ISSUED = "issued" + CONSUMED = "consumed" + + +def _require_nonblank(field_name: str, value: object) -> str: + if type(value) is not str or not value or value.isspace(): + raise ValueError(f"egress {field_name} must be non-empty") + return value + + +def _require_sha256(field_name: str, value: object) -> str: + if ( + type(value) is not str + or len(value) != hashlib.sha256().digest_size * 2 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ValueError(f"egress {field_name} must be lowercase SHA-256") + return value + + +def _require_positive_epoch(value: object) -> int: + if type(value) is not int or value < 1: + raise ValueError("egress policy_epoch must be a positive integer") + return value + + +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"egress {field_name} must be aware UTC") + return value + + +def _require_opaque_grant(value: object, *, prefix: str) -> str: + if ( + type(value) is not str + or not value.startswith(f"{prefix}_") + or len(value) != len(prefix) + 1 + 64 + or any(character not in "0123456789abcdef" for character in value[5:]) + ): + raise ValueError("egress grant must use the closed opaque locator format") + return value + + +def direct_egress_audience_digest( + *, + organization_id: UUID, + membership_id: UUID, + membership_version: int, + authenticated_application_ref: str, + delivery_binding_ref: str, +) -> str: + """Bind a direct trusted consumer without inventing an AudienceSnapshot.""" + + if type(organization_id) is not UUID or type(membership_id) is not UUID: + raise TypeError("direct egress audience requires UUID ownership") + if type(membership_version) is not int or membership_version < 1: + raise ValueError("direct egress audience Membership version must be positive") + _require_nonblank("authenticated_application_ref", authenticated_application_ref) + _require_nonblank("delivery_binding_ref", delivery_binding_ref) + document = { + "applicationRef": authenticated_application_ref, + "deliveryBindingRef": delivery_binding_ref, + "membershipId": str(membership_id), + "membershipVersion": membership_version, + "organizationId": str(organization_id), + "profile": "direct-egress-audience-rfc8785-sha256-v1", + } + return hashlib.sha256( + b"context-engine.direct-egress-audience.v1\x00" + + rfc8785.dumps(cast(Any, document)) + ).hexdigest() + + +@dataclass(frozen=True, slots=True) +class ModelEgressGrant: + """Opaque capability for exactly one model hop.""" + + value: str = field(repr=False) + + def __post_init__(self) -> None: + _require_opaque_grant(self.value, prefix=MODEL_EGRESS_GRANT_PREFIX) + + @property + def digest(self) -> bytes: + return hashlib.sha256(self.value.encode("utf-8")).digest() + + +@dataclass(frozen=True, slots=True) +class ChannelEgressGrant: + """Opaque capability for exactly one channel preflight hop, never an effect.""" + + value: str = field(repr=False) + + def __post_init__(self) -> None: + _require_opaque_grant(self.value, prefix=CHANNEL_EGRESS_GRANT_PREFIX) + + @property + def digest(self) -> bytes: + return hashlib.sha256(self.value.encode("utf-8")).digest() + + +@dataclass(frozen=True, slots=True) +class ModelEgressProfile: + """Versioned, server-owned final policy for one exact model boundary.""" + + profile_ref: str + retention_policy_ref: str + sensitivity_policy_ref: str + issuer_ref: str + consumer_ref: str + provider_ref: str + model_ref: str + region_ref: str + maximum_ttl: timedelta + + def __post_init__(self) -> None: + for field_name in ( + "profile_ref", + "retention_policy_ref", + "sensitivity_policy_ref", + "issuer_ref", + "consumer_ref", + "provider_ref", + "model_ref", + "region_ref", + ): + _require_nonblank(field_name, getattr(self, field_name)) + if type(self.maximum_ttl) is not timedelta or self.maximum_ttl <= timedelta(0): + raise ValueError("egress maximum_ttl must be positive") + + +@dataclass(frozen=True, slots=True) +class ChannelEgressProfile: + """Versioned, server-owned final policy for one exact channel boundary.""" + + profile_ref: str + retention_policy_ref: str + sensitivity_policy_ref: str + issuer_ref: str + consumer_ref: str + channel_ref: str + destination_ref: str + region_ref: str + maximum_ttl: timedelta + + def __post_init__(self) -> None: + for field_name in ( + "profile_ref", + "retention_policy_ref", + "sensitivity_policy_ref", + "issuer_ref", + "consumer_ref", + "channel_ref", + "destination_ref", + "region_ref", + ): + _require_nonblank(field_name, getattr(self, field_name)) + if type(self.maximum_ttl) is not timedelta or self.maximum_ttl <= timedelta(0): + raise ValueError("egress maximum_ttl must be positive") + + +@dataclass(frozen=True, slots=True) +class InternalOnlyEgressProfile: + """Closed policy state that issues no authority for an external hop.""" + + profile_ref: str = "internal-only-egress-v1" + + def __post_init__(self) -> None: + if self.profile_ref != "internal-only-egress-v1": + raise ValueError("internal-only egress profile is closed") + + +INTERNAL_ONLY_EGRESS_PROFILE = InternalOnlyEgressProfile() + + +@dataclass(frozen=True, slots=True) +class EgressGrantIssue: + """Trusted final Package/hop facts persisted without the bearer value.""" + + hop_kind: str + organization_id: UUID = field(repr=False) + package_digest: str + payload_digest: str + purpose: str + audience_digest: str + policy_epoch: int + retention_policy_ref: str + sensitivity_policy_ref: str + issuer_ref: str + consumer_ref: str + provider_ref: str | None + model_ref: str | None + channel_ref: str | None + destination_ref: str | None + region_ref: str + issued_at: datetime + expires_at: datetime + profile_ref: str + grant_profile_ref: str = EGRESS_GRANT_PROFILE_LINEAGE + category: EgressAuditCategory = EgressAuditCategory.ISSUED + + def __post_init__(self) -> None: + if type(self.organization_id) is not UUID: + raise TypeError("egress issue Organization must be UUID") + _require_sha256("package_digest", self.package_digest) + _require_sha256("payload_digest", self.payload_digest) + _require_sha256("audience_digest", self.audience_digest) + _require_positive_epoch(self.policy_epoch) + for field_name in ( + "purpose", + "retention_policy_ref", + "sensitivity_policy_ref", + "issuer_ref", + "consumer_ref", + "region_ref", + "profile_ref", + ): + _require_nonblank(field_name, getattr(self, field_name)) + _require_utc("issued_at", self.issued_at) + _require_utc("expires_at", self.expires_at) + if self.expires_at <= self.issued_at: + raise ValueError("egress expiry must follow issuance") + if self.grant_profile_ref != EGRESS_GRANT_PROFILE_LINEAGE: + raise ValueError("egress grant profile lineage is not active") + if self.category is not EgressAuditCategory.ISSUED: + raise ValueError("new egress grant category must be issued") + if self.hop_kind == "model": + if ( + self.provider_ref is None + or self.model_ref is None + or self.channel_ref is not None + or self.destination_ref is not None + ): + raise ValueError("model issue must contain only model hop fields") + _require_nonblank("provider_ref", self.provider_ref) + _require_nonblank("model_ref", self.model_ref) + elif self.hop_kind == "channel": + if ( + self.channel_ref is None + or self.destination_ref is None + or self.provider_ref is not None + or self.model_ref is not None + ): + raise ValueError("channel issue must contain only channel hop fields") + _require_nonblank("channel_ref", self.channel_ref) + _require_nonblank("destination_ref", self.destination_ref) + else: + raise ValueError("egress issue hop_kind must be model or channel") + + @classmethod + def for_model( + cls, + *, + organization_id: UUID, + package_digest: str, + payload_digest: str, + purpose: str, + audience_digest: str, + policy_epoch: int, + issued_at: datetime, + expires_at: datetime, + profile: ModelEgressProfile, + ) -> EgressGrantIssue: + if type(profile) is not ModelEgressProfile: + raise TypeError("model issue requires ModelEgressProfile") + if expires_at - issued_at > profile.maximum_ttl: + raise ValueError("model egress lifetime exceeds its profile") + return cls( + hop_kind="model", + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose=purpose, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + retention_policy_ref=profile.retention_policy_ref, + sensitivity_policy_ref=profile.sensitivity_policy_ref, + issuer_ref=profile.issuer_ref, + consumer_ref=profile.consumer_ref, + provider_ref=profile.provider_ref, + model_ref=profile.model_ref, + channel_ref=None, + destination_ref=None, + region_ref=profile.region_ref, + issued_at=issued_at, + expires_at=expires_at, + profile_ref=profile.profile_ref, + ) + + @classmethod + def for_channel( + cls, + *, + organization_id: UUID, + package_digest: str, + payload_digest: str, + purpose: str, + audience_digest: str, + policy_epoch: int, + issued_at: datetime, + expires_at: datetime, + profile: ChannelEgressProfile, + ) -> EgressGrantIssue: + if type(profile) is not ChannelEgressProfile: + raise TypeError("channel issue requires ChannelEgressProfile") + if expires_at - issued_at > profile.maximum_ttl: + raise ValueError("channel egress lifetime exceeds its profile") + return cls( + hop_kind="channel", + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose=purpose, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + retention_policy_ref=profile.retention_policy_ref, + sensitivity_policy_ref=profile.sensitivity_policy_ref, + issuer_ref=profile.issuer_ref, + consumer_ref=profile.consumer_ref, + provider_ref=None, + model_ref=None, + channel_ref=profile.channel_ref, + destination_ref=profile.destination_ref, + region_ref=profile.region_ref, + issued_at=issued_at, + expires_at=expires_at, + profile_ref=profile.profile_ref, + ) + + +class EgressGrantIssuancePort(Protocol): + """Digest-only write owned by the retained current-UserActor transaction.""" + + def issue(self, request: EgressGrantIssue, grant_digest: bytes) -> bool: ... + + +class _EgressGrantIssuanceScope: + __slots__ = ("_active", "_seal") + _active: bool + _seal: object + + def __init__(self) -> None: + raise TypeError("egress issuance scopes are not constructible") + + def __reduce__(self) -> NoReturn: + raise TypeError("egress issuance scopes are not serializable") + + +_EGRESS_GRANT_ISSUANCE_SCOPE_SEAL = object() + + +def _open_egress_grant_issuance_scope() -> _EgressGrantIssuanceScope: + scope = object.__new__(_EgressGrantIssuanceScope) + scope._active = True + scope._seal = _EGRESS_GRANT_ISSUANCE_SCOPE_SEAL + return scope + + +def _close_egress_grant_issuance_scope(scope: _EgressGrantIssuanceScope) -> None: + if ( + type(scope) is not _EgressGrantIssuanceScope + or getattr(scope, "_seal", None) is not _EGRESS_GRANT_ISSUANCE_SCOPE_SEAL + ): + raise TypeError("egress issuance scope has the wrong nominal type") + scope._active = False + + +@dataclass(frozen=True, slots=True, init=False) +class EgressGrantIssuanceSession: + """Nominal issuance authority valid only in its owning transaction.""" + + _authority_scope: _EgressGrantIssuanceScope = field(repr=False) + _port: EgressGrantIssuancePort = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("EgressGrantIssuanceSession is authority-constructed") + + def __reduce__(self) -> NoReturn: + raise TypeError("EgressGrantIssuanceSession is not serializable") + + +def _require_active_egress_grant_issuance_session( + session: EgressGrantIssuanceSession, +) -> None: + if type(session) is not EgressGrantIssuanceSession: + raise TypeError("egress issuance session has the wrong nominal type") + scope = session._authority_scope + if ( + type(scope) is not _EgressGrantIssuanceScope + or getattr(scope, "_seal", None) is not _EGRESS_GRANT_ISSUANCE_SCOPE_SEAL + or not getattr(scope, "_active", False) + ): + raise ValueError("egress issuance requires an active authority scope") + if not callable(getattr(session._port, "issue", None)): + raise TypeError("egress issuance port is incomplete") + + +def _construct_egress_grant_issuance_session( + *, + authority_scope: _EgressGrantIssuanceScope, + port: EgressGrantIssuancePort, +) -> EgressGrantIssuanceSession: + session = object.__new__(EgressGrantIssuanceSession) + object.__setattr__(session, "_authority_scope", authority_scope) + object.__setattr__(session, "_port", port) + _require_active_egress_grant_issuance_session(session) + return session + + +def issue_egress_grant( + session: EgressGrantIssuanceSession, + request: EgressGrantIssue, + *, + reference_factory: Callable[[str], str] | None = None, +) -> EgressGrant: + """Create one opaque variant and retain only its digest in durable audit.""" + + _require_active_egress_grant_issuance_session(session) + if type(request) is not EgressGrantIssue: + raise TypeError("egress issuance requires EgressGrantIssue") + prefix = ( + MODEL_EGRESS_GRANT_PREFIX + if request.hop_kind == "model" + else CHANNEL_EGRESS_GRANT_PREFIX + ) + factory = reference_factory or ( + lambda selected: f"{selected}_{secrets.token_hex(32)}" + ) + value = factory(prefix) + if request.hop_kind == "model": + grant: EgressGrant = ModelEgressGrant(value) + else: + grant = ChannelEgressGrant(value) + try: + persisted = session._port.issue(request, grant.digest) + except EgressGrantIssuanceUnavailable: + raise + except Exception as error: + raise EgressGrantIssuanceUnavailable from error + if persisted is not True: + raise EgressGrantIssuanceUnavailable( + "egress issuance authority did not persist the one-shot grant" + ) + return grant + + +@dataclass(frozen=True, slots=True) +class EgressGrantRedemption: + """Exact expected binding presented to the one-shot persistence authority.""" + + grant_digest: bytes = field(repr=False) + hop_kind: str + organization_id: UUID = field(repr=False) + package_digest: str + payload_digest: str + purpose: str + audience_digest: str + policy_epoch: int + retention_policy_ref: str + sensitivity_policy_ref: str + issuer_ref: str + consumer_ref: str + provider_ref: str | None + model_ref: str | None + channel_ref: str | None + destination_ref: str | None + region_ref: str + profile_ref: str + + def __post_init__(self) -> None: + if ( + type(self.grant_digest) is not bytes + or len(self.grant_digest) != hashlib.sha256().digest_size + ): + raise ValueError("egress grant digest must be SHA-256 bytes") + if type(self.organization_id) is not UUID: + raise TypeError("egress Organization must be UUID") + _require_sha256("package_digest", self.package_digest) + _require_sha256("payload_digest", self.payload_digest) + _require_sha256("audience_digest", self.audience_digest) + _require_positive_epoch(self.policy_epoch) + for field_name in ( + "purpose", + "retention_policy_ref", + "sensitivity_policy_ref", + "issuer_ref", + "consumer_ref", + "region_ref", + "profile_ref", + ): + _require_nonblank(field_name, getattr(self, field_name)) + if self.hop_kind == "model": + if ( + self.provider_ref is None + or self.model_ref is None + or self.channel_ref is not None + or self.destination_ref is not None + ): + raise ValueError("model redemption must contain only model hop fields") + _require_nonblank("provider_ref", self.provider_ref) + _require_nonblank("model_ref", self.model_ref) + elif self.hop_kind == "channel": + if ( + self.channel_ref is None + or self.destination_ref is None + or self.provider_ref is not None + or self.model_ref is not None + ): + raise ValueError( + "channel redemption must contain only channel hop fields" + ) + _require_nonblank("channel_ref", self.channel_ref) + _require_nonblank("destination_ref", self.destination_ref) + else: + raise ValueError("egress hop_kind must be model or channel") + + @classmethod + def for_model( + cls, + *, + grant: ModelEgressGrant, + organization_id: UUID, + package_digest: str, + payload_digest: str, + purpose: str, + audience_digest: str, + policy_epoch: int, + profile: ModelEgressProfile, + ) -> EgressGrantRedemption: + if type(grant) is not ModelEgressGrant: + raise TypeError("model redemption requires ModelEgressGrant") + if type(profile) is not ModelEgressProfile: + raise TypeError("model redemption requires ModelEgressProfile") + return cls( + grant_digest=grant.digest, + hop_kind="model", + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose=purpose, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + retention_policy_ref=profile.retention_policy_ref, + sensitivity_policy_ref=profile.sensitivity_policy_ref, + issuer_ref=profile.issuer_ref, + consumer_ref=profile.consumer_ref, + provider_ref=profile.provider_ref, + model_ref=profile.model_ref, + channel_ref=None, + destination_ref=None, + region_ref=profile.region_ref, + profile_ref=profile.profile_ref, + ) + + @classmethod + def for_channel( + cls, + *, + grant: ChannelEgressGrant, + organization_id: UUID, + package_digest: str, + payload_digest: str, + purpose: str, + audience_digest: str, + policy_epoch: int, + profile: ChannelEgressProfile, + ) -> EgressGrantRedemption: + if type(grant) is not ChannelEgressGrant: + raise TypeError("channel redemption requires ChannelEgressGrant") + if type(profile) is not ChannelEgressProfile: + raise TypeError("channel redemption requires ChannelEgressProfile") + return cls( + grant_digest=grant.digest, + hop_kind="channel", + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose=purpose, + audience_digest=audience_digest, + policy_epoch=policy_epoch, + retention_policy_ref=profile.retention_policy_ref, + sensitivity_policy_ref=profile.sensitivity_policy_ref, + issuer_ref=profile.issuer_ref, + consumer_ref=profile.consumer_ref, + provider_ref=None, + model_ref=None, + channel_ref=profile.channel_ref, + destination_ref=profile.destination_ref, + region_ref=profile.region_ref, + profile_ref=profile.profile_ref, + ) + + +class EgressGrantRedemptionAuthority(Protocol): + """One-shot authority used immediately before any outbound bytes.""" + + def redeem(self, redemption: EgressGrantRedemption) -> bool: ... + + +type EgressGrant = ModelEgressGrant | ChannelEgressGrant +type EgressProfile = ( + InternalOnlyEgressProfile | ModelEgressProfile | ChannelEgressProfile +) + + +__all__ = [ + "CHANNEL_EGRESS_GRANT_PREFIX", + "ChannelEgressGrant", + "ChannelEgressProfile", + "EGRESS_GRANT_DIGEST_PROFILE", + "EGRESS_GRANT_PROFILE_LINEAGE", + "EgressAuditCategory", + "EgressGrant", + "EgressGrantAuthorityUnavailable", + "EgressGrantIssue", + "EgressGrantIssuancePort", + "EgressGrantIssuanceSession", + "EgressGrantIssuanceUnavailable", + "EgressGrantNotAvailable", + "EgressGrantRedemption", + "EgressGrantRedemptionAuthority", + "EgressProfile", + "INTERNAL_ONLY_EGRESS_PROFILE", + "InternalOnlyEgressProfile", + "MODEL_EGRESS_GRANT_PREFIX", + "ModelEgressGrant", + "ModelEgressProfile", + "_close_egress_grant_issuance_scope", + "_construct_egress_grant_issuance_session", + "_open_egress_grant_issuance_scope", + "_require_active_egress_grant_issuance_session", + "direct_egress_audience_digest", + "issue_egress_grant", +] diff --git a/engine/runtime/egress_payload.py b/engine/runtime/egress_payload.py new file mode 100644 index 00000000..3fbdedf6 --- /dev/null +++ b/engine/runtime/egress_payload.py @@ -0,0 +1,43 @@ +"""Canonical Package payload digests shared by issuance and trusted consumers.""" + +from __future__ import annotations + +import hashlib +from typing import Any, cast + +import rfc8785 + +from engine.runtime.contracts import ContextPackage, context_package_public_document + +MODEL_INPUT_DIGEST_DOMAIN = b"context-engine.authorized-model-input.v1\x00" +CHANNEL_PAYLOAD_DIGEST_DOMAIN = b"context-engine.authorized-channel-payload.v1\x00" + + +def canonical_package_payload(package: ContextPackage) -> bytes: + if type(package) is not ContextPackage: + raise TypeError("egress payload requires ContextPackage") + return rfc8785.dumps(cast(Any, context_package_public_document(package))) + + +def model_input_digest(package: ContextPackage) -> str: + return hashlib.sha256( + MODEL_INPUT_DIGEST_DOMAIN + canonical_package_payload(package) + ).hexdigest() + + +def channel_payload_digest(package: ContextPackage) -> str: + return hashlib.sha256( + CHANNEL_PAYLOAD_DIGEST_DOMAIN + canonical_package_payload(package) + ).hexdigest() + + +def model_payload_bytes_digest(payload: bytes) -> str: + if type(payload) is not bytes: + raise TypeError("model egress payload must be bytes") + return hashlib.sha256(MODEL_INPUT_DIGEST_DOMAIN + payload).hexdigest() + + +def channel_payload_bytes_digest(payload: bytes) -> str: + if type(payload) is not bytes: + raise TypeError("channel egress payload must be bytes") + return hashlib.sha256(CHANNEL_PAYLOAD_DIGEST_DOMAIN + payload).hexdigest() diff --git a/eval/catalogs/m0-security-evidence.schema.json b/eval/catalogs/m0-security-evidence.schema.json index ee35b731..173701db 100644 --- a/eval/catalogs/m0-security-evidence.schema.json +++ b/eval/catalogs/m0-security-evidence.schema.json @@ -21,7 +21,7 @@ "const": { "path": "eval/catalogs/security-invariants.yaml", "schemaPath": "eval/catalogs/security-catalog.schema.json", - "catalogVersion": "1.2.0" + "catalogVersion": "1.3.0" } }, "execution": { diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index 32e313b6..3545f5a5 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -3,7 +3,7 @@ "catalog": { "path": "eval/catalogs/security-invariants.yaml", "schemaPath": "eval/catalogs/security-catalog.schema.json", - "catalogVersion": "1.2.0" + "catalogVersion": "1.3.0" }, "execution": { "framework": "pytest", @@ -83,11 +83,11 @@ {"id": "PG-NON-ENUMERATION-009", "layer": "postgres", "selector": "tests/integration/test_runtime_non_enumeration_integration.py::test_real_postgres_http_denied_and_missing_are_externally_equivalent"}, {"id": "RUNTIME-NON-ENUMERATION-009", "layer": "runtime", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_runtime_canonical_empty_package_is_equal_for_every_internal_branch"}, {"id": "PROP-CITATION-AUTH-010", "layer": "property", "selector": "tests/unit/test_runtime_unavailable_capabilities.py::test_capability_declarations_are_closed_and_m0_does_not_false_green_carriers"}, - {"id": "PG-CITATION-AUTH-010", "layer": "postgres", "selector": "tests/integration/test_m0_unavailable_security_carriers.py::test_m0_unavailable_citation_and_egress_carriers_fail_closed"}, + {"id": "PG-CITATION-AUTH-010", "layer": "postgres", "selector": "tests/integration/test_m0_unavailable_security_carriers.py::test_unavailable_citation_and_real_provider_carriers_fail_closed"}, {"id": "RUNTIME-CITATION-AUTH-010", "layer": "runtime", "selector": "tests/unit/test_http_unavailable_capabilities.py::test_accept_010_open_citation_is_generic_and_zero_io"}, - {"id": "PROP-EGRESS-011", "layer": "property", "selector": "tests/unit/test_evidence_contracts.py::test_package_constructor_rejects_cross_organization_or_mixed_request"}, - {"id": "PG-EGRESS-011", "layer": "postgres", "selector": "tests/integration/test_m0_unavailable_security_carriers.py::test_m0_unavailable_citation_and_egress_carriers_fail_closed"}, - {"id": "RUNTIME-EGRESS-011", "layer": "runtime", "selector": "tests/unit/test_m0_delivery_carriers.py::test_m0_egress_carrier_is_unavailable_before_model_or_sender_bytes"}, + {"id": "PROP-EGRESS-011", "layer": "property", "selector": "tests/unit/test_egress_grant.py::test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects"}, + {"id": "PG-EGRESS-011", "layer": "postgres", "selector": "tests/integration/test_egress_grant.py::test_digest_only_grant_is_atomic_one_shot_and_audited"}, + {"id": "RUNTIME-EGRESS-011", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_file_http_package_redeems_exact_model_grant_before_gateway_bytes"}, {"id": "PROP-TRACE-REDACTION-012", "layer": "property", "selector": "tests/unit/test_context_run.py::test_records_retain_digests_not_raw_query_package_or_denial_details"}, {"id": "PG-TRACE-REDACTION-012", "layer": "postgres", "selector": "tests/integration/test_context_run_schema.py::test_runtime_cross_organization_insert_attempts_are_bidirectionally_zero_effect"}, {"id": "RUNTIME-TRACE-REDACTION-012", "layer": "runtime", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_empty_decision_audit_is_generic_and_retains_no_denied_detail"}, diff --git a/eval/catalogs/security-catalog.schema.json b/eval/catalogs/security-catalog.schema.json index 689c0d13..5f7e50c1 100644 --- a/eval/catalogs/security-catalog.schema.json +++ b/eval/catalogs/security-catalog.schema.json @@ -14,7 +14,7 @@ "properties": { "catalogVersion": { "type": "string", - "const": "1.2.0" + "const": "1.3.0" }, "authority": { "$ref": "#/$defs/authority" @@ -59,8 +59,8 @@ }, "activations": { "type": "array", - "minItems": 7, - "maxItems": 7, + "minItems": 8, + "maxItems": 8, "uniqueItems": true, "prefixItems": [ { @@ -186,7 +186,25 @@ ], "deferredEvidence": ["group AudienceSnapshot DeliveryEvidenceRef", "frozen OpenAPI and generated TypeScript SDK carrier", "production BotDelivery caller"], "futureCarriers": ["public group DeliveryEvidenceRef", "OpenCitation delivery evidence", "generated TypeScript SDK", "private BotDelivery application"], - "notActive": ["group delivery", "AudienceSnapshot", "EgressGrant", "ModelGateway", "ActionPlane", "BotDelivery", "OpenAPI compatibility freeze", "generated SDK"] + "notActive": ["group delivery", "AudienceSnapshot", "production ModelGateway", "ActionPlane", "BotDelivery application", "OpenAPI compatibility freeze", "generated SDK"] + } + }, + { + "const": { + "issueRef": "#65", + "invariantRef": "EGRESS-011", + "carrier": "opaque one-shot model or channel EgressGrant with deterministic boundary spies", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "ContextPackage -> final EgressGate -> digest-only PostgreSQL grant -> AuthorizedModelInput | AuthorizedChannelPayload -> exact one-shot redemption -> ModelGateway | Sender preflight spy", + "testEvidence": [ + {"id": "PROP-EGRESS-011", "surface": "tests/unit/test_egress_grant.py::test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects", "oracle": "Every common and hop-specific model or channel redemption binding mutation, plus cross-kind use, is non-enumerating and emits zero gateway bytes, Sender preflight bytes, or effects."}, + {"id": "PG-EGRESS-011", "surface": "tests/integration/test_egress_grant.py::test_digest_only_grant_is_atomic_one_shot_and_audited", "oracle": "Real PostgreSQL under separate non-owner Runtime and egress roles stores only grant and payload digests, binds the exact Package, Organization, purpose, audience, Policy Epoch, hop, retention, sensitivity, issuer, consumer, provider/model or channel/destination, region, lifetime, and profile, consumes atomically once, and retains only restricted issued, consumed, or not-available audit categories."}, + {"id": "RUNTIME-EGRESS-011", "surface": "tests/integration/test_z_egress_grant_file.py::test_file_http_package_redeems_exact_model_grant_before_gateway_bytes", "oracle": "A real File-backed authenticated HTTP Acquire proves CandidateRef through the sealed AuthorizationKernel to an audience-bound ContextPackage, returns one model grant only after final policy, and the independent egress role permits exactly one deterministic ModelGateway spy request; replay emits zero additional model bytes."} + ], + "deferredEvidence": ["production provider ModelGateway conformance", "production Sender and ActionPlane effect conformance", "group AudienceSnapshot send-time revalidation"], + "futureCarriers": ["production ModelGateway", "production Sender preflight", "ActionPlane prepare and perform", "group-public and asker-private delivery"], + "notActive": ["real model or provider network call", "real Sender or channel write", "ActionTicket or external effect", "group AudienceSnapshot", "BotDelivery application process", "generated SDK consumer"] } } ], @@ -391,7 +409,7 @@ "additionalProperties": false, "required": ["id", "surface", "oracle"], "properties": { - "id": {"enum": ["PG-REVOCATION-006", "RUN-006", "CACHE-002", "RUN-UNAVAILABLE-016", "HTTP-UNAVAILABLE-016", "LEASE-SIGNING-017", "PG-WORKER-LEASE-NOOP-017", "WORKER-LEASE-REPLAY-007", "TICKET-AUDIENCE-018", "PG-TICKET-EPOCH-018", "DIGEST-019", "RUN-LINEAGE-019", "AUTHORIZED-RUN-019", "PG-TRACE-REDACTION-012", "PROP-FIELD-PROJECTION-048", "PG-FIELD-PROJECTION-048", "HTTP-ACCEPT-002-048", "PROP-DELIVERY-EVIDENCE-063", "PG-DELIVERY-EVIDENCE-063", "HTTP-DELIVERY-EVIDENCE-063", "FILE-DELIVERY-EVIDENCE-063"]}, + "id": {"enum": ["PG-REVOCATION-006", "RUN-006", "CACHE-002", "RUN-UNAVAILABLE-016", "HTTP-UNAVAILABLE-016", "LEASE-SIGNING-017", "PG-WORKER-LEASE-NOOP-017", "WORKER-LEASE-REPLAY-007", "TICKET-AUDIENCE-018", "PG-TICKET-EPOCH-018", "DIGEST-019", "RUN-LINEAGE-019", "AUTHORIZED-RUN-019", "PG-TRACE-REDACTION-012", "PROP-FIELD-PROJECTION-048", "PG-FIELD-PROJECTION-048", "HTTP-ACCEPT-002-048", "PROP-DELIVERY-EVIDENCE-063", "PG-DELIVERY-EVIDENCE-063", "HTTP-DELIVERY-EVIDENCE-063", "FILE-DELIVERY-EVIDENCE-063", "PROP-EGRESS-011", "PG-EGRESS-011", "RUNTIME-EGRESS-011"]}, "surface": {"$ref": "#/$defs/pytestSurface"}, "oracle": {"$ref": "#/$defs/nonEmptyString"} } @@ -401,12 +419,12 @@ "additionalProperties": false, "required": ["issueRef", "invariantRef", "carrier", "status", "policyEpochScope", "controlBoundary", "testEvidence", "deferredEvidence", "futureCarriers", "notActive"], "properties": { - "issueRef": {"enum": ["#15", "#16", "#17", "#18", "#19", "#48", "#63"]}, - "invariantRef": {"enum": ["REVOCATION-006", "INDEX-NOT-AUTHORITY-005", "WORKER-LEASE-007", "ACTION-SEPARATION-014", "TRACE-REDACTION-012", "SCOPE-INTERSECTION-004", "TRANSPORT-UNTRUSTED-008"]}, - "carrier": {"enum": ["ContextRuntime.resolve(Acquire)", "ContextRuntime.resolve(Continue | OpenCitation | server-owned unavailable Acquire plan)", "signed one-shot persistent no-op durable-job WorkerLease", "signed synthetic ContextAccessTicket Provider read | signed synthetic ActionTicket no-op channel action", "ContextRuntime.resolve(Acquire) authorized-only ContextRun | restricted delivered-empty DecisionAudit", "ACCEPT-002 ContextRuntime.resolve(Acquire) Membership field projection", "private authenticated HTTP Acquire DeliveryEvidenceRef"]}, + "issueRef": {"enum": ["#15", "#16", "#17", "#18", "#19", "#48", "#63", "#65"]}, + "invariantRef": {"enum": ["REVOCATION-006", "INDEX-NOT-AUTHORITY-005", "WORKER-LEASE-007", "ACTION-SEPARATION-014", "TRACE-REDACTION-012", "SCOPE-INTERSECTION-004", "TRANSPORT-UNTRUSTED-008", "EGRESS-011"]}, + "carrier": {"enum": ["ContextRuntime.resolve(Acquire)", "ContextRuntime.resolve(Continue | OpenCitation | server-owned unavailable Acquire plan)", "signed one-shot persistent no-op durable-job WorkerLease", "signed synthetic ContextAccessTicket Provider read | signed synthetic ActionTicket no-op channel action", "ContextRuntime.resolve(Acquire) authorized-only ContextRun | restricted delivered-empty DecisionAudit", "ACCEPT-002 ContextRuntime.resolve(Acquire) Membership field projection", "private authenticated HTTP Acquire DeliveryEvidenceRef", "opaque one-shot model or channel EgressGrant with deterministic boundary spies"]}, "status": {"const": "active_fail_closed"}, "policyEpochScope": {"enum": ["organization-v0", "not-bound-issue-17"]}, - "controlBoundary": {"enum": ["PostgreSQLAccessPolicyControl.change_access(ResourceAccessRevocation)", "RuntimeCapabilityGate.require_available(RuntimeCapability)", "complete_persistent_noop_job(PostgreSQLWorkerLeaseAuthority, WorkerLeaseRedemption)", "ContextAccessTicketReadHandler.read | ActionTicketNoopHandler.perform", "persist_context_run(ContextRunPersistenceSession, ContextRunRecord, DecisionAuditRecord | None) | PostgreSQLContextRunReader.find_by_decision_ref", "CandidateRef -> same-transaction active-lineage locator -> EffectiveScope -> current Membership/version field ceiling -> PostgreSQL/FORCE-RLS field reduction -> AuthorizationKernel -> AuthorizedProjection", "PrivateDeliveryEvidenceIssuer -> PostgreSQL identity function -> authenticated HTTP metadata -> current UserActor transaction redemption -> TrustedDeliveryContext -> sealed ContextRuntime.resolve(Acquire)"]}, + "controlBoundary": {"enum": ["PostgreSQLAccessPolicyControl.change_access(ResourceAccessRevocation)", "RuntimeCapabilityGate.require_available(RuntimeCapability)", "complete_persistent_noop_job(PostgreSQLWorkerLeaseAuthority, WorkerLeaseRedemption)", "ContextAccessTicketReadHandler.read | ActionTicketNoopHandler.perform", "persist_context_run(ContextRunPersistenceSession, ContextRunRecord, DecisionAuditRecord | None) | PostgreSQLContextRunReader.find_by_decision_ref", "CandidateRef -> same-transaction active-lineage locator -> EffectiveScope -> current Membership/version field ceiling -> PostgreSQL/FORCE-RLS field reduction -> AuthorizationKernel -> AuthorizedProjection", "PrivateDeliveryEvidenceIssuer -> PostgreSQL identity function -> authenticated HTTP metadata -> current UserActor transaction redemption -> TrustedDeliveryContext -> sealed ContextRuntime.resolve(Acquire)", "ContextPackage -> final EgressGate -> digest-only PostgreSQL grant -> AuthorizedModelInput | AuthorizedChannelPayload -> exact one-shot redemption -> ModelGateway | Sender preflight spy"]}, "testEvidence": { "type": "array", "minItems": 2, diff --git a/eval/catalogs/security-invariants.yaml b/eval/catalogs/security-invariants.yaml index 9f20fcb9..29901223 100644 --- a/eval/catalogs/security-invariants.yaml +++ b/eval/catalogs/security-invariants.yaml @@ -1,5 +1,5 @@ { - "catalogVersion": "1.2.0", + "catalogVersion": "1.3.0", "authority": { "issueRefs": [ "#2", @@ -10,7 +10,8 @@ "#18", "#19", "#48", - "#63" + "#63", + "#65" ], "documentRefs": [ "README.md", @@ -25,9 +26,10 @@ "docs/decisions/0030-bound-ticket-audiences.md", "docs/decisions/0031-persist-authorized-context-run-lineage.md", "docs/decisions/0032-bind-materialized-fields-to-membership-projection-rights.md", - "docs/decisions/0045-redeem-private-delivery-evidence-at-ingress.md" + "docs/decisions/0045-redeem-private-delivery-evidence-at-ingress.md", + "docs/decisions/0046-bind-egress-to-one-exact-package-hop.md" ], - "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE. Issue #16 activates only the M0 refusal gate for unavailable Continue, OpenCitation, and server-owned unavailable Acquire plans: its real continuation, citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue/OpenCitation, and Issue #20 runner substitution remain future or NOT_ACTIVE. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, OpenCitation, EgressGrant, ModelGateway, ActionPlane, BotDelivery, frozen OpenAPI compatibility, and the generated TypeScript SDK remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction." + "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE. Issue #16 activates only the M0 refusal gate for unavailable Continue, OpenCitation, and server-owned unavailable Acquire plans: its real continuation, citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue/OpenCitation, and Issue #20 runner substitution remain future or NOT_ACTIVE. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, OpenCitation, production ModelGateway, ActionPlane, the BotDelivery application, frozen OpenAPI compatibility, and the generated TypeScript SDK remain future or NOT_ACTIVE. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, the BotDelivery application process, and a generated SDK consumer remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction." }, "hardOracles": [ { @@ -241,7 +243,35 @@ ], "deferredEvidence": ["group AudienceSnapshot DeliveryEvidenceRef", "frozen OpenAPI and generated TypeScript SDK carrier", "production BotDelivery caller"], "futureCarriers": ["public group DeliveryEvidenceRef", "OpenCitation delivery evidence", "generated TypeScript SDK", "private BotDelivery application"], - "notActive": ["group delivery", "AudienceSnapshot", "EgressGrant", "ModelGateway", "ActionPlane", "BotDelivery", "OpenAPI compatibility freeze", "generated SDK"] + "notActive": ["group delivery", "AudienceSnapshot", "production ModelGateway", "ActionPlane", "BotDelivery application", "OpenAPI compatibility freeze", "generated SDK"] + }, + { + "issueRef": "#65", + "invariantRef": "EGRESS-011", + "carrier": "opaque one-shot model or channel EgressGrant with deterministic boundary spies", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "ContextPackage -> final EgressGate -> digest-only PostgreSQL grant -> AuthorizedModelInput | AuthorizedChannelPayload -> exact one-shot redemption -> ModelGateway | Sender preflight spy", + "testEvidence": [ + { + "id": "PROP-EGRESS-011", + "surface": "tests/unit/test_egress_grant.py::test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects", + "oracle": "Every common and hop-specific model or channel redemption binding mutation, plus cross-kind use, is non-enumerating and emits zero gateway bytes, Sender preflight bytes, or effects." + }, + { + "id": "PG-EGRESS-011", + "surface": "tests/integration/test_egress_grant.py::test_digest_only_grant_is_atomic_one_shot_and_audited", + "oracle": "Real PostgreSQL under separate non-owner Runtime and egress roles stores only grant and payload digests, binds the exact Package, Organization, purpose, audience, Policy Epoch, hop, retention, sensitivity, issuer, consumer, provider/model or channel/destination, region, lifetime, and profile, consumes atomically once, and retains only restricted issued, consumed, or not-available audit categories." + }, + { + "id": "RUNTIME-EGRESS-011", + "surface": "tests/integration/test_z_egress_grant_file.py::test_file_http_package_redeems_exact_model_grant_before_gateway_bytes", + "oracle": "A real File-backed authenticated HTTP Acquire proves CandidateRef through the sealed AuthorizationKernel to an audience-bound ContextPackage, returns one model grant only after final policy, and the independent egress role permits exactly one deterministic ModelGateway spy request; replay emits zero additional model bytes." + } + ], + "deferredEvidence": ["production provider ModelGateway conformance", "production Sender and ActionPlane effect conformance", "group AudienceSnapshot send-time revalidation"], + "futureCarriers": ["production ModelGateway", "production Sender preflight", "ActionPlane prepare and perform", "group-public and asker-private delivery"], + "notActive": ["real model or provider network call", "real Sender or channel write", "ActionTicket or external effect", "group AudienceSnapshot", "BotDelivery application process", "generated SDK consumer"] } ], "invariants": [ diff --git a/infra/postgres/init/10-security-roles.sh b/infra/postgres/init/10-security-roles.sh index b03998bf..21e03638 100755 --- a/infra/postgres/init/10-security-roles.sh +++ b/infra/postgres/init/10-security-roles.sh @@ -10,6 +10,8 @@ required_environment=( CONTEXT_ENGINE_CONTROL_PASSWORD CONTEXT_ENGINE_IDENTITY_ROLE CONTEXT_ENGINE_IDENTITY_PASSWORD + CONTEXT_ENGINE_EGRESS_ROLE + CONTEXT_ENGINE_EGRESS_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE @@ -39,6 +41,8 @@ psql \ \getenv control_password CONTEXT_ENGINE_CONTROL_PASSWORD \getenv identity_role CONTEXT_ENGINE_IDENTITY_ROLE \getenv identity_password CONTEXT_ENGINE_IDENTITY_PASSWORD +\getenv egress_role CONTEXT_ENGINE_EGRESS_ROLE +\getenv egress_password CONTEXT_ENGINE_EGRESS_PASSWORD \getenv runtime_role CONTEXT_ENGINE_RUNTIME_ROLE \getenv runtime_password CONTEXT_ENGINE_RUNTIME_PASSWORD \getenv worker_role CONTEXT_ENGINE_WORKER_ROLE @@ -98,6 +102,16 @@ CREATE ROLE :"identity_role" NOREPLICATION NOBYPASSRLS; +CREATE ROLE :"egress_role" + LOGIN + PASSWORD :'egress_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + CREATE ROLE :"learning_role" LOGIN PASSWORD :'learning_password' @@ -121,14 +135,14 @@ CREATE ROLE :"security_operator_role" REVOKE ALL ON DATABASE :"database_name" FROM PUBLIC; GRANT CONNECT ON DATABASE :"database_name" TO :"migrator_role", :"control_role", :"runtime_role", :"worker_role", - :"identity_role", :"learning_role", :"security_operator_role"; + :"identity_role", :"egress_role", :"learning_role", :"security_operator_role"; ALTER DATABASE :"database_name" OWNER TO :"migrator_role"; REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO :"migrator_role"; GRANT USAGE ON SCHEMA public TO :"control_role", :"runtime_role", :"worker_role", - :"identity_role", :"learning_role", :"security_operator_role"; + :"identity_role", :"egress_role", :"learning_role", :"security_operator_role"; -- pgvector is an untrusted extension, so only the disposable bootstrap -- superuser creates it. Application schema objects remain migrator-owned. diff --git a/migrations/versions/20260723_0020_egress_grant.py b/migrations/versions/20260723_0020_egress_grant.py new file mode 100644 index 00000000..0078a994 --- /dev/null +++ b/migrations/versions/20260723_0020_egress_grant.py @@ -0,0 +1,327 @@ +"""Persist digest-only one-shot EgressGrant state and restricted audit. + +Revision ID: 20260723_0020 +Revises: 20260723_0019 +Create Date: 2026-07-23 +""" + +# ruff: noqa: E501 + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260723_0020" +down_revision: str | None = "20260723_0019" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_MIGRATOR = "context_engine_migrator" +_RUNTIME = "context_engine_runtime" +_EGRESS = "context_engine_egress" +_DEFINER = "context_engine_egress_grant_definer" +_ISSUE = "context_runtime_issue_egress_grant" +_REDEEM = "context_egress_redeem_grant" +_ISSUE_SIGNATURE = "(uuid, bytea, text, text, bytea, bytea, text, bytea, bigint, text, text, text, text, text, text, text, text, text, timestamptz, timestamptz, text, text, text)" +_REDEEM_SIGNATURE = "(uuid, bytea, text, text, bytea, bytea, text, bytea, bigint, text, text, text, text, text, text, text, text, text, text)" + + +def upgrade() -> None: + """Create function-only Runtime issuance and egress redemption boundaries.""" + + op.create_table( + "egress_grant", + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("grant_digest", postgresql.BYTEA(), nullable=False), + sa.Column("digest_profile", sa.Text(), nullable=False), + sa.Column("hop_kind", sa.Text(), nullable=False), + sa.Column("package_digest", postgresql.BYTEA(), nullable=False), + sa.Column("payload_digest", postgresql.BYTEA(), nullable=False), + sa.Column("purpose", sa.Text(), nullable=False), + sa.Column("audience_digest", postgresql.BYTEA(), nullable=False), + sa.Column("policy_epoch", sa.BigInteger(), nullable=False), + sa.Column("retention_policy_ref", sa.Text(), nullable=False), + sa.Column("sensitivity_policy_ref", sa.Text(), nullable=False), + sa.Column("issuer_ref", sa.Text(), nullable=False), + sa.Column("consumer_ref", sa.Text(), nullable=False), + sa.Column("provider_ref", sa.Text(), nullable=True), + sa.Column("model_ref", sa.Text(), nullable=True), + sa.Column("channel_ref", sa.Text(), nullable=True), + sa.Column("destination_ref", sa.Text(), nullable=True), + sa.Column("region_ref", sa.Text(), nullable=False), + sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("profile_ref", sa.Text(), nullable=False), + sa.Column("grant_profile_ref", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("organization_id", "grant_digest", name="pk_egress_grant"), + sa.UniqueConstraint("grant_digest", name="uq_egress_grant_digest_global"), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.organization_id"], + name="fk_egress_grant_organization", ondelete="CASCADE" + ), + sa.CheckConstraint( + "octet_length(grant_digest) = 32 AND octet_length(package_digest) = 32 AND octet_length(payload_digest) = 32 AND octet_length(audience_digest) = 32", + name="ck_egress_grant_sha256_digests", + ), + sa.CheckConstraint( + "digest_profile = 'egress-grant-locator-sha256-v1' AND grant_profile_ref = 'egress-grant-v1'", + name="ck_egress_grant_profiles", + ), + sa.CheckConstraint("policy_epoch > 0", name="ck_egress_grant_positive_epoch"), + sa.CheckConstraint( + "expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at))", + name="ck_egress_grant_timestamp_order", + ), + sa.CheckConstraint( + "(hop_kind = 'model' AND provider_ref IS NOT NULL AND model_ref IS NOT NULL AND channel_ref IS NULL AND destination_ref IS NULL) OR (hop_kind = 'channel' AND provider_ref IS NULL AND model_ref IS NULL AND channel_ref IS NOT NULL AND destination_ref IS NOT NULL)", + name="ck_egress_grant_exact_hop_variant", + ), + sa.CheckConstraint( + "btrim(purpose) <> '' AND btrim(retention_policy_ref) <> '' AND btrim(sensitivity_policy_ref) <> '' AND btrim(issuer_ref) <> '' AND btrim(consumer_ref) <> '' AND btrim(region_ref) <> '' AND btrim(profile_ref) <> ''", + name="ck_egress_grant_bindings_nonblank", + ), + ) + op.create_table( + "egress_audit", + sa.Column("audit_id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("grant_digest", postgresql.BYTEA(), nullable=False), + sa.Column("payload_digest", postgresql.BYTEA(), nullable=False), + sa.Column("category", sa.Text(), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", "audit_id", name="pk_egress_audit" + ), + sa.ForeignKeyConstraint( + ["organization_id", "grant_digest"], + ["egress_grant.organization_id", "egress_grant.grant_digest"], + name="fk_egress_audit_exact_grant", ondelete="CASCADE", + ), + sa.CheckConstraint( + "octet_length(grant_digest) = 32 AND octet_length(payload_digest) = 32", + name="ck_egress_audit_sha256_digests", + ), + sa.CheckConstraint( + "category IN ('issued', 'consumed', 'not_available')", + name="ck_egress_audit_restricted_category", + ), + ) + + for table_name in ("egress_grant", "egress_audit"): + for role in ("PUBLIC", _RUNTIME, _EGRESS, _DEFINER): + 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 ON {table_name} FOR ALL TO {_MIGRATOR} USING (true) WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY {table_name}_definer_all ON {table_name} FOR ALL TO {_DEFINER} USING (true) WITH CHECK (true)" + ) + op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table_name} TO {_DEFINER}") + op.execute(f"GRANT SELECT ON TABLE organization_policy_epoch TO {_DEFINER}") + op.execute(f"GRANT SELECT ON TABLE membership TO {_DEFINER}") + op.execute( + f"CREATE POLICY organization_policy_epoch_egress_definer_select ON organization_policy_epoch FOR SELECT TO {_DEFINER} USING (true)" + ) + op.execute( + f"CREATE POLICY membership_egress_definer_select ON membership FOR SELECT TO {_DEFINER} USING (true)" + ) + + op.execute( + f""" + CREATE FUNCTION public.{_ISSUE}( + requested_organization_id uuid, requested_grant_digest bytea, + requested_digest_profile text, requested_hop_kind text, + requested_package_digest bytea, requested_payload_digest bytea, + requested_purpose text, requested_audience_digest bytea, + requested_policy_epoch bigint, requested_retention_policy_ref text, + requested_sensitivity_policy_ref text, requested_issuer_ref text, + requested_consumer_ref text, requested_provider_ref text, + requested_model_ref text, requested_channel_ref text, + requested_destination_ref text, requested_region_ref text, + requested_issued_at timestamptz, requested_expires_at timestamptz, + requested_profile_ref text, requested_grant_profile_ref text, + requested_category text + ) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE authority_now timestamptz := pg_catalog.clock_timestamp(); + BEGIN + IF SESSION_USER <> '{_RUNTIME}' + OR requested_digest_profile <> 'egress-grant-locator-sha256-v1' + OR requested_grant_profile_ref <> 'egress-grant-v1' + OR requested_category <> 'issued' + OR requested_issued_at > authority_now + OR requested_expires_at <= authority_now + OR requested_organization_id <> NULLIF(current_setting('app.organization_id', true), '')::uuid + OR requested_policy_epoch <= 0 + OR NOT EXISTS ( + SELECT 1 FROM public.organization_policy_epoch AS epoch + WHERE epoch.organization_id = requested_organization_id + AND epoch.policy_epoch = requested_policy_epoch + ) + OR NOT EXISTS ( + SELECT 1 FROM public.membership AS membership + WHERE membership.organization_id = requested_organization_id + AND membership.user_id = NULLIF(current_setting('app.user_id', true), '')::uuid + AND membership.membership_id = NULLIF(current_setting('app.membership_id', true), '')::uuid + AND membership.membership_version = NULLIF(current_setting('app.membership_version', true), '')::bigint + AND membership.status = 'active' + AND membership.valid_from <= authority_now + AND (membership.valid_until IS NULL OR membership.valid_until > authority_now) + ) + THEN RETURN false; END IF; + INSERT INTO public.egress_grant ( + organization_id, grant_digest, digest_profile, hop_kind, + package_digest, payload_digest, purpose, audience_digest, + policy_epoch, retention_policy_ref, sensitivity_policy_ref, + issuer_ref, consumer_ref, provider_ref, model_ref, channel_ref, + destination_ref, region_ref, issued_at, expires_at, profile_ref, + grant_profile_ref + ) VALUES ( + requested_organization_id, requested_grant_digest, + requested_digest_profile, requested_hop_kind, + requested_package_digest, requested_payload_digest, + requested_purpose, requested_audience_digest, + requested_policy_epoch, requested_retention_policy_ref, + requested_sensitivity_policy_ref, requested_issuer_ref, + requested_consumer_ref, requested_provider_ref, + requested_model_ref, requested_channel_ref, + requested_destination_ref, requested_region_ref, + requested_issued_at, requested_expires_at, + requested_profile_ref, requested_grant_profile_ref + ) ON CONFLICT DO NOTHING; + IF NOT FOUND THEN RETURN false; END IF; + INSERT INTO public.egress_audit ( + organization_id, grant_digest, payload_digest, category, recorded_at + ) VALUES ( + requested_organization_id, requested_grant_digest, + requested_payload_digest, 'issued', authority_now + ); + RETURN true; + END; + $function$ + """ + ) + op.execute( + f""" + CREATE FUNCTION public.{_REDEEM}( + requested_organization_id uuid, requested_grant_digest bytea, + requested_digest_profile text, requested_hop_kind text, + requested_package_digest bytea, requested_payload_digest bytea, + requested_purpose text, requested_audience_digest bytea, + requested_policy_epoch bigint, requested_retention_policy_ref text, + requested_sensitivity_policy_ref text, requested_issuer_ref text, + requested_consumer_ref text, requested_provider_ref text, + requested_model_ref text, requested_channel_ref text, + requested_destination_ref text, requested_region_ref text, + requested_profile_ref text + ) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE authority_now timestamptz := pg_catalog.clock_timestamp(); + DECLARE stored_payload_digest bytea; + BEGIN + IF SESSION_USER <> '{_EGRESS}' THEN RETURN false; END IF; + UPDATE public.egress_grant AS grant_record + SET consumed_at = authority_now + WHERE grant_record.organization_id = requested_organization_id + AND grant_record.grant_digest = requested_grant_digest + AND grant_record.digest_profile = requested_digest_profile + AND grant_record.hop_kind = requested_hop_kind + AND grant_record.package_digest = requested_package_digest + AND grant_record.payload_digest = requested_payload_digest + AND grant_record.purpose = requested_purpose + AND grant_record.audience_digest = requested_audience_digest + AND grant_record.policy_epoch = requested_policy_epoch + AND grant_record.retention_policy_ref = requested_retention_policy_ref + AND grant_record.sensitivity_policy_ref = requested_sensitivity_policy_ref + AND grant_record.issuer_ref = requested_issuer_ref + AND grant_record.consumer_ref = requested_consumer_ref + AND grant_record.provider_ref IS NOT DISTINCT FROM requested_provider_ref + AND grant_record.model_ref IS NOT DISTINCT FROM requested_model_ref + AND grant_record.channel_ref IS NOT DISTINCT FROM requested_channel_ref + AND grant_record.destination_ref IS NOT DISTINCT FROM requested_destination_ref + AND grant_record.region_ref = requested_region_ref + AND grant_record.profile_ref = requested_profile_ref + AND grant_record.consumed_at IS NULL + AND grant_record.issued_at <= authority_now + AND authority_now < grant_record.expires_at + AND EXISTS ( + SELECT 1 FROM public.organization_policy_epoch AS epoch + WHERE epoch.organization_id = grant_record.organization_id + AND epoch.policy_epoch = grant_record.policy_epoch + ) + RETURNING grant_record.payload_digest INTO stored_payload_digest; + IF FOUND THEN + INSERT INTO public.egress_audit ( + organization_id, grant_digest, payload_digest, category, recorded_at + ) VALUES ( + requested_organization_id, requested_grant_digest, + stored_payload_digest, 'consumed', authority_now + ); + RETURN true; + END IF; + IF EXISTS ( + SELECT 1 FROM public.egress_grant AS grant_record + WHERE grant_record.organization_id = requested_organization_id + AND grant_record.grant_digest = requested_grant_digest + ) THEN + INSERT INTO public.egress_audit ( + organization_id, grant_digest, payload_digest, category, recorded_at + ) SELECT requested_organization_id, requested_grant_digest, + grant_record.payload_digest, 'not_available', authority_now + FROM public.egress_grant AS grant_record + WHERE grant_record.organization_id = requested_organization_id + AND grant_record.grant_digest = requested_grant_digest; + END IF; + RETURN false; + END; + $function$ + """ + ) + for function_name, signature in ( + (_ISSUE, _ISSUE_SIGNATURE), (_REDEEM, _REDEEM_SIGNATURE) + ): + op.execute(f"REVOKE ALL ON FUNCTION public.{function_name}{signature} FROM PUBLIC") + op.execute(f"GRANT CREATE ON SCHEMA public TO {_DEFINER}") + op.execute(f"ALTER FUNCTION public.{function_name}{signature} OWNER TO {_DEFINER}") + op.execute(f"REVOKE CREATE ON SCHEMA public FROM {_DEFINER}") + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"GRANT EXECUTE ON FUNCTION public.{_ISSUE}{_ISSUE_SIGNATURE} TO {_RUNTIME}") + op.execute(f"GRANT EXECUTE ON FUNCTION public.{_REDEEM}{_REDEEM_SIGNATURE} TO {_EGRESS}") + op.execute("RESET ROLE") + + +def downgrade() -> None: + """Refuse to erase retained egress audit/state rows.""" + + op.execute( + """ + DO $block$ BEGIN + IF EXISTS (SELECT 1 FROM public.egress_grant) + OR EXISTS (SELECT 1 FROM public.egress_audit) + THEN RAISE EXCEPTION USING ERRCODE = '55000', + MESSAGE = 'cannot downgrade with egress grant audit rows'; + END IF; + END; $block$ + """ + ) + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"DROP FUNCTION public.{_REDEEM}{_REDEEM_SIGNATURE}") + op.execute(f"DROP FUNCTION public.{_ISSUE}{_ISSUE_SIGNATURE}") + op.execute("RESET ROLE") + op.execute("DROP POLICY organization_policy_epoch_egress_definer_select ON organization_policy_epoch") + op.execute("DROP POLICY membership_egress_definer_select ON membership") + op.execute(f"REVOKE SELECT ON TABLE organization_policy_epoch FROM {_DEFINER}") + op.execute(f"REVOKE SELECT ON TABLE membership FROM {_DEFINER}") + op.drop_table("egress_audit") + op.drop_table("egress_grant") diff --git a/scripts/database_harness.sh b/scripts/database_harness.sh index d5bba29b..bd3e5107 100755 --- a/scripts/database_harness.sh +++ b/scripts/database_harness.sh @@ -42,6 +42,7 @@ generate_environment() { local migrator_password local control_password local identity_password + local egress_password local runtime_password local worker_password local learning_password @@ -52,6 +53,7 @@ generate_environment() { migrator_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" control_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" identity_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + egress_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" runtime_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" worker_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" learning_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" @@ -77,6 +79,8 @@ generate_environment() { printf 'CONTEXT_ENGINE_CONTROL_PASSWORD=%s\n' "$control_password" printf 'CONTEXT_ENGINE_IDENTITY_ROLE=context_engine_identity\n' printf 'CONTEXT_ENGINE_IDENTITY_PASSWORD=%s\n' "$identity_password" + printf 'CONTEXT_ENGINE_EGRESS_ROLE=context_engine_egress\n' + printf 'CONTEXT_ENGINE_EGRESS_PASSWORD=%s\n' "$egress_password" printf 'CONTEXT_ENGINE_RUNTIME_ROLE=context_engine_runtime\n' printf 'CONTEXT_ENGINE_RUNTIME_PASSWORD=%s\n' "$runtime_password" printf 'CONTEXT_ENGINE_WORKER_ROLE=context_engine_worker\n' @@ -92,6 +96,8 @@ generate_environment() { "$control_password" "$postgres_port" printf 'CONTEXT_ENGINE_IDENTITY_DATABASE_URL=postgresql+psycopg://context_engine_identity:%s@127.0.0.1:%s/context_engine\n' \ "$identity_password" "$postgres_port" + printf 'CONTEXT_ENGINE_EGRESS_DATABASE_URL=postgresql+psycopg://context_engine_egress:%s@127.0.0.1:%s/context_engine\n' \ + "$egress_password" "$postgres_port" printf 'CONTEXT_ENGINE_RUNTIME_DATABASE_URL=postgresql+psycopg://context_engine_runtime:%s@127.0.0.1:%s/context_engine\n' \ "$runtime_password" "$postgres_port" printf 'CONTEXT_ENGINE_WORKER_DATABASE_URL=postgresql+psycopg://context_engine_worker:%s@127.0.0.1:%s/context_engine\n' \ @@ -140,6 +146,7 @@ migrate_legacy_environment() { (migrate_legacy_project_identity) (migrate_legacy_control_identity) (migrate_legacy_identity_identity) + (migrate_legacy_egress_identity) (migrate_legacy_learning_identity) (migrate_legacy_security_operator_identity) rmdir "$ENV_MIGRATION_LOCK" @@ -256,6 +263,36 @@ migrate_legacy_identity_identity() { trap - EXIT } +migrate_legacy_egress_identity() { + if grep -q '^CONTEXT_ENGINE_EGRESS_ROLE=' "$ENV_FILE"; then + return + fi + local postgres_port + postgres_port="$(sed -n 's/^CONTEXT_ENGINE_POSTGRES_PORT=//p' "$ENV_FILE")" + if [[ ! "$postgres_port" =~ ^[0-9]+$ ]]; then + printf 'legacy database environment has no valid PostgreSQL port\n' >&2 + exit 1 + fi + local egress_password + egress_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + local migration_file + migration_file="$(mktemp "$STATE_DIR/database.env.egress.XXXXXX")" + trap 'rm -f "$migration_file"' EXIT + ( + umask 077 + while IFS= read -r environment_line || [[ -n "$environment_line" ]]; do + printf '%s\n' "$environment_line" + done <"$ENV_FILE" + printf 'CONTEXT_ENGINE_EGRESS_ROLE=context_engine_egress\n' + printf 'CONTEXT_ENGINE_EGRESS_PASSWORD=%s\n' "$egress_password" + printf 'CONTEXT_ENGINE_EGRESS_DATABASE_URL=postgresql+psycopg://context_engine_egress:%s@127.0.0.1:%s/context_engine\n' \ + "$egress_password" "$postgres_port" + ) >"$migration_file" + chmod 600 "$migration_file" + mv "$migration_file" "$ENV_FILE" + trap - EXIT +} + migrate_legacy_learning_identity() { local learning_role_count local learning_password_count @@ -348,7 +385,7 @@ load_environment() { local variable_name local variable_value local loaded_variable_names=' ' - local allowed_variables=' POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD CONTEXT_ENGINE_POSTGRES_PORT CONTEXT_ENGINE_COMPOSE_PROJECT CONTEXT_ENGINE_MIGRATOR_ROLE CONTEXT_ENGINE_MIGRATOR_PASSWORD CONTEXT_ENGINE_CONTROL_ROLE CONTEXT_ENGINE_CONTROL_PASSWORD CONTEXT_ENGINE_IDENTITY_ROLE CONTEXT_ENGINE_IDENTITY_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE CONTEXT_ENGINE_WORKER_PASSWORD CONTEXT_ENGINE_LEARNING_ROLE CONTEXT_ENGINE_LEARNING_PASSWORD CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD CONTEXT_ENGINE_MIGRATION_DATABASE_URL CONTEXT_ENGINE_CONTROL_DATABASE_URL CONTEXT_ENGINE_IDENTITY_DATABASE_URL CONTEXT_ENGINE_RUNTIME_DATABASE_URL CONTEXT_ENGINE_WORKER_DATABASE_URL CONTEXT_ENGINE_LEARNING_DATABASE_URL CONTEXT_ENGINE_SECURITY_OPERATOR_DATABASE_URL CONTEXT_ENGINE_TEST_DATABASE_URL ' + local allowed_variables=' POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD CONTEXT_ENGINE_POSTGRES_PORT CONTEXT_ENGINE_COMPOSE_PROJECT CONTEXT_ENGINE_MIGRATOR_ROLE CONTEXT_ENGINE_MIGRATOR_PASSWORD CONTEXT_ENGINE_CONTROL_ROLE CONTEXT_ENGINE_CONTROL_PASSWORD CONTEXT_ENGINE_IDENTITY_ROLE CONTEXT_ENGINE_IDENTITY_PASSWORD CONTEXT_ENGINE_EGRESS_ROLE CONTEXT_ENGINE_EGRESS_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE CONTEXT_ENGINE_WORKER_PASSWORD CONTEXT_ENGINE_LEARNING_ROLE CONTEXT_ENGINE_LEARNING_PASSWORD CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD CONTEXT_ENGINE_MIGRATION_DATABASE_URL CONTEXT_ENGINE_CONTROL_DATABASE_URL CONTEXT_ENGINE_IDENTITY_DATABASE_URL CONTEXT_ENGINE_EGRESS_DATABASE_URL CONTEXT_ENGINE_RUNTIME_DATABASE_URL CONTEXT_ENGINE_WORKER_DATABASE_URL CONTEXT_ENGINE_LEARNING_DATABASE_URL CONTEXT_ENGINE_SECURITY_OPERATOR_DATABASE_URL CONTEXT_ENGINE_TEST_DATABASE_URL ' while IFS='=' read -r variable_name variable_value; do if [[ -z "$variable_name" || "$allowed_variables" != *" $variable_name "* ]]; then @@ -379,6 +416,7 @@ load_environment() { "$CONTEXT_ENGINE_MIGRATOR_ROLE" != 'context_engine_migrator' || \ "$CONTEXT_ENGINE_CONTROL_ROLE" != 'context_engine_control' || \ "$CONTEXT_ENGINE_IDENTITY_ROLE" != 'context_engine_identity' || \ + "$CONTEXT_ENGINE_EGRESS_ROLE" != 'context_engine_egress' || \ "$CONTEXT_ENGINE_RUNTIME_ROLE" != 'context_engine_runtime' || \ "$CONTEXT_ENGINE_WORKER_ROLE" != 'context_engine_worker' || \ "$CONTEXT_ENGINE_LEARNING_ROLE" != 'context_engine_learning' || \ @@ -390,6 +428,7 @@ load_environment() { ! "$CONTEXT_ENGINE_MIGRATOR_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_CONTROL_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_IDENTITY_PASSWORD" =~ ^[0-9a-f]{64}$ || \ + ! "$CONTEXT_ENGINE_EGRESS_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_RUNTIME_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_WORKER_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_LEARNING_PASSWORD" =~ ^[0-9a-f]{64}$ || \ @@ -406,6 +445,8 @@ load_environment() { "postgresql+psycopg://context_engine_control:$CONTEXT_ENGINE_CONTROL_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_IDENTITY_DATABASE_URL" != \ "postgresql+psycopg://context_engine_identity:$CONTEXT_ENGINE_IDENTITY_PASSWORD@$database_endpoint" || \ + "$CONTEXT_ENGINE_EGRESS_DATABASE_URL" != \ + "postgresql+psycopg://context_engine_egress:$CONTEXT_ENGINE_EGRESS_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_RUNTIME_DATABASE_URL" != \ "postgresql+psycopg://context_engine_runtime:$CONTEXT_ENGINE_RUNTIME_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_WORKER_DATABASE_URL" != \ diff --git a/scripts/provision_database_roles.py b/scripts/provision_database_roles.py index c9ea4645..429b543b 100644 --- a/scripts/provision_database_roles.py +++ b/scripts/provision_database_roles.py @@ -18,6 +18,8 @@ CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, + EGRESS_GRANT_DEFINER_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, MIGRATOR_ROLE, @@ -42,6 +44,8 @@ class RoleProvisioningContract: control_password: str identity_role: str identity_password: str + egress_role: str + egress_password: str learning_role: str learning_password: str security_operator_role: str @@ -51,6 +55,7 @@ class RoleProvisioningContract: context_run_reader_definer_role: str release_definer_role: str delivery_evidence_definer_role: str + egress_grant_definer_role: str def __post_init__(self) -> None: for field_name in ( @@ -59,6 +64,7 @@ def __post_init__(self) -> None: "migrator_role", "control_role", "identity_role", + "egress_role", "learning_role", "security_operator_role", "definer_role", @@ -66,6 +72,7 @@ def __post_init__(self) -> None: "context_run_reader_definer_role", "release_definer_role", "delivery_evidence_definer_role", + "egress_grant_definer_role", ): value = getattr(self, field_name) if type(value) is not str or not value or value.isspace(): @@ -74,6 +81,7 @@ def __post_init__(self) -> None: self.migrator_role, self.control_role, self.identity_role, + self.egress_role, self.learning_role, self.security_operator_role, self.definer_role, @@ -81,8 +89,9 @@ def __post_init__(self) -> None: self.context_run_reader_definer_role, self.release_definer_role, self.delivery_evidence_definer_role, + self.egress_grant_definer_role, } - if len(security_roles) != 10: + if len(security_roles) != 12: raise ValueError("provisioned database roles must be distinct") if type(self.postgres_port) is not int or not 1 <= self.postgres_port <= 65535: raise ValueError("postgres_port must be a valid TCP port") @@ -90,6 +99,7 @@ def __post_init__(self) -> None: "bootstrap_password", "control_password", "identity_password", + "egress_password", "learning_password", "security_operator_password", ): @@ -111,6 +121,8 @@ def _contract_from_environment( "CONTEXT_ENGINE_CONTROL_PASSWORD", "CONTEXT_ENGINE_IDENTITY_ROLE", "CONTEXT_ENGINE_IDENTITY_PASSWORD", + "CONTEXT_ENGINE_EGRESS_ROLE", + "CONTEXT_ENGINE_EGRESS_PASSWORD", "CONTEXT_ENGINE_LEARNING_ROLE", "CONTEXT_ENGINE_LEARNING_PASSWORD", "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE", @@ -132,6 +144,8 @@ def _contract_from_environment( raise ValueError("database role provisioning has an invalid control role") if environment["CONTEXT_ENGINE_IDENTITY_ROLE"] != IDENTITY_ROLE: raise ValueError("database role provisioning has an invalid identity role") + if environment["CONTEXT_ENGINE_EGRESS_ROLE"] != EGRESS_ROLE: + raise ValueError("database role provisioning has an invalid egress role") if environment["CONTEXT_ENGINE_LEARNING_ROLE"] != LEARNING_ROLE: raise ValueError("database role provisioning has an invalid learning role") if environment["CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE"] != OPERATOR_ROLE: @@ -152,6 +166,8 @@ def _contract_from_environment( control_password=environment["CONTEXT_ENGINE_CONTROL_PASSWORD"], identity_role=environment["CONTEXT_ENGINE_IDENTITY_ROLE"], identity_password=environment["CONTEXT_ENGINE_IDENTITY_PASSWORD"], + egress_role=environment["CONTEXT_ENGINE_EGRESS_ROLE"], + egress_password=environment["CONTEXT_ENGINE_EGRESS_PASSWORD"], learning_role=environment["CONTEXT_ENGINE_LEARNING_ROLE"], learning_password=environment["CONTEXT_ENGINE_LEARNING_PASSWORD"], security_operator_role=environment["CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE"], @@ -163,6 +179,7 @@ def _contract_from_environment( context_run_reader_definer_role=CONTEXT_RUN_READER_DEFINER_ROLE, release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, + egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, ) @@ -270,6 +287,7 @@ def provision_security_roles( _require_bootstrap_authority(connection, contract) _create_role_if_missing(connection, contract.control_role) _create_role_if_missing(connection, contract.identity_role) + _create_role_if_missing(connection, contract.egress_role) _create_role_if_missing(connection, contract.learning_role) _create_role_if_missing(connection, contract.security_operator_role) _create_role_if_missing(connection, contract.definer_role) @@ -277,6 +295,7 @@ def provision_security_roles( _create_role_if_missing(connection, contract.context_run_reader_definer_role) _create_role_if_missing(connection, contract.release_definer_role) _create_role_if_missing(connection, contract.delivery_evidence_definer_role) + _create_role_if_missing(connection, contract.egress_grant_definer_role) connection.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public") extension = connection.execute( @@ -302,6 +321,15 @@ def provision_security_roles( sql.Literal(contract.identity_password), ) ) + connection.execute( + sql.SQL( + "ALTER ROLE {} WITH LOGIN PASSWORD {} NOSUPERUSER NOCREATEDB " + "NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS" + ).format( + sql.Identifier(contract.egress_role), + sql.Literal(contract.egress_password), + ) + ) connection.execute( sql.SQL( "ALTER ROLE {} WITH LOGIN PASSWORD {} NOSUPERUSER NOCREATEDB " @@ -335,6 +363,12 @@ def provision_security_roles( "NOINHERIT NOREPLICATION NOBYPASSRLS" ).format(sql.Identifier(contract.delivery_evidence_definer_role)) ) + connection.execute( + sql.SQL( + "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + "NOINHERIT NOREPLICATION NOBYPASSRLS" + ).format(sql.Identifier(contract.egress_grant_definer_role)) + ) connection.execute( sql.SQL( "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " @@ -362,6 +396,7 @@ def provision_security_roles( _revoke_roles_granted_to(connection, contract.control_role) _revoke_roles_granted_to(connection, contract.identity_role) + _revoke_roles_granted_to(connection, contract.egress_role) _revoke_roles_granted_to(connection, contract.learning_role) _revoke_roles_granted_to(connection, contract.security_operator_role) _revoke_roles_granted_to(connection, contract.definer_role) @@ -369,8 +404,10 @@ def provision_security_roles( _revoke_roles_granted_to(connection, contract.context_run_reader_definer_role) _revoke_roles_granted_to(connection, contract.release_definer_role) _revoke_roles_granted_to(connection, contract.delivery_evidence_definer_role) + _revoke_roles_granted_to(connection, contract.egress_grant_definer_role) _revoke_members_of(connection, contract.control_role) _revoke_members_of(connection, contract.identity_role) + _revoke_members_of(connection, contract.egress_role) _revoke_members_of(connection, contract.learning_role) _revoke_members_of(connection, contract.security_operator_role) _revoke_members_of(connection, contract.definer_role) @@ -378,12 +415,19 @@ def provision_security_roles( _revoke_members_of(connection, contract.context_run_reader_definer_role) _revoke_members_of(connection, contract.release_definer_role) _revoke_members_of(connection, contract.delivery_evidence_definer_role) + _revoke_members_of(connection, contract.egress_grant_definer_role) connection.execute( sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( sql.Identifier(contract.definer_role), sql.Identifier(contract.migrator_role), ) ) + connection.execute( + sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( + sql.Identifier(contract.egress_grant_definer_role), + sql.Identifier(contract.migrator_role), + ) + ) connection.execute( sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( sql.Identifier(contract.context_run_reader_definer_role), @@ -412,6 +456,7 @@ def provision_security_roles( for role_name in ( contract.control_role, contract.identity_role, + contract.egress_role, contract.learning_role, contract.security_operator_role, contract.definer_role, @@ -419,6 +464,7 @@ def provision_security_roles( contract.context_run_reader_definer_role, contract.release_definer_role, contract.delivery_evidence_definer_role, + contract.egress_grant_definer_role, ): connection.execute( sql.SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM {}").format( @@ -442,6 +488,12 @@ def provision_security_roles( sql.Identifier(contract.identity_role), ) ) + connection.execute( + sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format( + sql.Identifier(contract.database_name), + sql.Identifier(contract.egress_role), + ) + ) connection.execute( sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format( sql.Identifier(contract.database_name), diff --git a/scripts/security_gate/rls.py b/scripts/security_gate/rls.py index 21ee74f4..a868e3bb 100644 --- a/scripts/security_gate/rls.py +++ b/scripts/security_gate/rls.py @@ -18,6 +18,8 @@ NON_OWNER_EVIDENCE_BY_TABLE: Mapping[str, str] = { "context_source": "PG-FILE-SOURCE-RLS-021", "delivery_evidence": "PG-DELIVERY-EVIDENCE-063", + "egress_grant": "PG-EGRESS-011", + "egress_audit": "PG-EGRESS-011", "membership": "PG-SCOPE-INTERSECTION-004", "organization_record": "PG-TENANT-FK-002", "context_resource": "PG-INDEX-NOT-AUTHORITY-005", diff --git a/scripts/security_gate/runner.py b/scripts/security_gate/runner.py index 1fcb2044..b4050d67 100644 --- a/scripts/security_gate/runner.py +++ b/scripts/security_gate/runner.py @@ -72,6 +72,8 @@ "CONTEXT_ENGINE_CONTROL_PASSWORD", "CONTEXT_ENGINE_IDENTITY_ROLE", "CONTEXT_ENGINE_IDENTITY_PASSWORD", + "CONTEXT_ENGINE_EGRESS_ROLE", + "CONTEXT_ENGINE_EGRESS_PASSWORD", "CONTEXT_ENGINE_RUNTIME_ROLE", "CONTEXT_ENGINE_RUNTIME_PASSWORD", "CONTEXT_ENGINE_WORKER_ROLE", @@ -83,6 +85,7 @@ "CONTEXT_ENGINE_MIGRATION_DATABASE_URL", "CONTEXT_ENGINE_CONTROL_DATABASE_URL", "CONTEXT_ENGINE_IDENTITY_DATABASE_URL", + "CONTEXT_ENGINE_EGRESS_DATABASE_URL", "CONTEXT_ENGINE_RUNTIME_DATABASE_URL", "CONTEXT_ENGINE_WORKER_DATABASE_URL", "CONTEXT_ENGINE_LEARNING_DATABASE_URL", diff --git a/scripts/validate_security_catalog.py b/scripts/validate_security_catalog.py index 946229f0..901fb109 100644 --- a/scripts/validate_security_catalog.py +++ b/scripts/validate_security_catalog.py @@ -32,7 +32,7 @@ DEFAULT_EXECUTION_REGISTRY_SCHEMA_PATH = ( REPOSITORY_ROOT / "eval/catalogs/m0-security-evidence.schema.json" ) -SUPPORTED_CATALOG_VERSION = "1.2.0" +SUPPORTED_CATALOG_VERSION = "1.3.0" SUPPORTED_EXECUTION_REGISTRY_VERSION = "1.0.0" EXPECTED_INVARIANT_COUNT = 15 EXPECTED_FIXTURE_COUNT = 12 @@ -1039,15 +1039,95 @@ "notActive": [ "group delivery", "AudienceSnapshot", - "EgressGrant", - "ModelGateway", + "production ModelGateway", "ActionPlane", - "BotDelivery", + "BotDelivery application", "OpenAPI compatibility freeze", "generated SDK", ], } +CANONICAL_EGRESS_GRANT_ACTIVATION: dict[str, object] = { + "issueRef": "#65", + "invariantRef": "EGRESS-011", + "carrier": ( + "opaque one-shot model or channel EgressGrant with deterministic " + "boundary spies" + ), + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": ( + "ContextPackage -> final EgressGate -> digest-only PostgreSQL grant -> " + "AuthorizedModelInput | AuthorizedChannelPayload -> exact one-shot " + "redemption -> ModelGateway | Sender preflight spy" + ), + "testEvidence": [ + { + "id": "PROP-EGRESS-011", + "surface": ( + "tests/unit/test_egress_grant.py::" + "test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects" + ), + "oracle": ( + "Every common and hop-specific model or channel redemption " + "binding mutation, plus cross-kind use, is non-enumerating and " + "emits zero gateway bytes, Sender preflight bytes, or effects." + ), + }, + { + "id": "PG-EGRESS-011", + "surface": ( + "tests/integration/test_egress_grant.py::" + "test_digest_only_grant_is_atomic_one_shot_and_audited" + ), + "oracle": ( + "Real PostgreSQL under separate non-owner Runtime and egress " + "roles stores only grant and payload digests, binds the exact " + "Package, Organization, purpose, audience, Policy Epoch, hop, " + "retention, sensitivity, issuer, consumer, provider/model or " + "channel/destination, region, lifetime, and profile, consumes " + "atomically once, and retains only restricted issued, consumed, " + "or not-available audit categories." + ), + }, + { + "id": "RUNTIME-EGRESS-011", + "surface": ( + "tests/integration/test_z_egress_grant_file.py::" + "test_file_http_package_redeems_exact_model_grant_before_gateway_" + "bytes" + ), + "oracle": ( + "A real File-backed authenticated HTTP Acquire proves " + "CandidateRef through the sealed AuthorizationKernel to an " + "audience-bound ContextPackage, returns one model grant only " + "after final policy, and the independent egress role permits " + "exactly one deterministic ModelGateway spy request; replay " + "emits zero additional model bytes." + ), + }, + ], + "deferredEvidence": [ + "production provider ModelGateway conformance", + "production Sender and ActionPlane effect conformance", + "group AudienceSnapshot send-time revalidation", + ], + "futureCarriers": [ + "production ModelGateway", + "production Sender preflight", + "ActionPlane prepare and perform", + "group-public and asker-private delivery", + ], + "notActive": [ + "real model or provider network call", + "real Sender or channel write", + "ActionTicket or external effect", + "group AudienceSnapshot", + "BotDelivery application process", + "generated SDK consumer", + ], +} + CANONICAL_ACTIVATIONS: list[dict[str, object]] = [ CANONICAL_REVOCATION_ACTIVATION, CANONICAL_UNAVAILABLE_CAPABILITY_ACTIVATION, @@ -1056,6 +1136,7 @@ CANONICAL_CONTEXT_RUN_ACTIVATION, CANONICAL_FIELD_PROJECTION_ACTIVATION, CANONICAL_PRIVATE_DELIVERY_EVIDENCE_ACTIVATION, + CANONICAL_EGRESS_GRANT_ACTIVATION, ] CANONICAL_ACTIVATION_ISSUE_LIST = ", ".join( f"Issue {activation['issueRef']}" for activation in CANONICAL_ACTIVATIONS diff --git a/scripts/wait_for_database.py b/scripts/wait_for_database.py index 1b5958ce..cefa3cbc 100644 --- a/scripts/wait_for_database.py +++ b/scripts/wait_for_database.py @@ -16,7 +16,11 @@ create_database_engine, load_harness_database_configurations, ) -from engine.persistence.role_guard import assert_identity_role, assert_learning_role +from engine.persistence.role_guard import ( + assert_egress_role, + assert_identity_role, + assert_learning_role, +) def wait_for_database(timeout_seconds: float) -> None: @@ -32,6 +36,7 @@ def wait_for_database(timeout_seconds: float) -> None: configurations.migration, configurations.control, configurations.identity, + configurations.egress, configurations.runtime, configurations.worker, configurations.learning, @@ -54,6 +59,8 @@ def wait_for_database(timeout_seconds: float) -> None: assert_learning_role(connection) if configuration.purpose is DatabasePurpose.TRUSTED_IDENTITY: assert_identity_role(connection) + if configuration.purpose is DatabasePurpose.TRUSTED_EGRESS: + assert_egress_role(connection) return except SQLAlchemyError as error: last_error = error @@ -72,7 +79,8 @@ def main(argv: Sequence[str] | None = None) -> int: arguments = parser.parse_args(argv) wait_for_database(arguments.timeout) purpose_names = ( - "migration, control, identity, runtime, worker, learning, security-operator, " + "migration, control, identity, egress, runtime, worker, learning, " + "security-operator, " "security-test" ) print("PostgreSQL harness ready: " + purpose_names) diff --git a/tests/catalog/test_m0_security_gate.py b/tests/catalog/test_m0_security_gate.py index e4fba287..ff4a8dc6 100644 --- a/tests/catalog/test_m0_security_gate.py +++ b/tests/catalog/test_m0_security_gate.py @@ -23,6 +23,7 @@ reconcile_execution, ) from scripts.security_gate.runner import ( + _ALLOWED_DATABASE_ENVIRONMENT_KEYS, DatabaseEnvironmentError, GatePaths, GateRunError, @@ -42,6 +43,14 @@ ) +def test_security_gate_database_contract_includes_the_egress_role() -> None: + assert { + "CONTEXT_ENGINE_EGRESS_ROLE", + "CONTEXT_ENGINE_EGRESS_PASSWORD", + "CONTEXT_ENGINE_EGRESS_DATABASE_URL", + } <= _ALLOWED_DATABASE_ENVIRONMENT_KEYS + + def complete_provenance(*, commit: str = "a" * 40) -> dict[str, object]: digest = "b" * 64 return { diff --git a/tests/catalog/test_validate_m0_security_evidence.py b/tests/catalog/test_validate_m0_security_evidence.py index 59718752..ac0ddeff 100644 --- a/tests/catalog/test_validate_m0_security_evidence.py +++ b/tests/catalog/test_validate_m0_security_evidence.py @@ -122,16 +122,27 @@ def test_planned_catalog_evidence_is_separate_from_executable_refs() -> None: } -def test_m0_registry_uses_honest_unavailable_carrier_and_learning_evidence() -> None: +def test_m0_registry_uses_activated_egress_and_honest_learning_evidence() -> None: registry, _, _ = _documents() evidence = {entry["id"]: entry["selector"] for entry in registry["evidence"]} unavailable_carrier = ( "tests/integration/test_m0_unavailable_security_carriers.py::" - "test_m0_unavailable_citation_and_egress_carriers_fail_closed" + "test_unavailable_citation_and_real_provider_carriers_fail_closed" ) assert evidence["PG-CITATION-AUTH-010"] == unavailable_carrier - assert evidence["PG-EGRESS-011"] == unavailable_carrier + assert evidence["PROP-EGRESS-011"] == ( + "tests/unit/test_egress_grant.py::" + "test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects" + ) + assert evidence["PG-EGRESS-011"] == ( + "tests/integration/test_egress_grant.py::" + "test_digest_only_grant_is_atomic_one_shot_and_audited" + ) + assert evidence["RUNTIME-EGRESS-011"] == ( + "tests/integration/test_z_egress_grant_file.py::" + "test_file_http_package_redeems_exact_model_grant_before_gateway_bytes" + ) assert evidence["PROP-CROSS-ORG-LEARN-015"] == ( "tests/unit/test_m0_learning_isolation.py::" "test_m0_learning_artifact_contract_has_no_cross_organization_carrier" diff --git a/tests/catalog/test_validate_security_catalog.py b/tests/catalog/test_validate_security_catalog.py index 4843da4d..50b4294b 100644 --- a/tests/catalog/test_validate_security_catalog.py +++ b/tests/catalog/test_validate_security_catalog.py @@ -23,6 +23,7 @@ AUDIENCE_ACTION_CASE_IDS, CANONICAL_ACTIVATION_ISSUE_LIST, CANONICAL_CONTEXT_RUN_ACTIVATION, + CANONICAL_EGRESS_GRANT_ACTIVATION, CANONICAL_FAIL_CLOSED_OUTCOMES, CANONICAL_FIELD_PROJECTION_ACTIVATION, CANONICAL_INVARIANT_IDS, @@ -496,7 +497,7 @@ def make_catalog() -> dict[str, object]: ) return { - "catalogVersion": "1.2.0", + "catalogVersion": "1.3.0", "authority": { "issueRefs": ["#5"], "documentRefs": ["docs/security/context-engine-threat-model.md"], @@ -514,6 +515,7 @@ def make_catalog() -> dict[str, object]: copy.deepcopy(CANONICAL_CONTEXT_RUN_ACTIVATION), copy.deepcopy(CANONICAL_FIELD_PROJECTION_ACTIVATION), copy.deepcopy(CANONICAL_PRIVATE_DELIVERY_EVIDENCE_ACTIVATION), + copy.deepcopy(CANONICAL_EGRESS_GRANT_ACTIVATION), ], "invariants": invariants, "fixtures": fixtures, @@ -561,7 +563,7 @@ def make_schema() -> dict[str, object]: "fixtures", ], "properties": { - "catalogVersion": {"const": "1.2.0"}, + "catalogVersion": {"const": "1.3.0"}, "authority": {"type": "object"}, "hardOracles": { "type": "array", @@ -584,8 +586,8 @@ def make_schema() -> dict[str, object]: }, "activations": { "type": "array", - "minItems": 7, - "maxItems": 7, + "minItems": 8, + "maxItems": 8, "uniqueItems": True, "prefixItems": [ {"const": copy.deepcopy(CANONICAL_REVOCATION_ACTIVATION)}, @@ -603,6 +605,7 @@ def make_schema() -> dict[str, object]: CANONICAL_PRIVATE_DELIVERY_EVIDENCE_ACTIVATION ) }, + {"const": copy.deepcopy(CANONICAL_EGRESS_GRANT_ACTIVATION)}, ], "items": False, }, @@ -863,7 +866,7 @@ def test_catalog_version_is_frozen(self) -> None: self.assert_catalog_error( catalog, - "catalogVersion: must be the supported version '1.2.0'", + "catalogVersion: must be the supported version '1.3.0'", schema, ) @@ -1657,9 +1660,10 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( assert isinstance(document_refs, list) assert isinstance(reconciliation, str) - self.assertEqual(catalog["catalogVersion"], "1.2.0") + self.assertEqual(catalog["catalogVersion"], "1.3.0") self.assertEqual( - issue_refs[-7:], ["#15", "#16", "#17", "#18", "#19", "#48", "#63"] + issue_refs[-8:], + ["#15", "#16", "#17", "#18", "#19", "#48", "#63", "#65"], ) self.assertIn( "docs/decisions/0031-persist-authorized-context-run-lineage.md", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3237fb86..f5f60d95 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -53,6 +53,13 @@ def identity_configuration( return database_configurations.identity +@pytest.fixture(scope="session") +def egress_configuration( + database_configurations: HarnessDatabaseConfigurations, +) -> DatabaseConfiguration: + return database_configurations.egress + + @pytest.fixture(scope="session") def migration_configuration( database_configurations: HarnessDatabaseConfigurations, diff --git a/tests/integration/test_egress_grant.py b/tests/integration/test_egress_grant.py new file mode 100644 index 00000000..bfa65ffe --- /dev/null +++ b/tests/integration/test_egress_grant.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from uuid import uuid4 + +import pytest +from sqlalchemy import Engine, text + +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLEgressGrantRedemptionAuthority, + create_database_engine, +) +from engine.persistence.membership_context import ( + MembershipIdentity, + PostgreSQLMembershipAuthority, +) +from engine.runtime.egress import ( + ChannelEgressGrant, + ChannelEgressProfile, + EgressGrantIssue, + EgressGrantRedemption, + ModelEgressGrant, + ModelEgressProfile, + issue_egress_grant, +) + +pytestmark = pytest.mark.integration + + +def _profile() -> ModelEgressProfile: + return ModelEgressProfile( + profile_ref="model-egress-integration-v1", + retention_policy_ref="no-provider-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-integration", + consumer_ref="model-gateway-integration", + provider_ref="provider-integration", + model_ref="model-integration", + region_ref="region-integration", + maximum_ttl=timedelta(minutes=2), + ) + + +def _channel_profile() -> ChannelEgressProfile: + return ChannelEgressProfile( + profile_ref="channel-egress-integration-v1", + retention_policy_ref="no-channel-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-integration", + consumer_ref="sender-preflight-integration", + channel_ref="channel-integration", + destination_ref="destination-integration", + region_ref="region-integration", + maximum_ttl=timedelta(minutes=2), + ) + + +@pytest.mark.security_evidence(id="PG-EGRESS-011", layer="postgres") +def test_digest_only_grant_is_atomic_one_shot_and_audited( + guarded_runtime_engine: Engine, + control_configuration: DatabaseConfiguration, + identity_configuration: DatabaseConfiguration, + egress_configuration: DatabaseConfiguration, + runtime_configuration: DatabaseConfiguration, + worker_configuration: DatabaseConfiguration, + learning_configuration: DatabaseConfiguration, + operator_configuration: DatabaseConfiguration, + migration_configuration: DatabaseConfiguration, +) -> None: + now = datetime.now(UTC).replace(microsecond=0) + organization_id, user_id, membership_id = uuid4(), uuid4(), uuid4() + other_organization_id, other_user_id, other_membership_id = ( + uuid4(), + uuid4(), + uuid4(), + ) + package_digest = "1" * 64 + payload_digest = "2" * 64 + audience_digest = "3" * 64 + bearer = "egrm_" + "4" * 64 + stale_bearer = "egrm_" + "5" * 64 + expired_bearer = "egrm_" + "6" * 64 + channel_bearer = "egrc_" + "7" * 64 + other_bearer = "egrm_" + "9" * 64 + migration_engine = create_database_engine(migration_configuration) + try: + with migration_engine.begin() as connection: + application_roles = ( + control_configuration.expected_role, + identity_configuration.expected_role, + egress_configuration.expected_role, + runtime_configuration.expected_role, + worker_configuration.expected_role, + learning_configuration.expected_role, + operator_configuration.expected_role, + ) + role_privileges = { + role: tuple( + connection.execute( + text( + "SELECT " + "has_table_privilege(:role, 'egress_grant', 'SELECT'), " + "has_table_privilege(:role, 'egress_grant', 'UPDATE'), " + "has_table_privilege(:role, 'egress_audit', 'SELECT'), " + "has_function_privilege(:role, " + "'context_runtime_issue_egress_grant(uuid,bytea,text," + "text,bytea,bytea,text,bytea,bigint,text,text,text,text," + "text,text,text,text,text,timestamptz,timestamptz,text," + "text,text)', 'EXECUTE'), " + "has_function_privilege(:role, " + "'context_egress_redeem_grant(uuid,bytea,text,text," + "bytea,bytea,text,bytea,bigint,text,text,text,text,text," + "text,text,text,text,text)', 'EXECUTE')" + ), + {"role": role}, + ).one() + ) + for role in application_roles + } + assert role_privileges[runtime_configuration.expected_role] == ( + False, + False, + False, + True, + False, + ) + assert role_privileges[egress_configuration.expected_role] == ( + False, + False, + False, + False, + True, + ) + for role in set(application_roles) - { + runtime_configuration.expected_role, + egress_configuration.expected_role, + }: + assert role_privileges[role] == (False,) * 5 + assert connection.execute( + text( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid = 'egress_audit'::regclass " + "AND conname = 'pk_egress_audit'" + ) + ).scalar_one() == "PRIMARY KEY (organization_id, audit_id)" + + connection.execute( + text( + "INSERT INTO organization (organization_id) " + "VALUES (:org), (:other_org)" + ), + {"org": organization_id, "other_org": other_organization_id}, + ) + connection.execute( + text( + "INSERT INTO user_account (user_id) " + "VALUES (:user), (:other_user)" + ), + {"user": user_id, "other_user": other_user_id}, + ) + connection.execute( + text( + "INSERT INTO membership (organization_id, membership_id, " + "user_id, status, membership_version, valid_from) VALUES " + "(:org, :membership, :user, 'active', 1, :valid_from)" + ), + { + "org": organization_id, + "membership": membership_id, + "user": user_id, + "valid_from": now - timedelta(minutes=1), + }, + ) + connection.execute( + text( + "INSERT INTO membership (organization_id, membership_id, " + "user_id, status, membership_version, valid_from) VALUES " + "(:org, :membership, :user, 'active', 1, :valid_from)" + ), + { + "org": other_organization_id, + "membership": other_membership_id, + "user": other_user_id, + "valid_from": now - timedelta(minutes=1), + }, + ) + + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + identity = MembershipIdentity( + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + membership_version=1, + principal_ref="principal:egress-integration", + request_id="request:egress-integration", + authentication_binding_ref="binding:egress-integration", + checked_at=now, + ) + issue = EgressGrantIssue.for_model( + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose="context.answer", + audience_digest=audience_digest, + policy_epoch=1, + issued_at=now, + expires_at=now + timedelta(minutes=1), + profile=_profile(), + ) + with authority.current_user_actor(identity) as actor: + session = actor.egress_grant_issuance_session + assert session is not None + grant = issue_egress_grant( + session, + issue, + reference_factory=lambda _prefix: bearer, + ) + stale_grant = issue_egress_grant( + session, + issue, + reference_factory=lambda _prefix: stale_bearer, + ) + expired_grant = issue_egress_grant( + session, + EgressGrantIssue.for_model( + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose="context.answer", + audience_digest=audience_digest, + policy_epoch=1, + issued_at=now - timedelta(minutes=1), + expires_at=now + timedelta(seconds=30), + profile=_profile(), + ), + reference_factory=lambda _prefix: expired_bearer, + ) + channel_grant = issue_egress_grant( + session, + EgressGrantIssue.for_channel( + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose="context.answer", + audience_digest=audience_digest, + policy_epoch=1, + issued_at=now, + expires_at=now + timedelta(minutes=1), + profile=_channel_profile(), + ), + reference_factory=lambda _prefix: channel_bearer, + ) + assert type(grant) is ModelEgressGrant + assert type(stale_grant) is ModelEgressGrant + assert type(expired_grant) is ModelEgressGrant + assert type(channel_grant) is ChannelEgressGrant + other_identity = MembershipIdentity( + organization_id=other_organization_id, + user_id=other_user_id, + membership_id=other_membership_id, + membership_version=1, + principal_ref="principal:other-egress-integration", + request_id="request:other-egress-integration", + authentication_binding_ref="binding:other-egress-integration", + checked_at=now, + ) + with authority.current_user_actor(other_identity) as actor: + other_session = actor.egress_grant_issuance_session + assert other_session is not None + other_grant = issue_egress_grant( + other_session, + replace(issue, organization_id=other_organization_id), + reference_factory=lambda _prefix: other_bearer, + ) + assert type(other_grant) is ModelEgressGrant + + redemption = EgressGrantRedemption.for_model( + grant=grant, + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose="context.answer", + audience_digest=audience_digest, + policy_epoch=1, + profile=_profile(), + ) + egress_engine = create_database_engine(egress_configuration) + try: + consumer = PostgreSQLEgressGrantRedemptionAuthority(egress_engine) + mutations = ( + replace( + redemption, + grant_digest=ModelEgressGrant("egrm_" + "8" * 64).digest, + ), + replace(redemption, organization_id=other_organization_id), + replace(redemption, package_digest="a" * 64), + replace(redemption, payload_digest="b" * 64), + replace(redemption, purpose="citation.open"), + replace(redemption, audience_digest="c" * 64), + replace(redemption, policy_epoch=2), + replace(redemption, retention_policy_ref="retention-wrong"), + replace(redemption, sensitivity_policy_ref="sensitivity-wrong"), + replace(redemption, issuer_ref="issuer-wrong"), + replace(redemption, consumer_ref="consumer-wrong"), + replace(redemption, provider_ref="provider-wrong"), + replace(redemption, model_ref="model-wrong"), + replace(redemption, region_ref="region-wrong"), + replace(redemption, profile_ref="profile-wrong"), + replace( + redemption, + hop_kind="channel", + provider_ref=None, + model_ref=None, + channel_ref="channel-integration", + destination_ref="destination-integration", + ), + ) + assert all(consumer.redeem(mutation) is False for mutation in mutations) + + with ThreadPoolExecutor(max_workers=8) as executor: + winners = tuple(executor.map(consumer.redeem, (redemption,) * 16)) + assert Counter(winners) == Counter({True: 1, False: 15}) + assert consumer.redeem(redemption) is False + + channel_redemption = EgressGrantRedemption.for_channel( + grant=channel_grant, + organization_id=organization_id, + package_digest=package_digest, + payload_digest=payload_digest, + purpose="context.answer", + audience_digest=audience_digest, + policy_epoch=1, + profile=_channel_profile(), + ) + assert consumer.redeem( + replace(channel_redemption, channel_ref="channel-wrong") + ) is False + assert consumer.redeem( + replace(channel_redemption, destination_ref="destination-wrong") + ) is False + assert consumer.redeem(channel_redemption) is True + + other_redemption = replace( + redemption, + grant_digest=other_grant.digest, + organization_id=other_organization_id, + ) + assert consumer.redeem(other_redemption) is True + + with migration_engine.begin() as connection: + connection.execute( + text( + "UPDATE egress_grant SET expires_at = " + "clock_timestamp() - interval '30 seconds' " + "WHERE organization_id = :org AND grant_digest = :digest" + ), + { + "org": organization_id, + "digest": expired_grant.digest, + }, + ) + expired_redemption = replace( + redemption, + grant_digest=expired_grant.digest, + ) + assert consumer.redeem(expired_redemption) is False + + with migration_engine.begin() as connection: + connection.execute( + text( + "UPDATE organization_policy_epoch SET policy_epoch = 2 " + "WHERE organization_id = :org" + ), + {"org": organization_id}, + ) + stale_redemption = replace( + redemption, + grant_digest=stale_grant.digest, + ) + assert consumer.redeem(stale_redemption) is False + finally: + egress_engine.dispose() + + with migration_engine.connect() as connection: + state = connection.execute( + text( + "SELECT grant_digest, payload_digest, consumed_at " + "FROM egress_grant WHERE organization_id = :org " + "AND grant_digest = :digest" + ), + {"org": organization_id, "digest": grant.digest}, + ).one() + audit = connection.execute( + text( + "SELECT grant_digest, payload_digest, category " + "FROM egress_audit WHERE organization_id = :org " + "ORDER BY recorded_at" + ), + {"org": organization_id}, + ).all() + expected_digest = sha256(bearer.encode("utf-8")).digest() + assert bytes(state.grant_digest) == expected_digest + assert bytes(state.payload_digest) == bytes.fromhex(payload_digest) + assert state.consumed_at is not None + categories = Counter(row.category for row in audit) + assert categories["issued"] == 4 + assert categories["consumed"] == 2 + assert categories["not_available"] >= 34 + assert set(categories) == {"issued", "consumed", "not_available"} + assert bearer not in repr(state) + assert bearer not in repr(audit) + assert stale_bearer not in repr(audit) + assert expired_bearer not in repr(audit) + assert channel_bearer not in repr(audit) + assert other_bearer not in repr(audit) + finally: + with migration_engine.begin() as connection: + connection.execute( + text("DELETE FROM egress_audit WHERE organization_id = :org"), + {"org": organization_id}, + ) + connection.execute( + text("DELETE FROM egress_grant WHERE organization_id = :org"), + {"org": organization_id}, + ) + connection.execute( + text( + "DELETE FROM egress_audit WHERE organization_id = :other_org" + ), + {"other_org": other_organization_id}, + ) + connection.execute( + text( + "DELETE FROM egress_grant WHERE organization_id = :other_org" + ), + {"other_org": other_organization_id}, + ) + connection.execute( + text( + "DELETE FROM membership WHERE organization_id IN (:org, :other_org)" + ), + {"org": organization_id, "other_org": other_organization_id}, + ) + connection.execute( + text( + "DELETE FROM organization " + "WHERE organization_id IN (:org, :other_org)" + ), + {"org": organization_id, "other_org": other_organization_id}, + ) + connection.execute( + text( + "DELETE FROM user_account WHERE user_id IN (:user, :other_user)" + ), + {"user": user_id, "other_user": other_user_id}, + ) + migration_engine.dispose() diff --git a/tests/integration/test_file_import_tracer.py b/tests/integration/test_file_import_tracer.py index 554210d8..42250e59 100644 --- a/tests/integration/test_file_import_tracer.py +++ b/tests/integration/test_file_import_tracer.py @@ -1560,7 +1560,7 @@ def _assert_structural_file_import_returns_coherent_authorized_units_over_http( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260723_0019" + == "20260723_0020" ) diff --git a/tests/integration/test_m0_security_gate_rls.py b/tests/integration/test_m0_security_gate_rls.py index a547c7bd..67711e28 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 38/38.""" + """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 40/40.""" 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": 41, - "tenantOwned": 38, + "allTables": 43, + "tenantOwned": 40, "global": 3, } assert report["coverage"] == { - "numerator": 38, - "denominator": 38, + "numerator": 40, + "denominator": 40, "percent": 100.0, } assert report["failures"] == [] @@ -86,9 +86,9 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( assert mutated["passed"] is False assert mutated["coverage"] == { - "numerator": 37, - "denominator": 38, - "percent": 97.37, + "numerator": 39, + "denominator": 40, + "percent": 97.5, } tenant_tables = cast(list[dict[str, Any]], mutated["tenantTables"]) organization_record = next( @@ -111,8 +111,8 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( ) assert restored["passed"] is True assert restored["coverage"] == { - "numerator": 38, - "denominator": 38, + "numerator": 40, + "denominator": 40, "percent": 100.0, } diff --git a/tests/integration/test_m0_unavailable_security_carriers.py b/tests/integration/test_m0_unavailable_security_carriers.py index 3117bfc7..9a13418c 100644 --- a/tests/integration/test_m0_unavailable_security_carriers.py +++ b/tests/integration/test_m0_unavailable_security_carriers.py @@ -11,13 +11,11 @@ UNAVAILABLE_M0_TABLE_STEMS = ( "citation", - "egress", "model_gateway", "model_input", ) UNAVAILABLE_M0_FUNCTION_STEMS = ( "citation", - "egress", "model_gateway", "model_input", ) @@ -64,11 +62,10 @@ def _public_application_objects(engine: Engine) -> tuple[set[str], set[str]]: @pytest.mark.security_evidence(id="PG-CITATION-AUTH-010", layer="postgres") -@pytest.mark.security_evidence(id="PG-EGRESS-011", layer="postgres") -def test_m0_unavailable_citation_and_egress_carriers_fail_closed( +def test_unavailable_citation_and_real_provider_carriers_fail_closed( guarded_runtime_engine: Engine, ) -> None: - """M0 exposes no database carrier that could mint future citation/egress auth.""" + """Activated egress state does not activate citation or real-provider state.""" tables, functions = _public_application_objects(guarded_runtime_engine) with guarded_runtime_engine.connect() as connection: diff --git a/tests/integration/test_membership_schema.py b/tests/integration/test_membership_schema.py index 4ae93a92..20368cb5 100644 --- a/tests/integration/test_membership_schema.py +++ b/tests/integration/test_membership_schema.py @@ -14,6 +14,7 @@ from engine.persistence import DatabaseConfiguration, create_database_engine from engine.persistence.configuration import ( DELIVERY_EVIDENCE_DEFINER_ROLE, + EGRESS_GRANT_DEFINER_ROLE, RUNTIME_ROLE, WORKER_LEASE_DEFINER_ROLE, WORKER_ROLE, @@ -465,7 +466,8 @@ def test_runtime_worker_and_public_grants_are_least_privilege( AND table_name IN ('user_account', 'membership') AND grantee IN ( 'PUBLIC', :runtime_role, :worker_role, - :delivery_evidence_definer_role + :delivery_evidence_definer_role, + :egress_grant_definer_role ) """ ), @@ -475,6 +477,7 @@ def test_runtime_worker_and_public_grants_are_least_privilege( "delivery_evidence_definer_role": ( DELIVERY_EVIDENCE_DEFINER_ROLE ), + "egress_grant_definer_role": EGRESS_GRANT_DEFINER_ROLE, }, ) } @@ -523,11 +526,13 @@ def test_runtime_worker_and_public_grants_are_least_privilege( assert grants == { (RUNTIME_ROLE, "membership", "SELECT"), (DELIVERY_EVIDENCE_DEFINER_ROLE, "membership", "SELECT"), + (EGRESS_GRANT_DEFINER_ROLE, "membership", "SELECT"), } assert security == (True, True) assert set(policies) == { "membership_current_user_actor", "membership_delivery_evidence_definer_select", + "membership_egress_definer_select", "membership_file_import_definer_select", "membership_migrator_administration", } @@ -576,6 +581,13 @@ def test_runtime_worker_and_public_grants_are_least_privilege( "true", None, ) + assert policies["membership_egress_definer_select"] == ( + "PERMISSIVE", + (EGRESS_GRANT_DEFINER_ROLE,), + "SELECT", + "true", + None, + ) migrator_policy = policies["membership_migrator_administration"] assert migrator_policy[:3] == ( diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 22d1fb04..f125b51c 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -43,7 +43,7 @@ pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] -_HEAD_REVISION = "20260723_0019" +_HEAD_REVISION = "20260723_0020" HEAD_TABLES = [ "active_release_manifest", "alembic_version", @@ -56,6 +56,8 @@ "context_source", "decision_audit", "delivery_evidence", + "egress_audit", + "egress_grant", "exact_phrase_candidate", "file_acquisition", "file_acquisition_result", diff --git a/tests/integration/test_postgres_harness.py b/tests/integration/test_postgres_harness.py index 24cec250..ac526e79 100644 --- a/tests/integration/test_postgres_harness.py +++ b/tests/integration/test_postgres_harness.py @@ -21,6 +21,8 @@ CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, + EGRESS_GRANT_DEFINER_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, MIGRATOR_ROLE, @@ -92,6 +94,7 @@ def test_all_login_roles_have_reviewed_capabilities( migration_configuration: DatabaseConfiguration, control_configuration: DatabaseConfiguration, identity_configuration: DatabaseConfiguration, + egress_configuration: DatabaseConfiguration, runtime_configuration: DatabaseConfiguration, worker_configuration: DatabaseConfiguration, learning_configuration: DatabaseConfiguration, @@ -101,6 +104,7 @@ def test_all_login_roles_have_reviewed_capabilities( migration_configuration, control_configuration, identity_configuration, + egress_configuration, runtime_configuration, worker_configuration, learning_configuration, @@ -118,6 +122,7 @@ def test_all_login_roles_have_reviewed_capabilities( MIGRATOR_ROLE, CONTROL_ROLE, IDENTITY_ROLE, + EGRESS_ROLE, RUNTIME_ROLE, WORKER_ROLE, LEARNING_ROLE, @@ -142,6 +147,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( guarded_learning_engine: Engine, guarded_operator_engine: Engine, identity_configuration: DatabaseConfiguration, + egress_configuration: DatabaseConfiguration, ) -> None: contract = RoleProvisioningContract( database_name=os.environ["POSTGRES_DB"], @@ -153,6 +159,8 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( control_password=os.environ["CONTEXT_ENGINE_CONTROL_PASSWORD"], identity_role=IDENTITY_ROLE, identity_password=os.environ["CONTEXT_ENGINE_IDENTITY_PASSWORD"], + egress_role=EGRESS_ROLE, + egress_password=os.environ["CONTEXT_ENGINE_EGRESS_PASSWORD"], learning_role=LEARNING_ROLE, learning_password=os.environ["CONTEXT_ENGINE_LEARNING_PASSWORD"], security_operator_role=OPERATOR_ROLE, @@ -164,14 +172,17 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( context_run_reader_definer_role=CONTEXT_RUN_READER_DEFINER_ROLE, release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, + egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, ) alembic_configuration = Config(ROOT / "alembic.ini") try: identity_engine = create_database_engine(identity_configuration) + egress_engine = create_database_engine(egress_configuration) guarded_control_engine.dispose() guarded_learning_engine.dispose() guarded_operator_engine.dispose() identity_engine.dispose() + egress_engine.dispose() clear_file_source_progress_projection(migration_configuration) command.downgrade(alembic_configuration, "20260721_0004") with psycopg.connect( @@ -189,9 +200,11 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( WORKER_LEASE_DEFINER_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, + EGRESS_GRANT_DEFINER_ROLE, RELEASE_DEFINER_ROLE, CONTROL_ROLE, IDENTITY_ROLE, + EGRESS_ROLE, LEARNING_ROLE, OPERATOR_ROLE, ): @@ -202,7 +215,9 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( """ SELECT count(*) FROM pg_roles - WHERE rolname IN (%s, %s, %s, %s, %s, %s, %s, %s, %s) + WHERE rolname IN ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) """, ( CONTROL_ROLE, @@ -214,6 +229,8 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( OPERATOR_ROLE, LEARNING_ROLE, IDENTITY_ROLE, + EGRESS_ROLE, + EGRESS_GRANT_DEFINER_ROLE, ), ).fetchone() assert missing_roles == (0,) diff --git a/tests/integration/test_z_egress_grant_file.py b/tests/integration/test_z_egress_grant_file.py new file mode 100644 index 00000000..f45430a9 --- /dev/null +++ b/tests/integration/test_z_egress_grant_file.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import Engine, text + +from adapters.exact_phrase import PostgreSQLExactPhraseCandidateIndex +from adapters.http.app import create_app +from bot_delivery.egress import ( + DeterministicModelGatewaySpy, + ModelEgressBoundary, + prepare_authorized_model_input, +) +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLEgressGrantRedemptionAuthority, + PostgreSQLMembershipAuthority, + create_database_engine, +) +from engine.runtime.construction import Runtime, required_kernel_dependencies +from engine.runtime.contracts import Resolved +from engine.runtime.egress import ( + EgressGrantNotAvailable, + ModelEgressGrant, + ModelEgressProfile, + direct_egress_audience_digest, +) +from engine.runtime.package_digest import QueryDigestKeyring +from tests.integration.test_file_import_tracer import ( + NOW, + _ExactScopeAuthority, + _OrganizationAuthority, + _prepare_file_import_scenario, + _run_file_import, + _RuntimeAuthenticator, +) + +pytestmark = pytest.mark.integration + + +def _file_model_profile() -> ModelEgressProfile: + return ModelEgressProfile( + profile_ref="file-model-egress-integration-v1", + retention_policy_ref="no-provider-retention-v1", + sensitivity_policy_ref="authorized-package-only-v1", + issuer_ref="context-runtime-integration", + consumer_ref="model-gateway-integration", + provider_ref="deterministic-provider-spy", + model_ref="deterministic-model-spy", + region_ref="local-test-region", + maximum_ttl=timedelta(minutes=1), + ) + + +@pytest.mark.security_evidence(id="RUNTIME-EGRESS-011", layer="runtime") +def test_file_http_package_redeems_exact_model_grant_before_gateway_bytes( + tmp_path: Path, + migration_configuration: DatabaseConfiguration, + guarded_control_engine: Engine, + guarded_worker_engine: Engine, + guarded_runtime_engine: Engine, + egress_configuration: DatabaseConfiguration, + query_digest_keyring: QueryDigestKeyring, +) -> None: + scenario = _prepare_file_import_scenario( + tmp_path, + migration_configuration, + guarded_control_engine, + ) + assert scenario.token is not None + published = _run_file_import( + scenario, + scenario.prepared, + scenario.token, + guarded_worker_engine, + ) + migration_engine = create_database_engine(migration_configuration) + egress_engine = create_database_engine(egress_configuration) + try: + with migration_engine.connect() as connection: + user_id = connection.execute( + text( + "SELECT user_id FROM membership " + "WHERE organization_id = :organization_id " + "AND membership_id = :membership_id" + ), + { + "organization_id": scenario.organization_id, + "membership_id": scenario.membership_id, + }, + ).scalar_one() + observed: list[Resolved] = [] + response = TestClient( + create_app( + authenticator=_RuntimeAuthenticator( + scenario.organization_id, + user_id, + scenario.membership_id, + ), + organization_authority=_OrganizationAuthority(), + membership_authority=PostgreSQLMembershipAuthority( + guarded_runtime_engine + ), + scope_authority=_ExactScopeAuthority( + published.candidate_ref.source_ref, + published.candidate_ref.resource_ref, + ), + runtime=Runtime( + required_kernel_dependencies(), + candidate_index=PostgreSQLExactPhraseCandidateIndex(), + egress_profile=_file_model_profile(), + clock=lambda: NOW, + query_digest_keyring=query_digest_keyring, + ), + resolution_observer=observed.append, + clock=lambda: NOW, + request_id_factory=lambda: "file-egress-http", + ) + ).post( + "/v1/context:resolve", + headers={"Authorization": "Bearer runtime-secret"}, + json={ + "kind": "acquire", + "need": {"query": "ContextEngine delivers context."}, + }, + ) + + assert response.status_code == 200 + assert response.json()["package"]["blocks"][0]["text"] == ( + "ContextEngine delivers context." + ) + wire_grant = response.json()["egressGrant"] + assert wire_grant["kind"] == "model" + assert len(observed) == 1 + outcome = observed[0] + assert type(outcome.egress_grant) is ModelEgressGrant + assert wire_grant["value"] == outcome.egress_grant.value + + audience_digest = direct_egress_audience_digest( + organization_id=scenario.organization_id, + membership_id=scenario.membership_id, + membership_version=1, + authenticated_application_ref="application:file-tracer", + delivery_binding_ref="binding:file-tracer", + ) + gateway = DeterministicModelGatewaySpy(_file_model_profile()) + boundary = ModelEgressBoundary( + organization_id=scenario.organization_id, + audience_digest=audience_digest, + policy_epoch=1, + profile=_file_model_profile(), + authority=PostgreSQLEgressGrantRedemptionAuthority(egress_engine), + gateway=gateway, + ) + authorized = prepare_authorized_model_input( + outcome.package, + outcome.egress_grant, + ) + + boundary.transmit(authorized, outcome.egress_grant) + + assert gateway.request_count == 1 + assert gateway.outbound_bytes > 0 + with pytest.raises(EgressGrantNotAvailable, match="not available"): + boundary.transmit(authorized, outcome.egress_grant) + assert gateway.request_count == 1 + finally: + with migration_engine.begin() as connection: + connection.execute( + text("DELETE FROM egress_audit WHERE organization_id = :org"), + {"org": scenario.organization_id}, + ) + connection.execute( + text("DELETE FROM egress_grant WHERE organization_id = :org"), + {"org": scenario.organization_id}, + ) + egress_engine.dispose() + migration_engine.dispose() diff --git a/tests/integration/test_zz_file_content_noop.py b/tests/integration/test_zz_file_content_noop.py index 4c90f11b..dad65249 100644 --- a/tests/integration/test_zz_file_content_noop.py +++ b/tests/integration/test_zz_file_content_noop.py @@ -257,7 +257,7 @@ def test_repeated_canonically_identical_file_import_is_an_auditable_noop( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260723_0019" + == "20260723_0020" ) diff --git a/tests/support/egress.py b/tests/support/egress.py new file mode 100644 index 00000000..6fe58ce9 --- /dev/null +++ b/tests/support/egress.py @@ -0,0 +1,43 @@ +"""Test-only digest recording egress issuance authority.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import cast + +from engine.runtime.egress import ( + EgressGrantIssuancePort, + EgressGrantIssuanceSession, + EgressGrantIssue, + _close_egress_grant_issuance_scope, + _construct_egress_grant_issuance_session, + _open_egress_grant_issuance_scope, +) + + +class RecordingEgressIssuancePort: + def __init__(self) -> None: + self.calls: list[tuple[EgressGrantIssue, bytes]] = [] + + def issue(self, request: EgressGrantIssue, grant_digest: bytes) -> bool: + self.calls.append((request, grant_digest)) + return True + + +@contextmanager +def recording_egress_issuance_session() -> Iterator[ + tuple[EgressGrantIssuanceSession, RecordingEgressIssuancePort] +]: + scope = _open_egress_grant_issuance_scope() + port = RecordingEgressIssuancePort() + try: + yield ( + _construct_egress_grant_issuance_session( + authority_scope=scope, + port=cast(EgressGrantIssuancePort, port), + ), + port, + ) + finally: + _close_egress_grant_issuance_scope(scope) diff --git a/tests/unit/test_database_configuration.py b/tests/unit/test_database_configuration.py index 8df21eec..cfeea1a6 100644 --- a/tests/unit/test_database_configuration.py +++ b/tests/unit/test_database_configuration.py @@ -9,6 +9,7 @@ from engine.persistence.configuration import ( CONTROL_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, MIGRATOR_ROLE, @@ -41,6 +42,10 @@ def database_environment() -> dict[str, str]: "postgresql+psycopg://context_engine_identity:identity-secret@" "127.0.0.1:5432/context_engine" ), + "CONTEXT_ENGINE_EGRESS_DATABASE_URL": ( + "postgresql+psycopg://context_engine_egress:egress-secret@" + "127.0.0.1:5432/context_engine" + ), "CONTEXT_ENGINE_WORKER_DATABASE_URL": ( "postgresql+psycopg://context_engine_worker:worker-secret@" "127.0.0.1:5432/context_engine" @@ -61,6 +66,7 @@ def database_environment() -> dict[str, str]: "CONTEXT_ENGINE_RUNTIME_ROLE": RUNTIME_ROLE, "CONTEXT_ENGINE_CONTROL_ROLE": CONTROL_ROLE, "CONTEXT_ENGINE_IDENTITY_ROLE": IDENTITY_ROLE, + "CONTEXT_ENGINE_EGRESS_ROLE": EGRESS_ROLE, "CONTEXT_ENGINE_WORKER_ROLE": WORKER_ROLE, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE": OPERATOR_ROLE, @@ -73,6 +79,7 @@ def database_environment() -> dict[str, str]: (DatabasePurpose.MIGRATION, "CONTEXT_ENGINE_MIGRATION_DATABASE_URL"), (DatabasePurpose.CONTROL_PLANE, "CONTEXT_ENGINE_CONTROL_DATABASE_URL"), (DatabasePurpose.TRUSTED_IDENTITY, "CONTEXT_ENGINE_IDENTITY_DATABASE_URL"), + (DatabasePurpose.TRUSTED_EGRESS, "CONTEXT_ENGINE_EGRESS_DATABASE_URL"), (DatabasePurpose.API_RUNTIME, "CONTEXT_ENGINE_RUNTIME_DATABASE_URL"), (DatabasePurpose.SUPPLY_WORKER, "CONTEXT_ENGINE_WORKER_DATABASE_URL"), (DatabasePurpose.LEARNING, "CONTEXT_ENGINE_LEARNING_DATABASE_URL"), @@ -205,6 +212,7 @@ def test_harness_contract_keeps_roles_distinct_and_test_uses_runtime() -> None: assert configurations.migration.expected_role == MIGRATOR_ROLE assert configurations.control.expected_role == CONTROL_ROLE + assert configurations.egress.expected_role == EGRESS_ROLE assert configurations.runtime.expected_role == RUNTIME_ROLE assert configurations.worker.expected_role == WORKER_ROLE assert configurations.learning.expected_role == LEARNING_ROLE diff --git a/tests/unit/test_database_harness_contract.py b/tests/unit/test_database_harness_contract.py index 1da78945..32e5f1f6 100644 --- a/tests/unit/test_database_harness_contract.py +++ b/tests/unit/test_database_harness_contract.py @@ -81,6 +81,8 @@ def test_database_harness_generates_secret_state_and_never_sources_it() -> None: assert "CONTEXT_ENGINE_CONTROL_DATABASE_URL" in script assert "CONTEXT_ENGINE_IDENTITY_ROLE=context_engine_identity" in script assert "CONTEXT_ENGINE_IDENTITY_DATABASE_URL" in script + assert "CONTEXT_ENGINE_EGRESS_ROLE=context_engine_egress" in script + assert "CONTEXT_ENGINE_EGRESS_DATABASE_URL" in script assert ( "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE=context_engine_security_operator" in script @@ -96,6 +98,8 @@ def test_compose_passes_dedicated_operator_credentials_to_bootstrap() -> None: assert "CONTEXT_ENGINE_CONTROL_PASSWORD" in compose assert "CONTEXT_ENGINE_IDENTITY_ROLE" in compose assert "CONTEXT_ENGINE_IDENTITY_PASSWORD" in compose + assert "CONTEXT_ENGINE_EGRESS_ROLE" in compose + assert "CONTEXT_ENGINE_EGRESS_PASSWORD" in compose assert "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE" in compose assert "CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD" in compose assert "CONTEXT_ENGINE_LEARNING_ROLE" in compose @@ -107,6 +111,7 @@ def test_readiness_probe_includes_dedicated_operator_configuration() -> None: assert "configurations.control" in wait_script assert "configurations.identity" in wait_script + assert "configurations.egress" in wait_script assert "configurations.learning" in wait_script assert "configurations.operator" in wait_script assert ( @@ -117,11 +122,11 @@ def test_readiness_probe_includes_dedicated_operator_configuration() -> None: wait_script ) assert " assert_learning_role(connection)" in wait_script - purpose_sequence = ( - "migration, control, identity, runtime, worker, learning, " - "security-operator" + assert ( + "migration, control, identity, egress, runtime, worker, learning, " + in wait_script ) - assert purpose_sequence in wait_script + assert '"security-operator, "' in wait_script def test_harness_provisions_post_init_roles_before_readiness() -> None: diff --git a/tests/unit/test_egress_grant.py b/tests/unit/test_egress_grant.py new file mode 100644 index 00000000..89bd2f0c --- /dev/null +++ b/tests/unit/test_egress_grant.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from typing import Any, cast +from uuid import UUID + +import pytest + +from bot_delivery.egress import ( + AuthorizedChannelPayload, + AuthorizedModelInput, + ChannelEgressBoundary, + DeterministicModelGatewaySpy, + DeterministicSenderPreflightSpy, + ModelEgressBoundary, + prepare_authorized_channel_payload, + prepare_authorized_model_input, +) +from engine.runtime.contracts import ( + BudgetUsage, + ContextPackage, + Coverage, + CoverageReason, + CoverageStatus, +) +from engine.runtime.egress import ( + ChannelEgressGrant, + ChannelEgressProfile, + EgressGrantNotAvailable, + EgressGrantRedemption, + ModelEgressGrant, + ModelEgressProfile, +) +from engine.runtime.evidence import AuthorizedProjection, CandidateRef + +ORGANIZATION_ID = UUID("10000000-0000-0000-0000-000000000001") +OTHER_ORGANIZATION_ID = UUID("20000000-0000-0000-0000-000000000002") +AUDIENCE_DIGEST = "a" * 64 +NOW = datetime(2026, 7, 23, 8, 0, tzinfo=UTC) + + +def _package(*, purpose: str = "answer") -> ContextPackage: + return ContextPackage( + organization_ref="orgpkg_" + "1" * 32, + purpose=purpose, + ttl_seconds=300, + as_of=NOW, + expires_at=NOW + timedelta(seconds=300), + decision_ref="dec_" + "2" * 32, + blocks=(), + evidence=(), + gaps=(), + budget_usage=BudgetUsage( + tokens=0, + provider_calls=0, + cost_microunits=0, + elapsed_ms=0, + ), + coverage=Coverage( + status=CoverageStatus.EMPTY, + reason=CoverageReason.NO_AUTHORIZED_EVIDENCE, + ), + ) + + +def _model_profile() -> ModelEgressProfile: + return ModelEgressProfile( + profile_ref="model-egress-test-v1", + retention_policy_ref="no-provider-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-test", + consumer_ref="model-gateway-test", + provider_ref="provider-test", + model_ref="model-test", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + + +def _channel_profile() -> ChannelEgressProfile: + return ChannelEgressProfile( + profile_ref="channel-egress-test-v1", + retention_policy_ref="no-channel-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-test", + consumer_ref="sender-preflight-test", + channel_ref="channel-test", + destination_ref="destination-test", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + + +class _ExactRedemptionAuthority: + def __init__(self, expected: EgressGrantRedemption) -> None: + self.expected = expected + self.calls: list[EgressGrantRedemption] = [] + self.consumed = False + + def redeem(self, redemption: EgressGrantRedemption) -> bool: + self.calls.append(redemption) + if self.consumed or redemption != self.expected: + return False + self.consumed = True + return True + + +def _model_boundary( + package: ContextPackage, + grant: ModelEgressGrant, +) -> tuple[ModelEgressBoundary, DeterministicModelGatewaySpy]: + authorized_input = prepare_authorized_model_input(package, grant) + expected = EgressGrantRedemption.for_model( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=authorized_input.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + ) + gateway = DeterministicModelGatewaySpy(_model_profile()) + return ( + ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(expected), + gateway=gateway, + ), + gateway, + ) + + +def test_model_and_channel_grants_are_distinct_opaque_nominal_types() -> None: + model = ModelEgressGrant("egrm_" + "1" * 64) + channel = ChannelEgressGrant("egrc_" + "2" * 64) + + assert type(model) is ModelEgressGrant + assert type(channel) is ChannelEgressGrant + assert cast(object, model) != cast(object, channel) + assert "1" * 64 not in repr(model) + assert "2" * 64 not in repr(channel) + assert not hasattr(model, "channel_ref") + assert not hasattr(channel, "provider_ref") + + +def test_authorized_model_input_requires_exact_package_and_model_grant() -> None: + package = _package() + model_grant = ModelEgressGrant("egrm_" + "1" * 64) + channel_grant = ChannelEgressGrant("egrc_" + "2" * 64) + + authorized = prepare_authorized_model_input(package, model_grant) + + assert type(authorized) is AuthorizedModelInput + assert authorized.package_digest == package.package_digest + assert authorized.payload_digest + with pytest.raises(TypeError, match="BotDelivery"): + AuthorizedModelInput() + for rejected in ( + "arbitrary text", + object.__new__(CandidateRef), + object.__new__(AuthorizedProjection), + ): + with pytest.raises(TypeError, match="ContextPackage"): + prepare_authorized_model_input(cast(Any, rejected), model_grant) + with pytest.raises(TypeError, match="ModelEgressGrant"): + prepare_authorized_model_input(package, cast(Any, channel_grant)) + + +def test_only_exact_authorized_model_input_and_grant_reach_gateway_bytes() -> None: + package = _package() + grant = ModelEgressGrant("egrm_" + "1" * 64) + boundary, gateway = _model_boundary(package, grant) + authorized = prepare_authorized_model_input(package, grant) + + boundary.transmit(authorized, grant) + + assert gateway.request_count == 1 + assert gateway.outbound_bytes > 0 + with pytest.raises(EgressGrantNotAvailable): + boundary.transmit(authorized, grant) + assert gateway.request_count == 1 + + +def test_wrong_grant_or_consumer_binding_emits_zero_model_bytes() -> None: + package = _package() + grant = ModelEgressGrant("egrm_" + "1" * 64) + wrong_grant = ModelEgressGrant("egrm_" + "2" * 64) + boundary, gateway = _model_boundary(package, grant) + authorized = prepare_authorized_model_input(package, grant) + + with pytest.raises(EgressGrantNotAvailable): + boundary.transmit(authorized, wrong_grant) + assert gateway.request_count == 0 + assert gateway.outbound_bytes == 0 + + +@pytest.mark.parametrize( + "mutation", + ( + lambda value: replace(value, organization_id=OTHER_ORGANIZATION_ID), + lambda value: replace(value, package_digest="b" * 64), + lambda value: replace(value, payload_digest="b" * 64), + lambda value: replace(value, purpose="citation.open"), + lambda value: replace(value, audience_digest="b" * 64), + lambda value: replace(value, policy_epoch=8), + lambda value: replace(value, retention_policy_ref="retain-wrong"), + lambda value: replace(value, sensitivity_policy_ref="sensitivity-wrong"), + lambda value: replace(value, issuer_ref="issuer-wrong"), + lambda value: replace(value, consumer_ref="gateway-wrong"), + lambda value: replace(value, provider_ref="provider-wrong"), + lambda value: replace(value, model_ref="model-wrong"), + lambda value: replace(value, region_ref="region-wrong"), + lambda value: replace(value, profile_ref="profile-wrong"), + ), +) +def test_each_model_redemption_binding_mismatch_emits_zero_bytes( + mutation: Any, +) -> None: + package = _package() + grant = ModelEgressGrant("egrm_" + "1" * 64) + authorized = prepare_authorized_model_input(package, grant) + expected = EgressGrantRedemption.for_model( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=authorized.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + ) + gateway = DeterministicModelGatewaySpy(_model_profile()) + boundary = ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(mutation(expected)), + gateway=gateway, + ) + + with pytest.raises(EgressGrantNotAvailable, match="not available"): + boundary.transmit(authorized, grant) + + assert gateway.request_count == 0 + assert gateway.outbound_bytes == 0 + + +def test_wrong_model_gateway_identity_is_non_enumerating_and_zero_bytes() -> None: + package = _package() + grant = ModelEgressGrant("egrm_" + "1" * 64) + authorized = prepare_authorized_model_input(package, grant) + expected = EgressGrantRedemption.for_model( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=authorized.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + ) + gateway = DeterministicModelGatewaySpy( + replace(_model_profile(), model_ref="wrong-model") + ) + + with pytest.raises(EgressGrantNotAvailable, match="not available"): + ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(expected), + gateway=gateway, + ) + + assert gateway.request_count == 0 + assert gateway.outbound_bytes == 0 + + +def test_mutated_model_payload_emits_zero_bytes_before_redemption() -> None: + package = _package() + grant = ModelEgressGrant("egrm_" + "1" * 64) + boundary, gateway = _model_boundary(package, grant) + authorized = prepare_authorized_model_input(package, grant) + object.__setattr__(authorized, "_payload", authorized._payload + b"tampered") + + with pytest.raises(EgressGrantNotAvailable, match="not available"): + boundary.transmit(authorized, grant) + + assert gateway.request_count == 0 + assert gateway.outbound_bytes == 0 + + +def test_channel_grant_reaches_only_exact_payload_preflight_and_never_effect() -> None: + package = _package() + grant = ChannelEgressGrant("egrc_" + "3" * 64) + payload = prepare_authorized_channel_payload(package, grant) + expected = EgressGrantRedemption.for_channel( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=payload.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + ) + sender = DeterministicSenderPreflightSpy(_channel_profile()) + boundary = ChannelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(expected), + sender=sender, + ) + + boundary.preflight(payload, grant) + + assert sender.preflight_count == 1 + assert sender.outbound_bytes > 0 + assert sender.effect_count == 0 + with pytest.raises(TypeError, match="BotDelivery"): + AuthorizedChannelPayload() + with pytest.raises(TypeError, match="AuthorizedChannelPayload"): + boundary.preflight(cast(Any, grant), grant) + assert sender.preflight_count == 1 + assert sender.effect_count == 0 + + +def test_cross_org_channel_redemption_is_non_enumerating_and_zero_effect() -> None: + package = _package() + grant = ChannelEgressGrant("egrc_" + "3" * 64) + payload = prepare_authorized_channel_payload(package, grant) + expected = EgressGrantRedemption.for_channel( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=payload.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + ) + sender = DeterministicSenderPreflightSpy(_channel_profile()) + boundary = ChannelEgressBoundary( + organization_id=OTHER_ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(expected), + sender=sender, + ) + + with pytest.raises(EgressGrantNotAvailable, match="not available"): + boundary.preflight(payload, grant) + + assert sender.preflight_count == 0 + assert sender.outbound_bytes == 0 + assert sender.effect_count == 0 + + +def test_wrong_sender_identity_is_non_enumerating_and_zero_effect() -> None: + package = _package() + grant = ChannelEgressGrant("egrc_" + "3" * 64) + payload = prepare_authorized_channel_payload(package, grant) + expected = EgressGrantRedemption.for_channel( + grant=grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=payload.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + ) + sender = DeterministicSenderPreflightSpy( + replace(_channel_profile(), destination_ref="wrong-destination") + ) + + with pytest.raises(EgressGrantNotAvailable, match="not available"): + ChannelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(expected), + sender=sender, + ) + + assert sender.preflight_count == 0 + assert sender.outbound_bytes == 0 + assert sender.effect_count == 0 + + +@pytest.mark.security_evidence(id="PROP-EGRESS-011", layer="property") +def test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects() -> None: + package = _package() + model_grant = ModelEgressGrant("egrm_" + "1" * 64) + channel_grant = ChannelEgressGrant("egrc_" + "3" * 64) + authorized = prepare_authorized_model_input(package, model_grant) + payload = prepare_authorized_channel_payload(package, channel_grant) + expected_model = EgressGrantRedemption.for_model( + grant=model_grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=authorized.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + ) + expected_channel = EgressGrantRedemption.for_channel( + grant=channel_grant, + organization_id=ORGANIZATION_ID, + package_digest=package.package_digest, + payload_digest=payload.payload_digest, + purpose=package.purpose, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + ) + common_mutations: tuple[ + Callable[[EgressGrantRedemption], EgressGrantRedemption], ... + ] = ( + lambda value: replace(value, organization_id=OTHER_ORGANIZATION_ID), + lambda value: replace(value, package_digest="b" * 64), + lambda value: replace(value, payload_digest="b" * 64), + lambda value: replace(value, purpose="citation.open"), + lambda value: replace(value, audience_digest="b" * 64), + lambda value: replace(value, policy_epoch=8), + lambda value: replace(value, retention_policy_ref="retain-wrong"), + lambda value: replace(value, sensitivity_policy_ref="sensitivity-wrong"), + lambda value: replace(value, issuer_ref="issuer-wrong"), + lambda value: replace(value, consumer_ref="consumer-wrong"), + lambda value: replace(value, region_ref="region-wrong"), + lambda value: replace(value, profile_ref="profile-wrong"), + ) + model_mutations: tuple[ + Callable[[EgressGrantRedemption], EgressGrantRedemption], ... + ] = common_mutations + ( + lambda value: replace(value, provider_ref="provider-wrong"), + lambda value: replace(value, model_ref="model-wrong"), + ) + for mutation in model_mutations: + gateway = DeterministicModelGatewaySpy(_model_profile()) + model_boundary = ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(mutation(expected_model)), + gateway=gateway, + ) + with pytest.raises(EgressGrantNotAvailable, match="not available"): + model_boundary.transmit(authorized, model_grant) + assert gateway.request_count == gateway.outbound_bytes == 0 + + channel_mutations: tuple[ + Callable[[EgressGrantRedemption], EgressGrantRedemption], ... + ] = common_mutations + ( + lambda value: replace(value, channel_ref="channel-wrong"), + lambda value: replace(value, destination_ref="destination-wrong"), + ) + for mutation in channel_mutations: + sender = DeterministicSenderPreflightSpy(_channel_profile()) + channel_boundary = ChannelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(mutation(expected_channel)), + sender=sender, + ) + with pytest.raises(EgressGrantNotAvailable, match="not available"): + channel_boundary.preflight(payload, channel_grant) + assert ( + sender.preflight_count + == sender.outbound_bytes + == sender.effect_count + == 0 + ) + + for wrong_gateway_profile in ( + replace(_model_profile(), consumer_ref="consumer-wrong"), + replace(_model_profile(), provider_ref="provider-wrong"), + replace(_model_profile(), model_ref="model-wrong"), + replace(_model_profile(), region_ref="region-wrong"), + ): + gateway = DeterministicModelGatewaySpy(wrong_gateway_profile) + with pytest.raises(EgressGrantNotAvailable, match="not available"): + ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(expected_model), + gateway=gateway, + ) + assert gateway.request_count == gateway.outbound_bytes == 0 + + for wrong_sender_profile in ( + replace(_channel_profile(), consumer_ref="consumer-wrong"), + replace(_channel_profile(), channel_ref="channel-wrong"), + replace(_channel_profile(), destination_ref="destination-wrong"), + replace(_channel_profile(), region_ref="region-wrong"), + ): + sender = DeterministicSenderPreflightSpy(wrong_sender_profile) + with pytest.raises(EgressGrantNotAvailable, match="not available"): + ChannelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(expected_channel), + sender=sender, + ) + assert ( + sender.preflight_count + == sender.outbound_bytes + == sender.effect_count + == 0 + ) + + model_gateway = DeterministicModelGatewaySpy(_model_profile()) + model_boundary = ModelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_model_profile(), + authority=_ExactRedemptionAuthority(expected_model), + gateway=model_gateway, + ) + with pytest.raises(TypeError, match="ModelEgressGrant"): + model_boundary.transmit(authorized, cast(Any, channel_grant)) + assert model_gateway.request_count == model_gateway.outbound_bytes == 0 + + channel_sender = DeterministicSenderPreflightSpy(_channel_profile()) + channel_boundary = ChannelEgressBoundary( + organization_id=ORGANIZATION_ID, + audience_digest=AUDIENCE_DIGEST, + policy_epoch=7, + profile=_channel_profile(), + authority=_ExactRedemptionAuthority(expected_channel), + sender=channel_sender, + ) + with pytest.raises(TypeError, match="ChannelEgressGrant"): + channel_boundary.preflight(payload, cast(Any, model_grant)) + assert ( + channel_sender.preflight_count + == channel_sender.outbound_bytes + == channel_sender.effect_count + == 0 + ) diff --git a/tests/unit/test_evidence_contracts.py b/tests/unit/test_evidence_contracts.py index 9443bc0c..4ee9624a 100644 --- a/tests/unit/test_evidence_contracts.py +++ b/tests/unit/test_evidence_contracts.py @@ -366,7 +366,6 @@ def test_package_constructor_rejects_expired_projection_authority() -> None: construct_package_content((authorized,)) -@pytest.mark.security_evidence(id="PROP-EGRESS-011", layer="property") def test_package_constructor_rejects_cross_organization_or_mixed_request() -> None: kernel_scope = _open_authorization_kernel_scope() authorized = projection("a", "authorized body", kernel_scope=kernel_scope) diff --git a/tests/unit/test_http_authorized_evidence_contract.py b/tests/unit/test_http_authorized_evidence_contract.py index d6cce25c..5b47ba3c 100644 --- a/tests/unit/test_http_authorized_evidence_contract.py +++ b/tests/unit/test_http_authorized_evidence_contract.py @@ -57,7 +57,7 @@ def empty_outcome() -> Resolved: ) return cast( Resolved, - SimpleNamespace(kind="resolved", package=package), + SimpleNamespace(kind="resolved", package=package, egress_grant=None), ) @@ -104,6 +104,7 @@ def authorized_outcome() -> Resolved: SimpleNamespace( kind="resolved", package=package, + egress_grant=None, effective_budget=PackageBudget( max_tokens=4_096, max_provider_calls=8, diff --git a/tests/unit/test_http_trust_boundary.py b/tests/unit/test_http_trust_boundary.py index 514740d9..703b83a3 100644 --- a/tests/unit/test_http_trust_boundary.py +++ b/tests/unit/test_http_trust_boundary.py @@ -52,6 +52,15 @@ _construct_delivery_evidence_redemption_session, _open_delivery_evidence_redemption_scope, ) +from engine.runtime.egress import ( + EgressGrantIssuancePort, + EgressGrantIssuanceUnavailable, + EgressGrantIssue, + ModelEgressProfile, + _close_egress_grant_issuance_scope, + _construct_egress_grant_issuance_session, + _open_egress_grant_issuance_scope, +) from engine.runtime.organization import ( ExistingOrganizationVerification, OrganizationVerificationProvenance, @@ -127,10 +136,12 @@ class DeterministicMembershipAuthority: def __init__( self, delivery_evidence_port: DeliveryEvidenceRedemptionPort | None = None, + egress_issuance_port: EgressGrantIssuancePort | None = None, ) -> None: self.identities: list[MembershipIdentity] = [] self.events: list[str] = [] self.delivery_evidence_port = delivery_evidence_port + self.egress_issuance_port = egress_issuance_port @contextmanager def current_user_actor( @@ -149,6 +160,7 @@ def current_user_actor( scope = _open_membership_authority_scope() policy_epoch_scope = _open_policy_epoch_authority_scope() delivery_evidence_scope = _open_delivery_evidence_redemption_scope() + egress_issuance_scope = _open_egress_grant_issuance_scope() class CurrentEpochPort: def read_current_epoch(self, organization_id: UUID) -> object: @@ -184,8 +196,17 @@ def read_current_epoch(self, organization_id: UUID) -> object: if self.delivery_evidence_port is not None else None ), + egress_grant_issuance_session=( + _construct_egress_grant_issuance_session( + authority_scope=egress_issuance_scope, + port=self.egress_issuance_port, + ) + if self.egress_issuance_port is not None + else None + ), ) finally: + _close_egress_grant_issuance_scope(egress_issuance_scope) _close_delivery_evidence_redemption_scope(delivery_evidence_scope) _close_policy_epoch_authority_scope(policy_epoch_scope) _close_membership_authority_scope(scope) @@ -210,6 +231,19 @@ def current_user_actor( return _RaisingMembershipContext(MembershipAuthorityUnavailable()) +class UnavailableEgressIssuancePort: + def __init__(self, *, raise_error: bool) -> None: + self.calls: list[EgressGrantIssue] = [] + self.raise_error = raise_error + + def issue(self, request: EgressGrantIssue, grant_digest: bytes) -> bool: + del grant_digest + self.calls.append(request) + if self.raise_error: + raise EgressGrantIssuanceUnavailable("private database detail") + return False + + class _RaisingMembershipContext(AbstractContextManager[CurrentMembershipVerification]): def __init__(self, error: Exception) -> None: self._error = error @@ -1017,6 +1051,60 @@ def test_membership_authority_unavailability_is_generic_503_and_zero_io() -> Non ) +@pytest.mark.parametrize("raise_error", (False, True)) +def test_egress_issuance_unavailability_is_generic_503_without_leakage( + raise_error: bool, +) -> None: + port = UnavailableEgressIssuancePort(raise_error=raise_error) + profile = ModelEgressProfile( + profile_ref="http-model-egress-test-v1", + retention_policy_ref="no-provider-retention-v1", + sensitivity_policy_ref="authorized-package-only-v1", + issuer_ref="context-runtime-test", + consumer_ref="model-gateway-test", + provider_ref="provider-test", + model_ref="model-test", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + candidate = Runtime( + required_kernel_dependencies(), + egress_profile=profile, + clock=lambda: RECEIVED_AT, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + ) + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority( + egress_issuance_port=port + ), + runtime=candidate, + clock=lambda: RECEIVED_AT, + ) + ) + + response = client.post( + "/v1/context:resolve", + headers={"Authorization": f"Bearer {VALID_TOKEN}"}, + json=VALID_BODY, + ) + + assert response.status_code == 503 + assert response.content == b'{"code":"service_unavailable"}' + assert len(port.calls) == 1 + for prohibited in ( + "egressGrant", + "package", + "private database detail", + INTERNAL_ORGANIZATION_REF, + INTERNAL_USER_REF, + INTERNAL_MEMBERSHIP_REF, + ): + assert prohibited not in response.text + + def test_membership_transaction_exit_failure_suppresses_prepared_200_as_503() -> None: resolution_spy = ResolutionSpy() client = TestClient( @@ -1955,7 +2043,21 @@ def test_openapi_body_is_closed_and_contains_no_trusted_fields() -> None: "EvidenceWire", "BudgetUsageWire", "CoverageWire", + "ModelEgressGrantWire", + "ChannelEgressGrantWire", + } + resolved_schema = response_models["ResolvedWire"] + assert set(resolved_schema["properties"]) == { + "kind", + "package", + "egressGrant", } + assert response_models["ModelEgressGrantWire"]["properties"]["value"][ + "pattern" + ] == "^egrm_[0-9a-f]{64}$" + assert response_models["ChannelEgressGrantWire"]["properties"]["value"][ + "pattern" + ] == "^egrc_[0-9a-f]{64}$" package_schema = response_models["ContextPackageWire"] assert package_schema["additionalProperties"] is False assert package_schema["required"] == [ diff --git a/tests/unit/test_learning_database_configuration.py b/tests/unit/test_learning_database_configuration.py index cda3e0b2..d260dd6d 100644 --- a/tests/unit/test_learning_database_configuration.py +++ b/tests/unit/test_learning_database_configuration.py @@ -4,6 +4,7 @@ from engine.persistence.configuration import ( CONTROL_ROLE, + EGRESS_ROLE, IDENTITY_ROLE, LEARNING_ROLE, MIGRATOR_ROLE, @@ -31,6 +32,10 @@ def _database_environment() -> dict[str, str]: "postgresql+psycopg://context_engine_identity:identity-secret@" "127.0.0.1:5432/context_engine" ), + "CONTEXT_ENGINE_EGRESS_DATABASE_URL": ( + "postgresql+psycopg://context_engine_egress:egress-secret@" + "127.0.0.1:5432/context_engine" + ), "CONTEXT_ENGINE_RUNTIME_DATABASE_URL": ( "postgresql+psycopg://context_engine_runtime:runtime-secret@" "127.0.0.1:5432/context_engine" @@ -54,6 +59,7 @@ def _database_environment() -> dict[str, str]: "CONTEXT_ENGINE_MIGRATOR_ROLE": MIGRATOR_ROLE, "CONTEXT_ENGINE_CONTROL_ROLE": CONTROL_ROLE, "CONTEXT_ENGINE_IDENTITY_ROLE": IDENTITY_ROLE, + "CONTEXT_ENGINE_EGRESS_ROLE": EGRESS_ROLE, "CONTEXT_ENGINE_RUNTIME_ROLE": RUNTIME_ROLE, "CONTEXT_ENGINE_WORKER_ROLE": WORKER_ROLE, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, diff --git a/tests/unit/test_learning_database_role_provisioning.py b/tests/unit/test_learning_database_role_provisioning.py index e044b8c3..f11a8dfd 100644 --- a/tests/unit/test_learning_database_role_provisioning.py +++ b/tests/unit/test_learning_database_role_provisioning.py @@ -17,6 +17,8 @@ def _provisioning_environment() -> dict[str, str]: "CONTEXT_ENGINE_CONTROL_PASSWORD": "b" * 64, "CONTEXT_ENGINE_IDENTITY_ROLE": "context_engine_identity", "CONTEXT_ENGINE_IDENTITY_PASSWORD": "e" * 64, + "CONTEXT_ENGINE_EGRESS_ROLE": "context_engine_egress", + "CONTEXT_ENGINE_EGRESS_PASSWORD": "8" * 64, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, "CONTEXT_ENGINE_LEARNING_PASSWORD": "c" * 64, "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE": "context_engine_security_operator", diff --git a/tests/unit/test_m0_delivery_carriers.py b/tests/unit/test_m0_delivery_carriers.py index 87a6a5e4..3ef28c88 100644 --- a/tests/unit/test_m0_delivery_carriers.py +++ b/tests/unit/test_m0_delivery_carriers.py @@ -2,22 +2,18 @@ import importlib.util -import pytest - import engine.runtime as runtime -@pytest.mark.security_evidence(id="RUNTIME-EGRESS-011", layer="runtime") def test_m0_egress_carrier_is_unavailable_before_model_or_sender_bytes() -> None: - """M0 cannot construct the future delivery/action types or trusted process.""" + """The grant tracer activates without a real provider or effect process.""" - assert importlib.util.find_spec("bot_delivery") is None + assert importlib.util.find_spec("bot_delivery") is not None assert importlib.util.find_spec("action_plane") is None for public_name in ( "ActionPlane", - "AuthorizedModelInput", - "EgressGrant", "ModelGateway", "Sender", ): assert not hasattr(runtime, public_name) + assert hasattr(runtime, "EgressGrant") diff --git a/tests/unit/test_m0_rls_inventory.py b/tests/unit/test_m0_rls_inventory.py index 3717dca5..9a283473 100644 --- a/tests/unit/test_m0_rls_inventory.py +++ b/tests/unit/test_m0_rls_inventory.py @@ -23,6 +23,8 @@ "context_run_operator_read_ticket", "context_source", "delivery_evidence", + "egress_audit", + "egress_grant", "decision_audit", "exact_phrase_candidate", "file_acquisition", @@ -135,7 +137,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) == 41 + assert len(tables) == 43 for name in sorted(GLOBAL_TABLES): rationale = tables[name]["classificationRationale"] @@ -158,8 +160,8 @@ def test_rls_auditor_requires_every_live_control_and_non_owner_evidence() -> Non assert report["passed"] is True assert report["coverage"] == { - "numerator": 38, - "denominator": 38, + "numerator": 40, + "denominator": 40, "percent": 100.0, } inventory = cast(dict[str, object], report["inventory"]) @@ -184,7 +186,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": 38, + "denominator": 40, "percent": 0.0, } tenant_reports = cast(list[dict[str, Any]], report["tenantTables"]) diff --git a/tests/unit/test_runtime_construction.py b/tests/unit/test_runtime_construction.py index 5d1c9892..8ccd84a5 100644 --- a/tests/unit/test_runtime_construction.py +++ b/tests/unit/test_runtime_construction.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize( - "missing", ["policy", "policy_epoch", "audit", "budget", "provenance"] + "missing", ["policy", "policy_epoch", "audit", "budget", "provenance", "egress"] ) def test_runtime_rejects_each_missing_kernel_dependency(missing: str) -> None: dependencies = required_kernel_dependencies() @@ -32,6 +32,7 @@ def test_runtime_rejects_a_dependency_in_the_wrong_slot() -> None: audit=dependencies.audit, budget=dependencies.budget, provenance=dependencies.provenance, + egress=dependencies.egress, ) with pytest.raises(RuntimeConfigurationError, match="invalid: policy"): @@ -49,6 +50,7 @@ def validate(self) -> None: audit=None, # type: ignore[arg-type] budget=None, # type: ignore[arg-type] provenance=None, # type: ignore[arg-type] + egress=None, # type: ignore[arg-type] ) with pytest.raises(RuntimeConfigurationError, match="must be KernelDependencies"): diff --git a/tests/unit/test_runtime_empty_package.py b/tests/unit/test_runtime_empty_package.py index b34184b5..7569b92a 100644 --- a/tests/unit/test_runtime_empty_package.py +++ b/tests/unit/test_runtime_empty_package.py @@ -2,7 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any, cast from uuid import UUID @@ -32,6 +32,14 @@ from engine.runtime.delivery import ( TrustedDeliveryContext, _construct_direct_delivery_context, + _construct_private_delivery_context, +) +from engine.runtime.egress import ( + ChannelEgressGrant, + ChannelEgressProfile, + EgressGrantIssuanceUnavailable, + ModelEgressGrant, + ModelEgressProfile, ) from engine.runtime.invocation import ( AuthenticatedInvocation, @@ -50,6 +58,7 @@ TEST_QUERY_DIGEST_KEYRING, recording_context_run_session, ) +from tests.support.egress import recording_egress_issuance_session AS_OF = datetime(2026, 7, 21, 5, 0, tzinfo=UTC) INTERNAL_ORGANIZATION_REF = "81e18bca-86a1-478a-937d-7675c6fe69b0" @@ -88,7 +97,10 @@ def total_calls(self) -> int: @contextmanager -def trusted_operands() -> Iterator[ +def trusted_operands( + *, + egress_enabled: bool = False, +) -> Iterator[ tuple[AuthenticatedInvocation, TrustedDeliveryContext] ]: authority_scope = _open_membership_authority_scope() @@ -113,7 +125,9 @@ def read_current_epoch(self, organization_id: UUID) -> object: verified_at=AS_OF, ) try: - with recording_context_run_session() as (persistence_session, _): + with recording_context_run_session() as (persistence_session, _), ( + recording_egress_issuance_session() + ) as (egress_session, _): membership_verification = _construct_current_membership_verification( authority_scope=authority_scope, organization_id=UUID(INTERNAL_ORGANIZATION_REF), @@ -126,6 +140,9 @@ def read_current_epoch(self, organization_id: UUID) -> object: checked_at=AS_OF, policy_epoch_verification=policy_epoch_verification, context_run_persistence_session=persistence_session, + egress_grant_issuance_session=( + egress_session if egress_enabled else None + ), ) scope_identity = ScopeAuthorityIdentity( organization_id=UUID(INTERNAL_ORGANIZATION_REF), @@ -189,6 +206,146 @@ def runtime(content_io_spy: ContentIoSpy | None = None) -> Runtime: ) +def test_runtime_issues_one_model_grant_only_after_final_package_policy() -> None: + profile = ModelEgressProfile( + profile_ref="model-egress-test-v1", + retention_policy_ref="no-provider-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-test", + consumer_ref="model-gateway-test", + provider_ref="provider-test", + model_ref="model-test", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + candidate = Runtime( + required_kernel_dependencies(), + package_ttl_seconds=300, + server_budget=SERVER_BUDGET, + egress_profile=profile, + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + ) + + with trusted_operands(egress_enabled=True) as (invocation, delivery): + outcome = candidate.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="issue exact model hop")), + ) + + assert type(outcome) is Resolved + assert type(outcome.egress_grant) is ModelEgressGrant + + +def test_channel_grant_requires_exact_redeemed_private_destination_and_consumer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile = ChannelEgressProfile( + profile_ref="channel-egress-test-v1", + retention_policy_ref="no-channel-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-test", + consumer_ref="sender-preflight-test", + channel_ref="private-chat-test", + destination_ref="chat:private:42", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + candidate = Runtime( + required_kernel_dependencies(), + package_ttl_seconds=300, + server_budget=SERVER_BUDGET, + egress_profile=profile, + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + ) + issuance_calls = 0 + + def observe_issuance(*args: object, **kwargs: object) -> None: + nonlocal issuance_calls + del args, kwargs + issuance_calls += 1 + + monkeypatch.setattr( + "engine.runtime.construction.issue_egress_grant", + observe_issuance, + ) + with trusted_operands(egress_enabled=True) as (invocation, direct_delivery): + with pytest.raises(EgressGrantIssuanceUnavailable): + candidate.resolve( + invocation, + direct_delivery, + Acquire(need=ContextNeed(query="direct cannot issue channel")), + ) + for destination_ref, consumer_ref in ( + ("chat:private:wrong", profile.consumer_ref), + (profile.destination_ref, "sender-preflight-wrong"), + ): + mismatched_delivery = _construct_private_delivery_context( + purpose="context.answer", + authenticated_application_ref="application-internal", + delivery_binding_ref="binding-internal", + established_at=AS_OF, + destination_ref=destination_ref, + consumer_ref=consumer_ref, + audience_digest="a" * 64, + logical_resolution_ref="logical-resolution-test", + delivery_profile_ref="private-delivery-test-v1", + ) + with pytest.raises(EgressGrantIssuanceUnavailable): + candidate.resolve( + invocation, + mismatched_delivery, + Acquire(need=ContextNeed(query="wrong trusted channel binding")), + ) + + assert issuance_calls == 0 + + +def test_channel_grant_uses_the_exact_redeemed_private_delivery_binding() -> None: + profile = ChannelEgressProfile( + profile_ref="channel-egress-test-v1", + retention_policy_ref="no-channel-retention-v1", + sensitivity_policy_ref="internal-authorized-package-v1", + issuer_ref="context-runtime-test", + consumer_ref="sender-preflight-test", + channel_ref="private-chat-test", + destination_ref="chat:private:42", + region_ref="region-test", + maximum_ttl=timedelta(seconds=60), + ) + candidate = Runtime( + required_kernel_dependencies(), + package_ttl_seconds=300, + server_budget=SERVER_BUDGET, + egress_profile=profile, + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + ) + + with trusted_operands(egress_enabled=True) as (invocation, _): + delivery = _construct_private_delivery_context( + purpose="context.answer", + authenticated_application_ref="application-internal", + delivery_binding_ref="binding-internal", + established_at=AS_OF, + destination_ref=profile.destination_ref, + consumer_ref=profile.consumer_ref, + audience_digest="a" * 64, + logical_resolution_ref="logical-resolution-test", + delivery_profile_ref="private-delivery-test-v1", + ) + outcome = candidate.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="exact trusted channel binding")), + ) + + assert type(outcome) is Resolved + assert type(outcome.egress_grant) is ChannelEgressGrant + + def test_resolve_returns_one_tenant_safe_empty_package() -> None: with trusted_operands() as (invocation, delivery): outcome = runtime().resolve( diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 3016bb98..4deeff16 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -32,7 +32,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "19.0.0" + assert document["manifestVersion"] == "20.0.0" assert set(tables) == { "active_release_manifest", "alembic_version", @@ -45,6 +45,8 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: "context_source", "decision_audit", "delivery_evidence", + "egress_audit", + "egress_grant", "exact_phrase_candidate", "file_acquisition", "file_acquisition_result", @@ -96,6 +98,8 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: ) assert tables["decision_audit"]["classification"] == "tenant_owned" assert tables["delivery_evidence"]["classification"] == "tenant_owned" + assert tables["egress_grant"]["classification"] == "tenant_owned" + assert tables["egress_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" @@ -129,6 +133,18 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: assert tables[release_table]["classification"] == "tenant_owned" +def test_egress_audit_primary_key_is_organization_inclusive() -> None: + audit = table_entries(manifest())["egress_audit"] + + assert audit["organizationInclusiveKeys"] == [ + { + "name": "pk_egress_audit", + "kind": "primary_key", + "columns": ["organization_id", "audit_id"], + } + ] + + def test_issue_24_structural_markdown_contract_is_versioned_and_function_only() -> None: document = manifest() entries = table_entries(document) @@ -1179,6 +1195,7 @@ def test_membership_manifest_requires_exact_user_actor_and_read_only_runtime() - "context_engine_worker": [], "context_engine_worker_lease_definer": ["SELECT"], "context_engine_delivery_evidence_definer": ["SELECT"], + "context_engine_egress_grant_definer": ["SELECT"], } rls = entry["rowLevelSecurity"] @@ -1738,6 +1755,9 @@ def test_policy_epoch_manifest_seals_runtime_reads_and_control_mutation() -> Non expected_operations[ "context_engine_delivery_evidence_definer" ] = ["SELECT"] + expected_operations["context_engine_egress_grant_definer"] = [ + "SELECT" + ] expected_operations["context_engine_control"].append( "EXECUTE context_control_tombstone_file_resource" )