From 36e49e9fc4947cde4aee8cd9834e6d0b8e06a609 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 28 Jul 2026 20:51:28 +0100 Subject: [PATCH 1/2] feat(auth): activate prepared guide source ingest --- .../ACTIVATION_CUSTODY.md | 11 +- .../WS-XINT-002-04A-internal-review.md | 70 ++++++ .../WS-XINT-002-04A-pr-trust-bundle.md | 64 +++++ .../app/modules/artifacts/authorization.py | 183 +++++++++++++- backend/app/modules/artifacts/repository.py | 16 +- .../app/modules/authorization/catalogue.py | 6 +- backend/app/modules/authorization/kernel.py | 56 ++++- backend/app/modules/authorization/policy.py | 1 + backend/app/modules/authorization/prepared.py | 8 + backend/app/modules/authorization/runtime.py | 35 ++- backend/tests/test_artifact_admission.py | 229 +++++++++++++++++- backend/tests/test_audit.py | 1 + backend/tests/test_authorization.py | 104 +++++++- backend/tests/test_guide_artifacts.py | 173 ++++++++++++- docs/operations_authorization_service.md | 16 +- docs/spec_artifact_storage_service.md | 3 +- docs/spec_authorization_service.md | 39 ++- 17 files changed, 954 insertions(+), 61 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-internal-review.md create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md index d8eb3f228..810d93290 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md @@ -41,7 +41,8 @@ mappings, and availability must remain identical. |---|---| | `WS-AUTH-001-ART-02D-INTERNAL` | `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-AUTH-001-ART-03` | `artifact.guide_source.read`, `artifact.guide_source.binding.create` | +| `WS-XINT-002-04A` | `artifact.guide_source.ingest` | | `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | | `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | @@ -52,10 +53,12 @@ mappings, and availability must remain identical. `WS-AUTH-001-ART-CUSTODY` historically transferred 25 rows. WS-XINT-002-01 reconciles the live catalogue by removing the six unused multi-step upload rows and registering three end-to-end bundle/review rows. The resulting 22 rows have -exact owner cardinalities `3/8/3/1/1/1/1/2/2` in the table order above. The +exact owner cardinalities `3/8/2/1/1/1/1/1/2/2` in the table order above. The `OPERATOR` suffix denotes only future activation custody; it grants no Operator -entitlement. All 22 actions remain planned, including independently gated -`artifact.verification_job.retry`, which +entitlement. Eighteen actions remain planned after the three fixed-service ART +actions and `artifact.guide_source.ingest` activate. The independently +gated `artifact.verification_job.retry` +remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 reconciles PostgreSQL parity through migration `0036`; the live catalogue has diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-internal-review.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-internal-review.md new file mode 100644 index 000000000..8c66e000f --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-internal-review.md @@ -0,0 +1,70 @@ +# WS-XINT-002-04A Internal Review + +## Scope reviewed + +Guide-source ingest activation based on planning correction `35b9bffb`: +Project Manager policy/custody, human PREP preparation and consumption, ART-owned +project/draft-guide/snapshot/item locks, final byte-fact evidence, and denial +atomicity. Guide read and binding remain planned. + +## Pre-implementation review + +Architecture, security, product/ops, QA, senior engineering, and CI review found +that the original contract omitted the canonical Project Manager policy file and +ART-owned project/guide lineage locks. Implementation did not bypass those +findings. The correction was split into its own planning commit and expanded the +allowed surface narrowly. Review also clarified that a covered Project Manager +grant follows the canonical scope rule: system-scoped grants cover every project; +project-scoped grants cover only their exact project. + +## Implementation review + +- Architecture: pass with low transaction-ownership reuse risk; no AUTH/ART + boundary violation. +- Security: pass after canonical Project Manager scope clarification; no + blocking authorization finding. +- Product/ops: pass with low wording risk, corrected in the custody table. +- QA: pass with low risk; hosted database proof remains required. +- Senior engineering: pass with low risk after separating the planning commit. +- CI integrity: pass with low risk; no gate, threshold, or runner change. +- Docs: findings on PREP narrative and catalogue counts were corrected. +- Reuse/dedup: pass with a low-risk note about the existing duplicated PREP + request-digest domain literal; no new abstraction required in 04A. +- Test delta: pass after correcting the real-database grant fixture and adding + adapter-specific final-fact and denial proofs. + +The first hosted exact-head run then exposed two stale closed-registry +assertions after guide ingest moved from planned ART-03 custody to active 04A +custody. The contract was corrected to own those exactness surfaces; the audit +active-action set, custody owner counts, canonical custody ledger, and catalogue +totals were updated without weakening an assertion. CI integrity, QA, senior, +docs, reuse, and test-delta tracks were rerun on the corrective delta. Their +valid stale-document findings were also corrected before the final hosted run. +The next hosted run passed semantic-lane custody but reported artifact foundation +coverage at 89.94 percent. Focused safety tests were added for nested-transaction +denial, caller-owned root commit/rollback, and PREP consume-error translation. +They execute eleven previously uncovered production statements, restoring margin +without changing the 90 percent gate. + +## Local evidence + +- Ruff passed for backend application and affected tests. +- Focused authorization catalogue/policy/PREP tests passed. +- The two exact assertions that failed in the first hosted run pass after the + corrective change. +- `tests/test_guide_artifacts.py` passes with the new transaction/error safety + paths and raises focused coverage of ART authorization from 32 to 37 percent. +- `tests/test_guide_artifacts.py` passed, including activated adapter binding, + forged/unissued handle, mismatch, and reuse proofs. +- Stale authorization docs, stale artifact contracts, Markdown links, agent + static gate, and `git diff --check` passed. +- The full database-backed coverage command was intentionally not run locally + because `WORKSTREAM_TEST_DATABASE_URL` is unavailable and the user's machine + is not the full-suite execution environment. + +## Hosted evidence required + +The exact implementation head must pass `Backend / test` and +`Agent Gates / agent-gates`. Hosted CI owns the database-backed focused tests, +repository-wide 78 percent baseline, and authorization/artifact 90 percent +coverage gates. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md new file mode 100644 index 000000000..e21f4d3d9 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md @@ -0,0 +1,64 @@ +# WS-XINT-002-04A PR Trust Bundle + +## Intent + +Activate only `artifact.guide_source.ingest` for an active covered Project +Manager grant through the shared opaque, transaction-bound PREP protocol. + +## Design + +The route obtains a request-local human PREP adapter. Preparation locks the +actor, exact identity link, and matched Project Manager grant before scratch or +body-byte intake. ART later locks the project, draft guide, source snapshot, and +source item, computes byte facts, and consumes the same handle before capacity, +put intent, or provider I/O. The allowed decision digest and protected database +mutation commit in one root transaction. + +## Scope + +- Activates only guide-source ingest and assigns activation custody to + `WS-XINT-002-04A`. +- Adds the existing ingest permission only to Project Manager policy. +- Adds one typed guide-ingest resource context and prepared-kernel path. +- Completes ART-owned project/draft-guide/snapshot/item locking. +- Keeps guide read, guide binding, submissions, reviews, and generic downloads + unavailable. + +## Tests and evidence + +- Catalogue availability/custody and exact role-policy matrix. +- Exact grant permission/project/`FOR UPDATE` request. +- Opaque handle forgery, lineage/request mismatch, complete final fact + projection, and reuse denial. +- Real PostgreSQL proof for wrong-project, revoked-link, revoked-grant, locked + lineage, non-draft lineage, successful admission, and zero denied side + effects. +- Existing canonical PREP tests retain copied/serialized, cross-session/root, + wrong-action/resource, concurrent consume, and replaced-transaction proof. +- The audit active-action set and independent custody/count fixtures prove the + planned-to-active transition exactly; the first hosted failure caught their + stale values and the correction preserves their strict equality checks. +- Transaction tests prove nested PREP scopes deny, successful caller-owned roots + commit once, failed roots roll back once, and consume failures close and deny. +- Local static and focused non-database checks passed; hosted database coverage + and full-suite evidence are pending on the exact PR head. + +## CI integrity + +No workflow, test runner, skip, coverage pragma, threshold, or failure-handling +configuration changes are included. A hosted 89.94 percent artifact-foundation +result was corrected by covering eleven additional production statements. +Required thresholds remain global 78 percent and affected +authorization/artifact subsystems at least 90 percent. + +## Review result + +All required internal tracks completed. Valid planning, test, wording, and +fixture findings were addressed. Remaining notes are low risk and documented in +the internal review record. + +## Human review focus + +Confirm PM-only policy, canonical system/project grant coverage, pre-byte +preparation, final ART-owned draft lineage locks, final resource-context digest, +denial atomicity, and that only guide ingest becomes active. diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 47cc4c292..a6f54e506 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -13,6 +13,7 @@ from app.api.deps.authorization import _authorization_context, get_authorization_actor from app.core.api_controls import request_ids from app.core.hashing import canonical_json_hash +from app.db.session import get_db_session from app.modules.actors.repository import ActorRepository from app.modules.actors.service import ResolvedActor from app.modules.actors.service_identities import ServiceIdentity @@ -44,6 +45,9 @@ PreparedAuthorityScopeKind, AuthorizationDecision, AuthorizationDenied, + GuideSourceIngestResourceContext, + HumanAuthorizationContext, + PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported, ServiceAuthorizationContext, AuthorizationContext, @@ -167,9 +171,182 @@ def close(self) -> None: """Deny-only adapters hold no capability state.""" -def get_guide_artifact_prepared_authorization() -> GuideArtifactPreparedAuthorization: - """Stable 04A activation selector; deny while guide ingest is planned.""" - return DenyGuideArtifactPreparedAuthorization() +class PreparedGuideArtifactAuthorization: + """Activate exact Project Manager guide ingest through AUTH-owned PREP.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._prepared: PreparedAuthorizationService | None = None + self._authorization: AuthorizationService | None = None + self._input: PreparedAuthorizationInput | None = None + self._handle: PreparedAuthorizationHandle | None = None + self._expected: tuple[UUID, UUID, UUID, UUID, str] | None = None + self._actor_profile_id: UUID | None = None + + @asynccontextmanager + async def transaction(self): + """Own the one root transaction shared by authorization and admission.""" + if self._session.in_nested_transaction(): + raise ArtifactAuthorityDeniedError( + "guide prepared authorization transaction is unavailable" + ) + if not self._session.in_transaction(): + async with self._session.begin(): + yield + return + try: + yield + except BaseException: + await self._session.rollback() + raise + else: + await self._session.commit() + + async def prepare( + self, + *, + authorization_context: AuthorizationContext, + project_id: UUID, + guide_id: UUID, + guide_source_snapshot_id: UUID, + guide_source_item_id: UUID, + idempotency_key: UUID, + ) -> PreparedAuthorizationHandle: + """Lock the exact Project Manager grant before any byte intake.""" + if ( + not isinstance(authorization_context, HumanAuthorizationContext) + or self._prepared is not None + or not self._session.in_transaction() + or self._session.in_nested_transaction() + ): + raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") + repository = AdminAuthorizationRepository(self._session) + authorization = AuthorizationService( + self._session, + authorization_context, + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + self._session, + authorization_context, + authorization, + repository, + ) + request_value = guide_ingest_prepared_request_value( + project_id=project_id, + guide_id=guide_id, + guide_source_snapshot_id=guide_source_snapshot_id, + guide_source_item_id=guide_source_item_id, + idempotency_key=idempotency_key, + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=request_value, + ) + try: + handle = await prepared.prepare( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + ) + except (PreparedAuthorizationUnsupported, PreparedAuthorizationHandleInvalid) as exc: + prepared.close() + raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") from exc + except BaseException: + prepared.close() + raise + self._prepared = prepared + self._authorization = authorization + self._input = caller_input + self._handle = handle + self._actor_profile_id = authorization_context.actor_profile_id + self._expected = ( + project_id, + guide_id, + guide_source_snapshot_id, + guide_source_item_id, + guide_ingest_prepared_request_digest( + project_id=project_id, + guide_id=guide_id, + guide_source_snapshot_id=guide_source_snapshot_id, + guide_source_item_id=guide_source_item_id, + idempotency_key=idempotency_key, + ), + ) + return handle + + async def consume( + self, + *, + prepared_authorization: PreparedAuthorizationHandle, + facts: GuideArtifactIngestAuthorityFacts, + ) -> UUID: + """Consume once against locked lineage and server-computed byte facts.""" + if ( + self._prepared is None + or self._authorization is None + or self._input is None + or self._handle is None + or self._expected is None + or self._actor_profile_id is None + or prepared_authorization is not self._handle + or self._expected + != ( + facts.project_id, + facts.guide_id, + facts.guide_source_snapshot_id, + facts.guide_source_item_id, + facts.request_digest, + ) + ): + raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") + prepared = self._prepared + actor_profile_id = self._actor_profile_id + try: + await prepared.consume( + prepared_authorization, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + self._input, + GuideSourceIngestResourceContext( + resource_type="project", + resource_id=facts.project_id, + scope_project_id=facts.project_id, + guide_id=facts.guide_id, + guide_source_snapshot_id=facts.guide_source_snapshot_id, + guide_source_item_id=facts.guide_source_item_id, + operation_identity=facts.operation_identity, + request_digest=facts.request_digest, + sha256=facts.sha256, + byte_count=facts.byte_count, + media_type=facts.media_type, + ), + ) + except (AuthorizationDenied, PreparedAuthorizationHandleInvalid, ValidationError) as exc: + raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") from exc + finally: + self.close() + return actor_profile_id + + def close(self) -> None: + """Invalidate every unconsumed request-local capability.""" + if self._prepared is not None: + self._prepared.close() + self._prepared = None + self._authorization = None + self._input = None + self._handle = None + self._expected = None + self._actor_profile_id = None + + +def get_guide_artifact_prepared_authorization( + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> GuideArtifactPreparedAuthorization: + """Compose the activated request-local guide ingest authority.""" + return PreparedGuideArtifactAuthorization(session) class PreparedArtifactInternalAuthority: diff --git a/backend/app/modules/artifacts/repository.py b/backend/app/modules/artifacts/repository.py index 994373268..184800c01 100644 --- a/backend/app/modules/artifacts/repository.py +++ b/backend/app/modules/artifacts/repository.py @@ -33,6 +33,8 @@ GuideSourceArtifactIngest, GuideSourceSnapshot, GuideSourceSnapshotItem, + Project, + ProjectGuide, ) from app.modules.tasks.models import Submission, WorkstreamTask @@ -226,8 +228,20 @@ async def get_guide_lineage(self, guide_source_item_id: str) -> GuideLineageFact GuideSourceSnapshot, GuideSourceSnapshot.id == GuideSourceSnapshotItem.source_snapshot_id, ) + .join( + ProjectGuide, + and_( + ProjectGuide.id == GuideSourceSnapshot.guide_id, + ProjectGuide.project_id == GuideSourceSnapshot.project_id, + ProjectGuide.version == GuideSourceSnapshot.guide_version, + ), + ) + .join(Project, Project.id == GuideSourceSnapshot.project_id) .where(GuideSourceSnapshotItem.id == guide_source_item_id) - .with_for_update(of=(GuideSourceSnapshotItem, GuideSourceSnapshot)) + .where(ProjectGuide.status == "draft") + .with_for_update( + of=(Project, ProjectGuide, GuideSourceSnapshot, GuideSourceSnapshotItem) + ) ) ).one_or_none() if lineage is None: diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 1002b24e2..a7b25d5cd 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -205,6 +205,7 @@ class ActionOwner(StrEnum): AUTH_ART_05 = "WS-AUTH-001-ART-05" AUTH_ART_06A = "WS-AUTH-001-ART-06A" AUTH_ART_06B = "WS-AUTH-001-ART-06B" + XINT_002_04A = "WS-XINT-002-04A" XINT_002_05A = "WS-XINT-002-05A" XINT_002_07 = "WS-XINT-002-07" @@ -534,10 +535,10 @@ def _active( PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_ART_02D_OPERATOR, ), - _planned( + _active( ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, - ActionOwner.AUTH_ART_03, + ActionOwner.XINT_002_04A, ), _planned( ActionId.ARTIFACT_GUIDE_SOURCE_READ, @@ -687,6 +688,7 @@ def _index_actions( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 42d1d12e5..835177938 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -47,6 +47,7 @@ AuthorizationEvidenceUnavailable, AuthorizationResourceContext, HumanAuthorizationContext, + GuideSourceIngestResourceContext, IdentityLinkStatus, MatchedAuthorityKind, PermissionCatalogueResourceContext, @@ -388,6 +389,27 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) + elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: + if ( + not isinstance(context, HumanAuthorizationContext) + or scope.kind is not PreparedAuthorityScopeKind.PROJECT + or scope.project_id is None + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + locked = await self._admin.lock_request_actor( + context.identity_link_id, context.actor_profile_id + ) + context = self._locked_human_context(locked, context) + grant = await self._admin.find_effective_grant( + context.actor_profile_id, + action.permission_id, + scope_project_id=scope.project_id, + for_update=True, + ) + if grant is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) else: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) lifecycle = self._lifecycle_denial(context) @@ -762,6 +784,24 @@ async def _require_prelocked( matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id matched_project_id = authority.scope_project_id + elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: + denial = self._lifecycle_denial(context) + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + if denial is None and not isinstance( + resource_context, GuideSourceIngestResourceContext + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if denial is None and resource_context.scope_project_id != authority.scope_project_id: + denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + if denial is None and ( + authority.matched_grant_id is None or authority.matched_grant_status != "active" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is None: + matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT + matched_grant_id = authority.matched_grant_id + matched_project_id = authority.scope_project_id else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE return await self._complete_decision( @@ -1151,12 +1191,16 @@ async def _stage_decision( 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", - "project_diagnostic", - }: + if ( + decision.resource_type + in { + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + "project_diagnostic", + } + or decision.action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST + ): after_facts["resource_context_digest"] = decision.resource_context_digest try: await self._audit.add_authority_event( diff --git a/backend/app/modules/authorization/policy.py b/backend/app/modules/authorization/policy.py index 638d6de29..e02123775 100644 --- a/backend/app/modules/authorization/policy.py +++ b/backend/app/modules/authorization/policy.py @@ -63,6 +63,7 @@ PermissionId.PROJECT_REVIEW_POLICY_MANAGE, PermissionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_MANAGE, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, PermissionId.REVIEW_QUEUE_INSPECT, PermissionId.CONTRIBUTION_READ_PROJECT, PermissionId.COMPENSATION_AWARD_READ, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 52f0036dd..a94ebe9c9 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -23,6 +23,7 @@ ArtifactPendingWorkResourceContext, ArtifactPutAttemptResourceContext, ArtifactVerificationJobResourceContext, + GuideSourceIngestResourceContext, AuthorizationContext, AuthorizationDecision, AuthorizationResourceContext, @@ -257,4 +258,11 @@ def _scope_from_resource( artifact_resource_type=resource.resource_type, artifact_resource_id=resource.resource_id, ) + if action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST and isinstance( + resource, GuideSourceIngestResourceContext + ): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 7dcf66e3e..2ac9cdb6a 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -21,9 +21,7 @@ ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: "sufficiency_report", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: "submission_artifact_policy_collection", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: "submission_artifact_policy", - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ( - "post_submit_checker_policy_setup" - ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ("post_submit_checker_policy_setup"), } @@ -253,9 +251,7 @@ class ProjectDiagnosticReadResourceContext(BaseModel): target_exists: bool target_binding_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") source_snapshot_id: UUID | None = None - source_snapshot_hash: str | None = Field( - default=None, pattern=r"^sha256:[0-9a-f]{64}$" - ) + source_snapshot_hash: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") @model_validator(mode="after") def require_canonical_shape(self): @@ -525,6 +521,32 @@ class ArtifactPutAttemptResourceContext(BaseModel): execution_generation: int = Field(gt=0) +class GuideSourceIngestResourceContext(BaseModel): + """Exact locked guide lineage and server-owned byte facts for ingest.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_source_snapshot_id: UUID + guide_source_item_id: UUID + operation_identity: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + request_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(ge=0) + media_type: str = Field(min_length=1, max_length=255) + + @model_validator(mode="after") + def require_exact_lineage(self): + """Keep the final resource bound to one concrete project lineage.""" + if self.resource_id != self.scope_project_id: + raise ValueError("guide ingest project scope must match resource") + if len({self.guide_source_item_id, self.guide_source_snapshot_id, self.guide_id}) != 3: + raise ValueError("guide ingest lineage identifiers must be distinct") + return self + + class ArtifactVerificationJobResourceContext(BaseModel): """Exact fenced verification-job facts composed by ART from locked rows.""" @@ -581,6 +603,7 @@ def bind_page_size(self): | ProjectRoleGrantReadResourceContext | ProjectRoleGrantIssueResourceContext | ProjectRoleGrantRevokeResourceContext + | GuideSourceIngestResourceContext | ArtifactPutAttemptResourceContext | ArtifactVerificationJobResourceContext | ArtifactPendingWorkResourceContext diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 6ea0c4a2b..24f2a5aa3 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -54,6 +54,10 @@ ArtifactStoreUnavailableError, ) from app.modules.artifacts.repository import ArtifactRepository +from app.modules.artifacts.authorization import ( + PreparedGuideArtifactAuthorization, + guide_ingest_prepared_request_digest, +) from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, ArtifactInternalResourceType, @@ -84,6 +88,7 @@ ) from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.models import AdminRoleGrant from app.modules.projects.models import ( GuideSourceArtifactIngest, EffectiveProjectSubmissionArtifactPolicy, @@ -2910,9 +2915,7 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( GuideArtifactAdmissionRequest( project_id=uuid4(), guide_id=UUID(lineage.guide_id), - guide_source_snapshot_id=UUID( - lineage.guide_source_snapshot_id - ), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), guide_source_item_id=UUID(item_id), source=source, operation_identity=_guide_operation(item_id), @@ -2935,9 +2938,7 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( GuideArtifactAdmissionRequest( project_id=UUID(lineage.project_id), guide_id=UUID(lineage.guide_id), - guide_source_snapshot_id=UUID( - lineage.guide_source_snapshot_id - ), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), guide_source_item_id=UUID(item_id), source=source, operation_identity=_guide_operation(item_id), @@ -3014,6 +3015,207 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( await engine.dispose() +async def test_guide_admission_consumes_real_project_manager_prep_atomically( + admission_database_env: str, + tmp_path: Path, +) -> None: + """Bind one real PM capability to locked lineage and server-owned bytes.""" + settings = _settings(tmp_path) + namespace = _namespace(settings) + context = _context() + engine = create_async_engine(admission_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session: + project_id, item_id = await _seed_guide( + session, + context=context, + content_hash="sha256:" + "f" * 64, + media_type="application/octet-stream", + ) + lineage = await ArtifactRepository(session).get_guide_lineage(item_id) + assert lineage is not None + await session.rollback() + bootstrap_grant_id = uuid4() + session.add( + AdminRoleGrant( + id=bootstrap_grant_id, + target_actor_profile_id=str(context.actor_profile_id), + role="access_administrator", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_system_principal="workstream:system:bootstrap", + grant_reason="guide ingest fixture bootstrap", + granted_at=datetime.now(UTC), + ) + ) + await session.flush() + await session.execute( + text( + "update authority_control set bootstrap_completed=true, " + "bootstrap_grant_id=:grant_id, version=1 where id=1" + ), + {"grant_id": bootstrap_grant_id}, + ) + project_manager_grant_id = uuid4() + session.add( + AdminRoleGrant( + id=project_manager_grant_id, + target_actor_profile_id=str(context.actor_profile_id), + role="project_manager", + scope_type="project", + scope_project_id=project_id, + status="active", + version=1, + granted_by_actor_profile_id=str(context.actor_profile_id), + granted_by_admin_role_grant_id=bootstrap_grant_id, + grant_reason="guide ingest authorization proof", + granted_at=datetime.now(UTC), + ) + ) + await session.commit() + idempotency_key = uuid4() + denied_authority = PreparedGuideArtifactAuthorization(session) + with pytest.raises( + ArtifactAuthorityDeniedError, + match="guide artifact ingest is unavailable", + ): + async with denied_authority.transaction(): + await denied_authority.prepare( + authorization_context=context, + project_id=uuid4(), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + idempotency_key=uuid4(), + ) + assert await _count(session, ArtifactAdmissionScope) == 0 + assert await _count(session, ArtifactAdmissionCharge) == 0 + assert await _count(session, ArtifactPutAttempt) == 0 + assert await _count(session, AuditEvent) == 0 + await session.rollback() + + link = await session.get(ActorIdentityLink, str(context.identity_link_id)) + assert link is not None + link.status = "revoked" + link.revoked_by = str(context.actor_profile_id) + link.revoked_at = datetime.now(UTC) + link.revoked_reason = "guide ingest denial proof" + await session.commit() + revoked_link_authority = PreparedGuideArtifactAuthorization(session) + with pytest.raises(ArtifactAuthorityDeniedError): + async with revoked_link_authority.transaction(): + await revoked_link_authority.prepare( + authorization_context=context, + project_id=UUID(project_id), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + idempotency_key=uuid4(), + ) + assert await _count(session, ArtifactPutAttempt) == 0 + assert await _count(session, AuditEvent) == 0 + await session.rollback() + link = await session.get(ActorIdentityLink, str(context.identity_link_id)) + assert link is not None + link.status = "active" + link.revoked_by = None + link.revoked_at = None + link.revoked_reason = None + link.reactivated_by = str(context.actor_profile_id) + link.reactivated_at = datetime.now(UTC) + link.reactivation_reason = "guide ingest test restoration" + await session.commit() + + grant = await session.get(AdminRoleGrant, project_manager_grant_id) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = str(context.actor_profile_id) + grant.revoked_by_admin_role_grant_id = bootstrap_grant_id + grant.revoked_reason = "guide ingest denial proof" + grant.revoked_at = datetime.now(UTC) + await session.commit() + revoked_grant_authority = PreparedGuideArtifactAuthorization(session) + with pytest.raises(ArtifactAuthorityDeniedError): + async with revoked_grant_authority.transaction(): + await revoked_grant_authority.prepare( + authorization_context=context, + project_id=UUID(project_id), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + idempotency_key=uuid4(), + ) + assert await _count(session, ArtifactPutAttempt) == 0 + assert await _count(session, AuditEvent) == 0 + await session.rollback() + session.add( + AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=str(context.actor_profile_id), + role="project_manager", + scope_type="project", + scope_project_id=project_id, + status="active", + version=1, + granted_by_actor_profile_id=str(context.actor_profile_id), + granted_by_admin_role_grant_id=bootstrap_grant_id, + grant_reason="guide ingest authorization replacement", + granted_at=datetime.now(UTC), + ) + ) + await session.commit() + authority = PreparedGuideArtifactAuthorization(session) + async with minted_source( + tmp_path / "real-guide-prep", + b"authorized guide", + media_type="application/octet-stream", + ) as source: + async with authority.transaction(): + handle = await authority.prepare( + authorization_context=context, + project_id=UUID(project_id), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + idempotency_key=idempotency_key, + ) + result = await ArtifactAdmissionService(session, settings, namespace).admit( + GuideArtifactAdmissionRequest( + project_id=UUID(project_id), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + source=source, + operation_identity=_guide_operation(item_id), + request_digest=guide_ingest_prepared_request_digest( + project_id=UUID(project_id), + guide_id=UUID(lineage.guide_id), + guide_source_snapshot_id=UUID(lineage.guide_source_snapshot_id), + guide_source_item_id=UUID(item_id), + idempotency_key=idempotency_key, + ), + ), + guide_prepared_authorization=authority, + prepared_authorization=handle, + existing_transaction=True, + ) + assert not session.in_transaction() + assert await session.get(ArtifactPutAttempt, str(result.attempt_id)) is not None + staged = await session.scalar( + select(GuideSourceArtifactIngest).where( + GuideSourceArtifactIngest.source_item_id == item_id + ) + ) + assert staged is not None + assert staged.actor_profile_id == str(context.actor_profile_id) + finally: + await engine.dispose() + + async def test_human_admission_revalidates_exact_active_profile_and_link( admission_database_env: str, tmp_path: Path, @@ -3146,6 +3348,14 @@ async def test_guide_admission_facts_lock_snapshot_and_item( assert facts is not None mutations = ( + ( + "update projects set status = 'active' where id = :project_id", + {"project_id": facts.project_id}, + ), + ( + "update project_guides set status = 'active' where id = :guide_id", + {"guide_id": facts.guide_id}, + ), ( "update guide_source_snapshot_items " "set media_type = 'application/json' where id = :item_id", @@ -3178,6 +3388,13 @@ async def test_guide_admission_facts_lock_snapshot_and_item( assert await _count(assertion_session, ArtifactAdmissionScope) == 0 assert await _count(assertion_session, ArtifactAdmissionCharge) == 0 assert await _count(assertion_session, ArtifactPutAttempt) == 0 + await assertion_session.execute( + text("update project_guides set status = 'active' where id = :guide_id"), + {"guide_id": facts.guide_id}, + ) + await assertion_session.flush() + assert await ArtifactRepository(assertion_session).get_guide_lineage(item_id) is None + await assertion_session.rollback() finally: await engine.dispose() diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 86f9c214a..ab38421af 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -193,6 +193,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, } artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index c6a65f69f..60b44fbe8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -184,6 +184,7 @@ AuthorizationDenialCode, AuthorizationEvidenceUnavailable, HumanAuthorizationContext, + GuideSourceIngestResourceContext, IdentityLinkStatus, PreparedAuthorizationHandleInvalid, PreparedAuthorizationInput, @@ -738,9 +739,7 @@ async def verified_human(): base_url="http://testserver", ) as client: response = await client.get( - path.format( - project_id=uuid4(), guide_id=uuid4(), report_id=uuid4(), policy_id=uuid4() - ), + path.format(project_id=uuid4(), guide_id=uuid4(), report_id=uuid4(), policy_id=uuid4()), headers={"Authorization": "Bearer test"}, ) @@ -1580,8 +1579,8 @@ async def consume_rate() -> None: ), "artifact.guide_source.ingest": ( "artifact.guide_source.ingest", - "WS-AUTH-001-ART-03", - "planned", + "WS-XINT-002-04A", + "active", ), "artifact.guide_source.read": ( "artifact.guide_source.read", @@ -1880,6 +1879,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -1917,17 +1917,19 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionOwner.AUTH_ART_05, ActionOwner.AUTH_ART_06A, ActionOwner.AUTH_ART_06B, + ActionOwner.XINT_002_04A, ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07, } } == { ActionOwner.AUTH_ART_02D_OPERATOR: 8, ActionOwner.AUTH_ART_02D_INTERNAL: 3, - ActionOwner.AUTH_ART_03: 3, + ActionOwner.AUTH_ART_03: 2, ActionOwner.AUTH_ART_04B: 1, ActionOwner.AUTH_ART_05: 1, ActionOwner.AUTH_ART_06A: 1, ActionOwner.AUTH_ART_06B: 2, + ActionOwner.XINT_002_04A: 1, ActionOwner.XINT_002_05A: 1, ActionOwner.XINT_002_07: 2, } @@ -1964,14 +1966,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 33 + == 34 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 45 + == 44 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2119,7 +2121,8 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - expected_owner_counts = { "WS-AUTH-001-ART-02D-OPERATOR": 8, "WS-AUTH-001-ART-02D-INTERNAL": 3, - "WS-AUTH-001-ART-03": 3, + "WS-AUTH-001-ART-03": 2, + "WS-XINT-002-04A": 1, "WS-XINT-002-05A": 1, "WS-AUTH-001-ART-04B": 1, "WS-AUTH-001-ART-05": 1, @@ -2164,7 +2167,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, 33 active actions, and\n45 planned actions" in operations + "71 PermissionIds, 78 ActionIds, 34 active actions, and\n44 planned actions" in operations ) @@ -2289,6 +2292,7 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None project.effective_policy.read project.update project.archive project.guide.manage project.effective_policy.manage project.task.manage project.review_policy.manage project.role_grant.read project.role_grant.manage + artifact.guide_source.ingest review.queue.inspect contribution.read_project compensation.award.read audit.read""".split(), AdminRole.FINANCE_AUTHORITY: """project.read contribution.read_project @@ -3260,6 +3264,7 @@ def __init__(self, context: HumanAuthorizationContext) -> None: self.grant_id = uuid4() self.control_calls = 0 self.grant_calls = 0 + self.grant_requests: list[tuple[tuple[object, ...], dict[str, object]]] = [] self.target_calls = 0 async def lock_control(self): @@ -3269,8 +3274,9 @@ async def lock_control(self): async def lock_request_actor(self, identity_link_id, actor_profile_id): return await self.lock_actor_self(actor_profile_id, identity_link_id) - async def find_effective_grant(self, *_args, **_kwargs): + async def find_effective_grant(self, *args, **kwargs): self.grant_calls += 1 + self.grant_requests.append((args, kwargs)) return SimpleNamespace(id=self.grant_id, status="active") async def lock_eligible_human(self, actor_profile_id): @@ -3770,6 +3776,82 @@ async def test_prepared_admin_consume_reuses_exact_locked_grant_without_requery( assert len(evidence.events) == 1 +@pytest.mark.asyncio +async def test_prepared_guide_ingest_binds_exact_project_and_locked_manager_grant(): + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + authorization, evidence = _runtime_service(context, session=session) + facts = _PreparedAdminFacts(context) + authorization._admin = facts # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, # type: ignore[arg-type] + ) + project_id = uuid4() + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value={"project_id": str(project_id)} + ) + handle = await prepared.prepare( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + ) + assert (facts.calls, facts.grant_calls) == (1, 1) + assert facts.grant_requests == [ + ( + (context.actor_profile_id, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST), + {"scope_project_id": project_id, "for_update": True}, + ) + ] + + def resource(scope_project_id: UUID) -> GuideSourceIngestResourceContext: + return GuideSourceIngestResourceContext( + resource_type="project", + resource_id=scope_project_id, + scope_project_id=scope_project_id, + guide_id=uuid4(), + guide_source_snapshot_id=uuid4(), + guide_source_item_id=uuid4(), + operation_identity="sha256:" + "b" * 64, + request_digest="sha256:" + "c" * 64, + sha256="sha256:" + "d" * 64, + byte_count=17, + media_type="application/octet-stream", + ) + + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + resource(uuid4()), + ) + assert evidence.events == [] + decision = await prepared.consume( + handle, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + caller_input, + resource(project_id), + ) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == facts.grant_id + assert decision.matched_scope_project_id == project_id + assert (facts.calls, facts.grant_calls) == (1, 1) + assert len(evidence.events) == 1 + assert evidence.events[0].project_id == str(project_id) + assert evidence.events[0].after_facts is not None + assert evidence.events[0].after_facts["resource_context_digest"] == ( + decision.resource_context_digest + ) + + @pytest.mark.asyncio async def test_prepared_project_scope_rejects_system_and_other_project_without_consuming(): context = _runtime_context() diff --git a/backend/tests/test_guide_artifacts.py b/backend/tests/test_guide_artifacts.py index 498d398a0..1009ae2a3 100644 --- a/backend/tests/test_guide_artifacts.py +++ b/backend/tests/test_guide_artifacts.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from typing import Any @@ -32,8 +33,10 @@ GuideArtifactIngestAuthorityFacts, ) from app.modules.artifacts.authorization import DenyGuideArtifactPreparedAuthorization +from app.modules.artifacts.authorization import PreparedGuideArtifactAuthorization from app.modules.artifacts.authorization import get_artifact_authorization_context from app.modules.artifacts.authorization import get_guide_artifact_prepared_authorization +from app.modules.artifacts.authorization import guide_ingest_prepared_request_digest from app.modules.artifacts.service import ( ArtifactAdmissionRelationshipError, ArtifactAdmissionService, @@ -48,6 +51,8 @@ ActorStatus, HumanAuthorizationContext, IdentityLinkStatus, + PreparedAuthorizationInput, + PreparedAuthorizationHandleInvalid, ) from app.modules.projects.router import ingest_guide_source_artifact from app.modules.projects.router import router as projects_router @@ -325,10 +330,10 @@ async def source() -> AsyncIterator[bytes]: @pytest.mark.asyncio -async def test_default_guide_authority_denies_final_consumption() -> None: - """Keep both PREP boundaries unavailable until AUTH installs 04A.""" - authority = get_guide_artifact_prepared_authorization() - assert type(authority) is DenyGuideArtifactPreparedAuthorization +async def test_activated_guide_authority_rejects_unissued_consumption() -> None: + """Compose the 04A adapter while rejecting any unissued capability.""" + authority = get_guide_artifact_prepared_authorization(SimpleNamespace()) # type: ignore[arg-type] + assert type(authority) is PreparedGuideArtifactAuthorization facts = GuideArtifactIngestAuthorityFacts( project_id=PROJECT_ID, guide_id=GUIDE_ID, @@ -349,6 +354,166 @@ async def test_default_guide_authority_denies_final_consumption() -> None: authority.close() +@pytest.mark.asyncio +async def test_activated_guide_authority_rejects_nested_transaction() -> None: + """Keep final PREP consumption bound to the caller-owned root transaction.""" + session = SimpleNamespace(in_nested_transaction=lambda: True) + authority = PreparedGuideArtifactAuthorization(session) # type: ignore[arg-type] + + with pytest.raises( + ArtifactAuthorityDeniedError, + match="prepared authorization transaction is unavailable", + ): + async with authority.transaction(): + pytest.fail("nested transaction must deny before entering the body") + + +@pytest.mark.asyncio +async def test_activated_guide_authority_finishes_existing_root_transaction() -> None: + """Commit successful admission roots and roll back failed roots exactly once.""" + + class RootSession: + def __init__(self) -> None: + self.commits = 0 + self.rollbacks = 0 + + def in_nested_transaction(self) -> bool: + return False + + def in_transaction(self) -> bool: + return True + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + + session = RootSession() + authority = PreparedGuideArtifactAuthorization(session) # type: ignore[arg-type] + async with authority.transaction(): + pass + assert (session.commits, session.rollbacks) == (1, 0) + + with pytest.raises(RuntimeError, match="admission failed"): + async with authority.transaction(): + raise RuntimeError("admission failed") + assert (session.commits, session.rollbacks) == (1, 1) + + +@pytest.mark.asyncio +async def test_activated_guide_authority_binds_every_final_fact_and_rejects_reuse() -> None: + """Keep the adapter's lineage/request binding exact and byte facts server-owned.""" + + class CapturingPrepared: + def __init__(self) -> None: + self.calls: list[tuple[Any, ...]] = [] + self.closed = False + + async def consume(self, *values: Any) -> None: + self.calls.append(values) + + def close(self) -> None: + self.closed = True + + idempotency_key = uuid4() + actor_id = uuid4() + request_digest = guide_ingest_prepared_request_digest( + project_id=PROJECT_ID, + guide_id=GUIDE_ID, + guide_source_snapshot_id=SNAPSHOT_ID, + guide_source_item_id=ITEM_ID, + idempotency_key=idempotency_key, + ) + handle = object.__new__(PreparedAuthorizationHandle) + prepared = CapturingPrepared() + authority = PreparedGuideArtifactAuthorization(SimpleNamespace()) # type: ignore[arg-type] + authority._prepared = prepared # type: ignore[assignment] + authority._authorization = SimpleNamespace() # type: ignore[assignment] + authority._input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value={"project_id": str(PROJECT_ID)}, + ) + authority._handle = handle + authority._actor_profile_id = actor_id + authority._expected = (PROJECT_ID, GUIDE_ID, SNAPSHOT_ID, ITEM_ID, request_digest) + facts = GuideArtifactIngestAuthorityFacts( + project_id=PROJECT_ID, + guide_id=GUIDE_ID, + guide_source_snapshot_id=SNAPSHOT_ID, + guide_source_item_id=ITEM_ID, + operation_identity="sha256:" + "a" * 64, + request_digest=request_digest, + sha256="sha256:" + "b" * 64, + byte_count=19, + media_type="application/octet-stream", + ) + + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.consume( + prepared_authorization=object.__new__(PreparedAuthorizationHandle), + facts=facts, + ) + for mismatched in ( + replace(facts, project_id=uuid4()), + replace(facts, guide_id=uuid4()), + replace(facts, guide_source_snapshot_id=uuid4()), + replace(facts, guide_source_item_id=uuid4()), + replace(facts, request_digest="sha256:" + "c" * 64), + ): + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.consume(prepared_authorization=handle, facts=mismatched) + assert prepared.calls == [] + assert await authority.consume(prepared_authorization=handle, facts=facts) == actor_id + assert prepared.closed is True + assert len(prepared.calls) == 1 + consumed_handle, action_id, caller_input, resource = prepared.calls[0] + assert consumed_handle is handle + assert action_id.value == "artifact.guide_source.ingest" + assert caller_input.idempotency_key == idempotency_key + assert resource.model_dump() == { + "resource_type": "project", + "resource_id": PROJECT_ID, + "scope_project_id": PROJECT_ID, + "guide_id": GUIDE_ID, + "guide_source_snapshot_id": SNAPSHOT_ID, + "guide_source_item_id": ITEM_ID, + "operation_identity": "sha256:" + "a" * 64, + "request_digest": request_digest, + "sha256": "sha256:" + "b" * 64, + "byte_count": 19, + "media_type": "application/octet-stream", + } + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.consume(prepared_authorization=handle, facts=facts) + + class FailingPrepared(CapturingPrepared): + async def consume(self, *values: Any) -> None: + del values + raise PreparedAuthorizationHandleInvalid + + failed_prepared = FailingPrepared() + failed_authority = PreparedGuideArtifactAuthorization(SimpleNamespace()) # type: ignore[arg-type] + failed_authority._prepared = failed_prepared # type: ignore[assignment] + failed_authority._authorization = SimpleNamespace() # type: ignore[assignment] + failed_authority._input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value={"project_id": str(PROJECT_ID)}, + ) + failed_authority._handle = handle + failed_authority._actor_profile_id = actor_id + failed_authority._expected = ( + PROJECT_ID, + GUIDE_ID, + SNAPSHOT_ID, + ITEM_ID, + request_digest, + ) + with pytest.raises(ArtifactAuthorityDeniedError, match="ingest is unavailable"): + await failed_authority.consume(prepared_authorization=handle, facts=facts) + assert failed_prepared.closed is True + + @pytest.mark.asyncio async def test_guide_ingest_uses_server_commitment_and_existing_put_path( tmp_path: Path, diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 6f9a4a307..8298dea48 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -708,11 +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, 33 active actions, and -45 planned actions after AUTH-11C1 activates three project setup-diagnostic and +Catalogue totals are 71 PermissionIds, 78 ActionIds, 34 active actions, and +44 planned actions after AUTH-11C1 activates three project setup-diagnostic and three draft/effective-policy diagnostic read actions. The exact route mapping -is in `docs/spec_authorization_service.md`. The other 19 ART actions remain planned, including every Operator -artifact action. +is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates only +guide-source ingest; the other 18 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. @@ -771,9 +772,10 @@ teardown commit it, or commit AUTH evidence separately from feature state. The existing `AuthorityClaimHandle` is a separate idempotency-reservation contract, not this prepared authorization handle. -PREP currently supports actor-self profile update and the eight active -AdminRoleGrant-backed administrative mutations only; it cuts over no production -feature command. Callers begin and own one root transaction, call `prepare`, +PREP currently supports actor-self profile update, the eight active +AdminRoleGrant-backed administrative mutations, the three active fixed-service +ART actions, and Project Manager `artifact.guide_source.ingest`. No other +production feature command is cut over. Callers begin and own one root transaction, call `prepare`, lock their participant rows, compose final typed facts, call `consume` with the independently expected ActionId and the same strict request/idempotency input, flush participant work, and commit once. AUTH never commits in dependency diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index d1a2c9348..240f52fe7 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -800,7 +800,8 @@ assigns the server-owned canonical media type `application/octet-stream`; it does not trust the request `Content-Type` header. After preparation, its durable admission transaction locks the exact project/guide/snapshot/item -lineage, revalidates `artifact.guide_source.ingest`, and records a +lineage, requires the locked guide to remain `draft`, revalidates +`artifact.guide_source.ingest`, and records a non-authoritative `GuideSourceArtifactIngest` from the server-computed digest, byte count, and media type. Legacy snapshot-item hashes are descriptors during the phased cutover and are not admission truth. The staged row cannot activate diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index e37f8c7ca..d7348d529 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 AUTH-11C1: 33 active actions -and 45 planned rows. AUTH-10A added five project-role read/manage rows; +The closed action registry contains 78 rows after WS-XINT-002-04A: 34 active +actions and 44 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 project identity and actor-context read rows: two are active under 11B, three @@ -254,7 +254,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, 19 artifact actions, canonical +three Operator recovery actions, 18 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 @@ -378,6 +378,14 @@ may execute that ActionId while it remains planned; activation waits until ART-04A-C publish the complete hidden surface and WS-XINT-002-05A consumes its exact evidence. +WS-XINT-002-04A activates only `artifact.guide_source.ingest`. The existing +permission belongs only to the Project Manager role and is evaluated through an +active covered grant: system-scoped or exact-project. Its prepared capability locks the +actor, exact identity link, and matched grant before byte intake; final +consumption binds the ART-locked project, draft guide, snapshot, item, +operation/request digests, and server-computed byte facts. Guide-source read and +binding creation remain planned. + Every row requires AUTH-07A's registry and AUTH-07B's kernel first. A row with an Operator principal also requires its AUTH-08 grant definition; a row with a fixed service principal also requires AUTH-09A's static matrix, AUTH-09B's provisioned service @@ -407,7 +415,8 @@ A mapping is not a permission alias. |---|---| | `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-04A` | Active: `artifact.guide_source.ingest` | +| `WS-AUTH-001-ART-03` | `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `WS-XINT-002-05A` | `artifact.submission_bundle.prepare` | | `WS-AUTH-001-ART-04B` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create` | @@ -416,8 +425,9 @@ 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. WS-XINT-002-03 activates only the three internal -service actions; the other 19 ART actions remain planned and unavailable. +Operator grant or entitlement. WS-XINT-002-03 activates the three internal +service actions and WS-XINT-002-04A activates guide-source ingest; the other 18 +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 @@ -437,7 +447,7 @@ remain planned and unavailable, and add no migration. | `artifact.recovery_attempt.read` | `artifact.recovery_attempt.read` | Operator | recovery attempt | `02D` | | `artifact.audit.read` | `artifact.audit.read` | Operator | artifact audit scope | `02D` | | `operations.artifact_storage_admission.read` | `operations.status.read` | Operator | deployment artifact-storage namespace | `02D` | -| `artifact.guide_source.ingest` | `artifact.guide_source.ingest` | authorized project actor | guide-source snapshot item | `03` | +| `artifact.guide_source.ingest` | `artifact.guide_source.ingest` | exact covered Project Manager | guide-source snapshot item | `03` | | `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | guide-source snapshot item | `03` | | `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `WS-XINT-002-05A` | | `artifact.guide_source.binding.create` | `artifact.binding.create` | fixed binding service | guide-source snapshot item | `03` | @@ -596,9 +606,10 @@ AUTH locks AuthorityControl first when final-admin safety applies -> route or service command commits once ``` -The PREP foundation currently issues handles only for -`actor.profile.update_self` and the eight active AdminRoleGrant-backed -administrative mutations. Actor-self preparation locks the exact caller +The PREP foundation issues handles for `actor.profile.update_self`, the eight +active AdminRoleGrant-backed administrative mutations, the three active fixed +ART service actions, and Project Manager `artifact.guide_source.ingest`. +Actor-self preparation locks the exact caller profile and then its exact identity link. Administrative preparation locks `AuthorityControl(id=1)`, the exact request profile, exact request identity link, and one deterministic effective AdminRoleGrant. The caller supplies an @@ -638,6 +649,14 @@ 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. +WS-XINT-002-04A adds the first active human feature consumer. Guide ingest +preparation locks the caller profile, exact identity link, and one active +covered Project Manager AdminRoleGrant before byte intake. ART then locks the +project, draft guide, snapshot, and item and supplies operation/request digests +plus server-computed digest, byte count, and media type for final consumption. +The allowed decision evidence carries the matched grant/project and exact final +resource-context digest in the same transaction as admission. + Service identity, static service-action matrix membership, and action availability are immutable code-owned validations after the service profile and link locks; they are not database rows or lock targets. Existing actor-self, From 36556c79b649bf55a54003bfa4782e17d71656cc Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 29 Jul 2026 05:41:14 +0100 Subject: [PATCH 2/2] docs(auth): resolve 04a review findings --- ...S-XINT-002-04A-external-review-response.md | 30 +++++++++++++++++++ .../WS-XINT-002-04A-pr-trust-bundle.md | 4 +++ backend/tests/test_authorization.py | 2 +- docs/operations_authorization_service.md | 2 +- docs/spec_authorization_service.md | 4 +-- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-external-review-response.md diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-external-review-response.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-external-review-response.md new file mode 100644 index 000000000..201e7e328 --- /dev/null +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-external-review-response.md @@ -0,0 +1,30 @@ +# WS-XINT-002-04A External Review Response + +## CodeRabbit + +The first review against `main` produced two actionable documentation findings. + +- The ART custody reconciliation still described nine activation custodians + after guide ingest introduced the tenth owner. The operations text and its + independent exact-substring test now require ten. +- The registry summary mixed the total artifact inventory with the remaining + planned count. It now states that the 19 other artifact actions comprise 18 + planned actions plus active `artifact.guide_source.ingest`; the separate + 18-remaining statement is unchanged. + +The description-template warning was addressed by replacing the PR body with +the repository trust-bundle structure. The bot's docstring warning does not +identify a missing production docstring and is checked independently by the +unchanged hosted docstring gate; no documentation threshold was weakened. + +## Verification + +- stale authorization documentation: passed +- stale artifact contracts: passed +- Markdown links: passed +- Ruff for the affected exactness test: passed +- independent ART custody documentation fixture: passed +- `git diff --check`: passed + +The exact corrected head still requires the hosted Backend gate and CodeRabbit +incremental review to complete. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md index e21f4d3d9..16c300607 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/reviews/WS-XINT-002-04A-pr-trust-bundle.md @@ -57,6 +57,10 @@ All required internal tracks completed. Valid planning, test, wording, and fixture findings were addressed. Remaining notes are low risk and documented in the internal review record. +CodeRabbit's two exact documentation findings after retargeting to `main` were +fixed and recorded in `WS-XINT-002-04A-external-review-response.md`. The final +incremental review and exact-head hosted Backend result remain required. + ## Human review focus Confirm PM-only policy, canonical system/project grant coverage, pre-byte diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 60b44fbe8..d82b7461c 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2161,7 +2161,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - operations = (repository_root / "docs/operations_authorization_service.md").read_text( encoding="utf-8" ) - assert "all 22 ART rows to nine exact activation custodians" in operations + assert "all 22 ART rows to ten exact activation custodians" in operations assert "all 19 REV\nrows to seven exact AUTH custodians" in operations assert "transfer adds no migration; the later WS-XINT-002-01" in operations assert "does not grant Operator" in operations diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 8298dea48..9721b80bc 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -684,7 +684,7 @@ 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 +reconciliation assigns all 22 ART rows to ten exact activation custodians and all 19 REV rows to seven exact AUTH custodians without changing mappings or planned availability. The REV owner cardinalities are `2/5/3/1/1/5/2` for `WS-AUTH-001-REV-05`, `WS-AUTH-001-REV-06`, `WS-AUTH-001-REV-07`, diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index d7348d529..de0630fe8 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -253,8 +253,8 @@ provisioning actions without activating a route; AUTH-09B activates only `actor.service.provision`, AUTH-09C activates only `actor.profile.read` and `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, 18 artifact actions, canonical +other registry rows cover three planned Operator recovery actions, 19 other +artifact actions—18 planned plus active `artifact.guide_source.ingest`—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