diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..30655971 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 +- Anonymous session command authorization compares the verified actor to the supplied participant tenant/owner and session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope and does not claim those aggregates were store-loaded. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #133. - 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..927cc327 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -134,6 +134,8 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** #86 anonymous-session resource authorization, plus follow-up #104, #118, #135, and honesty successor #144 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. 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` and append-only identity-link history remains Active PR #133. HTTP transport remains outside this slice. + ## 5. ADR traceability by concern | Concern | Governing ADR(s) | diff --git a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md index 05a2c795..1595043a 100644 --- a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md +++ b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md @@ -37,6 +37,14 @@ The mapping between operational and research identities is stored in a restricte Keyverse claims establish authenticated subject and coarse scopes. Psychometrics Commons performs resource-level decisions for instrument administration, result ownership, research roles, data export, deletion, and release approval. A Keyverse administrator is not automatically a Psychometrics Commons research data steward. +Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. This slice is an as-built library: `authorize_anonymous_session_command` / `apply_anonymous_session_command` compare the verified actor to the supplied `ParticipantRecord` and `AssessmentSession`. They do not accept a caller-built `ResourceScope`. They do not prove those records were loaded from the store. Persist/reload of `assessment_participant` remains Active PR #133. HTTP transport remains Target. + +Fail-closed classification order is a product contract: trusted server time, exclusive expiry, supplied-participant tenant, session/participant ownership, actor participant, then session identity. Named tests: `anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time`, `anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsistent_loaded_pair_as_cross_tenant`, and `anonymous_command_authorization_rejects_actor_when_loaded_participant_and_session_agree`. + +Trusted server time and exclusive authenticator validity follow NIST SP 800-63-4 (Temoshok et al., 2025). That publication does not specify the tenant-then-owner-then-session error order. + +The lower-level `authorize_anonymous_session(actor, resource, now)` check remains for callers that already hold a stored assessment-session `ResourceScope`. It is not sufficient by itself for a command against a different loaded session. + ## Invariants 1. Core anonymous assessment does not require a Keyverse account. @@ -61,6 +69,7 @@ The product does not rely on blanket PII masking that destroys operational utili - token validation and audience-confusion tests; - cross-tenant authorization tests; +- anonymous command-path tests that classify tenant before ownership and leave the session unmutated on authorization failure; - anonymous-to-account linking replay and conflict tests; - research-release joinability tests; - account deletion/export end-to-end tests. @@ -74,3 +83,7 @@ The product does not rely on blanket PII masking that destroys operational utili ## Reversal conditions Revisit if Keyverse cannot meet a deployment's residency or federation requirements. A replacement must remain OIDC-compatible and preserve subject-mapping semantics without moving credentials into the product database. + +## References + +Temoshok, D., Proud-Madruga, D., Choong, Y.-Y., Galluzzo, R., Gupta, S., LaSalle, C., Lefkovitz, N., & Regenscheid, A. (2025). *Digital identity guidelines* (NIST Special Publication 800-63-4). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63-4 diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 8f21954a..d7b8cc8c 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` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. Active PR #133 persists `assessment_participant` plus append-only link history; it is not protected-main truth until integrated. Prefer that head over #114 or #124. - `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/docs/architecture/SECURITY_AND_DATA.md b/docs/architecture/SECURITY_AND_DATA.md index c37236a1..4c986c50 100644 --- a/docs/architecture/SECURITY_AND_DATA.md +++ b/docs/architecture/SECURITY_AND_DATA.md @@ -100,7 +100,7 @@ sharing_token_audience/expiry if used Rules: -- Tenant context for state-changing requests is derived from authenticated authorization, not an untrusted body field or implicit default. +- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the supplied `ParticipantRecord` argument. The command gate does not prove those records were store-loaded. Active PR #133 persists the `assessment_participant` row and append-only identity-link history; prefer that head over #114 or #124. Tenant is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. - Public opaque identifiers are identifiers, not authorization capabilities. - Research steward, instrument publisher, participant result owner, and identity administrator are distinct authorities. - A sharing link, if introduced, must be revocable, scoped to an exact resource/audience, expire by default, and not reveal raw responses unless explicitly permitted by the participant and product policy. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index 13988939..35bb0946 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -278,6 +278,8 @@ Export cannot complete with a deletion-retention exception. Exact terminal repla ## 6. Anonymous assessment happy-path sequence +This sequence is target transport. The as-built command gate compares supplied records and does not perform the load. + ```mermaid sequenceDiagram autonumber @@ -295,6 +297,12 @@ sequenceDiagram DB-->>A: session_ref + pinned instrument version A-->>C: session resource + item-delivery contract + C->>A: activate session + A->>DB: load assessment_participant + assessment_session + A->>A: authorize anonymous command from loaded records + A->>DB: atomically state=Active + A-->>C: activation accepted + loop each presented item / response A->>DB: append ItemDeliveryEvent(sequence, item version, payload digest) P->>C: answer presented item @@ -305,6 +313,8 @@ sequenceDiagram end C->>A: complete session + A->>DB: load assessment_participant + assessment_session + A->>A: authorize anonymous command from loaded records A->>DB: atomically state=Completed + freeze ResponseSnapshot + outbox scoring request A-->>C: completion accepted / scoring pending diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs new file mode 100644 index 00000000..264ac5b4 --- /dev/null +++ b/src/anonymous_authorization.rs @@ -0,0 +1,235 @@ +//! Product authorization for an already-verified anonymous assessment session. +//! +//! An anonymous participant receives a short-lived proof when an assessment session is created. +//! Another part of the application verifies that proof and builds an [`AnonymousSessionContext`]. +//! This module does **not** read or verify the raw secret. Instead, it answers a narrower question: +//! "May this verified anonymous session act on this exact assessment-session resource right now?" +//! +//! The answer is deliberately limited. The verified session may act only on the one assessment +//! session named in its context. It cannot be reused to read results, change consent, exercise data +//! rights, administer a tenant, or access another participant's session. +//! +//! Transports that already loaded a participant and session should call +//! [`authorize_anonymous_session_command`]. That function compares the verified actor to the +//! supplied records so a matching invented [`ResourceScope`] cannot authorize a different +//! loaded session. It does not prove the records came from the product store. + +use crate::anonymous_session::AnonymousSessionContext; +use crate::authorization::{ResourceKind, ResourceScope}; +use crate::participant::ParticipantRecord; +use crate::session::{AssessmentSession, SessionCommand, SessionState, TransitionError}; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Fail-closed authorization error for a verified anonymous assessment session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AnonymousResourceAuthorizationError { + /// The caller did not provide a positive time obtained from the trusted server clock. + InvalidTimestamp, + /// The anonymous session had reached or passed its exclusive expiry time. + Expired, + /// The target resource belonged to another tenant. + CrossTenantDenied, + /// Anonymous-session access was requested for a resource other than an assessment session. + ResourceKindMismatch, + /// The target session belonged to another operational participant. + OwnerMismatch, + /// The target assessment-session reference differed from the session named by the proof. + SessionMismatch, +} + +impl Display for AnonymousResourceAuthorizationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidTimestamp => { + "anonymous resource authorization requires positive server time" + } + Self::Expired => "anonymous session authority is expired", + Self::CrossTenantDenied => { + "anonymous session authority does not match the resource tenant" + } + Self::ResourceKindMismatch => { + "anonymous session authority is limited to its assessment-session resource" + } + Self::OwnerMismatch => { + "anonymous session authority does not match the resource participant" + } + Self::SessionMismatch => { + "anonymous session authority does not match the resource session" + } + }) + } +} + +impl Error for AnonymousResourceAuthorizationError {} + +/// Allow a verified anonymous participant to act on one exact assessment session. +/// +/// Callers provide three values: +/// +/// - `actor`: an [`AnonymousSessionContext`] created only after the short-lived anonymous proof has +/// already been verified; +/// - `resource`: the [`ResourceScope`] for the assessment session the caller wants to use; and +/// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. +/// +/// For example, if the verified context names tenant `tenant_alpha`, participant +/// `participant_alpha`, and session `session_alpha`, this function allows access only to the +/// `session_alpha` assessment-session resource owned by that same participant in that same tenant. +/// A result resource or `session_beta` is denied even when the same caller presents the context. +/// +/// References in `actor` and `resource` are already in their validated, exact spelling because +/// their constructors reject non-canonical forms. This function therefore compares the exact +/// values instead of trimming, normalizing, or guessing aliases. +/// +/// Checks run in a stable fail-closed order: trusted server time, expiry, tenant, resource kind, +/// participant owner, then session identity. This order is part of the error contract used by +/// transports when more than one supplied property is wrong. +/// +/// # Errors +/// +/// Returns [`AnonymousResourceAuthorizationError`] when the trusted time is invalid, the verified +/// anonymous session has expired, or the requested resource differs from the exact +/// tenant/participant/session binding described above. +pub fn authorize_anonymous_session( + actor: &AnonymousSessionContext, + resource: &ResourceScope, + now_unix_ms: u64, +) -> Result<(), AnonymousResourceAuthorizationError> { + if now_unix_ms == 0 { + return Err(AnonymousResourceAuthorizationError::InvalidTimestamp); + } + if !actor.is_valid_at(now_unix_ms) { + return Err(AnonymousResourceAuthorizationError::Expired); + } + if actor.tenant_ref() != resource.tenant_ref() { + return Err(AnonymousResourceAuthorizationError::CrossTenantDenied); + } + if resource.kind() != ResourceKind::AssessmentSession { + return Err(AnonymousResourceAuthorizationError::ResourceKindMismatch); + } + if resource.owner_participant_ref() != Some(actor.participant_ref()) { + return Err(AnonymousResourceAuthorizationError::OwnerMismatch); + } + if resource.resource_ref() != actor.session_ref() { + return Err(AnonymousResourceAuthorizationError::SessionMismatch); + } + Ok(()) +} + +/// Allow a verified anonymous participant to command one supplied assessment session. +/// +/// Callers provide four values: +/// +/// - `actor`: an [`AnonymousSessionContext`] created only after the short-lived anonymous proof has +/// already been verified; +/// - `participant`: the [`ParticipantRecord`] the caller supplies for that command; +/// - `session`: the [`AssessmentSession`] the caller supplies for that command; and +/// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. +/// +/// The function compares the actor to those supplied records. It does **not** accept a +/// caller-built [`ResourceScope`]. It does not prove the records were loaded from the product +/// store; a transport can still construct both aggregates from the proof. Persist/reload of +/// `assessment_participant` remains Active PR #133. For example, a proof for `session_alpha` / +/// `participant_alpha` in `tenant_alpha` is allowed only when the supplied participant is that +/// same person in that same tenant and the supplied session is `session_alpha` owned by that +/// person. A session owned by `participant_beta`, or `session_beta` owned by the same person, +/// is denied. +/// +/// Checks run in a stable fail-closed order: trusted server time, expiry, supplied-participant +/// tenant, session/participant ownership, actor participant, then session identity. +/// Tenant is classified before ownership so a foreign-tenant record that also disagrees on +/// participant identity is reported as [`AnonymousResourceAuthorizationError::CrossTenantDenied`]. +/// +/// # Errors +/// +/// Returns [`AnonymousResourceAuthorizationError`] when trusted time is invalid, the verified +/// anonymous session has expired, the supplied participant belongs to another tenant, the +/// supplied session belongs to another participant, or the supplied session is not the session +/// named by the proof. +pub fn authorize_anonymous_session_command( + actor: &AnonymousSessionContext, + participant: &ParticipantRecord, + session: &AssessmentSession, + now_unix_ms: u64, +) -> Result<(), AnonymousResourceAuthorizationError> { + if now_unix_ms == 0 { + return Err(AnonymousResourceAuthorizationError::InvalidTimestamp); + } + if !actor.is_valid_at(now_unix_ms) { + return Err(AnonymousResourceAuthorizationError::Expired); + } + if actor.tenant_ref() != participant.tenant_ref() { + return Err(AnonymousResourceAuthorizationError::CrossTenantDenied); + } + if session.participant_ref() != participant.participant_ref() + || actor.participant_ref() != participant.participant_ref() + { + return Err(AnonymousResourceAuthorizationError::OwnerMismatch); + } + if actor.session_ref() != session.session_ref() { + return Err(AnonymousResourceAuthorizationError::SessionMismatch); + } + Ok(()) +} + +/// Fail-closed error for applying a session command after anonymous authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AnonymousSessionCommandError { + /// The verified anonymous session was not allowed to command the supplied session. + Authorization(AnonymousResourceAuthorizationError), + /// Authorization succeeded, but the lifecycle command was not legal for the current state. + Transition(TransitionError), +} + +impl Display for AnonymousSessionCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Authorization(error) => Display::fmt(error, formatter), + Self::Transition(error) => Display::fmt(error, formatter), + } + } +} + +impl Error for AnonymousSessionCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Authorization(error) => Some(error), + Self::Transition(error) => Some(error), + } + } +} + +/// Apply one session command only after the supplied session is authorized. +/// +/// Call this from an HTTP or messaging adapter after the short-lived anonymous proof has been +/// verified. Pass the participant and session records the caller holds. This function does not +/// prove the records were loaded from the product store. Authorization runs first. If it fails, +/// the session is left unchanged. If it succeeds, the existing session lifecycle rules decide +/// whether the command may change state. +/// +/// For example, a current proof for `session_alpha` may activate that supplied session. The same +/// proof cannot activate `session_beta`, and an expired proof cannot activate `session_alpha` even +/// though `Activate` is otherwise legal from `Created`. +/// +/// # Errors +/// +/// Returns [`AnonymousSessionCommandError::Authorization`] when the supplied records are not the +/// exact current anonymous session, or [`AnonymousSessionCommandError::Transition`] when the +/// command is not legal from the current lifecycle state. +pub fn apply_anonymous_session_command( + actor: &AnonymousSessionContext, + participant: &ParticipantRecord, + session: &mut AssessmentSession, + command_ref: &str, + sequence: u64, + command: SessionCommand, + now_unix_ms: u64, +) -> Result { + authorize_anonymous_session_command(actor, participant, session, now_unix_ms) + .map_err(AnonymousSessionCommandError::Authorization)?; + session + .apply_command(command_ref, sequence, command) + .map_err(AnonymousSessionCommandError::Transition) +} diff --git a/src/lib.rs b/src/lib.rs index 8b586a68..b8d2b8f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! contracts rather than reimplemented here. pub mod account_link; +pub mod anonymous_authorization; pub mod anonymous_session; pub mod authorization; pub mod consent; diff --git a/tests/anonymous_resource_authorization.rs b/tests/anonymous_resource_authorization.rs new file mode 100644 index 00000000..a87185b7 --- /dev/null +++ b/tests/anonymous_resource_authorization.rs @@ -0,0 +1,164 @@ +//! Contract tests for fail-closed anonymous assessment-session resource authorization. + +use psychometrics_commons_runtime::anonymous_authorization::{ + authorize_anonymous_session, AnonymousResourceAuthorizationError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::authorization::{ResourceKind, ResourceScope}; + +fn anonymous_context() -> AnonymousSessionContext { + AnonymousSessionContext::new( + "tenant_alpha", + "participant_alpha", + "session_alpha", + "anonymous_authorization_evidence_alpha", + 2_000, + ) + .unwrap() +} + +fn session_resource(tenant_ref: &str, participant_ref: &str, session_ref: &str) -> ResourceScope { + ResourceScope::participant_owned( + ResourceKind::AssessmentSession, + tenant_ref, + participant_ref, + session_ref, + ) + .unwrap() +} + +#[test] +fn current_anonymous_authority_may_manage_only_its_exact_session_resource() { + let context = anonymous_context(); + let resource = session_resource("tenant_alpha", "participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session(&context, &resource, 1_500), + Ok(()) + ); +} + +#[test] +fn anonymous_authority_fails_closed_for_zero_or_expired_server_time() { + let context = anonymous_context(); + let resource = session_resource("tenant_alpha", "participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session(&context, &resource, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session(&context, &resource, 2_000), + Err(AnonymousResourceAuthorizationError::Expired) + ); + assert_eq!( + authorize_anonymous_session(&context, &resource, 2_001), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} + +#[test] +fn anonymous_authority_never_crosses_tenant_or_participant_ownership() { + let context = anonymous_context(); + let foreign_tenant = session_resource("tenant_beta", "participant_alpha", "session_alpha"); + let foreign_owner = session_resource("tenant_alpha", "participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session(&context, &foreign_tenant, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); + assert_eq!( + authorize_anonymous_session(&context, &foreign_owner, 1_500), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_authorization_rejects_multiple_mismatches_in_documented_order() { + let context = anonymous_context(); + let foreign_tenant_result = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_beta", + "participant_alpha", + "result_alpha", + ) + .unwrap(); + let wrong_kind_and_owner = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_alpha", + "participant_beta", + "result_alpha", + ) + .unwrap(); + + assert_eq!( + authorize_anonymous_session(&context, &foreign_tenant_result, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); + assert_eq!( + authorize_anonymous_session(&context, &wrong_kind_and_owner, 1_500), + Err(AnonymousResourceAuthorizationError::ResourceKindMismatch) + ); +} + +#[test] +fn anonymous_authority_is_bound_to_one_exact_assessment_session() { + let context = anonymous_context(); + let other_session = session_resource("tenant_alpha", "participant_alpha", "session_beta"); + + assert_eq!( + authorize_anonymous_session(&context, &other_session, 1_500), + Err(AnonymousResourceAuthorizationError::SessionMismatch) + ); +} + +#[test] +fn anonymous_session_proof_cannot_be_reused_for_other_participant_resources() { + let context = anonymous_context(); + let result = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_alpha", + "participant_alpha", + "result_alpha", + ) + .unwrap(); + + assert_eq!( + authorize_anonymous_session(&context, &result, 1_500), + Err(AnonymousResourceAuthorizationError::ResourceKindMismatch) + ); +} + +#[test] +fn anonymous_resource_authorization_errors_are_stable_and_safe() { + let cases = [ + ( + AnonymousResourceAuthorizationError::InvalidTimestamp, + "anonymous resource authorization requires positive server time", + ), + ( + AnonymousResourceAuthorizationError::Expired, + "anonymous session authority is expired", + ), + ( + AnonymousResourceAuthorizationError::CrossTenantDenied, + "anonymous session authority does not match the resource tenant", + ), + ( + AnonymousResourceAuthorizationError::ResourceKindMismatch, + "anonymous session authority is limited to its assessment-session resource", + ), + ( + AnonymousResourceAuthorizationError::OwnerMismatch, + "anonymous session authority does not match the resource participant", + ), + ( + AnonymousResourceAuthorizationError::SessionMismatch, + "anonymous session authority does not match the resource session", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } +} diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs new file mode 100644 index 00000000..318cc9dd --- /dev/null +++ b/tests/anonymous_session_command_authorization.rs @@ -0,0 +1,435 @@ +//! Contract tests for anonymous command authorization against supplied aggregates. +//! +//! A transport must load the participant and assessment session from the product store, +//! then ask this boundary whether the already-verified anonymous session may command +//! that exact session. These tests pass supplied records; the type system does not +//! prove they were loaded. Persist/reload remains Active PR #133. + +use psychometrics_commons_runtime::anonymous_authorization::{ + apply_anonymous_session_command, authorize_anonymous_session_command, + AnonymousResourceAuthorizationError, AnonymousSessionCommandError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::session::{ + AssessmentSession, SessionCommand, SessionState, TransitionErrorKind, +}; + +const RELEASE_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +/// Session starts after the Big Five Korean release is published at `10_200`. +const SESSION_CREATED_AT_UNIX_MS: u64 = 10_300; +/// Trusted now sits after session creation and before exclusive proof expiry. +const COMMAND_NOW_UNIX_MS: u64 = 11_000; +/// Exclusive proof expiry: valid at `11_999`, expired at `12_000`. +const PROOF_VALID_UNTIL_UNIX_MS: u64 = 12_000; + +fn published_release() -> InstrumentRelease { + let manifest = InstrumentReleaseManifest::new( + "release_big_five_ko_v1", + "instrument_big_five", + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + RELEASE_DIGEST, + ) + .unwrap(); + let evidence = PublicationEvidenceRecord::new( + "publication_evidence_big_five_ko_v1", + "evidence_policy_self_reflection_v1", + "release_big_five_ko_v1", + "instrument_version_big_five_ko_v1", + &["item_version_001"], + RELEASE_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_ko_v1"], + &["approval_psychometrics_big_five_ko_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(); + let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); + release + .apply_command( + "publication_review_11d5b1e7", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_20f6c2a8", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn participant(tenant_ref: &str, participant_ref: &str) -> ParticipantRecord { + ParticipantRecord::new_anonymous(participant_ref, tenant_ref, 1_000).unwrap() +} + +fn session(participant_ref: &str, session_ref: &str) -> AssessmentSession { + AssessmentSession::new( + session_ref, + participant_ref, + &published_release(), + "ko-KR", + SESSION_CREATED_AT_UNIX_MS, + ) + .unwrap() +} + +fn anonymous_context( + tenant_ref: &str, + participant_ref: &str, + session_ref: &str, +) -> AnonymousSessionContext { + AnonymousSessionContext::new( + tenant_ref, + participant_ref, + session_ref, + "anonymous_command_evidence_alpha", + PROOF_VALID_UNTIL_UNIX_MS, + ) + .unwrap() +} + +#[test] +fn current_anonymous_proof_may_command_only_its_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, COMMAND_NOW_UNIX_MS), + Ok(()) + ); +} + +#[test] +fn anonymous_command_authorization_uses_loaded_participant_tenant_not_caller_scope() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &foreign_owner, &loaded, COMMAND_NOW_UNIX_MS), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_a_session_owned_by_another_loaded_participant() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command( + &actor, + &owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_a_different_loaded_session_for_the_same_owner() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_session = session("participant_alpha", "session_beta"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_session, COMMAND_NOW_UNIX_MS), + Err(AnonymousResourceAuthorizationError::SessionMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, PROOF_VALID_UNTIL_UNIX_MS), + Err(AnonymousResourceAuthorizationError::Expired) + ); + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, PROOF_VALID_UNTIL_UNIX_MS + 1), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_compound_failures_in_time_then_owner_order() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session_command( + &actor, + &owner, + &other_persons_session, + PROOF_VALID_UNTIL_UNIX_MS + ), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_actor_when_loaded_participant_and_session_agree() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let other_owner = participant("tenant_alpha", "participant_beta"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command( + &actor, + &other_owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsistent_loaded_pair_as_cross_tenant( +) { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command( + &actor, + &foreign_owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); +} + +#[test] +fn authorized_anonymous_proof_may_activate_only_its_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_activate_alpha", + 1, + SessionCommand::Activate, + COMMAND_NOW_UNIX_MS, + ), + Ok(SessionState::Active) + ); + assert_eq!(loaded.state(), SessionState::Active); +} + +#[test] +fn unauthorized_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut other_session = session("participant_alpha", "session_beta"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut other_session, + "command_activate_beta", + 1, + SessionCommand::Activate, + COMMAND_NOW_UNIX_MS, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::SessionMismatch + )) + ); + assert_eq!(other_session.state(), SessionState::Created); +} + +#[test] +fn cross_tenant_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &foreign_owner, + &mut loaded, + "command_activate_foreign_tenant", + 1, + SessionCommand::Activate, + COMMAND_NOW_UNIX_MS, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::CrossTenantDenied + )) + ); + assert_eq!(loaded.state(), SessionState::Created); +} + +#[test] +fn owner_mismatch_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut other_persons_session, + "command_activate_foreign_owner", + 1, + SessionCommand::Activate, + COMMAND_NOW_UNIX_MS, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::OwnerMismatch + )) + ); + assert_eq!(other_persons_session.state(), SessionState::Created); +} + +#[test] +fn expired_anonymous_proof_cannot_apply_an_otherwise_legal_session_command() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_activate_expired", + 1, + SessionCommand::Activate, + PROOF_VALID_UNTIL_UNIX_MS, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::Expired + )) + ); + assert_eq!(loaded.state(), SessionState::Created); +} + +#[test] +fn authorized_anonymous_command_still_fails_closed_on_illegal_lifecycle_transition() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + let error = apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_complete_too_early", + 1, + SessionCommand::Complete, + COMMAND_NOW_UNIX_MS, + ) + .expect_err("Created sessions cannot complete"); + match error { + AnonymousSessionCommandError::Transition(transition) => { + assert_eq!(transition.state(), SessionState::Created); + assert_eq!(transition.command(), SessionCommand::Complete); + assert_eq!(transition.kind(), TransitionErrorKind::InvalidTransition); + } + other => panic!("expected lifecycle rejection, got {other:?}"), + } + assert_eq!(loaded.state(), SessionState::Created); + assert!(error.to_string().contains("Complete")); + assert!(std::error::Error::source(&error).is_some()); +} + +#[test] +fn anonymous_session_command_authorization_errors_display_and_source_authorization_variants() { + let cases = [ + ( + AnonymousResourceAuthorizationError::InvalidTimestamp, + "anonymous resource authorization requires positive server time", + ), + ( + AnonymousResourceAuthorizationError::Expired, + "anonymous session authority is expired", + ), + ( + AnonymousResourceAuthorizationError::CrossTenantDenied, + "anonymous session authority does not match the resource tenant", + ), + ( + AnonymousResourceAuthorizationError::ResourceKindMismatch, + "anonymous session authority is limited to its assessment-session resource", + ), + ( + AnonymousResourceAuthorizationError::OwnerMismatch, + "anonymous session authority does not match the resource participant", + ), + ( + AnonymousResourceAuthorizationError::SessionMismatch, + "anonymous session authority does not match the resource session", + ), + ]; + + for (inner, expected) in cases { + let error = AnonymousSessionCommandError::Authorization(inner); + assert_eq!(error.to_string(), expected); + assert!(std::error::Error::source(&error).is_some()); + } +} diff --git a/tests/documentation_architecture_contract.rs b/tests/documentation_architecture_contract.rs index 7b5c6fad..6845f3e1 100644 --- a/tests/documentation_architecture_contract.rs +++ b/tests/documentation_architecture_contract.rs @@ -336,6 +336,73 @@ fn erd_covers_current_delivery_identity_and_longitudinal_boundaries() { } } +#[test] +fn anonymous_command_docs_do_not_claim_store_load() { + let root = repository_root(); + let authorization = read_required(&root.join("src/anonymous_authorization.rs")); + let security = read_required(&root.join("docs/architecture/SECURITY_AND_DATA.md")); + let changelog = read_required(&root.join("CHANGELOG.md")); + let traceability = read_required(&root.join("docs/TRACEABILITY.md")); + let adr = + read_required(&root.join("docs/adr/0003-keyverse-identity-and-anonymous-participation.md")); + let erd = read_required(&root.join("docs/architecture/ERD.md")); + let command_tests = + read_required(&root.join("tests/anonymous_session_command_authorization.rs")); + let uml = read_required(&root.join("docs/architecture/UML.md")); + + assert!( + !authorization.contains("have been loaded from the product store"), + "apply_anonymous_session_command rustdoc must not claim the caller already loaded records" + ); + assert!( + !authorization.contains("ParticipantRecord`] loaded from the product store"), + "authorize_anonymous_session_command rustdoc must not label the participant argument as store-loaded" + ); + assert!( + authorization.contains("does not prove the records were loaded"), + "command authorization rustdoc must say the gate does not prove store load" + ); + assert!( + !security.contains("supplied after a store load"), + "SECURITY_AND_DATA must not claim the command gate observed a store load" + ); + assert!( + security.contains("does not prove those records were store-loaded"), + "SECURITY_AND_DATA must say the command gate does not prove store load" + ); + assert!( + uml.contains( + "as-built command gate compares supplied records and does not perform the load" + ), + "UML happy-path must distinguish target store load from the as-built command gate" + ); + + for (label, document) in [ + ("CHANGELOG.md", changelog.as_str()), + ("docs/TRACEABILITY.md", traceability.as_str()), + ( + "docs/adr/0003-keyverse-identity-and-anonymous-participation.md", + adr.as_str(), + ), + ("docs/architecture/ERD.md", erd.as_str()), + ("docs/architecture/SECURITY_AND_DATA.md", security.as_str()), + ( + "tests/anonymous_session_command_authorization.rs", + command_tests.as_str(), + ), + ("src/anonymous_authorization.rs", authorization.as_str()), + ] { + assert!( + !document.contains("remains Active PR #114"), + "{label} must not name superseded #114 as the current participant persist landing" + ); + assert!( + document.contains("#133"), + "{label} must name Active PR #133 as the current participant persist landing" + ); + } +} + #[test] fn uml_covers_identity_longitudinal_and_workbench_behavior() { let uml = read_required(&repository_root().join("docs/architecture/UML.md"));