diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c1db80..8b05db16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Exact sensitive-data model-route admission that binds the complete existing sensitive authority to bounded provider, model, region, retention-policy, training-policy, reviewed subprocessor-policy, and export-policy identifiers; the compatibility constructor defaults export to `no-export`, and route admission remains explicitly separate from protected-value disclosure, export execution, provider authentication, runtime region attestation, model invocation, and fallback selection. - In-process authoritative sensitive-handle use reservation and first-revocation-wins lifecycle state that owns the bounded use count, records task-completion/policy-change/key-rotation/session-termination/suspicious-use revocation causes, blocks all future reservations after revocation, increments only after exact scope/classification/expiry/use-limit authorization, and leaves denied reservations unconsumed; this is a policy primitive only and does not claim durable broker storage, protected-value resolution, or cross-process transactionality. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index abb84862..52290f9d 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -7,8 +7,12 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod model_route; mod sensitive_data; +pub use model_route::{ + ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_model_route, +}; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason, HandleUseDecision, HandleUseRequest, SensitiveDataAuthority, SensitiveDataRequest, diff --git a/crates/originweave-policy/src/model_route.rs b/crates/originweave-policy/src/model_route.rs new file mode 100644 index 00000000..bb02847f --- /dev/null +++ b/crates/originweave-policy/src/model_route.rs @@ -0,0 +1,201 @@ +//! Exact provider/model/region/retention/training/subprocessor/export route admission for sensitive-data workflows. +//! +//! This module evaluates route metadata only. An [`ModelRouteDecision::Authorized`] result does +//! not authorize disclosure of a protected value, authenticate a provider, prove the provider's +//! physical region, invoke a model, or choose a fallback. A trusted broker/orchestrator must +//! independently authorize the permitted value form and derive the actual route identity from +//! trusted runtime configuration. + +use crate::sensitive_data::{ + DisclosureDecision, DisclosureScope, SensitiveDataAuthority, SensitiveDataRequest, + evaluate_disclosure, +}; + +const MAX_ROUTE_IDENTIFIER_BYTES: usize = 128; +const DEFAULT_EXPORT_POLICY_ID: &str = "no-export"; + +/// Result of comparing one requested model route with its exact sensitive-data route authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelRouteDecision { + /// The exact sensitive-data authority and every route identifier match. + Authorized, + /// The sensitive-data authority is malformed or does not match the route scope. + AuthorityMismatch, + /// Provider/model/region/retention/training/subprocessor/export metadata is malformed or mismatched. + RouteMismatch, +} + +/// One proposed model route for an already classified sensitive field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRouteRequest { + authority: SensitiveDataAuthority, + provider_id: String, + model_id: String, + region_id: String, + retention_policy_id: String, + training_policy_id: String, + subprocessor_policy_id: String, + export_policy_id: String, +} + +impl ModelRouteRequest { + /// Build a requested model route that is explicitly non-exporting. + /// + /// Call [`Self::with_export_policy`] when a workflow requests a separately governed export + /// behavior. Neither constructor nor export-policy selection grants disclosure authority. + #[must_use] + pub fn new( + authority: SensitiveDataAuthority, + provider_id: &str, + model_id: &str, + region_id: &str, + retention_policy_id: &str, + training_policy_id: &str, + subprocessor_policy_id: &str, + ) -> Self { + Self { + authority, + provider_id: provider_id.to_owned(), + model_id: model_id.to_owned(), + region_id: region_id.to_owned(), + retention_policy_id: retention_policy_id.to_owned(), + training_policy_id: training_policy_id.to_owned(), + subprocessor_policy_id: subprocessor_policy_id.to_owned(), + export_policy_id: DEFAULT_EXPORT_POLICY_ID.to_owned(), + } + } + + /// Select one explicit export-policy identifier for this requested route. + /// + /// The identifier describes policy intent only. Matching an export-policy identifier does not + /// execute an export or grant access to protected bytes; the later broker/export boundary must + /// independently authorize and enforce the actual destination and value form. + #[must_use] + pub fn with_export_policy(mut self, export_policy_id: &str) -> Self { + self.export_policy_id = export_policy_id.to_owned(); + self + } + + fn route_identifiers_are_valid(&self) -> bool { + route_identifier_is_valid(&self.provider_id) + && route_identifier_is_valid(&self.model_id) + && route_identifier_is_valid(&self.region_id) + && route_identifier_is_valid(&self.retention_policy_id) + && route_identifier_is_valid(&self.training_policy_id) + && route_identifier_is_valid(&self.subprocessor_policy_id) + && route_identifier_is_valid(&self.export_policy_id) + } +} + +/// Exact model-route authority for one existing sensitive-data authority tuple. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRouteScope { + authority: SensitiveDataAuthority, + provider_id: String, + model_id: String, + region_id: String, + retention_policy_id: String, + training_policy_id: String, + subprocessor_policy_id: String, + export_policy_id: String, +} + +impl ModelRouteScope { + /// Build a non-exporting route scope from trusted policy metadata. + /// + /// Route identifiers are validated when the scope is evaluated so malformed policy state + /// remains fail-closed rather than becoming authority merely because request and scope match. + /// Call [`Self::with_export_policy`] when policy explicitly governs another export mode. + #[must_use] + pub fn new( + authority: SensitiveDataAuthority, + provider_id: &str, + model_id: &str, + region_id: &str, + retention_policy_id: &str, + training_policy_id: &str, + subprocessor_policy_id: &str, + ) -> Self { + Self { + authority, + provider_id: provider_id.to_owned(), + model_id: model_id.to_owned(), + region_id: region_id.to_owned(), + retention_policy_id: retention_policy_id.to_owned(), + training_policy_id: training_policy_id.to_owned(), + subprocessor_policy_id: subprocessor_policy_id.to_owned(), + export_policy_id: DEFAULT_EXPORT_POLICY_ID.to_owned(), + } + } + + /// Select one explicit export-policy identifier for this trusted route scope. + /// + /// This is route metadata only. The eventual export path must separately verify protected-value + /// disclosure, destination authority, retention, and any required human or dual-control approval. + #[must_use] + pub fn with_export_policy(mut self, export_policy_id: &str) -> Self { + self.export_policy_id = export_policy_id.to_owned(); + self + } + + fn route_identifiers_are_valid(&self) -> bool { + route_identifier_is_valid(&self.provider_id) + && route_identifier_is_valid(&self.model_id) + && route_identifier_is_valid(&self.region_id) + && route_identifier_is_valid(&self.retention_policy_id) + && route_identifier_is_valid(&self.training_policy_id) + && route_identifier_is_valid(&self.subprocessor_policy_id) + && route_identifier_is_valid(&self.export_policy_id) + } +} + +fn route_identifier_is_valid(identifier: &str) -> bool { + !identifier.is_empty() + && identifier.len() <= MAX_ROUTE_IDENTIFIER_BYTES + && identifier.bytes().any(|byte| byte.is_ascii_alphanumeric()) + && identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +/// Evaluate exact model-route admission without authorizing protected-value disclosure. +/// +/// The existing sensitive-data disclosure comparator is reused only to validate that request and +/// scope carry the same complete [`SensitiveDataAuthority`]. Route admission then independently +/// requires valid, exact provider, model, region, retention-policy, training-policy, reviewed +/// subprocessor-policy, and export-policy identifiers. Keeping retention, training, subprocessor, +/// and export authority distinct prevents one permitted provider contract dimension from silently +/// authorizing another. The compatibility constructor defaults export policy to `no-export`, so an +/// omitted export choice never widens route authority. All route identifiers use bounded 1–128 byte +/// ASCII policy tokens containing alphanumeric characters plus `.`, `_`, `:`, and `-`. +#[must_use] +pub fn evaluate_model_route( + request: &ModelRouteRequest, + scope: &ModelRouteScope, +) -> ModelRouteDecision { + let authority_decision = evaluate_disclosure( + &SensitiveDataRequest::new(request.authority.clone()), + &DisclosureScope::new( + scope.authority.clone(), + DisclosureDecision::OpaqueHandleOnly, + ), + ); + if authority_decision == DisclosureDecision::DenyAccess { + return ModelRouteDecision::AuthorityMismatch; + } + + if !request.route_identifiers_are_valid() + || !scope.route_identifiers_are_valid() + || request.provider_id != scope.provider_id + || request.model_id != scope.model_id + || request.region_id != scope.region_id + || request.retention_policy_id != scope.retention_policy_id + || request.training_policy_id != scope.training_policy_id + || request.subprocessor_policy_id != scope.subprocessor_policy_id + || request.export_policy_id != scope.export_policy_id + { + ModelRouteDecision::RouteMismatch + } else { + ModelRouteDecision::Authorized + } +} diff --git a/crates/originweave-policy/tests/sensitive_model_export_route.rs b/crates/originweave-policy/tests/sensitive_model_export_route.rs new file mode 100644 index 00000000..0fd0a884 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_model_export_route.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +//! Export-policy binding regressions for sensitive model-route admission. +//! +//! Retention, training, reviewed subprocessors, and export are independent policy +//! dimensions. A route that is otherwise identical must not inherit export +//! authority merely because its provider/model/region tuple is approved. + +use originweave_core::Origin; +use originweave_policy::{ + DataClassification, ModelRouteDecision, ModelRouteRequest, ModelRouteScope, + SensitiveDataAuthority, evaluate_model_route, +}; + +fn authority() -> SensitiveDataAuthority { + SensitiveDataAuthority::new( + "tenant-alpha", + "task-42", + "customer-email", + "case-resolution", + Origin::parse("https://model-gateway.example").expect("valid destination origin"), + DataClassification::PersonalData, + ) +} + +fn request(export_policy_id: &str) -> ModelRouteRequest { + ModelRouteRequest::new( + authority(), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) + .with_export_policy(export_policy_id) +} + +fn scope(export_policy_id: &str) -> ModelRouteScope { + ModelRouteScope::new( + authority(), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) + .with_export_policy(export_policy_id) +} + +#[test] +fn exact_export_policy_is_part_of_model_route_authority() { + assert_eq!( + evaluate_model_route(&request("no-export"), &scope("no-export")), + ModelRouteDecision::Authorized + ); + assert_eq!( + evaluate_model_route(&request("approved-export"), &scope("no-export")), + ModelRouteDecision::RouteMismatch + ); +} + +#[test] +fn malformed_requested_export_policy_fails_closed() { + assert_eq!( + evaluate_model_route(&request("invalid export"), &scope("no-export")), + ModelRouteDecision::RouteMismatch + ); +} + +#[test] +fn malformed_scope_export_policy_fails_closed_against_valid_request() { + assert_eq!( + evaluate_model_route(&request("no-export"), &scope("invalid export")), + ModelRouteDecision::RouteMismatch + ); +} diff --git a/crates/originweave-policy/tests/sensitive_model_route.rs b/crates/originweave-policy/tests/sensitive_model_route.rs new file mode 100644 index 00000000..0eaf64f7 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_model_route.rs @@ -0,0 +1,314 @@ +#![allow(clippy::expect_used)] + +//! Fail-closed policy contracts for model-route admission of sensitive data. +//! +//! Route admission is intentionally separate from disclosure authority: authorizing a +//! provider/model/region/retention/training/subprocessor-policy tuple must never imply that raw +//! protected values may be disclosed. A later broker/orchestrator must independently authorize +//! the value form and derive the actual route identity from trusted runtime configuration. + +use originweave_core::Origin; +use originweave_policy::{ + DataClassification, ModelRouteDecision, ModelRouteRequest, ModelRouteScope, + SensitiveDataAuthority, evaluate_model_route, +}; + +fn authority(destination: &str) -> SensitiveDataAuthority { + SensitiveDataAuthority::new( + "tenant-alpha", + "task-42", + "customer-email", + "case-resolution", + Origin::parse(destination).expect("valid destination origin"), + DataClassification::PersonalData, + ) +} + +fn scope() -> ModelRouteScope { + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) +} + +fn request() -> ModelRouteRequest { + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) +} + +#[test] +fn exact_sensitive_authority_and_model_route_are_admitted() { + assert_eq!( + evaluate_model_route(&request(), &scope()), + ModelRouteDecision::Authorized + ); +} + +#[test] +fn sensitive_authority_mismatch_is_distinct_from_route_mismatch() { + let wrong_authority = ModelRouteRequest::new( + authority("https://different-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ); + + assert_eq!( + evaluate_model_route(&wrong_authority, &scope()), + ModelRouteDecision::AuthorityMismatch + ); +} + +#[test] +fn every_model_route_dimension_is_exact_and_non_transferable() { + let cases = [ + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-other", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-other-v2", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "us-east", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "provider-default-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "training-allowed", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-other-v2", + ), + ]; + + for candidate in cases { + assert_eq!( + evaluate_model_route(&candidate, &scope()), + ModelRouteDecision::RouteMismatch + ); + } +} + +#[test] +fn malformed_request_route_identifiers_fail_closed() { + let malformed_values = ["", "contains space", "🚫", &"x".repeat(129)]; + + for malformed in malformed_values { + let candidates = [ + ModelRouteRequest::new( + authority("https://model-gateway.example"), + malformed, + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + malformed, + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + malformed, + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + malformed, + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + malformed, + "subprocessors-reviewed-v1", + ), + ModelRouteRequest::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + malformed, + ), + ]; + for candidate in candidates { + assert_eq!( + evaluate_model_route(&candidate, &scope()), + ModelRouteDecision::RouteMismatch + ); + } + } +} + +#[test] +fn malformed_scope_route_identifiers_fail_closed_against_a_valid_request() { + let cases = [ + ModelRouteScope::new( + authority("https://model-gateway.example"), + "invalid provider", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "invalid model", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "invalid region", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "invalid retention", + "no-training", + "subprocessors-reviewed-v1", + ), + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "invalid training", + "subprocessors-reviewed-v1", + ), + ModelRouteScope::new( + authority("https://model-gateway.example"), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "invalid subprocessors", + ), + ]; + + for malformed_scope in cases { + assert_eq!( + evaluate_model_route(&request(), &malformed_scope), + ModelRouteDecision::RouteMismatch + ); + } +} + +#[test] +fn malformed_sensitive_authority_fails_closed_even_when_both_sides_match() { + let invalid_authority = SensitiveDataAuthority::new( + "invalid tenant", + "task-42", + "customer-email", + "case-resolution", + Origin::parse("https://model-gateway.example").expect("valid destination origin"), + DataClassification::PersonalData, + ); + let invalid_request = ModelRouteRequest::new( + invalid_authority.clone(), + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ); + let invalid_scope = ModelRouteScope::new( + invalid_authority, + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ); + + assert_eq!( + evaluate_model_route(&invalid_request, &invalid_scope), + ModelRouteDecision::AuthorityMismatch + ); +}