diff --git a/.gitignore b/.gitignore index b83d2226..451ec0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target/ +/.netlify diff --git a/CHANGELOG.md b/CHANGELOG.md index 18aada75..eee54559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- Active PR #248 adds PostgreSQL 18 durability for immutable normalized longitudinal observation evidence: exact source identity, separate validity/recorded/received/ingested clocks, explicit membership shares whose deferred transaction invariant totals 10,000 basis points, exact replay, tenant isolation, and fail-closed rebinding/immutability. This entry is active-PR evidence only and is not a protected-main or release claim. -- Active PR #287 rejects padded or otherwise noncanonical published narrative, style-mapping, interpretation-unit, and approved-selection references before deterministic rendering. Numeric score authority and the protected-main narrative boundary are unchanged; this is active-PR evidence only and not a release claim. +- Hosted dual-proof account-link write, recover, and unlink commands so a transport adapter can authorize both proofs, persist the append-only history, recover the same product-owned participant from a still-valid Keyverse account proof after restart, and end that current binding from the same still-valid proof. Expired proofs fail before persist, lookup, or unlink. A rebound current subject cannot be unlinked with an ended subject's proof. A later account-linked capability is bound to the current `link_event_ref`, so unlink, rebound, or a later same-subject attach under a new event invalidates a previously issued grant. HTTP transport and live token verification remain target. +- PostgreSQL 18 append-only participant identity-link persistence so a dual-proof account link, unlink, and relink survive process restart without rewriting the product-owned participant reference. Persist applies each link and then its matching ends in one transaction, exact replay is idempotent, and exact replay also restores or clears the derived current projection so operator repair cannot leave a missing or stale unique enforcer. Conflicting evidence fails closed, and one unterminated issuer-scoped subject cannot belong to two participants even when the derived current projection is missing. A returning account recovers the same `participant_ref` from that history. A link-end cannot attach to another participant's link. +- Merged #248 adds PostgreSQL 18 durability for immutable normalized longitudinal observation evidence: exact source identity, separate validity/recorded/received/ingested clocks, explicit membership shares whose deferred transaction invariant totals 10,000 basis points, exact replay, tenant isolation, and fail-closed rebinding/immutability. +- Merged #287 rejects padded or otherwise noncanonical published narrative, style-mapping, interpretation-unit, and approved-selection references before deterministic rendering. Numeric score authority and the protected-main narrative boundary are unchanged. - Personal result export delivery authorization (`authorize_result_export_read`) reuses the stored-record `ReadOwnResult` check on the product-owned participant and immutable result, then requires the export's result snapshot reference and copied participant reference to match that exact snapshot. A cross-tenant caller fails closed with the ordinary result-authorization denial before export-binding details are evaluated, so a mismatched export is not an existence oracle. No new permission or persistence is introduced; authorized HTTP transport ships through merged `src/result_export_http.rs` and `openapi/result-exports.yaml`. This is the post-#231 export-delivery guard (ADR-0010 provenance; ADR-0003 tenant-bound authorization). - Personal result export copies one immutable snapshot into JSON and a human-readable report so a purchaser can archive the same Extraversion estimate, standard error, and version provenance they were shown. The owner participant reference stays in both artifacts. Abstained or failed constructs keep their disposition and do not receive an invented score. Approved limitation text is required. - Product and technical gap baseline records the exact protected-main snapshot, current participant-visible gaps, all open repository PRs, issue #260 dependency, and the next executable delivery loop. @@ -73,6 +75,7 @@ All notable product and architecture changes are recorded here. Releases use imm ### Fixed +- Hosted account-link recover now keeps a loaded participant only when its current tenant, issuer, and subject still match the still-valid proof, so a concurrent unlink+relink cannot hand back a rebound identity. - Stale shorter assessment-session command history now fails closed instead of rewinding the current-state projection, so a later Pause/Resume still reloads after a rejected Activate-only persist. - Same-enrollment longitudinal ingest now keeps a later iOS copy and a later Android ping as distinct source identities instead of treating them as a rewrite of the first row. - Outbox delivery-lease expiry recovery now classifies liveness from the PostgreSQL clock, so a future caller timestamp cannot steal a still-live exclusive lease. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2985a8ec..c79aeb68 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -37,7 +37,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`: immutable release manifest, exact version/digest/locale/item set, fail-closed Draft/Review/Published/Suspended/Retired lifecycle, idempotent publication events, and new-session eligibility | | Quick and Deep assessment paths | PRD §3.1, §9 | TRD §5–7; immutable release/item-delivery boundary | ADR-0005, ADR-0010 | **Active PR #261** binds ordered Quick/Deep item subsets to one immutable release and copies release locale/provenance; path persistence, item-delivery transport, conversion, and scoring integration remain Target | | Instrument publication requires intended-use scientific/right/locale evidence | PRD §6, §9, §10 | Measurement Governance; publication evidence gate | ADR-0004, ADR-0013, ADR-0019 | **Implemented** policy gate and immutable evidence provenance in `src/instrument.rs`; each real instrument still requires its own rights/locale/scientific evidence artifacts before publication | -| Optional Keyverse account linking | PRD §3.1, §9.7 | TRD §10; UML identity-link lifecycle | ADR-0003, ADR-0020 | **Partially implemented**: issuer-scoped first-link fail-closed domain primitive in `src/participant.rs`; append-only unlink/relink/recovery history, persistence, audit, and transport remain Target | +| Optional Keyverse account linking | PRD §3.1, §9.7 | TRD §10; UML identity-link lifecycle | ADR-0003, ADR-0020 | **Partially implemented**: issuer-scoped first-link fail-closed domain primitive in `src/participant.rs`; **Active PR** #206 adds hosted dual-proof write/recover/unlink commands in `src/account_link_write.rs`, keeps a recovered participant only when the current tenant/issuer/subject still match the proof, and ends that binding from a still-valid current proof; **Active PR** #222 binds any later account-linked capability to the current `link_event_ref` so unlink or rebound invalidates a previously issued grant; **Active PR** #236 proves that unique event bind against a later same-subject relink and against reloaded history; **Active PR** #158 remains the store-wide restore-reconcile vehicle. Both include #147 persist.; HTTP/Keyverse token verification remain Target | | Cross-cutting tenant/task authorization | PRD §7, §9 | TRD §11; Security/Data | ADR-0001, ADR-0003 | **Implemented** fail-closed domain gate in `src/authorization.rs` binds consent operations to participant-owned `ConsentLedger` / `ManageOwnConsent`; persistence/policy-adapter/public-transport integration remains Target | | Purpose-specific consent | PRD §5, §9.6 | TRD §12 | ADR-0006 | **Implemented** domain contract in `src/consent.rs` plus `migrations/0005_consent_lifecycle.sql` / `src/postgres_consent.rs` purpose-specific ledgers; HTTP transport remains Target | | Explicit research contribution + withdrawal | PRD §5 | TRD §12, §14–15 | ADR-0006, ADR-0007 | **Implemented** product-domain lifecycle in `src/consent.rs`; dataset snapshot/release integration is Target | @@ -76,7 +76,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Only Published release accepts new sessions | TRD §7 | `PublicationState::accepts_new_sessions` in `src/instrument.rs`; protected-main `start_created_assessment_session_from_stored_release` locks publication evidence and persists HTTP create/reload; load still restores created identity without re-checking current eligibility | Command HTTP and the rest of the assessment transport remain missing | | Publication event replay is idempotent/conflicting reuse fails closed | TRD §7 | `src/instrument.rs` | durable DB uniqueness/concurrency test | | Published instrument requires exact-version scientific evidence | Measurement Governance; ADR-0019 | `src/instrument.rs` binds approved evidence status, provenance/scope, mandatory evidence references, validity window, and immutable release identity before publication/reactivation | persistence/API publication integration and real instrument-specific evidence artifacts | -| Optional account linking does not rewrite historical participant/result identity | ADR-0003, ADR-0020 | `src/participant.rs` issuer-scoped first-link primitive preserves stable participant ID | append-only identity-link persistence + unlink/relink/recovery audit tests | +| Optional account linking does not rewrite historical participant/result identity | ADR-0003, ADR-0020 | `src/participant.rs` issuer-scoped first-link primitive preserves stable participant ID; **Active PR** #206 `src/postgres_participant_identity_link.rs` persists and reloads that history without rewriting `participant_ref`; `src/account_link_write.rs` authorizes both proofs before persist, recovers from a still-valid account proof, drops a loaded participant whose current binding no longer matches that proof, ends a matching current binding from a still-valid account proof, and binds any later account-linked capability to that current `link_event_ref` so unlink invalidates the grant; **Active PR** #236 proves that unique event bind after same-subject relink and against reloaded history | HTTP unlink/relink transport, live Keyverse verification, and backup/restore evidence | | Sensitive authorization is tenant- and task-bound | TRD §11; Security/Data | `src/authorization.rs` fail-closed authorization context/gates bind consent operations to participant ownership | policy adapter + route/repository integration + cross-tenant E2E tests | | Research consent separate from service consent | TRD §12; Research Governance | `src/consent.rs` | public API/UI negative test | | Research withdrawal preserves evidence | TRD §12–15; Research Governance | `src/consent.rs` | release-pipeline exclusion test | @@ -123,8 +123,11 @@ src/lib.rs ├── item_delivery.rs # sequence-aware delivery evidence without confidential response data ├── longitudinal_observation.rs # longitudinal clocks, identity, and membership-share evidence ├── narrative.rs # deterministic Personality Style identity/key +├── account_link.rs # dual-proof authorization before participant identity mutation +├── account_link_write.rs # Active PR dual-proof persist/recover/unlink plus link-event-bound account capability (not protected-main truth) ├── participant.rs # stable participant identity + issuer-scoped optional Keyverse account link ├── postgres_consent.rs # PostgreSQL purpose-specific consent ledger persistence +├── postgres_participant_identity_link.rs # Active PR append-only identity-link persist/reload (not protected-main truth) ├── postgres_data_rights.rs # PostgreSQL data-rights request and local propagation persistence ├── postgres_data_rights_processing.rs # PostgreSQL identity-verified data-rights operation persistence ├── postgres_health.rs # PostgreSQL major/write-readiness and relation-integrity probe @@ -158,26 +161,20 @@ migrations/ └── 0019_inbox_claim_expiry_guard.sql ``` -Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, remaining public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal enrollment persistence, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. +Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, remaining public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal enrollment persistence, participant identity-link history transport, runtime health transports/metrics, and Measurement Workbench orchestration. ### Active implementation work that is not protected-main truth +**Active PR** #206 hosted dual-proof write/recover/unlink commands are not protected-main truth until an unchanged reviewed/check-clean head is integrated; they share persist files with this lane. +**Active PR** #236 proves that a same-subject relink binds account-linked capability authorization to the current unique `link_event_ref`, including accept against `load_participant_identity_history`, so an ended subject's proof cannot unlink or authorize after the rebound. Merged #249 `authorize_result_export_read` is protected-main delivery-guard evidence in `src/result_export_authorization.rs`: it authorizes the stored participant/result with existing `ReadOwnResult` (ADR-0010 export provenance; ADR-0003 tenant-bound authorization) and then requires the export's `result_snapshot_ref` and copied `participant_ref` to match that exact immutable snapshot. Cross-tenant callers fail closed with the ordinary result-authorization denial before export-binding details are evaluated. No new permission or persistence was introduced; authorized HTTP transport ships through merged #256. Merged #231 personal result export is protected-main domain evidence. `ResultExport::from_snapshot` copies the stored construct scores, standard errors, dispositions, owner `participant_ref`, and version provenance into a JSON document and a human-readable report. Approved limitation text is required so the report cannot imply diagnosis, employment fitness, or a type score. Padded export aliases are rejected at this boundary without rewriting shared reference trimming used by consent and other domains. The snapshot is not mutated. Do not fold unrelated persistence into this domain slice. -**Active PR** #257 authorized immutable personal result reads are not protected-main truth until an unchanged reviewed/check-clean head is integrated. `src/result_http.rs` accepts only exact `GET /v1/results/{result_ref}` targets, checks server-owned participant/result authorization before identity comparison, returns no result existence oracle to an unauthorized actor, and serializes the stored immutable provenance without recomputation. PostgreSQL result loading and cross-tenant HTTP E2E remain required before this becomes a complete product path. +Merged #257 authorized immutable personal result reads are protected-main transport evidence: `src/result_http.rs` accepts only exact `GET /v1/results/{result_ref}` targets, checks server-owned participant/result authorization before identity comparison, returns no result existence oracle to an unauthorized actor, and serializes the stored immutable provenance without recomputation. PostgreSQL result loading and cross-tenant HTTP E2E remain required before this becomes a complete product path. **Active PR** #284 durable accepted response-event persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` keep the accepted mid-session ledger prefix durable across process restart with exact replay classification, contiguous sequence recovery, immutable provenance, and fail-closed migration/reference contracts. Public response HTTP transport and completed snapshot reload remain separate slices. -**Active PR** #301 consolidated public-release identity privacy gate is not protected-main truth until an unchanged reviewed/check-clean head is integrated. It consolidates the identity-column denylist, cell-value scanner, separator/prefix hardening, structured-value fail-closed behavior, and the `IdentityInventoryUnavailable` fail-closed inventory contract required by issue #260. A missing or blank effective restricted-identity inventory must fail closed before public fixture approval rather than being read as "nothing to match". -Merged #249 `authorize_result_export_read` is protected-main delivery-guard evidence in `src/result_export_authorization.rs`: it authorizes the stored participant/result with existing `ReadOwnResult` (ADR-0010 export provenance; ADR-0003 tenant-bound authorization) and then requires the export's `result_snapshot_ref` and copied `participant_ref` to match that exact immutable snapshot. Cross-tenant callers fail closed with the ordinary result-authorization denial before export-binding details are evaluated. No new permission or persistence was introduced. - -Merged #256 authorized personal result export HTTP is protected-main transport evidence: `src/result_export_http.rs` and `openapi/result-exports.yaml` bind the stored result to the authenticated participant/resource scope and preserve the immutable export provenance from `src/result_export.rs`. - +Closed-unmerged #301 consolidated public-release identity privacy gate is not protected-main truth; its fail-closed goal survives as open issue #260, which requires a fresh independently reviewed slice that fails closed when the authoritative restricted-identity inventory is absent or blank. Merged #77 terminal data-rights completion persistence is protected-main evidence: `src/postgres_data_rights_completion.rs` and `migrations/0024_data_rights_completion.sql` persist request-bound completion evidence/time and immutable tenant-bound `data_rights_retained_scope_evidence` rows for deletion scopes that remain legally retained. Exact replay is idempotent; operation, completion identity/time, tenant, request kind/state, and retained-scope rebinding fail closed. This slice does not claim dependent-system execution has completed merely because local terminal evidence exists. - Closed-unmerged #220 public research-fixture identity-column rejection is no longer an active lane; its fail-closed goal survives as open issue #260, which requires a fresh independently reviewed slice that fails closed when the authoritative restricted-identity inventory is absent or blank. - -Merged #231 personal result export is protected-main domain evidence. `ResultExport::from_snapshot` copies the stored construct scores, standard errors, dispositions, owner `participant_ref`, and version provenance into a JSON document and a human-readable report. Approved limitation text is required so the report cannot imply diagnosis, employment fitness, or a type score. Padded export aliases are rejected at this boundary without rewriting shared reference trimming used by consent and other domains. The snapshot is not mutated. HTTP `POST /v1/results/{result_ref}/exports` remains Target/active #256. Do not fold unrelated persistence into this domain slice. - -Merged #225 anonymous-session resource authorization compares the verified actor to the supplied participant tenant/owner and session and applies a lifecycle command only after that check. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` remains Target. Append-only identity-link history persistence remains a later slice. HTTP transport remains outside this slice. Persist-backed session HTTP, exclusive outbox delivery leases, longitudinal observation clocks/membership, and claim-next scoring-job poll are already on protected main. +Merged #225 anonymous-session resource authorization compares the verified actor to the supplied participant tenant/owner and session and applies a lifecycle command only after that check. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` remains Target. Append-only identity-link history transport remains a later slice. HTTP transport remains outside this slice. Persist-backed session HTTP, exclusive outbox delivery leases, longitudinal observation clocks/membership, and claim-next scoring-job poll are already on protected main. ## 5. ADR traceability by concern @@ -266,6 +263,10 @@ CI should validate linked documentation paths and status/name consistency now an ## 10. References +International Organization for Standardization & International Electrotechnical Commission. (2019). *IT security and privacy—A framework for identity management—Part 1: Terminology and concepts* (ISO/IEC 24760-1:2019). + +National Institute of Standards and Technology. (2025). *Digital identity guidelines* (NIST Special Publication 800-63-4). https://doi.org/10.6028/NIST.SP.800-63-4 + Nottingham, M., Wilde, E., & Dalal, S. (2023). *Problem Details for HTTP APIs* (RFC 9457). Internet Engineering Task Force. https://doi.org/10.17487/RFC9457 OpenAPI Initiative. (2025). *OpenAPI Specification, Version 3.2.0*. diff --git a/docs/adr/0020-append-only-participant-identity-link-history.md b/docs/adr/0020-append-only-participant-identity-link-history.md index f1a9b2e3..fc20bb70 100644 --- a/docs/adr/0020-append-only-participant-identity-link-history.md +++ b/docs/adr/0020-append-only-participant-identity-link-history.md @@ -19,13 +19,13 @@ Active PR #29 (`fix: scope participant account links by identity issuer`) adds t A nullable current subject link on a participant projection is therefore useful as an application view, but it is insufficient as the future physical persistence model. In-place replacement would lose who linked or unlinked an account, when the relationship changed, why it changed, and which historical sessions/results were valid under which operational identity context. It would also make identity recovery vulnerable to accidental historical rewrites and would encourage coupling product records to an identity-provider object lifecycle. -This ADR is a mixture of current and target state. Protected main provides stable participant identity plus a fail-closed subject-link primitive. PR #29 provides issuer-scoped first-link behavior on an active branch. Append-only persistence, unlink/relink/recovery transport, and operational evidence remain target behavior until corresponding source, migrations, tests, and release evidence are merged. +This ADR is a mixture of current and target state. Protected main provides stable participant identity plus an issuer-scoped fail-closed first-link primitive, including dual-proof authorization at the application boundary. Append-only persistence is Active PR work. HTTP unlink/relink/recovery transport, live Keyverse token verification, and backup/restore evidence remain target behavior until corresponding source, migrations, tests, and release evidence are merged. ### Implementation status -- `IMPLEMENTED_ON_PROTECTED_MAIN`: stable product-owned `participant_ref`; optional first subject link; distinct proof references; exact-replay idempotency; conflicting replay rejection; no silent second link. -- `IMPLEMENTED_ON_ACTIVE_PR`: PR #29 adds opaque `identity_issuer` binding and issuer-aware replay equality to the first-link domain primitive. -- `PLANNED`: append-only identity-link persistence, durable transport, unlink/relink/recovery lifecycle, concurrency arbitration, data-rights execution, backup/restore evidence, and live Keyverse verification. +- `IMPLEMENTED_ON_PROTECTED_MAIN`: stable product-owned `participant_ref`; issuer-scoped first subject link; distinct proof references; exact-replay idempotency; conflicting replay rejection; no silent second link; dual-proof authorization in `src/account_link.rs`. +- `IMPLEMENTED_ON_ACTIVE_PR`: PR #206 adds hosted dual-proof write/recover/unlink commands in `src/account_link_write.rs` on the append-only persist/reload path, keeps a recovered participant only when the current tenant/issuer/subject still match the proof, and ends that binding from a still-valid current proof. PR #222 binds any later account-linked capability to the current `link_event_ref` so unlink or rebound invalidates a previously issued grant. PR #236 proves that unique event bind after a later same-subject relink, including accept against reloaded history. Prefer #206 for persist unlink, #222 for the grant/accept gate, and #236 for the unique-invariant proof. PR #158 adds store-wide restore reconcile of the derived current projection. Prefer #206 over #176 for write/recover/unlink and #158 for restore reconcile. Keep #202 as the inspect-line unlink vehicle. Do not merge #176, #160, #147, #133, #124, or #114. +- `PLANNED`: durable HTTP transport, unlink/relink HTTP commands, concurrency arbitration beyond the participant row lock, data-rights execution, backup/restore evidence, and live Keyverse verification. ## Decision @@ -160,7 +160,7 @@ Before account-link persistence is considered GA-complete, exact-head evidence m - security tests for account-link/recovery takeover and cross-tenant access; - exact deployment-profile recovery evidence before any GA/SLO/RPO/RTO claim involving this persistence. -Protected main satisfies only the domain-level stable first-link portion of this decision and does not yet bind issuer. PR #29 implements issuer-scoped first-link validation/storage/replay on an active branch. Persistence, transport, recovery, unlink/relink, concurrency, and audit evidence remain target work until separately implemented, reviewed, and merged. +Protected main satisfies the domain-level issuer-scoped first-link portion of this decision, including dual-proof authorization. Active PR persist must apply each link and then its matching ends in one transaction so a restart can write a complete unlink+relink aggregate. The hosted write/recover/unlink commands authorize both proofs before persist, recover a returning account from a still-valid authenticated proof, and end a matching current binding from that proof. After load, recover keeps that participant only when the current tenant, issuer, and subject still match the proof so unlink+relink cannot hand back a rebound identity. Hosted unlink reloads stored history before authorization so a stale in-memory record cannot end a rebound current binding. HTTP transport, live Keyverse verification, and backup/restore evidence remain target work until separately implemented, reviewed, and merged. ## Alternatives considered @@ -205,7 +205,7 @@ Until persistence/transport are implemented, protected main provides only the st ## Follow-up work - Psychometrics Commons: implement the physical append-only identity-link migration and repository transaction boundary. -- Psychometrics Commons: add unlink/relink/recovery commands with explicit idempotency, authority, and audit evidence. +- Psychometrics Commons: add unlink/relink/recovery commands with explicit idempotency, authority, and audit evidence. Dual-proof persist, returning-account recover, and hosted unlink commands exist on Active PR #206, including post-load current-binding rejection and fail-closed rebound unlink. PR #222 adds `grant_account_linked_capability` / `accept_account_linked_capability` bound to the current `link_event_ref`. PR #236 proves the unique event bind after same-subject relink. HTTP unlink/relink transport remains open and must re-check that grant before treating a recovered `participant_ref` as an account capability. - Psychometrics Commons: add Keyverse adapter contract without direct database coupling. - Psychometrics Commons: integrate data-rights propagation and restricted research-linkage separation tests. - Psychometrics Commons: add transaction/concurrency/crash/backup/restore and public-release leakage tests. @@ -227,11 +227,18 @@ Any reversal requires a superseding ADR and an explicit migration/rollback or ro - Product requirements: `docs/PRD.md` anonymous participation, optional account linking, research contribution, and data-rights requirements. - Technical requirements: `docs/TRD.md` identity, tenant authorization, consent/data-rights, persistence, and integration contracts. -- Protected-main domain evidence: `src/participant.rs` and its contract tests on the protected-main baseline named by `docs/TRACEABILITY.md`; this baseline does not yet bind issuer. -- Active-PR domain evidence: PR #29 adds issuer-scoped first-link validation/storage/replay and remains `IMPLEMENTED_ON_ACTIVE_PR` until merged. +- Protected-main domain evidence: `src/participant.rs`, `src/account_link.rs`, and their contract tests on the protected-main baseline named by `docs/TRACEABILITY.md`. +- Active-PR persistence evidence: PR #206 `migrations/0022_participant_identity_link.sql`, `src/postgres_participant_identity_link.rs`, and `src/account_link_write.rs` remain `IMPLEMENTED_ON_ACTIVE_PR` until merged. - Logical data view: `docs/architecture/ERD.md`. - Behavioral view: `docs/architecture/UML.md`. - Security/privacy views: `docs/architecture/SECURITY_AND_DATA.md`, `docs/THREAT_MODEL.md`. - Operations/recovery: ADR-0017 and `docs/OPERABILITY.md`. - Maturity/status mapping: `docs/TRACEABILITY.md` and `docs/ROADMAP.md`. -- Machine-readable transport and physical-schema artifacts: none claimed until corresponding implementation exists. +- Machine-readable transport artifacts: none claimed until a hosted identity-link API exists. +- Physical-schema artifacts: Active PR `migrations/0022_participant_identity_link.sql` is not protected-main truth. + +## References + +International Organization for Standardization & International Electrotechnical Commission. (2019). *IT security and privacy—A framework for identity management—Part 1: Terminology and concepts* (ISO/IEC 24760-1:2019). + +National Institute of Standards and Technology. (2025). *Digital identity guidelines* (NIST Special Publication 800-63-4). https://doi.org/10.6028/NIST.SP.800-63-4 diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index a6b8290e..4f4df876 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -90,6 +90,18 @@ The protected-main slice persists: The slice does **not** persist publication-event history, bound scientific evidence records, HTTP publication transport, or session-creation integration. Those remain Target unless separately evidenced on protected main. +## Active PR participant identity-link physical schema + +PR #206 adds hosted write/recover/unlink commands in `src/account_link_write.rs` on top of `migrations/0022_participant_identity_link.sql` and `src/postgres_participant_identity_link.rs`. Prefer #206 over #176 for write/recover/unlink and #158 for store-wide restore reconcile. Keep #202 as the inspect-line unlink vehicle. Do not merge #176, #160, #147, #133, #124, or #114. The slice is **Active PR**, not protected-main truth. It stores: + +- immutable `assessment_participant` identity (`participant_ref`, `tenant_ref`, `created_at_unix_ms`); +- append-only `participant_identity_link` rows for accepted dual-proof account links; +- append-only `participant_identity_link_end` rows that end a specific historical link without editing it; +- derived `current_participant_identity_link` projection enforcing one current link per participant and one current issuer-scoped subject per tenant; +- composite foreign keys so a link-end or current projection cannot point at another participant's link. + +Exact replay is idempotent and reconciles the derived current projection so a missing or stale unique enforcer is restored or cleared. Conflicting event identity fails closed. Reload reconstructs the domain `ParticipantRecord` so a buyer who linked an anonymous assessment to an account still sees that link after restart. A returning account recovers the same `participant_ref` from unterminated issuer-scoped history even when the derived current projection is missing. Hosted write/recover/unlink commands authorize both current proofs before persist, recover from a still-valid authenticated account proof, and end a matching current binding from that proof. After load, recover keeps the participant only when the current tenant, issuer, and subject still match that proof. Hosted unlink reloads stored history before authorization so a stale in-memory record cannot end a rebound current binding. Active PR #236 proves a later same-subject relink cannot keep a pre-unlink account-linked capability, including when accept runs against reloaded history. HTTP account-link transport and live Keyverse verification remain Target. + ## Logical-to-physical mapping rule A logical entity is classified as physical only when all of the following exist on the named protected-main baseline: diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index bf825bae..5d40dcdc 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -448,13 +448,13 @@ erDiagram The target ERD deliberately includes several logical entities that are not yet physical tables: - `instrument_release` is the locale-specific publication identity already owned by `src/instrument.rs`. Physical `migrations/0006_instrument_release.sql` persists that one-row aggregate (immutable manifest columns plus `publication_state`); HTTP publication transport remains Target. -- `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Protected main persists requested-state identity plus local propagation and processing evidence. **Active PR #77** adds terminal `completion_evidence_ref` / `completed_at_unix_ms` fields and immutable `data_rights_retained_scope_evidence` child rows for deletion scopes that must remain retained; this is not protected-main truth until #77 is integrated. Dependent-system execution remains Target. -- Physical `assessment_session` exists only on Active PR #218 (`migrations/0014_assessment_session.sql`): Created identity (participant, release, version, digest, locale, creation time) plus a current-state projection. New sessions start only from a stored published release locked in the same transaction; first insert through `persist_assessment_session` takes the same lock; when that lock finds a missing or unpublished release, persist still classifies an exact stored Created row as duplicate; exact replay of an already stored start or Created row still returns the original session after a later persist Suspend or Retire; reconstitution is load, not start. Physical `assessment_session_command` (`migrations/0016_assessment_session_command.sql`) stores append-only command history so later states reload by replaying Activate/Pause/Resume. A shorter persist than already stored fails closed and does not rewind that projection. Command persist locks the header row with `SELECT … FOR UPDATE` before inserting or counting commands. Load reconstitutes created identity without re-checking current publication eligibility. Persist-backed HTTP create/reload sits on this start path. Protected main still has the `src/session.rs` aggregate only. +- `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Protected main persists requested-state identity plus local propagation and processing evidence. Merged #77 adds terminal `completion_evidence_ref` / `completed_at_unix_ms` fields and immutable `data_rights_retained_scope_evidence` child rows for deletion scopes that must remain retained (`migrations/0024_data_rights_completion.sql`). Dependent-system execution remains Target. +- Physical `assessment_session` exists on protected main (`migrations/0014_assessment_session.sql`): Created identity (participant, release, version, digest, locale, creation time) plus a current-state projection. New sessions start only from a stored published release locked in the same transaction; first insert through `persist_assessment_session` takes the same lock; when that lock finds a missing or unpublished release, persist still classifies an exact stored Created row as duplicate; exact replay of an already stored start or Created row still returns the original session after a later persist Suspend or Retire; reconstitution is load, not start. Physical `assessment_session_command` (`migrations/0016_assessment_session_command.sql`) stores append-only command history so later states reload by replaying Activate/Pause/Resume. A shorter persist than already stored fails closed and does not rewind that projection. Command persist locks the header row with `SELECT … FOR UPDATE` before inserting or counting commands. Load reconstitutes created identity without re-checking current publication eligibility. Persist-backed HTTP create/reload sits on this start path. - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still Target. -- `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by Active PR #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. -- `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. Persist/reload of `assessment_participant` remains Target. Append-only identity-link history persist remains Active PR #52; it is not protected-main truth until integrated. Do not name closed #158, #147, #133, #114, or #124 as the current persist landing. +- `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by merged #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. +- `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` current-link fields are an application-domain first-link projection, not the future mutable persistence source of truth, and persist/reload of `assessment_participant` remains Target. Active PR #206 `migrations/0022_participant_identity_link.sql` persists append-only link and link-end rows plus a derived current projection, treats unterminated history as the lookup/uniqueness source of truth, reconciles the derived current projection on exact replay, and exposes hosted dual-proof write/recover/unlink commands that keep a recovered participant only when the current binding still matches the proof and end that binding from a still-valid current proof. The in-memory account-linked capability is bound to that current `link_event_ref` and is not a separate table; Active PR #236 proves accept fails closed after a later same-subject relink, including against reloaded history. HTTP transport remains Target. - `longitudinal_enrollment`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. No TEPP analytical kernel is duplicated here. -- `integration_outbox`, `integration_delivery_attempt`, `integration_inbox`, and `integration_consumption` reflect `src/integration.rs` domain semantics. Outbox/inbox/delivery-attempt tables are on protected main. Exclusive outbox delivery-lease columns (`lease_worker_ref`, `lease_ref`, `lease_fencing_token`, `lease_expires_at`, `delivery_lease_generation`) and database-clock expiry recovery exist only on Active PR #60 until merged. `integration_consumption` pending/processing/completed/quarantined persistence is already on protected main. +- `integration_outbox`, `integration_delivery_attempt`, `integration_inbox`, and `integration_consumption` reflect `src/integration.rs` domain semantics. Outbox/inbox/delivery-attempt tables and the exclusive outbox delivery-lease columns (`lease_worker_ref`, `lease_ref`, `lease_fencing_token`, `lease_expires_at`, `delivery_lease_generation`) with database-clock expiry recovery are on protected main (`migrations/0013_outbox_delivery_lease.sql`). `integration_consumption` pending/processing/completed/quarantined persistence is already on protected main. This section is a maturity guard: a logical entity may be architecture-complete without being as-built database evidence. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index c308b054..4182680e 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -380,9 +380,30 @@ sequenceDiagram C->>A: link request + anonymous-session proof + Keyverse assertion A->>K: validate issuer/audience/signature/expiry/anti-replay context K-->>A: validated subject claims + A->>A: persist_authorized_account_link dual-proof write command A->>DB: verify tenant/ownership + append Active ParticipantIdentityLink DB-->>A: immutable link evidence / current-link projection A-->>C: link complete + P->>C: return with the same Keyverse account + C->>A: recover request + current Keyverse assertion + A->>A: recover_participant_for_authenticated_account + A->>DB: load unterminated issuer-scoped subject + DB-->>A: candidate participant plus current binding + A->>A: keep only when current tenant/issuer/subject still match the proof + A->>A: grant_account_linked_capability bound to current link_event_ref + A-->>C: recovered participant plus account-linked capability, or unused-account none + C->>A: later account-privileged command + current Keyverse assertion + grant + A->>A: accept_account_linked_capability re-checks current link_event_ref + A-->>C: accept or NoCurrentBinding + P->>C: choose to unlink the current Keyverse account + C->>A: unlink request + current Keyverse assertion + A->>A: persist_authorized_account_unlink + A->>DB: reload history and append link-end when current binding still matches + DB-->>A: immutable unlink evidence / cleared current-link projection + A-->>C: unlink complete; later recover with the same proof returns none + C->>A: replay the pre-unlink account-linked capability + A->>A: accept_account_linked_capability + A-->>C: NoCurrentBinding — unlink invalidated the grant Note over DB: Unlink/relink/recovery appends lifecycle evidence; historical response/result identifiers are never rewritten ``` diff --git a/migrations/0022_participant_identity_link.sql b/migrations/0022_participant_identity_link.sql new file mode 100644 index 00000000..ed40ae60 --- /dev/null +++ b/migrations/0022_participant_identity_link.sql @@ -0,0 +1,163 @@ +CREATE TABLE IF NOT EXISTS assessment_participant ( + participant_ref TEXT CONSTRAINT assessment_participant_participant_ref_not_null NOT NULL + CONSTRAINT assessment_participant_participant_ref_format_check CHECK ( + participant_ref = btrim(participant_ref) + AND participant_ref <> '' + AND NOT ( + participant_ref ~ '[[:digit:]]' + AND participant_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + tenant_ref TEXT CONSTRAINT assessment_participant_tenant_ref_not_null NOT NULL + CONSTRAINT assessment_participant_tenant_ref_format_check CHECK ( + tenant_ref = btrim(tenant_ref) + AND tenant_ref <> '' + AND NOT ( + tenant_ref ~ '[[:digit:]]' + AND tenant_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + created_at_unix_ms BIGINT CONSTRAINT assessment_participant_created_at_unix_ms_not_null NOT NULL + CONSTRAINT assessment_participant_created_at_unix_ms_positive_check CHECK ( + created_at_unix_ms > 0 + ), + created_at TIMESTAMPTZ CONSTRAINT assessment_participant_created_at_not_null NOT NULL + DEFAULT clock_timestamp(), + CONSTRAINT assessment_participant_pkey PRIMARY KEY (participant_ref) +); + +CREATE TABLE IF NOT EXISTS participant_identity_link ( + identity_link_ref TEXT CONSTRAINT participant_identity_link_identity_link_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_identity_link_ref_format_check CHECK ( + identity_link_ref = btrim(identity_link_ref) + AND identity_link_ref <> '' + AND NOT ( + identity_link_ref ~ '[[:digit:]]' + AND identity_link_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + participant_ref TEXT CONSTRAINT participant_identity_link_participant_ref_not_null NOT NULL, + tenant_ref TEXT CONSTRAINT participant_identity_link_tenant_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_tenant_ref_format_check CHECK ( + tenant_ref = btrim(tenant_ref) + AND tenant_ref <> '' + AND NOT ( + tenant_ref ~ '[[:digit:]]' + AND tenant_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + identity_issuer TEXT CONSTRAINT participant_identity_link_identity_issuer_not_null NOT NULL + CONSTRAINT participant_identity_link_identity_issuer_format_check CHECK ( + identity_issuer = btrim(identity_issuer) + AND identity_issuer <> '' + AND NOT ( + identity_issuer ~ '[[:digit:]]' + AND identity_issuer ~ '^[[:digit:]+,.eE-]+$' + ) + ), + identity_subject_ref TEXT CONSTRAINT participant_identity_link_identity_subject_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_identity_subject_ref_format_check CHECK ( + identity_subject_ref = btrim(identity_subject_ref) + AND identity_subject_ref <> '' + AND NOT ( + identity_subject_ref ~ '[[:digit:]]' + AND identity_subject_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + anonymous_proof_ref TEXT CONSTRAINT participant_identity_link_anonymous_proof_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_anonymous_proof_ref_format_check CHECK ( + anonymous_proof_ref = btrim(anonymous_proof_ref) + AND anonymous_proof_ref <> '' + AND NOT ( + anonymous_proof_ref ~ '[[:digit:]]' + AND anonymous_proof_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + authenticated_proof_ref TEXT CONSTRAINT participant_identity_link_authenticated_proof_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_authenticated_proof_ref_format_check CHECK ( + authenticated_proof_ref = btrim(authenticated_proof_ref) + AND authenticated_proof_ref <> '' + AND NOT ( + authenticated_proof_ref ~ '[[:digit:]]' + AND authenticated_proof_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + linked_at_unix_ms BIGINT CONSTRAINT participant_identity_link_linked_at_unix_ms_not_null NOT NULL + CONSTRAINT participant_identity_link_linked_at_unix_ms_positive_check CHECK ( + linked_at_unix_ms > 0 + ), + created_at TIMESTAMPTZ CONSTRAINT participant_identity_link_created_at_not_null NOT NULL + DEFAULT clock_timestamp(), + CONSTRAINT participant_identity_link_pkey PRIMARY KEY (identity_link_ref), + CONSTRAINT participant_identity_link_participant_fk FOREIGN KEY (participant_ref) + REFERENCES assessment_participant (participant_ref), + CONSTRAINT participant_identity_link_participant_link_unique UNIQUE ( + participant_ref, + identity_link_ref + ), + CONSTRAINT participant_identity_link_distinct_proofs_check CHECK ( + anonymous_proof_ref <> authenticated_proof_ref + ) +); + +CREATE TABLE IF NOT EXISTS participant_identity_link_end ( + link_end_event_ref TEXT CONSTRAINT participant_identity_link_end_link_end_event_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_end_link_end_event_ref_format_check CHECK ( + link_end_event_ref = btrim(link_end_event_ref) + AND link_end_event_ref <> '' + AND NOT ( + link_end_event_ref ~ '[[:digit:]]' + AND link_end_event_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + participant_ref TEXT CONSTRAINT participant_identity_link_end_participant_ref_not_null NOT NULL, + linked_event_ref TEXT CONSTRAINT participant_identity_link_end_linked_event_ref_not_null NOT NULL, + evidence_ref TEXT CONSTRAINT participant_identity_link_end_evidence_ref_not_null NOT NULL + CONSTRAINT participant_identity_link_end_evidence_ref_format_check CHECK ( + evidence_ref = btrim(evidence_ref) + AND evidence_ref <> '' + AND NOT ( + evidence_ref ~ '[[:digit:]]' + AND evidence_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + ended_at_unix_ms BIGINT CONSTRAINT participant_identity_link_end_ended_at_unix_ms_not_null NOT NULL + CONSTRAINT participant_identity_link_end_ended_at_unix_ms_positive_check CHECK ( + ended_at_unix_ms > 0 + ), + created_at TIMESTAMPTZ CONSTRAINT participant_identity_link_end_created_at_not_null NOT NULL + DEFAULT clock_timestamp(), + CONSTRAINT participant_identity_link_end_pkey PRIMARY KEY (link_end_event_ref), + CONSTRAINT participant_identity_link_end_participant_fk FOREIGN KEY (participant_ref) + REFERENCES assessment_participant (participant_ref), + CONSTRAINT participant_identity_link_end_linked_event_fk FOREIGN KEY (linked_event_ref) + REFERENCES participant_identity_link (identity_link_ref), + CONSTRAINT participant_identity_link_end_linked_event_participant_fk + FOREIGN KEY (participant_ref, linked_event_ref) + REFERENCES participant_identity_link (participant_ref, identity_link_ref), + CONSTRAINT participant_identity_link_end_linked_event_unique UNIQUE (linked_event_ref) +); + +CREATE TABLE IF NOT EXISTS current_participant_identity_link ( + participant_ref TEXT CONSTRAINT current_participant_identity_link_participant_ref_not_null NOT NULL, + identity_link_ref TEXT CONSTRAINT current_participant_identity_link_identity_link_ref_not_null NOT NULL, + tenant_ref TEXT CONSTRAINT current_participant_identity_link_tenant_ref_not_null NOT NULL, + identity_issuer TEXT CONSTRAINT current_participant_identity_link_identity_issuer_not_null NOT NULL, + identity_subject_ref TEXT CONSTRAINT current_participant_identity_link_identity_subject_ref_not_null NOT NULL, + CONSTRAINT current_participant_identity_link_pkey PRIMARY KEY (participant_ref), + CONSTRAINT current_participant_identity_link_identity_fk FOREIGN KEY (identity_link_ref) + REFERENCES participant_identity_link (identity_link_ref), + CONSTRAINT current_participant_identity_link_identity_participant_fk + FOREIGN KEY (participant_ref, identity_link_ref) + REFERENCES participant_identity_link (participant_ref, identity_link_ref), + CONSTRAINT current_participant_identity_link_participant_fk FOREIGN KEY (participant_ref) + REFERENCES assessment_participant (participant_ref), + CONSTRAINT current_participant_identity_link_subject_unique UNIQUE ( + tenant_ref, + identity_issuer, + identity_subject_ref + ) +); + +CREATE INDEX IF NOT EXISTS participant_identity_link_current_subject_lookup + ON participant_identity_link (tenant_ref, identity_issuer, identity_subject_ref); diff --git a/src/account_link_write.rs b/src/account_link_write.rs new file mode 100644 index 00000000..b876d383 --- /dev/null +++ b/src/account_link_write.rs @@ -0,0 +1,421 @@ +//! Hosted dual-proof account-link write, unlink, and returning-account recovery. +//! +//! HTTP and messaging adapters validate anonymous-session and Keyverse proofs, +//! then call these commands. This module does not parse tokens or open a socket. +//! It authorizes the in-memory participant, persists append-only identity-link +//! history, ends a current binding from a still-valid account proof, recovers +//! the same product-owned participant from a still-valid authenticated account +//! proof, and binds any later account-linked capability to that current +//! `link_event_ref` so unlink invalidates the grant. + +use crate::account_link::{ + link_authenticated_account, AccountLinkAuthorizationError, AuthenticatedAccountControl, +}; +use crate::anonymous_session::AnonymousSessionContext; +use crate::participant::ParticipantRecord; +use crate::postgres_participant_identity_link::{ + load_participant_by_current_identity_subject, load_participant_identity_history, + persist_participant_identity_history, IdentityLinkPersistenceDisposition, + IdentityLinkPersistenceError, +}; +use postgres::Transaction; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Fail-closed error for the hosted account-link write, unlink, and recover commands. +#[derive(Debug)] +#[non_exhaustive] +pub enum AccountLinkWriteError { + /// Dual-proof authorization rejected the link, unlink, or recover attempt. + Authorization(AccountLinkAuthorizationError), + /// Durable identity-link persistence or reload rejected the command. + Persistence(IdentityLinkPersistenceError), + /// The authenticated proof is not the participant's current identity link. + /// + /// A rebound or unused account must not end another subject's current + /// binding. Exact replay of an already-recorded unlink for this proof is + /// accepted separately. + NoCurrentBinding, +} + +impl Display for AccountLinkWriteError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Authorization(error) => error.fmt(formatter), + Self::Persistence(error) => error.fmt(formatter), + Self::NoCurrentBinding => formatter.write_str( + "this authenticated account is not the participant's current identity link", + ), + } + } +} + +impl Error for AccountLinkWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Authorization(error) => Some(error), + Self::Persistence(error) => Some(error), + Self::NoCurrentBinding => None, + } + } +} + +impl From for AccountLinkWriteError { + fn from(error: AccountLinkAuthorizationError) -> Self { + Self::Authorization(error) + } +} + +impl From for AccountLinkWriteError { + fn from(error: IdentityLinkPersistenceError) -> Self { + Self::Persistence(error) + } +} + +/// Reject an expired or unknown-time authenticated account proof before lookup. +/// +/// A returning login must still hold a current account-control proof. This check +/// runs before history lookup so an expired proof cannot probe whether a +/// participant already exists. +/// +/// # Errors +/// +/// Returns [`AccountLinkAuthorizationError::InvalidTimestamp`] when server time +/// is zero, and +/// [`AccountLinkAuthorizationError::AuthenticatedProofExpired`] when the proof +/// is not valid at `now_unix_ms`. +pub fn require_recoverable_account( + authenticated_control: &AuthenticatedAccountControl, + now_unix_ms: u64, +) -> Result<(), AccountLinkWriteError> { + if now_unix_ms == 0 { + return Err(AccountLinkAuthorizationError::InvalidTimestamp.into()); + } + if !authenticated_control.is_valid_at(now_unix_ms) { + return Err(AccountLinkAuthorizationError::AuthenticatedProofExpired.into()); + } + Ok(()) +} + +/// Authorize a dual-proof account link and persist the resulting history. +/// +/// A speculative copy of the participant is linked only after both proofs are +/// current and tenant-bound. Persist then writes that candidate history and +/// reconciles the derived current projection. The caller-owned participant is +/// replaced only after persistence succeeds, so durable rejection leaves the +/// caller's aggregate unchanged. Exact replay of the same evidence is +/// idempotent. The transaction remains caller-owned. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] when dual-proof checks +/// fail, and [`AccountLinkWriteError::Persistence`] when durable write or +/// uniqueness checks fail. +pub fn persist_authorized_account_link( + transaction: &mut Transaction<'_>, + participant: &mut ParticipantRecord, + anonymous_control: &AnonymousSessionContext, + authenticated_control: &AuthenticatedAccountControl, + link_event_ref: &str, + linked_at_unix_ms: u64, +) -> Result { + let mut candidate = participant.clone(); + link_authenticated_account( + &mut candidate, + anonymous_control, + authenticated_control, + link_event_ref, + linked_at_unix_ms, + )?; + let disposition = persist_participant_identity_history(transaction, &candidate)?; + *participant = candidate; + Ok(disposition) +} + +/// Keep a recovered participant only when its current binding matches the proof. +/// +/// Subject lookup can race with unlink+relink under `READ COMMITTED`. A +/// still-valid proof for one issuer-scoped subject must not receive a +/// participant that is now bound to another tenant, issuer, or subject. A +/// missing or rebound record returns `None` so the caller cannot take over +/// another account's current identity. +#[must_use] +pub fn accept_recovered_participant_for_authenticated_account( + participant: Option, + authenticated_control: &AuthenticatedAccountControl, +) -> Option { + let participant = participant?; + let matches_current_binding = participant.tenant_ref() == authenticated_control.tenant_ref() + && participant.linked_issuer_ref() == Some(authenticated_control.issuer_ref()) + && participant.linked_subject_ref() == Some(authenticated_control.subject_ref()); + matches_current_binding.then_some(participant) +} + +/// Recover the participant currently bound to a still-valid authenticated account. +/// +/// The proof supplies tenant, issuer, and subject. A missing current link +/// returns `None` so a valid unused account is not turned into a participant. +/// After load, the current tenant/issuer/subject must still match the proof so +/// a concurrent unlink+relink cannot hand back a rebound participant. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] when the proof is expired +/// or the recover time is unknown, and +/// [`AccountLinkWriteError::Persistence`] when stored history cannot be loaded. +pub fn recover_participant_for_authenticated_account( + transaction: &mut Transaction<'_>, + authenticated_control: &AuthenticatedAccountControl, + now_unix_ms: u64, +) -> Result, AccountLinkWriteError> { + require_recoverable_account(authenticated_control, now_unix_ms)?; + let loaded = load_participant_by_current_identity_subject( + transaction, + authenticated_control.tenant_ref(), + authenticated_control.issuer_ref(), + authenticated_control.subject_ref(), + )?; + Ok(accept_recovered_participant_for_authenticated_account( + loaded, + authenticated_control, + )) +} + +fn ended_link_matches_authenticated_account( + participant: &ParticipantRecord, + authenticated_control: &AuthenticatedAccountControl, + link_end_event_ref: &str, +) -> bool { + let Some(end) = participant + .link_end_history() + .iter() + .find(|event| event.link_end_event_ref() == link_end_event_ref) + else { + return false; + }; + participant.link_history().iter().any(|link| { + link.link_event_ref() == end.linked_event_ref() + && link.issuer_ref() == authenticated_control.issuer_ref() + && link.subject_ref() == authenticated_control.subject_ref() + }) +} + +/// End the current identity link when the authenticated proof still matches it. +/// +/// A buyer who is signed in with the current Keyverse account can disconnect +/// that account. The command first rejects expired or unknown-time proofs. A +/// participant currently bound to another tenant, issuer, or subject fails +/// closed so unlink cannot take over a rebound identity. After a successful +/// unlink, exact replay of the same end event is idempotent. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] when the proof is expired, +/// the unlink time is unknown, the proof belongs to another tenant, or the +/// participant lifecycle rejects the end event. +/// Returns [`AccountLinkWriteError::NoCurrentBinding`] when the proof is not +/// the current binding and the event is not an exact historical replay of that +/// proof's ended link. +pub fn authorize_account_unlink( + participant: &mut ParticipantRecord, + authenticated_control: &AuthenticatedAccountControl, + link_end_event_ref: &str, + ended_at_unix_ms: u64, +) -> Result<(), AccountLinkWriteError> { + if ended_at_unix_ms == 0 { + return Err(AccountLinkAuthorizationError::InvalidTimestamp.into()); + } + require_recoverable_account(authenticated_control, ended_at_unix_ms)?; + if participant.tenant_ref() != authenticated_control.tenant_ref() { + return Err(AccountLinkAuthorizationError::CrossTenantDenied.into()); + } + + let currently_matches = participant.linked_issuer_ref() + == Some(authenticated_control.issuer_ref()) + && participant.linked_subject_ref() == Some(authenticated_control.subject_ref()); + if participant.linked_subject_ref().is_some() && !currently_matches { + return Err(AccountLinkWriteError::NoCurrentBinding); + } + + participant + .record_link_end( + link_end_event_ref, + authenticated_control.proof_evidence_ref(), + ended_at_unix_ms, + ) + .map_err(AccountLinkAuthorizationError::Participant)?; + + if !ended_link_matches_authenticated_account( + participant, + authenticated_control, + link_end_event_ref, + ) { + return Err(AccountLinkWriteError::NoCurrentBinding); + } + Ok(()) +} + +/// Reload stored history, authorize unlink, and persist the append-only end. +/// +/// The caller-owned participant is replaced with the stored history before +/// authorization so a stale in-memory record cannot end a rebound current +/// binding. After persist, recover with the same proof returns `None`. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] or +/// [`AccountLinkWriteError::NoCurrentBinding`] from +/// [`authorize_account_unlink`], and [`AccountLinkWriteError::Persistence`] +/// when stored history cannot be loaded or written. A participant that was +/// never persisted returns [`AccountLinkWriteError::NoCurrentBinding`]. +pub fn persist_authorized_account_unlink( + transaction: &mut Transaction<'_>, + participant: &mut ParticipantRecord, + authenticated_control: &AuthenticatedAccountControl, + link_end_event_ref: &str, + ended_at_unix_ms: u64, +) -> Result { + if ended_at_unix_ms == 0 { + return Err(AccountLinkAuthorizationError::InvalidTimestamp.into()); + } + require_recoverable_account(authenticated_control, ended_at_unix_ms)?; + let Some(mut loaded) = load_participant_identity_history( + transaction, + participant.participant_ref(), + participant.tenant_ref(), + )? + else { + return Err(AccountLinkWriteError::NoCurrentBinding); + }; + authorize_account_unlink( + &mut loaded, + authenticated_control, + link_end_event_ref, + ended_at_unix_ms, + )?; + let disposition = persist_participant_identity_history(transaction, &loaded)?; + *participant = loaded; + Ok(disposition) +} + +/// Account-linked capability bound to one current identity-link event. +/// +/// Recovering a `participant_ref` is not enough to keep acting as the Keyverse +/// account after unlink. A later account-privileged command must present this +/// grant and pass [`accept_account_linked_capability`], which re-checks that the +/// participant's current tenant, issuer, subject, and `link_event_ref` still +/// match. Unlink or rebound clears or replaces that event, so the old grant +/// fails closed. +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountLinkedCapability { + participant_ref: String, + tenant_ref: String, + issuer_ref: String, + subject_ref: String, + link_event_ref: String, +} + +impl AccountLinkedCapability { + /// Return the product-owned participant this grant recovered. + #[must_use] + pub fn participant_ref(&self) -> &str { + &self.participant_ref + } + + /// Return the tenant asserted when the grant was issued. + #[must_use] + pub fn tenant_ref(&self) -> &str { + &self.tenant_ref + } + + /// Return the identity issuer bound into the grant. + #[must_use] + pub fn issuer_ref(&self) -> &str { + &self.issuer_ref + } + + /// Return the issuer-scoped subject bound into the grant. + #[must_use] + pub fn subject_ref(&self) -> &str { + &self.subject_ref + } + + /// Return the current link event the grant is valid for. + #[must_use] + pub fn link_event_ref(&self) -> &str { + &self.link_event_ref + } +} + +/// Issue an account-linked capability from a still-valid current binding. +/// +/// The proof must still be valid at `now_unix_ms`. A missing, unlinked, or +/// rebound current binding returns `None` so a recovered `participant_ref` +/// cannot be treated as an account grant after unlink. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] when the proof is expired +/// or the grant time is unknown. +pub fn grant_account_linked_capability( + participant: &ParticipantRecord, + authenticated_control: &AuthenticatedAccountControl, + now_unix_ms: u64, +) -> Result, AccountLinkWriteError> { + require_recoverable_account(authenticated_control, now_unix_ms)?; + let matches_current_binding = participant.tenant_ref() == authenticated_control.tenant_ref() + && participant.linked_issuer_ref() == Some(authenticated_control.issuer_ref()) + && participant.linked_subject_ref() == Some(authenticated_control.subject_ref()); + let Some(link_event_ref) = participant + .link_event_ref() + .filter(|_| matches_current_binding) + else { + return Ok(None); + }; + Ok(Some(AccountLinkedCapability { + participant_ref: participant.participant_ref().to_owned(), + tenant_ref: participant.tenant_ref().to_owned(), + issuer_ref: authenticated_control.issuer_ref().to_owned(), + subject_ref: authenticated_control.subject_ref().to_owned(), + link_event_ref: link_event_ref.to_owned(), + })) +} + +/// Re-check a previously granted account-linked capability against current state. +/// +/// The proof must still be valid. The participant's current tenant, issuer, +/// subject, and `link_event_ref` must still match the grant. After unlink the +/// current event is cleared. After rebound — including a later attach of the +/// same issuer-scoped subject under a new `link_event_ref` — the current event +/// is different. Either case returns [`AccountLinkWriteError::NoCurrentBinding`]. +/// +/// # Errors +/// +/// Returns [`AccountLinkWriteError::Authorization`] when the proof is expired, +/// the accept time is unknown, or the grant tenant does not match the proof. +/// Returns [`AccountLinkWriteError::NoCurrentBinding`] when the current binding +/// no longer matches the grant. +pub fn accept_account_linked_capability( + participant: &ParticipantRecord, + capability: &AccountLinkedCapability, + authenticated_control: &AuthenticatedAccountControl, + now_unix_ms: u64, +) -> Result<(), AccountLinkWriteError> { + require_recoverable_account(authenticated_control, now_unix_ms)?; + if capability.tenant_ref != authenticated_control.tenant_ref() { + return Err(AccountLinkAuthorizationError::CrossTenantDenied.into()); + } + let current_matches = participant.participant_ref() == capability.participant_ref + && participant.tenant_ref() == capability.tenant_ref + && participant.linked_issuer_ref() == Some(capability.issuer_ref.as_str()) + && participant.linked_subject_ref() == Some(capability.subject_ref.as_str()) + && participant.link_event_ref() == Some(capability.link_event_ref.as_str()) + && capability.issuer_ref == authenticated_control.issuer_ref() + && capability.subject_ref == authenticated_control.subject_ref(); + if current_matches { + Ok(()) + } else { + Err(AccountLinkWriteError::NoCurrentBinding) + } +} diff --git a/src/lib.rs b/src/lib.rs index 8af59b3a..bdbf7918 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! contracts rather than reimplemented here. pub mod account_link; +pub mod account_link_write; pub mod anonymous_authorization; pub mod anonymous_credential; pub mod anonymous_session; @@ -39,6 +40,7 @@ pub mod postgres_instrument_release; pub mod postgres_integration; pub mod postgres_item_delivery; pub mod postgres_longitudinal_observation; +pub mod postgres_participant_identity_link; pub mod postgres_response_snapshot; pub mod postgres_result_snapshot; pub mod postgres_scoring_job; diff --git a/src/participant.rs b/src/participant.rs index 228f919b..d35a7f46 100644 --- a/src/participant.rs +++ b/src/participant.rs @@ -130,6 +130,25 @@ impl AccountLinkEvent { pub const fn linked_at_unix_ms(&self) -> u64 { self.linked_at_unix_ms } + + /// Rebuild one stored link event after persistence load. + pub(crate) fn from_stored( + link_event_ref: String, + issuer_ref: String, + subject_ref: String, + anonymous_proof_ref: String, + authenticated_proof_ref: String, + linked_at_unix_ms: u64, + ) -> Self { + Self { + link_event_ref, + issuer_ref, + subject_ref, + anonymous_proof_ref, + authenticated_proof_ref, + linked_at_unix_ms, + } + } } /// Immutable audit evidence that a previously current identity link ended. @@ -170,6 +189,21 @@ impl AccountLinkEndEvent { pub const fn ended_at_unix_ms(&self) -> u64 { self.ended_at_unix_ms } + + /// Rebuild one stored link-end event after persistence load. + pub(crate) fn from_stored( + link_end_event_ref: String, + linked_event_ref: String, + evidence_ref: String, + ended_at_unix_ms: u64, + ) -> Self { + Self { + link_end_event_ref, + linked_event_ref, + evidence_ref, + ended_at_unix_ms, + } + } } /// Stable product-owned participant identity with optional issuer-scoped account linkage. diff --git a/src/postgres_participant_identity_link.rs b/src/postgres_participant_identity_link.rs new file mode 100644 index 00000000..c796cb49 --- /dev/null +++ b/src/postgres_participant_identity_link.rs @@ -0,0 +1,713 @@ +//! `PostgreSQL` 18 persistence for append-only participant identity-link history. +//! +//! Dual-proof account linking stays in the product domain. This adapter stores +//! the accepted history and a derived current-link projection so a restart can +//! reload the same participant identity. It does not parse Keyverse tokens or +//! rewrite historical participant, session, or result identifiers. Replay +//! classification requires `READ COMMITTED`. + +use crate::participant::{AccountLinkEndEvent, AccountLinkEvent, ParticipantRecord}; +use crate::reference::normalized_reference; +use postgres::Transaction; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +const IDENTITY_LINK_MIGRATION: &str = + include_str!("../migrations/0022_participant_identity_link.sql"); + +/// Outcome of persisting one participant identity-link history. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IdentityLinkPersistenceDisposition { + /// At least one new participant, link, or link-end row was inserted. + Inserted, + /// The same immutable participant and identity-link evidence already existed. + Duplicate, +} + +/// Fail-closed error for durable participant identity-link persistence. +#[derive(Debug)] +#[non_exhaustive] +pub enum IdentityLinkPersistenceError { + /// A participant, tenant, issuer, subject, event, or proof reference was invalid. + InvalidReference, + /// Event identity was replayed with different immutable evidence. + ConflictingReplay, + /// A timestamp cannot be represented by the bounded database column. + InvalidTimestamp, + /// Identity-link persistence requires `PostgreSQL` `READ COMMITTED` isolation. + UnsupportedIsolationLevel, + /// The issuer-scoped subject already has a current link on another participant. + SubjectAlreadyBound, + /// Stored history could not be replayed through the domain lifecycle. + CorruptHistory, + /// `PostgreSQL` rejected or could not execute the persistence operation. + Database(postgres::Error), +} + +impl Display for IdentityLinkPersistenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidReference => { + "participant identity-link persistence references must be opaque values" + } + Self::ConflictingReplay => { + "participant identity-link evidence was replayed with conflicting values" + } + Self::InvalidTimestamp => { + "participant identity-link timestamp exceeds the PostgreSQL bigint range" + } + Self::UnsupportedIsolationLevel => { + "participant identity-link persistence requires read committed isolation" + } + Self::SubjectAlreadyBound => { + "this issuer-scoped subject already has a current participant identity link" + } + Self::CorruptHistory => { + "stored participant identity-link history could not be replayed" + } + Self::Database(_) => "PostgreSQL participant identity-link persistence failed", + }) + } +} + +impl Error for IdentityLinkPersistenceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Database(error) => Some(error), + Self::InvalidReference + | Self::ConflictingReplay + | Self::InvalidTimestamp + | Self::UnsupportedIsolationLevel + | Self::SubjectAlreadyBound + | Self::CorruptHistory => None, + } + } +} + +impl From for IdentityLinkPersistenceError { + fn from(error: postgres::Error) -> Self { + Self::Database(error) + } +} + +/// Apply the idempotent participant identity-link migration to a `PostgreSQL` connection. +/// +/// # Errors +/// +/// Returns the `PostgreSQL` error if the migration cannot be applied. +pub fn apply_participant_identity_link_migration( + client: &mut impl postgres::GenericClient, +) -> Result<(), postgres::Error> { + client.batch_execute(IDENTITY_LINK_MIGRATION) +} + +/// Persist one participant and its append-only identity-link history. +/// +/// History is applied in the same lifecycle order as reload: each link, then +/// the ends that close that link. Exact replay of the same participant, link, +/// and link-end evidence is idempotent. After that history is written, the +/// derived current projection is reconciled so operator repair or restore +/// cannot leave a missing or stale unique enforcer. Reusing an event identity +/// with different issuer, subject, proof, or time fails closed. An +/// unterminated issuer-scoped subject cannot belong to two participants at +/// once, even when the derived current projection is missing. +/// +/// # Errors +/// +/// Returns [`IdentityLinkPersistenceError`] for unsupported isolation, +/// conflicting replay, an already-bound subject, an invalid reference, a +/// timestamp outside the `PostgreSQL` range, or a database failure. +pub fn persist_participant_identity_history( + transaction: &mut Transaction<'_>, + participant: &ParticipantRecord, +) -> Result { + require_read_committed(transaction)?; + let participant_ref = required_reference(participant.participant_ref())?; + let tenant_ref = required_reference(participant.tenant_ref())?; + let created_at = unix_ms_to_i64(participant.created_at_unix_ms())?; + let mut inserted_any = + persist_participant_header(transaction, participant_ref, tenant_ref, created_at)?; + lock_participant(transaction, participant_ref)?; + if participant.link_end_history().iter().any(|end| { + participant + .link_history() + .iter() + .all(|link| link.link_event_ref() != end.linked_event_ref()) + }) { + return Err(IdentityLinkPersistenceError::CorruptHistory); + } + for event in participant.link_history() { + if persist_one_link(transaction, participant_ref, tenant_ref, event)? { + inserted_any = true; + } + for end in participant + .link_end_history() + .iter() + .filter(|end| end.linked_event_ref() == event.link_event_ref()) + { + if persist_one_link_end(transaction, participant_ref, end)? { + inserted_any = true; + } + } + } + reconcile_current_projection(transaction, participant)?; + if inserted_any { + Ok(IdentityLinkPersistenceDisposition::Inserted) + } else { + Ok(IdentityLinkPersistenceDisposition::Duplicate) + } +} + +/// Reload one tenant-scoped participant and replay its identity-link history. +/// +/// A missing participant or a tenant mismatch returns `None` so cross-tenant +/// probes cannot distinguish those cases. Loaded history is replayed through +/// [`ParticipantRecord`] so domain invariants remain authoritative. +/// +/// # Errors +/// +/// Returns [`IdentityLinkPersistenceError`] for an invalid reference, corrupt +/// stored history, an unrepresentable timestamp, or a database failure. +pub fn load_participant_identity_history( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let participant_ref = required_reference(participant_ref)?; + let tenant_ref = required_reference(tenant_ref)?; + let Some(created_at_unix_ms) = + load_participant_header(transaction, participant_ref, tenant_ref)? + else { + return Ok(None); + }; + let mut record = + ParticipantRecord::new_anonymous(participant_ref, tenant_ref, created_at_unix_ms) + .map_err(|_| IdentityLinkPersistenceError::CorruptHistory)?; + let links = load_link_events(transaction, participant_ref)?; + let ends = load_link_end_events(transaction, participant_ref)?; + for link in &links { + record + .link_account( + link.link_event_ref(), + link.issuer_ref(), + link.subject_ref(), + link.anonymous_proof_ref(), + link.authenticated_proof_ref(), + link.linked_at_unix_ms(), + ) + .map_err(|_| IdentityLinkPersistenceError::CorruptHistory)?; + for end in ends + .iter() + .filter(|event| event.linked_event_ref() == link.link_event_ref()) + { + record + .record_link_end( + end.link_end_event_ref(), + end.evidence_ref(), + end.ended_at_unix_ms(), + ) + .map_err(|_| IdentityLinkPersistenceError::CorruptHistory)?; + } + } + if ends.iter().any(|end| { + links + .iter() + .all(|link| link.link_event_ref() != end.linked_event_ref()) + }) { + return Err(IdentityLinkPersistenceError::CorruptHistory); + } + Ok(Some(record)) +} + +/// Reload the participant that currently holds an issuer-scoped subject. +/// +/// A returning Keyverse login uses this lookup to recover the stable +/// product-owned `participant_ref` after the anonymous session token is gone. +/// The append-only link history is the source of truth: a derived current +/// projection may be missing after restore or operator repair, but an +/// unterminated issuer-scoped subject still resolves. A missing current link +/// or a tenant mismatch returns `None`. +/// +/// # Errors +/// +/// Returns [`IdentityLinkPersistenceError`] for an invalid reference, corrupt +/// stored history, an unrepresentable timestamp, or a database failure. +pub fn load_participant_by_current_identity_subject( + transaction: &mut Transaction<'_>, + tenant_ref: &str, + identity_issuer: &str, + identity_subject_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let tenant_ref = required_reference(tenant_ref)?; + let identity_issuer = required_reference(identity_issuer)?; + let identity_subject_ref = required_reference(identity_subject_ref)?; + let Some(participant_ref) = current_subject_participant( + transaction, + tenant_ref, + identity_issuer, + identity_subject_ref, + )? + else { + return Ok(None); + }; + load_participant_identity_history(transaction, &participant_ref, tenant_ref) +} + +fn persist_participant_header( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, + created_at_unix_ms: i64, +) -> Result { + let inserted = transaction.execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, created_at_unix_ms\ + ) VALUES ($1, $2, $3) \ + ON CONFLICT (participant_ref) DO NOTHING", + &[&participant_ref, &tenant_ref, &created_at_unix_ms], + )?; + if inserted == 1 { + return Ok(true); + } + let row = transaction.query_one( + "SELECT tenant_ref, created_at_unix_ms \ + FROM assessment_participant WHERE participant_ref = $1", + &[&participant_ref], + )?; + let stored_tenant: String = row.get(0); + let stored_created: i64 = row.get(1); + if stored_tenant == tenant_ref && stored_created == created_at_unix_ms { + Ok(false) + } else { + Err(IdentityLinkPersistenceError::ConflictingReplay) + } +} + +fn lock_participant( + transaction: &mut Transaction<'_>, + participant_ref: &str, +) -> Result<(), IdentityLinkPersistenceError> { + transaction.query_one( + "SELECT participant_ref FROM assessment_participant \ + WHERE participant_ref = $1 FOR UPDATE", + &[&participant_ref], + )?; + Ok(()) +} + +fn persist_one_link( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, + event: &AccountLinkEvent, +) -> Result { + let identity_link_ref = required_reference(event.link_event_ref())?; + let identity_issuer = required_reference(event.issuer_ref())?; + let identity_subject_ref = required_reference(event.subject_ref())?; + let anonymous_proof_ref = required_reference(event.anonymous_proof_ref())?; + let authenticated_proof_ref = required_reference(event.authenticated_proof_ref())?; + let linked_at_unix_ms = unix_ms_to_i64(event.linked_at_unix_ms())?; + reject_subject_bound_to_another_participant( + transaction, + participant_ref, + tenant_ref, + identity_issuer, + identity_subject_ref, + )?; + let inserted = transaction.execute( + "INSERT INTO participant_identity_link (\ + identity_link_ref, participant_ref, tenant_ref, identity_issuer, \ + identity_subject_ref, anonymous_proof_ref, authenticated_proof_ref, \ + linked_at_unix_ms\ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + ON CONFLICT (identity_link_ref) DO NOTHING", + &[ + &identity_link_ref, + &participant_ref, + &tenant_ref, + &identity_issuer, + &identity_subject_ref, + &anonymous_proof_ref, + &authenticated_proof_ref, + &linked_at_unix_ms, + ], + )?; + if inserted == 1 { + insert_current_projection( + transaction, + participant_ref, + identity_link_ref, + tenant_ref, + identity_issuer, + identity_subject_ref, + )?; + return Ok(true); + } + let row = transaction.query_one( + "SELECT participant_ref, tenant_ref, identity_issuer, identity_subject_ref, \ + anonymous_proof_ref, authenticated_proof_ref, linked_at_unix_ms \ + FROM participant_identity_link WHERE identity_link_ref = $1", + &[&identity_link_ref], + )?; + let stored_participant: String = row.get(0); + let stored_tenant: String = row.get(1); + let stored_issuer: String = row.get(2); + let stored_subject: String = row.get(3); + let stored_anonymous: String = row.get(4); + let stored_authenticated: String = row.get(5); + let stored_linked: i64 = row.get(6); + if stored_participant == participant_ref + && stored_tenant == tenant_ref + && stored_issuer == identity_issuer + && stored_subject == identity_subject_ref + && stored_anonymous == anonymous_proof_ref + && stored_authenticated == authenticated_proof_ref + && stored_linked == linked_at_unix_ms + { + Ok(false) + } else { + Err(IdentityLinkPersistenceError::ConflictingReplay) + } +} + +fn current_link_event(participant: &ParticipantRecord) -> Option<&AccountLinkEvent> { + participant.link_history().iter().rev().find(|link| { + participant + .link_end_history() + .iter() + .all(|end| end.linked_event_ref() != link.link_event_ref()) + }) +} + +fn reconcile_current_projection( + transaction: &mut Transaction<'_>, + participant: &ParticipantRecord, +) -> Result<(), IdentityLinkPersistenceError> { + let participant_ref = required_reference(participant.participant_ref())?; + if let Some(event) = current_link_event(participant) { + let tenant_ref = required_reference(participant.tenant_ref())?; + let identity_link_ref = required_reference(event.link_event_ref())?; + let identity_issuer = required_reference(event.issuer_ref())?; + let identity_subject_ref = required_reference(event.subject_ref())?; + match transaction.execute( + "INSERT INTO current_participant_identity_link (\ + participant_ref, identity_link_ref, tenant_ref, identity_issuer, \ + identity_subject_ref\ + ) VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (participant_ref) DO UPDATE SET \ + identity_link_ref = EXCLUDED.identity_link_ref, \ + tenant_ref = EXCLUDED.tenant_ref, \ + identity_issuer = EXCLUDED.identity_issuer, \ + identity_subject_ref = EXCLUDED.identity_subject_ref", + &[ + &participant_ref, + &identity_link_ref, + &tenant_ref, + &identity_issuer, + &identity_subject_ref, + ], + ) { + Ok(_) => Ok(()), + Err(error) => Err(classify_current_unique_violation(error)), + } + } else { + transaction.execute( + "DELETE FROM current_participant_identity_link WHERE participant_ref = $1", + &[&participant_ref], + )?; + Ok(()) + } +} + +fn insert_current_projection( + transaction: &mut Transaction<'_>, + participant_ref: &str, + identity_link_ref: &str, + tenant_ref: &str, + identity_issuer: &str, + identity_subject_ref: &str, +) -> Result<(), IdentityLinkPersistenceError> { + match transaction.execute( + "INSERT INTO current_participant_identity_link (\ + participant_ref, identity_link_ref, tenant_ref, identity_issuer, \ + identity_subject_ref\ + ) VALUES ($1, $2, $3, $4, $5)", + &[ + &participant_ref, + &identity_link_ref, + &tenant_ref, + &identity_issuer, + &identity_subject_ref, + ], + ) { + Ok(_) => Ok(()), + Err(error) => Err(classify_current_unique_violation(error)), + } +} + +fn persist_one_link_end( + transaction: &mut Transaction<'_>, + participant_ref: &str, + event: &AccountLinkEndEvent, +) -> Result { + let link_end_event_ref = required_reference(event.link_end_event_ref())?; + let linked_event_ref = required_reference(event.linked_event_ref())?; + let evidence_ref = required_reference(event.evidence_ref())?; + let ended_at_unix_ms = unix_ms_to_i64(event.ended_at_unix_ms())?; + let inserted = transaction.execute( + "INSERT INTO participant_identity_link_end (\ + link_end_event_ref, participant_ref, linked_event_ref, evidence_ref, \ + ended_at_unix_ms\ + ) VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (link_end_event_ref) DO NOTHING", + &[ + &link_end_event_ref, + &participant_ref, + &linked_event_ref, + &evidence_ref, + &ended_at_unix_ms, + ], + )?; + if inserted == 1 { + transaction.execute( + "DELETE FROM current_participant_identity_link \ + WHERE participant_ref = $1 AND identity_link_ref = $2", + &[&participant_ref, &linked_event_ref], + )?; + return Ok(true); + } + let row = transaction.query_one( + "SELECT participant_ref, linked_event_ref, evidence_ref, ended_at_unix_ms \ + FROM participant_identity_link_end WHERE link_end_event_ref = $1", + &[&link_end_event_ref], + )?; + let stored_participant: String = row.get(0); + let stored_linked: String = row.get(1); + let stored_evidence: String = row.get(2); + let stored_ended: i64 = row.get(3); + if stored_participant == participant_ref + && stored_linked == linked_event_ref + && stored_evidence == evidence_ref + && stored_ended == ended_at_unix_ms + { + Ok(false) + } else { + Err(IdentityLinkPersistenceError::ConflictingReplay) + } +} + +fn load_participant_header( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let row = transaction.query_opt( + "SELECT created_at_unix_ms FROM assessment_participant \ + WHERE participant_ref = $1 AND tenant_ref = $2 FOR SHARE", + &[&participant_ref, &tenant_ref], + )?; + row.map(|row| i64_to_unix_ms(row.get(0))).transpose() +} + +fn load_link_events( + transaction: &mut Transaction<'_>, + participant_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let rows = transaction.query( + "SELECT identity_link_ref, identity_issuer, identity_subject_ref, \ + anonymous_proof_ref, authenticated_proof_ref, linked_at_unix_ms \ + FROM participant_identity_link \ + WHERE participant_ref = $1 \ + ORDER BY linked_at_unix_ms, identity_link_ref", + &[&participant_ref], + )?; + let mut events = Vec::new(); + for row in rows { + events.push(AccountLinkEvent::from_stored( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + row.get(4), + i64_to_unix_ms(row.get(5))?, + )); + } + Ok(events) +} + +fn load_link_end_events( + transaction: &mut Transaction<'_>, + participant_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let rows = transaction.query( + "SELECT link_end_event_ref, linked_event_ref, evidence_ref, ended_at_unix_ms \ + FROM participant_identity_link_end \ + WHERE participant_ref = $1 \ + ORDER BY ended_at_unix_ms, link_end_event_ref", + &[&participant_ref], + )?; + let mut events = Vec::new(); + for row in rows { + events.push(AccountLinkEndEvent::from_stored( + row.get(0), + row.get(1), + row.get(2), + i64_to_unix_ms(row.get(3))?, + )); + } + Ok(events) +} + +fn current_subject_participant( + transaction: &mut Transaction<'_>, + tenant_ref: &str, + identity_issuer: &str, + identity_subject_ref: &str, +) -> Result, IdentityLinkPersistenceError> { + let rows = transaction.query( + "SELECT l.participant_ref \ + FROM participant_identity_link l \ + WHERE l.tenant_ref = $1 \ + AND l.identity_issuer = $2 \ + AND l.identity_subject_ref = $3 \ + AND NOT EXISTS ( \ + SELECT 1 FROM participant_identity_link_end e \ + WHERE e.linked_event_ref = l.identity_link_ref \ + ) \ + FOR SHARE", + &[&tenant_ref, &identity_issuer, &identity_subject_ref], + )?; + match rows.as_slice() { + [] => Ok(None), + [row] => Ok(Some(row.get(0))), + _ => Err(IdentityLinkPersistenceError::CorruptHistory), + } +} + +fn reject_subject_bound_to_another_participant( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, + identity_issuer: &str, + identity_subject_ref: &str, +) -> Result<(), IdentityLinkPersistenceError> { + match current_subject_participant( + transaction, + tenant_ref, + identity_issuer, + identity_subject_ref, + )? { + Some(holder) if holder != participant_ref => { + Err(IdentityLinkPersistenceError::SubjectAlreadyBound) + } + Some(_) | None => Ok(()), + } +} + +fn classify_current_unique_violation(error: postgres::Error) -> IdentityLinkPersistenceError { + match error + .as_db_error() + .and_then(postgres::error::DbError::constraint) + { + Some("current_participant_identity_link_subject_unique") => { + IdentityLinkPersistenceError::SubjectAlreadyBound + } + Some("current_participant_identity_link_pkey") => { + IdentityLinkPersistenceError::ConflictingReplay + } + _ => IdentityLinkPersistenceError::Database(error), + } +} + +fn required_reference(reference: &str) -> Result<&str, IdentityLinkPersistenceError> { + normalized_reference(reference).ok_or(IdentityLinkPersistenceError::InvalidReference) +} + +fn unix_ms_to_i64(value: u64) -> Result { + i64::try_from(value).map_err(|_| IdentityLinkPersistenceError::InvalidTimestamp) +} + +fn i64_to_unix_ms(value: i64) -> Result { + u64::try_from(value).map_err(|_| IdentityLinkPersistenceError::InvalidTimestamp) +} + +fn require_read_committed( + transaction: &mut Transaction<'_>, +) -> Result<(), IdentityLinkPersistenceError> { + let row = transaction.query_one("SHOW transaction_isolation", &[])?; + let isolation: String = row.get(0); + if isolation == "read committed" { + Ok(()) + } else { + Err(IdentityLinkPersistenceError::UnsupportedIsolationLevel) + } +} + +#[cfg(test)] +mod tests { + use super::{required_reference, unix_ms_to_i64, IdentityLinkPersistenceError}; + + #[test] + fn blank_and_numeric_references_fail_closed() { + assert!(matches!( + required_reference(" "), + Err(IdentityLinkPersistenceError::InvalidReference) + )); + assert!(matches!( + required_reference("12"), + Err(IdentityLinkPersistenceError::InvalidReference) + )); + assert_eq!( + required_reference("participant_identity_alpha").unwrap(), + "participant_identity_alpha" + ); + } + + #[test] + fn persistence_errors_expose_stable_operator_messages() { + for (error, expected) in [ + ( + IdentityLinkPersistenceError::InvalidReference, + "participant identity-link persistence references must be opaque values", + ), + ( + IdentityLinkPersistenceError::ConflictingReplay, + "participant identity-link evidence was replayed with conflicting values", + ), + ( + IdentityLinkPersistenceError::InvalidTimestamp, + "participant identity-link timestamp exceeds the PostgreSQL bigint range", + ), + ( + IdentityLinkPersistenceError::UnsupportedIsolationLevel, + "participant identity-link persistence requires read committed isolation", + ), + ( + IdentityLinkPersistenceError::SubjectAlreadyBound, + "this issuer-scoped subject already has a current participant identity link", + ), + ( + IdentityLinkPersistenceError::CorruptHistory, + "stored participant identity-link history could not be replayed", + ), + ] { + assert_eq!(error.to_string(), expected); + assert!(std::error::Error::source(&error).is_none()); + } + } + + #[test] + fn timestamps_outside_signed_bigint_fail_closed() { + assert!(matches!( + unix_ms_to_i64(u64::MAX), + Err(IdentityLinkPersistenceError::InvalidTimestamp) + )); + assert_eq!(unix_ms_to_i64(10_100).unwrap(), 10_100); + assert!(matches!( + super::i64_to_unix_ms(-1), + Err(IdentityLinkPersistenceError::InvalidTimestamp) + )); + assert_eq!(super::i64_to_unix_ms(10_100).unwrap(), 10_100); + } +} diff --git a/tests/account_link_write.rs b/tests/account_link_write.rs new file mode 100644 index 00000000..df2308b4 --- /dev/null +++ b/tests/account_link_write.rs @@ -0,0 +1,579 @@ +//! Dual-proof account-link write and recover commands fail closed before persist. + +use psychometrics_commons_runtime::account_link::{ + AccountLinkAuthorizationError, AuthenticatedAccountControl, +}; +use psychometrics_commons_runtime::account_link_write::{ + accept_account_linked_capability, accept_recovered_participant_for_authenticated_account, + authorize_account_unlink, grant_account_linked_capability, AccountLinkWriteError, +}; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::postgres_participant_identity_link::IdentityLinkPersistenceError; +use std::error::Error; + +fn authenticated_control() -> AuthenticatedAccountControl { + AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 11_000, + ) + .unwrap() +} + +fn linked_participant(subject_ref: &str) -> ParticipantRecord { + let mut participant = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + participant + .link_account( + "link_event_identity_write", + "keyverse_issuer_write", + subject_ref, + "anonymous_proof_write", + "authenticated_proof_write", + 10_400, + ) + .unwrap(); + participant +} + +#[test] +fn write_errors_keep_operator_safe_messages_and_sources() { + let authorization = AccountLinkWriteError::Authorization( + AccountLinkAuthorizationError::AnonymousSessionExpired, + ); + assert_eq!( + authorization.to_string(), + "anonymous-session control proof is not valid at the account-link time" + ); + assert!(authorization.source().is_some()); + + let persistence = + AccountLinkWriteError::Persistence(IdentityLinkPersistenceError::SubjectAlreadyBound); + assert_eq!( + persistence.to_string(), + "this issuer-scoped subject already has a current participant identity link" + ); + assert!(persistence.source().is_some()); + + let no_current = AccountLinkWriteError::NoCurrentBinding; + assert_eq!( + no_current.to_string(), + "this authenticated account is not the participant's current identity link" + ); + assert!(no_current.source().is_none()); +} + +#[test] +fn expired_authenticated_proof_is_not_recoverable() { + let authenticated = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let error = psychometrics_commons_runtime::account_link_write::require_recoverable_account( + &authenticated, + 10_500, + ) + .expect_err("an expired account proof must not recover a participant"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization( + AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); +} + +#[test] +fn unknown_recover_time_fails_closed() { + let authenticated = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let error = psychometrics_commons_runtime::account_link_write::require_recoverable_account( + &authenticated, + 0, + ) + .expect_err("unknown recover time must not look up a participant"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::InvalidTimestamp) + )); +} + +#[test] +fn recover_does_not_return_a_participant_rebound_to_another_subject() { + let rebound = linked_participant("keyverse_subject_rebound"); + let accepted = accept_recovered_participant_for_authenticated_account( + Some(rebound), + &authenticated_control(), + ); + assert!( + accepted.is_none(), + "a still-valid proof must not recover a participant now bound to another subject" + ); +} + +#[test] +fn recover_keeps_a_participant_whose_current_binding_matches_the_proof() { + let current = linked_participant("keyverse_subject_write"); + let accepted = accept_recovered_participant_for_authenticated_account( + Some(current), + &authenticated_control(), + ) + .expect("a matching current binding must remain recoverable"); + assert_eq!(accepted.participant_ref(), "participant_identity_write"); + assert_eq!( + accepted.linked_subject_ref(), + Some("keyverse_subject_write") + ); +} + +#[test] +fn recover_treats_a_missing_or_unlinked_load_as_unused() { + assert!( + accept_recovered_participant_for_authenticated_account(None, &authenticated_control()) + .is_none() + ); + + let unlinked = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + assert!( + accept_recovered_participant_for_authenticated_account( + Some(unlinked), + &authenticated_control(), + ) + .is_none(), + "an unlinked participant is not currently bound to the proof" + ); +} + +#[test] +fn recover_rejects_tenant_or_issuer_mismatch_after_load() { + let mut foreign_tenant = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_foreign", + 10_000, + ) + .unwrap(); + foreign_tenant + .link_account( + "link_event_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "anonymous_proof_write", + "authenticated_proof_write", + 10_400, + ) + .unwrap(); + assert!(accept_recovered_participant_for_authenticated_account( + Some(foreign_tenant), + &authenticated_control(), + ) + .is_none()); + + let mut foreign_issuer = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + foreign_issuer + .link_account( + "link_event_identity_write", + "keyverse_issuer_foreign", + "keyverse_subject_write", + "anonymous_proof_write", + "authenticated_proof_write", + 10_400, + ) + .unwrap(); + assert!(accept_recovered_participant_for_authenticated_account( + Some(foreign_issuer), + &authenticated_control(), + ) + .is_none()); +} + +#[test] +fn unlink_ends_the_current_binding_for_a_still_valid_account_proof() { + let mut participant = linked_participant("keyverse_subject_write"); + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .expect("a matching current proof must end the current identity link"); + assert!(participant.linked_subject_ref().is_none()); + assert_eq!(participant.link_end_history().len(), 1); + assert_eq!( + participant.link_end_history()[0].link_end_event_ref(), + "link_end_event_identity_write" + ); +} + +#[test] +fn unlink_replay_of_the_same_end_event_is_idempotent() { + let mut participant = linked_participant("keyverse_subject_write"); + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .expect("exact unlink replay must not append a second end event"); + assert_eq!(participant.link_end_history().len(), 1); + assert!(participant.linked_subject_ref().is_none()); +} + +#[test] +fn unlink_rejects_a_proof_for_a_rebound_current_subject() { + let mut rebound = linked_participant("keyverse_subject_rebound"); + let error = authorize_account_unlink( + &mut rebound, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .expect_err("an ended subject's proof must not unlink a rebound current binding"); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); + assert_eq!( + rebound.linked_subject_ref(), + Some("keyverse_subject_rebound") + ); +} + +#[test] +fn expired_authenticated_proof_cannot_unlink() { + let mut participant = linked_participant("keyverse_subject_write"); + let expired = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let error = authorize_account_unlink( + &mut participant, + &expired, + "link_end_event_identity_write", + 10_500, + ) + .expect_err("an expired account proof must not end a current identity link"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization( + AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); + assert_eq!( + participant.linked_subject_ref(), + Some("keyverse_subject_write") + ); +} + +#[test] +fn unknown_unlink_time_fails_closed() { + let mut participant = linked_participant("keyverse_subject_write"); + let error = authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 0, + ) + .expect_err("unknown unlink time must not end a current identity link"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::InvalidTimestamp) + )); + assert_eq!( + participant.linked_subject_ref(), + Some("keyverse_subject_write") + ); +} + +#[test] +fn unlink_of_an_unlinked_participant_without_exact_replay_fails_closed() { + let mut unlinked = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + let error = authorize_account_unlink( + &mut unlinked, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .expect_err("an unused account must not invent an unlink against an unlinked participant"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::Participant(_)) + )); +} + +#[test] +fn grant_binds_account_capability_to_the_current_link_event() { + let participant = linked_participant("keyverse_subject_write"); + let capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .expect("a still-valid current proof must grant an account-linked capability") + .expect("a matching current binding must produce a capability"); + assert_eq!(capability.participant_ref(), "participant_identity_write"); + assert_eq!(capability.tenant_ref(), "tenant_identity_write"); + assert_eq!(capability.issuer_ref(), "keyverse_issuer_write"); + assert_eq!(capability.subject_ref(), "keyverse_subject_write"); + assert_eq!(capability.link_event_ref(), "link_event_identity_write"); +} + +#[test] +fn accept_keeps_a_grant_only_while_the_current_binding_matches() { + let mut participant = linked_participant("keyverse_subject_write"); + let capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .unwrap() + .unwrap(); + accept_account_linked_capability(&participant, &capability, &authenticated_control(), 10_550) + .expect("the same current binding must still accept the granted capability"); + + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_600, + ) + .unwrap(); + let error = accept_account_linked_capability( + &participant, + &capability, + &authenticated_control(), + 10_650, + ) + .expect_err("unlink must invalidate a previously granted account-linked capability"); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); +} + +#[test] +fn rebound_current_binding_rejects_the_ended_subject_capability() { + let mut participant = linked_participant("keyverse_subject_write"); + let ended_capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .unwrap() + .unwrap(); + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_550, + ) + .unwrap(); + participant + .link_account( + "link_event_identity_rebound", + "keyverse_issuer_write", + "keyverse_subject_rebound", + "anonymous_proof_rebound", + "authenticated_proof_rebound", + 10_600, + ) + .unwrap(); + + assert!( + grant_account_linked_capability(&participant, &authenticated_control(), 10_650) + .unwrap() + .is_none(), + "an ended subject's proof must not grant a capability for a rebound current binding" + ); + let error = accept_account_linked_capability( + &participant, + &ended_capability, + &authenticated_control(), + 10_650, + ) + .expect_err("a rebound participant must not accept the ended subject's capability"); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); +} + +#[test] +fn same_subject_relink_with_a_new_event_rejects_the_ended_grant() { + let mut participant = linked_participant("keyverse_subject_write"); + let ended_capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .unwrap() + .unwrap(); + authorize_account_unlink( + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_550, + ) + .unwrap(); + participant + .link_account( + "link_event_identity_relink", + "keyverse_issuer_write", + "keyverse_subject_write", + "anonymous_proof_relink", + "authenticated_proof_relink", + 10_700, + ) + .unwrap(); + + let current_capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_750) + .unwrap() + .expect("the same subject must grant a capability bound to the new link event"); + assert_eq!( + current_capability.link_event_ref(), + "link_event_identity_relink" + ); + accept_account_linked_capability( + &participant, + ¤t_capability, + &authenticated_control(), + 10_760, + ) + .expect("the new current binding must accept the grant issued for that event"); + + let error = accept_account_linked_capability( + &participant, + &ended_capability, + &authenticated_control(), + 10_770, + ) + .expect_err( + "a same-subject relink must reject the ended grant so subject match cannot hide a missing link-event check", + ); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); +} + +#[test] +fn expired_or_unknown_time_cannot_grant_or_accept_an_account_capability() { + let participant = linked_participant("keyverse_subject_write"); + let expired = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let expired_grant = grant_account_linked_capability(&participant, &expired, 10_500) + .expect_err("an expired proof must not grant an account-linked capability"); + assert!(matches!( + expired_grant, + AccountLinkWriteError::Authorization( + AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); + + let unknown_grant = grant_account_linked_capability(&participant, &authenticated_control(), 0) + .expect_err("unknown grant time must not issue an account-linked capability"); + assert!(matches!( + unknown_grant, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::InvalidTimestamp) + )); + + let capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .unwrap() + .unwrap(); + let expired_accept = + accept_account_linked_capability(&participant, &capability, &expired, 10_500) + .expect_err("an expired proof must not accept an account-linked capability"); + assert!(matches!( + expired_accept, + AccountLinkWriteError::Authorization( + AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); + + let unknown_accept = + accept_account_linked_capability(&participant, &capability, &authenticated_control(), 0) + .expect_err("unknown accept time must not keep an account-linked capability"); + assert!(matches!( + unknown_accept, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::InvalidTimestamp) + )); +} + +#[test] +fn accept_rejects_a_foreign_tenant_proof_for_an_issued_grant() { + let participant = linked_participant("keyverse_subject_write"); + let capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_500) + .unwrap() + .unwrap(); + let foreign = AuthenticatedAccountControl::new( + "tenant_identity_foreign", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_foreign", + 11_000, + ) + .unwrap(); + let error = accept_account_linked_capability(&participant, &capability, &foreign, 10_550) + .expect_err("a foreign-tenant proof must not accept another tenant's account grant"); + assert!(matches!( + error, + AccountLinkWriteError::Authorization(AccountLinkAuthorizationError::CrossTenantDenied) + )); +} + +#[test] +fn grant_returns_none_for_an_unlinked_or_foreign_tenant_binding() { + let unlinked = ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + assert!( + grant_account_linked_capability(&unlinked, &authenticated_control(), 10_500) + .unwrap() + .is_none(), + "an unlinked participant must not receive an account-linked capability" + ); + + let participant = linked_participant("keyverse_subject_write"); + let foreign = AuthenticatedAccountControl::new( + "tenant_identity_foreign", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_foreign", + 11_000, + ) + .unwrap(); + assert!( + grant_account_linked_capability(&participant, &foreign, 10_550) + .unwrap() + .is_none(), + "a foreign-tenant proof must not grant another tenant's account capability" + ); +} diff --git a/tests/postgres_account_link_failed_persist_state.rs b/tests/postgres_account_link_failed_persist_state.rs new file mode 100644 index 00000000..b7cb5130 --- /dev/null +++ b/tests/postgres_account_link_failed_persist_state.rs @@ -0,0 +1,133 @@ +//! Real `PostgreSQL` regression for account-link write atomicity at the caller boundary. +//! +//! A durable uniqueness failure must not leave the caller-owned participant +//! aggregate linked in memory when the database rejected that link. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::account_link::AuthenticatedAccountControl; +use psychometrics_commons_runtime::account_link_write::{ + persist_authorized_account_link, AccountLinkWriteError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::postgres_participant_identity_link::{ + apply_participant_identity_link_migration, IdentityLinkPersistenceError, +}; + +const FAILED_PERSIST_STATE_LOCK_KEY: i64 = 0x4143_4354_4C4B_4641; + +fn test_guard() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut guard = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + guard + .batch_execute("SET lock_timeout TO '60s'") + .expect("database-lock waits should have a finite CI bound"); + guard + .query_one( + "SELECT pg_advisory_lock($1)", + &[&FAILED_PERSIST_STATE_LOCK_KEY], + ) + .expect("account-link failed-persist fixture lock should be acquired"); + guard +} + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS account_link_failed_persist_test;\ + SET search_path TO account_link_failed_persist_test;\ + DROP TABLE IF EXISTS current_participant_identity_link;\ + DROP TABLE IF EXISTS participant_identity_link_end;\ + DROP TABLE IF EXISTS participant_identity_link;\ + DROP TABLE IF EXISTS assessment_participant;", + ) + .unwrap(); + client +} + +fn account_control() -> AuthenticatedAccountControl { + AuthenticatedAccountControl::new( + "tenant_failed_persist", + "keyverse_issuer_failed_persist", + "keyverse_subject_failed_persist", + "authenticated_proof_failed_persist", + 20_000, + ) + .unwrap() +} + +#[test] +fn subject_uniqueness_failure_leaves_caller_participant_unchanged() { + let _guard = test_guard(); + let mut client = test_client(); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut first = ParticipantRecord::new_anonymous( + "participant_failed_persist_first", + "tenant_failed_persist", + 10_000, + ) + .unwrap(); + let first_anonymous = AnonymousSessionContext::new( + "tenant_failed_persist", + "participant_failed_persist_first", + "session_failed_persist_first", + "anonymous_proof_failed_persist_first", + 20_000, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut first, + &first_anonymous, + &account_control(), + "link_event_failed_persist_first", + 15_000, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut second = ParticipantRecord::new_anonymous( + "participant_failed_persist_second", + "tenant_failed_persist", + 10_000, + ) + .unwrap(); + let second_before = second.clone(); + let second_anonymous = AnonymousSessionContext::new( + "tenant_failed_persist", + "participant_failed_persist_second", + "session_failed_persist_second", + "anonymous_proof_failed_persist_second", + 20_000, + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = persist_authorized_account_link( + &mut transaction, + &mut second, + &second_anonymous, + &account_control(), + "link_event_failed_persist_second", + 15_100, + ) + .expect_err("the already-bound subject must fail closed for the second participant"); + transaction.rollback().unwrap(); + + assert!(matches!( + error, + AccountLinkWriteError::Persistence(IdentityLinkPersistenceError::SubjectAlreadyBound) + )); + assert_eq!( + second, second_before, + "a rejected durable account link must not leak speculative linked state into the caller-owned participant" + ); +} diff --git a/tests/postgres_account_link_write.rs b/tests/postgres_account_link_write.rs new file mode 100644 index 00000000..b2d7ee49 --- /dev/null +++ b/tests/postgres_account_link_write.rs @@ -0,0 +1,762 @@ +//! Real `PostgreSQL` contract for the hosted dual-proof account-link write path. +//! +//! A buyer who proves control of both the anonymous session and a Keyverse +//! account must keep that link after process restart, and a later login with +//! the same valid account proof must recover the same product-owned participant. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::account_link::AuthenticatedAccountControl; +use psychometrics_commons_runtime::account_link_write::{ + accept_account_linked_capability, grant_account_linked_capability, + persist_authorized_account_link, persist_authorized_account_unlink, + recover_participant_for_authenticated_account, AccountLinkWriteError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::postgres_participant_identity_link::{ + apply_participant_identity_link_migration, load_participant_identity_history, + IdentityLinkPersistenceDisposition, IdentityLinkPersistenceError, +}; +use std::sync::{Mutex, MutexGuard}; + +static ACCOUNT_LINK_WRITE_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn write_test_guard() -> MutexGuard<'static, ()> { + ACCOUNT_LINK_WRITE_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS account_link_write_test;\ + SET search_path TO account_link_write_test;", + ) + .unwrap(); + client +} + +fn reset_tables(client: &mut Client) { + client + .batch_execute( + "DROP TABLE IF EXISTS account_link_write_test.current_participant_identity_link;\ + DROP TABLE IF EXISTS account_link_write_test.participant_identity_link_end;\ + DROP TABLE IF EXISTS account_link_write_test.participant_identity_link;\ + DROP TABLE IF EXISTS account_link_write_test.assessment_participant;", + ) + .unwrap(); +} + +fn anonymous_participant() -> ParticipantRecord { + ParticipantRecord::new_anonymous( + "participant_identity_write", + "tenant_identity_write", + 10_000, + ) + .unwrap() +} + +fn anonymous_control() -> AnonymousSessionContext { + AnonymousSessionContext::new( + "tenant_identity_write", + "participant_identity_write", + "session_identity_write", + "anonymous_proof_write", + 11_000, + ) + .unwrap() +} + +fn authenticated_control() -> AuthenticatedAccountControl { + AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 11_000, + ) + .unwrap() +} + +#[test] +fn dual_proof_link_survives_restart_and_returning_account_recovers_same_participant() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + let disposition = persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(disposition, IdentityLinkPersistenceDisposition::Inserted); + assert_eq!(participant.participant_ref(), "participant_identity_write"); + assert_eq!( + participant.linked_subject_ref(), + Some("keyverse_subject_write") + ); + + let mut replay = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + let replayed = persist_authorized_account_link( + &mut transaction, + &mut replay, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(replayed, IdentityLinkPersistenceDisposition::Duplicate); + + let mut transaction = client.transaction().unwrap(); + let recovered = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap() + .expect("a returning account must recover the same participant"); + transaction.commit().unwrap(); + assert_eq!(recovered.participant_ref(), "participant_identity_write"); + assert_eq!(recovered.tenant_ref(), "tenant_identity_write"); + assert_eq!( + recovered.linked_subject_ref(), + Some("keyverse_subject_write") + ); + assert_eq!( + recovered.link_event_ref(), + Some("link_event_identity_write") + ); +} + +#[test] +fn expired_or_foreign_proof_fails_before_persist_or_recovery() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let expired_authenticated = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_300, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let expired = persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &expired_authenticated, + "link_event_identity_write", + 10_400, + ) + .unwrap_err(); + transaction.rollback().unwrap(); + assert!(matches!( + expired, + AccountLinkWriteError::Authorization( + psychometrics_commons_runtime::account_link::AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); + assert!(participant.linked_subject_ref().is_none()); + + let mut transaction = client.transaction().unwrap(); + let missing = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap(); + transaction.commit().unwrap(); + assert!( + missing.is_none(), + "an unused valid account proof must not invent a participant" + ); +} + +#[test] +fn subject_already_bound_stays_on_the_first_participant() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut first = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut first, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut second = ParticipantRecord::new_anonymous( + "participant_identity_write_beta", + "tenant_identity_write", + 10_000, + ) + .unwrap(); + let second_anonymous = AnonymousSessionContext::new( + "tenant_identity_write", + "participant_identity_write_beta", + "session_identity_write_beta", + "anonymous_proof_write_beta", + 11_000, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let error = persist_authorized_account_link( + &mut transaction, + &mut second, + &second_anonymous, + &authenticated_control(), + "link_event_identity_write_beta", + 10_450, + ) + .unwrap_err(); + transaction.rollback().unwrap(); + assert!(matches!( + error, + AccountLinkWriteError::Persistence(IdentityLinkPersistenceError::SubjectAlreadyBound) + )); + assert!( + second.linked_subject_ref().is_none(), + "durable rejection must leave the caller-owned participant unchanged and safe to reuse" + ); + + let mut transaction = client.transaction().unwrap(); + let recovered = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap() + .expect("the first participant remains the current binding after rollback"); + transaction.commit().unwrap(); + assert_eq!(recovered.participant_ref(), "participant_identity_write"); +} + +#[test] +fn expired_proof_after_successful_link_cannot_recover_the_participant() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let expired = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let error = recover_participant_for_authenticated_account(&mut transaction, &expired, 10_500) + .expect_err("an expired account proof must not look up a linked participant"); + transaction.rollback().unwrap(); + assert!(matches!( + error, + AccountLinkWriteError::Authorization( + psychometrics_commons_runtime::account_link::AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); +} + +#[test] +fn other_tenant_or_ended_subject_cannot_recover_the_linked_participant() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let foreign_tenant = AuthenticatedAccountControl::new( + "tenant_identity_foreign", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_foreign", + 11_000, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let missing = + recover_participant_for_authenticated_account(&mut transaction, &foreign_tenant, 10_600) + .unwrap(); + transaction.commit().unwrap(); + assert!( + missing.is_none(), + "a valid other-tenant proof must not recover this tenant's participant" + ); + + participant + .record_link_end( + "link_end_event_identity_write", + "unlink_evidence_identity_write", + 10_500, + ) + .unwrap(); + participant + .link_account( + "link_event_identity_rebound", + "keyverse_issuer_write", + "keyverse_subject_rebound", + "anonymous_proof_write", + "authenticated_proof_rebound", + 10_550, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + psychometrics_commons_runtime::postgres_participant_identity_link::persist_participant_identity_history( + &mut transaction, + &participant, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut transaction = client.transaction().unwrap(); + let ended = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap(); + transaction.commit().unwrap(); + assert!( + ended.is_none(), + "a still-valid proof for an ended subject must not recover the rebound participant" + ); + + let rebound = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_rebound", + "authenticated_proof_rebound", + 11_000, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let recovered = + recover_participant_for_authenticated_account(&mut transaction, &rebound, 10_600) + .unwrap() + .expect("the current rebound subject must recover the same participant"); + transaction.commit().unwrap(); + assert_eq!(recovered.participant_ref(), "participant_identity_write"); + assert_eq!( + recovered.linked_subject_ref(), + Some("keyverse_subject_rebound") + ); +} + +#[test] +fn authorized_unlink_survives_restart_and_ended_subject_cannot_recover() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut transaction = client.transaction().unwrap(); + let disposition = persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(disposition, IdentityLinkPersistenceDisposition::Inserted); + assert!(participant.linked_subject_ref().is_none()); + + let mut replay = anonymous_participant(); + replay + .link_account( + "link_event_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "anonymous_proof_write", + "authenticated_proof_write", + 10_400, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let replayed = persist_authorized_account_unlink( + &mut transaction, + &mut replay, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(replayed, IdentityLinkPersistenceDisposition::Duplicate); + assert!(replay.linked_subject_ref().is_none()); + + let mut transaction = client.transaction().unwrap(); + let ended = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap(); + transaction.commit().unwrap(); + assert!( + ended.is_none(), + "a still-valid proof must not recover a participant after authorized unlink" + ); +} + +#[test] +fn expired_proof_cannot_unlink_a_persisted_current_binding() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let expired = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_write", + "authenticated_proof_write", + 10_500, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let error = persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &expired, + "link_end_event_identity_write", + 10_500, + ) + .expect_err("an expired account proof must not persist an unlink"); + transaction.rollback().unwrap(); + assert!(matches!( + error, + AccountLinkWriteError::Authorization( + psychometrics_commons_runtime::account_link::AccountLinkAuthorizationError::AuthenticatedProofExpired + ) + )); + + let mut transaction = client.transaction().unwrap(); + let recovered = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap() + .expect("a rejected unlink must leave the current binding recoverable"); + transaction.commit().unwrap(); + assert_eq!(recovered.participant_ref(), "participant_identity_write"); +} + +#[test] +fn ended_subject_proof_cannot_unlink_a_rebound_current_binding() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + transaction.commit().unwrap(); + + let rebound = AuthenticatedAccountControl::new( + "tenant_identity_write", + "keyverse_issuer_write", + "keyverse_subject_rebound", + "authenticated_proof_rebound", + 11_000, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &rebound, + "link_event_identity_rebound", + 10_550, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &authenticated_control(), + "link_end_event_identity_stale", + 10_600, + ) + .expect_err("an ended subject's proof must not unlink the rebound current binding"); + transaction.rollback().unwrap(); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); + + let mut transaction = client.transaction().unwrap(); + let recovered = + recover_participant_for_authenticated_account(&mut transaction, &rebound, 10_700) + .unwrap() + .expect("the rebound current binding must remain recoverable"); + transaction.commit().unwrap(); + assert_eq!( + recovered.linked_subject_ref(), + Some("keyverse_subject_rebound") + ); +} + +#[test] +fn persisted_unlink_invalidates_a_previously_granted_account_capability() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_450) + .unwrap() + .expect("a persisted current binding must grant an account-linked capability"); + accept_account_linked_capability(&participant, &capability, &authenticated_control(), 10_460) + .expect("the grant must remain valid while the current binding is stored"); + + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + transaction.commit().unwrap(); + + let error = accept_account_linked_capability( + &participant, + &capability, + &authenticated_control(), + 10_550, + ) + .expect_err("persisted unlink must invalidate the pre-unlink account-linked capability"); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); + + let mut transaction = client.transaction().unwrap(); + let recovered = recover_participant_for_authenticated_account( + &mut transaction, + &authenticated_control(), + 10_600, + ) + .unwrap(); + transaction.commit().unwrap(); + assert!( + recovered.is_none(), + "recover after persisted unlink must not return the participant" + ); + + let mut transaction = client.transaction().unwrap(); + let reloaded = load_participant_identity_history( + &mut transaction, + "participant_identity_write", + "tenant_identity_write", + ) + .unwrap() + .expect("unlink must keep the stable participant history loadable"); + transaction.commit().unwrap(); + let reloaded_error = + accept_account_linked_capability(&reloaded, &capability, &authenticated_control(), 10_650) + .expect_err("accept against reloaded history must fail closed after persisted unlink"); + assert!(matches!( + reloaded_error, + AccountLinkWriteError::NoCurrentBinding + )); +} + +#[test] +fn persisted_same_subject_relink_rejects_the_ended_account_capability() { + let _guard = write_test_guard(); + let mut client = test_client(); + reset_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut participant = anonymous_participant(); + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_write", + 10_400, + ) + .unwrap(); + transaction.commit().unwrap(); + + let ended_capability = + grant_account_linked_capability(&participant, &authenticated_control(), 10_450) + .unwrap() + .expect("the first persisted binding must grant an account-linked capability"); + + let mut transaction = client.transaction().unwrap(); + persist_authorized_account_unlink( + &mut transaction, + &mut participant, + &authenticated_control(), + "link_end_event_identity_write", + 10_500, + ) + .unwrap(); + persist_authorized_account_link( + &mut transaction, + &mut participant, + &anonymous_control(), + &authenticated_control(), + "link_event_identity_relink", + 10_700, + ) + .unwrap(); + transaction.commit().unwrap(); + + let mut transaction = client.transaction().unwrap(); + let reloaded = load_participant_identity_history( + &mut transaction, + "participant_identity_write", + "tenant_identity_write", + ) + .unwrap() + .expect("same-subject relink must keep the participant history loadable"); + transaction.commit().unwrap(); + assert_eq!( + reloaded.link_event_ref(), + Some("link_event_identity_relink") + ); + + let current_capability = + grant_account_linked_capability(&reloaded, &authenticated_control(), 10_750) + .unwrap() + .expect("the reloaded same-subject binding must grant a capability for the new event"); + assert_eq!( + current_capability.link_event_ref(), + "link_event_identity_relink" + ); + accept_account_linked_capability( + &reloaded, + ¤t_capability, + &authenticated_control(), + 10_760, + ) + .expect("the new persisted binding must accept the grant issued for that event"); + + let error = accept_account_linked_capability( + &reloaded, + &ended_capability, + &authenticated_control(), + 10_770, + ) + .expect_err( + "persisted same-subject relink must reject the ended grant so subject match cannot hide a missing link-event check", + ); + assert!(matches!(error, AccountLinkWriteError::NoCurrentBinding)); +} diff --git a/tests/postgres_participant_identity_link.rs b/tests/postgres_participant_identity_link.rs new file mode 100644 index 00000000..2f383632 --- /dev/null +++ b/tests/postgres_participant_identity_link.rs @@ -0,0 +1,771 @@ +//! Real `PostgreSQL` contract for append-only participant identity-link history. +//! +//! A buyer who links an anonymous assessment to a Keyverse account must still +//! see that link after process restart. Historical participant identity must +//! stay stable across link, unlink, and relink. + +use postgres::{Client, IsolationLevel, NoTls}; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::postgres_participant_identity_link::{ + apply_participant_identity_link_migration, load_participant_by_current_identity_subject, + load_participant_identity_history, persist_participant_identity_history, + IdentityLinkPersistenceDisposition, IdentityLinkPersistenceError, +}; +use std::sync::{Mutex, MutexGuard}; + +static IDENTITY_LINK_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn identity_link_test_guard() -> MutexGuard<'static, ()> { + IDENTITY_LINK_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS identity_link_persistence_test;\ + SET search_path TO identity_link_persistence_test;", + ) + .unwrap(); + client +} + +fn reset_identity_link_tables(client: &mut Client) { + client + .batch_execute( + "DROP TABLE IF EXISTS identity_link_persistence_test.current_participant_identity_link;\ + DROP TABLE IF EXISTS identity_link_persistence_test.participant_identity_link_end;\ + DROP TABLE IF EXISTS identity_link_persistence_test.participant_identity_link;\ + DROP TABLE IF EXISTS identity_link_persistence_test.assessment_participant;", + ) + .unwrap(); +} + +fn drop_current_projection(client: &mut Client) { + client + .batch_execute( + "DELETE FROM identity_link_persistence_test.current_participant_identity_link;", + ) + .unwrap(); +} + +fn current_projection( + client: &mut Client, + participant_ref: &str, +) -> Option<(String, String, String)> { + client + .query_opt( + "SELECT identity_link_ref, identity_issuer, identity_subject_ref \ + FROM identity_link_persistence_test.current_participant_identity_link \ + WHERE participant_ref = $1", + &[&participant_ref], + ) + .unwrap() + .map(|row| (row.get(0), row.get(1), row.get(2))) +} + +fn anonymous_participant() -> ParticipantRecord { + ParticipantRecord::new_anonymous( + "participant_identity_alpha", + "tenant_identity_alpha", + 10_000, + ) + .unwrap() +} + +fn anonymous_participant_beta() -> ParticipantRecord { + ParticipantRecord::new_anonymous("participant_identity_beta", "tenant_identity_alpha", 10_000) + .unwrap() +} + +fn linked_participant() -> ParticipantRecord { + let mut participant = anonymous_participant(); + participant + .link_account( + "link_event_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + "anonymous_proof_identity_alpha", + "authenticated_proof_identity_alpha", + 10_100, + ) + .unwrap(); + participant +} + +fn relinked_participant() -> ParticipantRecord { + let mut participant = linked_participant(); + participant + .record_link_end( + "link_end_event_identity_alpha", + "unlink_evidence_identity_alpha", + 10_200, + ) + .unwrap(); + participant + .link_account( + "link_event_identity_gamma", + "keyverse_issuer_gamma", + "keyverse_subject_gamma", + "anonymous_proof_identity_gamma", + "authenticated_proof_identity_gamma", + 10_300, + ) + .unwrap(); + participant +} + +fn persist_ok( + client: &mut Client, + participant: &ParticipantRecord, +) -> IdentityLinkPersistenceDisposition { + let mut transaction = client.transaction().unwrap(); + let disposition = persist_participant_identity_history(&mut transaction, participant).unwrap(); + transaction.commit().unwrap(); + disposition +} + +fn persist_err( + client: &mut Client, + participant: &ParticipantRecord, +) -> IdentityLinkPersistenceError { + let mut transaction = client.transaction().unwrap(); + let error = persist_participant_identity_history(&mut transaction, participant).unwrap_err(); + transaction.rollback().unwrap(); + error +} + +fn load_by_subject_ok( + client: &mut Client, + tenant_ref: &str, + identity_issuer: &str, + identity_subject_ref: &str, +) -> Option { + let mut transaction = client.transaction().unwrap(); + let loaded = load_participant_by_current_identity_subject( + &mut transaction, + tenant_ref, + identity_issuer, + identity_subject_ref, + ) + .unwrap(); + transaction.commit().unwrap(); + loaded +} + +fn load_ok(client: &mut Client, participant_ref: &str, tenant_ref: &str) -> ParticipantRecord { + let mut transaction = client.transaction().unwrap(); + let loaded = load_participant_identity_history(&mut transaction, participant_ref, tenant_ref) + .unwrap() + .expect("persisted participant identity history must reload"); + transaction.commit().unwrap(); + loaded +} + +#[test] +fn anonymous_participant_survives_restart_without_inventing_a_link() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let participant = anonymous_participant(); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Inserted + ); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Duplicate + ); + + let loaded = load_ok( + &mut client, + participant.participant_ref(), + participant.tenant_ref(), + ); + assert_eq!(loaded.participant_ref(), "participant_identity_alpha"); + assert_eq!(loaded.tenant_ref(), "tenant_identity_alpha"); + assert_eq!(loaded.created_at_unix_ms(), 10_000); + assert!(loaded.linked_subject_ref().is_none()); + assert!(loaded.link_history().is_empty()); + assert!(loaded.link_end_history().is_empty()); +} + +#[test] +fn linked_account_reloads_after_restart_without_rewriting_participant_identity() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let participant = linked_participant(); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Inserted + ); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Duplicate + ); + + let loaded = load_ok( + &mut client, + participant.participant_ref(), + participant.tenant_ref(), + ); + assert_eq!(loaded.participant_ref(), participant.participant_ref()); + assert_eq!(loaded.linked_issuer_ref(), Some("keyverse_issuer_alpha")); + assert_eq!(loaded.linked_subject_ref(), Some("keyverse_subject_alpha")); + assert_eq!(loaded.link_event_ref(), Some("link_event_identity_alpha")); + assert_eq!(loaded.link_history().len(), 1); + assert_eq!( + loaded.link_history()[0].anonymous_proof_ref(), + "anonymous_proof_identity_alpha" + ); + assert_eq!( + loaded.link_history()[0].authenticated_proof_ref(), + "authenticated_proof_identity_alpha" + ); + assert_eq!(loaded.link_history()[0].linked_at_unix_ms(), 10_100); + assert!(loaded.link_end_history().is_empty()); +} + +#[test] +fn conflicting_link_replay_fails_closed_and_preserves_the_original_evidence() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + + let mut conflicting = anonymous_participant(); + conflicting + .link_account( + "link_event_identity_alpha", + "keyverse_issuer_beta", + "keyverse_subject_alpha", + "anonymous_proof_identity_alpha", + "authenticated_proof_identity_alpha", + 10_100, + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &conflicting), + IdentityLinkPersistenceError::ConflictingReplay + )); + + let loaded = load_ok( + &mut client, + "participant_identity_alpha", + "tenant_identity_alpha", + ); + assert_eq!(loaded.linked_issuer_ref(), Some("keyverse_issuer_alpha")); +} + +#[test] +fn unlink_and_relink_reload_as_append_only_history() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let participant = relinked_participant(); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Inserted + ); + assert_eq!( + persist_ok(&mut client, &participant), + IdentityLinkPersistenceDisposition::Duplicate + ); + + let relinked = load_ok( + &mut client, + participant.participant_ref(), + participant.tenant_ref(), + ); + assert_eq!(relinked.participant_ref(), "participant_identity_alpha"); + assert_eq!( + relinked.linked_subject_ref(), + Some("keyverse_subject_gamma") + ); + assert_eq!(relinked.linked_issuer_ref(), Some("keyverse_issuer_gamma")); + assert_eq!(relinked.link_event_ref(), Some("link_event_identity_gamma")); + assert_eq!(relinked.link_history().len(), 2); + assert_eq!(relinked.link_end_history().len(), 1); + assert_eq!( + relinked.link_end_history()[0].linked_event_ref(), + "link_event_identity_alpha" + ); + assert_eq!( + relinked.link_end_history()[0].evidence_ref(), + "unlink_evidence_identity_alpha" + ); +} + +#[test] +fn unlinked_subject_can_become_current_on_another_participant() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut previous = linked_participant(); + previous + .record_link_end( + "link_end_event_identity_alpha", + "unlink_evidence_identity_alpha", + 10_200, + ) + .unwrap(); + persist_ok(&mut client, &previous); + + let mut next = ParticipantRecord::new_anonymous( + "participant_identity_beta", + "tenant_identity_alpha", + 10_000, + ) + .unwrap(); + next.link_account( + "link_event_identity_beta", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + "anonymous_proof_identity_beta", + "authenticated_proof_identity_beta", + 10_250, + ) + .unwrap(); + assert_eq!( + persist_ok(&mut client, &next), + IdentityLinkPersistenceDisposition::Inserted + ); + + let previous_loaded = load_ok( + &mut client, + previous.participant_ref(), + previous.tenant_ref(), + ); + let next_loaded = load_ok(&mut client, next.participant_ref(), next.tenant_ref()); + assert!(previous_loaded.linked_subject_ref().is_none()); + assert_eq!( + next_loaded.linked_subject_ref(), + Some("keyverse_subject_alpha") + ); + assert_eq!( + previous_loaded.participant_ref(), + "participant_identity_alpha" + ); + assert_eq!(next_loaded.participant_ref(), "participant_identity_beta"); + let found = load_by_subject_ok( + &mut client, + "tenant_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .expect("the reused subject must resolve to the participant that currently holds it"); + assert_eq!(found.participant_ref(), "participant_identity_beta"); +} + +#[test] +fn returning_account_finds_the_same_participant_by_current_subject() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + + let found = load_by_subject_ok( + &mut client, + "tenant_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .expect("a returning Keyverse login must find the stored participant"); + assert_eq!(found.participant_ref(), "participant_identity_alpha"); + assert_eq!(found.linked_subject_ref(), Some("keyverse_subject_alpha")); + + assert!(load_by_subject_ok( + &mut client, + "tenant_identity_other", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .is_none()); +} + +#[test] +fn ended_or_replaced_subject_is_not_findable_until_it_is_current_again() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &relinked_participant()); + + assert!(load_by_subject_ok( + &mut client, + "tenant_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .is_none()); + + let found = load_by_subject_ok( + &mut client, + "tenant_identity_alpha", + "keyverse_issuer_gamma", + "keyverse_subject_gamma", + ) + .expect("the current relinked account must resolve to the same participant"); + assert_eq!(found.participant_ref(), "participant_identity_alpha"); + assert_eq!(found.linked_subject_ref(), Some("keyverse_subject_gamma")); +} + +#[test] +fn one_external_subject_cannot_be_current_on_two_participants() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + + let mut other = ParticipantRecord::new_anonymous( + "participant_identity_beta", + "tenant_identity_alpha", + 10_000, + ) + .unwrap(); + other + .link_account( + "link_event_identity_beta", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + "anonymous_proof_identity_beta", + "authenticated_proof_identity_beta", + 10_150, + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &other), + IdentityLinkPersistenceError::SubjectAlreadyBound + )); +} + +#[test] +fn returning_account_finds_participant_after_current_projection_loss() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + drop_current_projection(&mut client); + + let found = load_by_subject_ok( + &mut client, + "tenant_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .expect("append-only history, not the derived projection, is the source of truth"); + assert_eq!(found.participant_ref(), "participant_identity_alpha"); + assert_eq!(found.linked_subject_ref(), Some("keyverse_subject_alpha")); +} + +#[test] +fn lost_current_projection_cannot_rebind_subject_to_another_participant() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + drop_current_projection(&mut client); + + let mut other = ParticipantRecord::new_anonymous( + "participant_identity_beta", + "tenant_identity_alpha", + 10_000, + ) + .unwrap(); + other + .link_account( + "link_event_identity_beta", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + "anonymous_proof_identity_beta", + "authenticated_proof_identity_beta", + 10_150, + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &other), + IdentityLinkPersistenceError::SubjectAlreadyBound + )); + + let original = load_ok( + &mut client, + "participant_identity_alpha", + "tenant_identity_alpha", + ); + assert_eq!(original.participant_ref(), "participant_identity_alpha"); + assert_eq!( + original.linked_subject_ref(), + Some("keyverse_subject_alpha") + ); +} + +#[test] +fn exact_replay_restores_missing_current_projection() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + drop_current_projection(&mut client); + assert!(current_projection(&mut client, "participant_identity_alpha").is_none()); + + assert_eq!( + persist_ok(&mut client, &linked_participant()), + IdentityLinkPersistenceDisposition::Duplicate + ); + + let restored = current_projection(&mut client, "participant_identity_alpha") + .expect("exact replay must restore the derived current projection"); + assert_eq!(restored.0, "link_event_identity_alpha"); + assert_eq!(restored.1, "keyverse_issuer_alpha"); + assert_eq!(restored.2, "keyverse_subject_alpha"); +} + +#[test] +fn exact_replay_of_relink_restores_only_the_current_projection() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &relinked_participant()); + drop_current_projection(&mut client); + + assert_eq!( + persist_ok(&mut client, &relinked_participant()), + IdentityLinkPersistenceDisposition::Duplicate + ); + + let restored = current_projection(&mut client, "participant_identity_alpha") + .expect("relink replay must restore only the current account projection"); + assert_eq!(restored.0, "link_event_identity_gamma"); + assert_eq!(restored.1, "keyverse_issuer_gamma"); + assert_eq!(restored.2, "keyverse_subject_gamma"); +} + +#[test] +fn exact_replay_clears_stale_projection_after_unlink() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut unlinked = linked_participant(); + unlinked + .record_link_end( + "link_end_event_identity_alpha", + "unlink_evidence_identity_alpha", + 10_200, + ) + .unwrap(); + persist_ok(&mut client, &unlinked); + client + .execute( + "INSERT INTO identity_link_persistence_test.current_participant_identity_link (\ + participant_ref, identity_link_ref, tenant_ref, identity_issuer, \ + identity_subject_ref\ + ) VALUES ($1, $2, $3, $4, $5)", + &[ + &"participant_identity_alpha", + &"link_event_identity_alpha", + &"tenant_identity_alpha", + &"keyverse_issuer_alpha", + &"keyverse_subject_alpha", + ], + ) + .unwrap(); + + assert_eq!( + persist_ok(&mut client, &unlinked), + IdentityLinkPersistenceDisposition::Duplicate + ); + assert!( + current_projection(&mut client, "participant_identity_alpha").is_none(), + "unlink replay must drop a stale current projection" + ); +} + +#[test] +fn two_unterminated_links_for_one_subject_fail_closed_on_lookup() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + client + .execute( + "INSERT INTO identity_link_persistence_test.assessment_participant \ + (participant_ref, tenant_ref, created_at_unix_ms) \ + VALUES ($1, $2, $3)", + &[ + &"participant_identity_beta", + &"tenant_identity_alpha", + &10_000_i64, + ], + ) + .unwrap(); + client + .execute( + "INSERT INTO identity_link_persistence_test.participant_identity_link (\ + identity_link_ref, participant_ref, tenant_ref, identity_issuer, \ + identity_subject_ref, anonymous_proof_ref, authenticated_proof_ref, \ + linked_at_unix_ms\ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + &[ + &"link_event_identity_corrupt", + &"participant_identity_beta", + &"tenant_identity_alpha", + &"keyverse_issuer_alpha", + &"keyverse_subject_alpha", + &"anonymous_proof_identity_corrupt", + &"authenticated_proof_identity_corrupt", + &10_150_i64, + ], + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = load_participant_by_current_identity_subject( + &mut transaction, + "tenant_identity_alpha", + "keyverse_issuer_alpha", + "keyverse_subject_alpha", + ) + .unwrap_err(); + transaction.rollback().unwrap(); + assert!(matches!( + error, + IdentityLinkPersistenceError::CorruptHistory + )); +} + +#[test] +fn link_end_cannot_attach_to_another_participants_link() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + persist_ok(&mut client, &anonymous_participant_beta()); + + let error = client + .execute( + "INSERT INTO identity_link_persistence_test.participant_identity_link_end (\ + link_end_event_ref, participant_ref, linked_event_ref, evidence_ref, \ + ended_at_unix_ms\ + ) VALUES ($1, $2, $3, $4, $5)", + &[ + &"link_end_event_identity_cross", + &"participant_identity_beta", + &"link_event_identity_alpha", + &"unlink_evidence_identity_cross", + &10_200_i64, + ], + ) + .expect_err("a link-end must belong to the same participant as the ended link"); + assert!(error.as_db_error().is_some()); +} + +#[test] +fn other_tenant_cannot_load_or_rebind_participant_identity() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + persist_ok(&mut client, &linked_participant()); + + let mut transaction = client.transaction().unwrap(); + let loaded = load_participant_identity_history( + &mut transaction, + "participant_identity_alpha", + "tenant_identity_other", + ) + .unwrap(); + transaction.commit().unwrap(); + assert!(loaded.is_none()); + + let rebound = ParticipantRecord::new_anonymous( + "participant_identity_alpha", + "tenant_identity_other", + 10_000, + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &rebound), + IdentityLinkPersistenceError::ConflictingReplay + )); +} + +#[test] +fn migration_indexes_history_subject_lookup() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let indexed: bool = client + .query_one( + "SELECT EXISTS (\ + SELECT 1 FROM pg_indexes \ + WHERE schemaname = 'identity_link_persistence_test' \ + AND indexname = 'participant_identity_link_current_subject_lookup'\ + )", + &[], + ) + .unwrap() + .get(0); + assert!( + indexed, + "unterminated-subject lookup must use an indexed history path" + ); +} + +#[test] +fn serializable_isolation_is_rejected() { + let _guard = identity_link_test_guard(); + let mut client = test_client(); + reset_identity_link_tables(&mut client); + apply_participant_identity_link_migration(&mut client).unwrap(); + + let mut transaction = client + .build_transaction() + .isolation_level(IsolationLevel::Serializable) + .start() + .unwrap(); + let error = persist_participant_identity_history(&mut transaction, &anonymous_participant()) + .unwrap_err(); + transaction.rollback().unwrap(); + assert!(matches!( + error, + IdentityLinkPersistenceError::UnsupportedIsolationLevel + )); +}