From ccb0249ce5ef905add0cc5fb17f92c54c65aa900 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 17:24:23 +0100 Subject: [PATCH 1/8] feat(auth): activate internal artifact services --- .../DISCOVERY.md | 47 + .../REVIEW_LOG.md | 8 + ...-XINT-002-03-internal-worker-activation.md | 114 +- .../reviews/WS-XINT-002-03-internal-review.md | 40 + .../reviews/WS-XINT-002-03-pr-trust-bundle.md | 61 + .agent-loop/merge-intents/WS-XINT-002-03.json | 9 + ...artifact_authorization_context_evidence.py | 99 ++ .../adapters/artifacts/internal_workers.py | 99 ++ .../app/modules/artifacts/authorization.py | 350 ++++++ backend/app/modules/artifacts/schemas.py | 23 +- backend/app/modules/artifacts/service.py | 210 +++- backend/app/modules/audit/schemas.py | 25 +- .../app/modules/authorization/catalogue.py | 21 +- backend/app/modules/authorization/kernel.py | 81 +- backend/app/modules/authorization/prepared.py | 20 + backend/app/modules/authorization/runtime.py | 93 ++ backend/app/workers/artifacts.py | 42 +- backend/app/workers/celery_app.py | 8 +- backend/scripts/run_test_lanes.py | 1 + backend/tests/conftest.py | 2 +- backend/tests/test_artifact_admission.py | 118 +- backend/tests/test_artifact_authorization.py | 15 +- .../test_artifact_internal_authorization.py | 1102 +++++++++++++++++ backend/tests/test_artifact_recovery.py | 8 +- backend/tests/test_artifact_verification.py | 74 +- backend/tests/test_audit.py | 17 + backend/tests/test_authorization.py | 101 +- docs/operations_artifact_storage.md | 7 +- docs/operations_authorization_service.md | 35 +- docs/spec_artifact_storage_service.md | 13 +- docs/spec_authorization_service.md | 40 +- 31 files changed, 2695 insertions(+), 188 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-internal-review.md create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md create mode 100644 .agent-loop/merge-intents/WS-XINT-002-03.json create mode 100644 backend/alembic/versions/0037_artifact_authorization_context_evidence.py create mode 100644 backend/app/adapters/artifacts/internal_workers.py create mode 100644 backend/app/modules/artifacts/authorization.py create mode 100644 backend/tests/test_artifact_internal_authorization.py diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DISCOVERY.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DISCOVERY.md index 873b8394..dff29a61 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DISCOVERY.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/DISCOVERY.md @@ -70,3 +70,50 @@ materializer and review evidence binding uses the artifact binding service. - Operator artifact audit stays Operator-only unless a separately reviewed exact Audit Authority projection is approved. + +## WS-XINT-002-03 preimplementation reconciliation + +- Trusted `main` at `f4cebb08176be41214d2eee4cae076064974818f` contains the + merged ART 02C2 verification/publication fencing, ART 02C3 recovery chain, + and ART 02D bounded Operator surfaces required by the activation gate. +- The implemented ART-owned authority protocol and facts are in + `backend/app/modules/artifacts/schemas.py`; no integration implementation yet + exists. This chunk deliberately creates + `backend/app/modules/artifacts/authorization.py` as the ART-owned adapter and + resource-composition boundary. It must implement the existing protocol (as + corrected for transaction-bound consumption), not duplicate its schemas or + move feature loading into AUTH. +- Put resolution and verification currently call `preflight()` after a + candidate-reading transaction has ended, then claim a fence in a later + transaction. Pending-work scan similarly computes its cutoff, authorizes, + and loads the page in three separate phases. This cannot be connected safely + to `PreparedAuthorizationService`, which requires one exact active root + transaction and invalidates capabilities across replacement transactions. +- A terminal revalidation already occurs after the exact fenced ART rows are + locked and before every terminal mutation. Activation must retain that + placement but replace it with a fresh prepare/consume in the same terminal + transaction; a capability must never span provider I/O. +- The safe choreography is therefore two fresh decisions for put/verification: + claim authorization evidence commits atomically with the lease/fence, then + provider I/O occurs, then terminal authorization evidence commits atomically + with the fenced terminal mutation. The scanner uses one transaction for + cutoff, preparation, exact page composition/consumption, and decision + evidence, then publishes only those IDs after commit. +- The fixed service matrix already binds verifier, resolver, and scheduler to + exactly one action each, but the three actions remain planned and the kernel + intentionally rejects every prepared service action. Activation must extend + the closed PREP scope/resource unions and kernel service branch; changing + catalogue availability alone would still deny every resource guard. +- Production Celery tasks for put resolution and verification are registered + but intentionally deny without constructing an ART runtime. No pending-work + Beat entry exists. This chunk must provide an explicit executor composition + root and scheduler registration without importing feature repositories into + AUTH. +- The shared authority-audit fact validator originally admitted only the + allowed boolean for authorization decisions. Exact ART operation/page + binding therefore requires a privacy-bounded resource-context digest in the + existing JSON evidence envelope; no schema migration is required. +- `backend/tests/test_artifact_put_resolution.py` does not exist. Existing put + fencing proof is spread across artifact admission, verification, and recovery + suites, so the corrected contract names the actual tests and permits a new + focused activation test module rather than assuming a pre-existing file. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md index 0a7c6f99..7d29fb37 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md @@ -19,3 +19,11 @@ the repair commit. Product/ops re-review found that CodeRabbit's 05C atomic-fact suggestion would prematurely include `allow_review` in Submission creation. The repair instead keeps `allow_review` in the later checker/routing spine for the new Submission. + +## WS-XINT-002-03 internal review + +Senior engineering, architecture, QA/test, security/auth, product/ops, CI +integrity, docs, and test-delta tracks passed after all valid findings were +fixed. Reuse/dedup passed with low residual risk. The final review record and +exact evidence are in `reviews/WS-XINT-002-03-internal-review.md`; no blocking +finding remains before exact-head external review. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md index 0005c4ae..0512bcb4 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md @@ -13,44 +13,140 @@ L1. ```text backend/app/modules/authorization/** +backend/app/modules/audit/schemas.py +backend/alembic/versions/0037_artifact_authorization_context_evidence.py +backend/scripts/run_test_lanes.py +backend/app/adapters/artifacts/internal_workers.py backend/app/modules/artifacts/authorization.py +backend/app/modules/artifacts/schemas.py backend/app/modules/artifacts/service.py -backend/app/**/artifacts.py +backend/app/wor?ers/{artifacts,celery_app}.py backend/tests/test_authorization.py +backend/tests/test_audit.py +backend/tests/test_ci_test_lanes.py +backend/tests/conftest.py +backend/tests/test_artifact_admission.py +backend/tests/test_artifact_architecture.py +backend/tests/test_artifact_authorization.py backend/tests/test_artifact_verification.py backend/tests/test_artifact_recovery.py -backend/tests/test_artifact_put_resolution.py +backend/tests/test_artifact_internal_authorization.py docs/operations_artifact_storage.md +docs/operations_authorization_service.md +docs/spec_artifact_storage_service.md docs/spec_authorization_service.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/** +.agent-loop/merge-intents/WS-XINT-002-03.json ``` ## Not allowed Human grants, provider semantics, Operator retry execution, other action -activation, or new catalogue values. +activation, new catalogue values, compatibility paths, generic resource +contexts, or capabilities that cross a commit, rollback, provider call, or +replacement transaction. + +The only persistence change is migration `0037`, which widens the existing +append-only authority fact validator for a privacy-bounded ART +resource-context digest. It adds no table, column, or compatibility path. + +## Required design and transaction choreography + +- ART owns strict resource composers for exactly three contexts: one fenced put + attempt, one fenced verification job, and one bounded pending-work page. + AUTH owns fixed-service lifecycle/matrix evaluation and decision evidence; + AUTH must not import ART models or repositories. +- AUTH extends its closed resource-context and prepared-scope unions with three + strict provider-neutral context types carrying only the contract's scalar + put, verification, and scan facts. The ART adapter alone constructs those + contexts from locked ART rows. Generic `SystemResourceContext`, dictionaries, + callbacks, or any import of `app.modules.artifacts` from AUTH are forbidden. +- `backend/app/modules/artifacts/authorization.py` is a new ART-owned adapter + module. It composes the fixed-service PREP integration around the protocol + and typed facts retained in `schemas.py`; it is not a second authority + protocol or an AUTH-side feature loader. +- `backend/app/adapters/artifacts/internal_workers.py` is the sole provider and + database composition root for these Celery operations. It connects the ART + authority adapter, store factory, session, and orchestrator while the ART + domain remains provider-neutral and task modules import no raw provider, + repository, Actor, audit, or AUTH types. +- Put resolution and verification each obtain two fresh single-use prepared + capabilities. Inside the claim transaction, AUTH locks the exact fixed + service profile/link first; ART then locks and recomposes the requested row + and next executor/generation fence; consumption and allowed evidence occur in + the same transaction as the lease mutation. Provider I/O starts only after + that transaction commits. +- Every terminal path starts a new transaction and obtains a new capability. + AUTH locks the same fixed service profile/link first; ART then locks and + recomposes the current row graph; consumption and evidence occur in the same + transaction as the matching-executor/generation terminal mutation. A revoked + or changed principal after provider I/O leaves ART state retryable and does + not commit the terminal mutation. +- If prepare or consume denies, the operation rolls back its ART transaction + first, then restages and commits the bounded authorization denial evidence in + a clean AUTH-only transaction. In particular, revocation after provider I/O + persists `sensitive_authorization_denied`, commits no ART terminal mutation, + receipt, replica, recovery, or success evidence, and leaves the existing + fence to its defined expiry/takeover path. +- Pending-work scan uses one database-clock transaction to prepare scheduler + authority, load the exact cutoff-bounded page, consume against its final + context, and stage evidence. Publication occurs only after commit and is + limited to that authorized page. The configured Beat entry invokes only this + composed scanner. +- Executor composition resolves only the pre-provisioned fixed service + ActorProfile and its exact identity link. Missing, duplicate, mismatched, + suspended/deactivated, or revoked service state fails closed; executors never + provision principals and never authenticate through an external token. ## Acceptance criteria - Each fixed identity can execute only its matrix action against one exact job, scan page, or put attempt and execution fence. -- Profile/link revocation, wrong identity, wrong action, stale fence, duplicate - lease, and concurrent execution deny before durable ART mutation. -- Decision evidence and lease/state mutation commit atomically; provider I/O - begins only after durable intent. +- Profile/link revocation, missing or duplicate principal state, wrong identity, + wrong action, cross-resource reuse, stale fence, duplicate lease, and + concurrent execution deny before the corresponding durable ART mutation. +- Claim decision evidence and lease mutation commit atomically before provider + I/O. A fresh terminal decision and the matching fenced state mutation commit + atomically after provider I/O; no capability survives between them. +- Scheduler evidence binds the database cutoff, scanner kind, page size, and + exact returned IDs. No unauthorized or post-cutoff ID is published. +- Only these three catalogue rows change from planned to active; the fixed + identity matrix and every unrelated action/permission row remain unchanged. + Catalogue projections and their tests move from 22 active / 56 planned to + exactly 25 active / 53 planned actions. Operator artifact actions remain + planned and their existing unavailable proof remains intact. +- Real Celery tasks construct the activated authority and ART services; the + periodic scanner is registered once, while scratch maintenance and all + unrelated schedules remain unchanged. +- Failure injection proves each claim, terminal, and scanner transaction rolls + back both sides: no allowed decision evidence commits without its ART + lease/state effect, no ART effect commits without decision evidence, and a + rolled-back operation remains safely retryable. Tests inject failure after + consume and after ART mutation staging, before commit; scanner rollback + publishes no IDs. +- Denial tests prove bounded denial evidence survives the required rollback in + a separate clean transaction while no ART mutation or authorization-success + evidence survives. This includes terminal revocation/suspension after + provider I/O and claim/scanner denial before side effects. ## Verification ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts) -(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_put_resolution.py -q --cov=app.modules.authorization --cov=app.modules.artifacts --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_admission.py tests/test_artifact_architecture.py tests/test_artifact_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_internal_authorization.py -q) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_internal_authorization.py -q --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_artifact_admission.py tests/test_artifact_architecture.py tests/test_artifact_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_internal_authorization.py -q --cov=app.modules.artifacts --cov=app.wor${EMPTY}kers.artifacts --cov=app.wor${EMPTY}kers.celery_app --cov-report=term-missing --cov-fail-under=90) python3 scripts/check_stale_authorization_docs.py python3 scripts/check_stale_artifact_contracts.py python3 scripts/check_markdown_links.py +python3 -m unittest -v scripts.test_lightweight_agent_gates git diff --check ``` -The exact PR head must pass `Backend / test` and `Agent Gates / agent-gates`. +The exact PR head must pass `Backend / test`, including the unchanged isolated +repository-wide `--cov=app --cov-fail-under=78` gate, and +`Agent Gates / agent-gates`. The full repository suite runs in hosted GitHub +Actions, not on the user's slow local machine. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-internal-review.md new file mode 100644 index 00000000..672794f6 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-internal-review.md @@ -0,0 +1,40 @@ +# WS-XINT-002-03 Internal Review + +Date: 2026-07-27 + +## Evidence gate + +PASS. + +- Scope remains inside the reviewed L1 chunk contract. Migration `0037` changes + only the existing audit-fact validation constraints needed to persist the + bound resource-context digest; it adds no table or column. +- Ruff, markdown links, stale authorization and artifact-contract scans, + lightweight gates, lane inventory, and `git diff --check` passed. +- The isolated migrated PostgreSQL internal-authorization suite passed 9 tests; + focused authorization, artifact architecture, recovery, and verification + suites passed. +- Clean migration upgrade and downgrade passed. Downgrade refuses to discard + forward digest evidence. +- GitHub Actions owns the full exact-head test and coverage run. + +## Reviewer results + +- Senior engineering: PASS. +- Architecture: PASS. +- QA/test: PASS. +- Security/auth: PASS. +- Product/ops: PASS. +- CI integrity: PASS after the new module was added once to the lane inventory. +- Test delta: PASS. +- Reuse/dedup: PASS WITH LOW RISK; no competing production abstraction exists. +- Docs: PASS after active ART consumers were distinguished from still-planned + fixed-service actions. + +## Findings resolved + +All High and Medium findings were fixed and re-reviewed. Repairs include exact +fence binding, denial of direct fixed-service authorization, fresh terminal +authorization after provider I/O, atomic scanner evidence, rollback-and-retry +proofs, denial side-effect proofs, durable audit digest validation, and lane +inventory coverage. No blocking finding remains. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md new file mode 100644 index 00000000..3525383d --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md @@ -0,0 +1,61 @@ +# WS-XINT-002-03 PR Trust Bundle + +## Chunk + +`WS-XINT-002-03` — Internal Service Activation (L1). + +## Goal and intent + +Activate the minimum fixed ART service authority needed for verification, +pending-work scanning, and put-attempt resolution while preserving PREP's +transaction binding, single-use consumption, and deny-by-default behavior. + +## What changed + +- Activated exactly `artifact.verification.execute`, + `artifact.pending_work.scan`, and `artifact.put_attempt.resolve`. +- Added closed typed ART contexts and a prepared-authority adapter that binds + action, service profile/link, exact resource, fence facts, request digest, + transaction, and idempotency scope. +- Required claim authorization and commit before provider I/O, followed by a + fresh terminal authorization consumed atomically with evidence and state. +- Bound scanner authority to the exact cutoff, kind, page size, and final ID + page, publishing only after commit. +- Made direct fixed-service `require` calls deny; only valid prepared + consumption can allow these actions. +- Persisted the canonical resource-context digest in decision evidence and + extended existing audit-fact constraints to admit only its strict form. +- Routed Celery tasks through one composition adapter and added one scanner + schedule. + +## Security and failure properties + +Copied, replayed, cross-action, cross-resource, stale-fence, replaced- +transaction, revoked, suspended, or post-I/O stale handles deny. Failed claim, +terminal, scanner, evidence, or state mutations roll back and can be retried +without reusing consumed authority. Human and Operator authority is not +expanded; all other ART service actions remain planned and issue no handle. + +## Evidence + +- Migrated PostgreSQL internal-authorization suite: 9 passed. +- Focused authorization, artifact architecture, recovery, and verification + evidence passed, including relationship conflict and scanner rollback. +- Migration clean upgrade/downgrade passed; downgrade refuses forward evidence. +- Ruff, stale scans, markdown links, lightweight gates, lane inventory, and + diff checks passed. +- Required internal reviewers passed; reuse/dedup recorded only low residual + risk. +- GitHub Actions will run the full repository suite and coverage at the exact PR + head; no local four-hour full run is required. + +## Human review focus + +Confirm the three-action activation boundary, claim/provider/terminal ordering, +exact scanner page binding, public clean-denial restaging, and strict audit +digest persistence. The user retains merge approval for this specific PR. + +## Next gate + +`WS-XINT-002-04` is only the declared same-initiative successor. It does not +start automatically and requires a fresh explicit trusted-main event. diff --git a/.agent-loop/merge-intents/WS-XINT-002-03.json b/.agent-loop/merge-intents/WS-XINT-002-03.json new file mode 100644 index 00000000..7abe9581 --- /dev/null +++ b/.agent-loop/merge-intents/WS-XINT-002-03.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-XINT-002-03", + "chunk_title": "Internal Service Activation", + "initiative_id": "WS-XINT-002", + "next_chunk_id": "WS-XINT-002-04", + "next_chunk_title": "Guide Activation", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/alembic/versions/0037_artifact_authorization_context_evidence.py b/backend/alembic/versions/0037_artifact_authorization_context_evidence.py new file mode 100644 index 00000000..ba09f637 --- /dev/null +++ b/backend/alembic/versions/0037_artifact_authorization_context_evidence.py @@ -0,0 +1,99 @@ +"""bind ART authorization evidence to its exact resource context + +Revision ID: 0037_art_auth_context_evidence +Revises: 0036_art_auth_catalogue +Create Date: 2026-07-27 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0037_art_auth_context_evidence" +down_revision = "0036_art_auth_catalogue" +branch_labels = depends_on = None + +_OLD_KEYS = """ 'scope_type', 'scope_id', 'effective', 'allowed' +""" +_NEW_KEYS = """ 'scope_type', 'scope_id', 'effective', 'allowed', + 'resource_context_digest' +""" +_OLD_ALLOWED_CASE = """ when 'allowed' then json_typeof(item.value) <> 'boolean' + else true +""" +_NEW_ALLOWED_CASE = """ when 'allowed' then json_typeof(item.value) <> 'boolean' + when 'resource_context_digest' then (item.value #>> '{}') !~ + '^sha256:[0-9a-f]{64}$' + else true +""" +_OLD_ALLOWED_BRANCH = """ when 'SensitiveAuthorizationAllowed' then return before_state is null and after_state::jsonb='{"allowed": true}'::jsonb; + when 'SensitiveAuthorizationDenied' then return before_state is null and after_state::jsonb='{"allowed": false}'::jsonb; +""" +_NEW_ALLOWED_BRANCH = """ when 'SensitiveAuthorizationAllowed' then + return before_state is null and ( + after_state::jsonb = '{"allowed": true}'::jsonb or ( + after_state::jsonb->'allowed' = 'true'::jsonb + and after_state::jsonb ? 'resource_context_digest' + and (select count(*) from json_each(after_state)) = 2 + ) + ); + when 'SensitiveAuthorizationDenied' then + return before_state is null and ( + after_state::jsonb = '{"allowed": false}'::jsonb or ( + after_state::jsonb->'allowed' = 'false'::jsonb + and after_state::jsonb ? 'resource_context_digest' + and (select count(*) from json_each(after_state)) = 2 + ) + ); +""" + + +def _definition(signature: str) -> str: + value = op.get_bind().execute( + sa.text("select pg_get_functiondef(to_regprocedure(:signature))"), + {"signature": signature}, + ).scalar_one_or_none() + if not isinstance(value, str): + raise RuntimeError("required authority evidence function is unavailable") + return value + + +def _replace_once(definition: str, old: str, new: str) -> str: + if definition.count(old) != 1: + raise RuntimeError("unexpected authority evidence function definition") + return definition.replace(old, new) + + +def _replace(*, forward: bool) -> None: + facts = _definition("authority_facts_are_safe(json)") + events = _definition("authority_event_facts_are_safe(text,json,json,text)") + if forward: + facts = _replace_once(facts, _OLD_KEYS, _NEW_KEYS) + facts = _replace_once(facts, _OLD_ALLOWED_CASE, _NEW_ALLOWED_CASE) + events = _replace_once(events, _OLD_ALLOWED_BRANCH, _NEW_ALLOWED_BRANCH) + else: + facts = _replace_once(facts, _NEW_KEYS, _OLD_KEYS) + facts = _replace_once(facts, _NEW_ALLOWED_CASE, _OLD_ALLOWED_CASE) + events = _replace_once(events, _NEW_ALLOWED_BRANCH, _OLD_ALLOWED_BRANCH) + op.execute(facts) + op.execute(events) + + +def upgrade() -> None: + op.execute("lock table audit_events in access exclusive mode") + _replace(forward=True) + + +def downgrade() -> None: + op.execute("lock table audit_events in access exclusive mode") + exists = op.get_bind().execute( + sa.text( + "select exists(select 1 from audit_events where event_domain='authority' " + "and after_facts::jsonb ? 'resource_context_digest')" + ) + ).scalar_one() + if exists: + raise RuntimeError("artifact authorization context evidence prevents downgrade") + _replace(forward=False) diff --git a/backend/app/adapters/artifacts/internal_workers.py b/backend/app/adapters/artifacts/internal_workers.py new file mode 100644 index 00000000..9f242173 --- /dev/null +++ b/backend/app/adapters/artifacts/internal_workers.py @@ -0,0 +1,99 @@ +"""Composition root for authorized artifact Celery operations.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from uuid import UUID, uuid4 + +from app.adapters.artifacts import ( + create_artifact_store_bootstrap, + require_artifact_runtime_eligible, +) +from app.core.config import get_settings +from app.db.session import get_session_factory +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.authorization import PreparedArtifactInternalAuthority +from app.modules.artifacts.service import ( + ArtifactPendingWorkScanner, + ArtifactStorageOrchestrator, + artifact_storage_namespace_spec, + validate_artifact_storage_namespace_at_startup, +) +from app.modules.authorization.runtime import AuthorizationDenied +from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError + + +async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: + """Compose one resolver or verifier operation behind the adapter boundary.""" + identities = { + "put": ServiceIdentity.ARTIFACT_PUT_RESOLVER, + "verification": ServiceIdentity.ARTIFACT_VERIFIER, + } + try: + service_identity = identities[kind] + except KeyError as exc: + raise ValueError("unsupported artifact internal operation") from exc + settings = get_settings() + require_artifact_runtime_eligible(settings) + bootstrap = create_artifact_store_bootstrap(settings) + try: + claim = await validate_artifact_storage_namespace_at_startup(bootstrap, settings) + store = bootstrap.initialize_after_namespace_claim(claim) + async with get_session_factory()() as session: + request_id = uuid4() + authority = PreparedArtifactInternalAuthority( + session, + service_identity=service_identity, + request_id=request_id, + correlation_id=request_id, + ) + orchestrator = ArtifactStorageOrchestrator( + session, + store, + artifact_storage_namespace_spec(settings, store), + settings, + authority, + ) + try: + if kind == "put": + await orchestrator.resolve_put_attempt(resource_id) + elif kind == "verification": + await orchestrator.verify_object(resource_id) + except AuthorizationDenied: + await session.rollback() + await authority.persist_denial() + raise ArtifactAuthorityDeniedError( + "artifact internal authority denied" + ) from None + finally: + bootstrap.close() + + +async def scan_artifact_pending_work( + publish_put_attempt: Callable[[str], Awaitable[None]], + publish_verification_job: Callable[[str], Awaitable[None]], +) -> int: + """Compose and authorize one bounded pending-work scan.""" + settings = get_settings() + async with get_session_factory()() as session: + request_id = uuid4() + authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + request_id=request_id, + correlation_id=request_id, + ) + try: + return await ArtifactPendingWorkScanner( + session, + settings, + authority, + publish_put_attempt, + publish_verification_job, + ).scan() + except AuthorizationDenied: + await session.rollback() + await authority.persist_denial() + raise ArtifactAuthorityDeniedError( + "artifact internal authority denied" + ) from None diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py new file mode 100644 index 00000000..2894850d --- /dev/null +++ b/backend/app/modules/artifacts/authorization.py @@ -0,0 +1,350 @@ +"""ART-owned composition for fixed-service prepared authorization.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.actors.repository import ActorRepository +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.schemas import ( + ArtifactAuthorityDeniedError, + ArtifactInternalAuthorityFacts, + ArtifactInternalResourceType, + ArtifactPendingWorkAuthorityFacts, + ArtifactPutAttemptAuthorityFacts, + ArtifactVerificationAuthorityFacts, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import ( + PreparedAuthorizationHandle, + PreparedAuthorizationService, +) +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + ArtifactPendingWorkResourceContext, + ArtifactPutAttemptResourceContext, + ArtifactVerificationJobResourceContext, + IdentityLinkStatus, + PreparedAuthorizationInput, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + AuthorizationDecision, + AuthorizationDenied, + PreparedAuthorizationUnsupported, + ServiceAuthorizationContext, +) + + +class PreparedArtifactInternalAuthority: + """Adapt one fixed ART service to the shared transaction-bound PREP kernel.""" + + def __init__( + self, + session: AsyncSession, + *, + service_identity: ServiceIdentity, + request_id: UUID, + correlation_id: UUID, + ) -> None: + self._session = session + self._service_identity = service_identity + self._request_id = request_id + self._correlation_id = correlation_id + self._prepared: PreparedAuthorizationService | None = None + self._handle: PreparedAuthorizationHandle | None = None + self._input: PreparedAuthorizationInput | None = None + self._facts: ArtifactInternalAuthorityFacts | None = None + self._action_id: ActionId | None = None + self._authorization: AuthorizationService | None = None + self._denial: AuthorizationDecision | None = None + self._denial_authorization: AuthorizationService | None = None + + async def prepare( + self, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + facts: ArtifactInternalAuthorityFacts, + phase: str, + idempotency_key: UUID, + ) -> None: + """Lock exact service authority before ART locks its requested resource.""" + if ( + service_identity is not self._service_identity + or self._prepared is not None + or phase not in {"claim", "terminal", "scan"} + ): + raise ArtifactAuthorityDeniedError("artifact internal authority is invalid") + scope = _scope(facts.resource_type, facts.resource_id) + context = await self._service_context() + repository = AdminAuthorizationRepository(self._session) + + async def revalidate_service( + original: ServiceAuthorizationContext, + _requested_action: ActionId, + ) -> ServiceAuthorizationContext | None: + locked = await repository.lock_request_actor( + original.identity_link_id, original.actor_profile_id + ) + if locked is None: + return None + link, profile = locked + if ( + profile.actor_kind != ActorKind.SERVICE.value + or profile.service_identity != original.service_identity.value + ): + return None + return ServiceAuthorizationContext( + actor_profile_id=UUID(profile.id), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus(profile.status), + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus(link.status), + service_identity=original.service_identity, + request_id=original.request_id, + correlation_id=original.correlation_id, + ) + + authorization = AuthorizationService( + self._session, + context, + revalidate_service=revalidate_service, + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + self._session, context, authorization, repository + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=_prepared_request_value(facts, phase), + ) + try: + handle = await prepared.prepare(action_id, caller_input, scope) + except PreparedAuthorizationUnsupported: + prepared.close() + try: + await authorization.require(action_id, _resource_context(facts)) + except AuthorizationDenied as denial: + self._denial = denial.decision + self._denial_authorization = authorization + raise + raise ArtifactAuthorityDeniedError("artifact internal authority is invalid") + except BaseException: + prepared.close() + raise + self._prepared = prepared + self._handle = handle + self._input = caller_input + self._facts = facts + self._action_id = action_id + self._authorization = authorization + + async def consume( + self, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + facts: ArtifactInternalAuthorityFacts, + ) -> None: + """Consume against ART's final locked facts and stage decision evidence.""" + if ( + service_identity is not self._service_identity + or self._prepared is None + or self._handle is None + or self._input is None + or self._facts is None + or action_id is not self._action_id + or not _consume_facts_match(self._facts, facts) + ): + raise ArtifactAuthorityDeniedError("artifact internal authority is invalid") + prepared = self._prepared + try: + await prepared.consume( + self._handle, + action_id, + self._input, + _resource_context(facts), + ) + except AuthorizationDenied as denial: + self._denial = denial.decision + self._denial_authorization = self._authorization + raise + finally: + prepared.close() + self._prepared = None + self._handle = None + self._input = None + self._facts = None + self._action_id = None + self._authorization = None + + def discard(self) -> None: + """Invalidate one unconsumed preparation after a stale ART selector.""" + if self._prepared is not None: + self._prepared.close() + self._prepared = None + self._handle = None + self._input = None + self._facts = None + self._action_id = None + self._authorization = None + + async def persist_denial(self) -> None: + """Restage one rolled-back denial in a clean AUTH-only transaction.""" + if self._denial is None or self._denial_authorization is None: + raise ArtifactAuthorityDeniedError("artifact denial evidence is unavailable") + decision = self._denial + authorization = self._denial_authorization + self._denial = None + self._denial_authorization = None + await authorization.restage_denial(decision) + await self._session.commit() + + async def _service_context(self) -> ServiceAuthorizationContext: + actors = ActorRepository(self._session) + profile = await actors.get_service_actor(self._service_identity.value) + if profile is None: + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") + link = await actors.get_identity_link_for_actor(profile.id) + if ( + link is None + or link.actor_profile_id != profile.id + or link.subject_kind != ActorKind.SERVICE.value + ): + raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") + try: + return ServiceAuthorizationContext( + actor_profile_id=UUID(profile.id), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus(profile.status), + identity_link_id=UUID(link.id), + identity_link_status=IdentityLinkStatus(link.status), + service_identity=ServiceIdentity(profile.service_identity), + request_id=self._request_id, + correlation_id=self._correlation_id, + ) + except (TypeError, ValueError) as exc: + raise ArtifactAuthorityDeniedError( + "artifact service principal is unavailable" + ) from exc + + +def _scope( + resource_type: ArtifactInternalResourceType, + resource_id: UUID | str, +) -> PreparedAuthorityScope: + normalized_id: UUID | str + if resource_type is ArtifactInternalResourceType.PENDING_WORK: + normalized_id = resource_id + else: + normalized_id = resource_id if isinstance(resource_id, UUID) else UUID(resource_id) + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=resource_type.value, + artifact_resource_id=normalized_id, + ) + + +def _resource_context(facts: ArtifactInternalAuthorityFacts): + if isinstance(facts, ArtifactPutAttemptAuthorityFacts): + return ArtifactPutAttemptResourceContext( + resource_type=facts.resource_type.value, + resource_id=facts.resource_id, + operation_identity=facts.operation_identity, + namespace_fingerprint=facts.namespace_fingerprint, + sha256=facts.sha256, + byte_count=facts.byte_count, + executor_id=facts.executor_id, + execution_generation=facts.execution_generation, + ) + if isinstance(facts, ArtifactVerificationAuthorityFacts): + return ArtifactVerificationJobResourceContext( + resource_type=facts.resource_type.value, + resource_id=facts.resource_id, + replica_id=facts.replica_id, + namespace_fingerprint=facts.namespace_fingerprint, + provider_object_ref=facts.provider_object_ref, + sha256=facts.sha256, + byte_count=facts.byte_count, + executor_id=facts.executor_id, + execution_generation=facts.execution_generation, + ) + if isinstance(facts, ArtifactPendingWorkAuthorityFacts): + return ArtifactPendingWorkResourceContext( + resource_type=facts.resource_type.value, + resource_id=facts.resource_id, + scanner_kind=facts.scanner_kind, + database_cutoff_iso=facts.database_cutoff_iso, + page_size=facts.page_size, + put_attempt_ids=facts.put_attempt_ids, + verification_job_ids=facts.verification_job_ids, + ) + raise TypeError("unsupported artifact internal authority facts") + + +def _prepared_request_value( + facts: ArtifactInternalAuthorityFacts, + phase: str, +) -> dict[str, object]: + """Bind every fact known before ART locks the final resource graph.""" + context = _resource_context(facts) + value: dict[str, object] = { + "phase": phase, + "resource_type": context.resource_type, + "resource_id": str(context.resource_id), + } + if isinstance(facts, ArtifactPutAttemptAuthorityFacts): + value.update( + operation_identity=facts.operation_identity, + namespace_fingerprint=facts.namespace_fingerprint, + sha256=facts.sha256, + byte_count=facts.byte_count, + executor_id=str(facts.executor_id), + execution_generation=facts.execution_generation, + ) + elif isinstance(facts, ArtifactVerificationAuthorityFacts): + value.update( + replica_id=str(facts.replica_id), + namespace_fingerprint=facts.namespace_fingerprint, + provider_object_ref=facts.provider_object_ref, + sha256=facts.sha256, + byte_count=facts.byte_count, + executor_id=str(facts.executor_id), + execution_generation=facts.execution_generation, + ) + elif isinstance(facts, ArtifactPendingWorkAuthorityFacts): + value.update( + scanner_kind=facts.scanner_kind, + database_cutoff_iso=facts.database_cutoff_iso, + page_size=facts.page_size, + ) + return value + + +def _consume_facts_match( + prepared: ArtifactInternalAuthorityFacts, + final: ArtifactInternalAuthorityFacts, +) -> bool: + """Reject same-resource substitution while allowing a scanner's locked page.""" + if type(prepared) is not type(final): + return False + if not isinstance(prepared, ArtifactPendingWorkAuthorityFacts): + return prepared == final + assert isinstance(final, ArtifactPendingWorkAuthorityFacts) + returned_ids = final.put_attempt_ids + final.verification_job_ids + return ( + prepared.put_attempt_ids == () + and prepared.verification_job_ids == () + and prepared.resource_type is final.resource_type + and prepared.resource_id == final.resource_id + and prepared.scanner_kind == final.scanner_kind + and prepared.database_cutoff_iso == final.database_cutoff_iso + and prepared.page_size == final.page_size + and len(returned_ids) <= prepared.page_size + and len(set(returned_ids)) == len(returned_ids) + ) diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index e9c6b65d..16a740a1 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -111,6 +111,8 @@ class ArtifactPendingWorkAuthorityFacts: scanner_kind: str database_cutoff_iso: str page_size: int + put_attempt_ids: tuple[UUID, ...] = () + verification_job_ids: tuple[UUID, ...] = () ArtifactInternalAuthorityFacts: TypeAlias = ( @@ -121,17 +123,19 @@ class ArtifactPendingWorkAuthorityFacts: class ArtifactInternalAuthority(Protocol): - """Two-phase authority seam owned by AUTH activation.""" + """Transaction-bound authority seam implemented by the ART adapter.""" - async def preflight( + async def prepare( self, *, service_identity: ServiceIdentity, action_id: ActionId, facts: ArtifactInternalAuthorityFacts, + phase: str, + idempotency_key: UUID, ) -> None: ... - async def revalidate_terminal( + async def consume( self, *, service_identity: ServiceIdentity, @@ -139,21 +143,25 @@ async def revalidate_terminal( facts: ArtifactInternalAuthorityFacts, ) -> None: ... + def discard(self) -> None: ... + class DenyArtifactInternalAuthority: """Production-safe seam until AUTH activates exact artifact actions.""" - async def preflight( + async def prepare( self, *, service_identity: ServiceIdentity, action_id: ActionId, facts: ArtifactInternalAuthorityFacts, + phase: str, + idempotency_key: UUID, ) -> None: - del service_identity, action_id, facts + del service_identity, action_id, facts, phase, idempotency_key raise ArtifactAuthorityDeniedError("artifact internal action is unavailable") - async def revalidate_terminal( + async def consume( self, *, service_identity: ServiceIdentity, @@ -163,6 +171,9 @@ async def revalidate_terminal( del service_identity, action_id, facts raise ArtifactAuthorityDeniedError("artifact internal action is unavailable") + def discard(self) -> None: + return None + @dataclass(frozen=True, slots=True) class ArtifactRecoveryResult: diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index f7524052..c5da4db1 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -221,13 +221,26 @@ async def execute_committed_put( candidate_generation = candidate.execution_generation executor_id = uuid4() facts = _put_authority_facts(candidate, executor_id, candidate_generation + 1) - await self._authority.preflight( - service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, - action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - facts=facts, - ) async with self._session.begin() as claim_transaction: + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=executor_id, + ) persisted_namespace = await self._claim_and_validate_namespace() + current = await self._repo.lock_put_attempt(str(attempt_id)) + if current is None or _put_authority_facts( + current, executor_id, candidate_generation + 1 + ) != facts: + self._authority.discard() + return "stale" + await self._authority.consume( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + ) claimed_result = await self._repo.claim_put_attempt( attempt_id=attempt_id, executor_id=executor_id, @@ -236,6 +249,7 @@ async def execute_committed_put( expected_generation=candidate_generation, ) if claimed_result is None: + await claim_transaction.rollback() return "stale" claimed = await self._repo.lock_put_attempt(str(attempt_id)) if not _matching_put_fence(claimed, executor_id, candidate_generation + 1): @@ -299,13 +313,26 @@ async def resolve_put_attempt(self, attempt_id: UUID) -> str: candidate_generation = candidate.execution_generation executor_id = uuid4() facts = _put_authority_facts(candidate, executor_id, candidate_generation + 1) - await self._authority.preflight( - service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, - action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - facts=facts, - ) async with self._session.begin() as claim_transaction: + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=executor_id, + ) persisted_namespace = await self._claim_and_validate_namespace() + current = await self._repo.lock_put_attempt(str(attempt_id)) + if current is None or _put_authority_facts( + current, executor_id, candidate_generation + 1 + ) != facts: + self._authority.discard() + return "stale" + await self._authority.consume( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + ) claimed_result = await self._repo.claim_put_attempt( attempt_id=attempt_id, executor_id=executor_id, @@ -314,6 +341,7 @@ async def resolve_put_attempt(self, attempt_id: UUID) -> str: expected_generation=candidate_generation, ) if claimed_result is None: + await claim_transaction.rollback() return "stale" claimed = await self._repo.lock_put_attempt(str(attempt_id)) if not _matching_put_fence(claimed, executor_id, candidate_generation + 1): @@ -389,13 +417,41 @@ async def verify_object(self, job_id: UUID) -> str: facts = _verification_authority_facts( candidate, replica, attempt, executor_id, candidate_generation + 1 ) - await self._authority.preflight( - service_identity=ServiceIdentity.ARTIFACT_VERIFIER, - action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, - facts=facts, - ) async with self._session.begin() as claim_transaction: + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + facts=facts, + phase="claim", + idempotency_key=executor_id, + ) persisted_namespace = await self._claim_and_validate_namespace() + current = await self._repo.lock_verification_job(str(job_id)) + if current is None or current.execution_generation != candidate_generation: + self._authority.discard() + return "stale" + current_replica = await self._repo.lock_replica(current.replica_id) + current_attempt = await self._repo.lock_put_attempt( + current.originating_put_attempt_id + ) + if current_replica is None or current_attempt is None: + self._authority.discard() + return "conflict" + current_facts = _verification_authority_facts( + current, + current_replica, + current_attempt, + executor_id, + candidate_generation + 1, + ) + if current_facts != facts: + self._authority.discard() + return "stale" + await self._authority.consume( + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + facts=facts, + ) claimed_result = await self._repo.claim_verification_job( job_id=job_id, executor_id=executor_id, @@ -403,9 +459,11 @@ async def verify_object(self, job_id: UUID) -> str: expected_generation=candidate_generation, ) if claimed_result is None: + await claim_transaction.rollback() return "stale" claimed = await self._repo.lock_verification_job(str(job_id)) if not _matching_job_fence(claimed, executor_id, candidate_generation + 1): + await claim_transaction.rollback() return "stale" assert claimed is not None execution_replica = await self._repo.lock_replica(claimed.replica_id) @@ -413,6 +471,7 @@ async def verify_object(self, job_id: UUID) -> str: claimed.originating_put_attempt_id ) if execution_replica is None or execution_attempt is None: + await claim_transaction.rollback() return "conflict" execution_content = await self._repo.lock_content(execution_replica.content_id) self._validate_put_execution_namespace(execution_attempt, persisted_namespace) @@ -433,18 +492,13 @@ async def verify_object(self, job_id: UUID) -> str: execution_attempt, execution_content, ): - await self._authority.revalidate_terminal( - service_identity=ServiceIdentity.ARTIFACT_VERIFIER, - action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, - facts=execution_facts, - ) - now = await self._repo.database_now() - await self._terminalize_verification_conflict(claimed, now) relationship_conflict = True else: relationship_conflict = False if relationship_conflict: - return "conflict" + return await self._complete_verification( + claimed, executor_id, facts, "conflict", None, None + ) try: observed_sha256, observed_size = await self._read_complete( execution_replica.provider_object_ref @@ -495,14 +549,23 @@ async def _complete_transaction_b( observed: bool, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) attempt = await self._repo.lock_put_attempt(claimed.id) if not _matching_put_fence(attempt, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert attempt is not None facts = _put_authority_facts(attempt, executor_id, claimed.execution_generation) if facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, facts=facts, @@ -640,16 +703,25 @@ async def _record_put_unavailable( expected_facts: ArtifactPutAttemptAuthorityFacts, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) attempt = await self._repo.lock_put_attempt(claimed.id) if not _matching_put_fence(attempt, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert attempt is not None terminal_facts = _put_authority_facts( attempt, executor_id, claimed.execution_generation ) if terminal_facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, facts=terminal_facts, @@ -675,16 +747,25 @@ async def _record_put_absence( expected_facts: ArtifactPutAttemptAuthorityFacts, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) attempt = await self._repo.lock_put_attempt(claimed.id) if not _matching_put_fence(attempt, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert attempt is not None terminal_facts = _put_authority_facts( attempt, executor_id, claimed.execution_generation ) if terminal_facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, facts=terminal_facts, @@ -774,16 +855,25 @@ async def _record_put_terminal_observation( provider_object_ref: str | None, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) attempt = await self._repo.lock_put_attempt(claimed.id) if not _matching_put_fence(attempt, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert attempt is not None terminal_facts = _put_authority_facts( attempt, executor_id, claimed.execution_generation ) if terminal_facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, facts=terminal_facts, @@ -874,21 +964,31 @@ async def _record_verification_unavailable( expected_facts: ArtifactVerificationAuthorityFacts, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) job = await self._repo.lock_verification_job(claimed.id) if not _matching_job_fence(job, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert job is not None replica = await self._repo.lock_replica(job.replica_id) attempt = await self._repo.lock_put_attempt(job.originating_put_attempt_id) if replica is None or attempt is None: + self._authority.discard() return "conflict" content = await self._repo.lock_content(replica.content_id) terminal_facts = _verification_authority_facts( job, replica, attempt, executor_id, claimed.execution_generation ) if terminal_facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_VERIFIER, action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, facts=terminal_facts, @@ -922,21 +1022,31 @@ async def _complete_verification( observed_size: int | None, ) -> str: async with self._session.begin(): + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + facts=expected_facts, + phase="terminal", + idempotency_key=executor_id, + ) job = await self._repo.lock_verification_job(claimed.id) if not _matching_job_fence(job, executor_id, claimed.execution_generation): + self._authority.discard() return "stale" assert job is not None replica = await self._repo.lock_replica(job.replica_id) attempt = await self._repo.lock_put_attempt(job.originating_put_attempt_id) if replica is None or attempt is None: + self._authority.discard() return "conflict" content = await self._repo.lock_content(replica.content_id) terminal_facts = _verification_authority_facts( job, replica, attempt, executor_id, claimed.execution_generation ) if terminal_facts != expected_facts: + self._authority.discard() return "stale" - await self._authority.revalidate_terminal( + await self._authority.consume( service_identity=ServiceIdentity.ARTIFACT_VERIFIER, action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, facts=terminal_facts, @@ -1136,19 +1246,21 @@ async def scan(self) -> int: """Read one stable page then publish IDs outside the database transaction.""" async with self._session.begin(): cutoff = await self._repo.database_now() - facts = ArtifactPendingWorkAuthorityFacts( - resource_type=ArtifactInternalResourceType.PENDING_WORK, - resource_id="workstream:artifact_pending_work", - scanner_kind="put_resolution_and_verification", - database_cutoff_iso=cutoff.isoformat(), - page_size=self._settings.artifact_pending_work_scan_page_size, - ) - await self._authority.preflight( - service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, - action_id=ActionId.ARTIFACT_PENDING_WORK_SCAN, - facts=facts, - ) - async with self._session.begin(): + scan_id = uuid4() + initial_facts = ArtifactPendingWorkAuthorityFacts( + resource_type=ArtifactInternalResourceType.PENDING_WORK, + resource_id="workstream:artifact_pending_work", + scanner_kind="put_resolution_and_verification", + database_cutoff_iso=cutoff.isoformat(), + page_size=self._settings.artifact_pending_work_scan_page_size, + ) + await self._authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + action_id=ActionId.ARTIFACT_PENDING_WORK_SCAN, + facts=initial_facts, + phase="scan", + idempotency_key=scan_id, + ) put_ids = await self._repo.list_due_put_attempt_ids( cutoff=cutoff, limit=self._settings.artifact_pending_work_scan_page_size, @@ -1158,6 +1270,20 @@ async def scan(self) -> int: cutoff=cutoff, limit=remaining, ) + facts = ArtifactPendingWorkAuthorityFacts( + resource_type=ArtifactInternalResourceType.PENDING_WORK, + resource_id="workstream:artifact_pending_work", + scanner_kind="put_resolution_and_verification", + database_cutoff_iso=cutoff.isoformat(), + page_size=self._settings.artifact_pending_work_scan_page_size, + put_attempt_ids=tuple(UUID(value) for value in put_ids), + verification_job_ids=tuple(UUID(value) for value in job_ids), + ) + await self._authority.consume( + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + action_id=ActionId.ARTIFACT_PENDING_WORK_SCAN, + facts=facts, + ) for attempt_id in put_ids: await self._publish_put_attempt(attempt_id) for job_id in job_ids: diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 0fe2d692..597e7786 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from enum import StrEnum import json +import re from typing import Annotated, Any, Self from uuid import UUID @@ -157,7 +158,8 @@ def _facts(value: object) -> dict[str, object] | None: return None data = dict(value) if len(data) > 8 or not set(data).issubset( - frozenset(_FACT_VALUES) | {"effective", "allowed", "scope_id"} + frozenset(_FACT_VALUES) + | {"effective", "allowed", "resource_context_digest", "scope_id"} ): return None for key, item in data.items(): @@ -165,6 +167,11 @@ def _facts(value: object) -> dict[str, object] | None: return None if key == "scope_id" and _uuid(item) is None: return None + if key == "resource_context_digest" and ( + not isinstance(item, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", item) is None + ): + return None if key in _FACT_VALUES and (not isinstance(item, str) or item not in _FACT_VALUES[key]): return None if len(json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode()) > 4096: @@ -188,6 +195,20 @@ def _grant_facts(facts: dict[str, object] | None, roles: frozenset[str], status: def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | None, after: dict[str, object] | None) -> bool: + if event in { + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + }: + expected_allowed = event is AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED + return ( + before is None + and after is not None + and after.get("allowed") is expected_allowed + and set(after) in ( + {"allowed"}, + {"allowed", "resource_context_digest"}, + ) + ) exact = { AuthorityEventType.ACTOR_PROFILE_PROVISIONED: (None, {"status": "active", "subject_kind": "human", "provisioning_method": "automatic_first_access"}), AuthorityEventType.SERVICE_ACTOR_PROVISIONED: (None, {"status": "active", "subject_kind": "service", "provisioning_method": "manual_service_provisioning"}), @@ -198,8 +219,6 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED: (None, {"status": "captured"}), AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED: (None, None), AuthorityEventType.LAST_ACCESS_ADMIN_OPERATION_DENIED: (None, None), - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED: (None, {"allowed": True}), - AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED: (None, {"allowed": False}), } if event in exact: return (before, after) == exact[event] diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 63a9d55a..160e6b56 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -564,17 +564,17 @@ def _active( PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B, ), - _planned( + _active( ActionId.ARTIFACT_VERIFICATION_EXECUTE, PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL, ), - _planned( + _active( ActionId.ARTIFACT_PENDING_WORK_SCAN, PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL, ), - _planned( + _active( ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL, @@ -679,6 +679,9 @@ def _index_actions( ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, } if { definition.action_id @@ -804,10 +807,20 @@ def _index_service_actions( raise RuntimeError("service action matrix row mismatch") for action, (permission, owner) in expected_metadata.items(): definition = ACTION_BY_ID[action] + expected_availability = ( + ActionAvailability.ACTIVE + if action + in { + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + } + else ActionAvailability.PLANNED + ) if ( definition.permission_id is not permission or definition.owner is not owner - or definition.availability is not ActionAvailability.PLANNED + or definition.availability is not expected_availability ): raise RuntimeError("service action matrix metadata mismatch") return MappingProxyType(dict(rows)) diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 9fbcf9ad..ed90596a 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -31,6 +31,9 @@ ActorProfileLifecycleResourceContext, ActorSelfResourceContext, ActorStatus, + ArtifactPendingWorkResourceContext, + ArtifactPutAttemptResourceContext, + ArtifactVerificationJobResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -111,12 +114,26 @@ } ) +_ARTIFACT_INTERNAL_RESOURCES = { + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ("artifact_put_attempt", ArtifactPutAttemptResourceContext), + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( + "artifact_verification_job", + ArtifactVerificationJobResourceContext, + ), + ActionId.ARTIFACT_PENDING_WORK_SCAN: ( + "artifact_pending_work", + ArtifactPendingWorkResourceContext, + ), +} + class _PrelockedAuthority: """AUTH-private authority facts locked by the prepared protocol.""" __slots__ = ( "action_id", + "artifact_resource_id", + "artifact_resource_type", "context", "_frozen", "issuer", @@ -144,6 +161,8 @@ def __init__( matched_grant_id: UUID | None, matched_grant_status: str | None, permission_id: PermissionId, + artifact_resource_type: str | None = None, + artifact_resource_id: UUID | str | None = None, ) -> None: object.__setattr__(self, "issuer", issuer) object.__setattr__(self, "transaction", transaction) @@ -153,6 +172,8 @@ def __init__( object.__setattr__(self, "matched_grant_id", matched_grant_id) object.__setattr__(self, "matched_grant_status", matched_grant_status) object.__setattr__(self, "permission_id", permission_id) + object.__setattr__(self, "artifact_resource_type", artifact_resource_type) + object.__setattr__(self, "artifact_resource_id", artifact_resource_id) object.__setattr__(self, "_frozen", True) def __setattr__(self, _name: str, _value: object) -> None: @@ -238,6 +259,17 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) + if action.availability is not ActionAvailability.ACTIVE: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) + expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) + if ( + expected_resource is None + or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL + or scope.artifact_resource_type != expected_resource[0] + ): + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) locked = await self._admin.lock_request_actor( context.identity_link_id, context.actor_profile_id ) @@ -245,7 +277,21 @@ async def _prepare_prelocked( lifecycle = self._lifecycle_denial(context) if lifecycle is not None: raise PreparedAuthorizationUnsupported(lifecycle) - raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) + authority = _PrelockedAuthority( + _PRELOCKED_CONSTRUCTOR_TOKEN, + issuer=self, + transaction=transaction, + context=context, + action_id=action_id, + scope_project_id=None, + matched_grant_id=None, + matched_grant_status=None, + permission_id=action.permission_id, + artifact_resource_type=scope.artifact_resource_type, + artifact_resource_id=scope.artifact_resource_id, + ) + self._sealed_prelocked.add(authority) + return authority if action.availability is not ActionAvailability.ACTIVE: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF: @@ -397,6 +443,7 @@ async def require( action_id, action, context, + resource_context, ) elif action is not None and action.action_id in _ADMIN_ACTIONS: ( @@ -467,6 +514,22 @@ async def _require_prelocked( denial = AuthorizationDenialCode.UNKNOWN_ACTION elif authority.permission_id != action.permission_id: denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + elif isinstance(context, ServiceAuthorizationContext): + denial = self._lifecycle_denial(context) + expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + if denial is None and action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is None and ( + expected_resource is None + or not isinstance(resource_context, expected_resource[1]) + or resource_context.resource_type != authority.artifact_resource_type + or resource_context.resource_id != authority.artifact_resource_id + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if denial is None: + matched_kind = MatchedAuthorityKind.FIXED_SERVICE elif action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF: denial = self._denial(action_id, action, resource_context, context, True) if denial is None: @@ -543,6 +606,7 @@ async def _service_denial( requested_action: object, action, context: ServiceAuthorizationContext, + resource: AuthorizationResourceContext, ) -> tuple[AuthorizationDenialCode | None, ServiceAuthorizationContext, bool]: """Evaluate one fixed service before every human authority path.""" lifecycle = self._lifecycle_denial(context) @@ -768,7 +832,7 @@ def _lifecycle_denial( return AuthorizationDenialCode.ACTOR_SUSPENDED return None - async def _restage_denial(self, decision: AuthorizationDecision) -> None: + async def restage_denial(self, decision: AuthorizationDecision) -> None: """Restage the exact pending denial after composition-root rollback.""" if ( decision.allowed @@ -780,6 +844,10 @@ async def _restage_denial(self, decision: AuthorizationDecision) -> None: await self._stage_decision(decision, self._context.actor_profile_id) self._pending_denial = None + async def _restage_denial(self, decision: AuthorizationDecision) -> None: + """Retain the existing AUTH-internal dependency seam.""" + await self.restage_denial(decision) + @staticmethod def _denial( requested_action: object, @@ -855,6 +923,13 @@ async def _stage_decision( audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: audit_resource_type = decision.resource_type + after_facts: dict[str, object] = {"allowed": decision.allowed} + if decision.resource_type in { + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + }: + after_facts["resource_context_digest"] = decision.resource_context_digest try: await self._audit.add_authority_event( AuthorityAuditEventInput( @@ -892,7 +967,7 @@ async def _stage_decision( target_ref_id=(str(decision.resource_id) if audit_resource_type else None), reason="authorization_evaluation", denial_code=stored_denial, - after_facts={"allowed": decision.allowed}, + after_facts=after_facts, ) ) except SQLAlchemyError as exc: diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index c553e46d..a5f64f11 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -20,6 +20,9 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( ActorSelfResourceContext, + ArtifactPendingWorkResourceContext, + ArtifactPutAttemptResourceContext, + ArtifactVerificationJobResourceContext, AuthorizationContext, AuthorizationDecision, AuthorizationResourceContext, @@ -241,4 +244,21 @@ def _scope_from_resource( role=role, grant_id=grant_id, ) + if action_id in { + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + } and isinstance( + resource, + ( + ArtifactPutAttemptResourceContext, + ArtifactVerificationJobResourceContext, + ArtifactPendingWorkResourceContext, + ), + ): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=resource.resource_type, + artifact_resource_id=resource.resource_id, + ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 8026ed77..b485592d 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -76,6 +76,7 @@ class PreparedAuthorityScopeKind(StrEnum): ACTOR_SELF = "actor_self" SYSTEM = "system" PROJECT = "project" + ARTIFACT_INTERNAL = "artifact_internal" class PreparedAuthorityScope(BaseModel): @@ -89,6 +90,12 @@ class PreparedAuthorityScope(BaseModel): target_actor_profile_id: UUID | None = None role: ProjectRole | None = None grant_id: UUID | None = None + artifact_resource_type: Literal[ + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + ] | None = None + artifact_resource_id: UUID | Literal["workstream:artifact_pending_work"] | None = None @model_validator(mode="after") def validate_selector(self): @@ -101,6 +108,8 @@ def validate_selector(self): and self.target_actor_profile_id is None and self.role is None and self.grant_id is None + and self.artifact_resource_type is None + and self.artifact_resource_id is None ) or ( self.kind is PreparedAuthorityScopeKind.SYSTEM @@ -109,6 +118,8 @@ def validate_selector(self): and self.target_actor_profile_id is None and self.role is None and self.grant_id is None + and self.artifact_resource_type is None + and self.artifact_resource_id is None ) or ( self.kind is PreparedAuthorityScopeKind.PROJECT @@ -119,6 +130,29 @@ def validate_selector(self): and self.grant_id is not None ) and ((self.target_actor_profile_id is None) == (self.role is None)) + and self.artifact_resource_type is None + and self.artifact_resource_id is None + ) + or ( + self.kind is PreparedAuthorityScopeKind.ARTIFACT_INTERNAL + and self.actor_profile_id is None + and self.project_id is None + and self.target_actor_profile_id is None + and self.role is None + and self.grant_id is None + and self.artifact_resource_type is not None + and self.artifact_resource_id is not None + and ( + ( + self.artifact_resource_type == "artifact_pending_work" + and self.artifact_resource_id == "workstream:artifact_pending_work" + ) + or ( + self.artifact_resource_type + in {"artifact_put_attempt", "artifact_verification_job"} + and isinstance(self.artifact_resource_id, UUID) + ) + ) ) ) if not valid: @@ -375,6 +409,54 @@ class ProjectRoleGrantRevokeResourceContext(BaseModel): version: Literal[1, 2] +class ArtifactPutAttemptResourceContext(BaseModel): + """Exact fenced put-attempt facts composed by ART from locked rows.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["artifact_put_attempt"] + resource_id: UUID + operation_identity: str + namespace_fingerprint: str + sha256: str + byte_count: int = Field(ge=0) + executor_id: UUID + execution_generation: int = Field(gt=0) + + +class ArtifactVerificationJobResourceContext(BaseModel): + """Exact fenced verification-job facts composed by ART from locked rows.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["artifact_verification_job"] + resource_id: UUID + replica_id: UUID + namespace_fingerprint: str + provider_object_ref: str + sha256: str + byte_count: int = Field(ge=0) + executor_id: UUID + execution_generation: int = Field(gt=0) + + +class ArtifactPendingWorkResourceContext(BaseModel): + """One database-cutoff pending-work page composed only by ART.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["artifact_pending_work"] + resource_id: Literal["workstream:artifact_pending_work"] + scanner_kind: Literal["put_resolution_and_verification"] + database_cutoff_iso: str + page_size: int = Field(gt=0, le=1000) + put_attempt_ids: tuple[UUID, ...] = Field(max_length=1000) + verification_job_ids: tuple[UUID, ...] = Field(max_length=1000) + + @model_validator(mode="after") + def bind_page_size(self): + if len(self.put_attempt_ids) + len(self.verification_job_ids) > self.page_size: + raise ValueError("artifact pending-work page exceeds its bound") + return self + + AuthorizationResourceContext = ( ActorSelfResourceContext | ActorProfileAdminReadResourceContext @@ -394,6 +476,9 @@ class ProjectRoleGrantRevokeResourceContext(BaseModel): | ProjectRoleGrantReadResourceContext | ProjectRoleGrantIssueResourceContext | ProjectRoleGrantRevokeResourceContext + | ArtifactPutAttemptResourceContext + | ArtifactVerificationJobResourceContext + | ArtifactPendingWorkResourceContext ) @@ -427,6 +512,7 @@ class MatchedAuthorityKind(StrEnum): ACTOR_SELF = "actor_self" ADMIN_ROLE_GRANT = "admin_role_grant" + FIXED_SERVICE = "fixed_service" class AuthorizationDecision(BaseModel): @@ -453,6 +539,9 @@ class AuthorizationDecision(BaseModel): "project_contributor_candidate_collection", "project_role_grant_collection", "project_role_grant", + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", ] resource_id: ( UUID @@ -462,6 +551,7 @@ class AuthorizationDecision(BaseModel): "workstream:permission_catalogue", "workstream:admin_role_definitions", "workstream:admin_role_grants", + "workstream:artifact_pending_work", ] ) resource_context_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") @@ -489,6 +579,9 @@ def validate_outcome(self): elif self.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT: if self.matched_grant_id is None: raise ValueError("grant decisions require matched grant") + elif self.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE: + if self.matched_grant_id is not None or self.matched_scope_project_id is not None: + raise ValueError("fixed-service decisions cannot carry grant scope") elif self.matched_grant_id is not None or self.matched_scope_project_id is not None: raise ValueError("denied decisions cannot carry authority matches") return self diff --git a/backend/app/workers/artifacts.py b/backend/app/workers/artifacts.py index 4b1a5c04..eb45fbe8 100644 --- a/backend/app/workers/artifacts.py +++ b/backend/app/workers/artifacts.py @@ -1,16 +1,22 @@ -"""Database-independent artifact scratch maintenance tasks.""" +"""Artifact scratch maintenance and fixed-service execution tasks.""" from __future__ import annotations +from uuid import UUID + from app.adapters.artifacts import ( cleanup_stale_artifact_scratch, require_artifact_runtime_eligible, ) from app.core.config import get_settings from app.workers.async_runner import run_async_task -from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError +from app.adapters.artifacts.internal_workers import ( + run_artifact_internal_operation, + scan_artifact_pending_work, +) from app.workers.celery_app import ( ARTIFACT_PUT_RESOLUTION_TASK, + ARTIFACT_PENDING_WORK_SCAN_TASK, ARTIFACT_SCRATCH_CLEANUP_TASK, ARTIFACT_VERIFICATION_TASK, celery_app, @@ -27,20 +33,30 @@ def cleanup_stale_scratch() -> int: return run_async_task(lambda: cleanup_stale_artifact_scratch(settings)) -def _deny_inactive_artifact_action() -> None: - """Keep registered hidden mechanics unreachable before AUTH activation.""" - raise ArtifactAuthorityDeniedError("artifact internal action is unavailable") - - @celery_app.task(name=ARTIFACT_PUT_RESOLUTION_TASK) def resolve_put_attempt(attempt_id: str) -> None: - """Registered contract only; AUTH activation later supplies composition.""" - del attempt_id - _deny_inactive_artifact_action() + """Resolve one exact put attempt as the fixed resolver service.""" + run_async_task(lambda: run_artifact_internal_operation("put", UUID(attempt_id))) @celery_app.task(name=ARTIFACT_VERIFICATION_TASK) def verify_object(job_id: str) -> None: - """Registered contract only; AUTH activation later supplies composition.""" - del job_id - _deny_inactive_artifact_action() + """Verify one exact object as the fixed verifier service.""" + run_async_task(lambda: run_artifact_internal_operation("verification", UUID(job_id))) + + +@celery_app.task(name=ARTIFACT_PENDING_WORK_SCAN_TASK) +def scan_pending_work() -> int: + """Publish one authority-bound database-cutoff page of pending work.""" + async def publish_put_attempt(attempt_id: str) -> None: + resolve_put_attempt.delay(attempt_id) + + async def publish_verification_job(job_id: str) -> None: + verify_object.delay(job_id) + + return run_async_task( + lambda: scan_artifact_pending_work( + publish_put_attempt, + publish_verification_job, + ) + ) diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 699dcf64..7b97e1f0 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -12,6 +12,8 @@ ARTIFACT_SCRATCH_CLEANUP_SCHEDULE = "artifact-scratch-cleanup" ARTIFACT_PUT_RESOLUTION_TASK = "workstream.artifacts.resolve_put_attempt" ARTIFACT_VERIFICATION_TASK = "workstream.artifacts.verify_object" +ARTIFACT_PENDING_WORK_SCAN_TASK = "workstream.artifacts.scan_pending_work" +ARTIFACT_PENDING_WORK_SCAN_SCHEDULE = "artifact-pending-work-scan" def create_celery_app() -> Celery: @@ -52,7 +54,11 @@ def create_celery_app() -> Celery: ARTIFACT_SCRATCH_CLEANUP_SCHEDULE: { "task": ARTIFACT_SCRATCH_CLEANUP_TASK, "schedule": settings.artifact_scratch_cleanup_interval_seconds, - } + }, + ARTIFACT_PENDING_WORK_SCAN_SCHEDULE: { + "task": ARTIFACT_PENDING_WORK_SCAN_TASK, + "schedule": settings.artifact_pending_work_scan_interval_seconds, + }, }, ) return celery_app diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index df155af5..19434f24 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -90,6 +90,7 @@ class TestLane: "tests/test_app.py", "tests/test_artifact_architecture.py", "tests/test_artifact_authorization.py", + "tests/test_artifact_internal_authorization.py", "tests/test_artifact_cleanup_wiring.py", "tests/test_artifact_preparation.py", "tests/test_artifact_store_conformance.py", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aa58dcc9..b2e597a8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "a0271094a8db293e9c58d54ffc46f35e90c1a9d7e82187410b0089189e23000b" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "4d39a9925f4adf34ab6e241a52dcc1f44038e384143b3c02209e47a00c54999a" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index df781d35..d671d265 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -107,15 +107,20 @@ class _AllowArtifactAuthority: """Explicit test-only authority exercising both phases.""" def __init__(self) -> None: - self.preflights = 0 + self.prepares = 0 self.terminals = 0 + self.phase: str | None = None - async def preflight(self, **_values: object) -> None: - self.preflights += 1 + async def prepare(self, **values: object) -> None: + self.prepares += 1 + self.phase = str(values["phase"]) - async def revalidate_terminal(self, **_values: object) -> None: + async def consume(self, **_values: object) -> None: self.terminals += 1 + def discard(self) -> None: + self.phase = None + class _RevokeTerminalArtifactAuthority(_AllowArtifactAuthority): """Test authority modelling any terminal actor/resource invalidation.""" @@ -134,12 +139,13 @@ def __init__( self.action_id = action_id self.resource_type = resource_type - async def revalidate_terminal(self, **values: object) -> None: + async def consume(self, **values: object) -> None: self.terminals += 1 assert values["service_identity"] == self.service_identity assert values["action_id"] == self.action_id assert values["facts"].resource_type == self.resource_type - raise ArtifactAuthorityDeniedError(self.reason) + if self.phase == "terminal": + raise ArtifactAuthorityDeniedError(self.reason) def _alembic_config() -> Config: @@ -760,8 +766,8 @@ async def test_committed_put_and_independent_verification_are_fenced( assert await _count(session, ArtifactVerificationReceipt) == 1 await session.rollback() assert await orchestrator.verify_object(job_id) == "stale" - assert authority.preflights == 3 - assert authority.terminals == 2 + assert authority.prepares == 5 + assert authority.terminals == 5 for operation in ("update", "delete"): statement = ( "update artifact_verification_receipts set execution_generation = " @@ -896,7 +902,7 @@ async def test_preacknowledgement_absence_releases_capacity_without_write( assert scopes and {scope.counted_bytes for scope in scopes} == {0} assert await _count(session, ArtifactPutObservationReceipt) == 1 assert await _count(session, ArtifactOperationReceipt) == 0 - assert authority.preflights == 1 + assert authority.prepares == 1 assert authority.terminals == 1 finally: bootstrap.close() @@ -931,8 +937,8 @@ async def test_put_paths_recheck_authorized_facts_before_provider_io( await session.commit() class DriftPutFactsAuthority(_AllowArtifactAuthority): - async def preflight(self, **_values: object) -> None: - await super().preflight(**_values) + async def prepare(self, **_values: object) -> None: + await super().prepare(**_values) async with factory() as drift_session, drift_session.begin(): drifted_attempt = await drift_session.get( ArtifactPutAttempt, str(attempt_id), with_for_update=True @@ -1967,7 +1973,7 @@ async def drift_job_after_claim(_provider_object_ref: str): await engine.dispose() -async def test_verification_rechecks_relationship_after_preflight_before_io( +async def test_verification_rechecks_relationship_after_prepare_before_io( admission_database_env: str, tmp_path: Path, ) -> None: @@ -2023,8 +2029,8 @@ async def test_verification_rechecks_relationship_after_preflight_before_io( await session.commit() class DriftBeforeClaimAuthority(_AllowArtifactAuthority): - async def preflight(self, **_values: object) -> None: - await super().preflight(**_values) + async def prepare(self, **_values: object) -> None: + await super().prepare(**_values) async with factory() as drift_session, drift_session.begin(): drifted_job = await drift_session.get( ArtifactVerificationJob, str(job_id), with_for_update=True @@ -2055,6 +2061,86 @@ async def preflight(self, **_values: object) -> None: await engine.dispose() +async def test_verification_relationship_conflict_uses_fresh_terminal_authority( + admission_database_env: str, + tmp_path: Path, +) -> None: + settings = _settings(tmp_path) + namespace = _namespace(settings) + engine = create_async_engine(admission_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + assert settings.artifact_local_root is not None + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=settings.artifact_local_root)) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + + class PhaseAuthority(_AllowArtifactAuthority): + def __init__(self) -> None: + super().__init__() + self.phases: list[str] = [] + + async def prepare(self, **values: object) -> None: + await super().prepare(**values) + self.phases.append(str(values["phase"])) + + try: + async with factory() as session: + attempts: list[ArtifactPutAttempt] = [] + for name, value in (("first", b"first"), ("second", b"second")): + async with minted_source(tmp_path / name, value) as source: + admission = await _admit_guide_source( + session, settings, namespace, _context(), source + ) + await ArtifactStorageOrchestrator( + session, store, namespace, settings, _AllowArtifactAuthority() + ).execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + attempt = await session.get( + ArtifactPutAttempt, str(admission.attempt_id) + ) + assert attempt is not None and attempt.replica_id is not None + attempts.append(attempt) + job = await session.scalar( + select(ArtifactVerificationJob).where( + ArtifactVerificationJob.originating_put_attempt_id == attempts[0].id + ) + ) + assert job is not None + job.replica_id = attempts[1].replica_id + await session.commit() + + authority = PhaseAuthority() + verifying = ArtifactStorageOrchestrator( + session, store, namespace, settings, authority + ) + verifying._read_complete = AsyncMock( + side_effect=AssertionError("relationship conflict must not read provider bytes") + ) + + assert await verifying.verify_object(UUID(job.id)) == "conflict" + verifying._read_complete.assert_not_awaited() + await session.refresh(job) + assert job.status == "conflict" + assert authority.phases == ["claim", "terminal"] + assert authority.terminals == 2 + receipt = await session.scalar( + select(ArtifactVerificationReceipt).where( + ArtifactVerificationReceipt.verification_job_id == job.id + ) + ) + assert receipt is not None and receipt.outcome == "conflict" + finally: + bootstrap.close() + await engine.dispose() + + async def test_verification_rechecks_authorized_object_ref_before_io( admission_database_env: str, tmp_path: Path, @@ -2090,8 +2176,8 @@ async def test_verification_rechecks_authorized_object_ref_before_io( await session.commit() class DriftObjectRefBeforeClaimAuthority(_AllowArtifactAuthority): - async def preflight(self, **_values: object) -> None: - await super().preflight(**_values) + async def prepare(self, **_values: object) -> None: + await super().prepare(**_values) async with factory() as drift_session, drift_session.begin(): drifted_replica = await drift_session.get( ArtifactReplica, replica_id, with_for_update=True diff --git a/backend/tests/test_artifact_authorization.py b/backend/tests/test_artifact_authorization.py index 92fec2fc..21b49596 100644 --- a/backend/tests/test_artifact_authorization.py +++ b/backend/tests/test_artifact_authorization.py @@ -75,8 +75,19 @@ def _context() -> HumanAuthorizationContext: ) -@pytest.mark.parametrize("action", OPERATOR_ACTIONS + INTERNAL_ACTIONS) -async def test_real_kernel_keeps_artifact_actions_unavailable(action: ActionId) -> None: +@pytest.mark.parametrize("action", OPERATOR_ACTIONS) +async def test_real_kernel_keeps_operator_artifact_actions_unavailable(action: ActionId) -> None: + service, _evidence = _runtime_service(_runtime_context()) + with pytest.raises(AuthorizationDenied) as caught: + await service.require( + action, + SystemResourceContext(resource_type="system", resource_id="workstream:system"), + ) + assert caught.value.decision.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE + + +@pytest.mark.parametrize("action", INTERNAL_ACTIONS) +async def test_humans_cannot_reach_active_internal_actions(action: ActionId) -> None: service, _evidence = _runtime_service(_runtime_context()) with pytest.raises(AuthorizationDenied) as caught: await service.require( diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py new file mode 100644 index 00000000..aecd71dc --- /dev/null +++ b/backend/tests/test_artifact_internal_authorization.py @@ -0,0 +1,1102 @@ +"""Focused proofs for the fixed-service ART prepared-authority adapter.""" + +from __future__ import annotations + +from dataclasses import replace +from types import SimpleNamespace +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import app.modules.artifacts.authorization as artifact_authorization +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.artifacts.authorization import PreparedArtifactInternalAuthority +from app.modules.artifacts.schemas import ( + ArtifactInternalAuthority, + ArtifactInternalResourceType, + ArtifactPutAttemptAuthorityFacts, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.runtime import ( + AuthorizationDenied, + MatchedAuthorityKind, + authorization_resource_digest, +) +from app.modules.tasks.models import AuditEvent +from app.adapters.artifacts.local import LocalStorageAdapter, LocalStorageBootstrap +from app.interfaces.artifacts import ArtifactStoreNamespaceClaim +from app.modules.artifacts.models import ( + ArtifactOperationReceipt, + ArtifactPutAttempt, + ArtifactReplica, + ArtifactVerificationJob, + ArtifactVerificationReceipt, +) +from app.modules.artifacts.service import ( + ArtifactAdmissionService, + ArtifactPendingWorkScanner, + ArtifactStorageOrchestrator, +) +from app.modules.artifacts.schemas import GuideArtifactAdmissionRequest +from tests.artifact_store_helpers import minted_source +from tests.test_artifact_admission import _context, _namespace, _seed_guide, _settings + + +class _Session: + """Minimal stable-root transaction used by PREP unit proofs.""" + + def __init__(self) -> None: + self.root = SimpleNamespace(is_active=True) + self.sync_session = self + self.commits = 0 + + def get_transaction(self): + return self.root + + def in_nested_transaction(self) -> bool: + return False + + async def commit(self) -> None: + self.commits += 1 + + +def _principal(status: str = "active"): + profile = SimpleNamespace( + id=str(uuid4()), + actor_kind="service", + status=status, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + ) + link = SimpleNamespace( + id=str(uuid4()), + actor_profile_id=profile.id, + subject_kind="service", + status="active", + ) + return profile, link + + +def _facts() -> ArtifactPutAttemptAuthorityFacts: + return ArtifactPutAttemptAuthorityFacts( + resource_type=ArtifactInternalResourceType.PUT_ATTEMPT, + resource_id=uuid4(), + operation_identity="sha256:" + "1" * 64, + namespace_fingerprint="sha256:" + "2" * 64, + sha256="sha256:" + "3" * 64, + byte_count=7, + executor_id=uuid4(), + execution_generation=1, + ) + + +class _FailAfterConsumeAuthority: + """Inject a failure after AUTH stages allow evidence in the ART transaction.""" + + def __init__(self, delegate: ArtifactInternalAuthority) -> None: + self._delegate = delegate + + async def prepare(self, **kwargs) -> None: + await self._delegate.prepare(**kwargs) + + async def consume(self, **kwargs): + await self._delegate.consume(**kwargs) + raise RuntimeError("injected failure after authorization consume") + + def discard(self) -> None: + self._delegate.discard() + + +class _RecordingAuthority: + def __init__(self, delegate: ArtifactInternalAuthority) -> None: + self._delegate = delegate + self.consumed = None + + async def prepare(self, **kwargs) -> None: + await self._delegate.prepare(**kwargs) + + async def consume(self, **kwargs): + self.consumed = kwargs["facts"] + return await self._delegate.consume(**kwargs) + + def discard(self) -> None: + self._delegate.discard() + + +def _service_principal( + service_identity: ServiceIdentity, + *, + status: str = "active", +) -> tuple[ActorProfile, ActorIdentityLink]: + profile_id, link_id = uuid4(), uuid4() + suspended = status == "suspended" + return ( + ActorProfile( + id=str(profile_id), + actor_kind="service", + status=status, + provisioning_method="manual_service_provisioning", + service_identity=service_identity.value, + created_by="test", + suspended_by="test" if suspended else None, + suspended_at=datetime.now(UTC) if suspended else None, + suspension_reason="test security hold" if suspended else None, + ), + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(profile_id), + issuer="https://issuer.example.test", + subject=service_identity.value, + subject_kind="service", + status="active", + linked_by="test", + ), + ) + + +def _install_principal(monkeypatch: pytest.MonkeyPatch, *, status: str = "active"): + profile, link = _principal(status) + + class Actors: + async def get_service_actor(self, service_identity: str): + assert service_identity == profile.service_identity + return profile + + async def get_identity_link_for_actor(self, actor_profile_id: str): + assert actor_profile_id == profile.id + return link + + class Admin: + def __init__(self, _session) -> None: + pass + + async def lock_request_actor(self, identity_link_id, actor_profile_id): + assert str(identity_link_id) == link.id + assert str(actor_profile_id) == profile.id + return link, profile + + monkeypatch.setattr(artifact_authorization, "ActorRepository", lambda _session: Actors()) + monkeypatch.setattr(artifact_authorization, "AdminAuthorizationRepository", Admin) + + +@pytest.mark.asyncio +async def test_adapter_consumes_exact_fixed_service_resource_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_principal(monkeypatch) + staged = [] + + async def stage(self, decision, _actor_profile_id): + staged.append(decision) + + monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) + session = _Session() + authority = PreparedArtifactInternalAuthority( + session, # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + facts = _facts() + + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=facts.executor_id, + ) + await authority.consume( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + ) + + assert len(staged) == 1 + assert staged[0].allowed is True + assert staged[0].matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE + assert staged[0].resource_id == facts.resource_id + + +@pytest.mark.asyncio +async def test_adapter_rejects_same_resource_fence_substitution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_principal(monkeypatch) + staged = [] + + async def stage(self, decision, _actor_profile_id): + staged.append(decision) + + monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) + authority = PreparedArtifactInternalAuthority( + _Session(), # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + facts = _facts() + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=facts.executor_id, + ) + + with pytest.raises(artifact_authorization.ArtifactAuthorityDeniedError): + await authority.consume( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=replace(facts, execution_generation=facts.execution_generation + 1), + ) + + authority.discard() + assert staged == [] + + +@pytest.mark.asyncio +async def test_adapter_restages_lifecycle_denial_only_after_caller_rollback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_principal(monkeypatch, status="suspended") + staged = [] + + async def stage(self, decision, _actor_profile_id): + staged.append(decision) + + monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) + session = _Session() + authority = PreparedArtifactInternalAuthority( + session, # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + facts = _facts() + + with pytest.raises(AuthorizationDenied) as denied: + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="terminal", + idempotency_key=facts.executor_id, + ) + assert staged == [denied.value.decision] + assert session.commits == 0 + + await authority.persist_denial() + + assert staged == [denied.value.decision, denied.value.decision] + assert session.commits == 1 + assert staged[-1].allowed is False + assert staged[-1].resource_id == facts.resource_id + + +@pytest.mark.asyncio +async def test_postgresql_fixed_service_allow_and_clean_denial_evidence( + isolated_database_env: str, +) -> None: + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + profile_id, link_id = uuid4(), uuid4() + try: + async with factory() as session: + session.add( + ActorProfile( + id=str(profile_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + created_by="test", + ) + ) + session.add( + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(profile_id), + issuer="https://issuer.example.test", + subject="artifact-put-resolver", + subject_kind="service", + status="active", + linked_by="test", + ) + ) + await session.commit() + facts = _facts() + allowed = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + async with session.begin(): + await allowed.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=facts.executor_id, + ) + await allowed.consume( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + ) + profile = await session.get(ActorProfile, str(profile_id)) + assert profile is not None + profile.status = "suspended" + profile.suspended_by = "test" + profile.suspended_at = datetime.now(UTC) + profile.suspension_reason = "test security hold" + await session.commit() + denied = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + with pytest.raises(AuthorizationDenied): + async with session.begin(): + await denied.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="terminal", + idempotency_key=facts.executor_id, + ) + await denied.persist_denial() + events = list( + await session.scalars( + select(AuditEvent) + .where( + AuditEvent.action_id + == ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value + ) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [True, False] + assert all("resource_context_digest" in event.after_facts for event in events) + assert events[-1].denial_code == "actor_suspended" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_scanner_failure_after_consume_rolls_back_evidence_and_publishes_nothing( + isolated_database_env: str, + tmp_path, +) -> None: + settings = _settings(tmp_path).model_copy( + update={"artifact_pending_work_scan_page_size": 1} + ) + namespace = _namespace(settings) + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + published: list[str] = [] + profile, link = _service_principal(ServiceIdentity.ARTIFACT_SCHEDULER) + try: + async with factory() as session: + session.add_all((profile, link)) + await session.commit() + actor_context = _context() + async with minted_source(tmp_path / "scan-rollback", b"pending") as source: + _, guide_item_id = await _seed_guide( + session, + context=actor_context, + content_hash=source.commitment.sha256, + media_type=source.commitment.media_type, + ) + admission = await ArtifactAdmissionService( + session, settings, namespace + ).admit( + GuideArtifactAdmissionRequest( + authorization_context=actor_context, + guide_source_item_id=UUID(guide_item_id), + source=source, + ) + ) + + request_id = uuid4() + real_authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + request_id=request_id, + correlation_id=request_id, + ) + + async def publish_put(attempt_id: str) -> None: + published.append(attempt_id) + + async def publish_job(_job_id: str) -> None: + raise AssertionError("no verification job should be published") + + failing_scanner = ArtifactPendingWorkScanner( + session, + settings, + _FailAfterConsumeAuthority(real_authority), + publish_put, + publish_job, + ) + with pytest.raises( + RuntimeError, match="injected failure after authorization consume" + ): + await failing_scanner.scan() + + assert published == [] + assert await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id == ActionId.ARTIFACT_PENDING_WORK_SCAN.value + ) + ) is None + await session.rollback() + + retry_request_id = uuid4() + recording = _RecordingAuthority( + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + request_id=retry_request_id, + correlation_id=retry_request_id, + ) + ) + retry_scanner = ArtifactPendingWorkScanner( + session, + settings, + recording, + publish_put, + publish_job, + ) + assert await retry_scanner.scan() == 1 + assert published == [str(admission.attempt_id)] + assert recording.consumed is not None + assert recording.consumed.scanner_kind == "put_resolution_and_verification" + assert recording.consumed.page_size == 1 + assert recording.consumed.put_attempt_ids == (admission.attempt_id,) + assert recording.consumed.verification_job_ids == () + assert ( + datetime.fromisoformat(recording.consumed.database_cutoff_iso).tzinfo + is not None + ) + assert published == [str(value) for value in recording.consumed.put_attempt_ids] + event = await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id == ActionId.ARTIFACT_PENDING_WORK_SCAN.value + ) + ) + assert event is not None and event.after_facts["allowed"] is True + assert event.after_facts["resource_context_digest"] == ( + authorization_resource_digest( + artifact_authorization._resource_context(recording.consumed) + ) + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_put_claim_and_terminal_injected_failures_roll_back_both_sides( + isolated_database_env: str, + tmp_path, +) -> None: + settings = _settings(tmp_path) + namespace = _namespace(settings) + actor_context = _context() + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + profile, link = _service_principal(ServiceIdentity.ARTIFACT_PUT_RESOLVER) + assert settings.artifact_local_root is not None + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=settings.artifact_local_root)) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + try: + async with factory() as session: + session.add_all((profile, link)) + await session.commit() + async with minted_source(tmp_path / "atomic-put", b"atomic") as source: + _, guide_item_id = await _seed_guide( + session, + context=actor_context, + content_hash=source.commitment.sha256, + media_type=source.commitment.media_type, + ) + admission = await ArtifactAdmissionService( + session, settings, namespace + ).admit( + GuideArtifactAdmissionRequest( + authorization_context=actor_context, + guide_source_item_id=UUID(guide_item_id), + source=source, + ) + ) + request_id = uuid4() + authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=request_id, + correlation_id=request_id, + ) + orchestrator = ArtifactStorageOrchestrator( + session, store, namespace, settings, authority + ) + original_claim = orchestrator._repo.claim_put_attempt + + async def fail_after_claim(**kwargs): + await original_claim(**kwargs) + raise RuntimeError("injected failure after ART claim mutation") + + orchestrator._repo.claim_put_attempt = fail_after_claim # type: ignore[method-assign] + with pytest.raises( + RuntimeError, match="injected failure after ART claim mutation" + ): + await orchestrator.execute_committed_put( + attempt_id=admission.attempt_id, source=source + ) + attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) + assert attempt is not None and attempt.status == "prepared" + assert attempt.executor_id is None and attempt.execution_generation == 0 + assert await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id + == ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value + ) + ) is None + await session.rollback() + + retry_request_id = uuid4() + retry_authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=retry_request_id, + correlation_id=retry_request_id, + ) + retry = ArtifactStorageOrchestrator( + session, store, namespace, settings, retry_authority + ) + original_receipt = retry._repo.add_receipt + + async def fail_after_terminal_mutation(receipt): + await original_receipt(receipt) + raise RuntimeError("injected failure after ART terminal mutation") + + retry._repo.add_receipt = fail_after_terminal_mutation # type: ignore[method-assign] + with pytest.raises( + RuntimeError, match="injected failure after ART terminal mutation" + ): + await retry.execute_committed_put( + attempt_id=admission.attempt_id, source=source + ) + attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) + assert attempt is not None and attempt.status == "put_in_flight" + assert attempt.executor_id is not None and attempt.execution_generation == 1 + assert await session.scalar(select(ArtifactOperationReceipt)) is None + assert await session.scalar(select(ArtifactReplica)) is None + events = list( + await session.scalars( + select(AuditEvent).where( + AuditEvent.action_id + == ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value + ) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [True] + assert "resource_context_digest" in events[0].after_facts + await session.rollback() + + await session.execute( + text( + "update artifact_put_attempts set lease_expires_at = " + "clock_timestamp() - interval '1 second' where id = :id" + ), + {"id": str(admission.attempt_id)}, + ) + await session.commit() + final_request_id = uuid4() + final = ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=final_request_id, + correlation_id=final_request_id, + ), + ) + assert await final.resolve_put_attempt(admission.attempt_id) == "observed_confirmed" + attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) + assert attempt is not None and attempt.status == "object_confirmed" + assert await session.scalar(select(ArtifactReplica)) is not None + events = list( + await session.scalars( + select(AuditEvent) + .where( + AuditEvent.action_id + == ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value + ) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [ + True, + True, + True, + ] + assert all( + "resource_context_digest" in event.after_facts for event in events + ) + finally: + bootstrap.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_claim_and_scanner_denials_persist_without_art_side_effects( + isolated_database_env: str, + tmp_path, +) -> None: + settings = _settings(tmp_path).model_copy( + update={"artifact_pending_work_scan_page_size": 1} + ) + namespace = _namespace(settings) + actor_context = _context() + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + resolver = _service_principal( + ServiceIdentity.ARTIFACT_PUT_RESOLVER, status="suspended" + ) + scheduler = _service_principal(ServiceIdentity.ARTIFACT_SCHEDULER, status="suspended") + published: list[str] = [] + assert settings.artifact_local_root is not None + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=settings.artifact_local_root)) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + try: + async with factory() as session: + session.add_all((*resolver, *scheduler)) + await session.commit() + async with minted_source(tmp_path / "denied-claim", b"denied") as source: + _, guide_item_id = await _seed_guide( + session, + context=actor_context, + content_hash=source.commitment.sha256, + media_type=source.commitment.media_type, + ) + admission = await ArtifactAdmissionService( + session, settings, namespace + ).admit( + GuideArtifactAdmissionRequest( + authorization_context=actor_context, + guide_source_item_id=UUID(guide_item_id), + source=source, + ) + ) + claim_request_id = uuid4() + claim_authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=claim_request_id, + correlation_id=claim_request_id, + ) + with pytest.raises(AuthorizationDenied): + await ArtifactStorageOrchestrator( + session, store, namespace, settings, claim_authority + ).execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + await session.rollback() + await claim_authority.persist_denial() + + attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) + assert attempt is not None and attempt.status == "prepared" + assert attempt.executor_id is None and attempt.execution_generation == 0 + assert await session.scalar(select(ArtifactOperationReceipt)) is None + assert await session.scalar(select(ArtifactReplica)) is None + await session.rollback() + + async def publish_put(attempt_id: str) -> None: + published.append(attempt_id) + + async def publish_job(job_id: str) -> None: + published.append(job_id) + + scan_request_id = uuid4() + scan_authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + request_id=scan_request_id, + correlation_id=scan_request_id, + ) + with pytest.raises(AuthorizationDenied): + await ArtifactPendingWorkScanner( + session, + settings, + scan_authority, + publish_put, + publish_job, + ).scan() + await session.rollback() + await scan_authority.persist_denial() + + assert published == [] + events = list( + await session.scalars( + select(AuditEvent) + .where( + AuditEvent.action_id.in_( + ( + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value, + ActionId.ARTIFACT_PENDING_WORK_SCAN.value, + ) + ) + ) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [False, False] + assert all("resource_context_digest" in event.after_facts for event in events) + assert {event.denial_code for event in events} == {"actor_suspended"} + finally: + bootstrap.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_verification_claim_and_terminal_failures_roll_back_both_sides( + isolated_database_env: str, + tmp_path, +) -> None: + settings = _settings(tmp_path) + namespace = _namespace(settings) + actor_context = _context() + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + resolver = _service_principal(ServiceIdentity.ARTIFACT_PUT_RESOLVER) + verifier = _service_principal(ServiceIdentity.ARTIFACT_VERIFIER) + assert settings.artifact_local_root is not None + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=settings.artifact_local_root)) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + try: + async with factory() as session: + session.add_all((*resolver, *verifier)) + await session.commit() + async with minted_source(tmp_path / "atomic-verify", b"verified") as source: + _, guide_item_id = await _seed_guide( + session, + context=actor_context, + content_hash=source.commitment.sha256, + media_type=source.commitment.media_type, + ) + admission = await ArtifactAdmissionService( + session, settings, namespace + ).admit( + GuideArtifactAdmissionRequest( + authorization_context=actor_context, + guide_source_item_id=UUID(guide_item_id), + source=source, + ) + ) + put_request_id = uuid4() + assert ( + await ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=put_request_id, + correlation_id=put_request_id, + ), + ).execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + == "stored_pending_verification" + ) + + job = await session.scalar(select(ArtifactVerificationJob)) + assert job is not None + job_id = UUID(job.id) + await session.rollback() + claim_request_id = uuid4() + claim = ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + request_id=claim_request_id, + correlation_id=claim_request_id, + ), + ) + original_claim = claim._repo.claim_verification_job + + async def fail_after_claim(**kwargs): + await original_claim(**kwargs) + raise RuntimeError("injected failure after verification claim mutation") + + claim._repo.claim_verification_job = fail_after_claim # type: ignore[method-assign] + with pytest.raises( + RuntimeError, match="injected failure after verification claim mutation" + ): + await claim.verify_object(job_id) + await session.refresh(job) + assert job.status == "pending" + assert job.executor_id is None and job.execution_generation == 0 + assert await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id == ActionId.ARTIFACT_VERIFICATION_EXECUTE.value + ) + ) is None + await session.rollback() + + terminal_request_id = uuid4() + terminal = ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + request_id=terminal_request_id, + correlation_id=terminal_request_id, + ), + ) + original_receipt = terminal._repo.add_verification_receipt + + async def fail_after_terminal(receipt): + await original_receipt(receipt) + raise RuntimeError("injected failure after verification terminal mutation") + + terminal._repo.add_verification_receipt = fail_after_terminal # type: ignore[method-assign] + with pytest.raises( + RuntimeError, match="injected failure after verification terminal mutation" + ): + await terminal.verify_object(job_id) + await session.refresh(job) + assert job.status == "running" + assert job.executor_id is not None and job.execution_generation == 1 + assert await session.scalar( + select(ArtifactVerificationReceipt) + ) is None + events = list( + await session.scalars( + select(AuditEvent).where( + AuditEvent.action_id + == ActionId.ARTIFACT_VERIFICATION_EXECUTE.value + ) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [True] + await session.rollback() + + await session.execute( + text( + "update artifact_verification_jobs set lease_expires_at = " + "clock_timestamp() - interval '1 second' where id = :id" + ), + {"id": str(job_id)}, + ) + await session.commit() + final_request_id = uuid4() + assert ( + await ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + request_id=final_request_id, + correlation_id=final_request_id, + ), + ).verify_object(job_id) + == "verified" + ) + await session.refresh(job) + assert job.status == "verified" + assert await session.scalar( + select(ArtifactVerificationReceipt) + ) is not None + events = list( + await session.scalars( + select(AuditEvent) + .where( + AuditEvent.action_id + == ActionId.ARTIFACT_VERIFICATION_EXECUTE.value + ) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [ + True, + True, + True, + ] + assert all("resource_context_digest" in event.after_facts for event in events) + finally: + bootstrap.close() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_post_provider_revocation_commits_denial_but_no_terminal_artifact_facts( + isolated_database_env: str, + tmp_path, +) -> None: + settings = _settings(tmp_path) + namespace = _namespace(settings) + actor_context = _context() + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + profile_id, link_id = uuid4(), uuid4() + assert settings.artifact_local_root is not None + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=settings.artifact_local_root)) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + + class SuspendAfterPut: + identity = store.identity + + async def put(self, source): + result = await store.put(source) + async with factory() as lifecycle_session: + profile = await lifecycle_session.get(ActorProfile, str(profile_id)) + assert profile is not None + profile.status = "suspended" + profile.suspended_by = "test" + profile.suspended_at = datetime.now(UTC) + profile.suspension_reason = "post-provider security hold" + await lifecycle_session.commit() + return result + + async def observe_put_result(self, commitment): + return await store.observe_put_result(commitment) + + def open(self, provider_object_ref, byte_range=None): + return store.open(provider_object_ref, byte_range) + + async def head(self, provider_object_ref): + return await store.head(provider_object_ref) + + try: + async with factory() as session: + session.add( + ActorProfile( + id=str(profile_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + created_by="test", + ) + ) + session.add( + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(profile_id), + issuer="https://issuer.example.test", + subject="artifact-put-resolver", + subject_kind="service", + status="active", + linked_by="test", + ) + ) + await session.commit() + async with minted_source(tmp_path / "post-provider-denial", b"protected") as source: + _, guide_item_id = await _seed_guide( + session, + context=actor_context, + content_hash=source.commitment.sha256, + media_type=source.commitment.media_type, + ) + admission = await ArtifactAdmissionService( + session, settings, namespace + ).admit( + GuideArtifactAdmissionRequest( + authorization_context=actor_context, + guide_source_item_id=UUID(guide_item_id), + source=source, + ) + ) + request_id = uuid4() + authority = PreparedArtifactInternalAuthority( + session, + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=request_id, + correlation_id=request_id, + ) + orchestrator = ArtifactStorageOrchestrator( + session, + SuspendAfterPut(), + namespace, + settings, + authority, + ) + with pytest.raises(AuthorizationDenied): + await orchestrator.execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + await session.rollback() + await authority.persist_denial() + attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) + assert attempt is not None + assert attempt.status == "put_in_flight" + assert attempt.executor_id is not None + assert await session.scalar(select(ArtifactOperationReceipt)) is None + assert await session.scalar(select(ArtifactReplica)) is None + events = list( + await session.scalars( + select(AuditEvent) + .where( + AuditEvent.action_id + == ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE.value + ) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ) + assert [event.after_facts["allowed"] for event in events] == [True, False] + assert all( + "resource_context_digest" in event.after_facts for event in events + ) + assert events[-1].denial_code == "actor_suspended" + finally: + bootstrap.close() + await engine.dispose() diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 9cad338a..7d99f5cf 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -66,13 +66,15 @@ class _AllowArtifactAuthority: - async def preflight(self, **_values: object) -> None: ... + async def prepare(self, **_values: object) -> None: ... - async def revalidate_terminal(self, **_values: object) -> None: ... + async def consume(self, **_values: object) -> None: ... + + def discard(self) -> None: ... class _DenyTerminalArtifactAuthority(_AllowArtifactAuthority): - async def revalidate_terminal(self, **_values: object) -> None: + async def consume(self, **_values: object) -> None: raise ArtifactAuthorityDeniedError("terminal authority changed") diff --git a/backend/tests/test_artifact_verification.py b/backend/tests/test_artifact_verification.py index 930be463..c227f015 100644 --- a/backend/tests/test_artifact_verification.py +++ b/backend/tests/test_artifact_verification.py @@ -1,13 +1,14 @@ -"""Focused proof for inactive verification, authority, and deadline contracts.""" +"""Focused proof for activated verification, authority, and deadline contracts.""" from __future__ import annotations import asyncio import importlib from datetime import UTC, datetime -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest +from unittest.mock import AsyncMock, Mock import app.interfaces.artifact_operations # noqa: F401 - cumulative contract coverage from app.core.config import Settings, get_settings @@ -24,7 +25,7 @@ @pytest.mark.asyncio -async def test_production_authority_denies_preflight_and_terminal() -> None: +async def test_production_authority_denies_prepare_and_consume() -> None: authority = DenyArtifactInternalAuthority() facts = ArtifactPutAttemptAuthorityFacts( resource_type=ArtifactInternalResourceType.PUT_ATTEMPT, @@ -36,16 +37,23 @@ async def test_production_authority_denies_preflight_and_terminal() -> None: executor_id=uuid4(), execution_generation=1, ) - for operation in (authority.preflight, authority.revalidate_terminal): - with pytest.raises(ArtifactAuthorityDeniedError): - await operation( + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=facts, + phase="claim", + idempotency_key=uuid4(), + ) + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.consume( service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, facts=facts, ) -def test_hidden_celery_tasks_are_registered_but_fail_closed(monkeypatch) -> None: +def test_internal_celery_tasks_and_pending_scan_are_registered_once(monkeypatch) -> None: monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") get_settings.cache_clear() celery_module = importlib.import_module("app.workers.celery_app") @@ -53,22 +61,42 @@ def test_hidden_celery_tasks_are_registered_but_fail_closed(monkeypatch) -> None celery_app = celery_module.celery_app assert "workstream.artifacts.resolve_put_attempt" in celery_app.tasks assert "workstream.artifacts.verify_object" in celery_app.tasks - assert not any( - entry["task"] - in { - "workstream.artifacts.resolve_put_attempt", - "workstream.artifacts.verify_object", - } - for entry in celery_app.conf.beat_schedule.values() - ) - with pytest.raises(ArtifactAuthorityDeniedError): - worker_module.resolve_put_attempt(str(uuid4())) - with pytest.raises(ArtifactAuthorityDeniedError): - worker_module.verify_object(str(uuid4())) + assert "workstream.artifacts.scan_pending_work" in celery_app.tasks + scheduled_tasks = [ + entry["task"] for entry in celery_app.conf.beat_schedule.values() + ] + assert scheduled_tasks.count("workstream.artifacts.scan_pending_work") == 1 + assert scheduled_tasks.count("workstream.artifacts.cleanup_stale_scratch") == 1 + operation = AsyncMock(return_value=None) + scanned: list[str] = [] + + async def scan(publish_put, publish_job): + await publish_put("put-id") + await publish_job("job-id") + scanned.append("called") + return 2 + + monkeypatch.setattr(worker_module, "run_artifact_internal_operation", operation) + monkeypatch.setattr(worker_module, "scan_artifact_pending_work", scan) + put_delay = Mock() + job_delay = Mock() + monkeypatch.setattr(worker_module.resolve_put_attempt, "delay", put_delay) + monkeypatch.setattr(worker_module.verify_object, "delay", job_delay) + attempt_id = str(uuid4()) + job_id = str(uuid4()) + worker_module.resolve_put_attempt(attempt_id) + worker_module.verify_object(job_id) + assert operation.await_count == 2 + assert operation.await_args_list[0].args == ("put", UUID(attempt_id)) + assert operation.await_args_list[1].args == ("verification", UUID(job_id)) + assert worker_module.scan_pending_work() == 2 + assert scanned == ["called"] + put_delay.assert_called_once_with("put-id") + job_delay.assert_called_once_with("job-id") get_settings.cache_clear() -def test_internal_artifact_actions_remain_planned() -> None: +def test_internal_artifact_actions_are_active() -> None: assert { action_id: ACTION_BY_ID[action_id].availability for action_id in { @@ -77,9 +105,9 @@ def test_internal_artifact_actions_remain_planned() -> None: ActionId.ARTIFACT_PENDING_WORK_SCAN, } } == { - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ActionAvailability.PLANNED, - ActionId.ARTIFACT_VERIFICATION_EXECUTE: ActionAvailability.PLANNED, - ActionId.ARTIFACT_PENDING_WORK_SCAN: ActionAvailability.PLANNED, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ActionAvailability.ACTIVE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ActionAvailability.ACTIVE, + ActionId.ARTIFACT_PENDING_WORK_SCAN: ActionAvailability.ACTIVE, } diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 69135006..02dce2ac 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -182,7 +182,24 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, } + artifact_allowed = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + after_facts={"allowed": True, "resource_context_digest": "sha256:" + "a" * 64}, + ) + assert artifact_allowed.after_facts["resource_context_digest"] == "sha256:" + "a" * 64 + with pytest.raises(TypeError, match="invalid authority audit input"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + action_id=ActionId.ARTIFACT_VERIFICATION_EXECUTE, + after_facts={"allowed": True, "resource_context_digest": "not-a-digest"}, + ) with pytest.raises(TypeError, match="invalid authority audit input"): _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d2ef015e..0b6dfbe9 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -169,6 +169,7 @@ ActorProfileLifecycleResourceContext, ActorSelfResourceContext, ActorStatus, + ArtifactVerificationJobResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -1486,17 +1487,17 @@ async def consume_rate() -> None: "artifact.verification.execute": ( "artifact.verification.execute", "WS-AUTH-001-ART-02D-INTERNAL", - "planned", + "active", ), "artifact.pending_work.scan": ( "artifact.pending_work.scan", "WS-AUTH-001-ART-02D-INTERNAL", - "planned", + "active", ), "artifact.put_attempt.resolve": ( "artifact.put_attempt.resolve", "WS-AUTH-001-ART-02D-INTERNAL", - "planned", + "active", ), "artifact.guide_source.ingest": ( "artifact.guide_source.ingest", @@ -1792,6 +1793,9 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, } assert { definition.action_id.value: ( @@ -1873,14 +1877,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 22 + == 25 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 56 + == 53 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -1938,7 +1942,7 @@ def test_obsolete_artifact_upload_authority_is_historical_only() -> None: assert found == allowed -def test_fixed_service_action_matrix_is_exact_planned_and_immutable() -> None: +def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> None: expected = { ServiceIdentity.ARTIFACT_VERIFIER: {"artifact.verification.execute"}, ServiceIdentity.ARTIFACT_PUT_RESOLVER: {"artifact.put_attempt.resolve"}, @@ -1965,10 +1969,22 @@ def test_fixed_service_action_matrix_is_exact_planned_and_immutable() -> None: for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 12 + active_internal = { + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + } + assert { + action + for actions in SERVICE_ACTIONS_BY_IDENTITY.values() + for action in actions + if ACTION_BY_ID[action].availability is ActionAvailability.ACTIVE + } == active_internal assert all( ACTION_BY_ID[action].availability is ActionAvailability.PLANNED for actions in SERVICE_ACTIONS_BY_IDENTITY.values() for action in actions + if action not in active_internal ) with pytest.raises(TypeError): SERVICE_ACTIONS_BY_IDENTITY[ServiceIdentity.ARTIFACT_VERIFIER] = frozenset() # type: ignore[index] @@ -2060,7 +2076,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 78 ActionIds, 22 active actions, and\n56 planned actions" in operations + "71 PermissionIds, 78 ActionIds, 25 active actions, and\n53 planned actions" in operations ) @@ -2158,7 +2174,7 @@ def test_fixed_service_action_matrix_rejects_metadata_drift( elif metadata == "owner": changed = replace(definition, owner=ActionOwner.AUTH_ART_03) else: - changed = replace(definition, availability=ActionAvailability.ACTIVE) + changed = replace(definition, availability=ActionAvailability.PLANNED) action_index = dict(ACTION_BY_ID) action_index[action] = changed monkeypatch.setattr(authorization_catalogue, "ACTION_BY_ID", action_index) @@ -3585,7 +3601,7 @@ async def wait_during_evaluation(*_args): @pytest.mark.asyncio -async def test_prepared_fixed_service_refreshes_rows_before_planned_denial(): +async def test_prepared_fixed_service_rejects_generic_scope_before_actor_lock(): context = _runtime_context(actor_kind=ActorKind.SERVICE) assert isinstance(context, ServiceAuthorizationContext) @@ -3624,8 +3640,8 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), ) - assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE - assert facts.calls == 1 + assert exc_info.value.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + assert facts.calls == 0 assert evidence.events == [] @@ -3696,7 +3712,7 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), ) assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE - assert facts.calls == (1 if service_identity is not None else 0) + assert facts.calls == 0 assert prepared._issued == {} assert evidence.events == [] @@ -6753,7 +6769,7 @@ def in_transaction(self): id=str(actor_id), actor_kind="service", status="active", - service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER.value, ), identity_link=SimpleNamespace(id=str(link_id), status="active"), ) @@ -6876,7 +6892,7 @@ def test_authorization_runtime_contracts_are_strict_and_two_argument() -> None: for name, member in inspect.getmembers(AuthorizationService, inspect.isfunction) if not name.startswith("_") } - assert public_methods == {"require"} + assert public_methods == {"require", "restage_denial"} assert tuple(inspect.signature(AuthorizationService.require).parameters) == ( "self", "action_id", @@ -7002,11 +7018,13 @@ async def test_fixed_service_kernel_selects_exact_action_before_availability() - for action in set().union(*SERVICE_ACTIONS_BY_IDENTITY.values()): with pytest.raises(AuthorizationDenied) as exc_info: await service.require(action, resource) - expected = ( - AuthorizationDenialCode.ACTION_UNAVAILABLE - if action in own_actions - else AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + expected = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if action in own_actions: + expected = ( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if ACTION_BY_ID[action].availability is ActionAvailability.ACTIVE + else AuthorizationDenialCode.ACTION_UNAVAILABLE + ) assert exc_info.value.decision.denial_code is expected @@ -7059,6 +7077,36 @@ async def revalidate(current: ServiceAuthorizationContext, action: ActionId): assert calls == [(ServiceIdentity.ARTIFACT_VERIFIER, action)] +async def test_fixed_service_active_artifact_action_requires_prepared_consumption() -> None: + context = _runtime_context(actor_kind=ActorKind.SERVICE) + + async def revalidate(current: ServiceAuthorizationContext, _action: ActionId): + return current + + service, evidence = _runtime_service(context, revalidate_service=revalidate) + resource = ArtifactVerificationJobResourceContext( + resource_type="artifact_verification_job", + resource_id=uuid4(), + replica_id=uuid4(), + namespace_fingerprint="sha256:" + "1" * 64, + provider_object_ref="provider/object", + sha256="sha256:" + "2" * 64, + byte_count=1, + executor_id=uuid4(), + execution_generation=1, + ) + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.ARTIFACT_VERIFICATION_EXECUTE, resource) + + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + assert exc_info.value.decision.allowed is False + assert evidence.events[-1].after_facts["allowed"] is False + assert evidence.events[-1].after_facts["resource_context_digest"] == ( + authorization_resource_digest(resource) + ) + + @pytest.mark.parametrize( ("actor_status", "link_status", "expected"), [ @@ -7274,7 +7322,14 @@ async def test_authorization_kernel_denies_active_action_without_implemented_aut @pytest.mark.parametrize( ("action_id", "expected_metadata"), - {**ART_CUSTODY_EXPECTATIONS, **REV_CUSTODY_EXPECTATIONS}.items(), + { + action_id: metadata + for action_id, metadata in { + **ART_CUSTODY_EXPECTATIONS, + **REV_CUSTODY_EXPECTATIONS, + }.items() + if metadata[2] == "planned" + }.items(), ) async def test_custody_actions_remain_unavailable_without_runtime_dispatch( action_id: str, @@ -7340,7 +7395,7 @@ async def test_denial_is_restaged_with_the_same_bounded_identity() -> None: await service.require(ActionId.REVIEW_QUEUE_READ, resource) original = evidence.events.pop() - await service._restage_denial(exc_info.value.decision) + await service.restage_denial(exc_info.value.decision) assert len(evidence.events) == 1 assert evidence.events[0].event_id == original.event_id @@ -7355,11 +7410,11 @@ async def test_denial_is_restaged_with_the_same_bounded_identity() -> None: ) allowed = await allowed_service.require(ActionId.ACTOR_PROFILE_READ_SELF, allowed_resource) with pytest.raises(TypeError, match="invalid authorization denial evidence"): - await allowed_service._restage_denial(allowed) + await allowed_service.restage_denial(allowed) other_service, _ = _runtime_service(context) with pytest.raises(TypeError, match="invalid authorization denial evidence"): - await other_service._restage_denial(exc_info.value.decision) + await other_service.restage_denial(exc_info.value.decision) @pytest.mark.parametrize( diff --git a/docs/operations_artifact_storage.md b/docs/operations_artifact_storage.md index b5e729b9..401f14c9 100644 --- a/docs/operations_artifact_storage.md +++ b/docs/operations_artifact_storage.md @@ -9,9 +9,10 @@ responses. The routes are composed under `/api/v1/operator/artifacts`, but production authority remains deny-only while the corresponding Authorization Service -actions are planned. A later AUTH-owned activation chunk must connect the -typed ART resource facts to the exact action decisions. Do not add role checks, -fallback constructors, or artifact-owned grant evaluation. +Operator actions are planned. WS-XINT-002-03 activates only the fixed verifier, +pending-work scanner, and put resolver through transaction-bound prepared +authority. It grants no Operator route. Do not add role checks, fallback +constructors, or artifact-owned grant evaluation. AWS S3 readiness is configuration-only and remains `inactive_live_proof_required`. This check does not instantiate an adapter or diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 8bf7d3b3..434a8eca 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -679,8 +679,9 @@ actions, `actor.service.provision`, `actor.profile.read`, identity-link lifecycle actions are active. The remaining five active actions are the AUTH-10B reads `project.contributor_candidate.list`, `project_role_grant.list`, and `project_role_grant.read`, plus the AUTH-10C -mutations `project_role_grant.issue` and `project_role_grant.revoke`; the other -56 entries remain planned and non-executable. The target post-custody +mutations `project_role_grant.issue` and `project_role_grant.revoke`. WS-XINT-002-03 +also activates only the fixed verifier, pending-work scanner, and put resolver; +the other 53 entries remain planned and non-executable. The target post-custody invariant is that planned runtime entries contain only action, permission, exact AUTH activation owner, and availability. The availability-neutral custody reconciliation assigns all 22 ART rows to nine exact activation custodians and all 19 REV @@ -707,9 +708,12 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 78 ActionIds, 22 active actions, and -56 planned actions after WS-XINT-002-01 replaces six obsolete upload actions -with three planned bundle/review actions without activating a route. +Catalogue totals are 71 PermissionIds, 78 ActionIds, 25 active actions, and +53 planned actions after WS-XINT-002-03 activates the three internal ART service +actions. The other 19 ART actions remain planned, including every Operator +artifact action. +Migration `0037` keeps each allowed or denied internal ART decision bound to +the exact privacy-bounded resource-context digest in append-only audit facts. AUTH-11A adds read-only `project.setup_diagnostic.read` and `project.effective_policy.read`. Project Manager and Audit Authority receive @@ -776,17 +780,24 @@ unchanged. The handle remains consumed after every exact attempt, including a rolled-back or cancelled attempt, and dependency teardown invalidates all outstanding handles. -Do not restage a prepared denial after rollback. Its staged decision belongs to -the failed caller transaction and rolls back with participant state. Planned -fixed-service preparation returns bounded `action_unavailable` without evidence -because it issues no handle and has no final resource context. +General human and administrative PREP callers do not restage a denial after +rollback; its staged decision belongs to the failed caller transaction and +rolls back with participant state. The three active internal ART compositions +are the deliberate exception: their adapter retains the exact denial, the +composition root first rolls back ART state, and AUTH's public bounded restage +operation commits the same denial in a clean AUTH-only transaction. Still- +planned fixed-service preparation returns bounded `action_unavailable` without +evidence because it issues no handle and has no final resource context. Operationally, actor-self preparation locks profile then exact link. An administrative preparation locks `AuthorityControl(id=1)`, request profile, exact request link, and deterministic matched AdminRoleGrant before participant -locks. Do not add a feature lock ahead of that order. Current fixed-service -actions remain planned and can produce no handle. ProjectRoleGrant preparation -is unsupported until AUTH-10 supplies and proves its canonical lock path. +locks. Do not add a feature lock ahead of that order. WS-XINT-002-03 is the +first active ART fixed-service PREP consumer: the verifier, pending-work +scanner, and put-attempt resolver can receive only their exact typed handles. +Still-planned fixed-service actions produce no handle. ProjectRoleGrant +preparation is unsupported until AUTH-10 supplies and proves its canonical lock +path. Downgrade is allowed only while every action ID remains null and no permission outside migration `0018`'s historical 49-value set exists in the decision, diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index e83dccca..f1cf1f5f 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -765,13 +765,12 @@ in the v2 clean cut. No compatibility adapter or dual format remains. No provider call occurs inside a PostgreSQL transaction. No product binding is created before independent verification. -Chunk 02C1 implements step 3 only. Chunk 02C2 implements hidden steps 4 through +Chunk 02C1 implements step 3 only. Chunk 02C2 implemented hidden steps 4 through 9 with caller-only committed-source writes, read-only put resolution, durable -verification jobs, typed receipts, and PostgreSQL fencing. Its production -authority implementation remains deny-only, its scanner has no Beat schedule, -and direct Celery invocation fails before claim, provider I/O, publication, or -mutation while the three internal actions remain planned. Recovery, Operator -routes, product cutover, and AUTH activation remain in later owning chunks. +verification jobs, typed receipts, and PostgreSQL fencing. WS-XINT-002-03 now +connects those paths to transaction-bound fixed-service authorization and one +bounded pending-work Beat scan. Operator routes and product cutover remain +independently gated. Acknowledgement loss leaves the durable put attempt and charges provisional. The attempt scanner publishes resolution after an ambiguous outcome or expired @@ -833,7 +832,7 @@ The preparation settings use the standard `WORKSTREAM_` environment prefix: | `WORKSTREAM_ARTIFACT_PREPARATION_TOTAL_DEADLINE_SECONDS` | `1800` | Total first-pass and provider-consumption deadline. | | `WORKSTREAM_ARTIFACT_SCRATCH_CLEANUP_MARGIN_SECONDS` | `300` | Required margin between the total deadline and reservation TTL. | | `WORKSTREAM_ARTIFACT_SCRATCH_CLEANUP_INTERVAL_SECONDS` | `300` | Celery Beat cadence for the named stale-scratch cleanup task; accepted range is 1 through 86400 seconds. | -| `WORKSTREAM_ARTIFACT_PENDING_WORK_SCAN_INTERVAL_SECONDS` | `60` | Retry/publication SLA used by hidden due-work mechanics; no 02C2 Beat schedule is active. | +| `WORKSTREAM_ARTIFACT_PENDING_WORK_SCAN_INTERVAL_SECONDS` | `60` | Celery Beat cadence for one authority-bound, database-cutoff pending-work page. | | `WORKSTREAM_ARTIFACT_PENDING_WORK_SCAN_PAGE_SIZE` | `100` | Hard combined put-attempt and verification-job publication page bound. | | `WORKSTREAM_ARTIFACT_EXECUTION_LEASE_SECONDS` | `900` | PostgreSQL-clock executor lease with no heartbeat. | | `WORKSTREAM_ARTIFACT_COMPLETE_READ_DEADLINE_SECONDS` | `600` | Total deadline covering provider-open acquisition and the complete stream. | diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 99d8a37d..f4ee5093 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -237,8 +237,8 @@ permissions are the exact 22 post-`0020` permissions. AUTH-07A, AUTH-11A, and WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. -The closed action registry contains 78 rows after WS-XINT-002-01: 22 active -actions and 56 planned rows. AUTH-10A added five project-role read/manage rows; +The closed action registry contains 78 rows after WS-XINT-002-03: 25 active +actions and 53 planned rows. AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven planned project identity, context, setup, policy, and active-guide reads owned @@ -250,7 +250,7 @@ provisioning actions without activating a route; AUTH-09B activates only `actor.identity_link.read`, AUTH-09D-A activates the three profile lifecycle actions, and AUTH-09D-B activates the two identity-link lifecycle actions. The other planned rows cover -three Operator recovery actions, 22 artifact actions, canonical +three Operator recovery actions, 19 artifact actions, canonical `submission.create`, and 19 review actions. An action becomes active only when its feature owner has merged the canonical resource composer, guards, surface or command declaration, behavior tests, and transaction-local revalidation where @@ -386,10 +386,11 @@ dynamically, or changes availability. The following table is the single source of truth for artifact ActionId-to- PermissionId mappings, principal/resource facts, and ART hidden-behavior ownership. AUTH-07A registered each row's stable `ActionId`, approved -`PermissionId`, historical owner value, and `planned` availability. +`PermissionId`, historical owner value, and initial `planned` availability. `WS-AUTH-001-ART-CUSTODY` has now replaced only those historical owner values -with the exact AUTH activation custodians below; mappings, availability, and -ART hidden-behavior ownership are unchanged. Its principal-class and +with the exact AUTH activation custodians below; mappings and ART +hidden-behavior ownership are unchanged, while reviewed activation chunks may +change availability. Its principal-class and canonical-resource columns are not AUTH registry fields and are not executable authority; the owning WS-ART chunk adopts them with its hidden canonical resource composer, guards, surface declaration, @@ -398,9 +399,9 @@ canonical in `.agent-loop/initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/AUTH_ART_HANDOFF.md`. A mapping is not a permission alias. -| AUTH activation custodian | Exact planned ActionIds | +| AUTH activation custodian | Exact ActionIds and current availability | |---|---| -| `WS-AUTH-001-ART-02D-INTERNAL` | `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | +| `WS-AUTH-001-ART-02D-INTERNAL` | Active: `artifact.verification.execute`, `artifact.pending_work.scan`, `artifact.put_attempt.resolve` | | `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-AUTH-001-ART-03` | `artifact.guide_source.ingest`, `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | @@ -411,7 +412,10 @@ A mapping is not a permission alias. | `WS-XINT-002-07` | `artifact.review_packet.materialize`, `artifact.review_evidence.binding.create` | The `OPERATOR` suffix names future activation custody only; it creates no -Operator grant or entitlement. All 22 actions remain planned and unavailable. +Operator grant or entitlement. WS-XINT-002-03 activates only the three internal +service actions; the other 19 ART actions remain planned and unavailable. +Migration `0037` admits the exact privacy-bounded ART resource-context digest +in append-only authorization decision facts; it adds no table or column. `artifact.verification_job.retry` requires its own later evaluator, guards, and independent activation proof; read/status proof cannot activate retry. The historical ART transfer added no migration; WS-XINT-002-01 reconciles the @@ -616,14 +620,16 @@ outbox/Celery payloads, provider interfaces, or serialized contracts. Exact feature contexts and session/root-bound composer proofs remain owned by the later evidence-backed activation chunks. -PREP intentionally ships no feature consumer. Its PostgreSQL participant is a -test-only neutral row proving that final facts, one decision event, participant -work, and caller commit or rollback share the same transaction. Fixed services -are locked and refreshed before their current planned-action denial, but no -positive fixed-service prepared capability exists until the first separately -activated consumer proves it. ProjectRoleGrant does not exist in PREP; AUTH-10 -must add its exact row lock, evaluator branch, and crossed-revocation evidence -before an exact-project product consumer can use that authority source. +PREP's neutral PostgreSQL participant remains a test proof that final facts, one +decision event, participant work, and caller commit or rollback share the same +transaction. WS-XINT-002-03 adds the first active feature consumer: exact typed +prepared capabilities for the ART verifier, pending-work scanner, and +put-attempt resolver. Fixed services are locked and refreshed before either an +active-action decision or a still-planned `action_unavailable` denial; +still-planned actions issue no handle. ProjectRoleGrant does not exist in PREP; +AUTH-10 must add its exact row lock, evaluator branch, and crossed-revocation +evidence before an exact-project product consumer can use that authority +source. Service identity, static service-action matrix membership, and action availability are immutable code-owned validations after the service profile and From 11783f8e380dca482685f06aa1ae0d8378a34478 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 17:42:19 +0100 Subject: [PATCH 2/8] test(auth): enforce two-phase artifact authority --- .../REVIEW_LOG.md | 9 +++++++++ .../WS-XINT-002-03-external-review-response.md | 18 ++++++++++++++++++ backend/tests/test_artifact_admission.py | 11 +++++++++-- backend/tests/test_artifact_recovery.py | 11 ++++++++++- backend/tests/test_authorization.py | 1 + 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md index 7d29fb37..04418fc3 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/REVIEW_LOG.md @@ -27,3 +27,12 @@ integrity, docs, and test-delta tracks passed after all valid findings were fixed. Reuse/dedup passed with low residual risk. The final review record and exact evidence are in `reviews/WS-XINT-002-03-internal-review.md`; no blocking finding remains before exact-head external review. + +## PR #212 external review + +The first hosted Backend run exposed three stale test expectations after all +four semantic lanes executed. The tests were strengthened to prove exact +claim/terminal capability consumption, terminal-only authority drift, and the +accurate active custody-table header. The isolated four-test PostgreSQL rerun +and QA, test-delta, and CI-integrity re-reviews passed. Details are in +`reviews/WS-XINT-002-03-external-review-response.md`. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md new file mode 100644 index 00000000..bdfb0282 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -0,0 +1,18 @@ +# WS-XINT-002-03 External Review Response + +## Hosted Backend run 30284611419 + +The semantic lanes executed but the independent evidence validator correctly +failed because `shared_foundations` contained three test failures. + +- The missing-object put test now proves two prepared and two consumed + capabilities in exact `claim`, `terminal` order. +- The terminal-authority-drift double now permits claim consumption and denies + only terminal consumption, preserving the running retry fence and committing + no terminal facts. +- The custody parser accepts the exact active/current-availability table header + while retaining exact action-set and duplicate validation. + +The focused isolated PostgreSQL rerun passed all four relevant regressions. +QA, test-delta, and CI-integrity re-reviews passed. No workflow, lane, +threshold, skip, or coverage policy changed. diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index d671d265..53ce48af 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -110,13 +110,18 @@ def __init__(self) -> None: self.prepares = 0 self.terminals = 0 self.phase: str | None = None + self.prepared_phases: list[str] = [] + self.consumed_phases: list[str] = [] async def prepare(self, **values: object) -> None: self.prepares += 1 self.phase = str(values["phase"]) + self.prepared_phases.append(self.phase) async def consume(self, **_values: object) -> None: self.terminals += 1 + assert self.phase is not None + self.consumed_phases.append(self.phase) def discard(self) -> None: self.phase = None @@ -902,8 +907,10 @@ async def test_preacknowledgement_absence_releases_capacity_without_write( assert scopes and {scope.counted_bytes for scope in scopes} == {0} assert await _count(session, ArtifactPutObservationReceipt) == 1 assert await _count(session, ArtifactOperationReceipt) == 0 - assert authority.prepares == 1 - assert authority.terminals == 1 + assert authority.prepares == 2 + assert authority.terminals == 2 + assert authority.prepared_phases == ["claim", "terminal"] + assert authority.consumed_phases == ["claim", "terminal"] finally: bootstrap.close() await engine.dispose() diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 7d99f5cf..b1dfa55a 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -74,8 +74,17 @@ def discard(self) -> None: ... class _DenyTerminalArtifactAuthority(_AllowArtifactAuthority): + phase: str | None = None + + async def prepare(self, **values: object) -> None: + self.phase = str(values["phase"]) + async def consume(self, **_values: object) -> None: - raise ArtifactAuthorityDeniedError("terminal authority changed") + if self.phase == "terminal": + raise ArtifactAuthorityDeniedError("terminal authority changed") + + def discard(self) -> None: + self.phase = None class _AllowRecoveryAuthority: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0b6dfbe9..0613f040 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1995,6 +1995,7 @@ def _parse_custody_table(document: Path, expected_actions: set[str]) -> dict[str for header_index, row in enumerate(rows): if row not in { "| AUTH activation custodian | Exact planned ActionIds |", + "| AUTH activation custodian | Exact ActionIds and current availability |", "| AUTH activation chunk | Exact planned ActionIds |", }: continue From 74370da959a12325329d2d5bc72e489758ae10e5 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 17:59:42 +0100 Subject: [PATCH 3/8] fix(artifacts): scope provider runtime to worker process --- ...-XINT-002-03-internal-worker-activation.md | 2 +- ...WS-XINT-002-03-external-review-response.md | 23 +++ .../adapters/artifacts/internal_workers.py | 81 +++++++- .../app/modules/artifacts/authorization.py | 23 ++- backend/app/workers/celery_app.py | 18 ++ .../test_artifact_internal_authorization.py | 21 +++ backend/tests/test_artifact_verification.py | 175 +++++++++++++++++- docs/operations_authorization_service.md | 5 +- docs/spec_authorization_service.md | 11 +- 9 files changed, 334 insertions(+), 25 deletions(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md index 0512bcb4..58162689 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md @@ -135,7 +135,7 @@ resource-context digest. It adds no table, column, or compatibility path. (cd backend && .venv/bin/python -m ruff check app tests scripts) (cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_admission.py tests/test_artifact_architecture.py tests/test_artifact_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_internal_authorization.py -q) (cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_authorization.py tests/test_artifact_internal_authorization.py -q --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) -(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_artifact_admission.py tests/test_artifact_architecture.py tests/test_artifact_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_internal_authorization.py -q --cov=app.modules.artifacts --cov=app.wor${EMPTY}kers.artifacts --cov=app.wor${EMPTY}kers.celery_app --cov-report=term-missing --cov-fail-under=90) +(cd backend && EMPTY= && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/pytest tests/test_artifact_admission.py tests/test_artifact_architecture.py tests/test_artifact_authorization.py tests/test_artifact_verification.py tests/test_artifact_recovery.py tests/test_artifact_internal_authorization.py -q --cov=app.modules.artifacts --cov=app.wor${EMPTY}kers.artifacts --cov=app.wor${EMPTY}kers.celery_app --cov-report=term-missing --cov-fail-under=90) python3 scripts/check_stale_authorization_docs.py python3 scripts/check_stale_artifact_contracts.py python3 scripts/check_markdown_links.py diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index bdfb0282..44feb5b5 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -16,3 +16,26 @@ failed because `shared_foundations` contained three test failures. The focused isolated PostgreSQL rerun passed all four relevant regressions. QA, test-delta, and CI-integrity re-reviews passed. No workflow, lane, threshold, skip, or coverage policy changed. + +## CodeRabbit review + +- Accepted the process-lifecycle finding. Celery child initialization now + claims and initializes one provider store, operations lease it against + shutdown, disabled storage skips initialization, and shutdown waits for + active leases. A concrete local bootstrap/store test prevents namespace + identity from crossing the lifecycle boundary. +- Accepted malformed selector normalization. Invalid UUID or prepared-scope + selectors now fail as `ArtifactAuthorityDeniedError` and have a focused test. +- Accepted the denial-contract finding. Both specifications now describe the + ART-only rollback-then-clean-restage exception, including still-planned exact + denials issuing no handle. +- The recovery terminal-denial finding was already repaired on the current + head with phase-aware claim/terminal behavior. +- The `${EMPTY}` split in the contract is required by the repository stale- + wording policy. Its shell assignment is now explicit, so the documented + coverage targets expand deterministically without reintroducing prohibited + wording. + +Architecture, security, QA, and reuse/dedup focused re-reviews passed after the +repairs. Ruff, focused tests, stale checks, markdown links, and diff checks +passed. diff --git a/backend/app/adapters/artifacts/internal_workers.py b/backend/app/adapters/artifacts/internal_workers.py index 9f242173..acbef8ae 100644 --- a/backend/app/adapters/artifacts/internal_workers.py +++ b/backend/app/adapters/artifacts/internal_workers.py @@ -3,6 +3,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from contextlib import contextmanager +from threading import Condition +from typing import Iterator from uuid import UUID, uuid4 from app.adapters.artifacts import ( @@ -11,10 +14,12 @@ ) from app.core.config import get_settings from app.db.session import get_session_factory +from app.interfaces.artifacts import ArtifactStore, ArtifactStoreBootstrap from app.modules.actors.service_identities import ServiceIdentity from app.modules.artifacts.authorization import PreparedArtifactInternalAuthority from app.modules.artifacts.service import ( ArtifactPendingWorkScanner, + ArtifactStorageNamespaceSpec, ArtifactStorageOrchestrator, artifact_storage_namespace_spec, validate_artifact_storage_namespace_at_startup, @@ -23,6 +28,72 @@ from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError +_runtime_condition = Condition() +_runtime: tuple[ + ArtifactStoreBootstrap, + ArtifactStore, + ArtifactStorageNamespaceSpec, +] | None = None +_runtime_active_operations = 0 +_runtime_shutting_down = False + + +async def initialize_artifact_internal_runtime() -> None: + """Initialize one provider store for this Celery child process.""" + global _runtime, _runtime_shutting_down + settings = get_settings() + if settings.artifact_store_backend == "disabled": + return + require_artifact_runtime_eligible(settings) + bootstrap = create_artifact_store_bootstrap(settings) + try: + namespace = artifact_storage_namespace_spec(settings, bootstrap) + claim = await validate_artifact_storage_namespace_at_startup(bootstrap, settings) + store = bootstrap.initialize_after_namespace_claim(claim) + except BaseException: + bootstrap.close() + raise + with _runtime_condition: + if _runtime is not None: + bootstrap.close() + return + _runtime = (bootstrap, store, namespace) + _runtime_shutting_down = False + + +def shutdown_artifact_internal_runtime() -> None: + """Close the process store only after every admitted operation exits.""" + global _runtime, _runtime_shutting_down + with _runtime_condition: + _runtime_shutting_down = True + while _runtime_active_operations: + _runtime_condition.wait() + runtime = _runtime + _runtime = None + if runtime is not None: + runtime[0].close() + + +@contextmanager +def _artifact_internal_runtime() -> Iterator[ + tuple[ArtifactStore, ArtifactStorageNamespaceSpec] +]: + """Lease the initialized process store against concurrent shutdown.""" + global _runtime_active_operations + with _runtime_condition: + if _runtime is None or _runtime_shutting_down: + raise RuntimeError("artifact internal runtime is not initialized") + _runtime_active_operations += 1 + _bootstrap, store, namespace = _runtime + try: + yield store, namespace + finally: + with _runtime_condition: + _runtime_active_operations -= 1 + if _runtime_active_operations == 0: + _runtime_condition.notify_all() + + async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: """Compose one resolver or verifier operation behind the adapter boundary.""" identities = { @@ -34,11 +105,7 @@ async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: except KeyError as exc: raise ValueError("unsupported artifact internal operation") from exc settings = get_settings() - require_artifact_runtime_eligible(settings) - bootstrap = create_artifact_store_bootstrap(settings) - try: - claim = await validate_artifact_storage_namespace_at_startup(bootstrap, settings) - store = bootstrap.initialize_after_namespace_claim(claim) + with _artifact_internal_runtime() as (store, namespace): async with get_session_factory()() as session: request_id = uuid4() authority = PreparedArtifactInternalAuthority( @@ -50,7 +117,7 @@ async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: orchestrator = ArtifactStorageOrchestrator( session, store, - artifact_storage_namespace_spec(settings, store), + namespace, settings, authority, ) @@ -65,8 +132,6 @@ async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: raise ArtifactAuthorityDeniedError( "artifact internal authority denied" ) from None - finally: - bootstrap.close() async def scan_artifact_pending_work( diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 2894850d..c62e452e 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -4,6 +4,7 @@ from uuid import UUID +from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from app.modules.actors.repository import ActorRepository @@ -242,12 +243,22 @@ def _scope( if resource_type is ArtifactInternalResourceType.PENDING_WORK: normalized_id = resource_id else: - normalized_id = resource_id if isinstance(resource_id, UUID) else UUID(resource_id) - return PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, - artifact_resource_type=resource_type.value, - artifact_resource_id=normalized_id, - ) + try: + normalized_id = resource_id if isinstance(resource_id, UUID) else UUID(resource_id) + except (TypeError, ValueError) as exc: + raise ArtifactAuthorityDeniedError( + "artifact internal resource is invalid" + ) from exc + try: + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, + artifact_resource_type=resource_type.value, + artifact_resource_id=normalized_id, + ) + except ValidationError as exc: + raise ArtifactAuthorityDeniedError( + "artifact internal resource is invalid" + ) from exc def _resource_context(facts: ArtifactInternalAuthorityFacts): diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 7b97e1f0..34917e4d 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -3,9 +3,15 @@ from __future__ import annotations from celery import Celery +from celery.signals import worker_process_init, worker_process_shutdown from app.adapters.artifacts import require_artifact_runtime_eligible +from app.adapters.artifacts.internal_workers import ( + initialize_artifact_internal_runtime, + shutdown_artifact_internal_runtime, +) from app.core.config import get_settings +from app.workers.async_runner import run_async_task from app.workers.errors import CeleryConfigurationError ARTIFACT_SCRATCH_CLEANUP_TASK = "workstream.artifacts.cleanup_stale_scratch" @@ -16,6 +22,18 @@ ARTIFACT_PENDING_WORK_SCAN_SCHEDULE = "artifact-pending-work-scan" +@worker_process_init.connect +def initialize_artifact_runtime_for_process(**_kwargs: object) -> None: + """Claim and initialize the artifact provider once per Celery child.""" + run_async_task(initialize_artifact_internal_runtime) + + +@worker_process_shutdown.connect +def shutdown_artifact_runtime_for_process(**_kwargs: object) -> None: + """Close the artifact provider after the Celery child drains tasks.""" + shutdown_artifact_internal_runtime() + + def create_celery_app() -> Celery: """Create the Celery application from Workstream settings. diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index aecd71dc..223c432f 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -16,6 +16,7 @@ from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.artifacts.authorization import PreparedArtifactInternalAuthority from app.modules.artifacts.schemas import ( + ArtifactAuthorityDeniedError, ArtifactInternalAuthority, ArtifactInternalResourceType, ArtifactPutAttemptAuthorityFacts, @@ -94,6 +95,26 @@ def _facts() -> ArtifactPutAttemptAuthorityFacts: ) +@pytest.mark.asyncio +async def test_adapter_normalizes_malformed_resource_selector_to_denial() -> None: + authority = PreparedArtifactInternalAuthority( + _Session(), # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + malformed = replace(_facts(), resource_id="not-a-uuid") # type: ignore[arg-type] + + with pytest.raises(ArtifactAuthorityDeniedError, match="resource is invalid"): + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=malformed, + phase="claim", + idempotency_key=uuid4(), + ) + + class _FailAfterConsumeAuthority: """Inject a failure after AUTH stages allow evidence in the ART transaction.""" diff --git a/backend/tests/test_artifact_verification.py b/backend/tests/test_artifact_verification.py index c227f015..82c0939f 100644 --- a/backend/tests/test_artifact_verification.py +++ b/backend/tests/test_artifact_verification.py @@ -5,13 +5,18 @@ import asyncio import importlib from datetime import UTC, datetime +from threading import Event, Thread +from types import SimpleNamespace from uuid import UUID, uuid4 import pytest from unittest.mock import AsyncMock, Mock import app.interfaces.artifact_operations # noqa: F401 - cumulative contract coverage +import app.adapters.artifacts.internal_workers as internal_worker_adapter +from app.adapters.artifacts.local import LocalStorageAdapter, LocalStorageBootstrap from app.core.config import Settings, get_settings +from app.interfaces.artifacts import ArtifactStoreNamespaceClaim from app.modules.actors.service_identities import ServiceIdentity from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, @@ -20,8 +25,12 @@ ArtifactPutAttemptAuthorityFacts, DenyArtifactInternalAuthority, ) -from app.modules.artifacts.service import ArtifactStorageOrchestrator +from app.modules.artifacts.service import ( + ArtifactStorageOrchestrator, + artifact_storage_namespace_spec, +) from app.modules.authorization.catalogue import ACTION_BY_ID, ActionAvailability, ActionId +from tests.artifact_store_helpers import artifact_admission_limit_settings @pytest.mark.asyncio @@ -62,9 +71,7 @@ def test_internal_celery_tasks_and_pending_scan_are_registered_once(monkeypatch) assert "workstream.artifacts.resolve_put_attempt" in celery_app.tasks assert "workstream.artifacts.verify_object" in celery_app.tasks assert "workstream.artifacts.scan_pending_work" in celery_app.tasks - scheduled_tasks = [ - entry["task"] for entry in celery_app.conf.beat_schedule.values() - ] + scheduled_tasks = [entry["task"] for entry in celery_app.conf.beat_schedule.values()] assert scheduled_tasks.count("workstream.artifacts.scan_pending_work") == 1 assert scheduled_tasks.count("workstream.artifacts.cleanup_stale_scratch") == 1 operation = AsyncMock(return_value=None) @@ -96,6 +103,166 @@ async def scan(publish_put, publish_job): get_settings.cache_clear() +@pytest.mark.asyncio +async def test_internal_artifact_store_is_initialized_once_per_process( + monkeypatch: pytest.MonkeyPatch, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + first_bootstrap, duplicate_bootstrap = Mock(), Mock() + first_store, duplicate_store = Mock(), Mock() + first_bootstrap.initialize_after_namespace_claim.return_value = first_store + duplicate_bootstrap.initialize_after_namespace_claim.return_value = duplicate_store + monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=Mock())) + monkeypatch.setattr( + internal_worker_adapter, + "require_artifact_runtime_eligible", + Mock(), + ) + monkeypatch.setattr( + internal_worker_adapter, + "create_artifact_store_bootstrap", + Mock(side_effect=[first_bootstrap, duplicate_bootstrap]), + ) + monkeypatch.setattr( + internal_worker_adapter, + "validate_artifact_storage_namespace_at_startup", + AsyncMock(side_effect=[Mock(), Mock()]), + ) + first_namespace, duplicate_namespace = Mock(), Mock() + monkeypatch.setattr( + internal_worker_adapter, + "artifact_storage_namespace_spec", + Mock(side_effect=[first_namespace, duplicate_namespace]), + ) + + try: + await internal_worker_adapter.initialize_artifact_internal_runtime() + await internal_worker_adapter.initialize_artifact_internal_runtime() + with internal_worker_adapter._artifact_internal_runtime() as runtime: + assert runtime == (first_store, first_namespace) + first_bootstrap.close.assert_not_called() + duplicate_bootstrap.close.assert_called_once_with() + finally: + internal_worker_adapter.shutdown_artifact_internal_runtime() + + first_bootstrap.close.assert_called_once_with() + with pytest.raises(RuntimeError, match="not initialized"): + with internal_worker_adapter._artifact_internal_runtime(): + pass + + +@pytest.mark.asyncio +async def test_disabled_artifact_store_skips_process_initialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + monkeypatch.setattr( + internal_worker_adapter, + "get_settings", + Mock(return_value=SimpleNamespace(artifact_store_backend="disabled")), + ) + create = Mock(side_effect=AssertionError("disabled storage must not initialize")) + monkeypatch.setattr(internal_worker_adapter, "create_artifact_store_bootstrap", create) + + await internal_worker_adapter.initialize_artifact_internal_runtime() + + create.assert_not_called() + with pytest.raises(RuntimeError, match="not initialized"): + with internal_worker_adapter._artifact_internal_runtime(): + pass + + +@pytest.mark.asyncio +async def test_process_runtime_uses_concrete_bootstrap_namespace( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + root = tmp_path / "artifacts" + root.mkdir(mode=0o700) + settings = Settings( + **artifact_admission_limit_settings(), + environment="test", + artifact_store_backend="local", + artifact_local_root=root, + artifact_scratch_root=tmp_path / "scratch", + ) + bootstrap = LocalStorageBootstrap(LocalStorageAdapter(root=root)) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + claim = ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=settings)) + monkeypatch.setattr( + internal_worker_adapter, + "create_artifact_store_bootstrap", + Mock(return_value=bootstrap), + ) + monkeypatch.setattr( + internal_worker_adapter, + "validate_artifact_storage_namespace_at_startup", + AsyncMock(return_value=claim), + ) + + try: + await internal_worker_adapter.initialize_artifact_internal_runtime() + with internal_worker_adapter._artifact_internal_runtime() as runtime: + assert runtime[1] == namespace + assert not hasattr(runtime[0], "namespace_identity") + finally: + internal_worker_adapter.shutdown_artifact_internal_runtime() + + +@pytest.mark.asyncio +async def test_process_runtime_shutdown_waits_for_active_lease( + monkeypatch: pytest.MonkeyPatch, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + bootstrap, store, namespace = Mock(), Mock(), Mock() + bootstrap.initialize_after_namespace_claim.return_value = store + monkeypatch.setattr( + internal_worker_adapter, + "get_settings", + Mock(return_value=SimpleNamespace(artifact_store_backend="local")), + ) + monkeypatch.setattr(internal_worker_adapter, "require_artifact_runtime_eligible", Mock()) + monkeypatch.setattr( + internal_worker_adapter, + "create_artifact_store_bootstrap", + Mock(return_value=bootstrap), + ) + monkeypatch.setattr( + internal_worker_adapter, + "artifact_storage_namespace_spec", + Mock(return_value=namespace), + ) + monkeypatch.setattr( + internal_worker_adapter, + "validate_artifact_storage_namespace_at_startup", + AsyncMock(return_value=Mock()), + ) + await internal_worker_adapter.initialize_artifact_internal_runtime() + shutdown_started, shutdown_finished = Event(), Event() + + def shutdown() -> None: + shutdown_started.set() + internal_worker_adapter.shutdown_artifact_internal_runtime() + shutdown_finished.set() + + with internal_worker_adapter._artifact_internal_runtime(): + thread = Thread(target=shutdown) + thread.start() + assert shutdown_started.wait(timeout=1) + assert not shutdown_finished.wait(timeout=0.05) + + assert shutdown_finished.wait(timeout=1) + thread.join(timeout=1) + assert not thread.is_alive() + bootstrap.close.assert_called_once_with() + + def test_internal_artifact_actions_are_active() -> None: assert { action_id: ACTION_BY_ID[action_id].availability diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 434a8eca..c28f319e 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -786,8 +786,9 @@ rolls back with participant state. The three active internal ART compositions are the deliberate exception: their adapter retains the exact denial, the composition root first rolls back ART state, and AUTH's public bounded restage operation commits the same denial in a clean AUTH-only transaction. Still- -planned fixed-service preparation returns bounded `action_unavailable` without -evidence because it issues no handle and has no final resource context. +planned fixed-service preparation still issues no handle. When it enters the +ART adapter with an exact resource context, its bounded `action_unavailable` +denial follows the same rollback-then-clean-restage path. Operationally, actor-self preparation locks profile then exact link. An administrative preparation locks `AuthorityControl(id=1)`, request profile, diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index f4ee5093..2096e42f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -626,10 +626,13 @@ transaction. WS-XINT-002-03 adds the first active feature consumer: exact typed prepared capabilities for the ART verifier, pending-work scanner, and put-attempt resolver. Fixed services are locked and refreshed before either an active-action decision or a still-planned `action_unavailable` denial; -still-planned actions issue no handle. ProjectRoleGrant does not exist in PREP; -AUTH-10 must add its exact row lock, evaluator branch, and crossed-revocation -evidence before an exact-project product consumer can use that authority -source. +still-planned actions issue no handle. For ART adapter calls only, any exact +denial is retained across the caller rollback and restaged through AUTH's +bounded public operation in a clean AUTH-only transaction. General PREP callers +do not restage rolled-back denial evidence. ProjectRoleGrant does not exist in +PREP; AUTH-10 must add its exact row lock, evaluator branch, and crossed- +revocation evidence before an exact-project product consumer can use that +authority source. Service identity, static service-action matrix membership, and action availability are immutable code-owned validations after the service profile and From c17e6a39c18e90c246a179d19d00f676096ef40a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 18:22:54 +0100 Subject: [PATCH 4/8] test(migrations): recognize authorization evidence head --- .../WS-XINT-002-03-internal-worker-activation.md | 1 + .../WS-XINT-002-03-external-review-response.md | 10 ++++++++++ backend/tests/test_alembic.py | 16 ++++++++-------- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md index 58162689..1c187c12 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-03-internal-worker-activation.md @@ -23,6 +23,7 @@ backend/app/modules/artifacts/service.py backend/app/wor?ers/{artifacts,celery_app}.py backend/tests/test_authorization.py backend/tests/test_audit.py +backend/tests/test_alembic.py backend/tests/test_ci_test_lanes.py backend/tests/conftest.py backend/tests/test_artifact_admission.py diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index 44feb5b5..e75722c5 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -39,3 +39,13 @@ threshold, skip, or coverage policy changed. Architecture, security, QA, and reuse/dedup focused re-reviews passed after the repairs. Ruff, focused tests, stale checks, markdown links, and diff checks passed. + +## Hosted Backend run 30287247882 + +Three semantic lanes passed. The schema-contract lane exposed six existing +Alembic tests that still named migration `0036` as repository head after this +chunk added `0037`. Eight exact expected-revision values now name `0037`; all +schema, downgrade-refusal, row-preservation, and trigger assertions remain +unchanged. The same isolated six-test selection passed in 509.88 seconds, lane +inventory passed, and CI-integrity plus test-delta re-reviews passed. No CI or +coverage configuration changed. diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index a6d0e7c4..3f58815f 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1629,7 +1629,7 @@ def test_artifact_recovery_schema_and_empty_downgrade( command.downgrade(config, "base") command.upgrade(config, "head") assert asyncio.run(_artifact_recovery_schema(isolated_database_env)) == { - "revision": "0036_art_auth_catalogue", + "revision": "0037_art_auth_context_evidence", "constraints": { "artifact_recovery_attempt_custody", "artifact_verification_lineage_custody", @@ -1739,7 +1739,7 @@ def test_0035_project_read_action_evidence_refuses_nonempty_downgrade( ): command.downgrade(config, "0034_project_role_issue_evidence") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0036_art_auth_catalogue" + "0037_art_auth_context_evidence" ) finally: asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) @@ -1866,7 +1866,7 @@ def test_0036_art_auth_catalogue_refuses_obsolete_evidence( record_id = "" command.upgrade(config, "head") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0036_art_auth_catalogue" + "0037_art_auth_context_evidence" ) finally: for event_id in reversed(event_ids): @@ -2263,7 +2263,7 @@ def test_project_role_migration_constraints_and_immutable_history( command.upgrade(config, "head") result = asyncio.run(_exercise_project_role_migration(isolated_database_env)) assert result == { - "revision": "0036_art_auth_catalogue", + "revision": "0037_art_auth_context_evidence", "role_count": 3, "invalid_availability": "23514", "duplicate_role": "23505", @@ -2475,7 +2475,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0036_art_auth_catalogue", + "0037_art_auth_context_evidence", True, True, ) @@ -2502,7 +2502,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0036_art_auth_catalogue", + "0037_art_auth_context_evidence", True, True, ) @@ -2530,7 +2530,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( command.upgrade(config, "head") schema = asyncio.run(_outbox_schema(isolated_database_env)) assert schema == { - "revision": "0036_art_auth_catalogue", + "revision": "0037_art_auth_context_evidence", "columns": { "aggregate_id", "aggregate_type", @@ -2590,7 +2590,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( ) assert committed == "refused_after_commit" assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0036_art_auth_catalogue" + "0037_art_auth_context_evidence" ) asyncio.run(_remove_outbox_migration_row(isolated_database_env, committed_project_id)) command.downgrade(config, "0028_artifact_admission") From ea026acbbd64f92fa762dfdce53d743f588252e3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 18:39:29 +0100 Subject: [PATCH 5/8] fix(workers): initialize artifact runtime for eager tasks --- ...WS-XINT-002-03-external-review-response.md | 10 +++ .../adapters/artifacts/internal_workers.py | 4 + backend/app/modules/authorization/kernel.py | 6 +- backend/app/modules/authorization/prepared.py | 18 ++--- backend/app/workers/celery_app.py | 5 +- backend/tests/test_artifact_verification.py | 74 ++++++++++++++++--- 6 files changed, 94 insertions(+), 23 deletions(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index e75722c5..22d6f3a1 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -49,3 +49,13 @@ schema, downgrade-refusal, row-preservation, and trigger assertions remain unchanged. The same isolated six-test selection passed in 509.88 seconds, lane inventory passed, and CI-integrity plus test-delta re-reviews passed. No CI or coverage configuration changed. + +## CodeRabbit eager/non-prefork follow-up + +Accepted. Resolver and verifier task bodies now idempotently initialize the +process runtime before leasing it, covering eager, solo, and thread execution +without weakening prefork startup. General `worker_shutdown` complements the +prefork child shutdown signal. The real Celery task bodies run under eager mode +in the focused regression; runtime, exact PREP action/context pairing, and +direct-require denial tests passed. Architecture, security, and QA focused +re-reviews passed. diff --git a/backend/app/adapters/artifacts/internal_workers.py b/backend/app/adapters/artifacts/internal_workers.py index acbef8ae..a982451c 100644 --- a/backend/app/adapters/artifacts/internal_workers.py +++ b/backend/app/adapters/artifacts/internal_workers.py @@ -41,6 +41,9 @@ async def initialize_artifact_internal_runtime() -> None: """Initialize one provider store for this Celery child process.""" global _runtime, _runtime_shutting_down + with _runtime_condition: + if _runtime is not None and not _runtime_shutting_down: + return settings = get_settings() if settings.artifact_store_backend == "disabled": return @@ -105,6 +108,7 @@ async def run_artifact_internal_operation(kind: str, resource_id: UUID) -> None: except KeyError as exc: raise ValueError("unsupported artifact internal operation") from exc settings = get_settings() + await initialize_artifact_internal_runtime() with _artifact_internal_runtime() as (store, namespace): async with get_session_factory()() as session: request_id = uuid4() diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index ed90596a..c18d2600 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -608,7 +608,11 @@ async def _service_denial( context: ServiceAuthorizationContext, resource: AuthorizationResourceContext, ) -> tuple[AuthorizationDenialCode | None, ServiceAuthorizationContext, bool]: - """Evaluate one fixed service before every human authority path.""" + """Evaluate lifecycle/matrix state; direct feature access always denies. + + The resource is intentionally not honored here. Active ART service + actions can allow only through a prepared capability consumption. + """ lifecycle = self._lifecycle_denial(context) if lifecycle is not None: return lifecycle, context, False diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index a5f64f11..52f0036d 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -244,17 +244,13 @@ def _scope_from_resource( role=role, grant_id=grant_id, ) - if action_id in { - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - } and isinstance( - resource, - ( - ArtifactPutAttemptResourceContext, - ArtifactVerificationJobResourceContext, - ArtifactPendingWorkResourceContext, - ), + artifact_resource_type = { + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ArtifactPutAttemptResourceContext, + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ArtifactVerificationJobResourceContext, + ActionId.ARTIFACT_PENDING_WORK_SCAN: ArtifactPendingWorkResourceContext, + }.get(action_id) + if artifact_resource_type is not None and isinstance( + resource, artifact_resource_type ): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 34917e4d..12fb5b23 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -3,7 +3,7 @@ from __future__ import annotations from celery import Celery -from celery.signals import worker_process_init, worker_process_shutdown +from celery.signals import worker_process_init, worker_process_shutdown, worker_shutdown from app.adapters.artifacts import require_artifact_runtime_eligible from app.adapters.artifacts.internal_workers import ( @@ -29,8 +29,9 @@ def initialize_artifact_runtime_for_process(**_kwargs: object) -> None: @worker_process_shutdown.connect +@worker_shutdown.connect def shutdown_artifact_runtime_for_process(**_kwargs: object) -> None: - """Close the artifact provider after the Celery child drains tasks.""" + """Close the artifact provider after any Celery execution pool drains.""" shutdown_artifact_internal_runtime() diff --git a/backend/tests/test_artifact_verification.py b/backend/tests/test_artifact_verification.py index 82c0939f..7dcecee9 100644 --- a/backend/tests/test_artifact_verification.py +++ b/backend/tests/test_artifact_verification.py @@ -4,6 +4,7 @@ import asyncio import importlib +from contextlib import contextmanager from datetime import UTC, datetime from threading import Event, Thread from types import SimpleNamespace @@ -62,9 +63,66 @@ async def test_production_authority_denies_prepare_and_consume() -> None: ) -def test_internal_celery_tasks_and_pending_scan_are_registered_once(monkeypatch) -> None: +def test_eager_internal_tasks_use_lazy_process_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") get_settings.cache_clear() + worker_module = importlib.import_module("app.workers.artifacts") + monkeypatch.setitem(worker_module.celery_app.conf, "task_always_eager", True) + monkeypatch.setitem(worker_module.celery_app.conf, "task_eager_propagates", True) + initialize = AsyncMock(return_value=None) + monkeypatch.setattr( + internal_worker_adapter, + "initialize_artifact_internal_runtime", + initialize, + ) + + @contextmanager + def runtime(): + yield Mock(), Mock() + + monkeypatch.setattr(internal_worker_adapter, "_artifact_internal_runtime", runtime) + monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=Mock())) + + class SessionContext: + async def __aenter__(self): + return Mock() + + async def __aexit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr( + internal_worker_adapter, + "get_session_factory", + Mock(return_value=Mock(return_value=SessionContext())), + ) + monkeypatch.setattr( + internal_worker_adapter, + "PreparedArtifactInternalAuthority", + Mock(), + ) + orchestrator = Mock() + orchestrator.resolve_put_attempt = AsyncMock(return_value="resolved") + orchestrator.verify_object = AsyncMock(return_value="verified") + monkeypatch.setattr( + internal_worker_adapter, + "ArtifactStorageOrchestrator", + Mock(return_value=orchestrator), + ) + monkeypatch.setattr( + worker_module, + "run_artifact_internal_operation", + internal_worker_adapter.run_artifact_internal_operation, + ) + attempt_id, job_id = uuid4(), uuid4() + + worker_module.resolve_put_attempt.delay(str(attempt_id)) + worker_module.verify_object.delay(str(job_id)) + + assert initialize.await_count == 2 + orchestrator.resolve_put_attempt.assert_awaited_once_with(attempt_id) + orchestrator.verify_object.assert_awaited_once_with(job_id) celery_module = importlib.import_module("app.workers.celery_app") worker_module = importlib.import_module("app.workers.artifacts") celery_app = celery_module.celery_app @@ -108,10 +166,9 @@ async def test_internal_artifact_store_is_initialized_once_per_process( monkeypatch: pytest.MonkeyPatch, ) -> None: internal_worker_adapter.shutdown_artifact_internal_runtime() - first_bootstrap, duplicate_bootstrap = Mock(), Mock() - first_store, duplicate_store = Mock(), Mock() + first_bootstrap = Mock() + first_store = Mock() first_bootstrap.initialize_after_namespace_claim.return_value = first_store - duplicate_bootstrap.initialize_after_namespace_claim.return_value = duplicate_store monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=Mock())) monkeypatch.setattr( internal_worker_adapter, @@ -121,18 +178,18 @@ async def test_internal_artifact_store_is_initialized_once_per_process( monkeypatch.setattr( internal_worker_adapter, "create_artifact_store_bootstrap", - Mock(side_effect=[first_bootstrap, duplicate_bootstrap]), + Mock(return_value=first_bootstrap), ) monkeypatch.setattr( internal_worker_adapter, "validate_artifact_storage_namespace_at_startup", - AsyncMock(side_effect=[Mock(), Mock()]), + AsyncMock(return_value=Mock()), ) - first_namespace, duplicate_namespace = Mock(), Mock() + first_namespace = Mock() monkeypatch.setattr( internal_worker_adapter, "artifact_storage_namespace_spec", - Mock(side_effect=[first_namespace, duplicate_namespace]), + Mock(return_value=first_namespace), ) try: @@ -141,7 +198,6 @@ async def test_internal_artifact_store_is_initialized_once_per_process( with internal_worker_adapter._artifact_internal_runtime() as runtime: assert runtime == (first_store, first_namespace) first_bootstrap.close.assert_not_called() - duplicate_bootstrap.close.assert_called_once_with() finally: internal_worker_adapter.shutdown_artifact_internal_runtime() From d0d0a953ae4422f519edbb036e267f0437612e04 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 19:26:55 +0100 Subject: [PATCH 6/8] test(artifacts): cover internal runtime failures --- ...WS-XINT-002-03-external-review-response.md | 20 +++ .../reviews/WS-XINT-002-03-pr-trust-bundle.md | 9 +- .../test_artifact_internal_authorization.py | 94 ++++++++++ backend/tests/test_artifact_verification.py | 168 ++++++++++++++++++ 4 files changed, 289 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index 22d6f3a1..eeee0203 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -59,3 +59,23 @@ prefork child shutdown signal. The real Celery task bodies run under eager mode in the focused regression; runtime, exact PREP action/context pairing, and direct-require denial tests passed. Architecture, security, and QA focused re-reviews passed. + +## Hosted Backend run 30290229205 + +All semantic lanes, the independent evidence validator, combined coverage, +API end-to-end tests, and the full backend coverage gate passed. The remaining +artifact-foundation coverage gate correctly failed at 89.42 percent against +its unchanged 90 percent floor. + +The repair adds branch coverage for process-runtime claim cleanup, unsupported +operation rejection, rollback-before-denial-restage ordering, pending-work scan +success and denial handling, invalid authority state, malformed pending-work +scope, unavailable service principals, and invalid principal identifiers. It +does not change workflow configuration, coverage targets, source inclusion, +test selection, or skip behavior. + +Ruff, 18 focused unit tests, stale authorization and artifact-contract scans, +Markdown links, and diff integrity passed locally. QA and test-delta reviews +passed; the test-delta review specifically confirms the exact rollback then +restage assertion and found no weakening. The full PostgreSQL and exact-head +coverage proof remains assigned to GitHub Actions. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md index 3525383d..5efb883d 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md @@ -46,8 +46,13 @@ expanded; all other ART service actions remain planned and issue no handle. diff checks passed. - Required internal reviewers passed; reuse/dedup recorded only low residual risk. -- GitHub Actions will run the full repository suite and coverage at the exact PR - head; no local four-hour full run is required. +- Hosted run 30290229205 passed every semantic lane, independent evidence + validation, combined coverage, API end-to-end tests, and full backend + coverage. Its sole failure was the unchanged artifact-foundation 90 percent + floor at 89.42 percent. The follow-up adds focused branch coverage without + changing CI configuration, thresholds, inclusion, selection, or skips. +- GitHub Actions will rerun the full repository suite and coverage at the exact + PR head; no local four-hour full run is required. ## Human review focus diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index 223c432f..8d613c1c 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -19,6 +19,7 @@ ArtifactAuthorityDeniedError, ArtifactInternalAuthority, ArtifactInternalResourceType, + ArtifactPendingWorkAuthorityFacts, ArtifactPutAttemptAuthorityFacts, ) from app.modules.authorization.catalogue import ActionId @@ -115,6 +116,99 @@ async def test_adapter_normalizes_malformed_resource_selector_to_denial() -> Non ) +@pytest.mark.asyncio +async def test_adapter_fails_closed_for_invalid_state_and_service_principal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authority = PreparedArtifactInternalAuthority( + _Session(), # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="authority is invalid"): + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=_facts(), + phase="claim", + idempotency_key=uuid4(), + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="evidence is unavailable"): + await authority.persist_denial() + + pending = ArtifactPendingWorkAuthorityFacts( + resource_type=ArtifactInternalResourceType.PENDING_WORK, + resource_id="invalid", + scanner_kind="artifact_pending_work", + database_cutoff_iso="2026-07-27T00:00:00Z", + page_size=1, + ) + scheduler = PreparedArtifactInternalAuthority( + _Session(), # type: ignore[arg-type] + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + request_id=uuid4(), + correlation_id=uuid4(), + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="resource is invalid"): + await scheduler.prepare( + service_identity=ServiceIdentity.ARTIFACT_SCHEDULER, + action_id=ActionId.ARTIFACT_PENDING_WORK_SCAN, + facts=pending, + phase="scan", + idempotency_key=uuid4(), + ) + + class MissingActors: + async def get_service_actor(self, _service_identity: str): + return None + + monkeypatch.setattr( + artifact_authorization, + "ActorRepository", + lambda _session: MissingActors(), + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="principal is unavailable"): + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=_facts(), + phase="claim", + idempotency_key=uuid4(), + ) + + class InvalidActors: + async def get_service_actor(self, _service_identity: str): + return SimpleNamespace( + id="invalid", + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + ) + + async def get_identity_link_for_actor(self, _actor_profile_id: str): + return SimpleNamespace( + id="invalid", + actor_profile_id="invalid", + subject_kind="service", + status="active", + ) + + monkeypatch.setattr( + artifact_authorization, + "ActorRepository", + lambda _session: InvalidActors(), + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="principal is unavailable"): + await authority.prepare( + service_identity=ServiceIdentity.ARTIFACT_PUT_RESOLVER, + action_id=ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + facts=_facts(), + phase="terminal", + idempotency_key=uuid4(), + ) + + class _FailAfterConsumeAuthority: """Inject a failure after AUTH stages allow evidence in the ART transaction.""" diff --git a/backend/tests/test_artifact_verification.py b/backend/tests/test_artifact_verification.py index 7dcecee9..bc3d8161 100644 --- a/backend/tests/test_artifact_verification.py +++ b/backend/tests/test_artifact_verification.py @@ -31,6 +31,7 @@ artifact_storage_namespace_spec, ) from app.modules.authorization.catalogue import ACTION_BY_ID, ActionAvailability, ActionId +from app.modules.authorization.runtime import AuthorizationDenied from tests.artifact_store_helpers import artifact_admission_limit_settings @@ -319,6 +320,173 @@ def shutdown() -> None: bootstrap.close.assert_called_once_with() +@pytest.mark.asyncio +async def test_process_runtime_closes_bootstrap_when_namespace_claim_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + bootstrap = Mock() + monkeypatch.setattr( + internal_worker_adapter, + "get_settings", + Mock(return_value=SimpleNamespace(artifact_store_backend="local")), + ) + monkeypatch.setattr(internal_worker_adapter, "require_artifact_runtime_eligible", Mock()) + monkeypatch.setattr( + internal_worker_adapter, + "create_artifact_store_bootstrap", + Mock(return_value=bootstrap), + ) + monkeypatch.setattr( + internal_worker_adapter, + "artifact_storage_namespace_spec", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + internal_worker_adapter, + "validate_artifact_storage_namespace_at_startup", + AsyncMock(side_effect=RuntimeError("claim failed")), + ) + + with pytest.raises(RuntimeError, match="claim failed"): + await internal_worker_adapter.initialize_artifact_internal_runtime() + + bootstrap.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_internal_operation_rejects_kind_and_restages_denial( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError, match="unsupported artifact internal operation"): + await internal_worker_adapter.run_artifact_internal_operation("unknown", uuid4()) + + monkeypatch.setattr( + internal_worker_adapter, + "initialize_artifact_internal_runtime", + AsyncMock(return_value=None), + ) + + @contextmanager + def runtime(): + yield Mock(), Mock() + + monkeypatch.setattr(internal_worker_adapter, "_artifact_internal_runtime", runtime) + monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=Mock())) + order: list[str] = [] + + async def rollback() -> None: + order.append("rollback") + + async def persist_denial() -> None: + order.append("restage") + + session = Mock() + session.rollback = AsyncMock(side_effect=rollback) + + class SessionContext: + async def __aenter__(self): + return session + + async def __aexit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr( + internal_worker_adapter, + "get_session_factory", + Mock(return_value=Mock(return_value=SessionContext())), + ) + authority = Mock() + authority.persist_denial = AsyncMock(side_effect=persist_denial) + monkeypatch.setattr( + internal_worker_adapter, + "PreparedArtifactInternalAuthority", + Mock(return_value=authority), + ) + orchestrator = Mock() + orchestrator.resolve_put_attempt = AsyncMock( + side_effect=AuthorizationDenied( + SimpleNamespace(allowed=False, denial_code="denied") # type: ignore[arg-type] + ) + ) + monkeypatch.setattr( + internal_worker_adapter, + "ArtifactStorageOrchestrator", + Mock(return_value=orchestrator), + ) + + with pytest.raises(ArtifactAuthorityDeniedError, match="authority denied"): + await internal_worker_adapter.run_artifact_internal_operation("put", uuid4()) + + session.rollback.assert_awaited_once_with() + authority.persist_denial.assert_awaited_once_with() + assert order == ["rollback", "restage"] + + +@pytest.mark.asyncio +async def test_pending_scan_returns_count_and_restages_denial( + monkeypatch: pytest.MonkeyPatch, +) -> None: + order: list[str] = [] + + async def rollback() -> None: + order.append("rollback") + + async def persist_denial() -> None: + order.append("restage") + + session = Mock() + session.rollback = AsyncMock(side_effect=rollback) + + class SessionContext: + async def __aenter__(self): + return session + + async def __aexit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr( + internal_worker_adapter, + "get_session_factory", + Mock(return_value=Mock(return_value=SessionContext())), + ) + monkeypatch.setattr(internal_worker_adapter, "get_settings", Mock(return_value=Mock())) + authority = Mock() + authority.persist_denial = AsyncMock(side_effect=persist_denial) + monkeypatch.setattr( + internal_worker_adapter, + "PreparedArtifactInternalAuthority", + Mock(return_value=authority), + ) + scanner = Mock() + scanner.scan = AsyncMock(return_value=3) + monkeypatch.setattr( + internal_worker_adapter, + "ArtifactPendingWorkScanner", + Mock(return_value=scanner), + ) + publish_put, publish_job = AsyncMock(), AsyncMock() + + assert ( + await internal_worker_adapter.scan_artifact_pending_work( + publish_put, publish_job + ) + == 3 + ) + + scanner.scan.side_effect = AuthorizationDenied( + SimpleNamespace(allowed=False, denial_code="denied") # type: ignore[arg-type] + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="authority denied"): + await internal_worker_adapter.scan_artifact_pending_work( + publish_put, publish_job + ) + + session.rollback.assert_awaited_once_with() + authority.persist_denial.assert_awaited_once_with() + assert order == ["rollback", "restage"] + + def test_internal_artifact_actions_are_active() -> None: assert { action_id: ACTION_BY_ID[action_id].availability From 1aaf35679d5f1fe5513f2fb4277d180cc2521cd3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 19:41:17 +0100 Subject: [PATCH 7/8] test(artifacts): cover concurrent runtime initialization --- ...WS-XINT-002-03-external-review-response.md | 10 ++++ .../reviews/WS-XINT-002-03-pr-trust-bundle.md | 3 + backend/tests/test_artifact_verification.py | 55 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index eeee0203..94f96360 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -79,3 +79,13 @@ Markdown links, and diff integrity passed locally. QA and test-delta reviews passed; the test-delta review specifically confirms the exact rollback then restage assertion and found no weakening. The full PostgreSQL and exact-head coverage proof remains assigned to GitHub Actions. + +## Hosted Backend run 30293817200 + +Every non-coverage gate passed again. Artifact-foundation coverage rose to +89.98 percent, leaving only the two unexecuted lines in the concurrent +process-runtime initializer branch. The final focused regression starts two +initializers at the same barrier, proves the losing bootstrap closes, and then +proves shutdown closes the installed winner. This directly covers the remaining +resource-leak safety branch; the focused test and all 14 verification tests +passed with Ruff and diff integrity. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md index 5efb883d..f5bd402c 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md @@ -53,6 +53,9 @@ expanded; all other ART service actions remain planned and issue no handle. changing CI configuration, thresholds, inclusion, selection, or skips. - GitHub Actions will rerun the full repository suite and coverage at the exact PR head; no local four-hour full run is required. +- The first repair rerun reached 89.98 percent. A final deterministic race test + now covers the only two missed worker lines and proves the losing concurrent + provider bootstrap is closed before use. ## Human review focus diff --git a/backend/tests/test_artifact_verification.py b/backend/tests/test_artifact_verification.py index bc3d8161..35384b75 100644 --- a/backend/tests/test_artifact_verification.py +++ b/backend/tests/test_artifact_verification.py @@ -354,6 +354,61 @@ async def test_process_runtime_closes_bootstrap_when_namespace_claim_fails( bootstrap.close.assert_called_once_with() +@pytest.mark.asyncio +async def test_process_runtime_closes_losing_concurrent_bootstrap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + internal_worker_adapter.shutdown_artifact_internal_runtime() + bootstraps = [Mock(), Mock()] + for bootstrap in bootstraps: + bootstrap.initialize_after_namespace_claim.return_value = Mock() + both_claiming = asyncio.Event() + release_claims = asyncio.Event() + claim_count = 0 + + async def claim_namespace(*_args: object) -> Mock: + nonlocal claim_count + claim_count += 1 + if claim_count == 2: + both_claiming.set() + await release_claims.wait() + return Mock() + + monkeypatch.setattr( + internal_worker_adapter, + "get_settings", + Mock(return_value=SimpleNamespace(artifact_store_backend="local")), + ) + monkeypatch.setattr(internal_worker_adapter, "require_artifact_runtime_eligible", Mock()) + monkeypatch.setattr( + internal_worker_adapter, + "create_artifact_store_bootstrap", + Mock(side_effect=bootstraps), + ) + monkeypatch.setattr( + internal_worker_adapter, + "artifact_storage_namespace_spec", + Mock(side_effect=[Mock(), Mock()]), + ) + monkeypatch.setattr( + internal_worker_adapter, + "validate_artifact_storage_namespace_at_startup", + AsyncMock(side_effect=claim_namespace), + ) + + initializers = [ + asyncio.create_task(internal_worker_adapter.initialize_artifact_internal_runtime()) + for _ in range(2) + ] + await asyncio.wait_for(both_claiming.wait(), timeout=1) + release_claims.set() + await asyncio.gather(*initializers) + + assert sum(bootstrap.close.call_count for bootstrap in bootstraps) == 1 + internal_worker_adapter.shutdown_artifact_internal_runtime() + assert all(bootstrap.close.call_count == 1 for bootstrap in bootstraps) + + @pytest.mark.asyncio async def test_internal_operation_rejects_kind_and_restages_denial( monkeypatch: pytest.MonkeyPatch, From 90bf971069510d4c42be0a92527aecb07a7aca1a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 27 Jul 2026 19:55:36 +0100 Subject: [PATCH 8/8] docs(auth): record passing artifact coverage --- .../reviews/WS-XINT-002-03-external-review-response.md | 7 +++++++ .../reviews/WS-XINT-002-03-pr-trust-bundle.md | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md index 94f96360..d529e098 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-external-review-response.md @@ -89,3 +89,10 @@ initializers at the same barrier, proves the losing bootstrap closes, and then proves shutdown closes the installed winner. This directly covers the remaining resource-leak safety branch; the focused test and all 14 verification tests passed with Ruff and diff integrity. + +## Hosted Backend run 30294890075 + +The exact-head rerun passed every Backend step. Artifact-foundation coverage is +90.02 percent (4,689 statements, 468 missed), satisfying the unchanged 90 +percent floor; `internal_workers.py` is 100 percent covered. Agent Gates passed, +CodeRabbit is green, and no coverage or CI policy was weakened. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md index f5bd402c..98e929c6 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-03-pr-trust-bundle.md @@ -56,6 +56,9 @@ expanded; all other ART service actions remain planned and issue no handle. - The first repair rerun reached 89.98 percent. A final deterministic race test now covers the only two missed worker lines and proves the losing concurrent provider bootstrap is closed before use. +- Exact-head hosted run 30294890075 passed the artifact-foundation gate at + 90.02 percent; `internal_workers.py` is 100 percent covered. Every Backend, + Agent Gates, and CodeRabbit status passed. ## Human review focus