diff --git a/CHANGELOG.md b/CHANGELOG.md index f3764d25..3fe6d08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. - `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. diff --git a/Cargo.lock b/Cargo.lock index 372a55f4..616bfd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1272,6 +1272,8 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", + "temporal_core", ] [[package]] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f60654ab..f64cf32b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -34,6 +34,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | +| Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/persistence_postgres/src/artifact_sql.rs b/crates/persistence_postgres/src/artifact_sql.rs index d654d183..9f997a73 100644 --- a/crates/persistence_postgres/src/artifact_sql.rs +++ b/crates/persistence_postgres/src/artifact_sql.rs @@ -226,6 +226,7 @@ mod tests { assert_source_artifact_matches_sql(&with_ref).expect("referenced assertion"); assert!(referenced_assertion.contains("s3://tepp/object")); assert!(referenced_assertion.contains("IS NOT DISTINCT FROM 's3://tepp/object'")); + assert!(referenced_assertion.contains("protected_object_ref IS NOT DISTINCT FROM")); assert_eq!( insert_source_artifact_sql(&SourceArtifactRecord { diff --git a/crates/persistence_postgres/tests/source_artifact_sql_contract.rs b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs index fae0effd..db87a044 100644 --- a/crates/persistence_postgres/tests/source_artifact_sql_contract.rs +++ b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs @@ -177,4 +177,11 @@ fn assert_sql_requires_every_stored_field_to_match() { assert!(sql.contains("protected_object_ref IS NOT DISTINCT FROM")); assert!(sql.contains("system_time")); assert!(sql.contains("available_time")); + assert!(sql.contains("NULL")); + + let mut with_ref = artifact(); + with_ref.protected_object_ref = Some("s3://tepp/evidence/object".into()); + let referenced = assert_source_artifact_matches_sql(&with_ref).expect("assert-ref"); + assert!(referenced.contains("s3://tepp/evidence/object")); + assert!(referenced.contains("protected_object_ref IS NOT DISTINCT FROM")); } diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 6768ea18..027f975e 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -16,6 +16,8 @@ publish = false [dependencies] serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } +temporal_core = { path = "../temporal_core", version = "0.1.0" } [lints] workspace = true diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b675a818..7be66c1b 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -15,6 +15,7 @@ mod envelope; mod error; mod export; mod naruon_http; +mod provider_payload; mod wire; /// Analysis-run contract version constant. @@ -66,3 +67,25 @@ pub use naruon_http::naruon_analysis_run_exchange_with_headers; pub use naruon_http::naruon_export_exchange; /// Refuse lexical heuristics as TEPP inference claims. pub use naruon_http::naruon_may_claim_tepp_inference; +/// Elevated re-identification result. +pub use provider_payload::DisclosedIdentityMapping; +/// Separately protected identity mapping. +pub use provider_payload::IdentityMappingRecord; +/// Minimized provider payload without direct identity. +pub use provider_payload::MinimizedProviderPayload; +/// Log-safe provider disclosure record. +pub use provider_payload::ProviderDisclosureLog; +/// Evidence offered to a model provider. +pub use provider_payload::ProviderEvidenceOffer; +/// Time-bounded purpose grant. +pub use provider_payload::PurposeGrant; +/// Redacted re-identification decision outcome. +pub use provider_payload::ReidentificationAuditOutcome; +/// Redacted append-only re-identification audit record. +pub use provider_payload::ReidentificationAuditRecord; +/// Append-only persistence port for re-identification audit evidence. +pub use provider_payload::ReidentificationAuditSink; +/// Disclose a mapping on the elevated scientific path. +pub use provider_payload::disclose_identity_mapping; +/// Minimize evidence for a model provider. +pub use provider_payload::minimize_provider_payload; diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs new file mode 100644 index 00000000..e0ebdd88 --- /dev/null +++ b/crates/tepp_api/src/provider_payload.rs @@ -0,0 +1,1008 @@ +//! Purpose-bound provider payloads and separately authorized re-identification. + +use crate::ApiError; +use crate::authorization::{ + AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, +}; +use crate::wire::require_nonempty; +use sha2::{Digest, Sha256}; +use std::fmt; +use temporal_core::TemporalInstant; + +/// Time-bounded purpose grant evaluated at a decision instant. +/// +/// `valid_from` / `valid_to` are RFC 3339 UTC instants (`YYYY-MM-DDTHH:MM:SSZ`). +/// An omitted `valid_to` is an open-ended grant. The decision instant is the +/// authorization's available/system time and must not use future-available +/// evidence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PurposeGrant { + /// Opaque tenant/workspace identity bound to the grant. + pub tenant_workspace_id: String, + /// Opaque principal identity (not a password or token). + pub principal_id: String, + /// Declared analytical purpose. + pub purpose: AnalyticalPurpose, + /// Inclusive grant start instant. + pub valid_from: String, + /// Inclusive grant end instant, or `None` for an open-ended grant. + pub valid_to: Option, + /// Whether a separate re-identification path is authorized. + pub reidentification_authorized: bool, +} + +/// Evidence offered to a model provider or modular CWL peer. +/// +/// Direct identity mappings must stay empty on this path. Opaque analytical +/// identifiers and membership roles are scientific linkage and are not +/// blanket-masked. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderEvidenceOffer { + /// Tenant/workspace that owns the offered evidence. + pub tenant_workspace_id: String, + /// Opaque artifact identity. + pub artifact_id: String, + /// Opaque analytical identifier retained for multilevel membership. + pub opaque_analytical_id: String, + /// Optional free-text source body. + pub source_text: Option, + /// Direct identity mapping; must be absent for provider disclosure. + pub identity_mapping: Option, + /// Optional contextual membership role preserved as scientific linkage. + pub membership_role: Option, +} + +/// Minimized payload that may be sent to a provider. +/// +/// Construct only via [`minimize_provider_payload`]. The payload never carries +/// a direct identity mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinimizedProviderPayload { + artifact_id: String, + opaque_analytical_id: String, + source_text: Option, + membership_role: Option, +} + +impl MinimizedProviderPayload { + /// Opaque artifact identity. + #[must_use] + pub fn artifact_id(&self) -> &str { + &self.artifact_id + } + + /// Opaque analytical identifier retained for measurement. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Free-text source body when the purpose grant allows it. + #[must_use] + pub fn source_text(&self) -> Option<&str> { + self.source_text.as_deref() + } + + /// Contextual membership role, if offered. + #[must_use] + pub fn membership_role(&self) -> Option<&str> { + self.membership_role.as_deref() + } + + /// Direct identity mapping; always absent on the provider path. + #[must_use] + pub const fn identity_mapping(&self) -> Option<&str> { + None + } +} + +/// Log-safe record of a provider disclosure decision. +/// +/// Ordinary logs must not copy source text or direct identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderDisclosureLog { + purpose: String, + included_source_text: bool, + included_identity_mapping: bool, +} + +impl ProviderDisclosureLog { + /// Stable purpose wire name recorded with the decision. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose + } + + /// Whether the minimized payload included a source body. + #[must_use] + pub const fn included_source_text(&self) -> bool { + self.included_source_text + } + + /// Whether a direct identity mapping was included; always false. + #[must_use] + pub const fn included_identity_mapping(&self) -> bool { + self.included_identity_mapping + } +} + +impl fmt::Display for ProviderDisclosureLog { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "provider_disclosure purpose={} source={} mapping={}", + self.purpose, self.included_source_text, self.included_identity_mapping + ) + } +} + +/// Separately protected identity mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityMappingRecord { + /// Tenant/workspace that owns the mapping. + pub tenant_workspace_id: String, + /// Opaque analytical identifier. + pub opaque_analytical_id: String, + /// Direct identity string; never copied onto a provider payload. + pub direct_identity: String, +} + +/// Result of an elevated re-identification disclosure. +/// +/// Construct only via [`disclose_identity_mapping`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisclosedIdentityMapping { + opaque_analytical_id: String, + direct_identity: String, +} + +impl DisclosedIdentityMapping { + /// Opaque analytical identifier that was resolved. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Direct identity released on the elevated path only. + #[must_use] + pub fn direct_identity(&self) -> &str { + &self.direct_identity + } +} + +/// Redacted outcome of an elevated re-identification decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReidentificationAuditOutcome { + /// The protected mapping was released after audit append succeeded. + Allowed, + /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. + Denied, +} + +impl ReidentificationAuditOutcome { + /// Stable wire name for append-only audit persistence. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Allowed => "allowed", + Self::Denied => "denied", + } + } +} + +/// Redacted append-only evidence for an elevated re-identification decision. +/// +/// Direct identity is deliberately absent. The digest identifies the governed +/// decision input without copying protected source or mapping content. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReidentificationAuditRecord { + tenant_workspace_id: String, + principal_id: String, + purpose_wire_name: String, + action_code: &'static str, + opaque_analytical_id: String, + decision_time: String, + outcome: ReidentificationAuditOutcome, + decision_digest: String, +} + +impl ReidentificationAuditRecord { + /// Tenant/workspace in which the decision occurred. + #[must_use] + pub fn tenant_workspace_id(&self) -> &str { + &self.tenant_workspace_id + } + + /// Opaque principal that requested disclosure. + #[must_use] + pub fn principal_id(&self) -> &str { + &self.principal_id + } + + /// Purpose wire name evaluated by policy. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose_wire_name + } + + /// Stable elevated action code. + #[must_use] + pub const fn action_code(&self) -> &'static str { + self.action_code + } + + /// Opaque analytical identity involved in the decision. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Canonical UTC decision instant. + #[must_use] + pub fn decision_time(&self) -> &str { + &self.decision_time + } + + /// Allowed or denied decision outcome. + #[must_use] + pub const fn outcome(&self) -> ReidentificationAuditOutcome { + self.outcome + } + + /// Canonical SHA-256 digest of the governed decision input. + #[must_use] + pub fn decision_digest(&self) -> &str { + &self.decision_digest + } +} + +/// Append-only persistence port for elevated re-identification audit evidence. +/// +/// Implementations must append an immutable row or event and must never copy +/// the disclosed direct identity into ordinary audit storage. +pub trait ReidentificationAuditSink { + /// Append one redacted decision record before disclosure or denial returns. + /// + /// # Errors + /// + /// Returns a redacted [`ApiError`] when append-only persistence fails. The + /// disclosure then fails closed and no direct identity is returned. + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError>; +} + +/// Minimize evidence for a model provider without blanket PII masking. +/// +/// Opaque analytical identifiers and membership roles are preserved. Source +/// text follows [`crate::authorize_export`]. Direct identity mappings are +/// refused on this path even when re-identification is separately authorized. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for empty identities, empty +/// optional strings, inverted grant windows, or non-canonical RFC 3339 UTC +/// instants. Returns [`ApiError::AuthorizationDenied`] for expired or +/// not-yet-valid grants, cross-tenant offers, attached identity mappings, or +/// purpose-denied source text. +pub fn minimize_provider_payload( + grant: &PurposeGrant, + offer: &ProviderEvidenceOffer, + decision_time: &str, +) -> Result<(MinimizedProviderPayload, ProviderDisclosureLog), ApiError> { + validate_grant(grant)?; + require_nonempty(&offer.tenant_workspace_id)?; + require_nonempty(&offer.artifact_id)?; + require_nonempty(&offer.opaque_analytical_id)?; + require_optional_nonempty(offer.source_text.as_deref())?; + require_optional_nonempty(offer.membership_role.as_deref())?; + require_optional_nonempty(offer.identity_mapping.as_deref())?; + require_rfc3339_utc(decision_time)?; + if !grant_covers(grant, decision_time) { + return Err(ApiError::AuthorizationDenied); + } + if offer.tenant_workspace_id != grant.tenant_workspace_id { + return Err(ApiError::AuthorizationDenied); + } + if offer.identity_mapping.is_some() { + return Err(ApiError::AuthorizationDenied); + } + let decision = authorize_export(&ExportAuthorizationRequest { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose: grant.purpose, + artifact_id: offer.artifact_id.clone(), + includes_source_text: offer.source_text.is_some(), + })?; + require_export_allowed(&decision)?; + let log = ProviderDisclosureLog { + purpose: grant.purpose.wire_name().into(), + included_source_text: offer.source_text.is_some(), + included_identity_mapping: false, + }; + Ok(( + MinimizedProviderPayload { + artifact_id: offer.artifact_id.clone(), + opaque_analytical_id: offer.opaque_analytical_id.clone(), + source_text: offer.source_text.clone(), + membership_role: offer.membership_role.clone(), + }, + log, + )) +} + +/// Disclose a direct identity mapping on the elevated scientific path. +/// +/// This is not a provider payload. Modular consumers, operational monitoring, +/// and partner disclosure cannot receive the mapping even when the grant flag +/// is set. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for empty identities, inverted +/// windows, or non-canonical instants. Returns +/// [`ApiError::AuthorizationDenied`] when the grant is expired, not yet +/// valid, cross-tenant, missing the elevated flag, or not +/// [`AnalyticalPurpose::ScientificValidation`]. +pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + audit_sink: &mut S, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + + let allowed = grant_covers(grant, decision_time) + && mapping.tenant_workspace_id == grant.tenant_workspace_id + && grant.reidentification_authorized + && grant.purpose == AnalyticalPurpose::ScientificValidation; + let outcome = if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }; + let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: "reidentify_identity_mapping", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome, + decision_digest: reidentification_decision_digest(grant, mapping, decision_time, outcome), + }; + audit_sink.append_reidentification_audit(&audit_record)?; + if !allowed { + return Err(ApiError::AuthorizationDenied); + } + Ok(( + DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }, + audit_record, + )) +} + +const REIDENTIFICATION_AUDIT_DIGEST_VERSION: &str = "tepp.reidentification.audit.v1"; + +fn reidentification_decision_digest( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + outcome: ReidentificationAuditOutcome, +) -> String { + let mut hasher = Sha256::new(); + let has_until = if grant.valid_to.is_some() { "1" } else { "0" }; + let until = grant.valid_to.as_deref().unwrap_or(""); + let reidentify_flag = if grant.reidentification_authorized { + "1" + } else { + "0" + }; + for value in [ + REIDENTIFICATION_AUDIT_DIGEST_VERSION, + "reidentify_identity_mapping", + &grant.tenant_workspace_id, + &grant.principal_id, + grant.purpose.wire_name(), + &grant.valid_from, + has_until, + until, + reidentify_flag, + &mapping.tenant_workspace_id, + &mapping.opaque_analytical_id, + &mapping.direct_identity, + decision_time, + outcome.wire_name(), + ] { + update_audit_digest_field(&mut hasher, value); + } + format!("sha256:{:x}", hasher.finalize()) +} + +fn update_audit_digest_field(hasher: &mut Sha256, value: &str) { + // `usize` fits in `u64` on all TEPP targets; avoid a dead `try_from` Err arm. + let length = value.len() as u64; + hasher.update(length.to_be_bytes()); + hasher.update(value.as_bytes()); +} + +// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid +// only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also +// rejects impossible calendar dates and leap seconds before comparison. +fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { + require_nonempty(&grant.tenant_workspace_id)?; + require_nonempty(&grant.principal_id)?; + require_rfc3339_utc(&grant.valid_from)?; + if let Some(until) = &grant.valid_to { + require_rfc3339_utc(until)?; + if until.as_str() < grant.valid_from.as_str() { + return Err(ApiError::InvalidWirePayload); + } + } + Ok(()) +} + +fn grant_covers(grant: &PurposeGrant, decision_time: &str) -> bool { + if decision_time < grant.valid_from.as_str() { + return false; + } + if let Some(until) = &grant.valid_to + && decision_time > until.as_str() + { + return false; + } + true +} + +fn require_optional_nonempty(value: Option<&str>) -> Result<(), ApiError> { + match value { + Some(text) => require_nonempty(text), + None => Ok(()), + } +} + +fn require_rfc3339_utc(value: &str) -> Result<(), ApiError> { + if is_rfc3339_utc(value) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn is_rfc3339_utc(value: &str) -> bool { + // Exact second-resolution UTC (`YYYY-MM-DDTHH:MM:SSZ`). Calendar/syntax is + // owned by `TemporalInstant`; keep length + trailing `Z` only so offsets and + // fractional seconds stay out of grants without long `&&` branch chains. + if value.len() != 20 { + return false; + } + if value.as_bytes()[19] != b'Z' { + return false; + } + TemporalInstant::parse_rfc3339(value).is_ok() +} + +#[cfg(test)] +mod tests { + use super::{ + DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping as disclose_identity_mapping_with_audit, is_rfc3339_utc, + minimize_provider_payload, reidentification_decision_digest, + }; + use crate::ApiError; + use crate::authorization::AnalyticalPurpose; + + fn grant(purpose: AnalyticalPurpose, reidentification: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-ws-1".into(), + principal_id: "principal-analyst-1".into(), + purpose, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: reidentification, + } + } + + fn offer(source: Option<&str>) -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-1".into(), + opaque_analytical_id: "entity-1".into(), + source_text: source.map(str::to_owned), + identity_mapping: None, + membership_role: None, + } + } + + fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + } + } + + #[derive(Default)] + struct RecordingAuditSink { + records: Vec, + } + + impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } + } + + struct FailingSink; + impl ReidentificationAuditSink for FailingSink { + fn append_reidentification_audit( + &mut self, + _record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + Err(ApiError::AuthorizationDenied) + } + } + + fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut audit_sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit(grant, mapping, decision_time, &mut audit_sink) + } + + #[test] + fn rfc3339_utc_is_strict_and_windows_are_inclusive() { + assert!(is_rfc3339_utc("2026-01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00+00:00")); + assert!(!is_rfc3339_utc("2026-01-01 00:00:00Z")); + assert!(!is_rfc3339_utc("20260101T000000Z")); + assert!(!is_rfc3339_utc("xxxx-01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-xx-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-xxT00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01Txx:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:xx:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:xxZ")); + assert!(!is_rfc3339_utc("2026X01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01X01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01X00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00X00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00X00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00X")); + + let at_start = minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-01-01T00:00:00Z", + ) + .expect("start"); + assert!(!at_start.1.included_source_text()); + let at_end = minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-12-31T23:59:59Z", + ) + .expect("end"); + assert!(at_end.0.identity_mapping().is_none()); + assert_eq!( + at_end.1.to_string(), + "provider_disclosure purpose=scientific_validation source=false mapping=false" + ); + } + + #[test] + fn minimize_denies_expired_foreign_and_identity_mapping_offers() { + let expired = PurposeGrant { + valid_to: Some("2026-03-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&expired, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let not_yet = PurposeGrant { + valid_from: "2026-07-01T00:00:00Z".into(), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(¬_yet, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let foreign = ProviderEvidenceOffer { + tenant_workspace_id: "other-tenant".into(), + ..offer(None) + }; + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let mut mapped = offer(None); + mapped.identity_mapping = Some("secret-name".into()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapped, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + } + + #[test] + fn partner_and_ops_follow_export_source_rules() { + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::PartnerDisclosure, false), + &offer(Some("body")), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let partner = minimize_provider_payload( + &grant(AnalyticalPurpose::PartnerDisclosure, false), + &offer(None), + "2026-06-15T12:00:00Z", + ) + .expect("partner derived"); + assert!(partner.0.source_text().is_none()); + let ops = minimize_provider_payload( + &grant(AnalyticalPurpose::OperationalMonitoring, false), + &offer(None), + "2026-06-15T12:00:00Z", + ) + .expect("ops derived"); + assert_eq!(ops.1.purpose_wire_name(), "operational_monitoring"); + } + + #[test] + fn empty_optionals_and_grant_fields_fail_closed() { + let mut empty_principal = grant(AnalyticalPurpose::ScientificValidation, false); + empty_principal.principal_id.clear(); + assert_eq!( + minimize_provider_payload(&empty_principal, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_source = offer(Some("")); + empty_source.source_text = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_source, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_role = offer(None); + empty_role.membership_role = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_role, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_mapping_text = offer(None); + empty_mapping_text.identity_mapping = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_mapping_text, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_artifact = offer(None); + empty_artifact.artifact_id.clear(); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_artifact, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_opaque = offer(None); + empty_opaque.opaque_analytical_id.clear(); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_opaque, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn disclose_policy_denials_and_invalid_mapping_fields() { + assert_eq!( + disclose( + &grant(AnalyticalPurpose::PartnerDisclosure, true), + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let expired = PurposeGrant { + valid_to: Some("2026-02-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + assert_eq!( + disclose(&expired, &mapping(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let inverted = PurposeGrant { + valid_from: "2026-12-31T00:00:00Z".into(), + valid_to: Some("2026-01-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + assert_eq!( + disclose(&inverted, &mapping(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let foreign = IdentityMappingRecord { + tenant_workspace_id: "other-tenant".into(), + ..mapping() + }; + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, true), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let mut empty = mapping(); + empty.direct_identity.clear(); + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "bad", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_opaque = mapping(); + empty_opaque.opaque_analytical_id.clear(); + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty_opaque, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_tenant = mapping(); + empty_tenant.tenant_workspace_id.clear(); + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty_tenant, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn open_ended_grant_and_reidentify_false_paths() { + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let open_ended = PurposeGrant { + valid_to: None, + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + let open_min = minimize_provider_payload(&open_ended, &offer(None), "2026-06-15T12:00:00Z") + .expect("open-ended minimize"); + assert_eq!(open_min.0.artifact_id(), "artifact-1"); + assert_eq!(open_min.0.opaque_analytical_id(), "entity-1"); + assert!(open_min.0.membership_role().is_none()); + assert_eq!( + disclose( + &PurposeGrant { + valid_to: None, + ..grant(AnalyticalPurpose::ScientificValidation, false) + }, + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let mut failing = FailingSink; + assert_eq!( + disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + &mut failing, + ), + Err(ApiError::AuthorizationDenied) + ); + } + + #[test] + fn minimized_payload_and_disclosure_log_accessors() { + let disclosed = DisclosedIdentityMapping { + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + }; + assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + let payload = MinimizedProviderPayload { + artifact_id: "a".into(), + opaque_analytical_id: "e".into(), + source_text: None, + membership_role: None, + }; + assert_eq!(payload.artifact_id(), "a"); + assert_eq!(payload.opaque_analytical_id(), "e"); + assert!(payload.membership_role().is_none()); + let log = ProviderDisclosureLog { + purpose: "scientific_validation".into(), + included_source_text: false, + included_identity_mapping: false, + }; + assert!(!log.included_identity_mapping()); + } + + #[test] + fn audit_digest_binds_outcome_when_other_canonical_fields_are_held_fixed() { + let allowed = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ); + let denied = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Denied, + ); + assert_ne!( + allowed, denied, + "outcome wire name must change the digest when grant, mapping, and time stay fixed" + ); + } + + #[test] + fn rfc3339_rejects_impossible_calendar_and_malformed_separators() { + // Fixed-width UTC shape but impossible calendar → TemporalInstant fails closed. + assert!(!is_rfc3339_utc("2026-02-30T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-13-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-00-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-00T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-32T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T24:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:60:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:60Z")); + // Separator / terminator failures that still keep length 20. + assert!(!is_rfc3339_utc("2026/01/01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01 00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00-00-00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:0aZ")); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-02-30T00:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let bad_from = PurposeGrant { + valid_from: "2026-02-30T00:00:00Z".into(), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&bad_from, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let bad_to = PurposeGrant { + valid_to: Some("2026-13-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&bad_to, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn digest_and_audit_cover_open_ended_grant_and_sink_failure() { + let open = PurposeGrant { + valid_to: None, + reidentification_authorized: true, + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + let open_digest = reidentification_decision_digest( + &open, + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ); + let closed_digest = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ); + assert_ne!( + open_digest, closed_digest, + "open-ended valid_to must change the canonical audit digest" + ); + let unauthorized = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Denied, + ); + assert_ne!(closed_digest, unauthorized); + + let mut sink = FailingSink; + assert_eq!( + disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ), + Err(ApiError::AuthorizationDenied) + ); + + let mut sink = RecordingAuditSink::default(); + let (disclosed, audit) = disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ) + .expect("allowed with audit"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + assert_eq!(audit.tenant_workspace_id(), "tenant-ws-1"); + assert_eq!(audit.principal_id(), "principal-analyst-1"); + assert_eq!(audit.purpose_wire_name(), "scientific_validation"); + assert_eq!(audit.action_code(), "reidentify_identity_mapping"); + assert_eq!(audit.opaque_analytical_id(), "entity-1"); + assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); + assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); + assert!(audit.decision_digest().starts_with("sha256:")); + assert_eq!(audit.outcome().wire_name(), "allowed"); + assert_eq!(ReidentificationAuditOutcome::Denied.wire_name(), "denied"); + assert_eq!(sink.records.len(), 1); + assert_eq!(sink.records[0].decision_digest(), audit.decision_digest()); + } +} diff --git a/crates/tepp_api/tests/provider_payload_contract.rs b/crates/tepp_api/tests/provider_payload_contract.rs new file mode 100644 index 00000000..f7294a0e --- /dev/null +++ b/crates/tepp_api/tests/provider_payload_contract.rs @@ -0,0 +1,222 @@ +//! Purpose-bound provider payloads refuse identity mappings and expired grants. + +use tepp_api::{ + AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord, + ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload, +}; + +fn active_grant(purpose: AnalyticalPurpose, reidentification: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-ws-1".into(), + principal_id: "principal-analyst-1".into(), + purpose, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: reidentification, + } +} + +fn scientific_offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-quarterly-review-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), + identity_mapping: None, + membership_role: Some("author".into()), + } +} + +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } +} + +fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit(grant, mapping, decision_time, &mut sink) +} + +#[test] +fn scientific_provider_payload_keeps_opaque_ids_and_roles_without_mapping() { + let (payload, log) = minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &scientific_offer(), + "2026-06-15T12:00:00Z", + ) + .expect("scientific minimize"); + + assert_eq!(payload.artifact_id(), "artifact-quarterly-review-1"); + assert_eq!(payload.opaque_analytical_id(), "entity-opaque-42"); + assert_eq!(payload.membership_role(), Some("author")); + assert_eq!( + payload.source_text(), + Some("Q3 pipeline slipped after the Acme renewal stalled.") + ); + assert!(payload.identity_mapping().is_none()); + assert!(log.included_source_text()); + assert!(!log.included_identity_mapping()); + assert_eq!(log.purpose_wire_name(), "scientific_validation"); + assert!(!log.to_string().contains("Acme")); +} + +#[test] +fn operational_monitoring_cannot_receive_source_text() { + let error = minimize_provider_payload( + &active_grant(AnalyticalPurpose::OperationalMonitoring, false), + &scientific_offer(), + "2026-06-15T12:00:00Z", + ) + .expect_err("ops source"); + assert_eq!(error, ApiError::AuthorizationDenied); +} + +#[test] +fn expired_and_not_yet_valid_grants_fail_closed() { + let expired = PurposeGrant { + valid_to: Some("2026-03-01T00:00:00Z".into()), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&expired, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + + let future = PurposeGrant { + valid_from: "2026-07-01T00:00:00Z".into(), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&future, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn open_ended_grant_stays_valid_and_cross_tenant_is_denied() { + let open = PurposeGrant { + valid_to: None, + ..active_grant(AnalyticalPurpose::ModularServiceConsumer, false) + }; + let (payload, _) = + minimize_provider_payload(&open, &scientific_offer(), "2027-01-02T00:00:00Z") + .expect("open grant"); + assert_eq!(payload.opaque_analytical_id(), "entity-opaque-42"); + + let foreign = ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-other".into(), + ..scientific_offer() + }; + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn identity_mapping_never_enters_a_provider_payload() { + let mut offer = scientific_offer(); + offer.identity_mapping = Some("Jane Roe ".into()); + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, true), + &offer, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn reidentification_is_a_separate_elevated_path() { + let mapping = IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + direct_identity: "Jane Roe ".into(), + }; + + let disclosed = disclose( + &active_grant(AnalyticalPurpose::ScientificValidation, true), + &mapping, + "2026-06-15T12:00:00Z", + ) + .expect("elevated"); + assert_eq!( + disclosed.0.direct_identity(), + "Jane Roe " + ); + assert_eq!(disclosed.0.opaque_analytical_id(), "entity-opaque-42"); + + assert_eq!( + disclose( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + disclose( + &active_grant(AnalyticalPurpose::OperationalMonitoring, true), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + disclose( + &active_grant(AnalyticalPurpose::ModularServiceConsumer, true), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn empty_identities_and_inverted_windows_are_invalid() { + let mut grant = active_grant(AnalyticalPurpose::ScientificValidation, false); + grant.tenant_workspace_id.clear(); + assert_eq!( + minimize_provider_payload(&grant, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + + let inverted = PurposeGrant { + valid_from: "2026-12-31T00:00:00Z".into(), + valid_to: Some("2026-01-01T00:00:00Z".into()), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&inverted, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &scientific_offer(), + "not-a-timestamp", + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/crates/tepp_api/tests/provider_payload_time_semantics.rs b/crates/tepp_api/tests/provider_payload_time_semantics.rs new file mode 100644 index 00000000..3a46087d --- /dev/null +++ b/crates/tepp_api/tests/provider_payload_time_semantics.rs @@ -0,0 +1,72 @@ +//! Semantic RFC 3339 validation contracts for provider-purpose grants. + +use tepp_api::{ + AnalyticalPurpose, ApiError, ProviderEvidenceOffer, PurposeGrant, minimize_provider_payload, +}; + +fn grant() -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: false, + } +} + +fn offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-workspace".into(), + artifact_id: "artifact-record".into(), + opaque_analytical_id: "analytical-identity".into(), + source_text: None, + identity_mapping: None, + membership_role: Some("project_member".into()), + } +} + +#[test] +fn provider_payload_rejects_semantically_invalid_utc_instants() { + for invalid_decision_time in [ + "2026-00-01T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-02-30T00:00:00Z", + "2026-04-31T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", + ] { + assert_eq!( + minimize_provider_payload(&grant(), &offer(), invalid_decision_time), + Err(ApiError::InvalidWirePayload), + "invalid instant must fail closed: {invalid_decision_time}", + ); + } +} + +#[test] +fn provider_payload_enforces_gregorian_leap_year_semantics() { + for valid_leap_day in ["2000-02-29T12:00:00Z", "2028-02-29T12:00:00Z"] { + let leap_grant = PurposeGrant { + valid_from: valid_leap_day.into(), + valid_to: Some(valid_leap_day.into()), + ..grant() + }; + minimize_provider_payload(&leap_grant, &offer(), valid_leap_day) + .expect("Gregorian leap day must be accepted"); + } + + for invalid_leap_day in ["2027-02-29T00:00:00Z", "2100-02-29T00:00:00Z"] { + let false_leap_grant = PurposeGrant { + valid_from: invalid_leap_day.into(), + valid_to: None, + ..grant() + }; + assert_eq!( + minimize_provider_payload(&false_leap_grant, &offer(), "2100-03-01T00:00:00Z"), + Err(ApiError::InvalidWirePayload), + "non-leap-century and ordinary non-leap years must fail closed: {invalid_leap_day}", + ); + } +} diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs new file mode 100644 index 00000000..e873d58d --- /dev/null +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -0,0 +1,202 @@ +//! Elevated re-identification must append redacted audit evidence for every decision. + +use tepp_api::{ + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, +}; + +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, + fail_closed: bool, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + if self.fail_closed { + return Err(ApiError::LimitExceeded); + } + self.records.push(record.clone()); + Ok(()) + } +} + +fn grant(reidentification_authorized: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized, + } +} + +fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-workspace".into(), + opaque_analytical_id: "opaque-person-42".into(), + direct_identity: "Pat Lee ".into(), + } +} + +#[test] +fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure() { + let mut sink = RecordingAuditSink::default(); + let (disclosed, audit) = + disclose_identity_mapping(&grant(true), &mapping(), "2026-06-15T12:00:00Z", &mut sink) + .expect("audited elevated disclosure"); + + assert_eq!( + disclosed.direct_identity(), + "Pat Lee " + ); + assert_eq!(sink.records, vec![audit.clone()]); + assert_eq!(audit.tenant_workspace_id(), "tenant-workspace"); + assert_eq!(audit.principal_id(), "principal-analyst"); + assert_eq!(audit.purpose_wire_name(), "scientific_validation"); + assert_eq!(audit.action_code(), "reidentify_identity_mapping"); + assert_eq!(audit.opaque_analytical_id(), "opaque-person-42"); + assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); + assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); + assert_eq!(audit.outcome().wire_name(), "allowed"); + assert_eq!( + audit.decision_digest(), + "sha256:1a3b774ae989b971cd6ba7f4a38697e94a532ce29cff7c0a8e0d8d2a73f45ded", + "published FIPS 180-4 SHA-256 test vector for the v1 length-delimited decision encoding" + ); + assert!(!format!("{audit:?}").contains("Pat Lee")); + assert!(!format!("{audit:?}").contains("pat.lee@example.test")); +} + +#[test] +fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { + let mut sink = RecordingAuditSink::default(); + assert_eq!( + disclose_identity_mapping(&grant(false), &mapping(), "2026-06-15T12:00:00Z", &mut sink,), + Err(ApiError::AuthorizationDenied), + ); + let (_, allowed) = + disclose_identity_mapping(&grant(true), &mapping(), "2026-06-15T12:00:01Z", &mut sink) + .expect("second audited decision"); + + assert_eq!(sink.records.len(), 2); + assert_eq!( + sink.records[0].outcome(), + ReidentificationAuditOutcome::Denied + ); + assert_eq!(sink.records[0].outcome().wire_name(), "denied"); + assert_eq!(sink.records[1], allowed); + assert_eq!( + sink.records[1].outcome(), + ReidentificationAuditOutcome::Allowed + ); + assert_ne!( + sink.records[0].decision_digest(), + sink.records[1].decision_digest(), + "allow and deny outcomes must be bound into distinct audit evidence" + ); +} + +#[test] +fn disclosure_fails_closed_when_audit_append_fails() { + let mut failed_sink = RecordingAuditSink { + fail_closed: true, + ..RecordingAuditSink::default() + }; + assert_eq!( + disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut failed_sink, + ), + Err(ApiError::LimitExceeded), + ); +} + +#[test] +fn audit_digest_is_deterministic_and_binds_protected_mapping_content() { + let mut first_sink = RecordingAuditSink::default(); + let (_, first) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut first_sink, + ) + .expect("first disclosure"); + + let mut repeat_sink = RecordingAuditSink::default(); + let (_, repeated) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut repeat_sink, + ) + .expect("repeat disclosure"); + assert_eq!(first.decision_digest(), repeated.decision_digest()); + + let mut changed_mapping = mapping(); + changed_mapping.direct_identity = "Different Person".into(); + let mut changed_sink = RecordingAuditSink::default(); + let (_, changed) = disclose_identity_mapping( + &grant(true), + &changed_mapping, + "2026-06-15T12:00:00Z", + &mut changed_sink, + ) + .expect("changed mapping disclosure"); + assert_ne!(first.decision_digest(), changed.decision_digest()); + assert!(!first.decision_digest().contains("Pat")); + assert!(!changed.decision_digest().contains("Different")); +} + +#[test] +fn audit_digest_binds_principal_grant_window_and_decision_time() { + let mut baseline_sink = RecordingAuditSink::default(); + let (_, baseline) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut baseline_sink, + ) + .expect("baseline disclosure"); + + let mut changed_principal_grant = grant(true); + changed_principal_grant.principal_id = "principal-reviewer".into(); + let mut changed_principal_sink = RecordingAuditSink::default(); + let (_, changed_principal) = disclose_identity_mapping( + &changed_principal_grant, + &mapping(), + "2026-06-15T12:00:00Z", + &mut changed_principal_sink, + ) + .expect("changed principal disclosure"); + + let mut changed_window_grant = grant(true); + changed_window_grant.valid_from = "2026-02-01T00:00:00Z".into(); + let mut changed_window_sink = RecordingAuditSink::default(); + let (_, changed_window) = disclose_identity_mapping( + &changed_window_grant, + &mapping(), + "2026-06-15T12:00:00Z", + &mut changed_window_sink, + ) + .expect("changed grant window disclosure"); + + let mut changed_time_sink = RecordingAuditSink::default(); + let (_, changed_time) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:01Z", + &mut changed_time_sink, + ) + .expect("changed decision time disclosure"); + + for changed in [&changed_principal, &changed_window, &changed_time] { + assert_ne!(baseline.decision_digest(), changed.decision_digest()); + } +} diff --git a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs new file mode 100644 index 00000000..63d5e852 --- /dev/null +++ b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs @@ -0,0 +1,111 @@ +//! Replay contract for every well-formed elevated re-identification denial path. + +use tepp_api::{ + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, +}; + +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } +} + +fn grant() -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: true, + } +} + +fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-workspace".into(), + opaque_analytical_id: "opaque-person-42".into(), + direct_identity: "Pat Lee ".into(), + } +} + +fn assert_denied_and_audited( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + sink: &mut RecordingAuditSink, +) { + assert_eq!( + disclose_identity_mapping(grant, mapping, decision_time, sink), + Err(ApiError::AuthorizationDenied) + ); + let record = sink.records.last().expect("denial audit record"); + assert_eq!(record.outcome(), ReidentificationAuditOutcome::Denied); + assert!(record.decision_digest().starts_with("sha256:")); + assert_eq!(record.decision_digest().len(), 71); + assert!(!format!("{record:?}").contains("Pat Lee")); + assert!(!format!("{record:?}").contains("pat.lee@example.test")); +} + +#[test] +fn all_well_formed_denial_paths_append_replayable_redacted_records() { + let mut sink = RecordingAuditSink::default(); + + let expired = grant(); + assert_denied_and_audited(&expired, &mapping(), "2027-01-01T00:00:00Z", &mut sink); + + let not_yet_valid = grant(); + assert_denied_and_audited( + ¬_yet_valid, + &mapping(), + "2025-12-31T23:59:59Z", + &mut sink, + ); + + let mut cross_tenant = mapping(); + cross_tenant.tenant_workspace_id = "other-tenant".into(); + assert_denied_and_audited(&grant(), &cross_tenant, "2026-06-15T12:00:00Z", &mut sink); + + let mut wrong_purpose = grant(); + wrong_purpose.purpose = AnalyticalPurpose::PartnerDisclosure; + assert_denied_and_audited( + &wrong_purpose, + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ); + + let mut missing_elevation = grant(); + missing_elevation.reidentification_authorized = false; + assert_denied_and_audited( + &missing_elevation, + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ); + + let mut operational = grant(); + operational.purpose = AnalyticalPurpose::OperationalMonitoring; + assert_denied_and_audited(&operational, &mapping(), "2026-06-15T12:00:00Z", &mut sink); + + let mut modular = grant(); + modular.purpose = AnalyticalPurpose::ModularServiceConsumer; + assert_denied_and_audited(&modular, &mapping(), "2026-06-15T12:00:00Z", &mut sink); + + assert_eq!(sink.records.len(), 7); + assert!( + sink.records + .iter() + .all(|record| record.outcome() == ReidentificationAuditOutcome::Denied) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index e2263ea2..09b7998d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -112,6 +112,10 @@ TEPP owns its application/API state, authorized evidence, model runs, and artifa `naruon` may submit evidence/analysis requests or consume versioned topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP topic inference and must not read TEPP database tables directly. HTTP interchange is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` (`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Detailed modular surfaces and failure modes are recorded in [`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md). +### Provider payload minimization + +Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants, and never copies a direct identity mapping into the provider body or ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. + ### contextual-orchestrator TEPP may call a provider-neutral interpretation/orchestration port for semantic unitization, blinded model review, and evidence-bounded interpretation. The orchestrator does not own TEPP's statistical truth, source evidence, model registry, merge/release authority, or scientific acceptance. Detailed port boundary and credential separation are recorded in [`docs/connectors/contextual-orchestrator-interpretation-port.md`](connectors/contextual-orchestrator-interpretation-port.md). diff --git a/docs/PRIVACY_DATA_GOVERNANCE.md b/docs/PRIVACY_DATA_GOVERNANCE.md index 88199b21..a96143ef 100644 --- a/docs/PRIVACY_DATA_GOVERNANCE.md +++ b/docs/PRIVACY_DATA_GOVERNANCE.md @@ -52,6 +52,8 @@ LLM use is optional and bounded. The default provider payload is evidence-minimi 4. treat returned content as untrusted and subject to deterministic verification; 5. allow local/private provider profiles for deployments that prohibit external disclosure. +`tepp_api::minimize_provider_payload` is the fail-closed adapter for this policy: a time-bounded purpose grant is required, identity mappings cannot ride on a provider offer, and ordinary disclosure logs record purpose and field-class flags without source text. Re-identification uses `tepp_api::disclose_identity_mapping` and is limited to scientific validation with an explicit grant flag. + `NVIDIA_NIM_API_KEY` is a development/test credential boundary, not authorization to send unrestricted production data. ## 6. Retention, deletion, and legal hold diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d9743..dfcdd9e8 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -33,8 +33,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | future contextual-orchestrator integration + ablation evidence | accepted-target | -| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | -| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target | +| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization plus provider-payload minimization / elevated re-identification on the active PR; persistence retention/deletion remaining | partial | +| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial on the active PR; persistent `access_grant` storage remaining | partial | | standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; future service ports | partial | | naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (active PR); live HTTP service remaining | partial | | contextual-orchestrator interpretation port boundary | ADR 0010/0011; LLM orchestration | `docs/connectors/contextual-orchestrator-interpretation-port.md`; live port remaining | partial | diff --git a/docs/adr/0009-purpose-bound-pii-governance.md b/docs/adr/0009-purpose-bound-pii-governance.md index 229733fd..b705b886 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -1,7 +1,8 @@ # ADR 0009 — Purpose-bound PII governance without blanket masking **Decision status:** Accepted -**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; authorization/export/provider adapters remain accepted-target +**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is implemented-main; purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target + **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5d3aa546..f16c2345 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,7 +14,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | | [0007](0007-rust-workspace-quality-gates.md) | Explicit Rust workspace, pinned toolchains, and exact quality gates | Accepted | implemented-main | ADR 0014 governs scientific/product claim promotion beyond repository-quality tooling. | | [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | ADR 0013 governs future persistence/reproducibility/split authority. | -| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Persistence retention/deletion/legal-hold (`0007`) is on the active PR; authorization/export/provider adapters and deployment evidence remain accepted-target. | +| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Persistence retention/deletion/legal-hold (`0007`) implemented-main; provider-payload minimization and elevated re-identification are on the active PR; deployment evidence remains accepted-target. | | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | accepted-target | Owns direct/verify/committee/conductor selection, budget, role/topology, and ablation policy. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | diff --git a/docs/research/provider-payload-minimization.md b/docs/research/provider-payload-minimization.md new file mode 100644 index 00000000..2a84e646 --- /dev/null +++ b/docs/research/provider-payload-minimization.md @@ -0,0 +1,58 @@ +# Provider payload minimization and purpose-bound re-identification + +## Scope + +This note doctors the `tepp_api` provider-payload adapter that implements ADR 0009 without a new database migration: + +1. a time-bounded `PurposeGrant` is evaluated at a decision instant (`YYYY-MM-DDTHH:MM:SSZ`); expired and not-yet-valid grants fail closed; +2. model-provider payloads keep opaque analytical identifiers and membership roles so multilevel measurement is not destroyed by blanket masking; +3. free-text source bodies follow the existing purpose-bound export gate; +4. direct identity mappings never enter a provider payload or an ordinary disclosure log, even when a separate re-identification flag is set; +5. re-identification is a distinct elevated path limited to scientific validation with an explicit grant flag and matching tenant. + +HTTP posting to NVIDIA NIM, naruon, or contextual-orchestrator remains a later connector slice. This adapter is the fail-closed payload contract those connectors must call. + +## Authoritative sources + +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + +National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 + +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. + +## Application + +ISO/IEC 27701:2025 is the current standalone Privacy Information Management System standard and is cited for purpose limitation and disclosure minimization (ISO/IEC, 2025). The 2019 edition remains recorded because earlier TEPP doctoring referenced it as an extension to ISO/IEC 27001 (ISO/IEC, 2019). The NIST Privacy Framework supplies the Core functions (Identify-P, Control-P, Communicate-P) used to separate provider disclosure from re-identification and to keep logs free of source bodies (National Institute of Standards and Technology, 2020). FIPS PUB 180-4 is the primary source for the SHA-256 function used to bind re-identification audit evidence (National Institute of Standards and Technology, 2015). These citations are readiness mappings, not certification or legal sufficiency. A keyed HMAC commitment and key-rotation port remain an ADR 0009 persistence follow-on; this adapter does not introduce a key-management surface. + +## Verification + +- scientific payloads retain opaque IDs, roles, and authorized source text; +- operational/partner source-text offers are denied; +- expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grants fail closed; +- attached identity mappings are refused on the provider path; +- elevated scientific re-identification returns the mapping; other purposes and missing flags are denied and audited; +- disclosure logs never contain source text or mapping strings; +- the published SHA-256 test vector matches the v1 length-delimited encoding. + +## Re-identification audit digest authority + +The caller cannot provide or select the decision digest. TEPP computes a +versioned, length-delimited SHA-256 digest inside the trust boundary from the +purpose grant, protected mapping, decision instant, and allow/deny outcome +(National Institute of Standards and Technology, 2015). Direct identity +contributes to the digest but never appears in the redacted audit record or +ordinary logs. This makes append-only replay evidence bind the actual governed +decision rather than an arbitrary caller assertion. + +Serialization for `tepp.reidentification.audit.v1` is UTF-8 field bytes, each +prefixed by its `u64` big-endian length. Empty strings are encoded as length +`0` with no payload. The field order is version, action code +`reidentify_identity_mapping`, grant tenant, principal, purpose wire name, +`valid_from`, `valid_to` presence (`1` or `0`), `valid_to` text or empty, +re-identification flag (`1` or `0`), mapping tenant, opaque analytical id, +direct identity, decision instant, and outcome wire name (`allowed` or +`denied`). The published vector for the scientific-validation fixture in +`reidentification_audit_contract.rs` is +`sha256:1a3b774ae989b971cd6ba7f4a38697e94a532ce29cff7c0a8e0d8d2a73f45ded`. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 75710ed3..6e0438fe 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -122,6 +122,16 @@ National Institute of Standards and Technology. (n.d.). *AI risk management fram 한국인터넷진흥원. (n.d.). *클라우드서비스 보안인증제 제도소개*. Retrieved August 11, 2026, from https://isms.kisa.or.kr/main/csap/intro/index.jsp +## Privacy purpose limitation and provider minimization + +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + +ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. + +National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 + +TEPP applies ISO/IEC 27701:2025 purpose limitation and disclosure minimization, and the NIST Privacy Framework Control-P / Communicate-P functions, to provider payloads and separately authorized re-identification (ISO/IEC, 2025; National Institute of Standards and Technology, 2020). The 2019 edition is retained for earlier doctoring that treated PIMS as an ISO/IEC 27001 extension (ISO/IEC, 2019). Re-identification audit evidence is bound with FIPS 180-4 SHA-256 over a length-delimited canonical encoding (National Institute of Standards and Technology, 2015). These sources are readiness mappings, not certification. + TEPP uses these sources as management/risk/readiness inputs, not as self-certification authority. ISO/IEC 42001:2023 and ISO/IEC 23894:2023 are published international standards (International Organization for Standardization, 2023a, 2023b). NIST AI RMF 1.0 remains the published framework while NIST is preparing a revision (Tabassi, 2023; National Institute of Standards and Technology, n.d.); the repository tracks the revision but does not silently treat an unpublished successor as normative. AICPA Trust Services Criteria are readiness inputs rather than self-issued attestation (American Institute of Certified Public Accountants, 2023). KISA currently describes CSAP service types as IaaS, SaaS, and DaaS and grades as high, medium, and low, while noting that the high and medium grades await later implementation (한국인터넷진흥원, n.d.). CSAP and SOC 2 evidence depend on actual deployment/organization controls and independent assessment. ## Security, accessibility, and software supply chain diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index bc9d83fb..ecc175bd 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -10,7 +10,8 @@ Task 12 introduces fail-closed versioned wire contracts in `tepp_api` for standa 4. JSON-LD export envelopes; 5. GraphML export rendering with XML escaping; 6. committed JSON Schema and example payloads under `schemas/` and `examples/`; -7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking. +7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking; +8. purpose-bound provider-payload minimization with expired-grant denial and separately authorized re-identification. HTTP service routing remains accepted-target. Domain estimation and persistence stay outside this crate. @@ -26,11 +27,15 @@ Brandes, U., Eiglsperger, M., Herman, I., Himsolt, M., & Marshall, M. S. (2002). Wright, A., Andrews, H., Hutton, B., & Dennis, G. (2022). *JSON Schema: A media type for describing JSON documents* (Internet-Draft draft-bhutton-json-schema-01). IETF. https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-01 +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + ## Verification -- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, and example payload parsing; +- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, example payload parsing, expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay; - workspace line and branch coverage gates must remain complete for production modules. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0..7c50db23 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 |