diff --git a/AGENTS.md b/AGENTS.md index dcb40dc6..906993d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,10 +75,10 @@ Before claiming an implementation done, run the verified commands recorded above. Never fabricate output. `.context-engine/database.env` is the generated, ignored, mode-0600 source for local database connection contracts; `compose.yaml` owns the pinned test service topology. A green process smoke proves only -boot/readiness. The database harness additionally proves the bounded Organization -plus representative-record FORCE-RLS slice; Membership, the complete ActorContext -protocol, Runtime delivery, and worker-job behavior remain `NOT_ACTIVE` until their -owning issues verify them. +boot/readiness. The database harness additionally proves Organization, current +Membership, the online UserActor transaction, and representative-record FORCE-RLS +isolation. Content-bearing Runtime delivery and worker-job behavior remain +`NOT_ACTIVE` until their owning issues verify them. ## Safety-Rails / Do Not diff --git a/README.md b/README.md index a64d8a10..29fa9adb 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ **当前状态**:M0 工程骨架已启动。API 和独立 Supply worker 可运行, [`compose.yaml`](./compose.yaml) 固定的真实 PostgreSQL + pgvector 测试底座可复现; -Organization 安全根与一张代表性 tenant-owned 表的非 owner FORCE RLS 隔离切片 -已验证;HTTP 已能用确定性测试认证构造 nominal `AuthenticatedInvocation`,并用 +Organization 安全根、全局 User、Organization-scoped Membership 与一张代表性 +tenant-owned 表的非 owner FORCE RLS 隔离已验证;HTTP 已能把确定性测试认证解析成 +当前 Membership-backed `UserActor`,构造 nominal `AuthenticatedInvocation`,并用 closed body 与通用错误证明 caller 不能注入 trusted identity;该测试组合已通过唯一 `ContextRuntime.resolve` 返回 tenant-safe、evidence-free ContextPackage。默认应用仍拒绝全部 -credential;生产认证、带内容 Runtime delivery、完整 ActorContext / Membership 以及 -worker job 行为仍为 `NOT_ACTIVE`。整体计划见 [PLAN.md](./PLAN.md)。 +credential;生产认证、带内容 Runtime delivery、Principal/Agent scope 交集以及 worker job +行为仍为 `NOT_ACTIVE`。整体计划见 [PLAN.md](./PLAN.md)。 ## 开发命令 @@ -77,20 +78,21 @@ uv run context-engine-worker --test-mode 健康响应中的 `runtime_delivery: NOT_ACTIVE` 表示默认进程没有生产认证入口,worker 输出中的 `job_behavior: NOT_ACTIVE` 是能力边界。当前数据库测试证明 `compose.yaml` 固定的 PostgreSQL/pgvector、 -角色隔离、迁移、连接池清理,以及 Organization + `organization_record` 的 -事务级租户上下文、复合所有权和 FORCE RLS;它不声明 Membership、完整 -ActorContext、内容授权或生产 ContextPackage 交付已经实现;注入的 conformance 组合只证明 -Issue #10 的安全空包路径。 +角色隔离、迁移、连接池清理,以及 Organization + current Membership-backed +`UserActor` + `organization_record` 的事务级租户上下文、复合所有权和 FORCE RLS。 +它不声明 Principal/Agent scope、内容授权或生产 ContextPackage 交付已经实现;注入的 +conformance 组合证明 Issue #11 在安全空包路径上增加的当前 Membership 门禁。 ### 当前 HTTP empty-Package tracer `POST /v1/context:resolve` 的 conformance 组合可注入一个把 opaque credential -映射为 verified transport facts 的 authenticator,以及一个为已登记 Organization -签发 request-bound nominal proof 的 trusted authority;ingress 构造 nominal -`AuthenticatedInvocation` 与 server-owned direct `TrustedDeliveryContext` 后,恰好 -调用一次 sealed Runtime。有效 Acquire 返回 `200 resolved` 与 evidence-free -ContextPackage;模块级默认应用的两条 authority 均 reject-all,因此不会接受任何 -生产 credential。 +映射为 verified transport facts 的 authenticator、一个为已登记 Organization +签发 request-bound nominal proof 的 trusted authority,以及一个在单次 PostgreSQL +事务内校验 current Membership 并签发 lifetime-bound `UserActor` proof 的 authority; +该事务保持到 sealed Runtime 与 ContextPackage 构造完成。有效 Acquire 返回 +`200 resolved` 与 evidence-free ContextPackage;无效 Membership 统一返回通用 401, +数据库 authority 不可用统一返回通用 503,且两者都不会调用内容系统。模块级默认应用的 +三条 authority 均 reject-all,因此不会接受任何生产 credential。 请求体仅允许 `kind: "acquire"`、`need.query`、可选的有限 `packageBudget` 和可选的 `requestNarrowing`(ref 长度与集合数量均受 active profile 限制),每层 unknown field、重复 JSON key @@ -101,9 +103,9 @@ JSON/media type、 不会回显 tenant、Principal、Membership 或注入字段。purpose 只来自服务端 route policy;返回的 `organizationRef` 是新生成的 package-scoped opaque reference,不能作为 后续请求的 trusted tenant input。空包的 blocks/evidence/gaps 均为空,coverage 为 -`no_authorized_evidence`,Provider/index/source-content 调用均为零。确定性与 -real-PostgreSQL seeded Organization authority 只属于测试组合。生产 OAuth/JWT、 -Membership 查询、ActorContext、Evidence 与 continuation 不属于这个已激活 tracer。 +`no_authorized_evidence`,Provider/index/source-content 调用均为零。确定性 authorities +与 real-PostgreSQL seeded composition 只属于测试组合。生产 OAuth/JWT、Principal/ +Agent scope、Evidence 与 continuation 不属于这个已激活 tracer。 本次公开候选 bundle 包含实现权威、ADR、安全契约、PRD、Tech Spec 与四个公开参考仓的证据基线;经维护者批准并提交后,它们将与实现一同 diff --git a/adapters/http/app.py b/adapters/http/app.py index 68ecf03a..5a7b4d46 100644 --- a/adapters/http/app.py +++ b/adapters/http/app.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from json import loads from typing import Annotated, Final, Literal, cast -from uuid import uuid4 +from uuid import UUID, uuid4 from fastapi import Depends, FastAPI, Header, Request, Response, Security from fastapi.exception_handlers import http_exception_handler @@ -28,6 +28,11 @@ CoverageWire, InvalidRequestWire, ResolvedWire, + ServiceUnavailableWire, +) +from adapters.http.membership_authority import ( + MembershipAuthority, + RejectingMembershipAuthority, ) from adapters.http.organization_authority import ( OrganizationAuthority, @@ -41,7 +46,13 @@ enforce_json_nesting, ) from engine import BUILD_IDENTIFIER +from engine.persistence.membership_context import ( + MembershipAuthorityUnavailable, + MembershipIdentity, + MembershipNotCurrent, +) from engine.runtime import AuthenticatedInvocation, Runtime +from engine.runtime.actor import MembershipRejectionAuditReceipt from engine.runtime.budget import PackageBudgetRequest from engine.runtime.construction import required_kernel_dependencies from engine.runtime.contracts import Acquire, ContextNeed, RequestNarrowing, Resolved @@ -56,6 +67,7 @@ } AUTHENTICATION_FAILED_RESPONSE: Final = {"code": "authentication_failed"} INVALID_REQUEST_RESPONSE: Final = {"code": "invalid_request"} +SERVICE_UNAVAILABLE_RESPONSE: Final = {"code": "service_unavailable"} RESOLVE_PATH: Final = "/v1/context:resolve" @@ -63,6 +75,10 @@ class TransportAuthenticationFailed(Exception): """Authentication failed without exposing credential or identity detail.""" +class TrustedAuthorityUnavailable(Exception): + """A required trusted authority failed without exposing identity detail.""" + + class InvalidRequestMediaType(Exception): """Resolve received a body outside its sole JSON media type.""" @@ -105,9 +121,13 @@ def create_app( *, authenticator: Authenticator | None = None, organization_authority: OrganizationAuthority | None = None, + membership_authority: MembershipAuthority | None = None, runtime: Runtime | None = None, invocation_observer: Callable[[AuthenticatedInvocation], None] | None = None, resolution_observer: Callable[[Resolved], None] | None = None, + membership_rejection_observer: ( + Callable[[MembershipRejectionAuditReceipt], None] | None + ) = None, clock: Callable[[], datetime] = _utc_now, request_id_factory: Callable[[], str] = _new_request_id, transport_profile: HttpTransportProfile = HTTP_TRANSPORT_PROFILE_V1, @@ -124,6 +144,9 @@ def create_app( selected_organization_authority = ( organization_authority or RejectingOrganizationAuthority() ) + selected_membership_authority = ( + membership_authority or RejectingMembershipAuthority() + ) bearer = HTTPBearer( scheme_name="ContextEngineBearer", bearerFormat="opaque", @@ -148,6 +171,14 @@ async def authentication_failed( headers={"WWW-Authenticate": "Bearer"}, ) + @app.exception_handler(TrustedAuthorityUnavailable) + async def trusted_authority_unavailable( + request: Request, + error: TrustedAuthorityUnavailable, + ) -> JSONResponse: + del request, error + return JSONResponse(SERVICE_UNAVAILABLE_RESPONSE, status_code=503) + @app.exception_handler(InvalidRequestMediaType) @app.exception_handler(InvalidJsonTransport) async def invalid_media_type( @@ -251,6 +282,10 @@ def health() -> dict[str, str]: "model": InvalidRequestWire, "description": "The closed request schema rejected the body.", }, + 503: { + "model": ServiceUnavailableWire, + "description": "A required trusted authority is unavailable.", + }, }, ) def resolve_context( @@ -281,46 +316,86 @@ def resolve_context( verified_at=received_at, ) ) - invocation = _construct_authenticated_http_invocation( - request_id=request_id, - authenticated_organization_ref=authentication.organization_ref, - organization_verification=organization_verification, + membership_identity = MembershipIdentity( + organization_id=UUID(authentication.organization_ref), + user_id=UUID(authentication.user_ref), + membership_id=UUID(authentication.membership_ref), + membership_version=authentication.membership_version, principal_ref=authentication.principal_ref, - membership_ref=authentication.membership_ref, - agent_version_ref=authentication.agent_version_ref, - authenticated_application_ref=( - authentication.authenticated_application_ref - ), + request_id=request_id, authentication_binding_ref=( authentication.authentication_binding_ref ), - received_at=received_at, + checked_at=received_at, ) except (OrganizationVerificationRejected, TypeError, ValueError): raise TransportAuthenticationFailed from None - if invocation_observer is not None: - invocation_observer(invocation) - delivery_context = _construct_direct_delivery_context( - purpose=DIRECT_ACQUIRE_PURPOSE, - authenticated_application_ref=( - authentication.authenticated_application_ref - ), - delivery_binding_ref=authentication.authentication_binding_ref, - established_at=invocation.received_at, - ) - request = _acquire_from_wire(body) - outcome = selected_runtime.resolve(invocation, delivery_context, request) - response = _resolved_to_wire(outcome) - if resolution_observer is not None: - resolution_observer(outcome) - return JSONResponse( - response.model_dump(mode="json", by_alias=True), - status_code=200, - headers={ - "Cache-Control": "no-store", - "X-Context-Request-Id": invocation.request_id, - }, - ) + try: + with selected_membership_authority.current_user_actor( + membership_identity + ) as current_membership_verification: + try: + invocation = _construct_authenticated_http_invocation( + request_id=request_id, + authenticated_organization_ref=( + authentication.organization_ref + ), + organization_verification=organization_verification, + user_ref=authentication.user_ref, + principal_ref=authentication.principal_ref, + membership_ref=authentication.membership_ref, + membership_version=authentication.membership_version, + current_membership_verification=( + current_membership_verification + ), + agent_version_ref=authentication.agent_version_ref, + authenticated_application_ref=( + authentication.authenticated_application_ref + ), + authentication_binding_ref=( + authentication.authentication_binding_ref + ), + received_at=received_at, + ) + except (TypeError, ValueError): + raise TransportAuthenticationFailed from None + if invocation_observer is not None: + invocation_observer(invocation) + delivery_context = _construct_direct_delivery_context( + purpose=DIRECT_ACQUIRE_PURPOSE, + authenticated_application_ref=( + authentication.authenticated_application_ref + ), + delivery_binding_ref=( + authentication.authentication_binding_ref + ), + established_at=invocation.received_at, + ) + request = _acquire_from_wire(body) + outcome = selected_runtime.resolve( + invocation, + delivery_context, + request, + ) + response = _resolved_to_wire(outcome) + if resolution_observer is not None: + resolution_observer(outcome) + return JSONResponse( + response.model_dump(mode="json", by_alias=True), + status_code=200, + headers={ + "Cache-Control": "no-store", + "X-Context-Request-Id": invocation.request_id, + }, + ) + except MembershipNotCurrent as error: + if type(error) is not MembershipNotCurrent: + raise TrustedAuthorityUnavailable from None + if membership_rejection_observer is not None: + membership_rejection_observer(error.audit_receipt) + raise TransportAuthenticationFailed from None + except MembershipAuthorityUnavailable: + raise TrustedAuthorityUnavailable from None return app diff --git a/adapters/http/authentication.py b/adapters/http/authentication.py index 91228256..9494bfa2 100644 --- a/adapters/http/authentication.py +++ b/adapters/http/authentication.py @@ -4,6 +4,8 @@ from typing import Protocol from uuid import UUID +from engine.runtime.actor import MAX_MEMBERSHIP_VERSION + class AuthenticationRejected(Exception): """Opaque credential did not establish verified authentication context.""" @@ -18,8 +20,10 @@ class VerifiedAuthenticationContext: """Identity facts emitted by a verified transport/session authenticator.""" organization_ref: str + user_ref: str principal_ref: str - membership_ref: str | None + membership_ref: str + membership_version: int agent_version_ref: str authenticated_application_ref: str authentication_binding_ref: str @@ -27,7 +31,9 @@ class VerifiedAuthenticationContext: def __post_init__(self) -> None: required_refs = ( self.organization_ref, + self.user_ref, self.principal_ref, + self.membership_ref, self.agent_version_ref, self.authenticated_application_ref, self.authentication_binding_ref, @@ -39,20 +45,23 @@ def __post_init__(self) -> None: raise InvalidAuthenticationContext( "verified authentication refs must be non-empty" ) - try: - organization_id = UUID(self.organization_ref) - except ValueError: - raise InvalidAuthenticationContext( - "verified Organization ref must be an internal UUID" - ) from None - object.__setattr__(self, "organization_ref", str(organization_id)) - if self.membership_ref is not None and ( - type(self.membership_ref) is not str - or not self.membership_ref - or self.membership_ref.isspace() + for field_name in ("organization_ref", "user_ref", "membership_ref"): + value = getattr(self, field_name) + try: + internal_id = UUID(value) + except ValueError: + label = field_name.removesuffix("_ref").replace("_", " ") + raise InvalidAuthenticationContext( + f"verified {label} ref must be an internal UUID" + ) from None + object.__setattr__(self, field_name, str(internal_id)) + if ( + type(self.membership_version) is not int + or not 1 <= self.membership_version <= MAX_MEMBERSHIP_VERSION ): raise InvalidAuthenticationContext( - "verified membership ref must be non-empty" + "verified Membership version must fit a positive signed 64-bit " + "integer" ) diff --git a/adapters/http/contracts.py b/adapters/http/contracts.py index 5048a088..4043e88f 100644 --- a/adapters/http/contracts.py +++ b/adapters/http/contracts.py @@ -151,3 +151,9 @@ class InvalidRequestWire(ClosedWireModel): """Closed public response for request syntax or schema rejection.""" code: Literal["invalid_request"] + + +class ServiceUnavailableWire(ClosedWireModel): + """Closed response when a required trusted authority is unavailable.""" + + code: Literal["service_unavailable"] diff --git a/adapters/http/membership_authority.py b/adapters/http/membership_authority.py new file mode 100644 index 00000000..94c7569f --- /dev/null +++ b/adapters/http/membership_authority.py @@ -0,0 +1,49 @@ +"""HTTP ingress port for one request-lived current-Membership authority.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from types import TracebackType +from typing import Protocol + +from engine.persistence.membership_context import ( + MembershipIdentity, + MembershipNotCurrent, +) +from engine.runtime.actor import CurrentMembershipVerification + + +class MembershipAuthority(Protocol): + """Resolve one trusted identity inside a request-lived authority scope.""" + + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> AbstractContextManager[CurrentMembershipVerification]: ... + + +class _RejectingMembershipContext( + AbstractContextManager[CurrentMembershipVerification] +): + def __enter__(self) -> CurrentMembershipVerification: + raise MembershipNotCurrent + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + del exc_type, exc_value, traceback + return None + + +class RejectingMembershipAuthority: + """Production-safe default until a live Membership authority is composed.""" + + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> AbstractContextManager[CurrentMembershipVerification]: + del identity + return _RejectingMembershipContext() diff --git a/docs/decisions/0023-bind-runtime-to-current-membership-user-actor.md b/docs/decisions/0023-bind-runtime-to-current-membership-user-actor.md new file mode 100644 index 00000000..21b031b2 --- /dev/null +++ b/docs/decisions/0023-bind-runtime-to-current-membership-user-actor.md @@ -0,0 +1,174 @@ +--- +name: adr-0023-bind-runtime-to-current-membership-user-actor +version: "1.0.0" +description: > + Bind online Runtime Acquire to one current Membership, a complete + transaction-local UserActor, and a nominal proof that remains live through + ContextPackage construction. +--- + +# 0023. Bind Runtime to a current Membership-backed UserActor + +- Status: accepted +- Date: 2026-07-21 +- Refines: ADR-0015, ADR-0017, ADR-0020, ADR-0021, ADR-0022 + +## Context + +ADR-0022 activates an evidence-free Runtime Acquire while deliberately leaving +complete request database binding to Issue #11. ADR-0020's earlier +Organization-only transaction is isolation evidence, not sufficient online +authority: a global User has no Organization rights without one current +Organization-scoped Membership, and an Organization value alone cannot identify +the acting member. + +Checking Membership before opening the Runtime transaction would also create a +reusable, stale authorization result. The trusted identity, PostgreSQL RLS +context, nominal Runtime operand, and current durable Membership must instead be +one request-bound operation. Invalid identity categories must remain +indistinguishable to callers, while database unavailability must not be +misreported as an invalid identity. + +## Decision + +### Trusted identity and current Membership + +The trusted authenticator supplies canonical internal `user_ref` and +`membership_ref` values plus an exact positive integer `membership_version`, in +addition to the already trusted Organization, Principal, application, Agent, +and authentication-binding facts. These values never come from the Acquire +body. Ingress does not infer a default Membership, guest Membership, or +service-principal fallback. + +User remains global and carries no tenant rights. Membership is scoped to one +Organization and one User, with at most one Membership for an +Organization/User pair. Its tenant key contains both Organization and +Membership identifiers; neither current lookup nor a tenant-owned reference may +treat the Membership identifier alone as ownership. For the request's trusted +UTC check time, a Membership is current only when the authenticated +Organization, Membership, User, and version match the same durable row exactly, +its status is `active`, its validity has begun, and its exclusive end time, when +present, has not been reached. A missing row, inactive or revoked status, +not-yet-valid or expired interval, stale version, mismatched User, or +cross-Organization reference is not current. + +Principal and Agent references remain trusted identity facts in this slice; +carrying them does not activate Principal grants, Agent delegation, roles, or +content scope. + +### One PostgreSQL transaction and complete UserActor context + +The Membership authority begins one real PostgreSQL request transaction before +current-Membership validation. Before querying the Membership, it sets and +reads back the complete transaction-local UserActor context: actor kind, +Organization, User, Membership identifier and version, Principal, request +identifier, authentication binding, and trusted check time. A missing, +malformed, partial, or mismatched setting fails closed. + +The same transaction remains open through Membership validation, trusted +invocation construction, `ContextRuntime.resolve`, ContextPackage construction, +and HTTP response construction. Successful completion commits only after that +scope exits. Error and cancellation paths roll back, and pooled connections +must not retain any actor setting. + +The non-owner Runtime role sees the Membership and the current representative +tenant-owned record only through FORCE RLS policies that require this exact +current UserActor. Runtime has read-only access to Membership; writes to the +representative tenant record require the same context through both policy +checks and the existing fail-fast write guard. Organization-only, User-only, +stale, partial, or absent context exposes zero tenant rows and grants no write +authority. This complete online protocol replaces ADR-0020's staged +Organization-only access; that boundary remains historical database evidence, +not an alternate Runtime path. + +### Nominal proof lifetime + +Only the trusted Membership authority may construct a nominal +`CurrentMembershipVerification` and its exactly matching `UserActor`. Neither +type is caller-constructible or serializable. The proof is bound to the full +Organization, User, Membership, version, Principal, request, authentication, +and check-time tuple and to a private authority scope that is active only while +the owning database transaction is open. + +Runtime validates the UserActor's nominal type, trusted construction +provenance, exact tuple match, and active proof scope at its public seam before +the policy pipeline. Closing the Membership authority scope invalidates the +proof, so neither the proof nor an invocation carrying it can become reusable +authorization outside the request transaction. + +### External outcomes and content boundary + +An exactly matching active Membership preserves Issue #10's successful, +evidence-free empty ContextPackage, including its sealed Kernel path and zero +index, provider, or source-content I/O. Current Membership is necessary tenant +authority; it is not sufficient content authorization and does not add Evidence +or broaden the package. + +Malformed required trusted identity facts and every non-current Membership +category produce the same generic `401 authentication_failed` response and +Bearer challenge before Runtime or content I/O. The response does not reveal +whether a User, Membership, or Organization exists or which check failed. + +An inability of the required PostgreSQL Membership authority to open, bind, +read back, or validate its transaction produces the generic +`503 service_unavailable` response before Runtime. A failure while completing +that transaction produces the same response and prevents the prepared success +response from escaping. Neither case is collapsed into the authentication +response or exposes database detail, and the evidence-free path still performs +zero content I/O. + +### Explicit exclusions + +Issue #12 and later own every broader authorization capability. In particular, +this decision does not activate: + +- Principal grants, role semantics, AgentVersion delegation ceilings, + EffectiveScope, or request-scope intersection, which begin in Issue #12; +- Candidate retrieval, Resource ACL, exact hydration authorization, Evidence, + or any other content-bearing path, which begin in Issue #13 and later; +- denied-versus-nonexistent content equivalence beyond the existing empty path, + which is owned by Issue #14; or +- production OAuth or external-identity selection, ServiceActor/WorkerLease + authority, Policy Epoch, audience authorization, durable audit/package + records, release selection, or egress grants. + +## Rationale + +One transaction and one lifetime-bound nominal proof make the durable +Membership decision inseparable from the Runtime operation that consumes it. +The full tuple prevents an authenticated User or Membership identifier from +being replayed under another Organization, request, version, or authentication +binding. FORCE RLS then treats PostgreSQL as authorization truth instead of +trusting an application-side tenant filter. + +Separating generic authentication failure from generic authority +unavailability preserves non-enumeration without turning an infrastructure +incident into a false identity denial. Keeping the valid output empty proves +the new authority boundary without manufacturing the grant and content +semantics owned by later issues. + +## Consequences + +Issue #11 activates the minimum User and Membership persistence and the online +UserActor transaction for the bounded empty Acquire path. The default +application remains fail closed until owning production authentication, +Organization, and Membership authorities are deliberately composed; seeded or +deterministic authorities remain conformance twins only. + +Required evidence spans the HTTP, domain, and real PostgreSQL seams. It must +cover the active and every non-current Membership category, byte-equivalent +generic invalid responses, generic database unavailability, zero downstream +content calls, non-owner FORCE RLS, cross-Organization isolation, partial and +missing actor context, proof invalidation after scope exit, rollback, and pooled +connection reuse. A green active-Membership case proves only the same empty +ContextPackage already fixed by ADR-0022. + +## Revisit trigger + +Revisit when Issue #12 adds the first EffectiveScope operands, when Issue #13 +adds the first content-bearing authorization path, when a production identity +provider owns the trusted references, or when a ServiceActor first enters a +worker transaction. Each refinement must preserve exact current-Membership +validation, one transaction through the consuming operation, nominal proof +lifetime, generic invalid responses, distinct generic unavailability, and +zero-rights behavior for missing or partial context. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 107cebbc..9a516e69 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,6 +21,7 @@ kernel, capability separation, and publication visibility model. | Trusted access boundary | [0017 — Trusted invocation and closed Runtime access](0017-trusted-invocation-and-closed-runtime-access.md) | HTTP, generated SDK, and activated MCP map to one Runtime contract; trusted inputs are ingress-built | Caller-supplied identity/ACL/audience, transport-local policy, or IM as a fourth transport | | Staged HTTP authentication | [0021 — HTTP authentication before provider selection](0021-stage-http-authentication-before-provider-selection.md) | Test auth proves nominal invocation construction; the default application rejects every credential | Treating the test seam as production authentication or Runtime delivery | | Staged empty Package | [0022 — Tenant-safe empty ContextPackage](0022-stage-tenant-safe-empty-context-package.md) | One real Runtime Acquire returns only an evidence-free, budgeted Package with server-owned trust facts | Placeholder policy/egress lineage, caller-authored purpose, or content I/O | +| Current online actor | [0023 — Current Membership-backed UserActor](0023-bind-runtime-to-current-membership-user-actor.md) | Trusted auth selects one exact current Membership; one complete PostgreSQL UserActor transaction and nominal proof remain live through Runtime and Package construction | Organization-only tenant access, caller-authored or default Membership, or proof reuse outside its authority transaction | | Read versus effect | [0011 — Read/write plane separation](0011-read-write-plane-separation.md) | `ContextAccessTicket` and `ActionTicket` use different audiences and are non-interchangeable | Using content/read authority to execute an external effect | | Publication visibility | [0018 — Immutable ContextRevision publication](0018-immutable-revision-publication.md) | `ContextResource` content is immutable `ContextRevision`/`ContextFragment` lineage; one transaction changes the active pointer | In-place content mutation, mixed old/new reads, or cleanup-defined visibility | | Release security catalog | [0019 — Security catalog normalization](0019-security-catalog-normalization.md) | One machine catalog contains exactly fifteen stable release IDs; overlapping labels and derived scenarios keep their safeguards without inflating the count | Parallel prose catalogs, renumbering, or treating inactive cache behavior as a canonical release family | @@ -94,3 +95,4 @@ touched: - [0020 — Staged Organization RLS proof](0020-stage-organization-rls-before-actor-context.md) - [0021 — HTTP authentication before provider selection](0021-stage-http-authentication-before-provider-selection.md) - [0022 — Tenant-safe empty ContextPackage](0022-stage-tenant-safe-empty-context-package.md) +- [0023 — Current Membership-backed UserActor](0023-bind-runtime-to-current-membership-user-actor.md) diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py index 34fe6bff..7bbc5091 100644 --- a/engine/persistence/__init__.py +++ b/engine/persistence/__init__.py @@ -9,6 +9,12 @@ load_harness_database_configurations, ) from engine.persistence.database import create_database_engine +from engine.persistence.membership_context import ( + MembershipAuthorityUnavailable, + MembershipIdentity, + MembershipNotCurrent, + PostgreSQLMembershipAuthority, +) from engine.persistence.role_guard import assert_runtime_role from engine.persistence.tenant_context import ( OrganizationContextBindingError, @@ -20,7 +26,11 @@ "DatabaseConfigurationError", "DatabasePurpose", "HarnessDatabaseConfigurations", + "MembershipAuthorityUnavailable", + "MembershipIdentity", + "MembershipNotCurrent", "OrganizationContextBindingError", + "PostgreSQLMembershipAuthority", "assert_runtime_role", "create_database_engine", "load_database_configuration", diff --git a/engine/persistence/membership_context.py b/engine/persistence/membership_context.py new file mode 100644 index 00000000..93ca5632 --- /dev/null +++ b/engine/persistence/membership_context.py @@ -0,0 +1,195 @@ +"""Current UserActor transaction boundary backed by PostgreSQL authority.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Protocol +from uuid import UUID + +from sqlalchemy import Engine, text +from sqlalchemy.exc import SQLAlchemyError + +from engine.persistence.role_guard import assert_runtime_role +from engine.runtime.actor import ( + MAX_MEMBERSHIP_VERSION, + CurrentMembershipVerification, + MembershipRejectionAuditReceipt, + _close_membership_authority_scope, + _construct_current_membership_verification, + _open_membership_authority_scope, +) + + +class MembershipNotCurrent(Exception): + """Trusted identity did not map to one current Membership.""" + + def __init__(self) -> None: + super().__init__("current Membership is not available") + self.audit_receipt = MembershipRejectionAuditReceipt() + + +class MembershipAuthorityUnavailable(RuntimeError): + """The current-Membership authority could not complete its database work.""" + + +@dataclass(frozen=True, slots=True) +class MembershipIdentity: + """Trusted identity locators used for one exact current-Membership check.""" + + organization_id: UUID + user_id: UUID + membership_id: UUID + membership_version: int + principal_ref: str + request_id: str + authentication_binding_ref: str + checked_at: datetime + + def __post_init__(self) -> None: + for field_name in ("organization_id", "user_id", "membership_id"): + if type(getattr(self, field_name)) is not UUID: + raise TypeError(f"Membership {field_name} must be UUID") + if ( + type(self.membership_version) is not int + or not 1 <= self.membership_version <= MAX_MEMBERSHIP_VERSION + ): + raise ValueError( + "Membership version must fit a positive signed 64-bit integer" + ) + for field_name in ( + "principal_ref", + "request_id", + "authentication_binding_ref", + ): + value = getattr(self, field_name) + if type(value) is not str or not value or value.isspace(): + raise ValueError(f"Membership {field_name} must be non-empty") + if ( + type(self.checked_at) is not datetime + or self.checked_at.tzinfo is None + or self.checked_at.utcoffset() != timedelta(0) + ): + raise ValueError("Membership checked_at must be an aware UTC datetime") + + +class _MembershipIdentityValue(Protocol): + def __call__(self, identity: MembershipIdentity) -> str: ... + + +_ACTOR_SETTINGS: dict[str, _MembershipIdentityValue] = { + "app.actor_kind": lambda identity: "user", + "app.authentication_binding_ref": lambda identity: ( + identity.authentication_binding_ref + ), + "app.checked_at": lambda identity: ( + identity.checked_at.isoformat().replace("+00:00", "Z") + ), + "app.membership_id": lambda identity: str(identity.membership_id), + "app.membership_version": lambda identity: str(identity.membership_version), + "app.organization_id": lambda identity: str(identity.organization_id), + "app.principal_ref": lambda identity: identity.principal_ref, + "app.request_id": lambda identity: identity.request_id, + "app.user_id": lambda identity: str(identity.user_id), +} + + +class PostgreSQLMembershipAuthority: + """Open and retain the exact UserActor transaction through Runtime work.""" + + def __init__(self, engine: Engine) -> None: + self._engine = engine + + @contextmanager + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> Iterator[CurrentMembershipVerification]: + """Bind, verify, and hold one current Membership until caller exit.""" + + if type(identity) is not MembershipIdentity: + raise TypeError("Membership identity must be MembershipIdentity") + try: + with self._engine.begin() as connection: + try: + assert_runtime_role(connection) + except AssertionError as error: + raise MembershipAuthorityUnavailable( + "current Membership authority is not the Runtime role" + ) from error + for setting_name, value_factory in _ACTOR_SETTINGS.items(): + expected = value_factory(identity) + connection.execute( + text( + "SELECT set_config(" + ":setting_name, :setting_value, true" + ")" + ), + { + "setting_name": setting_name, + "setting_value": expected, + }, + ) + observed = connection.execute( + text("SELECT current_setting(:setting_name, true)"), + {"setting_name": setting_name}, + ).scalar_one() + if observed != expected: + raise MembershipAuthorityUnavailable( + "UserActor context binding failed" + ) + + row = connection.execute( + text( + """ + SELECT user_id + FROM membership + WHERE organization_id = :organization_id + AND membership_id = :membership_id + AND user_id = :user_id + AND membership_version = :membership_version + AND status = 'active' + AND valid_from <= :checked_at + AND ( + valid_until IS NULL + OR :checked_at < valid_until + ) + """ + ), + { + "organization_id": identity.organization_id, + "membership_id": identity.membership_id, + "user_id": identity.user_id, + "membership_version": identity.membership_version, + "checked_at": identity.checked_at, + }, + ).one_or_none() + if row is None or row.user_id != identity.user_id: + raise MembershipNotCurrent + + scope = _open_membership_authority_scope() + try: + verification = _construct_current_membership_verification( + organization_id=identity.organization_id, + user_id=identity.user_id, + membership_id=identity.membership_id, + membership_version=identity.membership_version, + principal_ref=identity.principal_ref, + request_id=identity.request_id, + authentication_binding_ref=( + identity.authentication_binding_ref + ), + checked_at=identity.checked_at, + authority_scope=scope, + ) + yield verification + finally: + _close_membership_authority_scope(scope) + except (MembershipNotCurrent, MembershipAuthorityUnavailable): + raise + except SQLAlchemyError as error: + raise MembershipAuthorityUnavailable( + "current Membership authority unavailable" + ) from error diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index 2861737d..55239917 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,5 +1,5 @@ { - "manifestVersion": "1.0.0", + "manifestVersion": "2.0.0", "tables": [ { "name": "alembic_version", @@ -21,10 +21,109 @@ }, "partitions": [] }, + { + "name": "user_account", + "classification": "global", + "purpose": "Global user identity root without tenant rights", + "primaryKey": { + "name": "pk_user_account", + "columns": ["user_id"] + }, + "permittedOperations": { + "context_engine_runtime": [], + "context_engine_worker": [] + }, + "partitions": [] + }, + { + "name": "membership", + "classification": "tenant_owned", + "purpose": "Organization-scoped current UserActor membership authority", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_membership", + "kind": "primary_key", + "columns": ["organization_id", "membership_id"] + }, + { + "name": "uq_membership_organization_user", + "kind": "unique", + "columns": ["organization_id", "user_id"] + } + ], + "foreignKeys": [ + { + "name": "fk_membership_organization", + "columns": ["organization_id"], + "references": { + "table": "organization", + "columns": ["organization_id"] + } + }, + { + "name": "fk_membership_user_account", + "columns": ["user_id"], + "references": { + "table": "user_account", + "columns": ["user_id"] + } + } + ], + "checkConstraints": [ + { + "name": "ck_membership_status", + "expression": "status IN ('active', 'inactive', 'revoked')" + }, + { + "name": "ck_membership_version_positive", + "expression": "membership_version > 0" + }, + { + "name": "ck_membership_valid_interval", + "expression": "valid_until IS NULL OR valid_until > valid_from" + } + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + { + "name": "membership_current_user_actor", + "command": "SELECT", + "roles": ["context_engine_runtime"], + "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid AND current_setting('app.actor_kind', true) = 'user' AND user_id = NULLIF(current_setting('app.user_id', true), '')::uuid AND NULLIF(current_setting('app.principal_ref', true), '') IS NOT NULL AND membership_id = NULLIF(current_setting('app.membership_id', true), '')::uuid AND membership_version = NULLIF(current_setting('app.membership_version', true), '')::bigint AND NULLIF(current_setting('app.request_id', true), '') IS NOT NULL AND NULLIF(current_setting('app.authentication_binding_ref', true), '') IS NOT NULL AND NULLIF(current_setting('app.checked_at', true), '') IS NOT NULL AND status = 'active' AND valid_from <= NULLIF(current_setting('app.checked_at', true), '')::timestamptz AND (valid_until IS NULL OR valid_until > NULLIF(current_setting('app.checked_at', true), '')::timestamptz)" + }, + { + "name": "membership_migrator_administration", + "command": "ALL", + "roles": ["context_engine_migrator"], + "using": "true", + "withCheck": "true" + } + ] + }, + "permittedOperations": { + "context_engine_runtime": ["SELECT"], + "context_engine_worker": [] + }, + "partitions": [], + "securityInvariantIds": [ + "TENANT-OWNERSHIP-001", + "RLS-FAIL-CLOSED-003" + ], + "negativeTestIds": [ + "DB-001", + "DB-002", + "DB-004", + "DB-009", + "DB-010" + ] + }, { "name": "organization_record", "classification": "tenant_owned", - "purpose": "Representative tenant-owned record for the first isolation slice", + "purpose": "Representative tenant-owned record protected by current UserActor Membership", "organizationColumn": "organization_id", "organizationInclusiveKeys": [ { @@ -59,8 +158,15 @@ "name": "organization_record_organization_isolation", "command": "ALL", "roles": ["context_engine_runtime"], - "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid", - "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid AND EXISTS (SELECT 1 FROM public.membership AS actor_membership WHERE actor_membership.organization_id = organization_record.organization_id AND actor_membership.organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid AND current_setting('app.actor_kind', true) = 'user' AND actor_membership.user_id = NULLIF(current_setting('app.user_id', true), '')::uuid AND NULLIF(current_setting('app.principal_ref', true), '') IS NOT NULL AND actor_membership.membership_id = NULLIF(current_setting('app.membership_id', true), '')::uuid AND actor_membership.membership_version = NULLIF(current_setting('app.membership_version', true), '')::bigint AND NULLIF(current_setting('app.request_id', true), '') IS NOT NULL AND NULLIF(current_setting('app.authentication_binding_ref', true), '') IS NOT NULL AND NULLIF(current_setting('app.checked_at', true), '') IS NOT NULL AND actor_membership.status = 'active' AND actor_membership.valid_from <= NULLIF(current_setting('app.checked_at', true), '')::timestamptz AND (actor_membership.valid_until IS NULL OR actor_membership.valid_until > NULLIF(current_setting('app.checked_at', true), '')::timestamptz))", + "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid AND EXISTS (SELECT 1 FROM public.membership AS actor_membership WHERE actor_membership.organization_id = organization_record.organization_id AND actor_membership.organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid AND current_setting('app.actor_kind', true) = 'user' AND actor_membership.user_id = NULLIF(current_setting('app.user_id', true), '')::uuid AND NULLIF(current_setting('app.principal_ref', true), '') IS NOT NULL AND actor_membership.membership_id = NULLIF(current_setting('app.membership_id', true), '')::uuid AND actor_membership.membership_version = NULLIF(current_setting('app.membership_version', true), '')::bigint AND NULLIF(current_setting('app.request_id', true), '') IS NOT NULL AND NULLIF(current_setting('app.authentication_binding_ref', true), '') IS NOT NULL AND NULLIF(current_setting('app.checked_at', true), '') IS NOT NULL AND actor_membership.status = 'active' AND actor_membership.valid_from <= NULLIF(current_setting('app.checked_at', true), '')::timestamptz AND (actor_membership.valid_until IS NULL OR actor_membership.valid_until > NULLIF(current_setting('app.checked_at', true), '')::timestamptz))" + }, + { + "name": "organization_record_migrator_administration", + "command": "ALL", + "roles": ["context_engine_migrator"], + "using": "true", + "withCheck": "true" } ], "writeContextGuard": { @@ -69,7 +175,9 @@ "timing": "BEFORE", "orientation": "STATEMENT", "events": ["INSERT", "UPDATE", "DELETE"], - "missingContextSqlstate": "42501" + "missingContextSqlstate": "42501", + "requiredActorKind": "user", + "requiresCurrentMembership": true } }, "permittedOperations": { @@ -90,6 +198,8 @@ "DB-006", "DB-007", "DB-008", + "DB-009", + "DB-010", "MIG-001", "MIG-002" ] diff --git a/engine/runtime/__init__.py b/engine/runtime/__init__.py index 110ca7b8..a9ccd1ff 100644 --- a/engine/runtime/__init__.py +++ b/engine/runtime/__init__.py @@ -1,5 +1,13 @@ """Sealed Runtime boundary and first evidence-free delivery contracts.""" +from engine.runtime.actor import ( + CurrentMembershipVerification, + MembershipRejectionAuditReceipt, + MembershipRejectionCategory, + MembershipVerificationProvenance, + UserActor, + UserActorConstructionProvenance, +) from engine.runtime.budget import ( PackageBudget, PackageBudgetRequest, @@ -39,7 +47,11 @@ "Coverage", "CoverageReason", "CoverageStatus", + "CurrentMembershipVerification", "InvocationConstructionProvenance", + "MembershipVerificationProvenance", + "MembershipRejectionAuditReceipt", + "MembershipRejectionCategory", "PackageBudget", "PackageBudgetRequest", "RequestNarrowing", @@ -47,5 +59,7 @@ "Runtime", "RuntimeConfigurationError", "TrustedDeliveryContext", + "UserActor", + "UserActorConstructionProvenance", "effective_package_budget", ] diff --git a/engine/runtime/actor.py b/engine/runtime/actor.py new file mode 100644 index 00000000..f45524a4 --- /dev/null +++ b/engine/runtime/actor.py @@ -0,0 +1,266 @@ +"""Nominal current-Membership and online UserActor authority contracts.""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Final, Literal, NoReturn +from uuid import UUID + +MAX_MEMBERSHIP_VERSION: Final = (1 << 63) - 1 + + +class MembershipVerificationProvenance(StrEnum): + """Closed provenance for a current Membership authority decision.""" + + TRUSTED_MEMBERSHIP_AUTHORITY = "trusted_membership_authority" + + +class UserActorConstructionProvenance(StrEnum): + """Closed provenance for an online actor derived from current Membership.""" + + TRUSTED_MEMBERSHIP_AUTHORITY = "trusted_membership_authority" + + +class MembershipRejectionCategory(StrEnum): + """Sole restricted category for every non-current Membership decision.""" + + NOT_CURRENT = "membership_not_current" + + +@dataclass(frozen=True, slots=True) +class MembershipRejectionAuditReceipt: + """Safe in-memory audit carrier with no denied identity detail.""" + + category: MembershipRejectionCategory = MembershipRejectionCategory.NOT_CURRENT + denied_detail_count: Literal[0] = 0 + + +class _MembershipAuthorityScope: + """Private lifetime token owned by one trusted authority operation.""" + + __slots__ = ("_active", "_seal") + _active: bool + _seal: object + + def __init__(self) -> None: + raise TypeError("Membership authority scopes are not publicly constructible") + + def __reduce__(self) -> NoReturn: + raise TypeError("Membership authority scopes are not serializable") + + +_MEMBERSHIP_AUTHORITY_SCOPE_SEAL = object() + + +def _open_membership_authority_scope() -> _MembershipAuthorityScope: + scope = object.__new__(_MembershipAuthorityScope) + scope._active = True + scope._seal = _MEMBERSHIP_AUTHORITY_SCOPE_SEAL + return scope + + +def _close_membership_authority_scope(scope: _MembershipAuthorityScope) -> None: + if ( + type(scope) is not _MembershipAuthorityScope + or getattr(scope, "_seal", None) is not _MEMBERSHIP_AUTHORITY_SCOPE_SEAL + ): + raise TypeError("Membership authority scope has the wrong nominal type") + scope._active = False + + +@dataclass(frozen=True, slots=True, init=False) +class CurrentMembershipVerification: + """Request-bound proof that one Membership was current when checked.""" + + organization_id: UUID + user_id: UUID + membership_id: UUID + membership_version: int + principal_ref: str + request_id: str + authentication_binding_ref: str + checked_at: datetime + construction_provenance: MembershipVerificationProvenance + _authority_scope: _MembershipAuthorityScope = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError( + "CurrentMembershipVerification can only be constructed by a trusted " + "Membership authority" + ) + + def __reduce__(self) -> NoReturn: + raise TypeError("CurrentMembershipVerification is not serializable") + + +def _construct_current_membership_verification( + *, + authority_scope: _MembershipAuthorityScope, + organization_id: UUID, + user_id: UUID, + membership_id: UUID, + membership_version: int, + principal_ref: str, + request_id: str, + authentication_binding_ref: str, + checked_at: datetime, +) -> CurrentMembershipVerification: + """Construct proof after the trusted authority verifies the durable row.""" + + if ( + type(authority_scope) is not _MembershipAuthorityScope + or getattr(authority_scope, "_seal", None) + is not _MEMBERSHIP_AUTHORITY_SCOPE_SEAL + or not getattr(authority_scope, "_active", False) + ): + raise ValueError( + "current Membership requires an active Membership authority scope" + ) + uuid_facts: tuple[tuple[str, object], ...] = ( + ("organization_id", organization_id), + ("user_id", user_id), + ("membership_id", membership_id), + ) + for field_name, value in uuid_facts: + if type(value) is not UUID: + raise TypeError(f"current Membership {field_name} must be UUID") + if ( + type(membership_version) is not int + or not 1 <= membership_version <= MAX_MEMBERSHIP_VERSION + ): + raise ValueError( + "current Membership version must fit a positive signed 64-bit integer" + ) + for field_name, value in ( + ("principal_ref", principal_ref), + ("request_id", request_id), + ("authentication_binding_ref", authentication_binding_ref), + ): + if type(value) is not str or not value or value.isspace(): + raise ValueError(f"current Membership {field_name} must be non-empty") + if ( + type(checked_at) is not datetime + or checked_at.tzinfo is None + or checked_at.utcoffset() != timedelta(0) + ): + raise ValueError("current Membership checked_at must be timezone-aware UTC") + + verification = object.__new__(CurrentMembershipVerification) + object.__setattr__(verification, "organization_id", organization_id) + object.__setattr__(verification, "user_id", user_id) + object.__setattr__(verification, "membership_id", membership_id) + object.__setattr__(verification, "membership_version", membership_version) + object.__setattr__(verification, "principal_ref", principal_ref) + object.__setattr__(verification, "request_id", request_id) + object.__setattr__( + verification, + "authentication_binding_ref", + authentication_binding_ref, + ) + object.__setattr__(verification, "checked_at", checked_at) + object.__setattr__( + verification, + "construction_provenance", + MembershipVerificationProvenance.TRUSTED_MEMBERSHIP_AUTHORITY, + ) + object.__setattr__(verification, "_authority_scope", authority_scope) + return verification + + +def _require_active_current_membership_verification( + verification: CurrentMembershipVerification, +) -> None: + """Reject proofs used outside their exact trusted authority operation.""" + + if type(verification) is not CurrentMembershipVerification: + raise TypeError("current Membership proof has the wrong nominal type") + if ( + verification.construction_provenance + is not MembershipVerificationProvenance.TRUSTED_MEMBERSHIP_AUTHORITY + or type(verification._authority_scope) is not _MembershipAuthorityScope + or getattr(verification._authority_scope, "_seal", None) + is not _MEMBERSHIP_AUTHORITY_SCOPE_SEAL + or not getattr(verification._authority_scope, "_active", False) + ): + raise ValueError( + "current Membership proof requires active Membership authority scope" + ) + + +@dataclass(frozen=True, slots=True, init=False) +class UserActor: + """Online actor bound to one current User/Membership authority decision.""" + + organization_id: UUID + user_id: UUID + membership_id: UUID + membership_version: int + principal_ref: str + request_id: str + authentication_binding_ref: str + checked_at: datetime + current_membership_verification: CurrentMembershipVerification = field(repr=False) + construction_provenance: UserActorConstructionProvenance + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError( + "UserActor can only be constructed by a trusted Membership authority" + ) + + def __reduce__(self) -> NoReturn: + raise TypeError("UserActor is not serializable") + + +def _construct_user_actor( + verification: CurrentMembershipVerification, +) -> UserActor: + """Construct the online actor from one active nominal authority proof.""" + + _require_active_current_membership_verification(verification) + actor = object.__new__(UserActor) + for field_name, value in ( + ("organization_id", verification.organization_id), + ("user_id", verification.user_id), + ("membership_id", verification.membership_id), + ("membership_version", verification.membership_version), + ("principal_ref", verification.principal_ref), + ("request_id", verification.request_id), + ( + "authentication_binding_ref", + verification.authentication_binding_ref, + ), + ("checked_at", verification.checked_at), + ("current_membership_verification", verification), + ( + "construction_provenance", + UserActorConstructionProvenance.TRUSTED_MEMBERSHIP_AUTHORITY, + ), + ): + object.__setattr__(actor, field_name, value) + return actor + + +def _require_active_user_actor(actor: UserActor) -> None: + """Validate actor nominality, binding integrity, and authority lifetime.""" + + if type(actor) is not UserActor: + raise TypeError("UserActor has the wrong nominal type") + if ( + actor.construction_provenance + is not UserActorConstructionProvenance.TRUSTED_MEMBERSHIP_AUTHORITY + ): + raise ValueError("UserActor has invalid construction provenance") + verification = actor.current_membership_verification + _require_active_current_membership_verification(verification) + if ( + actor.organization_id != verification.organization_id + or actor.user_id != verification.user_id + or actor.membership_id != verification.membership_id + or actor.membership_version != verification.membership_version + or actor.principal_ref != verification.principal_ref + or actor.request_id != verification.request_id + or actor.authentication_binding_ref + != verification.authentication_binding_ref + or actor.checked_at != verification.checked_at + ): + raise ValueError("UserActor does not match its current Membership proof") diff --git a/engine/runtime/construction.py b/engine/runtime/construction.py index 6c94b8b9..ab3623cc 100644 --- a/engine/runtime/construction.py +++ b/engine/runtime/construction.py @@ -11,6 +11,7 @@ from threading import Lock from typing import Literal +from engine.runtime.actor import _require_active_user_actor from engine.runtime.budget import PackageBudget, effective_package_budget from engine.runtime.content_io import RuntimeContentIo, prohibited_empty_path_content_io from engine.runtime.contracts import ( @@ -110,6 +111,20 @@ def _validate_trusted_operands( raise ValueError( "Runtime requires a matching existing-Organization verification" ) + actor = invocation.user_actor + _require_active_user_actor(actor) + if ( + str(actor.organization_id) != invocation.organization_ref + or str(actor.user_id) != invocation.user_ref + or str(actor.membership_id) != invocation.membership_ref + or actor.membership_version != invocation.membership_version + or actor.principal_ref != invocation.principal_ref + or actor.request_id != invocation.request_id + or actor.authentication_binding_ref + != invocation.authentication_binding_ref + or actor.checked_at != invocation.received_at + ): + raise ValueError("Runtime requires a matching current UserActor") if ( type(delivery_context) is not TrustedDeliveryContext or delivery_context.construction_provenance diff --git a/engine/runtime/invocation.py b/engine/runtime/invocation.py index a0012c04..a0725d3c 100644 --- a/engine/runtime/invocation.py +++ b/engine/runtime/invocation.py @@ -1,9 +1,16 @@ """Nominal trusted invocation constructed only by authenticated ingress.""" -from dataclasses import dataclass -from datetime import datetime +from dataclasses import dataclass, field +from datetime import datetime, timedelta from enum import StrEnum +from uuid import UUID +from engine.runtime.actor import ( + MAX_MEMBERSHIP_VERSION, + CurrentMembershipVerification, + UserActor, + _construct_user_actor, +) from engine.runtime.organization import ( ExistingOrganizationVerification, OrganizationVerificationProvenance, @@ -22,13 +29,16 @@ class AuthenticatedInvocation: request_id: str organization_ref: str + user_ref: str principal_ref: str - membership_ref: str | None + membership_ref: str + membership_version: int agent_version_ref: str authenticated_application_ref: str authentication_binding_ref: str received_at: datetime organization_verification: ExistingOrganizationVerification + user_actor: UserActor = field(repr=False) construction_provenance: InvocationConstructionProvenance def __init__(self, *args: object, **kwargs: object) -> None: @@ -42,8 +52,11 @@ def _construct_authenticated_http_invocation( request_id: str, authenticated_organization_ref: str, organization_verification: ExistingOrganizationVerification, + user_ref: str, principal_ref: str, - membership_ref: str | None, + membership_ref: str, + membership_version: int, + current_membership_verification: CurrentMembershipVerification, agent_version_ref: str, authenticated_application_ref: str, authentication_binding_ref: str, @@ -54,18 +67,43 @@ def _construct_authenticated_http_invocation( required_refs = { "request_id": request_id, "authenticated_organization_ref": authenticated_organization_ref, + "user_ref": user_ref, "principal_ref": principal_ref, + "membership_ref": membership_ref, "agent_version_ref": agent_version_ref, "authenticated_application_ref": authenticated_application_ref, "authentication_binding_ref": authentication_binding_ref, } for field_name, value in required_refs.items(): - if not value or value.isspace(): + if type(value) is not str or not value or value.isspace(): raise ValueError(f"trusted invocation {field_name} must be non-empty") - if membership_ref is not None and (not membership_ref or membership_ref.isspace()): - raise ValueError("trusted invocation membership_ref must be non-empty") - if received_at.tzinfo is None or received_at.utcoffset() is None: - raise ValueError("trusted invocation received_at must be timezone-aware") + for field_name, value in ( + ("authenticated_organization_ref", authenticated_organization_ref), + ("user_ref", user_ref), + ("membership_ref", membership_ref), + ): + try: + canonical_ref = str(UUID(value)) + except ValueError: + raise ValueError( + f"trusted invocation {field_name} must be an internal UUID" + ) from None + if value != canonical_ref: + raise ValueError(f"trusted invocation {field_name} must be canonical") + if ( + type(membership_version) is not int + or not 1 <= membership_version <= MAX_MEMBERSHIP_VERSION + ): + raise ValueError( + "trusted invocation membership_version must fit a positive signed " + "64-bit integer" + ) + if ( + type(received_at) is not datetime + or received_at.tzinfo is None + or received_at.utcoffset() != timedelta(0) + ): + raise ValueError("trusted invocation received_at must be timezone-aware UTC") if type(organization_verification) is not ExistingOrganizationVerification: raise TypeError( "trusted invocation requires ExistingOrganizationVerification" @@ -83,6 +121,20 @@ def _construct_authenticated_http_invocation( raise ValueError( "trusted invocation Organization verification must match authentication" ) + user_actor = _construct_user_actor(current_membership_verification) + if ( + str(user_actor.organization_id) != authenticated_organization_ref + or str(user_actor.user_id) != user_ref + or str(user_actor.membership_id) != membership_ref + or user_actor.membership_version != membership_version + or user_actor.principal_ref != principal_ref + or user_actor.request_id != request_id + or user_actor.authentication_binding_ref != authentication_binding_ref + or user_actor.checked_at != received_at + ): + raise ValueError( + "trusted invocation current Membership must match authentication" + ) invocation = object.__new__(AuthenticatedInvocation) object.__setattr__(invocation, "request_id", request_id) @@ -91,8 +143,10 @@ def _construct_authenticated_http_invocation( "organization_ref", authenticated_organization_ref, ) + object.__setattr__(invocation, "user_ref", user_ref) object.__setattr__(invocation, "principal_ref", principal_ref) object.__setattr__(invocation, "membership_ref", membership_ref) + object.__setattr__(invocation, "membership_version", membership_version) object.__setattr__(invocation, "agent_version_ref", agent_version_ref) object.__setattr__( invocation, @@ -110,6 +164,7 @@ def _construct_authenticated_http_invocation( "organization_verification", organization_verification, ) + object.__setattr__(invocation, "user_actor", user_actor) object.__setattr__( invocation, "construction_provenance", diff --git a/migrations/versions/20260721_0003_membership_user_actor.py b/migrations/versions/20260721_0003_membership_user_actor.py new file mode 100644 index 00000000..831a0467 --- /dev/null +++ b/migrations/versions/20260721_0003_membership_user_actor.py @@ -0,0 +1,245 @@ +"""Add User/Membership identity and require a current UserActor for tenant rows. + +Revision ID: 20260721_0003 +Revises: 20260720_0002 +Create Date: 2026-07-21 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260721_0003" +down_revision: str | None = "20260720_0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_MIGRATOR_ROLE = "context_engine_migrator" +_RUNTIME_ROLE = "context_engine_runtime" +_WORKER_ROLE = "context_engine_worker" +_RECORD_POLICY = "organization_record_organization_isolation" +_MEMBERSHIP_RUNTIME_POLICY = "membership_current_user_actor" +_MEMBERSHIP_MIGRATOR_POLICY = "membership_migrator_administration" +_RECORD_MIGRATOR_POLICY = "organization_record_migrator_administration" +_WRITE_CONTEXT_GUARD_FUNCTION = "public.organization_record_require_write_context" + +_CURRENT_MEMBERSHIP_EXPRESSION = """ +organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid +AND current_setting('app.actor_kind', true) = 'user' +AND user_id = NULLIF(current_setting('app.user_id', true), '')::uuid +AND NULLIF(current_setting('app.principal_ref', true), '') IS NOT NULL +AND membership_id = NULLIF(current_setting('app.membership_id', true), '')::uuid +AND membership_version = NULLIF( + current_setting('app.membership_version', true), '' +)::bigint +AND NULLIF(current_setting('app.request_id', true), '') IS NOT NULL +AND NULLIF( + current_setting('app.authentication_binding_ref', true), '' +) IS NOT NULL +AND NULLIF(current_setting('app.checked_at', true), '') IS NOT NULL +AND status = 'active' +AND valid_from <= NULLIF( + current_setting('app.checked_at', true), '' +)::timestamptz +AND ( + valid_until IS NULL + OR valid_until > NULLIF( + current_setting('app.checked_at', true), '' + )::timestamptz +) +""".strip() + +_CURRENT_RECORD_MEMBERSHIP_EXPRESSION = """ +organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid +AND EXISTS ( + SELECT 1 + FROM public.membership AS actor_membership + WHERE actor_membership.organization_id = organization_record.organization_id + AND actor_membership.organization_id = NULLIF( + current_setting('app.organization_id', true), '' + )::uuid + AND current_setting('app.actor_kind', true) = 'user' + AND actor_membership.user_id = NULLIF( + current_setting('app.user_id', true), '' + )::uuid + AND NULLIF(current_setting('app.principal_ref', true), '') IS NOT NULL + AND actor_membership.membership_id = NULLIF( + current_setting('app.membership_id', true), '' + )::uuid + AND actor_membership.membership_version = NULLIF( + current_setting('app.membership_version', true), '' + )::bigint + AND NULLIF(current_setting('app.request_id', true), '') IS NOT NULL + AND NULLIF( + current_setting('app.authentication_binding_ref', true), '' + ) IS NOT NULL + AND NULLIF(current_setting('app.checked_at', true), '') IS NOT NULL + AND actor_membership.status = 'active' + AND actor_membership.valid_from <= NULLIF( + current_setting('app.checked_at', true), '' + )::timestamptz + AND ( + actor_membership.valid_until IS NULL + OR actor_membership.valid_until > NULLIF( + current_setting('app.checked_at', true), '' + )::timestamptz + ) +) +""".strip() + + +def upgrade() -> None: + """Create the minimum current-Membership authority and activate UserActor RLS.""" + + op.create_table( + "user_account", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.PrimaryKeyConstraint("user_id", name="pk_user_account"), + ) + op.create_table( + "membership", + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("membership_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("membership_version", sa.BigInteger(), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint( + "organization_id", + "membership_id", + name="pk_membership", + ), + sa.UniqueConstraint( + "organization_id", + "user_id", + name="uq_membership_organization_user", + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.organization_id"], + name="fk_membership_organization", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user_account.user_id"], + name="fk_membership_user_account", + ), + sa.CheckConstraint( + "status IN ('active', 'inactive', 'revoked')", + name="ck_membership_status", + ), + sa.CheckConstraint( + "membership_version > 0", + name="ck_membership_version_positive", + ), + sa.CheckConstraint( + "valid_until IS NULL OR valid_until > valid_from", + name="ck_membership_valid_interval", + ), + ) + + for table_name in ("user_account", "membership"): + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM PUBLIC") + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM {_RUNTIME_ROLE}") + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM {_WORKER_ROLE}") + + op.execute("ALTER TABLE membership ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE membership FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY {_MEMBERSHIP_RUNTIME_POLICY} " + "ON membership AS PERMISSIVE FOR SELECT " + f"TO {_RUNTIME_ROLE} USING ({_CURRENT_MEMBERSHIP_EXPRESSION})" + ) + op.execute( + f"CREATE POLICY {_MEMBERSHIP_MIGRATOR_POLICY} " + "ON membership AS PERMISSIVE FOR ALL " + f"TO {_MIGRATOR_ROLE} USING (true) WITH CHECK (true)" + ) + op.execute(f"GRANT SELECT ON TABLE membership TO {_RUNTIME_ROLE}") + + op.execute(f"DROP POLICY {_RECORD_POLICY} ON organization_record") + op.execute( + f"CREATE POLICY {_RECORD_POLICY} " + "ON organization_record AS PERMISSIVE FOR ALL " + f"TO {_RUNTIME_ROLE} " + f"USING ({_CURRENT_RECORD_MEMBERSHIP_EXPRESSION}) " + f"WITH CHECK ({_CURRENT_RECORD_MEMBERSHIP_EXPRESSION})" + ) + op.execute( + f"CREATE POLICY {_RECORD_MIGRATOR_POLICY} " + "ON organization_record AS PERMISSIVE FOR ALL " + f"TO {_MIGRATOR_ROLE} USING (true) WITH CHECK (true)" + ) + op.execute( + f""" + CREATE OR REPLACE FUNCTION {_WRITE_CONTEXT_GUARD_FUNCTION}() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog + AS $function$ + BEGIN + IF current_user = '{_MIGRATOR_ROLE}' THEN + RETURN NULL; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.membership AS actor_membership + WHERE {_CURRENT_MEMBERSHIP_EXPRESSION} + ) THEN + RAISE EXCEPTION USING + ERRCODE = '42501', + MESSAGE = + 'current UserActor Membership is required for tenant writes'; + END IF; + RETURN NULL; + END; + $function$ + """ + ) + + +def downgrade() -> None: + """Restore the Organization-only Issue #8 evidence boundary.""" + + op.execute( + f""" + CREATE OR REPLACE FUNCTION {_WRITE_CONTEXT_GUARD_FUNCTION}() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog + AS $function$ + BEGIN + IF NULLIF( + current_setting('app.organization_id', true), + '' + ) IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '42501', + MESSAGE = 'organization context is required for tenant writes'; + END IF; + RETURN NULL; + END; + $function$ + """ + ) + op.execute(f"DROP POLICY {_RECORD_MIGRATOR_POLICY} ON organization_record") + op.execute(f"DROP POLICY {_RECORD_POLICY} ON organization_record") + tenant_expression = ( + "organization_id = NULLIF(" + "current_setting('app.organization_id', true), ''" + ")::uuid" + ) + op.execute( + f"CREATE POLICY {_RECORD_POLICY} " + "ON organization_record AS PERMISSIVE FOR ALL " + f"TO {_RUNTIME_ROLE} USING ({tenant_expression}) " + f"WITH CHECK ({tenant_expression})" + ) + op.execute(f"REVOKE SELECT ON TABLE membership FROM {_RUNTIME_ROLE}") + op.drop_table("membership") + op.drop_table("user_account") diff --git a/tests/integration/test_membership_schema.py b/tests/integration/test_membership_schema.py new file mode 100644 index 00000000..98b6f79d --- /dev/null +++ b/tests/integration/test_membership_schema.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import Connection, Engine, text +from sqlalchemy.exc import DBAPIError, IntegrityError + +from engine.persistence import DatabaseConfiguration, create_database_engine +from engine.persistence.configuration import RUNTIME_ROLE, WORKER_ROLE + +pytestmark = pytest.mark.integration +CHECKED_AT = datetime(2026, 7, 21, 8, 0, tzinfo=UTC) + + +@dataclass(frozen=True, slots=True) +class IdentityFixture: + organization_a: UUID + organization_b: UUID + user_a: UUID + user_b: UUID + user_without_membership: UUID + membership_a: UUID + membership_b: UUID + + +@contextmanager +def user_actor_connection( + engine: Engine, + *, + organization_id: UUID, + user_id: UUID, + membership_id: UUID, + membership_version: int = 1, + checked_at: datetime = CHECKED_AT, +) -> Iterator[Connection]: + settings = { + "app.organization_id": str(organization_id), + "app.actor_kind": "user", + "app.user_id": str(user_id), + "app.membership_id": str(membership_id), + "app.membership_version": str(membership_version), + "app.principal_ref": f"principal:{user_id}", + "app.request_id": f"request:{uuid4()}", + "app.authentication_binding_ref": f"binding:{uuid4()}", + "app.checked_at": checked_at.isoformat().replace("+00:00", "Z"), + } + with engine.begin() as connection: + for setting_name, setting_value in settings.items(): + connection.execute( + text("SELECT set_config(:name, :value, true)"), + {"name": setting_name, "value": setting_value}, + ) + yield connection + + +@pytest.fixture +def identities( + migration_configuration: DatabaseConfiguration, +) -> Iterator[IdentityFixture]: + repeated_membership_id = uuid4() + identity = IdentityFixture( + organization_a=uuid4(), + organization_b=uuid4(), + user_a=uuid4(), + user_b=uuid4(), + user_without_membership=uuid4(), + membership_a=repeated_membership_id, + membership_b=repeated_membership_id, + ) + engine = create_database_engine(migration_configuration) + try: + with engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO organization (organization_id) + VALUES (:organization_a), (:organization_b) + """ + ), + asdict(identity), + ) + connection.execute( + text( + """ + INSERT INTO user_account (user_id) + VALUES (:user_a), (:user_b), (:user_without_membership) + """ + ), + asdict(identity), + ) + connection.execute( + text( + """ + INSERT INTO membership ( + organization_id, membership_id, user_id, status, + membership_version, valid_from, valid_until + ) VALUES + ( + :organization_a, :membership_a, :user_a, 'active', + 1, :valid_from, NULL + ), + ( + :organization_b, :membership_b, :user_b, 'active', + 1, :valid_from, NULL + ) + """ + ), + { + **asdict(identity), + "valid_from": CHECKED_AT - timedelta(days=1), + }, + ) + connection.execute( + text( + """ + INSERT INTO organization_record ( + organization_id, record_id, parent_record_id, payload + ) VALUES + (:organization_a, :record_a, NULL, 'organization-a'), + (:organization_b, :record_b, NULL, 'organization-b') + """ + ), + { + **asdict(identity), + "record_a": uuid4(), + "record_b": uuid4(), + }, + ) + yield identity + finally: + with engine.begin() as connection: + parameters = asdict(identity) + connection.execute( + text( + """ + DELETE FROM organization_record + WHERE organization_id IN (:organization_a, :organization_b) + """ + ), + parameters, + ) + connection.execute( + text( + """ + DELETE FROM membership + WHERE organization_id IN (:organization_a, :organization_b) + """ + ), + parameters, + ) + connection.execute( + text( + """ + DELETE FROM user_account + WHERE user_id IN (:user_a, :user_b, :user_without_membership) + """ + ), + parameters, + ) + connection.execute( + text( + """ + DELETE FROM organization + WHERE organization_id IN (:organization_a, :organization_b) + """ + ), + parameters, + ) + engine.dispose() + + +def membership_rows(connection: Connection) -> list[tuple[UUID, UUID, UUID]]: + return [ + (row.organization_id, row.membership_id, row.user_id) + for row in connection.execute( + text( + """ + SELECT organization_id, membership_id, user_id + FROM membership + ORDER BY organization_id + """ + ) + ) + ] + + +def test_runtime_membership_rls_is_bidirectional_and_exact( + guarded_runtime_engine: Engine, + identities: IdentityFixture, +) -> None: + assert identities.membership_a == identities.membership_b + directions = ( + ( + identities.organization_a, + identities.user_a, + identities.membership_a, + ), + ( + identities.organization_b, + identities.user_b, + identities.membership_b, + ), + ) + for organization_id, user_id, membership_id in directions: + with user_actor_connection( + guarded_runtime_engine, + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + ) as connection: + assert membership_rows(connection) == [ + (organization_id, membership_id, user_id) + ] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 1 + + with user_actor_connection( + guarded_runtime_engine, + organization_id=identities.organization_a, + user_id=identities.user_b, + membership_id=identities.membership_b, + ) as connection: + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + + +@pytest.mark.parametrize( + ("status", "version", "valid_from_delta", "valid_until_delta"), + [ + ("inactive", 1, timedelta(days=-1), None), + ("revoked", 1, timedelta(days=-1), None), + ("active", 2, timedelta(days=-1), None), + ("active", 1, timedelta(seconds=1), None), + ("active", 1, timedelta(days=-2), timedelta(seconds=-1)), + ("active", 1, timedelta(days=-2), timedelta(0)), + ], +) +def test_status_version_and_validity_fail_closed_at_the_database_seam( + guarded_runtime_engine: Engine, + migration_configuration: DatabaseConfiguration, + identities: IdentityFixture, + status: str, + version: int, + valid_from_delta: timedelta, + valid_until_delta: timedelta | None, +) -> None: + migration_engine = create_database_engine(migration_configuration) + try: + with migration_engine.begin() as connection: + connection.execute( + text( + """ + UPDATE membership + SET status = :status, + membership_version = :version, + valid_from = :valid_from, + valid_until = :valid_until + WHERE organization_id = :organization_id + AND membership_id = :membership_id + """ + ), + { + "status": status, + "version": version, + "valid_from": CHECKED_AT + valid_from_delta, + "valid_until": ( + None + if valid_until_delta is None + else CHECKED_AT + valid_until_delta + ), + "organization_id": identities.organization_a, + "membership_id": identities.membership_a, + }, + ) + with user_actor_connection( + guarded_runtime_engine, + organization_id=identities.organization_a, + user_id=identities.user_a, + membership_id=identities.membership_a, + ) as connection: + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + finally: + migration_engine.dispose() + + +def test_organization_only_and_user_only_context_have_no_tenant_rights( + guarded_runtime_engine: Engine, + identities: IdentityFixture, +) -> None: + with guarded_runtime_engine.begin() as connection: + connection.execute( + text("SELECT set_config('app.organization_id', :value, true)"), + {"value": str(identities.organization_a)}, + ) + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + with pytest.raises(DBAPIError, match="current UserActor Membership"): + connection.execute( + text( + """ + INSERT INTO organization_record ( + organization_id, record_id, parent_record_id, payload + ) VALUES (:organization_id, :record_id, NULL, 'denied') + """ + ), + { + "organization_id": identities.organization_a, + "record_id": uuid4(), + }, + ) + + with user_actor_connection( + guarded_runtime_engine, + organization_id=identities.organization_a, + user_id=identities.user_without_membership, + membership_id=uuid4(), + ) as connection: + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + with pytest.raises(DBAPIError, match="current UserActor Membership"): + connection.execute( + text( + """ + INSERT INTO organization_record ( + organization_id, record_id, parent_record_id, payload + ) VALUES (:organization_id, :record_id, NULL, 'denied') + """ + ), + { + "organization_id": identities.organization_a, + "record_id": uuid4(), + }, + ) + + with guarded_runtime_engine.begin() as connection: + connection.execute( + text("SELECT set_config('app.actor_kind', 'user', true)") + ) + connection.execute( + text("SELECT set_config('app.user_id', :value, true)"), + {"value": str(identities.user_a)}, + ) + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + + +def test_global_user_and_membership_constraints_reject_orphans_and_invalid_rows( + migration_configuration: DatabaseConfiguration, + identities: IdentityFixture, +) -> None: + engine = create_database_engine(migration_configuration) + try: + invalid_rows = ( + ( + {"organization": uuid4(), "user": identities.user_a}, + "fk_membership_organization", + ), + ( + {"organization": identities.organization_a, "user": uuid4()}, + "fk_membership_user_account", + ), + ) + for values, constraint in invalid_rows: + with ( + pytest.raises(IntegrityError, match=constraint), + engine.begin() as conn, + ): + conn.execute( + text( + """ + INSERT INTO membership ( + organization_id, membership_id, user_id, status, + membership_version, valid_from, valid_until + ) VALUES ( + :organization, :membership, :user, 'active', + 1, :valid_from, NULL + ) + """ + ), + { + **values, + "membership": uuid4(), + "valid_from": CHECKED_AT, + }, + ) + + for overrides, constraint in ( + ({"status": "guest"}, "ck_membership_status"), + ({"version": 0}, "ck_membership_version_positive"), + ({"valid_until": CHECKED_AT}, "ck_membership_valid_interval"), + ): + constraint_values: dict[str, Any] = { + "status": "active", + "version": 1, + "valid_from": CHECKED_AT, + "valid_until": None, + **overrides, + } + with ( + pytest.raises(IntegrityError, match=constraint), + engine.begin() as conn, + ): + conn.execute( + text( + """ + INSERT INTO membership ( + organization_id, membership_id, user_id, status, + membership_version, valid_from, valid_until + ) VALUES ( + :organization, :membership, :user, :status, + :version, :valid_from, :valid_until + ) + """ + ), + { + **constraint_values, + "organization": identities.organization_a, + "membership": uuid4(), + "user": identities.user_b, + }, + ) + finally: + engine.dispose() + + +def test_runtime_worker_and_public_grants_are_least_privilege( + migration_configuration: DatabaseConfiguration, +) -> None: + engine = create_database_engine(migration_configuration) + try: + with engine.connect() as connection: + grants = { + (row.grantee, row.table_name, row.privilege_type) + for row in connection.execute( + text( + """ + SELECT grantee, table_name, privilege_type + FROM information_schema.table_privileges + WHERE table_schema = 'public' + AND table_name IN ('user_account', 'membership') + AND grantee IN ('PUBLIC', :runtime_role, :worker_role) + """ + ), + {"runtime_role": RUNTIME_ROLE, "worker_role": WORKER_ROLE}, + ) + } + security = tuple( + connection.execute( + text( + """ + SELECT relrowsecurity, relforcerowsecurity + FROM pg_class + WHERE oid = 'public.membership'::regclass + """ + ) + ).one() + ) + policies = { + row.policyname: ( + row.permissive, + tuple(row.roles), + row.cmd, + row.qual, + row.with_check, + ) + for row in connection.execute( + text( + """ + SELECT policyname, permissive, roles, cmd, qual, with_check + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'membership' + """ + ) + ) + } + constraints = { + row.conname: row.definition + for row in connection.execute( + text( + """ + SELECT conname, pg_get_constraintdef(oid, true) AS definition + FROM pg_constraint + WHERE conrelid = 'public.membership'::regclass + """ + ) + ) + } + assert grants == {(RUNTIME_ROLE, "membership", "SELECT")} + assert security == (True, True) + assert set(policies) == { + "membership_current_user_actor", + "membership_migrator_administration", + } + runtime_policy = policies["membership_current_user_actor"] + assert runtime_policy[:3] == ( + "PERMISSIVE", + (RUNTIME_ROLE,), + "SELECT", + ) + assert runtime_policy[3] is not None + assert runtime_policy[4] is None + normalized_policy = str(runtime_policy[3]).lower() + for required_fragment in ( + "app.organization_id", + "app.actor_kind", + "app.user_id", + "app.membership_id", + "app.membership_version", + "app.principal_ref", + "app.request_id", + "app.authentication_binding_ref", + "app.checked_at", + "status", + "valid_from", + "valid_until", + ): + assert required_fragment in normalized_policy + + migrator_policy = policies["membership_migrator_administration"] + assert migrator_policy[:3] == ( + "PERMISSIVE", + ("context_engine_migrator",), + "ALL", + ) + assert str(migrator_policy[3]).lower() == "true" + assert str(migrator_policy[4]).lower() == "true" + + assert set(constraints) == { + "pk_membership", + "uq_membership_organization_user", + "fk_membership_organization", + "fk_membership_user_account", + "ck_membership_status", + "ck_membership_version_positive", + "ck_membership_valid_interval", + } + assert constraints["pk_membership"].lower() == ( + "primary key (organization_id, membership_id)" + ) + assert constraints["uq_membership_organization_user"].lower() == ( + "unique (organization_id, user_id)" + ) + assert constraints["fk_membership_organization"].lower().startswith( + "foreign key (organization_id) references organization(organization_id)" + ) + assert constraints["fk_membership_user_account"].lower().startswith( + "foreign key (user_id) references user_account(user_id)" + ) + finally: + engine.dispose() + + +def test_invalid_actor_mutations_hide_existing_rows_and_produce_zero_effects( + guarded_runtime_engine: Engine, + identities: IdentityFixture, +) -> None: + valid_settings = { + "app.organization_id": str(identities.organization_a), + "app.actor_kind": "user", + "app.user_id": str(identities.user_a), + "app.membership_id": str(identities.membership_a), + "app.membership_version": "1", + "app.principal_ref": f"principal:{identities.user_a}", + "app.request_id": "request-valid", + "app.authentication_binding_ref": "binding-valid", + "app.checked_at": CHECKED_AT.isoformat().replace("+00:00", "Z"), + } + mutations = ( + ("app.actor_kind", "service"), + ("app.user_id", str(identities.user_b)), + ("app.membership_id", str(uuid4())), + ("app.membership_version", "2"), + ("app.principal_ref", ""), + ("app.request_id", ""), + ("app.authentication_binding_ref", ""), + ("app.checked_at", ""), + ) + for setting_name, setting_value in mutations: + settings = {**valid_settings, setting_name: setting_value} + with guarded_runtime_engine.begin() as connection: + for name, value in settings.items(): + connection.execute( + text("SELECT set_config(:name, :value, true)"), + {"name": name, "value": value}, + ) + assert membership_rows(connection) == [] + assert connection.execute( + text("SELECT count(*) FROM organization_record") + ).scalar_one() == 0 + with pytest.raises(DBAPIError, match="current UserActor Membership"): + connection.execute( + text( + """ + UPDATE organization_record + SET payload = 'denied' + WHERE organization_id = :organization_id + """ + ), + {"organization_id": identities.organization_a}, + ) diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 85069778..69b339a5 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -59,7 +59,7 @@ def test_empty_baseline_remains_a_reversible_historical_revision( assert _application_tables(migration_configuration) == ["alembic_version"] finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260720_0002"] + assert _revision_rows(migration_configuration) == ["20260721_0003"] def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( @@ -74,9 +74,30 @@ def test_organization_isolation_revision_downgrades_and_reapplies_cleanly( finally: command.upgrade(alembic_configuration, "head") - assert _revision_rows(migration_configuration) == ["20260720_0002"] + assert _revision_rows(migration_configuration) == ["20260721_0003"] assert _application_tables(migration_configuration) == [ "alembic_version", + "membership", "organization", "organization_record", + "user_account", ] + + +def test_membership_revision_downgrades_to_issue_8_and_reapplies_cleanly( + migration_configuration: DatabaseConfiguration, +) -> None: + alembic_configuration = Config(ROOT / "alembic.ini") + + try: + command.downgrade(alembic_configuration, "20260720_0002") + assert _revision_rows(migration_configuration) == ["20260720_0002"] + assert _application_tables(migration_configuration) == [ + "alembic_version", + "organization", + "organization_record", + ] + finally: + command.upgrade(alembic_configuration, "head") + + assert _revision_rows(migration_configuration) == ["20260721_0003"] diff --git a/tests/integration/test_organization_isolation.py b/tests/integration/test_organization_isolation.py index d5cc2a57..1bbc410a 100644 --- a/tests/integration/test_organization_isolation.py +++ b/tests/integration/test_organization_isolation.py @@ -2,7 +2,9 @@ import json from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Protocol, cast from uuid import UUID, uuid4 @@ -14,13 +16,13 @@ from engine.persistence import ( DatabaseConfiguration, create_database_engine, - organization_transaction, ) from engine.persistence.configuration import MIGRATOR_ROLE, RUNTIME_ROLE, WORKER_ROLE pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] MANIFEST_PATH = ROOT / "engine/persistence/schema_security_manifest.yaml" +ACTOR_CHECKED_AT = datetime(2026, 7, 21, 8, 0, tzinfo=UTC) @dataclass(frozen=True, slots=True) @@ -37,6 +39,33 @@ def assert_sqlstate(error: DBAPIError, expected: str) -> None: assert cast(PostgreSQLError, error.orig).sqlstate == expected +@contextmanager +def current_user_transaction( + engine: Engine, + organization_id: UUID, +) -> Iterator[Connection]: + """Bind the complete test UserActor; IDs deliberately share one UUID.""" + + settings = { + "app.organization_id": str(organization_id), + "app.actor_kind": "user", + "app.user_id": str(organization_id), + "app.membership_id": str(organization_id), + "app.membership_version": "1", + "app.principal_ref": f"principal:{organization_id}", + "app.request_id": f"request:{uuid4()}", + "app.authentication_binding_ref": f"binding:{uuid4()}", + "app.checked_at": ACTOR_CHECKED_AT.isoformat().replace("+00:00", "Z"), + } + with engine.begin() as connection: + for setting_name, setting_value in settings.items(): + connection.execute( + text("SELECT set_config(:name, :value, true)"), + {"name": setting_name, "value": setting_value}, + ) + yield connection + + @pytest.fixture def organizations( migration_configuration: DatabaseConfiguration, @@ -60,26 +89,102 @@ def organizations( "organization_b": pair.organization_b, }, ) + connection.execute( + text( + """ + INSERT INTO user_account (user_id) + VALUES (:organization_a), (:organization_b) + """ + ), + { + "organization_a": pair.organization_a, + "organization_b": pair.organization_b, + }, + ) + connection.execute( + text( + """ + INSERT INTO membership ( + organization_id, + membership_id, + user_id, + status, + membership_version, + valid_from, + valid_until + ) VALUES + ( + :organization_a, + :organization_a, + :organization_a, + 'active', + 1, + :valid_from, + NULL + ), + ( + :organization_b, + :organization_b, + :organization_b, + 'active', + 1, + :valid_from, + NULL + ) + """ + ), + { + "organization_a": pair.organization_a, + "organization_b": pair.organization_b, + "valid_from": ACTOR_CHECKED_AT - timedelta(days=1), + }, + ) yield pair finally: - for organization_id in ( - pair.organization_a, - pair.organization_b, - ): - with organization_transaction( - guarded_runtime_engine, organization_id - ) as connection: - connection.execute( - text( - """ - DELETE FROM organization_record - WHERE organization_id = :organization_id - """ - ), - {"organization_id": organization_id}, - ) with migration_engine.begin() as connection: + connection.execute( + text( + """ + DELETE FROM organization_record + WHERE organization_id IN ( + :organization_a, + :organization_b + ) + """ + ), + { + "organization_a": pair.organization_a, + "organization_b": pair.organization_b, + }, + ) + connection.execute( + text( + """ + DELETE FROM membership + WHERE organization_id IN ( + :organization_a, + :organization_b + ) + """ + ), + { + "organization_a": pair.organization_a, + "organization_b": pair.organization_b, + }, + ) + connection.execute( + text( + """ + DELETE FROM user_account + WHERE user_id IN (:organization_a, :organization_b) + """ + ), + { + "organization_a": pair.organization_a, + "organization_b": pair.organization_b, + }, + ) connection.execute( text( """ @@ -106,7 +211,7 @@ def insert_record( *, parent_record_id: UUID | None = None, ) -> None: - with organization_transaction(engine, organization_id) as connection: + with current_user_transaction(engine, organization_id) as connection: connection.execute( text( """ @@ -184,7 +289,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( (organizations.organization_b, organizations.organization_a), ) for current_organization, other_organization in directions: - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, current_organization ) as connection: assert visible_records(connection) == [ @@ -198,7 +303,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( with ( pytest.raises(DBAPIError, match="row-level security"), - organization_transaction( + current_user_transaction( guarded_runtime_engine, current_organization ) as connection, ): @@ -223,7 +328,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( with ( pytest.raises(DBAPIError, match="row-level security"), - organization_transaction( + current_user_transaction( guarded_runtime_engine, current_organization ) as connection, ): @@ -245,7 +350,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( }, ) - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, current_organization ) as connection: update_result = connection.execute( @@ -265,7 +370,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( wrong_organization_effect_count += update_result.rowcount assert update_result.rowcount == 0 - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, current_organization ) as connection: delete_result = connection.execute( @@ -286,7 +391,7 @@ def test_bidirectional_rls_hides_and_blocks_wrong_organization_effects( assert wrong_organization_effect_count == 0 for organization_id, payload in records.items(): - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, organization_id ) as connection: assert visible_records(connection) == [ @@ -336,7 +441,7 @@ def test_composite_ownership_accepts_same_org_and_rejects_cross_org_parent( parent_record_id=parent_b, ) - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, organizations.organization_a ) as connection: assert set(visible_records(connection)) == { @@ -351,21 +456,35 @@ def test_composite_ownership_accepts_same_org_and_rejects_cross_org_parent( def test_organization_foreign_key_rejects_orphan_record( - guarded_runtime_engine: Engine, + migration_configuration: DatabaseConfiguration, ) -> None: """TENANT-OWNERSHIP-001: no representative row can lack a real owner.""" nonexistent_organization = uuid4() - with pytest.raises( - IntegrityError, - match="fk_organization_record_organization", - ): - insert_record( - guarded_runtime_engine, - nonexistent_organization, - uuid4(), - "orphan", - ) + migration_engine = create_database_engine(migration_configuration) + try: + with ( + pytest.raises( + IntegrityError, + match="fk_organization_record_organization", + ), + migration_engine.begin() as connection, + ): + connection.execute( + text( + """ + INSERT INTO organization_record ( + organization_id, record_id, parent_record_id, payload + ) VALUES (:organization_id, :record_id, NULL, 'orphan') + """ + ), + { + "organization_id": nonexistent_organization, + "record_id": uuid4(), + }, + ) + finally: + migration_engine.dispose() def test_missing_tenant_context_is_fail_closed_for_every_operation( @@ -466,7 +585,7 @@ def test_missing_tenant_context_is_fail_closed_for_every_operation( (organizations.organization_a, record_a, "organization-a"), (organizations.organization_b, record_b, "organization-b"), ): - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, organization_id ) as connection: assert visible_records(connection) == [ @@ -499,7 +618,7 @@ def test_single_connection_pool_reuse_never_leaks_organization_context( for iteration in range(12): organization_id = ordered_organizations[iteration % 2] record_id, payload = records[organization_id] - with organization_transaction(engine, organization_id) as connection: + with current_user_transaction(engine, organization_id) as connection: backend_pids.add( connection.execute(text("SELECT pg_backend_pid()")).scalar_one() ) @@ -511,9 +630,21 @@ def test_single_connection_pool_reuse_never_leaks_organization_context( backend_pids.add( connection.execute(text("SELECT pg_backend_pid()")).scalar_one() ) - assert connection.execute( - text("SELECT current_setting('app.organization_id', true)") - ).scalar_one_or_none() in {None, ""} + for setting_name in ( + "app.organization_id", + "app.actor_kind", + "app.user_id", + "app.membership_id", + "app.membership_version", + "app.principal_ref", + "app.request_id", + "app.authentication_binding_ref", + "app.checked_at", + ): + assert connection.execute( + text("SELECT current_setting(:name, true)"), + {"name": setting_name}, + ).scalar_one_or_none() in {None, ""} rows = visible_records(connection) missing_context_fallback_count += len(rows) assert rows == [] @@ -561,7 +692,7 @@ def test_failed_organization_transaction_rolls_back_row_and_local_context( try: with ( pytest.raises(LookupError, match="cancel operation"), - organization_transaction( + current_user_transaction( engine, organizations.organization_a ) as connection, ): @@ -598,7 +729,7 @@ def test_failed_organization_transaction_rolls_back_row_and_local_context( ).scalar_one_or_none() in {None, ""} assert visible_records(connection) == [] - with organization_transaction( + with current_user_transaction( engine, organizations.organization_a ) as connection: assert ( @@ -618,12 +749,12 @@ def test_failed_organization_transaction_rolls_back_row_and_local_context( engine.dispose() -def test_force_rls_subjects_the_table_owner_without_tenant_context( +def test_explicit_migrator_policy_is_the_only_force_rls_administration_path( guarded_runtime_engine: Engine, migration_configuration: DatabaseConfiguration, organizations: OrganizationPair, ) -> None: - """DB-004: FORCE is behavioral—the owning migrator cannot bypass RLS.""" + """DB-004: FORCE remains on while the named migration policy administers.""" record_id = uuid4() insert_record( @@ -639,74 +770,18 @@ def test_force_rls_subjects_the_table_owner_without_tenant_context( assert connection.execute( text("SELECT current_setting('app.organization_id', true)") ).scalar_one_or_none() in {None, ""} - assert visible_records(connection) == [] - - with ( - pytest.raises(DBAPIError) as update_error, - migration_engine.begin() as connection, - ): - connection.execute( - text( - """ - UPDATE organization_record - SET payload = 'owner-bypass-update' - WHERE organization_id = :organization_id - AND record_id = :record_id - """ - ), - { - "organization_id": organizations.organization_a, - "record_id": record_id, - }, - ) - assert_sqlstate(update_error.value, "42501") - - with ( - pytest.raises(DBAPIError) as delete_error, - migration_engine.begin() as connection, - ): - connection.execute( - text( - """ - DELETE FROM organization_record - WHERE organization_id = :organization_id - AND record_id = :record_id - """ - ), - { - "organization_id": organizations.organization_a, - "record_id": record_id, - }, - ) - assert_sqlstate(delete_error.value, "42501") - - with ( - pytest.raises(DBAPIError) as insert_error, - migration_engine.begin() as connection, - ): - connection.execute( - text( - """ - INSERT INTO organization_record ( - organization_id, - record_id, - parent_record_id, - payload - ) - VALUES (:organization_id, :record_id, NULL, :payload) - """ - ), - { - "organization_id": organizations.organization_a, - "record_id": uuid4(), - "payload": "owner-bypass-insert", - }, - ) - assert_sqlstate(insert_error.value, "42501") + assert visible_records(connection) == [ + ( + organizations.organization_a, + record_id, + None, + "force-owner-oracle", + ) + ] finally: migration_engine.dispose() - with organization_transaction( + with current_user_transaction( guarded_runtime_engine, organizations.organization_a ) as connection: assert visible_records(connection) == [ @@ -868,6 +943,8 @@ def test_catalog_proves_force_rls_policy_ownership_constraints_and_grants( FROM pg_policies WHERE schemaname = 'public' AND tablename = 'organization_record' + AND policyname = + 'organization_record_organization_isolation' """ ) ).one() @@ -1019,6 +1096,10 @@ def test_catalog_proves_force_rls_policy_ownership_constraints_and_grants( assert "organization_id" in normalized_policy assert "current_setting" in normalized_policy assert "app.organization_id" in normalized_policy + assert "app.membership_id" in normalized_policy + assert "app.membership_version" in normalized_policy + assert "app.checked_at" in normalized_policy + assert "actor_membership" in normalized_policy assert "nullif" in normalized_policy assert write_guard[:3] == ( @@ -1039,6 +1120,10 @@ def test_catalog_proves_force_rls_policy_ownership_constraints_and_grants( ) normalized_guard_body = str(write_guard[9]).lower() assert "current_setting('app.organization_id', true)" in normalized_guard_body + assert "current_setting('app.membership_id', true)" in normalized_guard_body + assert "current_setting('app.membership_version', true)" in normalized_guard_body + assert "current_setting('app.checked_at', true)" in normalized_guard_body + assert "from public.membership" in normalized_guard_body assert "nullif" in normalized_guard_body assert "errcode = '42501'" in normalized_guard_body assert write_guard_grants == { diff --git a/tests/integration/test_runtime_empty_package_integration.py b/tests/integration/test_runtime_empty_package_integration.py index c9109f02..21d9f2f1 100644 --- a/tests/integration/test_runtime_empty_package_integration.py +++ b/tests/integration/test_runtime_empty_package_integration.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import cast from uuid import UUID, uuid4 @@ -14,7 +14,12 @@ VerifiedAuthenticationContext, ) from adapters.http.organization_authority import OrganizationVerificationRejected -from engine.persistence import DatabaseConfiguration, create_database_engine +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLMembershipAuthority, + create_database_engine, +) +from engine.runtime.actor import MembershipRejectionAuditReceipt from engine.runtime.construction import Runtime, required_kernel_dependencies from engine.runtime.content_io import RuntimeContentIo from engine.runtime.contracts import Acquire @@ -29,16 +34,27 @@ class SeededAuthenticator: - def __init__(self, organization_id: UUID) -> None: + def __init__( + self, + organization_id: UUID, + user_id: UUID, + membership_id: UUID, + membership_version: int = 1, + ) -> None: self._organization_id = organization_id + self._user_id = user_id + self._membership_id = membership_id + self._membership_version = membership_version def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: if opaque_credential != TOKEN: raise AuthenticationRejected return VerifiedAuthenticationContext( organization_ref=str(self._organization_id), + user_ref=str(self._user_id), principal_ref="seeded-principal", - membership_ref=None, + membership_ref=str(self._membership_id), + membership_version=self._membership_version, agent_version_ref="seeded-agent", authenticated_application_ref="seeded-application", authentication_binding_ref="seeded-binding", @@ -89,9 +105,11 @@ def test_seeded_existing_organization_reaches_http_empty_package( migration_configuration: DatabaseConfiguration, guarded_runtime_engine: Engine, ) -> None: - """Issue #10: real root existence plus HTTP/Runtime zero-content path.""" + """Issue #11: a real active Membership reaches the empty Package path.""" organization_id = uuid4() + user_id = uuid4() + membership_id = uuid4() migration_engine = create_database_engine(migration_configuration) try: with migration_engine.begin() as connection: @@ -105,6 +123,39 @@ def test_seeded_existing_organization_reaches_http_empty_package( ), {"organization_id": organization_id}, ).scalar_one() + connection.execute( + text("INSERT INTO user_account (user_id) VALUES (:user_id)"), + {"user_id": user_id}, + ) + connection.execute( + text( + """ + INSERT INTO membership ( + organization_id, + membership_id, + user_id, + status, + membership_version, + valid_from, + valid_until + ) VALUES ( + :organization_id, + :membership_id, + :user_id, + 'active', + 1, + :valid_from, + NULL + ) + """ + ), + { + "organization_id": organization_id, + "membership_id": membership_id, + "user_id": user_id, + "valid_from": RECEIVED_AT, + }, + ) assert cast(UUID, inserted) == organization_id spy = ContentIoSpy() @@ -119,8 +170,15 @@ def test_seeded_existing_organization_reaches_http_empty_package( ) client = TestClient( create_app( - authenticator=SeededAuthenticator(organization_id), + authenticator=SeededAuthenticator( + organization_id, + user_id, + membership_id, + ), organization_authority=SeededOrganizationAuthority(organization_id), + membership_authority=PostgreSQLMembershipAuthority( + guarded_runtime_engine + ), runtime=runtime, clock=lambda: RECEIVED_AT, ) @@ -143,11 +201,40 @@ def test_seeded_existing_organization_reaches_http_empty_package( assert spy.calls == 0 with guarded_runtime_engine.connect() as connection: - assert connection.execute( - text("SELECT current_setting('app.organization_id', true)") - ).scalar_one_or_none() in {None, ""} + for setting_name in ( + "app.organization_id", + "app.actor_kind", + "app.user_id", + "app.membership_id", + "app.membership_version", + "app.principal_ref", + "app.request_id", + "app.authentication_binding_ref", + "app.checked_at", + ): + assert connection.execute( + text("SELECT current_setting(:setting_name, true)"), + {"setting_name": setting_name}, + ).scalar_one_or_none() in {None, ""} finally: with migration_engine.begin() as connection: + connection.execute( + text( + """ + DELETE FROM membership + WHERE organization_id = :organization_id + AND membership_id = :membership_id + """ + ), + { + "organization_id": organization_id, + "membership_id": membership_id, + }, + ) + connection.execute( + text("DELETE FROM user_account WHERE user_id = :user_id"), + {"user_id": user_id}, + ) connection.execute( text( "DELETE FROM organization WHERE organization_id = :organization_id" @@ -155,3 +242,203 @@ def test_seeded_existing_organization_reaches_http_empty_package( {"organization_id": organization_id}, ) migration_engine.dispose() + + +def test_real_postgres_http_membership_matrix_is_generic_and_zero_io( + migration_configuration: DatabaseConfiguration, + guarded_runtime_engine: Engine, +) -> None: + """Issue #11 DoD: the full invalid matrix crosses HTTP and real RLS.""" + + organization_a = uuid4() + organization_b = uuid4() + users = { + category: uuid4() + for category in ( + "active", + "missing", + "inactive", + "expired", + "revoked", + "cross-organization", + "stale-version", + "not-yet-valid", + "wrong-user", + ) + } + memberships = {category: uuid4() for category in users} + migration_engine = create_database_engine(migration_configuration) + try: + with migration_engine.begin() as connection: + connection.execute( + text( + """ + INSERT INTO organization (organization_id) + VALUES (:organization_a), (:organization_b) + """ + ), + { + "organization_a": organization_a, + "organization_b": organization_b, + }, + ) + connection.execute( + text("INSERT INTO user_account (user_id) VALUES (:user_id)"), + [{"user_id": user_id} for user_id in users.values()], + ) + connection.execute( + text( + """ + INSERT INTO membership ( + organization_id, + membership_id, + user_id, + status, + membership_version, + valid_from, + valid_until + ) VALUES ( + :organization_id, + :membership_id, + :user_id, + :status, + 1, + :valid_from, + :valid_until + ) + """ + ), + [ + { + "organization_id": ( + organization_b + if category == "cross-organization" + else organization_a + ), + "membership_id": memberships[category], + "user_id": users[category], + "status": ( + category + if category in {"inactive", "revoked"} + else "active" + ), + "valid_from": ( + RECEIVED_AT + timedelta(seconds=1) + if category == "not-yet-valid" + else RECEIVED_AT - timedelta(days=2) + ), + "valid_until": ( + RECEIVED_AT - timedelta(seconds=1) + if category == "expired" + else None + ), + } + for category in ( + "active", + "inactive", + "expired", + "revoked", + "cross-organization", + "stale-version", + "not-yet-valid", + "wrong-user", + ) + ], + ) + + spy = ContentIoSpy() + runtime = Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=spy, + provider=spy, + source_content=spy, + ), + clock=lambda: RECEIVED_AT, + ) + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + audit_receipts: list[MembershipRejectionAuditReceipt] = [] + responses: dict[str, tuple[int, bytes]] = {} + for category in users: + client = TestClient( + create_app( + authenticator=SeededAuthenticator( + organization_a, + ( + users["active"] + if category == "wrong-user" + else users[category] + ), + memberships[category], + 2 if category == "stale-version" else 1, + ), + organization_authority=SeededOrganizationAuthority( + organization_a + ), + membership_authority=authority, + membership_rejection_observer=audit_receipts.append, + runtime=runtime, + clock=lambda: RECEIVED_AT, + ) + ) + response = client.post( + "/v1/context:resolve", + headers={"Authorization": f"Bearer {TOKEN}"}, + json={"kind": "acquire", "need": {"query": category}}, + ) + responses[category] = (response.status_code, response.content) + + assert responses.pop("active")[0] == 200 + assert set(responses.values()) == { + (401, b'{"code":"authentication_failed"}') + } + assert audit_receipts == [MembershipRejectionAuditReceipt()] * 8 + assert spy.calls == 0 + + with guarded_runtime_engine.connect() as connection: + for setting_name in ( + "app.organization_id", + "app.actor_kind", + "app.user_id", + "app.membership_id", + "app.membership_version", + "app.principal_ref", + "app.request_id", + "app.authentication_binding_ref", + "app.checked_at", + ): + assert connection.execute( + text("SELECT current_setting(:setting_name, true)"), + {"setting_name": setting_name}, + ).scalar_one_or_none() in {None, ""} + finally: + with migration_engine.begin() as connection: + connection.execute( + text( + """ + DELETE FROM membership + WHERE organization_id IN (:organization_a, :organization_b) + """ + ), + { + "organization_a": organization_a, + "organization_b": organization_b, + }, + ) + connection.execute( + text("DELETE FROM user_account WHERE user_id = :user_id"), + [{"user_id": user_id} for user_id in users.values()], + ) + connection.execute( + text( + """ + DELETE FROM organization + WHERE organization_id IN (:organization_a, :organization_b) + """ + ), + { + "organization_a": organization_a, + "organization_b": organization_b, + }, + ) + migration_engine.dispose() diff --git a/tests/process/conformance_app.py b/tests/process/conformance_app.py index bbde7323..dd8a7b36 100644 --- a/tests/process/conformance_app.py +++ b/tests/process/conformance_app.py @@ -1,5 +1,7 @@ -"""Test-only listening API composition for the Issue #10 process smoke.""" +"""Test-only listening API composition for the current process smoke.""" +from collections.abc import Iterator +from contextlib import contextmanager from datetime import datetime from uuid import UUID @@ -9,6 +11,13 @@ VerifiedAuthenticationContext, ) from adapters.http.organization_authority import OrganizationVerificationRejected +from engine.persistence import MembershipIdentity, MembershipNotCurrent +from engine.runtime.actor import ( + CurrentMembershipVerification, + _close_membership_authority_scope, + _construct_current_membership_verification, + _open_membership_authority_scope, +) from engine.runtime.organization import ( ExistingOrganizationVerification, _construct_existing_http_organization_verification, @@ -16,6 +25,8 @@ PROCESS_VALID_TOKEN = "process-test-credential" PROCESS_ORGANIZATION_REF = "81e18bca-86a1-478a-937d-7675c6fe69b0" +PROCESS_USER_REF = "d3d9893f-82d2-4890-8cb2-4c7e57a56f16" +PROCESS_MEMBERSHIP_REF = "9c9e9f4c-a5ec-4417-9408-0346e1c6c998" class ProcessTestAuthenticator: @@ -26,14 +37,48 @@ def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: raise AuthenticationRejected return VerifiedAuthenticationContext( organization_ref=PROCESS_ORGANIZATION_REF, + user_ref=PROCESS_USER_REF, principal_ref="process-principal", - membership_ref=None, + membership_ref=PROCESS_MEMBERSHIP_REF, + membership_version=1, agent_version_ref="process-agent-version", authenticated_application_ref="process-application", authentication_binding_ref="process-binding", ) +class ProcessTestMembershipAuthority: + """One active conformance Membership with a request-lived proof.""" + + @contextmanager + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> Iterator[CurrentMembershipVerification]: + if ( + identity.organization_id != UUID(PROCESS_ORGANIZATION_REF) + or identity.user_id != UUID(PROCESS_USER_REF) + or identity.membership_id != UUID(PROCESS_MEMBERSHIP_REF) + or identity.membership_version != 1 + ): + raise MembershipNotCurrent + scope = _open_membership_authority_scope() + try: + yield _construct_current_membership_verification( + authority_scope=scope, + organization_id=identity.organization_id, + user_id=identity.user_id, + membership_id=identity.membership_id, + membership_version=identity.membership_version, + principal_ref=identity.principal_ref, + request_id=identity.request_id, + authentication_binding_ref=identity.authentication_binding_ref, + checked_at=identity.checked_at, + ) + finally: + _close_membership_authority_scope(scope) + + class ProcessTestOrganizationAuthority: """Conformance registry containing exactly one known test Organization.""" @@ -57,4 +102,5 @@ def verify_existing( app = create_app( authenticator=ProcessTestAuthenticator(), organization_authority=ProcessTestOrganizationAuthority(), + membership_authority=ProcessTestMembershipAuthority(), ) diff --git a/tests/unit/test_actor_contracts.py b/tests/unit/test_actor_contracts.py new file mode 100644 index 00000000..d5e87f26 --- /dev/null +++ b/tests/unit/test_actor_contracts.py @@ -0,0 +1,376 @@ +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime, timedelta, timezone +from typing import Any, cast +from uuid import UUID + +import pytest + +from adapters.http.authentication import ( + InvalidAuthenticationContext, + VerifiedAuthenticationContext, +) +from engine.runtime.actor import ( + CurrentMembershipVerification, + MembershipVerificationProvenance, + UserActor, + UserActorConstructionProvenance, + _close_membership_authority_scope, + _construct_current_membership_verification, + _construct_user_actor, + _MembershipAuthorityScope, + _open_membership_authority_scope, + _require_active_current_membership_verification, + _require_active_user_actor, +) +from engine.runtime.invocation import _construct_authenticated_http_invocation +from engine.runtime.organization import ( + _construct_existing_http_organization_verification, +) + +CHECKED_AT = datetime(2026, 7, 21, 6, 0, tzinfo=UTC) +ORGANIZATION_ID = UUID("81e18bca-86a1-478a-937d-7675c6fe69b0") +USER_ID = UUID("d3d9893f-82d2-4890-8cb2-4c7e57a56f16") +MEMBERSHIP_ID = UUID("9c9e9f4c-a5ec-4417-9408-0346e1c6c998") + + +def verified_authentication_context( + *, + user_ref: object = str(USER_ID), + membership_ref: object = str(MEMBERSHIP_ID), + membership_version: object = 7, +) -> VerifiedAuthenticationContext: + return VerifiedAuthenticationContext( + organization_ref=str(ORGANIZATION_ID), + user_ref=user_ref, # type: ignore[arg-type] + principal_ref="principal-not-the-user-id", + membership_ref=membership_ref, # type: ignore[arg-type] + membership_version=membership_version, # type: ignore[arg-type] + agent_version_ref="agent-version-1", + authenticated_application_ref="application-1", + authentication_binding_ref="binding-1", + ) + + +def current_membership_proof( + *, + scope: object, + organization_id: UUID = ORGANIZATION_ID, + user_id: UUID = USER_ID, + membership_id: UUID = MEMBERSHIP_ID, + membership_version: int = 7, + principal_ref: str = "principal-not-the-user-id", + request_id: str = "request-1", + authentication_binding_ref: str = "binding-1", + checked_at: datetime = CHECKED_AT, +) -> CurrentMembershipVerification: + return _construct_current_membership_verification( + authority_scope=scope, # type: ignore[arg-type] + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + membership_version=membership_version, + principal_ref=principal_ref, + request_id=request_id, + authentication_binding_ref=authentication_binding_ref, + checked_at=checked_at, + ) + + +def test_current_membership_proof_is_nominal_frozen_and_scope_lived() -> None: + with pytest.raises(TypeError): + CurrentMembershipVerification() + + scope = _open_membership_authority_scope() + proof = _construct_current_membership_verification( + authority_scope=scope, + organization_id=ORGANIZATION_ID, + user_id=USER_ID, + membership_id=MEMBERSHIP_ID, + membership_version=7, + principal_ref="principal-from-auth", + request_id="request-1", + authentication_binding_ref="binding-1", + checked_at=CHECKED_AT, + ) + + _require_active_current_membership_verification(proof) + with pytest.raises(FrozenInstanceError): + proof.membership_version = 8 # type: ignore[misc] + assert "_authority_scope" not in repr(proof) + + _close_membership_authority_scope(scope) + with pytest.raises(ValueError, match="active Membership authority scope"): + _require_active_current_membership_verification(proof) + + +@pytest.mark.parametrize( + ("field_name", "invalid_value", "error_type"), + ( + ("organization_id", str(ORGANIZATION_ID), TypeError), + ("user_id", str(USER_ID), TypeError), + ("membership_id", str(MEMBERSHIP_ID), TypeError), + ("membership_version", True, ValueError), + ("membership_version", 0, ValueError), + ("membership_version", 1 << 63, ValueError), + ("principal_ref", " ", ValueError), + ("request_id", "", ValueError), + ("authentication_binding_ref", 42, ValueError), + ("checked_at", datetime(2026, 7, 21, 6, 0), ValueError), + ( + "checked_at", + datetime(2026, 7, 21, 14, 0, tzinfo=timezone(timedelta(hours=8))), + ValueError, + ), + ), +) +def test_current_membership_proof_rejects_malformed_authority_facts( + field_name: str, + invalid_value: object, + error_type: type[Exception], +) -> None: + scope = _open_membership_authority_scope() + values: dict[str, object] = { + "organization_id": ORGANIZATION_ID, + "user_id": USER_ID, + "membership_id": MEMBERSHIP_ID, + "membership_version": 7, + "principal_ref": "principal-not-the-user-id", + "request_id": "request-1", + "authentication_binding_ref": "binding-1", + "checked_at": CHECKED_AT, + } + values[field_name] = invalid_value + + with pytest.raises(error_type): + current_membership_proof(scope=scope, **cast(Any, values)) + _close_membership_authority_scope(scope) + + +def test_current_membership_proof_rejects_forged_or_closed_authority_scope() -> None: + forged_scope = object.__new__(_MembershipAuthorityScope) + with pytest.raises(ValueError, match="active Membership authority scope"): + current_membership_proof(scope=forged_scope) + + closed_scope = _open_membership_authority_scope() + _close_membership_authority_scope(closed_scope) + with pytest.raises(ValueError, match="active Membership authority scope"): + current_membership_proof(scope=closed_scope) + + +def test_current_membership_proofs_from_distinct_authority_scopes_are_distinct( +) -> None: + first_scope = _open_membership_authority_scope() + second_scope = _open_membership_authority_scope() + + assert current_membership_proof( + scope=first_scope + ) != current_membership_proof(scope=second_scope) + + _close_membership_authority_scope(first_scope) + _close_membership_authority_scope(second_scope) + + +def test_user_actor_is_nominal_exact_and_keeps_principal_distinct_from_user() -> None: + with pytest.raises(TypeError): + UserActor() + + scope = _open_membership_authority_scope() + proof = _construct_current_membership_verification( + authority_scope=scope, + organization_id=ORGANIZATION_ID, + user_id=USER_ID, + membership_id=MEMBERSHIP_ID, + membership_version=7, + principal_ref="principal-not-the-user-id", + request_id="request-1", + authentication_binding_ref="binding-1", + checked_at=CHECKED_AT, + ) + actor = _construct_user_actor(proof) + + assert actor.organization_id == ORGANIZATION_ID + assert actor.user_id == USER_ID + assert actor.membership_id == MEMBERSHIP_ID + assert actor.membership_version == 7 + assert actor.principal_ref == "principal-not-the-user-id" + assert actor.principal_ref != str(actor.user_id) + assert actor.current_membership_verification is proof + _require_active_user_actor(actor) + with pytest.raises(FrozenInstanceError): + actor.principal_ref = "changed" # type: ignore[misc] + + _close_membership_authority_scope(scope) + with pytest.raises(ValueError, match="active Membership authority scope"): + _require_active_user_actor(actor) + + +def test_verified_authentication_requires_canonical_user_membership_and_version( +) -> None: + context = verified_authentication_context( + user_ref=str(USER_ID).replace("-", "").upper(), + membership_ref=str(MEMBERSHIP_ID).replace("-", "").upper(), + ) + + assert context.user_ref == str(USER_ID) + assert context.membership_ref == str(MEMBERSHIP_ID) + assert context.membership_version == 7 + assert context.principal_ref == "principal-not-the-user-id" + assert context.principal_ref != context.user_ref + + for field_name, invalid_value in ( + ("user_ref", None), + ("user_ref", "not-a-uuid"), + ("membership_ref", None), + ("membership_ref", "not-a-uuid"), + ("membership_version", None), + ("membership_version", True), + ("membership_version", 0), + ("membership_version", 1 << 63), + ): + with pytest.raises(InvalidAuthenticationContext): + values = { + "user_ref": str(USER_ID), + "membership_ref": str(MEMBERSHIP_ID), + "membership_version": 7, + } + values[field_name] = invalid_value + verified_authentication_context(**values) + + +def test_authenticated_invocation_binds_one_current_user_actor() -> None: + scope = _open_membership_authority_scope() + proof = current_membership_proof(scope=scope) + organization_proof = _construct_existing_http_organization_verification( + organization_id=ORGANIZATION_ID, + request_id="request-1", + authentication_binding_ref="binding-1", + verified_at=CHECKED_AT, + ) + + invocation = _construct_authenticated_http_invocation( + request_id="request-1", + authenticated_organization_ref=str(ORGANIZATION_ID), + organization_verification=organization_proof, + user_ref=str(USER_ID), + principal_ref="principal-not-the-user-id", + membership_ref=str(MEMBERSHIP_ID), + membership_version=7, + current_membership_verification=proof, + agent_version_ref="agent-version-1", + authenticated_application_ref="application-1", + authentication_binding_ref="binding-1", + received_at=CHECKED_AT, + ) + + assert invocation.user_ref == str(USER_ID) + assert invocation.membership_ref == str(MEMBERSHIP_ID) + assert invocation.membership_version == 7 + assert invocation.user_actor.current_membership_verification is proof + assert invocation.principal_ref != invocation.user_ref + _close_membership_authority_scope(scope) + + +@pytest.mark.parametrize( + ("proof_override", "invocation_override"), + ( + ({"organization_id": UUID(int=9)}, {}), + ({"user_id": UUID(int=10)}, {}), + ({"membership_id": UUID(int=11)}, {}), + ({"membership_version": 8}, {}), + ({"principal_ref": "other-principal"}, {}), + ({"request_id": "other-request"}, {}), + ({"authentication_binding_ref": "other-binding"}, {}), + ({"checked_at": datetime(2026, 7, 21, 5, 59, tzinfo=UTC)}, {}), + ({}, {"user_ref": str(UUID(int=12))}), + ({}, {"membership_ref": str(UUID(int=13))}), + ({}, {"membership_version": 9}), + ), +) +def test_authenticated_invocation_rejects_mismatched_current_membership( + proof_override: dict[str, object], + invocation_override: dict[str, object], +) -> None: + scope = _open_membership_authority_scope() + proof = current_membership_proof( + scope=scope, + **cast(Any, proof_override), + ) + organization_proof = _construct_existing_http_organization_verification( + organization_id=ORGANIZATION_ID, + request_id="request-1", + authentication_binding_ref="binding-1", + verified_at=CHECKED_AT, + ) + values: dict[str, object] = { + "request_id": "request-1", + "authenticated_organization_ref": str(ORGANIZATION_ID), + "organization_verification": organization_proof, + "user_ref": str(USER_ID), + "principal_ref": "principal-not-the-user-id", + "membership_ref": str(MEMBERSHIP_ID), + "membership_version": 7, + "current_membership_verification": proof, + "agent_version_ref": "agent-version-1", + "authenticated_application_ref": "application-1", + "authentication_binding_ref": "binding-1", + "received_at": CHECKED_AT, + } + values.update(invocation_override) + + with pytest.raises(ValueError, match="current Membership"): + _construct_authenticated_http_invocation(**values) # type: ignore[arg-type] + + _close_membership_authority_scope(scope) + + +def test_authenticated_invocation_rejects_closed_membership_authority_scope() -> None: + scope = _open_membership_authority_scope() + proof = current_membership_proof(scope=scope) + _close_membership_authority_scope(scope) + organization_proof = _construct_existing_http_organization_verification( + organization_id=ORGANIZATION_ID, + request_id="request-1", + authentication_binding_ref="binding-1", + verified_at=CHECKED_AT, + ) + + with pytest.raises(ValueError, match="active Membership authority scope"): + _construct_authenticated_http_invocation( + request_id="request-1", + authenticated_organization_ref=str(ORGANIZATION_ID), + organization_verification=organization_proof, + user_ref=str(USER_ID), + principal_ref="principal-not-the-user-id", + membership_ref=str(MEMBERSHIP_ID), + membership_version=7, + current_membership_verification=proof, + agent_version_ref="agent-version-1", + authenticated_application_ref="application-1", + authentication_binding_ref="binding-1", + received_at=CHECKED_AT, + ) + + +@pytest.mark.parametrize("target", ("proof", "actor")) +def test_current_membership_authority_rejects_mutated_provenance(target: str) -> None: + scope = _open_membership_authority_scope() + proof = current_membership_proof(scope=scope) + actor = _construct_user_actor(proof) + if target == "proof": + object.__setattr__( + proof, + "construction_provenance", + cast(MembershipVerificationProvenance, cast(Any, "untrusted")), + ) + expected = "active Membership authority scope" + else: + object.__setattr__( + actor, + "construction_provenance", + cast(UserActorConstructionProvenance, cast(Any, "untrusted")), + ) + expected = "invalid construction provenance" + + with pytest.raises(ValueError, match=expected): + _require_active_user_actor(actor) + _close_membership_authority_scope(scope) diff --git a/tests/unit/test_http_trust_boundary.py b/tests/unit/test_http_trust_boundary.py index 3e41517a..0cf64706 100644 --- a/tests/unit/test_http_trust_boundary.py +++ b/tests/unit/test_http_trust_boundary.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager from dataclasses import FrozenInstanceError from datetime import UTC, datetime from typing import Any, cast @@ -19,11 +21,27 @@ OrganizationVerificationRejected, ) from adapters.http.transport import HttpTransportProfile +from engine.persistence.membership_context import ( + MembershipAuthorityUnavailable, + MembershipIdentity, + MembershipNotCurrent, +) from engine.runtime import ( AuthenticatedInvocation, InvocationConstructionProvenance, Resolved, + Runtime, +) +from engine.runtime.actor import ( + CurrentMembershipVerification, + MembershipRejectionAuditReceipt, + _close_membership_authority_scope, + _construct_current_membership_verification, + _open_membership_authority_scope, ) +from engine.runtime.construction import required_kernel_dependencies +from engine.runtime.content_io import RuntimeContentIo +from engine.runtime.contracts import Acquire from engine.runtime.organization import ( ExistingOrganizationVerification, OrganizationVerificationProvenance, @@ -37,6 +55,8 @@ VALID_TOKEN = "opaque-test-credential" RECEIVED_AT = datetime(2026, 7, 21, 5, 0, tzinfo=UTC) INTERNAL_ORGANIZATION_REF = "81e18bca-86a1-478a-937d-7675c6fe69b0" +INTERNAL_USER_REF = "d3d9893f-82d2-4890-8cb2-4c7e57a56f16" +INTERNAL_MEMBERSHIP_REF = "9c9e9f4c-a5ec-4417-9408-0346e1c6c998" class DeterministicAuthenticator: @@ -51,14 +71,143 @@ def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: raise AuthenticationRejected return VerifiedAuthenticationContext( organization_ref=INTERNAL_ORGANIZATION_REF, + user_ref=INTERNAL_USER_REF, principal_ref="principal-from-auth", - membership_ref=None, + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=7, agent_version_ref="agent-version-from-auth", authenticated_application_ref="application-from-auth", authentication_binding_ref="binding-from-auth", ) +class DeterministicMembershipAuthority: + """Test twin retaining one nominal proof for the whole Runtime call.""" + + def __init__(self) -> None: + self.identities: list[MembershipIdentity] = [] + self.events: list[str] = [] + + @contextmanager + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> Iterator[CurrentMembershipVerification]: + self.identities.append(identity) + if ( + identity.organization_id != UUID(INTERNAL_ORGANIZATION_REF) + or identity.user_id != UUID(INTERNAL_USER_REF) + or identity.membership_id != UUID(INTERNAL_MEMBERSHIP_REF) + or identity.membership_version != 7 + ): + raise MembershipNotCurrent + self.events.append("authority-open") + scope = _open_membership_authority_scope() + try: + yield _construct_current_membership_verification( + authority_scope=scope, + organization_id=identity.organization_id, + user_id=identity.user_id, + membership_id=identity.membership_id, + membership_version=identity.membership_version, + principal_ref=identity.principal_ref, + request_id=identity.request_id, + authentication_binding_ref=identity.authentication_binding_ref, + checked_at=identity.checked_at, + ) + finally: + _close_membership_authority_scope(scope) + self.events.append("authority-close") + + +class RejectingTestMembershipAuthority: + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> AbstractContextManager[CurrentMembershipVerification]: + del identity + return _RaisingMembershipContext(MembershipNotCurrent()) + + +class UnavailableTestMembershipAuthority: + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> AbstractContextManager[CurrentMembershipVerification]: + del identity + return _RaisingMembershipContext(MembershipAuthorityUnavailable()) + + +class _RaisingMembershipContext( + AbstractContextManager[CurrentMembershipVerification] +): + def __init__(self, error: Exception) -> None: + self._error = error + + def __enter__(self) -> CurrentMembershipVerification: + raise self._error + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object, + ) -> bool | None: + del exc_type, exc_value, traceback + return None + + +class ExitUnavailableMembershipAuthority(DeterministicMembershipAuthority): + @contextmanager + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> Iterator[CurrentMembershipVerification]: + with super().current_user_actor(identity) as verification: + yield verification + raise MembershipAuthorityUnavailable + + +class CategorizedRejectingMembershipAuthority: + """Represent any durable invalid category without changing its boundary result.""" + + def __init__(self, category: str) -> None: + self.category = category + self.calls = 0 + + def current_user_actor( + self, + identity: MembershipIdentity, + ) -> AbstractContextManager[CurrentMembershipVerification]: + del identity + self.calls += 1 + return _RaisingMembershipContext(MembershipNotCurrent()) + + +class DownstreamContentIoSpy: + def __init__(self) -> None: + self.index_calls = 0 + self.provider_calls = 0 + self.source_content_calls = 0 + + def discover(self, request: Acquire) -> tuple[()]: + del request + self.index_calls += 1 + return () + + def authorize_and_project(self) -> tuple[()]: + self.provider_calls += 1 + return () + + def read_content(self) -> tuple[()]: + self.source_content_calls += 1 + return () + + @property + def total_calls(self) -> int: + return self.index_calls + self.provider_calls + self.source_content_calls + + class DeterministicOrganizationAuthority: """Test twin that recognizes the one registered conformance Organization.""" @@ -178,8 +327,10 @@ class InvalidClaimsAuthenticator: def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: return VerifiedAuthenticationContext( organization_ref="organization-secret-malformed", + user_ref=INTERNAL_USER_REF, principal_ref=" ", - membership_ref="membership-from-auth", + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=7, agent_version_ref="agent-version-from-auth", authenticated_application_ref="application-from-auth", authentication_binding_ref="binding-from-auth", @@ -192,8 +343,10 @@ class InvalidClaimTypeAuthenticator: def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: return VerifiedAuthenticationContext( organization_ref=cast(Any, 42), + user_ref=INTERNAL_USER_REF, principal_ref="principal-from-auth", - membership_ref="membership-from-auth", + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=7, agent_version_ref="agent-version-from-auth", authenticated_application_ref="application-from-auth", authentication_binding_ref="binding-from-auth", @@ -206,8 +359,27 @@ class InvalidOrganizationRefAuthenticator: def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: return VerifiedAuthenticationContext( organization_ref="orgpkg_caller-replayed-output-reference", + user_ref=INTERNAL_USER_REF, principal_ref="principal-from-auth", - membership_ref="membership-from-auth", + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=7, + agent_version_ref="agent-version-from-auth", + authenticated_application_ref="application-from-auth", + authentication_binding_ref="binding-from-auth", + ) + + +class OversizedMembershipVersionAuthenticator: + """Test double whose Membership version cannot fit durable BIGINT.""" + + def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: + del opaque_credential + return VerifiedAuthenticationContext( + organization_ref=INTERNAL_ORGANIZATION_REF, + user_ref=INTERNAL_USER_REF, + principal_ref="principal-from-auth", + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=1 << 63, agent_version_ref="agent-version-from-auth", authenticated_application_ref="application-from-auth", authentication_binding_ref="binding-from-auth", @@ -221,8 +393,10 @@ def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: context = super().authenticate(opaque_credential) return VerifiedAuthenticationContext( organization_ref=context.organization_ref.replace("-", "").upper(), + user_ref=context.user_ref, principal_ref=context.principal_ref, membership_ref=context.membership_ref, + membership_version=context.membership_version, agent_version_ref=context.agent_version_ref, authenticated_application_ref=context.authenticated_application_ref, authentication_binding_ref=context.authentication_binding_ref, @@ -269,8 +443,10 @@ def test_valid_auth_constructs_exact_trusted_invocation_once() -> None: invocation = spy.invocations[0] assert invocation.request_id == "request-from-header" assert invocation.organization_ref == INTERNAL_ORGANIZATION_REF + assert invocation.user_ref == INTERNAL_USER_REF assert invocation.principal_ref == "principal-from-auth" - assert invocation.membership_ref is None + assert invocation.membership_ref == INTERNAL_MEMBERSHIP_REF + assert invocation.membership_version == 7 assert invocation.agent_version_ref == "agent-version-from-auth" assert invocation.authenticated_application_ref == "application-from-auth" assert invocation.authentication_binding_ref == "binding-from-auth" @@ -336,6 +512,7 @@ def test_valid_http_acquire_reaches_the_single_runtime_entry_exactly_once() -> N create_app( authenticator=DeterministicAuthenticator(), organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(), clock=lambda: RECEIVED_AT, resolution_observer=resolution_spy.observe, ) @@ -402,6 +579,166 @@ def test_unverified_or_switched_organization_fails_before_runtime( assert resolution_spy.outcomes == [] +@pytest.mark.parametrize( + "category", + ("missing", "inactive", "expired", "revoked", "cross-organization"), +) +def test_invalid_membership_matrix_is_externally_equivalent_and_zero_io( + category: str, +) -> None: + authority = CategorizedRejectingMembershipAuthority(category) + audit_receipts: list[MembershipRejectionAuditReceipt] = [] + content_io = DownstreamContentIoSpy() + runtime = Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=content_io, + provider=content_io, + source_content=content_io, + ), + clock=lambda: RECEIVED_AT, + ) + resolution_spy = ResolutionSpy() + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=authority, + membership_rejection_observer=audit_receipts.append, + runtime=runtime, + resolution_observer=resolution_spy.observe, + clock=lambda: RECEIVED_AT, + ) + ) + + response = client.post( + "/v1/context:resolve", + headers={"Authorization": f"Bearer {VALID_TOKEN}"}, + json=VALID_BODY, + ) + + assert response.status_code == 401 + assert response.content == b'{"code":"authentication_failed"}' + assert response.headers["www-authenticate"] == "Bearer" + assert authority.calls == 1 + assert resolution_spy.outcomes == [] + assert content_io.total_calls == 0 + assert audit_receipts == [MembershipRejectionAuditReceipt()] + assert all( + protected not in response.text + for protected in ( + category, + INTERNAL_ORGANIZATION_REF, + INTERNAL_USER_REF, + INTERNAL_MEMBERSHIP_REF, + ) + ) + + +def test_membership_authority_unavailability_is_generic_503_and_zero_io() -> None: + content_io = DownstreamContentIoSpy() + runtime = Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=content_io, + provider=content_io, + source_content=content_io, + ), + clock=lambda: RECEIVED_AT, + ) + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=UnavailableTestMembershipAuthority(), + runtime=runtime, + 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 content_io.total_calls == 0 + assert all( + protected not in response.text + for protected in ( + INTERNAL_ORGANIZATION_REF, + INTERNAL_USER_REF, + INTERNAL_MEMBERSHIP_REF, + ) + ) + + +def test_membership_transaction_exit_failure_suppresses_prepared_200_as_503() -> None: + resolution_spy = ResolutionSpy() + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=ExitUnavailableMembershipAuthority(), + resolution_observer=resolution_spy.observe, + 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(resolution_spy.outcomes) == 1 + assert "resolved" not in response.text + + +def test_membership_authority_scope_encloses_invocation_runtime_and_response() -> None: + authority = DeterministicMembershipAuthority() + invocation_spy = InvocationSpy() + resolution_spy = ResolutionSpy() + + def observe_invocation(invocation: AuthenticatedInvocation) -> None: + authority.events.append("invocation") + invocation_spy.observe(invocation) + + def observe_resolution(outcome: Resolved) -> None: + authority.events.append("resolved") + resolution_spy.observe(outcome) + + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=authority, + invocation_observer=observe_invocation, + resolution_observer=observe_resolution, + clock=lambda: RECEIVED_AT, + ) + ) + + response = client.post( + "/v1/context:resolve", + headers={"Authorization": f"Bearer {VALID_TOKEN}"}, + json=VALID_BODY, + ) + + assert response.status_code == 200 + assert authority.events == [ + "authority-open", + "invocation", + "resolved", + "authority-close", + ] + assert len(invocation_spy.invocations) == len(resolution_spy.outcomes) == 1 + + @pytest.mark.parametrize( "body", [ @@ -507,6 +844,7 @@ def test_authentication_failures_are_generic_and_call_no_domain_seam( InvalidClaimsAuthenticator(), InvalidClaimTypeAuthenticator(), InvalidOrganizationRefAuthenticator(), + OversizedMembershipVersionAuthenticator(), ], ) def test_invalid_authenticator_output_is_a_generic_authentication_failure( @@ -548,9 +886,11 @@ def test_verified_authentication_context_rejects_non_string_refs( field_value: object, ) -> None: claims: dict[str, Any] = { - "organization_ref": "organization-from-auth", + "organization_ref": INTERNAL_ORGANIZATION_REF, + "user_ref": INTERNAL_USER_REF, "principal_ref": "principal-from-auth", - "membership_ref": "membership-from-auth", + "membership_ref": INTERNAL_MEMBERSHIP_REF, + "membership_version": 7, "agent_version_ref": "agent-version-from-auth", "authenticated_application_ref": "application-from-auth", "authentication_binding_ref": "binding-from-auth", @@ -1108,6 +1448,7 @@ def test_injected_authenticator_runs_only_the_real_sealed_runtime() -> None: create_app( authenticator=DeterministicAuthenticator(), organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(), clock=lambda: RECEIVED_AT, ) ) @@ -1194,10 +1535,11 @@ def test_openapi_body_is_closed_and_contains_no_trusted_fields() -> None: ): assert forbidden not in serialized_request_graph - assert set(operation["responses"]) == {"200", "400", "401", "422"} + assert set(operation["responses"]) == {"200", "400", "401", "422", "503"} assert response_schema_name(operation, 400) == "InvalidRequestWire" assert response_schema_name(operation, 401) == "AuthenticationFailureWire" assert response_schema_name(operation, 422) == "InvalidRequestWire" + assert response_schema_name(operation, 503) == "ServiceUnavailableWire" assert response_schema_name(operation, 200) == "ResolvedWire" response_schema = operation["responses"]["200"]["content"][ "application/json" @@ -1245,6 +1587,7 @@ def test_openapi_body_is_closed_and_contains_no_trusted_fields() -> None: for name, code in ( ("InvalidRequestWire", "invalid_request"), ("AuthenticationFailureWire", "authentication_failed"), + ("ServiceUnavailableWire", "service_unavailable"), ): error_schema = schema["components"]["schemas"][name] assert error_schema["additionalProperties"] is False @@ -1341,6 +1684,7 @@ def trust_boundary_client( create_app( authenticator=authenticator, organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(), invocation_observer=spy.observe, clock=lambda: RECEIVED_AT, request_id_factory=lambda: "server-generated-request", @@ -1350,6 +1694,7 @@ def trust_boundary_client( create_app( authenticator=authenticator, organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(), invocation_observer=spy.observe, clock=lambda: RECEIVED_AT, request_id_factory=lambda: "server-generated-request", diff --git a/tests/unit/test_membership_context.py b/tests/unit/test_membership_context.py new file mode 100644 index 00000000..b15c17b4 --- /dev/null +++ b/tests/unit/test_membership_context.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import UTC, datetime +from types import TracebackType +from typing import Any, cast +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import Connection, Engine +from sqlalchemy.exc import OperationalError + +import engine.persistence.membership_context as membership_context_module +from engine.persistence.membership_context import ( + MembershipAuthorityUnavailable, + MembershipIdentity, + MembershipNotCurrent, + PostgreSQLMembershipAuthority, +) +from engine.runtime.actor import ( + MembershipRejectionAuditReceipt, + MembershipRejectionCategory, +) + +CHECKED_AT = datetime(2026, 7, 21, 8, 0, tzinfo=UTC) + + +class _ScalarResult: + def __init__(self, value: object) -> None: + self._value = value + + def scalar_one(self) -> object: + return self._value + + def one_or_none(self) -> _MembershipRow | None: + return cast(_MembershipRow | None, self._value) + + +class _MembershipRow: + def __init__(self, user_id: UUID) -> None: + self.user_id = user_id + + +class _FakeConnection: + def __init__( + self, + events: list[str], + settings: dict[str, str], + row: _MembershipRow | None, + *, + fail_on_query: bool = False, + ) -> None: + self._events = events + self._settings = settings + self._row = row + self._fail_on_query = fail_on_query + + def execute( + self, + statement: object, + parameters: dict[str, object] | None = None, + ) -> _ScalarResult: + sql = str(statement) + self._events.append(f"sql:{sql}") + if "FROM pg_roles AS role" in sql: + return _MappingResult( + { + "current_role": "context_engine_runtime", + "session_role": "context_engine_runtime", + "is_superuser": False, + "bypasses_rls": False, + "inherits_roles": False, + "can_create_roles": False, + "can_create_databases": False, + "can_replicate": False, + "has_no_role_memberships": True, + "is_migrator_member": False, + "can_use_migrator": False, + "owns_database": False, + "owns_public_schema": False, + "owns_no_public_relations": True, + "can_create_in_database": False, + "can_create_temporary_tables": False, + "can_create_in_public_schema": False, + } + ) + if "set_config" in sql: + assert parameters is not None + name = cast(str, parameters["setting_name"]) + value = cast(str, parameters["setting_value"]) + self._settings[name] = value + return _ScalarResult(value) + if "current_setting" in sql: + assert parameters is not None + return _ScalarResult(self._settings[cast(str, parameters["setting_name"])]) + if self._fail_on_query: + raise OperationalError("query", {}, RuntimeError("database unavailable")) + return _ScalarResult(self._row) + + +class _MappingResult(_ScalarResult): + def mappings(self) -> _MappingResult: + return self + + def one(self) -> object: + return self._value + + +class _BeginContext(AbstractContextManager[Connection]): + def __init__( + self, + connection: _FakeConnection, + events: list[str], + *, + fail_on_commit: bool = False, + ) -> None: + self._connection = connection + self._events = events + self._fail_on_commit = fail_on_commit + + def __enter__(self) -> Connection: + self._events.append("begin") + return cast(Connection, self._connection) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + del exc_value, traceback + self._events.append("rollback" if exc_type is not None else "commit") + if exc_type is None and self._fail_on_commit: + raise OperationalError( + "commit", + {}, + RuntimeError("database unavailable during commit"), + ) + return None + + +class _FakeEngine: + def __init__( + self, + row: _MembershipRow | None, + *, + fail_on_query: bool = False, + fail_on_begin: bool = False, + fail_on_commit: bool = False, + ) -> None: + self.events: list[str] = [] + self.settings: dict[str, str] = {} + self.connection = _FakeConnection( + self.events, + self.settings, + row, + fail_on_query=fail_on_query, + ) + self._fail_on_begin = fail_on_begin + self._fail_on_commit = fail_on_commit + + def begin(self, **kwargs: Any) -> AbstractContextManager[Connection]: + assert kwargs == {} + if self._fail_on_begin: + raise OperationalError( + "begin", + {}, + RuntimeError("database unavailable during begin"), + ) + return _BeginContext( + self.connection, + self.events, + fail_on_commit=self._fail_on_commit, + ) + + +def identity() -> MembershipIdentity: + return MembershipIdentity( + organization_id=UUID("81e18bca-86a1-478a-937d-7675c6fe69b0"), + user_id=UUID("9d429284-aea8-467b-a177-d4cdb7670a65"), + membership_id=UUID("d8e1c5bf-bcd0-48a9-b651-b539225bcad8"), + membership_version=7, + principal_ref="principal-from-auth", + request_id="request-1", + authentication_binding_ref="binding-from-auth", + checked_at=CHECKED_AT, + ) + + +def test_current_membership_transaction_binds_every_actor_fact_before_lookup() -> None: + expected = identity() + fake_engine = _FakeEngine(_MembershipRow(expected.user_id)) + authority = PostgreSQLMembershipAuthority(cast(Engine, fake_engine)) + + with authority.current_user_actor(expected) as verification: + fake_engine.events.append("runtime-resolve") + assert verification.user_id == expected.user_id + assert verification.membership_id == expected.membership_id + + assert fake_engine.events[0] == "begin" + lookup_position = next( + index + for index, event in enumerate(fake_engine.events) + if "FROM membership" in event + ) + assert lookup_position > 17 + assert fake_engine.events[-2:] == ["runtime-resolve", "commit"] + assert fake_engine.settings == { + "app.organization_id": str(expected.organization_id), + "app.actor_kind": "user", + "app.user_id": str(expected.user_id), + "app.membership_id": str(expected.membership_id), + "app.membership_version": "7", + "app.principal_ref": expected.principal_ref, + "app.request_id": expected.request_id, + "app.authentication_binding_ref": expected.authentication_binding_ref, + "app.checked_at": "2026-07-21T08:00:00Z", + } + + +@pytest.mark.parametrize("row", [None, _MembershipRow(uuid4())]) +def test_missing_or_mismatched_membership_is_one_generic_denial( + row: _MembershipRow | None, +) -> None: + expected = identity() + fake_engine = _FakeEngine(row) + authority = PostgreSQLMembershipAuthority(cast(Engine, fake_engine)) + + with ( + pytest.raises(MembershipNotCurrent) as rejection, + authority.current_user_actor(expected), + ): + pytest.fail("an invalid Membership must not reach Runtime") + + assert fake_engine.events[-1] == "rollback" + assert rejection.value.audit_receipt == MembershipRejectionAuditReceipt( + category=MembershipRejectionCategory.NOT_CURRENT, + denied_detail_count=0, + ) + assert repr(rejection.value.audit_receipt) == ( + "MembershipRejectionAuditReceipt(" + "category=, denied_detail_count=0)" + ) + + +def test_database_fault_is_not_reclassified_as_membership_denial() -> None: + fake_engine = _FakeEngine(None, fail_on_query=True) + authority = PostgreSQLMembershipAuthority(cast(Engine, fake_engine)) + + with ( + pytest.raises(MembershipAuthorityUnavailable), + authority.current_user_actor(identity()), + ): + pytest.fail("an unavailable authority must not reach Runtime") + + assert fake_engine.events[-1] == "rollback" + + +def test_non_runtime_database_role_never_opens_membership_authority_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + expected = identity() + fake_engine = _FakeEngine(_MembershipRow(expected.user_id)) + authority = PostgreSQLMembershipAuthority(cast(Engine, fake_engine)) + + def reject_role(connection: Connection) -> None: + del connection + raise AssertionError("not the exact Runtime role") + + monkeypatch.setattr( + membership_context_module, + "assert_runtime_role", + reject_role, + ) + + with ( + pytest.raises(MembershipAuthorityUnavailable, match="Runtime role"), + authority.current_user_actor(expected), + ): + pytest.fail("a privileged database role must not reach Runtime") + + assert fake_engine.events == ["begin", "rollback"] + + +@pytest.mark.parametrize("failure_point", ["begin", "commit"]) +def test_transaction_boundary_fault_is_authority_unavailability( + failure_point: str, +) -> None: + expected = identity() + fake_engine = _FakeEngine( + _MembershipRow(expected.user_id), + fail_on_begin=failure_point == "begin", + fail_on_commit=failure_point == "commit", + ) + authority = PostgreSQLMembershipAuthority(cast(Engine, fake_engine)) + + with ( + pytest.raises(MembershipAuthorityUnavailable), + authority.current_user_actor(expected), + ): + fake_engine.events.append("runtime-resolve") + + +@pytest.mark.parametrize( + "change", + [ + {"membership_version": 0}, + {"membership_version": True}, + {"membership_version": 1 << 63}, + {"principal_ref": " "}, + {"request_id": ""}, + {"authentication_binding_ref": object()}, + {"checked_at": datetime(2026, 7, 21, 8, 0)}, + ], +) +def test_membership_identity_is_closed_and_exact(change: dict[str, object]) -> None: + values = { + "organization_id": uuid4(), + "user_id": uuid4(), + "membership_id": uuid4(), + "membership_version": 1, + "principal_ref": "principal", + "request_id": "request", + "authentication_binding_ref": "binding", + "checked_at": CHECKED_AT, + } + values.update(change) + + with pytest.raises((TypeError, ValueError), match=r"Membership|membership"): + MembershipIdentity(**cast(Any, values)) diff --git a/tests/unit/test_runtime_empty_package.py b/tests/unit/test_runtime_empty_package.py index d262f6a1..5fd71c30 100644 --- a/tests/unit/test_runtime_empty_package.py +++ b/tests/unit/test_runtime_empty_package.py @@ -1,10 +1,17 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager from datetime import UTC, datetime from uuid import UUID import pytest +from engine.runtime.actor import ( + _close_membership_authority_scope, + _construct_current_membership_verification, + _open_membership_authority_scope, +) from engine.runtime.budget import PackageBudget, PackageBudgetRequest from engine.runtime.construction import ( AuthorizationKernel, @@ -30,6 +37,8 @@ AS_OF = datetime(2026, 7, 21, 5, 0, tzinfo=UTC) INTERNAL_ORGANIZATION_REF = "81e18bca-86a1-478a-937d-7675c6fe69b0" +INTERNAL_USER_REF = "d3d9893f-82d2-4890-8cb2-4c7e57a56f16" +INTERNAL_MEMBERSHIP_REF = "9c9e9f4c-a5ec-4417-9408-0346e1c6c998" SERVER_BUDGET = PackageBudget( max_tokens=1_000, max_provider_calls=8, @@ -61,19 +70,37 @@ def total_calls(self) -> int: return self.index_calls + self.provider_calls + self.source_content_calls -def trusted_operands() -> tuple[AuthenticatedInvocation, TrustedDeliveryContext]: +@contextmanager +def trusted_operands() -> Iterator[ + tuple[AuthenticatedInvocation, TrustedDeliveryContext] +]: + authority_scope = _open_membership_authority_scope() verification = _construct_existing_http_organization_verification( organization_id=UUID(INTERNAL_ORGANIZATION_REF), request_id="request-1", authentication_binding_ref="binding-internal", verified_at=AS_OF, ) + membership_verification = _construct_current_membership_verification( + authority_scope=authority_scope, + organization_id=UUID(INTERNAL_ORGANIZATION_REF), + user_id=UUID(INTERNAL_USER_REF), + membership_id=UUID(INTERNAL_MEMBERSHIP_REF), + membership_version=1, + principal_ref="principal-internal", + request_id="request-1", + authentication_binding_ref="binding-internal", + checked_at=AS_OF, + ) invocation = _construct_authenticated_http_invocation( request_id="request-1", authenticated_organization_ref=INTERNAL_ORGANIZATION_REF, organization_verification=verification, + user_ref=INTERNAL_USER_REF, principal_ref="principal-internal", - membership_ref=None, + membership_ref=INTERNAL_MEMBERSHIP_REF, + membership_version=1, + current_membership_verification=membership_verification, agent_version_ref="agent-version-internal", authenticated_application_ref="application-internal", authentication_binding_ref="binding-internal", @@ -85,7 +112,10 @@ def trusted_operands() -> tuple[AuthenticatedInvocation, TrustedDeliveryContext] delivery_binding_ref="binding-internal", established_at=AS_OF, ) - return invocation, delivery + try: + yield invocation, delivery + finally: + _close_membership_authority_scope(authority_scope) def runtime(content_io_spy: ContentIoSpy | None = None) -> Runtime: @@ -106,13 +136,12 @@ def runtime(content_io_spy: ContentIoSpy | None = None) -> Runtime: def test_resolve_returns_one_tenant_safe_empty_package() -> None: - invocation, delivery = trusted_operands() - - outcome = runtime().resolve( - invocation, - delivery, - Acquire(need=ContextNeed(query="What constrains Runtime delivery?")), - ) + with trusted_operands() as (invocation, delivery): + outcome = runtime().resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="What constrains Runtime delivery?")), + ) assert outcome.kind == "resolved" package = outcome.package @@ -137,14 +166,14 @@ def test_resolve_returns_one_tenant_safe_empty_package() -> None: def test_empty_path_performs_zero_index_provider_or_source_content_io() -> None: - invocation, delivery = trusted_operands() spy = ContentIoSpy() - runtime(spy).resolve( - invocation, - delivery, - Acquire(need=ContextNeed(query="zero I/O probe")), - ) + with trusted_operands() as (invocation, delivery): + runtime(spy).resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="zero I/O probe")), + ) assert spy.total_calls == 0 @@ -162,24 +191,23 @@ def test_content_io_spy_would_detect_every_runtime_dependency_call() -> None: def test_resolve_records_the_finite_effective_budget_without_exposing_usage() -> None: - invocation, delivery = trusted_operands() - - inherited = runtime().resolve( - invocation, - delivery, - Acquire(need=ContextNeed(query="inherit budget")), - ) - narrowed = runtime().resolve( - invocation, - delivery, - Acquire( - need=ContextNeed(query="narrow budget"), - package_budget=PackageBudgetRequest( - max_tokens=100, - max_elapsed_ms=1_000, + with trusted_operands() as (invocation, delivery): + inherited = runtime().resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="inherit budget")), + ) + narrowed = runtime().resolve( + invocation, + delivery, + Acquire( + need=ContextNeed(query="narrow budget"), + package_budget=PackageBudgetRequest( + max_tokens=100, + max_elapsed_ms=1_000, + ), ), - ), - ) + ) assert inherited.effective_budget == SERVER_BUDGET assert narrowed.effective_budget == PackageBudget( @@ -192,55 +220,100 @@ def test_resolve_records_the_finite_effective_budget_without_exposing_usage() -> def test_internal_org_ref_must_be_trusted_uuid_not_opaque_output_ref() -> None: - invocation, delivery = trusted_operands() - object.__setattr__( - invocation, - "organization_ref", - "orgpkg_00000000000000000000000000000001", - ) - - with pytest.raises(ValueError, match="existing-Organization verification"): - runtime().resolve( + with trusted_operands() as (invocation, delivery): + object.__setattr__( invocation, - delivery, - Acquire(need=ContextNeed(query="replay package ref")), + "organization_ref", + "orgpkg_00000000000000000000000000000001", ) + with pytest.raises(ValueError, match="existing-Organization verification"): + runtime().resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="replay package ref")), + ) -def test_runtime_rejects_mismatched_operands_before_allocating_refs() -> None: - invocation, delivery = trusted_operands() - object.__setattr__(delivery, "delivery_binding_ref", "other-binding") - candidate = Runtime( - required_kernel_dependencies(), - package_ttl_seconds=300, - server_budget=SERVER_BUDGET, - clock=lambda: AS_OF, - ) - with pytest.raises(ValueError, match="trusted delivery context"): - candidate.resolve( - invocation, - delivery, - Acquire(need=ContextNeed(query="mismatch")), +def test_runtime_rejects_mismatched_operands_before_allocating_refs() -> None: + with trusted_operands() as (invocation, delivery): + object.__setattr__(delivery, "delivery_binding_ref", "other-binding") + candidate = Runtime( + required_kernel_dependencies(), + package_ttl_seconds=300, + server_budget=SERVER_BUDGET, + clock=lambda: AS_OF, ) + with pytest.raises(ValueError, match="trusted delivery context"): + candidate.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="mismatch")), + ) + def test_runtime_rejects_organization_proof_mismatch() -> None: - invocation, delivery = trusted_operands() - object.__setattr__(invocation, "organization_ref", str(UUID(int=9))) - candidate = Runtime( - required_kernel_dependencies(), - server_budget=SERVER_BUDGET, - clock=lambda: AS_OF, - ) + with trusted_operands() as (invocation, delivery): + object.__setattr__(invocation, "organization_ref", str(UUID(int=9))) + candidate = Runtime( + required_kernel_dependencies(), + server_budget=SERVER_BUDGET, + clock=lambda: AS_OF, + ) - with pytest.raises(ValueError, match="existing-Organization verification"): - candidate.resolve( + with pytest.raises(ValueError, match="existing-Organization verification"): + candidate.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="switched organization")), + ) + + +def test_runtime_rejects_user_actor_after_authority_scope_closes_before_io() -> None: + spy = ContentIoSpy() + with trusted_operands() as operands: + invocation, delivery = operands + + with pytest.raises(ValueError, match="active Membership authority scope"): + runtime(spy).resolve( invocation, delivery, - Acquire(need=ContextNeed(query="switched organization")), + Acquire(need=ContextNeed(query="expired actor authority")), ) + assert spy.total_calls == 0 + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + ( + ("user_ref", str(UUID(int=10))), + ("membership_ref", str(UUID(int=11))), + ("membership_version", 2), + ("principal_ref", "other-principal"), + ("request_id", "other-request"), + ("authentication_binding_ref", "other-binding"), + ("received_at", datetime(2026, 7, 21, 5, 1, tzinfo=UTC)), + ), +) +def test_runtime_revalidates_every_user_actor_binding_before_io( + field_name: str, + invalid_value: object, +) -> None: + spy = ContentIoSpy() + with trusted_operands() as (invocation, delivery): + object.__setattr__(invocation, field_name, invalid_value) + + with pytest.raises(ValueError): + runtime(spy).resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="mutated actor binding")), + ) + + assert spy.total_calls == 0 + @pytest.mark.parametrize("invalid_ttl", [0, -1, True, 1.5]) def test_runtime_profile_requires_a_finite_positive_exact_ttl( @@ -255,12 +328,12 @@ def test_runtime_profile_requires_a_finite_positive_exact_ttl( def test_runtime_issues_closed_fresh_server_refs_without_factory_injection() -> None: - invocation, delivery = trusted_operands() - candidate = runtime() - request = Acquire(need=ContextNeed(query="fresh refs")) + with trusted_operands() as (invocation, delivery): + candidate = runtime() + request = Acquire(need=ContextNeed(query="fresh refs")) - first = candidate.resolve(invocation, delivery, request).package - second = candidate.resolve(invocation, delivery, request).package + first = candidate.resolve(invocation, delivery, request).package + second = candidate.resolve(invocation, delivery, request).package assert first.organization_ref != second.organization_ref assert first.decision_ref != second.decision_ref diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 4469c242..45dfb061 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -23,23 +23,80 @@ def table_entries(document: dict[str, Any]) -> dict[str, dict[str, Any]]: } -def test_manifest_classifies_the_exact_bounded_issue_8_schema() -> None: +def test_manifest_classifies_the_exact_issue_11_identity_schema() -> None: """PROP-TENANT-OWNERSHIP-001: no current table is left unclassified.""" document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "1.0.0" + assert document["manifestVersion"] == "2.0.0" assert set(tables) == { "alembic_version", + "membership", "organization", "organization_record", + "user_account", } assert tables["alembic_version"]["classification"] == "global" assert tables["organization"]["classification"] == "global" + assert tables["user_account"]["classification"] == "global" + assert tables["membership"]["classification"] == "tenant_owned" assert tables["organization_record"]["classification"] == "tenant_owned" +def test_membership_manifest_requires_exact_user_actor_and_read_only_runtime() -> None: + """DB-009/DB-010: the identity row is not an Organization-only grant.""" + + entry = table_entries(manifest())["membership"] + assert entry["organizationColumn"] == "organization_id" + assert entry["organizationInclusiveKeys"] == [ + { + "name": "pk_membership", + "kind": "primary_key", + "columns": ["organization_id", "membership_id"], + }, + { + "name": "uq_membership_organization_user", + "kind": "unique", + "columns": ["organization_id", "user_id"], + }, + ] + assert entry["permittedOperations"] == { + "context_engine_runtime": ["SELECT"], + "context_engine_worker": [], + } + + rls = entry["rowLevelSecurity"] + assert rls["enabled"] is True + assert rls["forced"] is True + runtime_policy = next( + policy + for policy in rls["policies"] + if policy["roles"] == ["context_engine_runtime"] + ) + assert runtime_policy["command"] == "SELECT" + expression = runtime_policy["using"] + for setting_name in ( + "app.organization_id", + "app.actor_kind", + "app.user_id", + "app.membership_id", + "app.membership_version", + "app.principal_ref", + "app.request_id", + "app.authentication_binding_ref", + "app.checked_at", + ): + assert setting_name in expression + for membership_property in ( + "status", + "membership_version", + "valid_from", + "valid_until", + ): + assert membership_property in expression + + def test_tenant_owned_manifest_entry_preserves_every_security_property() -> None: """PROP-TENANT-FK-002/PROP-RLS-FAIL-CLOSED-003 structural properties.""" @@ -64,11 +121,19 @@ def test_tenant_owned_manifest_entry_preserves_every_security_property() -> None rls = entry["rowLevelSecurity"] assert rls["enabled"] is True assert rls["forced"] is True - assert len(rls["policies"]) == 1 - policy = rls["policies"][0] + assert len(rls["policies"]) == 2 + policy = next( + candidate + for candidate in rls["policies"] + if candidate["roles"] == ["context_engine_runtime"] + ) assert policy["roles"] == ["context_engine_runtime"] assert policy["using"] == policy["withCheck"] assert "app.organization_id" in policy["using"] + assert "app.membership_id" in policy["using"] + assert "app.membership_version" in policy["using"] + assert "app.checked_at" in policy["using"] + assert "EXISTS" in policy["using"] assert "NULLIF" in policy["using"] assert rls["writeContextGuard"] == { @@ -78,6 +143,8 @@ def test_tenant_owned_manifest_entry_preserves_every_security_property() -> None "orientation": "STATEMENT", "events": ["INSERT", "UPDATE", "DELETE"], "missingContextSqlstate": "42501", + "requiredActorKind": "user", + "requiresCurrentMembership": True, } assert entry["permittedOperations"] == { @@ -97,6 +164,8 @@ def test_tenant_owned_manifest_entry_preserves_every_security_property() -> None "DB-006", "DB-007", "DB-008", + "DB-009", + "DB-010", "MIG-001", "MIG-002", }