diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..451ec0e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +/.netlify diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..b13f600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added +- 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, 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. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 72bc73c2..1ac63ccf 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -32,7 +32,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Continuous scores remain source of truth; Personality Style is presentation | PRD §3.2 | Measurement Governance; AI Governance | ADR-0018 | Target product narrative mapping; numeric source remains External fast-mlsirm contract | | 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 | | 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** #133 persists append-only `participant_identity_link` history, reloads it after restart, and recovers the participant from unterminated history even when the derived current projection is missing; 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 | @@ -66,7 +66,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`; `AssessmentSession` creation copies exact published release/version/locale provenance and fails closed on unpublished eligibility or locale mismatch | session-creation persistence/API integration test | | 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** #124 `src/postgres_participant_identity_link.rs` persists and reloads that history without rewriting `participant_ref` | 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 | @@ -103,6 +103,7 @@ src/lib.rs ├── narrative.rs # deterministic Personality Style identity/key ├── 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_health.rs # PostgreSQL major/write-readiness and relation-integrity probe ├── postgres_inbox_consumption.rs # PostgreSQL inbox consumption distinct from receipt @@ -128,11 +129,11 @@ migrations/ └── 0012_integration_consumption.sql ``` -Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal normalized ingestion, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. +Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal normalized ingestion, runtime health transports/metrics, and Measurement Workbench orchestration. ### Active implementation work that is not protected-main truth -**Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. +**Active PR** #133 participant identity-link persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer #133 over #124 and #114. `migrations/0022_participant_identity_link.sql` and `src/postgres_participant_identity_link.rs` persist `assessment_participant`, append-only `participant_identity_link` / `participant_identity_link_end` evidence, and a derived `current_participant_identity_link` projection. Persist applies each link and then its matching ends so a complete unlink+relink aggregate can be written in one transaction. Unterminated issuer-scoped subjects are the lookup and uniqueness source of truth, so a returning account still recovers the same `participant_ref` when the derived projection is missing. HTTP account-link transport and live Keyverse token verification remain outside this slice. Migration `0021` remains reserved for #113 scoring-job health indexes. ## 5. ADR traceability by concern @@ -220,6 +221,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..0de3e753 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 #133 adds append-only `participant_identity_link` / `participant_identity_link_end` persistence, derived current-link projection, lifecycle-order persist of a complete unlink+relink aggregate, restart reload, and current-subject lookup from unterminated history through `src/postgres_participant_identity_link.rs`. Prefer #133 over #124 and #114. +- `PLANNED`: durable HTTP transport, unlink/relink/recovery operator 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. HTTP transport, live Keyverse verification, operator recovery commands, and backup/restore evidence remain target work until separately implemented, reviewed, and merged. ## Alternatives considered @@ -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 #133 `migrations/0022_participant_identity_link.sql` and `src/postgres_participant_identity_link.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 8c2c3510..04a4d741 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -58,6 +58,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 #133 adds `migrations/0022_participant_identity_link.sql` and `src/postgres_participant_identity_link.rs`. Prefer this head over #124 and #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. 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. 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 8f21954a..bd71149c 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -426,7 +426,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Physical `migrations/0003_data_rights_propagation.sql` stores requested-state identity plus one local outbox event per dependent system; verification, processing, completion, and dependent-system execution remain Target. - `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. +- `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. Active PR #133 `migrations/0022_participant_identity_link.sql` persists append-only link and link-end rows plus a derived current projection, and treats unterminated history as the lookup/uniqueness source of truth; 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; `integration_consumption` pending/processing/completed/quarantined persistence and expire-and-reclaim of a crashed processing claim exist only on this Active PR until merged. diff --git a/migrations/0022_participant_identity_link.sql b/migrations/0022_participant_identity_link.sql new file mode 100644 index 00000000..0be7ccd9 --- /dev/null +++ b/migrations/0022_participant_identity_link.sql @@ -0,0 +1,160 @@ +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 + ) +); diff --git a/src/lib.rs b/src/lib.rs index 8b586a68..36738293 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod postgres_inbox_consumption; pub mod postgres_instrument_release; pub mod postgres_integration; pub mod postgres_item_delivery; +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 9cf24a1f..560dc73f 100644 --- a/src/participant.rs +++ b/src/participant.rs @@ -126,6 +126,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. @@ -166,6 +185,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..f9b03ae5 --- /dev/null +++ b/src/postgres_participant_identity_link.rs @@ -0,0 +1,661 @@ +//! `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. 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; + } + } + } + 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 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/postgres_participant_identity_link.rs b/tests/postgres_participant_identity_link.rs new file mode 100644 index 00000000..a6ac7f62 --- /dev/null +++ b/tests/postgres_participant_identity_link.rs @@ -0,0 +1,649 @@ +//! 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 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 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 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 + )); +}