diff --git a/crates/persistence_postgres/src/error.rs b/crates/persistence_postgres/src/error.rs index 67bd69661..f0fa61573 100644 --- a/crates/persistence_postgres/src/error.rs +++ b/crates/persistence_postgres/src/error.rs @@ -127,6 +127,8 @@ pub enum MigrationContractError { MissingTemporalColumns, /// Embedded or supplied migration SQL was empty or unreadable. EmptyMigrationSql, + /// A committed table mutation requires final-state semantics the bounded validator does not yet own. + UnsupportedTableFinalStateMutation, /// Tenant RLS was declared without enabling FORCE RLS on a table. MissingRlsEnable, /// Tenant RLS was declared without a multi-word isolation policy. @@ -150,6 +152,7 @@ impl fmt::Display for MigrationContractError { Self::MissingTenantBoundary => "missing tenant boundary column", Self::MissingTemporalColumns => "missing temporal columns", Self::EmptyMigrationSql => "empty migration sql", + Self::UnsupportedTableFinalStateMutation => "unsupported table final-state mutation", Self::MissingRlsEnable => "missing row level security enable", Self::MissingRlsPolicy => "missing tenant isolation policy", Self::MissingAppRuntimeRole => "missing application runtime role", @@ -296,6 +299,10 @@ mod tests { MigrationContractError::EmptyMigrationSql.to_string(), "empty migration sql" ); + assert_eq!( + MigrationContractError::UnsupportedTableFinalStateMutation.to_string(), + "unsupported table final-state mutation" + ); assert_eq!( MigrationContractError::MissingRlsEnable.to_string(), "missing row level security enable" diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 60ef41617..8863a9d48 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -1,996 +1,1065 @@ //! Embedded migration catalog and fail-closed SQL contracts. -use crate::MigrationContractError; -use crate::naming::is_multi_word_snake_case; -use std::collections::BTreeSet; - -const FOUNDATION_UP: &str = include_str!("../../../migrations/0001_bitemporal_foundation.up.sql"); -const FOUNDATION_DOWN: &str = - include_str!("../../../migrations/0001_bitemporal_foundation.down.sql"); -const RLS_UP: &str = include_str!("../../../migrations/0002_tenant_row_level_security.up.sql"); -const RLS_DOWN: &str = include_str!("../../../migrations/0002_tenant_row_level_security.down.sql"); -const MODEL_RUN_UP: &str = include_str!("../../../migrations/0003_model_run_artifact_chain.up.sql"); -const MODEL_RUN_DOWN: &str = - include_str!("../../../migrations/0003_model_run_artifact_chain.down.sql"); -const APPEND_ONLY_UP: &str = - include_str!("../../../migrations/0004_append_only_immutability_triggers.up.sql"); -const APPEND_ONLY_DOWN: &str = - include_str!("../../../migrations/0004_append_only_immutability_triggers.down.sql"); -const TEMPORAL_ORDER_UP: &str = - include_str!("../../../migrations/0005_temporal_interval_ordering.up.sql"); -const TEMPORAL_ORDER_DOWN: &str = - include_str!("../../../migrations/0005_temporal_interval_ordering.down.sql"); -const MEMBERSHIP_UP: &str = - include_str!("../../../migrations/0006_typed_membership_assignment.up.sql"); -const MEMBERSHIP_DOWN: &str = - include_str!("../../../migrations/0006_typed_membership_assignment.down.sql"); -const RETENTION_UP: &str = - include_str!("../../../migrations/0007_retention_deletion_legal_hold.up.sql"); -const RETENTION_DOWN: &str = - include_str!("../../../migrations/0007_retention_deletion_legal_hold.down.sql"); - -/// Forward and rollback SQL for one migration unit. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MigrationCatalog { - up_sql: String, - down_sql: String, -} - -impl MigrationCatalog { - /// Load the embedded foundation and tenant RLS migrations shipped with this crate. - /// - /// # Errors - /// - /// Returns [`MigrationContractError::EmptyMigrationSql`] when embedded - /// sources are unexpectedly empty. - pub fn from_embedded() -> Result { - let up_sql = format!( - "{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}\n{APPEND_ONLY_UP}\n{TEMPORAL_ORDER_UP}\n{MEMBERSHIP_UP}\n{RETENTION_UP}" - ); - let down_sql = format!( - "{RETENTION_DOWN}\n{MEMBERSHIP_DOWN}\n{TEMPORAL_ORDER_DOWN}\n{APPEND_ONLY_DOWN}\n{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}" - ); - Self::from_sources(&up_sql, &down_sql) - } - - fn from_sources(up_sql: &str, down_sql: &str) -> Result { - if up_sql.trim().is_empty() || down_sql.trim().is_empty() { - return Err(MigrationContractError::EmptyMigrationSql); - } - Ok(Self { - up_sql: up_sql.to_owned(), - down_sql: down_sql.to_owned(), - }) - } - - /// Construct a catalog from raw SQL strings (used by contract tests). - #[must_use] - pub fn from_sql(up_sql: &str, down_sql: &str) -> Self { - Self { - up_sql: up_sql.to_owned(), - down_sql: down_sql.to_owned(), - } - } +#[path = "migration_core.rs"] +mod core; +#[path = "migration_validation.rs"] +mod validation; - /// Borrow the forward migration SQL. - #[must_use] - pub fn up_sql(&self) -> &str { - &self.up_sql - } - - /// Borrow the rollback migration SQL. - #[must_use] - pub fn down_sql(&self) -> &str { - &self.down_sql - } -} +use crate::MigrationContractError; +pub use core::MigrationCatalog; -/// Validate migration SQL against TEPP persistence contracts. +/// Validate migration SQL against TEPP persistence contracts through one +/// PostgreSQL-aware lexical boundary. /// -/// When the catalog declares row-level security, every tenant-scoped table must -/// enable RLS and name multi-word isolation policies. +/// The lexical boundary removes comments and quoted SQL bodies from the +/// structural parser view, exposes quoted identifiers with their declared +/// spelling, and rejects unterminated lexical regions before contract parsing. +/// Both forward and rollback SQL pass through that boundary before structural +/// validation so malformed rollback text cannot bypass the catalog contract. /// /// # Errors /// -/// Returns naming, tenant, temporal, RLS, or emptiness failures. +/// Returns lexical, naming, tenant, temporal, RLS, or emptiness failures. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { - if catalog.up_sql.trim().is_empty() || catalog.down_sql.trim().is_empty() { - return Err(MigrationContractError::EmptyMigrationSql); + let runtime_role_declared = + validation::declares_created_role(catalog.up_sql(), "tepp_app_runtime") + .ok_or(MigrationContractError::EmptyMigrationSql)?; + let normalized_up = normalize_catalog_sql(catalog.up_sql()) + .ok_or(MigrationContractError::EmptyMigrationSql)?; + let normalized_down = normalize_catalog_sql(catalog.down_sql()) + .ok_or(MigrationContractError::EmptyMigrationSql)?; + let committed_up = core::project_committed_sql(&normalized_up) + .ok_or(MigrationContractError::MissingAppRuntimeRole)?; + let requires_runtime_role = validation::declares_row_level_security(&committed_up); + if requires_runtime_role && !declares_tenant_session_guc(&committed_up) { + return Err(MigrationContractError::MissingTenantSessionGuc); } - - let tables = parse_create_table_names(catalog.up_sql()); - if tables.is_empty() { - return Err(MigrationContractError::EmptyMigrationSql); + if requires_runtime_role && !tenant_policies_bind_session_guc(&committed_up) { + return Err(MigrationContractError::MissingRlsPolicy); + } + let normalized = MigrationCatalog::from_sql(&normalized_up, &normalized_down); + core::validate_migration_catalog(&normalized)?; + if requires_runtime_role && !runtime_role_declared { + return Err(MigrationContractError::MissingAppRuntimeRole); } + Ok(()) +} + +/// Require the tenant setting key to be the first argument of PostgreSQL's +/// unqualified `current_setting` call rather than accepting the same literal +/// anywhere in the migration text. Schema-qualified lookalikes fail closed so +/// application-defined functions cannot impersonate the built-in witness. +fn declares_tenant_session_guc(normalized_sql: &str) -> bool { + const FUNCTION_NAME: &str = "current_setting"; + const TENANT_GUC: &str = "'tepp.current_tenant_record_id'"; + + let mut search_from = 0usize; + while let Some(relative) = normalized_sql[search_from..].find(FUNCTION_NAME) { + let start = search_from + relative; + let end = start + FUNCTION_NAME.len(); + let prefix = normalized_sql[..start].trim_end(); + let starts_at_boundary = normalized_sql[..start] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + let is_unqualified = !prefix.ends_with('.'); + let ends_at_boundary = normalized_sql[end..] + .chars() + .next() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); - for table in &tables { - if !is_multi_word_snake_case(table) { - return Err(MigrationContractError::SingleWordObjectName); + if starts_at_boundary && is_unqualified && ends_at_boundary { + let after_name = normalized_sql[end..].trim_start(); + if let Some(arguments) = after_name.strip_prefix('(') { + let first_argument = arguments.trim_start(); + if let Some(after_key) = first_argument.strip_prefix(TENANT_GUC) { + let delimiter = after_key.trim_start().chars().next(); + if matches!(delimiter, Some(',' | ')')) { + return true; + } + } + } } - let body = - table_body(catalog.up_sql(), table).ok_or(MigrationContractError::EmptyMigrationSql)?; - validate_table_body(table, body)?; + search_from = end; } + false +} - if declares_row_level_security(catalog.up_sql()) { - validate_tenant_rls_contract(catalog.up_sql(), &tables)?; - } - if declares_append_only_immutability(catalog.up_sql()) { - validate_append_only_immutability(catalog.up_sql())?; +/// Byte span occupied by a top-level policy clause keyword. +/// +/// Keeping start and end separately lets callers slice only the predicate body +/// while excluding headers such as policy names, target tables, commands, and +/// role lists from tenant-isolation evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PolicyClauseSpan { + start: usize, + end: usize, +} + +/// Row-predicate clauses whose PostgreSQL command semantics differ. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PolicyClause { + Using, + WithCheck, +} + +/// Return whether one ASCII byte can continue the bounded SQL identifiers used here. +/// +/// This helper is intentionally narrower than the PostgreSQL lexer because it +/// is used only for ASCII keyword and role-list boundary checks after lexical +/// normalization; durable object-name parsing uses the core identifier authority. +fn is_sql_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// Match an ASCII SQL keyword only when both sides are identifier boundaries. +/// +/// The returned index is immediately after the keyword. Prefixes embedded in a +/// longer identifier are rejected so policy-clause and Boolean parsing cannot +/// manufacture structure from names such as `using_flag` or `orphan`. +fn bounded_ascii_keyword(bytes: &[u8], start: usize, keyword: &[u8]) -> Option { + let end = start.checked_add(keyword.len())?; + if end > bytes.len() || !bytes[start..end].eq_ignore_ascii_case(keyword) { + return None; } - if declares_temporal_interval_ordering(catalog.up_sql()) { - validate_temporal_interval_ordering(catalog.up_sql())?; + if start > 0 && is_sql_identifier_byte(bytes[start - 1]) { + return None; } - if declares_retention_legal_hold(catalog.up_sql()) { - validate_retention_legal_hold(catalog.up_sql())?; + if end < bytes.len() && is_sql_identifier_byte(bytes[end]) { + return None; } - - Ok(()) + Some(end) } -fn declares_append_only_immutability(up_sql: &str) -> bool { - let lower = up_sql.to_ascii_lowercase(); - lower.contains("reject_append_only_mutation") || lower.contains("_reject_mutation") -} +/// Locate a policy clause at parenthesis depth zero after lexical normalization. +/// PostgreSQL keywords need token boundaries, not surrounding whitespace, so +/// `)WITH CHECK(` and `USING(` are valid clause boundaries. Comments have +/// already been converted to spacing by the lexical authority. +fn policy_clause_span(policy_sql: &str, clause: PolicyClause) -> Option { + let bytes = policy_sql.as_bytes(); + let mut depth = 0usize; + let mut index = 0usize; -fn validate_append_only_immutability(up_sql: &str) -> Result<(), MigrationContractError> { - let lower = up_sql.to_ascii_lowercase(); - if !lower.contains("create or replace function reject_append_only_mutation") { - return Err(MigrationContractError::MissingAppendOnlyTrigger); - } - let required = [ - "source_artifact", - "audit_event", - "reproducibility_manifest", - "corpus_split_manifest", - "model_run", - "model_artifact", - ]; - for table in required { - let trigger = format!("{table}_reject_mutation"); - if !lower.contains(&format!("create trigger {trigger}")) { - return Err(MigrationContractError::MissingAppendOnlyTrigger); + while index < bytes.len() { + match bytes[index] { + b'(' => { + depth = depth.saturating_add(1); + index += 1; + continue; + } + b')' => { + depth = depth.saturating_sub(1); + index += 1; + continue; + } + _ => {} } - if !lower.contains(&format!("revoke update, delete on table {table}")) { - return Err(MigrationContractError::MissingAppendOnlyTrigger); + if depth != 0 { + index += 1; + continue; } + + match clause { + PolicyClause::Using => { + if let Some(end) = bounded_ascii_keyword(bytes, index, b"using") { + return Some(PolicyClauseSpan { start: index, end }); + } + } + PolicyClause::WithCheck => { + if let Some(with_end) = bounded_ascii_keyword(bytes, index, b"with") { + let mut check_start = with_end; + let whitespace_start = check_start; + while check_start < bytes.len() && bytes[check_start].is_ascii_whitespace() { + check_start += 1; + } + if check_start > whitespace_start { + if let Some(check_end) = + bounded_ascii_keyword(bytes, check_start, b"check") + { + return Some(PolicyClauseSpan { + start: index, + end: check_end, + }); + } + } + } + } + } + index += 1; } - Ok(()) + None } -fn declares_temporal_interval_ordering(up_sql: &str) -> bool { - let lower = up_sql.to_ascii_lowercase(); - lower.contains("_valid_order") || lower.contains("_system_order") -} +/// Return whether a row predicate in the normalized policy statement contains +/// the exact unquoted tenant key identifier. Header names, target tables, and +/// role lists are excluded so they cannot impersonate predicate evidence. +fn policy_binds_tenant_identifier(policy_sql: &str) -> bool { + const TENANT_IDENTIFIER: &str = "tenant_record_id"; -fn validate_temporal_interval_ordering(up_sql: &str) -> Result<(), MigrationContractError> { - let lower = up_sql.to_ascii_lowercase(); - let required = [ - "document_record_valid_order", - "document_record_system_order", - "document_record_revision_positive", - "event_instance_valid_order", - "event_instance_system_order", - "membership_assignment_valid_order", - ]; - for constraint in required { - if !lower.contains(&format!("constraint {constraint}")) { - return Err(MigrationContractError::MissingTemporalIntervalConstraint); + let using_clause = policy_clause_span(policy_sql, PolicyClause::Using); + let check_clause = policy_clause_span(policy_sql, PolicyClause::WithCheck); + let Some(predicate_start) = [using_clause, check_clause] + .into_iter() + .flatten() + .map(|span| span.end) + .min() + else { + return false; + }; + let predicate_sql = &policy_sql[predicate_start..]; + + let mut search_from = 0usize; + while let Some(relative) = predicate_sql[search_from..].find(TENANT_IDENTIFIER) { + let start = search_from + relative; + let end = start + TENANT_IDENTIFIER.len(); + let inside_atomic_literal = predicate_sql[..start] + .bytes() + .filter(|byte| *byte == b'\'') + .count() + % 2 + == 1; + let starts_at_boundary = predicate_sql[..start] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + let ends_at_boundary = predicate_sql[end..] + .chars() + .next() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + if !inside_atomic_literal && starts_at_boundary && ends_at_boundary { + return true; } + search_from = end; } - if !lower.contains("valid_to is null or valid_from <=") { - return Err(MigrationContractError::MissingTemporalIntervalConstraint); - } - if !lower.contains("system_to is null or system_from <=") { - return Err(MigrationContractError::MissingTemporalIntervalConstraint); - } - if !lower.contains("revision_number > 0") { - return Err(MigrationContractError::MissingTemporalIntervalConstraint); - } - Ok(()) + false } -fn declares_retention_legal_hold(up_sql: &str) -> bool { - let lower = up_sql.to_ascii_lowercase(); - lower.contains("retention_policy") - || lower.contains("legal_hold") - || lower.contains("evidence_tombstone") +/// Return whether one equality operand is exactly TEPP's tenant row key. +/// +/// Only the direct identifier and its shipped `::text` cast are admitted; more +/// complex expressions fail closed so computed values cannot masquerade as the +/// authoritative row tenant. +fn direct_tenant_operand(side: &str) -> bool { + let compact = strip_enclosing_predicate_parentheses(side) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + matches!(compact.as_str(), "tenant_record_id" | "tenant_record_id::text") } -fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractError> { - let lower = up_sql.to_ascii_lowercase(); - let required_tables = [ - "retention_policy", - "legal_hold", - "deletion_request", - "evidence_tombstone", - ]; - for table in required_tables { - if !lower.contains(&format!("create table {table}")) { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - } - if !lower.contains("create or replace function reject_held_evidence_deletion") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - if !lower.contains("create or replace function reject_tombstoned_evidence_restore") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - if !lower.contains("create trigger deletion_request_reject_held_deletion") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - if !lower.contains("create trigger document_record_reject_tombstone_restore") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - if !lower.contains("constraint retention_policy_period_positive") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - if !lower.contains("constraint legal_hold_document_scope_consistent") { - return Err(MigrationContractError::MissingRetentionLegalHold); - } - Ok(()) +/// Accept only the bounded session-side expressions used by TEPP's tenant RLS +/// contract. Merely containing `current_setting(...)` is insufficient: wrappers +/// such as `coalesce(current_setting(...), tenant_record_id::text)` can fall +/// back to the row's own tenant value and turn the equality into a tautology. +/// Empty string literals are removed by lexical normalization, so the shipped +/// `nullif(current_setting(..., true), '')` form appears with an empty second +/// argument here. +fn direct_tenant_session_operand(side: &str) -> bool { + let compact = strip_enclosing_predicate_parentheses(side) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + matches!( + compact.as_str(), + "current_setting('tepp.current_tenant_record_id')" + | "current_setting('tepp.current_tenant_record_id',true)" + | "nullif(current_setting('tepp.current_tenant_record_id'),)" + | "nullif(current_setting('tepp.current_tenant_record_id',true),)" + ) } -fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { - let lower = body.to_ascii_lowercase(); - if requires_tenant_boundary(table) && !lower.contains("tenant_record_id") { - return Err(MigrationContractError::MissingTenantBoundary); - } +/// Return whether a depth-zero equality directly binds row tenant to session tenant. +/// +/// Comparison operators such as `<=`, `>=`, `!=`, and `==` are excluded. Both +/// operand orders are supported, but each side must satisfy the bounded direct +/// operand contracts rather than merely containing the relevant identifiers. +fn predicate_contains_top_level_tenant_session_equality(predicate_sql: &str) -> bool { + let bytes = predicate_sql.as_bytes(); + let mut depth = 0usize; - if !has_system_time_column(&lower) { - return Err(MigrationContractError::MissingTemporalColumns); - } + for equality in 0..bytes.len() { + match bytes[equality] { + b'(' => { + depth = depth.saturating_add(1); + continue; + } + b')' => { + depth = depth.saturating_sub(1); + continue; + } + b'=' if depth == 0 => {} + _ => continue, + } - // Registry and immutable audit tables may omit availability/valid windows. - if is_registry_or_audit_table(table) { - return Ok(()); - } + let previous = equality.checked_sub(1).and_then(|index| bytes.get(index)); + let next = bytes.get(equality + 1); + if previous.is_some_and(|byte| matches!(*byte, b'<' | b'>' | b'!' | b'=')) + || next.is_some_and(|byte| matches!(*byte, b'<' | b'>' | b'=')) + { + continue; + } - if !has_domain_time_column(&lower) { - return Err(MigrationContractError::MissingTemporalColumns); + let left = &predicate_sql[..equality]; + let right = &predicate_sql[equality + 1..]; + if (direct_tenant_operand(left) && direct_tenant_session_operand(right)) + || (direct_tenant_session_operand(left) && direct_tenant_operand(right)) + { + return true; + } } - Ok(()) + false } -fn validate_tenant_rls_contract( - up_sql: &str, - tables: &BTreeSet, -) -> Result<(), MigrationContractError> { - let lower = up_sql.to_ascii_lowercase(); - if !lower.contains("tepp_app_runtime") { - return Err(MigrationContractError::MissingAppRuntimeRole); - } - if !lower.contains("tepp.current_tenant_record_id") { - return Err(MigrationContractError::MissingTenantSessionGuc); - } +/// Remove only parentheses that enclose the entire predicate expression. +/// +/// Parentheses that close before trailing content are structural and therefore +/// retained. Malformed nesting also stops stripping so later checks fail closed +/// rather than accepting a widened or synthetically simplified expression. +fn strip_enclosing_predicate_parentheses(mut predicate_sql: &str) -> &str { + loop { + let trimmed = predicate_sql.trim(); + let bytes = trimmed.as_bytes(); + if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') { + return trimmed; + } - let policies = parse_create_policy_names(up_sql); - if policies.is_empty() { - return Err(MigrationContractError::MissingRlsPolicy); - } - for policy in &policies { - if !is_multi_word_snake_case(policy) { - return Err(MigrationContractError::SingleWordObjectName); + let mut depth = 0usize; + let mut encloses_entire_expression = true; + for (index, byte) in bytes.iter().enumerate() { + match *byte { + b'(' => depth = depth.saturating_add(1), + b')' => { + if depth == 0 { + return trimmed; + } + depth -= 1; + if depth == 0 && index + 1 != bytes.len() { + encloses_entire_expression = false; + break; + } + } + _ => {} + } } + if depth != 0 || !encloses_entire_expression { + return trimmed; + } + predicate_sql = &trimmed[1..trimmed.len() - 1]; } +} - for table in tables { - if !table_has_rls_enabled(&lower, table) { - return Err(MigrationContractError::MissingRlsEnable); +/// Split a predicate on one bounded Boolean keyword only at depth zero. +/// +/// Returning `None` means the keyword is absent at top level, not that the +/// predicate is malformed. Nested alternatives remain inside their owning +/// segment for recursive evaluation by the Boolean-path contract. +fn split_top_level_boolean<'a>(predicate_sql: &'a str, keyword: &[u8]) -> Option> { + let bytes = predicate_sql.as_bytes(); + let mut depth = 0usize; + let mut segment_start = 0usize; + let mut index = 0usize; + let mut segments = Vec::new(); + + while index < bytes.len() { + match bytes[index] { + b'(' => { + depth = depth.saturating_add(1); + index += 1; + continue; + } + b')' => { + depth = depth.saturating_sub(1); + index += 1; + continue; + } + _ => {} } - if !table_has_tenant_policy(&lower, table) { - return Err(MigrationContractError::MissingRlsPolicy); + if depth == 0 { + if let Some(keyword_end) = bounded_ascii_keyword(bytes, index, keyword) { + segments.push(&predicate_sql[segment_start..index]); + segment_start = keyword_end; + index = keyword_end; + continue; + } } + index += 1; } - Ok(()) -} -fn declares_row_level_security(up_sql: &str) -> bool { - let lower = up_sql.to_ascii_lowercase(); - let has_enable = lower.contains("enable row level security"); - let has_policy = lower.contains("create policy"); - has_enable | has_policy + if segments.is_empty() { + None + } else { + segments.push(&predicate_sql[segment_start..]); + Some(segments) + } } -fn table_has_rls_enabled(lower_sql: &str, table: &str) -> bool { - let enable = format!("alter table {table} enable row level security"); - let force = format!("alter table {table} force row level security"); - lower_sql.contains(&enable) & lower_sql.contains(&force) -} +/// Evaluate the bounded Boolean structure of a normalized policy predicate. +/// `OR` requires every disjunct to carry the tenant/session equality, while one +/// tenant-bound `AND` conjunct guards the complete conjunction. Parentheses are +/// grouping only; arbitrary function-call wrappers remain opaque and therefore +/// cannot donate an equality hidden inside their argument list. +fn all_top_level_or_paths_bind_tenant_session(predicate_sql: &str) -> bool { + let predicate_sql = strip_enclosing_predicate_parentheses(predicate_sql); -fn table_has_tenant_policy(lower_sql: &str, table: &str) -> bool { - let on_table = format!(" on {table}"); - let mut search_from = 0usize; - while let Some(rel) = lower_sql[search_from..].find("create policy") { - let abs = search_from + rel; - let after_policy = &lower_sql[abs..]; - let window_end = after_policy[13..] - .find("create policy") - .map_or(after_policy.len(), |idx| 13 + idx); - let window = &after_policy[..window_end]; - if window.contains(&on_table) && window.contains("tenant_record_id") { - return true; - } - search_from = abs + "create policy".len(); + if let Some(disjuncts) = split_top_level_boolean(predicate_sql, b"or") { + return disjuncts + .into_iter() + .all(all_top_level_or_paths_bind_tenant_session); } - false + if let Some(conjuncts) = split_top_level_boolean(predicate_sql, b"and") { + return conjuncts + .into_iter() + .any(all_top_level_or_paths_bind_tenant_session); + } + + predicate_contains_top_level_tenant_session_equality(predicate_sql) } -fn requires_tenant_boundary(table: &str) -> bool { - table != "tenant_record" +/// Require the tenant column and tenant session key to participate in the same +/// equality comparison on every row-admitting Boolean path. A tenant-bound +/// conjunct may safely guard nested alternatives such as +/// `tenant_binding AND (role_a OR role_b)`, while `tenant_binding OR true` and +/// opaque wrappers around an unbound alternative fail closed. +fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { + all_top_level_or_paths_bind_tenant_session(policy_sql) } -fn is_registry_or_audit_table(table: &str) -> bool { - table == "tenant_record" || table == "audit_event" +/// PostgreSQL row-level-security commands represented by the bounded validator. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PolicyCommand { + All, + Select, + Insert, + Update, + Delete, } -fn has_system_time_column(lower_body: &str) -> bool { - let has_system_time = lower_body.contains("system_time"); - let has_system_from = lower_body.contains("system_from"); - let has_recorded_system_time = lower_body.contains("recorded_system_time"); - has_system_time | has_system_from | has_recorded_system_time +/// Parse the command scope of one normalized CREATE POLICY statement. +/// +/// Omitted `FOR` defaults to PostgreSQL `ALL`; unsupported or malformed command +/// tokens return `None` so they cannot inherit permissive coverage accidentally. +fn policy_command(policy_sql: &str) -> Option { + let using_clause = policy_clause_span(policy_sql, PolicyClause::Using); + let check_clause = policy_clause_span(policy_sql, PolicyClause::WithCheck); + let header_end = [using_clause, check_clause] + .into_iter() + .flatten() + .map(|span| span.start) + .min() + .unwrap_or(policy_sql.len()); + let tokens = policy_sql[..header_end].split_whitespace().collect::>(); + let Some(for_index) = tokens + .iter() + .position(|token| token.eq_ignore_ascii_case("FOR")) + else { + return Some(PolicyCommand::All); + }; + + match tokens.get(for_index + 1).map(|token| token.to_ascii_uppercase()) { + Some(command) if command == "ALL" => Some(PolicyCommand::All), + Some(command) if command == "SELECT" => Some(PolicyCommand::Select), + Some(command) if command == "INSERT" => Some(PolicyCommand::Insert), + Some(command) if command == "UPDATE" => Some(PolicyCommand::Update), + Some(command) if command == "DELETE" => Some(PolicyCommand::Delete), + _ => None, + } } -fn has_domain_time_column(lower_body: &str) -> bool { - let has_available = lower_body.contains("available_time"); - let has_valid_from = lower_body.contains("valid_from"); - has_available | has_valid_from +/// Return the exact table token targeted by one normalized policy header. +/// +/// Only the header before `USING`/`WITH CHECK` is searched, preventing `ON` +/// inside row expressions from donating a false policy target. +fn policy_target_table(policy_sql: &str) -> Option<&str> { + let using_clause = policy_clause_span(policy_sql, PolicyClause::Using); + let check_clause = policy_clause_span(policy_sql, PolicyClause::WithCheck); + let header_end = [using_clause, check_clause] + .into_iter() + .flatten() + .map(|span| span.start) + .min() + .unwrap_or(policy_sql.len()); + let tokens = policy_sql[..header_end].split_whitespace().collect::>(); + let on_index = tokens + .iter() + .enumerate() + .skip(2) + .find_map(|(index, token)| token.eq_ignore_ascii_case("ON").then_some(index))?; + tokens.get(on_index + 1).copied() } -fn parse_create_table_names(sql: &str) -> BTreeSet { - let mut names = BTreeSet::new(); - let upper = sql.to_ascii_uppercase(); - let mut search_from = 0usize; - while let Some(rel) = upper[search_from..].find("CREATE TABLE") { - let abs = search_from + rel + "CREATE TABLE".len(); - let rest = sql[abs..].trim_start(); - let rest = rest - .strip_prefix("IF NOT EXISTS") - .or_else(|| rest.strip_prefix("if not exists")) - .map_or(rest, str::trim_start); - let name: String = rest - .chars() - .take_while(|ch| { - let alphanumeric = ch.is_ascii_alphanumeric(); - let underscore = *ch == '_'; - alphanumeric | underscore - }) - .collect(); - if name.is_empty() { - search_from = abs; - continue; +/// Parse the policy's explicit `TO` role list, defaulting omission to `PUBLIC`. +/// +/// The grammar is intentionally `role (, role)*`; leading, trailing, adjacent, +/// or missing commas and unsupported role tokens return `None` rather than being +/// compacted into a different authorization scope. +fn policy_roles(policy_sql: &str) -> Option> { + let using_clause = policy_clause_span(policy_sql, PolicyClause::Using); + let check_clause = policy_clause_span(policy_sql, PolicyClause::WithCheck); + let header_end = [using_clause, check_clause] + .into_iter() + .flatten() + .map(|span| span.start) + .min() + .unwrap_or(policy_sql.len()); + let tokenizable = policy_sql[..header_end].replace(',', " , "); + let tokens = tokenizable.split_whitespace().collect::>(); + let Some(to_index) = tokens + .iter() + .position(|token| token.eq_ignore_ascii_case("TO")) + else { + return Some(vec!["public".to_owned()]); + }; + + let mut roles = Vec::new(); + let mut expect_role = true; + for token in tokens.iter().skip(to_index + 1) { + if expect_role { + if *token == "," || !token.bytes().all(is_sql_identifier_byte) { + return None; + } + roles.push(token.to_ascii_lowercase()); + expect_role = false; + } else { + if *token != "," { + return None; + } + expect_role = true; } - names.insert(name.to_ascii_lowercase()); - search_from = abs; } - names + (!roles.is_empty() && !expect_role).then_some(roles) } -fn parse_create_policy_names(sql: &str) -> BTreeSet { - let mut names = BTreeSet::new(); - let upper = sql.to_ascii_uppercase(); - let mut search_from = 0usize; - while let Some(rel) = upper[search_from..].find("CREATE POLICY") { - let abs = search_from + rel + "CREATE POLICY".len(); - let rest = sql[abs..].trim_start(); - let name: String = rest - .chars() - .take_while(|ch| { - let alphanumeric = ch.is_ascii_alphanumeric(); - let underscore = *ch == '_'; - alphanumeric | underscore - }) - .collect(); - if !name.is_empty() { - names.insert(name.to_ascii_lowercase()); +/// Return whether one policy command covers a requested concrete command. +/// +/// PostgreSQL `FOR ALL` is the only wildcard in this bounded model; otherwise +/// coverage requires exact command equality. +fn policy_command_applies(policy_command: PolicyCommand, requested_command: PolicyCommand) -> bool { + policy_command == PolicyCommand::All || policy_command == requested_command +} + +/// Validate the row-predicate clauses required by one policy command. +/// +/// SELECT/DELETE require `USING`, INSERT requires only `WITH CHECK`, and +/// ALL/UPDATE require `USING` plus a tenant-bound `WITH CHECK` when that clause +/// is explicitly present. Reversed clause order or command-incompatible clauses +/// fail closed. +fn policy_row_predicates_bind_tenant_session(policy_sql: &str) -> bool { + let Some(command) = policy_command(policy_sql) else { + return false; + }; + let using_clause = policy_clause_span(policy_sql, PolicyClause::Using); + let check_clause = policy_clause_span(policy_sql, PolicyClause::WithCheck); + if using_clause.is_some_and(|using_span| { + check_clause.is_some_and(|check_span| check_span.start < using_span.end) + }) { + return false; + } + + let using_sql = using_clause.map(|using_span| { + let end = check_clause + .filter(|check_span| check_span.start >= using_span.end) + .map(|check_span| check_span.start) + .unwrap_or(policy_sql.len()); + &policy_sql[using_span.end..end] + }); + let check_sql = check_clause.map(|check_span| &policy_sql[check_span.end..]); + let using_binds = using_sql.is_some_and(policy_binds_tenant_session_equality); + let check_binds = check_sql.is_some_and(policy_binds_tenant_session_equality); + + match command { + PolicyCommand::All | PolicyCommand::Update => { + using_binds && (check_sql.is_none() || check_binds) } - search_from = abs; + PolicyCommand::Select | PolicyCommand::Delete => using_binds && check_sql.is_none(), + PolicyCommand::Insert => using_sql.is_none() && check_binds, } - names } -fn table_body<'a>(sql: &'a str, table: &str) -> Option<&'a str> { - let lower = sql.to_ascii_lowercase(); - let needles = [ - format!("create table if not exists {table}"), - format!("create table {table}"), - ]; - let start = needles +/// Return whether the normalized policy header explicitly declares +/// PostgreSQL's restrictive policy composition mode. Only the grammar slot +/// immediately after `ON table_name` counts; `AS restrictive` inside a policy +/// expression is an SQL alias and must not change composition semantics. +fn policy_is_restrictive(policy_sql: &str) -> bool { + let tokens = policy_sql.split_whitespace().collect::>(); + let Some(on_index) = tokens .iter() - .find_map(|needle| lower.find(needle).map(|idx| (idx, needle.len())))?; - let after = &sql[start.0 + start.1..]; - let open = after.find('(')?; - let mut depth = 0i32; - for (idx, ch) in after[open..].char_indices() { - match ch { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - return Some(&after[open..=open + idx]); - } - } - _ => {} - } - } - None + .enumerate() + .skip(2) + .find_map(|(index, token)| token.eq_ignore_ascii_case("ON").then_some(index)) + else { + return false; + }; + + tokens + .get(on_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("AS")) + && tokens + .get(on_index + 3) + .is_some_and(|token| token.eq_ignore_ascii_case("RESTRICTIVE")) } -#[cfg(test)] -mod tests { - use super::{MigrationCatalog, validate_migration_catalog}; - use crate::MigrationContractError; +/// Bind tenant identity and tenant-session evidence to every policy that can +/// independently admit rows. PostgreSQL permissive policies are OR-composed; +/// restrictive policies are AND-composed only after a permissive policy grants +/// access. A restrictive policy therefore requires permissive coverage for each +/// command and target role it can constrain, otherwise PostgreSQL's default-deny +/// composition makes that policy path operationally inaccessible. `PUBLIC` +/// coverage is universal; otherwise role coverage is matched conservatively by +/// exact declared role because role-membership grants are outside this bounded +/// migration parser. +/// +/// Command semantics determine which tenant-bound predicates are mandatory: +/// read-capable permissive policies require `USING`, insert requires +/// `WITH CHECK`, and `ALL`/`UPDATE` reuse a valid `USING` for writes only when +/// `WITH CHECK` is omitted. Restrictive policies may add narrower conditions +/// without duplicating the tenant predicate once matching permissive coverage +/// exists. +fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { + const CREATE_POLICY: &str = "create policy"; + const CONCRETE_COMMANDS: [PolicyCommand; 4] = [ + PolicyCommand::Select, + PolicyCommand::Insert, + PolicyCommand::Update, + PolicyCommand::Delete, + ]; - #[test] - fn embedded_catalog_is_non_empty_and_valid() { - let catalog = MigrationCatalog::from_embedded().expect("embedded"); - validate_migration_catalog(&catalog).expect("valid"); - assert!(catalog.up_sql().contains("CREATE TABLE")); - assert!(catalog.up_sql().contains("ENABLE ROW LEVEL SECURITY")); - assert!(catalog.up_sql().contains("CREATE POLICY")); - assert!(catalog.up_sql().contains("tepp_app_runtime")); - assert!(catalog.up_sql().contains("tepp.current_tenant_record_id")); - assert!(catalog.down_sql().contains("DROP TABLE")); - assert!(catalog.down_sql().contains("DROP POLICY")); - assert!(catalog.down_sql().contains("DROP ROLE")); - } + let lower = normalized_sql.to_ascii_lowercase(); + let mut search_from = 0usize; + let mut saw_policy = false; + let mut permissive_coverage = Vec::new(); + let mut restrictive_requirements = Vec::new(); - #[test] - fn helper_predicates_are_exhaustive() { - use super::{ - declares_row_level_security, has_domain_time_column, has_system_time_column, - is_registry_or_audit_table, parse_create_policy_names, requires_tenant_boundary, - table_has_rls_enabled, table_has_tenant_policy, + while let Some(relative) = lower[search_from..].find(CREATE_POLICY) { + saw_policy = true; + let start = search_from + relative; + let statement_tail = &normalized_sql[start..]; + let statement_end = statement_tail.find(';').unwrap_or(statement_tail.len()); + let statement = &statement_tail[..statement_end]; + let Some(table) = policy_target_table(statement) else { + return false; }; - assert!(requires_tenant_boundary("document_record")); - assert!(!requires_tenant_boundary("tenant_record")); - assert!(is_registry_or_audit_table("tenant_record")); - assert!(is_registry_or_audit_table("audit_event")); - assert!(!is_registry_or_audit_table("document_record")); - assert!(has_system_time_column("system_time timestamptz")); - assert!(has_system_time_column("system_from timestamptz")); - assert!(has_system_time_column("recorded_system_time timestamptz")); - assert!(!has_system_time_column("available_time timestamptz")); - assert!(has_domain_time_column("available_time timestamptz")); - assert!(has_domain_time_column("valid_from timestamptz")); - assert!(!has_domain_time_column("system_time timestamptz")); - assert!(declares_row_level_security("ENABLE ROW LEVEL SECURITY")); - assert!(declares_row_level_security("CREATE POLICY x ON y")); - assert!(!declares_row_level_security( - "CREATE TABLE document_record ()" - )); - assert!(table_has_rls_enabled( - "alter table document_record enable row level security; alter table document_record force row level security;", - "document_record" - )); - assert!(!table_has_rls_enabled( - "alter table document_record enable row level security;", - "document_record" - )); - assert!(table_has_tenant_policy( - "create policy document_record_tenant_isolation on document_record using (tenant_record_id = 'x'::uuid)", - "document_record" - )); - assert!(!table_has_tenant_policy( - "create policy other_table_policy on other_table using (tenant_record_id = 'x'::uuid)", - "document_record" - )); - let policies = parse_create_policy_names( - "CREATE POLICY document_record_tenant_isolation ON document_record FOR ALL USING (true);", - ); - assert!(policies.contains("document_record_tenant_isolation")); + let Some(command) = policy_command(statement) else { + return false; + }; + let Some(roles) = policy_roles(statement) else { + return false; + }; + + if policy_is_restrictive(statement) { + restrictive_requirements.push((table.to_ascii_lowercase(), command, roles)); + } else { + if !declares_tenant_session_guc(statement) + || !policy_binds_tenant_identifier(statement) + || !policy_row_predicates_bind_tenant_session(statement) + { + return false; + } + permissive_coverage.push((table.to_ascii_lowercase(), command, roles)); + } + search_from = start + CREATE_POLICY.len(); } - #[test] - fn naming_and_column_contracts_fail_closed() { - let single_word = MigrationCatalog::from_sql( - "CREATE TABLE documents (document_id uuid PRIMARY KEY);", - "DROP TABLE documents;", - ); - assert_eq!( - validate_migration_catalog(&single_word), - Err(MigrationContractError::SingleWordObjectName) - ); - let no_tenant = MigrationCatalog::from_sql( - r" - CREATE TABLE document_record ( - document_record_id uuid PRIMARY KEY, - available_time timestamptz NOT NULL, - system_time timestamptz NOT NULL - ); - ", - "DROP TABLE document_record;", - ); - assert_eq!( - validate_migration_catalog(&no_tenant), - Err(MigrationContractError::MissingTenantBoundary) - ); - let no_system = MigrationCatalog::from_sql( - r" - CREATE TABLE document_record ( - document_record_id uuid PRIMARY KEY, - tenant_record_id uuid NOT NULL, - available_time timestamptz NOT NULL - ); - ", - "DROP TABLE document_record;", - ); - assert_eq!( - validate_migration_catalog(&no_system), - Err(MigrationContractError::MissingTemporalColumns) - ); - let missing_domain_time = MigrationCatalog::from_sql( - r" - CREATE TABLE document_record ( - document_record_id uuid PRIMARY KEY, - tenant_record_id uuid NOT NULL, - system_time timestamptz NOT NULL - ); - ", - "DROP TABLE document_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_domain_time), - Err(MigrationContractError::MissingTemporalColumns) - ); + if !saw_policy { + return false; } - #[test] - #[allow(clippy::too_many_lines)] - fn rls_contracts_fail_closed_when_declared() { - let missing_role = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; - ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; - CREATE POLICY tenant_record_tenant_isolation ON tenant_record - FOR ALL USING ( - tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') - ); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_role), - Err(MigrationContractError::MissingAppRuntimeRole) - ); + restrictive_requirements.into_iter().all( + |(table, restrictive_command, restrictive_roles)| { + CONCRETE_COMMANDS + .into_iter() + .filter(|command| policy_command_applies(restrictive_command, *command)) + .all(|command| { + if restrictive_roles.iter().any(|role| role == "public") { + return permissive_coverage.iter().any( + |(permissive_table, permissive_command, permissive_roles)| { + permissive_table == &table + && policy_command_applies(*permissive_command, command) + && permissive_roles.iter().any(|role| role == "public") + }, + ); + } - let missing_guc = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE ROLE tepp_app_runtime NOSUPERUSER; - ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; - ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; - CREATE POLICY tenant_record_tenant_isolation ON tenant_record - FOR ALL USING (tenant_record_id IS NOT NULL); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_guc), - Err(MigrationContractError::MissingTenantSessionGuc) - ); + restrictive_roles.iter().all(|restrictive_role| { + permissive_coverage.iter().any( + |(permissive_table, permissive_command, permissive_roles)| { + permissive_table == &table + && policy_command_applies(*permissive_command, command) + && permissive_roles.iter().any(|permissive_role| { + permissive_role == "public" + || permissive_role == restrictive_role + }) + }, + ) + }) + }) + }, + ) +} - let missing_enable = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE ROLE tepp_app_runtime NOSUPERUSER; - CREATE POLICY tenant_record_tenant_isolation ON tenant_record - FOR ALL USING ( - tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') - ); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_enable), - Err(MigrationContractError::MissingRlsEnable) - ); +/// Normalize SQL and then remove PostgreSQL's `CONCURRENTLY` index modifier +/// from the structural parser view without treating it as the index name. +/// +/// The first pass owns lexical masking. A second pass is used only when the +/// modifier was removed so existing qualified-name and object-name guards see +/// the canonical `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name` shape. +fn normalize_catalog_sql(sql: &str) -> Option { + let normalized = validation::normalize_migration_sql(sql)?; + let canonical = canonicalize_concurrent_index_modifier(&normalized); + if canonical == normalized { + Some(normalized) + } else { + validation::normalize_migration_sql(&canonical) + } +} - let single_word_policy = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE ROLE tepp_app_runtime NOSUPERUSER; - ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; - ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; - CREATE POLICY isolation ON tenant_record - FOR ALL USING ( - tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') - ); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&single_word_policy), - Err(MigrationContractError::SingleWordObjectName) - ); +/// Remove PostgreSQL's `CONCURRENTLY` modifier from CREATE INDEX structural syntax. +/// +/// The lexical pass has already masked quoted/commented semicolons, so exposing +/// real statement delimiters as tokens is safe. Only the modifier is removed; +/// `UNIQUE`, `IF NOT EXISTS`, and the declared index name retain their order for +/// downstream naming and qualified-name checks. +fn canonicalize_concurrent_index_modifier(sql: &str) -> String { + // PostgreSQL does not require whitespace after a statement delimiter. The + // lexical pass has already masked quoted/commented semicolons, so exposing + // real delimiters as tokens here keeps `;CREATE INDEX CONCURRENTLY` on the + // same structural path as its whitespace-separated form. + let tokenizable = sql.replace(';', " ; "); + let tokens = tokenizable.split_whitespace().collect::>(); + let mut canonical = Vec::with_capacity(tokens.len()); + let mut index = 0usize; - let missing_policy = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE ROLE tepp_app_runtime NOSUPERUSER; - -- tepp.current_tenant_record_id referenced for GUC scan; isolation policy omitted - ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; - ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_policy), - Err(MigrationContractError::MissingRlsPolicy) - ); + while index < tokens.len() { + if tokens[index].eq_ignore_ascii_case("CREATE") + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("INDEX")) + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("CONCURRENTLY")) + { + canonical.push(tokens[index]); + canonical.push(tokens[index + 1]); + index += 3; + } else if tokens[index].eq_ignore_ascii_case("CREATE") + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("UNIQUE")) + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("INDEX")) + && tokens + .get(index + 3) + .is_some_and(|token| token.eq_ignore_ascii_case("CONCURRENTLY")) + { + canonical.push(tokens[index]); + canonical.push(tokens[index + 1]); + canonical.push(tokens[index + 2]); + index += 4; + } else { + canonical.push(tokens[index]); + index += 1; + } + } - // Policy exists and is multi-word, but does not mention tenant_record_id. - let policy_without_tenant_predicate = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE ROLE tepp_app_runtime NOSUPERUSER; - -- bind GUC name for scan: tepp.current_tenant_record_id - ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; - ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; - CREATE POLICY tenant_record_tenant_isolation ON tenant_record - FOR ALL USING (true); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&policy_without_tenant_predicate), - Err(MigrationContractError::MissingRlsPolicy) - ); + canonical.join(" ") +} - // Second CREATE POLICY window + IF NOT EXISTS / empty policy name edges. - assert!(!super::table_has_tenant_policy( - "create policy other_table_isolation on other_table using (tenant_record_id = 1); \ - create policy tenant_record_tenant_isolation on tenant_record using (true);", - "tenant_record", - )); - assert!(super::table_has_tenant_policy( - "create policy other_table_isolation on other_table using (true); \ - create policy tenant_record_tenant_isolation on tenant_record using (tenant_record_id = 1);", - "tenant_record", - )); - assert!(super::parse_create_policy_names("CREATE POLICY \"weird\" ON t;").is_empty()); - assert!(super::table_body( - "CREATE TABLE IF NOT EXISTS tenant_record (tenant_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", - "tenant_record", - ) - .is_some()); - assert!( - super::table_body("CREATE TABLE tenant_record NO_PARENS;", "tenant_record").is_none() - ); - } +#[cfg(test)] +mod tests { + use super::{ + PolicyClause, PolicyCommand, all_top_level_or_paths_bind_tenant_session, + canonicalize_concurrent_index_modifier, declares_tenant_session_guc, + direct_tenant_session_operand, policy_binds_tenant_identifier, + policy_binds_tenant_session_equality, policy_clause_span, policy_command, + policy_is_restrictive, policy_roles, policy_row_predicates_bind_tenant_session, + tenant_policies_bind_session_guc, + }; #[test] - fn append_only_immutability_contract_fails_closed() { - let missing_function = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE TRIGGER source_artifact_reject_mutation - BEFORE UPDATE ON source_artifact - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_function), - Err(MigrationContractError::MissingAppendOnlyTrigger) - ); + fn tenant_guc_requires_a_current_setting_call() { + assert!(declares_tenant_session_guc( + "tenant_record_id = current_setting ( 'tepp.current_tenant_record_id' , true )" + )); + assert!(!declares_tenant_session_guc( + "select 'tepp.current_tenant_record_id'" + )); + assert!(!declares_tenant_session_guc( + "other_current_setting ( 'tepp.current_tenant_record_id' , true )" + )); + assert!(!declares_tenant_session_guc( + "current_setting ( 'tepp.current_tenant_record_id_shadow' , true )" + )); + assert!(!declares_tenant_session_guc( + "tenant_schema.current_setting ( 'tepp.current_tenant_record_id' , true )" + )); + assert!(!declares_tenant_session_guc( + "tenant_schema . current_setting ( 'tepp.current_tenant_record_id' , true )" + )); + } - let missing_trigger = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE OR REPLACE FUNCTION reject_append_only_mutation() - RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_trigger), - Err(MigrationContractError::MissingAppendOnlyTrigger) - ); + #[test] + fn policy_clauses_use_structural_token_boundaries() { + let sql = "create policy tenant_policy on tenant_record for all using(tenant_record_id is not null)with\ncheck(tenant_record_id is not null)"; + let using_span = policy_clause_span(sql, PolicyClause::Using).expect("USING clause"); + let check_span = + policy_clause_span(sql, PolicyClause::WithCheck).expect("WITH CHECK clause"); + assert_eq!(&sql[using_span.start..using_span.end], "using"); + assert_eq!(&sql[check_span.start..check_span.end], "with\ncheck"); + assert!(using_span.end < check_span.start); + assert!(policy_clause_span("select confusing(1)", PolicyClause::Using).is_none()); + } - // All triggers present; REVOKE omitted only for model_artifact so the - // last revoke branch returns MissingAppendOnlyTrigger. - let missing_revoke = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE OR REPLACE FUNCTION reject_append_only_mutation() - RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; - CREATE TRIGGER source_artifact_reject_mutation - BEFORE UPDATE ON source_artifact - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - REVOKE UPDATE, DELETE ON TABLE source_artifact FROM tepp_app_runtime; - CREATE TRIGGER audit_event_reject_mutation - BEFORE UPDATE ON audit_event - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - REVOKE UPDATE, DELETE ON TABLE audit_event FROM tepp_app_runtime; - CREATE TRIGGER reproducibility_manifest_reject_mutation - BEFORE UPDATE ON reproducibility_manifest - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - REVOKE UPDATE, DELETE ON TABLE reproducibility_manifest FROM tepp_app_runtime; - CREATE TRIGGER corpus_split_manifest_reject_mutation - BEFORE UPDATE ON corpus_split_manifest - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - REVOKE UPDATE, DELETE ON TABLE corpus_split_manifest FROM tepp_app_runtime; - CREATE TRIGGER model_run_reject_mutation - BEFORE UPDATE ON model_run - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - REVOKE UPDATE, DELETE ON TABLE model_run FROM tepp_app_runtime; - CREATE TRIGGER model_artifact_reject_mutation - BEFORE UPDATE ON model_artifact - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); - ", - "DROP TABLE tenant_record;", - ); - assert_eq!( - validate_migration_catalog(&missing_revoke), - Err(MigrationContractError::MissingAppendOnlyTrigger) - ); + #[test] + fn tenant_identifier_must_be_structural_policy_evidence() { + assert!(policy_binds_tenant_identifier( + "create policy document_record_tenant_isolation on document_record using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + assert!(!policy_binds_tenant_identifier( + "create policy document_record_tenant_isolation on document_record using(document_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + assert!(!policy_binds_tenant_identifier( + "create policy tenant_record_id on document_record using(document_record_id is not null)" + )); + assert!(!policy_binds_tenant_identifier( + "create policy document_record_tenant_isolation on document_record using(tenant_record_id_shadow is not null)" + )); + } - assert!(super::declares_append_only_immutability( - "CREATE TRIGGER source_artifact_reject_mutation" + #[test] + fn tenant_session_operand_is_bounded_to_the_supported_contract_shape() { + assert!(direct_tenant_session_operand( + "current_setting ( 'tepp.current_tenant_record_id' , true )" + )); + assert!(direct_tenant_session_operand( + "nullif ( current_setting ( 'tepp.current_tenant_record_id' , true ) , )" + )); + assert!(!direct_tenant_session_operand( + "coalesce ( current_setting ( 'tepp.current_tenant_record_id' , true ) , tenant_record_id::text )" + )); + assert!(!direct_tenant_session_operand( + "other_current_setting ( 'tepp.current_tenant_record_id' , true )" )); - assert!(!super::declares_append_only_immutability("CREATE TABLE x")); - assert_eq!( - super::validate_append_only_immutability( - "CREATE TRIGGER source_artifact_reject_mutation BEFORE UPDATE ON source_artifact \ - FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation();" - ), - Err(MigrationContractError::MissingAppendOnlyTrigger) - ); } #[test] - fn temporal_interval_ordering_contract_fails_closed() { - assert!(super::declares_temporal_interval_ordering( - "CONSTRAINT document_record_valid_order CHECK (true)" + fn tenant_session_witness_must_be_relationally_bound() { + assert!(policy_binds_tenant_session_equality( + "tenant_record_id::text = nullif ( current_setting ( 'tepp.current_tenant_record_id' , true ) , )" + )); + assert!(policy_binds_tenant_session_equality( + "current_setting ( 'tepp.current_tenant_record_id' , true ) = tenant_record_id::text" + )); + assert!(!policy_binds_tenant_session_equality( + "tenant_record_id::text = coalesce ( current_setting ( 'tepp.current_tenant_record_id' , true ) , tenant_record_id::text )" )); - assert!(!super::declares_temporal_interval_ordering( - "CREATE TABLE x" + assert!(!policy_binds_tenant_session_equality( + "tenant_record_id is not null and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null" )); + assert!(!policy_binds_tenant_session_equality( + "tenant_record_id::text = document_record_id::text and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null" + )); + } + + #[test] + fn every_top_level_or_path_requires_tenant_equality() { + let binding = "tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )"; + assert!(!all_top_level_or_paths_bind_tenant_session(&format!( + "({binding} or true)" + ))); + assert!(all_top_level_or_paths_bind_tenant_session(&format!( + "(({binding} and document_record_id is not null) or ({binding} and document_record_id is null))" + ))); + assert!(all_top_level_or_paths_bind_tenant_session(&format!( + "({binding} and (document_record_id is null or document_record_id is not null))" + ))); + assert!(!all_top_level_or_paths_bind_tenant_session(&format!( + "coalesce({binding} or true, false)" + ))); + } + #[test] + fn policy_command_defaults_to_all_and_rejects_unknown_commands() { assert_eq!( - super::validate_temporal_interval_ordering( - "CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to)" - ), - Err(MigrationContractError::MissingTemporalIntervalConstraint) + policy_command("create policy tenant_policy on tenant_record using(true)"), + Some(PolicyCommand::All) ); - - // Named constraints present; fail each predicate branch independently. - let names = r" - CONSTRAINT document_record_valid_order CHECK (true) - CONSTRAINT document_record_system_order CHECK (true) - CONSTRAINT document_record_revision_positive CHECK (true) - CONSTRAINT event_instance_valid_order CHECK (true) - CONSTRAINT event_instance_system_order CHECK (true) - CONSTRAINT membership_assignment_valid_order CHECK (true) - "; assert_eq!( - super::validate_temporal_interval_ordering(names), - Err(MigrationContractError::MissingTemporalIntervalConstraint) + policy_command("create policy tenant_policy on tenant_record for all using(true)"), + Some(PolicyCommand::All) ); - let missing_system_order = format!( - "{names}\nCHECK (valid_to IS NULL OR valid_from <= valid_to)\n\ - CHECK (revision_number > 0)" + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for select using(true)"), + Some(PolicyCommand::Select) ); assert_eq!( - super::validate_temporal_interval_ordering(&missing_system_order), - Err(MigrationContractError::MissingTemporalIntervalConstraint) + policy_command("create policy tenant_policy on tenant_record for insert with check(true)"), + Some(PolicyCommand::Insert) ); - let missing_revision = format!( - "{names}\nCHECK (valid_to IS NULL OR valid_from <= valid_to)\n\ - CHECK (system_to IS NULL OR system_from <= system_to)" + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for update using(true)"), + Some(PolicyCommand::Update) ); assert_eq!( - super::validate_temporal_interval_ordering(&missing_revision), - Err(MigrationContractError::MissingTemporalIntervalConstraint) + policy_command("create policy tenant_policy on tenant_record for delete using(true)"), + Some(PolicyCommand::Delete) ); - - // Predicates present but last named constraint missing. - let missing_membership = r" - CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) - CONSTRAINT document_record_system_order CHECK (system_to IS NULL OR system_from <= system_to) - CONSTRAINT document_record_revision_positive CHECK (revision_number > 0) - CONSTRAINT event_instance_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) - CONSTRAINT event_instance_system_order CHECK (system_to IS NULL OR system_from <= system_to) - "; assert_eq!( - super::validate_temporal_interval_ordering(missing_membership), - Err(MigrationContractError::MissingTemporalIntervalConstraint) + policy_command("create policy tenant_policy on tenant_record for merge using(true)"), + None ); - - let complete = r" - CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) - CONSTRAINT document_record_system_order CHECK (system_to IS NULL OR system_from <= system_to) - CONSTRAINT document_record_revision_positive CHECK (revision_number > 0) - CONSTRAINT event_instance_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) - CONSTRAINT event_instance_system_order CHECK (system_to IS NULL OR system_from <= system_to) - CONSTRAINT membership_assignment_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) - "; - assert_eq!(super::validate_temporal_interval_ordering(complete), Ok(())); } #[test] - fn retention_legal_hold_contract_fails_closed() { - assert!(super::declares_retention_legal_hold( - "CREATE TABLE retention_policy (retention_policy_id uuid PRIMARY KEY)" - )); - assert!(super::declares_retention_legal_hold( - "CREATE TABLE legal_hold ()" - )); - assert!(super::declares_retention_legal_hold( - "CREATE TABLE evidence_tombstone ()" - )); - assert!(!super::declares_retention_legal_hold("CREATE TABLE x")); - - let missing_table = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - CREATE TABLE retention_policy ( - retention_policy_id uuid PRIMARY KEY, - tenant_record_id uuid NOT NULL, - system_time timestamptz NOT NULL, - available_time timestamptz NOT NULL - ); - ", - "DROP TABLE tenant_record;", - ); + fn policy_roles_default_to_public_and_preserve_explicit_targets() { assert_eq!( - validate_migration_catalog(&missing_table), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles("create policy tenant_policy on tenant_record using(true)"), + Some(vec!["public".to_owned()]) ); - - let tables_only = r" - CREATE TABLE retention_policy (x int); - CREATE TABLE legal_hold (x int); - CREATE TABLE deletion_request (x int); - CREATE TABLE evidence_tombstone (x int); - "; assert_eq!( - super::validate_retention_legal_hold(tables_only), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles( + "create policy tenant_policy on tenant_record for select to Reader_Role, writer_role using(true)" + ), + Some(vec!["reader_role".to_owned(), "writer_role".to_owned()]) ); - let with_hold_fn = - format!("{tables_only} CREATE OR REPLACE FUNCTION reject_held_evidence_deletion()"); assert_eq!( - super::validate_retention_legal_hold(&with_hold_fn), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles("create policy tenant_policy on tenant_record for select to using(true)"), + None ); - let with_restore_fn = format!( - "{with_hold_fn} CREATE OR REPLACE FUNCTION reject_tombstoned_evidence_restore()" + assert_eq!( + policy_roles( + "create policy tenant_policy on tenant_record for select to reader-role using(true)" + ), + None ); assert_eq!( - super::validate_retention_legal_hold(&with_restore_fn), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles("create policy tenant_policy on tenant_record for select to ,reader_role using(true)"), + None ); - let with_hold_trigger = - format!("{with_restore_fn} CREATE TRIGGER deletion_request_reject_held_deletion"); assert_eq!( - super::validate_retention_legal_hold(&with_hold_trigger), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles("create policy tenant_policy on tenant_record for select to reader_role, using(true)"), + None ); - let with_restore_trigger = - format!("{with_hold_trigger} CREATE TRIGGER document_record_reject_tombstone_restore"); assert_eq!( - super::validate_retention_legal_hold(&with_restore_trigger), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles( + "create policy tenant_policy on tenant_record for select to reader_role,,writer_role using(true)" + ), + None ); - let with_period = - format!("{with_restore_trigger} CONSTRAINT retention_policy_period_positive"); assert_eq!( - super::validate_retention_legal_hold(&with_period), - Err(MigrationContractError::MissingRetentionLegalHold) + policy_roles( + "create policy tenant_policy on tenant_record for select to reader_role writer_role using(true)" + ), + None ); - let complete = format!("{with_period} CONSTRAINT legal_hold_document_scope_consistent"); - super::validate_retention_legal_hold(&complete).expect("complete 0007 contract"); } #[test] - fn empty_and_malformed_sql_fail_closed() { - let empty = MigrationCatalog::from_sql(" ", "DROP TABLE x;"); - assert_eq!( - validate_migration_catalog(&empty), - Err(MigrationContractError::EmptyMigrationSql) - ); - assert_eq!( - MigrationCatalog::from_sources("", "DROP TABLE x_y;"), - Err(MigrationContractError::EmptyMigrationSql) - ); + fn explicit_policy_command_requires_the_correct_tenant_predicate() { + let binding = "tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )"; + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for all using({binding})" + ))); + assert!(!policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for all with check({binding})" + ))); + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for select using({binding})" + ))); + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for delete using({binding})" + ))); + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for insert with check({binding})" + ))); + assert!(!policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for insert using({binding}) with check({binding})" + ))); + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for update using({binding})" + ))); + assert!(!policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for update with check({binding})" + ))); + assert!(!policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for all using({binding})with check(tenant_record_id is not null)" + ))); + assert!(policy_row_predicates_bind_tenant_session(&format!( + "create policy tenant_policy on tenant_record for all using({binding})with check({binding})" + ))); + } + + #[test] + fn tenant_guc_is_required_for_permissive_but_not_restrictive_policies() { + assert!(tenant_policies_bind_session_guc( + "create policy document_record_tenant_isolation on document_record using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ));" + )); + assert!(!tenant_policies_bind_session_guc( + "select current_setting ( 'tepp.current_tenant_record_id' , true ); create policy document_record_tenant_isolation on document_record using(tenant_record_id is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_tenant_isolation on document_record using(document_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); select tenant_record_id from document_record;" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_tenant_isolation on document_record using(tenant_record_id is not null and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_tenant_isolation on document_record for all using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )) with check(tenant_record_id is not null);" + )); + assert!(tenant_policies_bind_session_guc( + "create policy document_record_tenant_isolation on document_record using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_visibility_guard on document_record as restrictive for select using(document_record_id is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_visibility_guard on document_record as restrictive for select using(document_record_id is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_insert_isolation on document_record as permissive for insert with check(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_visibility_guard on document_record as restrictive for select using(document_record_id is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_writer_isolation on document_record as permissive for select to writer_role using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_reader_guard on document_record as restrictive for select to reader_role using(document_record_id is not null);" + )); + assert!(tenant_policies_bind_session_guc( + "create policy document_record_reader_isolation on document_record as permissive for select to reader_role using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_reader_guard on document_record as restrictive for select to reader_role using(document_record_id is not null);" + )); + assert!(tenant_policies_bind_session_guc( + "create policy document_record_public_isolation on document_record as permissive for select using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_reader_guard on document_record as restrictive for select to reader_role using(document_record_id is not null);" + )); + assert!(!tenant_policies_bind_session_guc( + "create policy document_record_reader_isolation on document_record as permissive for select to reader_role using(tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )); create policy document_record_public_guard on document_record as restrictive for select using(document_record_id is not null);" + )); + assert!(policy_is_restrictive( + "create policy document_record_visibility_guard on document_record AS RESTRICTIVE for select using(true)" + )); + assert!(!policy_is_restrictive( + "create policy document_record_visibility_guard on document_record AS PERMISSIVE for select using(true)" + )); + assert!(!policy_is_restrictive( + "create policy document_record_visibility_guard on document_record for select using(exists (select 1 AS restrictive))" + )); + } + + #[test] + fn concurrent_index_modifier_is_removed_without_changing_the_declared_name() { assert_eq!( - MigrationCatalog::from_sources( - "CREATE TABLE tenant_record (tenant_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", - "", + canonicalize_concurrent_index_modifier( + "CREATE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record" ), - Err(MigrationContractError::EmptyMigrationSql) - ); - let empty_down = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - ", - " ", - ); - assert_eq!( - validate_migration_catalog(&empty_down), - Err(MigrationContractError::EmptyMigrationSql) - ); - let no_tables = MigrationCatalog::from_sql( - "-- comment only without table definitions", - "DROP TABLE IF EXISTS none_present;", + "CREATE INDEX tenant_record_lookup_index ON tenant_record" ); assert_eq!( - validate_migration_catalog(&no_tables), - Err(MigrationContractError::EmptyMigrationSql) - ); - let if_not_exists = MigrationCatalog::from_sql( - r" - CREATE TABLE IF NOT EXISTS tenant_record ( - tenant_record_id uuid PRIMARY KEY, - system_time timestamptz NOT NULL - ); - ", - "DROP TABLE tenant_record;", - ); - validate_migration_catalog(&if_not_exists).expect("if not exists parse"); - let unclosed = MigrationCatalog::from_sql( - "CREATE TABLE broken_table (tenant_record_id uuid, system_time timestamptz", - "DROP TABLE broken_table;", - ); - assert_eq!( - validate_migration_catalog(&unclosed), - Err(MigrationContractError::EmptyMigrationSql) - ); - let nested = MigrationCatalog::from_sql( - r" - CREATE TABLE document_record ( - document_record_id uuid PRIMARY KEY, - tenant_record_id uuid NOT NULL, - system_time timestamptz NOT NULL, - available_time timestamptz NOT NULL, - CONSTRAINT document_record_positive CHECK (revision_number > 0) - ); - ", - "DROP TABLE document_record;", + canonicalize_concurrent_index_modifier( + "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS tenant_record_lookup_index ON tenant_record" + ), + "CREATE UNIQUE INDEX IF NOT EXISTS tenant_record_lookup_index ON tenant_record" ); - validate_migration_catalog(&nested).expect("nested parentheses"); - let trailing = MigrationCatalog::from_sql("CREATE TABLE ", "DROP TABLE none_present;"); assert_eq!( - validate_migration_catalog(&trailing), - Err(MigrationContractError::EmptyMigrationSql) + canonicalize_concurrent_index_modifier( + "CREATE TABLE tenant_record (tenant_record_id uuid);CREATE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record" + ), + "CREATE TABLE tenant_record (tenant_record_id uuid) ; CREATE INDEX tenant_record_lookup_index ON tenant_record" ); } } diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs new file mode 100644 index 000000000..905860ee4 --- /dev/null +++ b/crates/persistence_postgres/src/migration_core.rs @@ -0,0 +1,920 @@ +//! Embedded migration catalog boundary and PostgreSQL table-declaration canonicalization. +//! +//! The lexical facade normalizes comments and quoted regions before this module +//! runs. PostgreSQL permits persistence modifiers between `CREATE` and `TABLE`; +//! those modifiers are validation syntax, not part of the durable table name. +//! This boundary canonicalizes only those bounded declaration prefixes, then +//! delegates all naming, tenant, temporal, RLS, and table-body invariants to the +//! existing migration-core implementation. + +#[path = "migration_core_impl.rs"] +mod implementation; +#[path = "migration_rls_table_state.rs"] +mod rls_table_state; +#[path = "migration_runtime_role_executor.rs"] +mod runtime_role_executor; +#[path = "migration_runtime_role_grantor.rs"] +mod runtime_role_grantor; +#[path = "migration_runtime_role_membership.rs"] +mod runtime_role_membership; +#[path = "migration_transaction_projection.rs"] +mod transaction_projection; + +use crate::MigrationContractError; +use crate::naming::is_multi_word_snake_case; +pub use implementation::MigrationCatalog; + +/// Project already-normalized SQL onto the statements that survive PostgreSQL transaction outcome. +/// +/// This is the shared transaction authority for the facade lifecycle/RLS checks +/// and the structural core. Returning `None` means final durable state cannot be +/// proven locally because the input contains savepoint/two-phase ambiguity or an +/// unterminated explicit transaction. +pub(super) fn project_committed_sql(sql: &str) -> Option { + transaction_projection::project_committed_statements(sql) +} + +/// Detect a committed policy-definition mutation not yet owned by the final-policy state model. +/// +/// PostgreSQL `ALTER POLICY` can independently replace the role list, `USING`, +/// and `WITH CHECK` clauses while omitted clauses retain prior state. `DROP POLICY` +/// removes the policy definition entirely. Until this bounded validator owns the +/// policy identity/state fold, accepting historical `CREATE POLICY` evidence after +/// either mutation would be fail-open. The input is already lexically normalized +/// and transaction-projected, so statement-first token matching is sufficient and +/// comments/literals cannot manufacture these markers. +fn contains_unsupported_policy_mutation(sql: &str) -> bool { + sql.split(';').any(|statement| { + let mut tokens = statement.split_whitespace(); + let Some(verb) = tokens.next() else { + return false; + }; + matches!(verb.to_ascii_uppercase().as_str(), "ALTER" | "DROP") + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) + }) +} + +/// Return the next whitespace-delimited token span beginning at or after `from`. +/// +/// Input has already crossed the shared PostgreSQL lexical authority. This +/// cursor exists only to retain the exact byte boundary after an ALTER TABLE +/// target; it does not interpret comments, quoted bodies, or identifiers again. +fn next_sql_token_span(sql: &str, from: usize) -> Option<(usize, usize)> { + let tail = sql.get(from..)?; + let mut token_start = None; + + for (offset, ch) in tail.char_indices() { + if token_start.is_none() { + if !ch.is_whitespace() { + token_start = Some(from + offset); + } + continue; + } + if ch.is_whitespace() { + if let Some(start) = token_start { + return Some((start, from + offset)); + } + } + } + + token_start.map(|start| (start, sql.len())) +} + +/// Compare one normalized token span with an ASCII PostgreSQL keyword. +fn token_span_eq(sql: &str, span: (usize, usize), keyword: &str) -> bool { + sql.get(span.0..span.1) + .is_some_and(|token| token.eq_ignore_ascii_case(keyword)) +} + +/// Return whether one normalized SQL span denotes `routine_name` in the canonical schema. +/// +/// PostgreSQL permits whitespace around the period in a schema-qualified name +/// and between a routine name and its argument list. The shared lexical authority +/// has already removed comments and opaque bodies, so this bounded identity check +/// only joins whitespace-separated name punctuation until the signature or DROP +/// behavior keyword. TEPP's embedded guards are created in `public`; therefore +/// the unqualified form and an explicit `public.` qualification identify the +/// protected routine, while the same local name in another schema is unrelated. +/// This does not reparse executable SQL. +fn sql_span_names_guard_routine( + sql: &str, + span: (usize, usize), + routine_name: &str, +) -> bool { + let Some(fragment) = sql.get(span.0..span.1) else { + return false; + }; + let mut name = String::new(); + for token in fragment.split_whitespace() { + if token.eq_ignore_ascii_case("CASCADE") || token.eq_ignore_ascii_case("RESTRICT") { + break; + } + if let Some((before_signature, _)) = token.split_once('(') { + name.push_str(before_signature); + break; + } + name.push_str(token); + } + + let mut parts = name.split('.'); + let first = parts.next(); + let second = parts.next(); + let third = parts.next(); + match (first, second, third) { + (Some(local), None, None) => local.eq_ignore_ascii_case(routine_name), + (Some(schema), Some(local), None) => { + schema.eq_ignore_ascii_case("public") && local.eq_ignore_ascii_case(routine_name) + } + _ => false, + } +} + +/// Return whether one committed DROP FUNCTION / DROP ROUTINE statement targets `routine_name`. +/// +/// PostgreSQL permits multiple routine targets separated by top-level commas; +/// commas inside routine signatures are not target boundaries. Malformed +/// parenthesis structure fails closed because this bounded authority cannot prove +/// that the protected routine is absent from an ambiguous DROP statement. +fn statement_drops_guard_routine(statement: &str, routine_name: &str) -> bool { + let Some(drop_keyword) = next_sql_token_span(statement, 0) else { + return false; + }; + let Some(routine_kind) = next_sql_token_span(statement, drop_keyword.1) else { + return false; + }; + if !token_span_eq(statement, drop_keyword, "DROP") + || !(token_span_eq(statement, routine_kind, "FUNCTION") + || token_span_eq(statement, routine_kind, "ROUTINE")) + { + return false; + } + + let mut cursor = routine_kind.1; + let Some(mut first_target) = next_sql_token_span(statement, cursor) else { + return true; + }; + if token_span_eq(statement, first_target, "IF") { + let Some(exists_keyword) = next_sql_token_span(statement, first_target.1) else { + return true; + }; + if !token_span_eq(statement, exists_keyword, "EXISTS") { + return true; + } + cursor = exists_keyword.1; + first_target = match next_sql_token_span(statement, cursor) { + Some(target) => target, + None => return true, + }; + } + cursor = first_target.0; + + let Some(targets) = statement.get(cursor..) else { + return true; + }; + let mut parenthesis_depth = 0usize; + let mut target_start = 0usize; + for (index, ch) in targets.char_indices() { + match ch { + '(' => parenthesis_depth += 1, + ')' => { + if parenthesis_depth == 0 { + return true; + } + parenthesis_depth -= 1; + } + ',' if parenthesis_depth == 0 => { + let target = &targets[target_start..index]; + if sql_span_names_guard_routine(target, (0, target.len()), routine_name) { + return true; + } + target_start = index + ch.len_utf8(); + } + _ => {} + } + } + if parenthesis_depth != 0 { + return true; + } + let final_target = &targets[target_start..]; + sql_span_names_guard_routine(final_target, (0, final_target.len()), routine_name) +} + +/// Return whether one committed statement defines `routine_name` with CREATE OR REPLACE FUNCTION. +fn statement_defines_guard_routine(statement: &str, routine_name: &str) -> bool { + let Some(create_keyword) = next_sql_token_span(statement, 0) else { + return false; + }; + let Some(or_keyword) = next_sql_token_span(statement, create_keyword.1) else { + return false; + }; + let Some(replace_keyword) = next_sql_token_span(statement, or_keyword.1) else { + return false; + }; + let Some(function_keyword) = next_sql_token_span(statement, replace_keyword.1) else { + return false; + }; + + token_span_eq(statement, create_keyword, "CREATE") + && token_span_eq(statement, or_keyword, "OR") + && token_span_eq(statement, replace_keyword, "REPLACE") + && token_span_eq(statement, function_keyword, "FUNCTION") + && sql_span_names_guard_routine( + statement, + (function_keyword.1, statement.len()), + routine_name, + ) +} + +/// Detect committed mutations that make historical guard-routine evidence stale. +/// +/// PostgreSQL `DROP FUNCTION` / `DROP ROUTINE ... CASCADE` can remove dependent +/// triggers, while a later `CREATE OR REPLACE FUNCTION` can replace a routine +/// body without changing the function identity referenced by those triggers. +/// Until TEPP owns final routine-body and dependency state, the first canonical +/// definition is accepted but a later replacement or committed removal fails +/// closed. Input is already lexically normalized and transaction-projected, so +/// rolled-back mutations and marker text in comments/literals/dollar bodies are +/// absent here. +fn contains_unsupported_guard_routine_mutation(sql: &str, routine_name: &str) -> bool { + let mut seen_guard_definition = false; + for statement in sql.split(';') { + if statement_drops_guard_routine(statement, routine_name) { + return true; + } + if statement_defines_guard_routine(statement, routine_name) { + if seen_guard_definition { + return true; + } + seen_guard_definition = true; + } + } + false +} + +/// Return whether one ALTER TABLE action begins with destructive `DROP`. +fn alter_table_action_starts_with_drop(action: &str) -> bool { + next_sql_token_span(action, 0).is_some_and(|span| token_span_eq(action, span, "DROP")) +} + +/// Return whether one ALTER TABLE action weakens ordinary trigger enforcement. +/// +/// PostgreSQL keeps disabled triggers in catalog state but does not execute them. +/// `ENABLE REPLICA TRIGGER` is likewise insufficient for TEPP's ordinary +/// application path because it fires only when `session_replication_role` is +/// `replica`, not under the normal origin/local modes. Ordinary `ENABLE TRIGGER` +/// and `ENABLE ALWAYS TRIGGER` remain admissible. +fn alter_table_action_weakens_trigger_enforcement(action: &str) -> bool { + let Some(first) = next_sql_token_span(action, 0) else { + return false; + }; + let Some(second) = next_sql_token_span(action, first.1) else { + return false; + }; + + if token_span_eq(action, first, "DISABLE") && token_span_eq(action, second, "TRIGGER") { + return true; + } + if !token_span_eq(action, first, "ENABLE") || !token_span_eq(action, second, "REPLICA") { + return false; + } + next_sql_token_span(action, second.1) + .is_some_and(|third| token_span_eq(action, third, "TRIGGER")) +} + +/// Detect unsupported final-state actions in PostgreSQL's comma-separated ALTER TABLE action list. +/// +/// Action commas are recognized only outside expression parentheses and +/// array/subscript brackets. That keeps commas inside CHECK/function expressions +/// or `ARRAY[...]` from manufacturing action boundaries. Destructive `DROP` and +/// trigger modes that disable normal application-path enforcement fail closed. +/// Unbalanced delimiters also fail closed because malformed structure cannot +/// prove the absence of a later unsupported action. +fn alter_table_actions_include_unsupported_final_state_mutation(actions: &str) -> bool { + let mut parenthesis_depth = 0usize; + let mut bracket_depth = 0usize; + let mut action_start = 0usize; + + for (index, ch) in actions.char_indices() { + match ch { + '(' => parenthesis_depth += 1, + ')' => { + if parenthesis_depth == 0 { + return true; + } + parenthesis_depth -= 1; + } + '[' => bracket_depth += 1, + ']' => { + if bracket_depth == 0 { + return true; + } + bracket_depth -= 1; + } + ',' if parenthesis_depth == 0 && bracket_depth == 0 => { + let action = &actions[action_start..index]; + if alter_table_action_starts_with_drop(action) + || alter_table_action_weakens_trigger_enforcement(action) + { + return true; + } + action_start = index + ch.len_utf8(); + } + _ => {} + } + } + + if parenthesis_depth != 0 || bracket_depth != 0 { + return true; + } + let final_action = &actions[action_start..]; + alter_table_action_starts_with_drop(final_action) + || alter_table_action_weakens_trigger_enforcement(final_action) +} + +/// Return the committed ALTER TABLE action list after its target relation. +/// +/// `None` means the statement is not an ALTER TABLE statement. `Err(())` means +/// an ALTER TABLE prefix was present but its target grammar was incomplete, so +/// callers that certify final-state safety must fail closed rather than treating +/// malformed SQL as an unrelated statement. +fn alter_table_action_list(statement: &str) -> Result, ()> { + let Some(first) = next_sql_token_span(statement, 0) else { + return Ok(None); + }; + let Some(second) = next_sql_token_span(statement, first.1) else { + return Ok(None); + }; + if !token_span_eq(statement, first, "ALTER") || !token_span_eq(statement, second, "TABLE") { + return Ok(None); + } + + let mut cursor = second.1; + let Some(mut target) = next_sql_token_span(statement, cursor) else { + return Err(()); + }; + if token_span_eq(statement, target, "IF") { + let Some(exists) = next_sql_token_span(statement, target.1) else { + return Err(()); + }; + if !token_span_eq(statement, exists, "EXISTS") { + return Err(()); + } + target = next_sql_token_span(statement, exists.1).ok_or(())?; + } + if token_span_eq(statement, target, "ONLY") { + target = next_sql_token_span(statement, target.1).ok_or(())?; + } + + cursor = target.1; + if let Some(star) = next_sql_token_span(statement, cursor) { + if statement.get(star.0..star.1) == Some("*") { + cursor = star.1; + } + } + + let actions = statement.get(cursor..).ok_or(())?; + if next_sql_token_span(actions, 0).is_none() { + return Err(()); + } + Ok(Some(actions)) +} + +/// Return whether one ALTER TABLE ADD action introduces a nonconforming column name. +/// +/// PostgreSQL permits both `ADD [COLUMN] name ...` and table-constraint forms +/// such as `ADD CONSTRAINT`, `ADD CHECK`, and `ADD FOREIGN KEY`. Only the column +/// form is subject to the durable column-name contract here. The action has +/// already crossed the shared lexical authority, so quoted-identifier handling +/// remains owned by that authority rather than being reparsed locally. +fn alter_table_add_action_has_invalid_column_name(action: &str) -> bool { + let Some(add) = next_sql_token_span(action, 0) else { + return false; + }; + if !token_span_eq(action, add, "ADD") { + return false; + } + + let Some(mut candidate) = next_sql_token_span(action, add.1) else { + return true; + }; + let explicit_column = token_span_eq(action, candidate, "COLUMN"); + if explicit_column { + candidate = match next_sql_token_span(action, candidate.1) { + Some(span) => span, + None => return true, + }; + } + + if token_span_eq(action, candidate, "IF") { + let Some(not) = next_sql_token_span(action, candidate.1) else { + return true; + }; + let Some(exists) = next_sql_token_span(action, not.1) else { + return true; + }; + if !token_span_eq(action, not, "NOT") || !token_span_eq(action, exists, "EXISTS") { + return true; + } + candidate = match next_sql_token_span(action, exists.1) { + Some(span) => span, + None => return true, + }; + } else if !explicit_column + && ["CONSTRAINT", "CHECK", "NOT", "UNIQUE", "PRIMARY", "EXCLUDE", "FOREIGN"] + .iter() + .any(|keyword| token_span_eq(action, candidate, keyword)) + { + return false; + } + + action + .get(candidate.0..candidate.1) + .is_none_or(|name| !is_multi_word_snake_case(name)) +} + +/// Detect invalid ADD-column names in PostgreSQL's top-level ALTER TABLE action list. +/// +/// The delimiter rules mirror the destructive-action scan: commas nested in +/// expressions or array/subscript brackets stay inside one action. Structural +/// imbalance is already rejected by the final-state mutation boundary before +/// this naming check runs. +fn alter_table_actions_include_invalid_added_column_name(actions: &str) -> bool { + let mut parenthesis_depth = 0usize; + let mut bracket_depth = 0usize; + let mut action_start = 0usize; + + for (index, ch) in actions.char_indices() { + match ch { + '(' => parenthesis_depth += 1, + ')' => parenthesis_depth = parenthesis_depth.saturating_sub(1), + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + ',' if parenthesis_depth == 0 && bracket_depth == 0 => { + if alter_table_add_action_has_invalid_column_name(&actions[action_start..index]) { + return true; + } + action_start = index + ch.len_utf8(); + } + _ => {} + } + } + + alter_table_add_action_has_invalid_column_name(&actions[action_start..]) +} + +/// Detect committed ALTER TABLE additions that bypass the durable column-name contract. +fn contains_invalid_alter_table_added_column_name(sql: &str) -> bool { + sql.split(';').any(|statement| { + alter_table_action_list(statement) + .ok() + .flatten() + .is_some_and(alter_table_actions_include_invalid_added_column_name) + }) +} + +/// Return whether one committed statement mutates table final state beyond the bounded model. +/// +/// `DROP TABLE` removes the durable relation and `DROP TRIGGER` removes durable +/// trigger enforcement. Standalone PostgreSQL `RENAME` forms make historical +/// table/column identities stale. The ordinary `ALTER TABLE ... action [, ...]` +/// form can also contain destructive `DROP` actions such as `DROP COLUMN` or +/// `DROP CONSTRAINT`, plus trigger firing-state changes that can disable ordinary +/// application-path enforcement. Every top-level action is inspected so a safe +/// first action cannot hide a later unsupported mutation. +/// Target parsing is positional, which keeps a table literally named `rename` +/// or `drop` from being confused with an action after lexical normalization. +fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool { + let Some(first) = next_sql_token_span(statement, 0) else { + return false; + }; + let Some(second) = next_sql_token_span(statement, first.1) else { + return false; + }; + + if token_span_eq(statement, first, "DROP") + && (token_span_eq(statement, second, "TABLE") + || token_span_eq(statement, second, "TRIGGER")) + { + return true; + } + + let actions = match alter_table_action_list(statement) { + Ok(Some(actions)) => actions, + Ok(None) => return false, + Err(()) => return true, + }; + let Some(first_action) = next_sql_token_span(actions, 0) else { + return true; + }; + if token_span_eq(actions, first_action, "RENAME") { + return true; + } + alter_table_actions_include_unsupported_final_state_mutation(actions) +} + +/// Detect committed table removals or identity/destructive mutations not yet owned by final-table state. +/// +/// The input has already crossed lexical normalization and transaction outcome, +/// so rolled-back mutations never reach this boundary. Until a first-class +/// table aggregate owns create/drop/rename/recreate plus column/constraint state, +/// accepting historical `CREATE TABLE` evidence after these mutations would be +/// fail-open and is therefore rejected explicitly. +fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { + sql.split(';') + .any(statement_has_unsupported_table_final_state_mutation) +} + +/// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. +/// +/// `UNLOGGED`, `TEMP`/`TEMPORARY`, and PostgreSQL's compatibility +/// `GLOBAL`/`LOCAL TEMP[TEMPORARY]` spellings must traverse the same table-name +/// and table-body contracts as ordinary `CREATE TABLE`. The canonicalized copy +/// exists only for validation; executable migration SQL is never rewritten. +/// Runtime-role membership safety consumes only statements whose effects survive +/// explicit transaction outcome. Executor-relative grantor identity is projected +/// before that transaction filter so in-transaction `SET LOCAL ROLE` and session +/// authorization still identify the grantor that PostgreSQL recorded, while a +/// later rollback cannot donate false membership or revocation evidence. The +/// structural validator and facade-level RLS witnesses receive the same committed +/// final-state projection, so rolled-back DDL or policy composition cannot +/// satisfy naming, tenant, temporal, RLS, or governance contracts. RLS table +/// enablement is additionally folded in statement order so a committed trailing +/// `DISABLE` or `NO FORCE` cannot reuse stale positive evidence from earlier SQL. +/// Committed `ALTER POLICY` and `DROP POLICY` are temporarily rejected until +/// policy identity, clause replacement, and removal have their own final-state +/// authority. Committed `DROP TABLE` / `DROP TRIGGER`, standalone table/column +/// rename forms, destructive ALTER TABLE DROP actions, trigger modes that disable +/// normal application-path enforcement, protected guard-routine removal, and a +/// second committed `CREATE OR REPLACE FUNCTION` for an append-only or retention +/// enforcement guard are likewise rejected until table, trigger, routine, column, +/// constraint, removal/recreation, dependent-object, and routine-body effects are +/// represented by first-class final-state aggregates. Committed ADD-column actions +/// remain supported only when each introduced durable column satisfies the same +/// multi-word `snake_case` authority as CREATE TABLE columns. Mutations removed +/// by the transaction projection never reach either bounded boundary. +/// +/// # Errors +/// +/// Returns the same naming, tenant, temporal, RLS, or structural contract +/// errors as the underlying migration validator, plus `MissingAppRuntimeRole` +/// when the application runtime has an unsafe or unprovable PostgreSQL +/// membership path or transaction outcome. +pub fn validate_migration_catalog( + catalog: &MigrationCatalog, +) -> Result<(), MigrationContractError> { + let Some(grantor_sql) = + runtime_role_executor::project_executor_relative_grantors(catalog.up_sql()) + else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; + let Some(committed_up) = project_committed_sql(catalog.up_sql()) else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; + let Some(committed_down) = project_committed_sql(catalog.down_sql()) else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; + let Some(committed_grantor_sql) = project_committed_sql(&grantor_sql) else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; + + if contains_unsupported_guard_routine_mutation( + &committed_up, + "reject_append_only_mutation", + ) || contains_unsupported_table_final_state_mutation(&committed_up) + { + return Err(MigrationContractError::UnsupportedTableFinalStateMutation); + } + if [ + "reject_held_evidence_deletion", + "reject_tombstoned_evidence_restore", + ] + .iter() + .any(|routine_name| contains_unsupported_guard_routine_mutation(&committed_up, routine_name)) + { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if contains_invalid_alter_table_added_column_name(&committed_up) { + return Err(MigrationContractError::SingleWordObjectName); + } + if contains_unsupported_policy_mutation(&committed_up) { + return Err(MigrationContractError::MissingRlsPolicy); + } + let committed_requires_runtime_role = + super::validation::declares_row_level_security(&committed_up); + if committed_requires_runtime_role + && !rls_table_state::final_rls_table_states_are_safe(&committed_up) + { + return Err(MigrationContractError::MissingRlsEnable); + } + if committed_requires_runtime_role && !super::declares_tenant_session_guc(&committed_up) { + return Err(MigrationContractError::MissingTenantSessionGuc); + } + if committed_requires_runtime_role && !super::tenant_policies_bind_session_guc(&committed_up) { + return Err(MigrationContractError::MissingRlsPolicy); + } + + if !runtime_role_membership::runtime_membership_is_rls_safe(&committed_up) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe( + &committed_grantor_sql, + ) + { + return Err(MigrationContractError::MissingAppRuntimeRole); + } + + let canonical_up = canonicalize_table_persistence_modifiers(&committed_up); + let canonical_catalog = MigrationCatalog::from_sql(&canonical_up, &committed_down); + implementation::validate_migration_catalog(&canonical_catalog) +} + +/// Return whether `ch` can continue a PostgreSQL unquoted identifier. +/// +/// This mirrors the migration-core token boundary so `CREATE` embedded in an +/// identifier cannot start a synthetic declaration while scanning modifiers. +fn is_postgresql_identifier_continuation(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() +} + +/// Match one ASCII keyword at `start` with PostgreSQL identifier boundaries. +fn bounded_keyword_end(sql: &str, start: usize, keyword: &str) -> Option { + let end = start.checked_add(keyword.len())?; + let candidate = sql.get(start..end)?; + if !candidate.eq_ignore_ascii_case(keyword) { + return None; + } + if sql[..start] + .chars() + .next_back() + .is_some_and(is_postgresql_identifier_continuation) + { + return None; + } + if sql[end..] + .chars() + .next() + .is_some_and(is_postgresql_identifier_continuation) + { + return None; + } + Some(end) +} + +/// Consume at least one SQL whitespace character before matching `keyword`. +fn keyword_after_required_whitespace(sql: &str, from: usize, keyword: &str) -> Option { + let rest = sql.get(from..)?; + let mut consumed = 0usize; + for ch in rest.chars() { + if !ch.is_whitespace() { + break; + } + consumed += ch.len_utf8(); + } + if consumed == 0 { + return None; + } + bounded_keyword_end(sql, from + consumed, keyword) +} + +/// Return the byte immediately after `TABLE` for one supported modifier-bearing declaration. +/// +/// PostgreSQL 18 accepts `UNLOGGED`, `TEMP`/`TEMPORARY`, and compatibility +/// `GLOBAL`/`LOCAL TEMP[TEMPORARY]` prefixes. Ordinary `CREATE TABLE` is left +/// untouched so this helper cannot broaden the legacy parser's authority. +fn modifier_table_declaration_end(sql: &str, create_start: usize) -> Option { + let create_end = bounded_keyword_end(sql, create_start, "CREATE")?; + + let first_start = { + let rest = sql.get(create_end..)?; + let mut consumed = 0usize; + for ch in rest.chars() { + if !ch.is_whitespace() { + break; + } + consumed += ch.len_utf8(); + } + if consumed == 0 { + return None; + } + create_end + consumed + }; + + if let Some(unlogged_end) = bounded_keyword_end(sql, first_start, "UNLOGGED") { + return keyword_after_required_whitespace(sql, unlogged_end, "TABLE"); + } + if let Some(temp_end) = bounded_keyword_end(sql, first_start, "TEMP") { + return keyword_after_required_whitespace(sql, temp_end, "TABLE"); + } + if let Some(temporary_end) = bounded_keyword_end(sql, first_start, "TEMPORARY") { + return keyword_after_required_whitespace(sql, temporary_end, "TABLE"); + } + + let scope_end = bounded_keyword_end(sql, first_start, "GLOBAL") + .or_else(|| bounded_keyword_end(sql, first_start, "LOCAL"))?; + let temp_end = keyword_after_required_whitespace(sql, scope_end, "TEMP") + .or_else(|| keyword_after_required_whitespace(sql, scope_end, "TEMPORARY"))?; + keyword_after_required_whitespace(sql, temp_end, "TABLE") +} + +/// Canonicalize supported PostgreSQL table persistence modifiers for validation only. +/// +/// The scanner changes only bounded declaration prefixes and copies every other +/// byte verbatim. This preserves declared table spelling, `IF NOT EXISTS`, table +/// bodies, policy evidence, and statement locality while making all supported +/// table forms visible to the one existing `CREATE TABLE` structural authority. +fn canonicalize_table_persistence_modifiers(sql: &str) -> String { + let bytes = sql.as_bytes(); + let mut output = String::with_capacity(sql.len()); + let mut copied_through = 0usize; + let mut index = 0usize; + + while index < bytes.len() { + if matches!(bytes[index], b'C' | b'c') { + if let Some(declaration_end) = modifier_table_declaration_end(sql, index) { + output.push_str(&sql[copied_through..index]); + output.push_str("CREATE TABLE"); + copied_through = declaration_end; + index = declaration_end; + continue; + } + } + index += 1; + } + + if copied_through == 0 { + return sql.to_owned(); + } + output.push_str(&sql[copied_through..]); + output +} + +#[cfg(test)] +mod tests { + use super::{ + canonicalize_table_persistence_modifiers, + contains_invalid_alter_table_added_column_name, contains_unsupported_guard_routine_mutation, + contains_unsupported_policy_mutation, contains_unsupported_table_final_state_mutation, + project_committed_sql, + }; + + #[test] + fn table_modifier_canonicalization_preserves_the_declared_name_and_body() { + let sql = "CREATE\nGLOBAL\tTEMPORARY TABLE IF NOT EXISTS derived_cache (derived_cache_id uuid);"; + assert_eq!( + canonicalize_table_persistence_modifiers(sql), + "CREATE TABLE IF NOT EXISTS derived_cache (derived_cache_id uuid);" + ); + } + + #[test] + fn ordinary_and_identifier_attached_create_tokens_are_unchanged() { + for sql in [ + "CREATE TABLE tenant_record (tenant_record_id uuid);", + "prefixCREATE UNLOGGED TABLE derived_cache (derived_cache_id uuid);", + "CREATE_UNLOGGED TABLE derived_cache (derived_cache_id uuid);", + ] { + assert_eq!(canonicalize_table_persistence_modifiers(sql), sql); + } + } + + #[test] + fn rolled_back_structural_statement_is_absent_from_committed_projection() { + let projected = project_committed_sql( + "CREATE TABLE durable_record (durable_record_id uuid); BEGIN; CREATE TABLE rolled_back_record (rolled_back_record_id uuid); ROLLBACK;", + ) + .expect("simple rollback outcome must project"); + assert!(projected.contains("CREATE TABLE durable_record")); + assert!(!projected.contains("rolled_back_record")); + } + + #[test] + fn guard_routine_final_state_detection_is_target_bounded() { + let canonical = "CREATE OR REPLACE FUNCTION reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;"; + assert!(!contains_unsupported_guard_routine_mutation( + canonical, + "reject_append_only_mutation" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!( + "{canonical} CREATE OR REPLACE FUNCTION reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" + ), + "reject_append_only_mutation" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!("{canonical} DROP FUNCTION reject_append_only_mutation() CASCADE ;"), + "reject_append_only_mutation" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!( + "{canonical} DROP FUNCTION other_guard(), public.reject_append_only_mutation() CASCADE ;" + ), + "reject_append_only_mutation" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!( + "{canonical} DROP FUNCTION other_guard(), public . reject_append_only_mutation() CASCADE ;" + ), + "reject_append_only_mutation" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!( + "{canonical} CREATE OR REPLACE FUNCTION public . reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" + ), + "reject_append_only_mutation" + )); + assert!(!contains_unsupported_guard_routine_mutation( + &format!("{canonical} DROP FUNCTION reject_append_only_mutation_shadow() CASCADE ;"), + "reject_append_only_mutation" + )); + assert!(!contains_unsupported_guard_routine_mutation( + &format!("{canonical} DROP FUNCTION audit_support.reject_append_only_mutation() CASCADE ;"), + "reject_append_only_mutation" + )); + + let retention = "CREATE OR REPLACE FUNCTION reject_held_evidence_deletion() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NEW END ;"; + assert!(!contains_unsupported_guard_routine_mutation( + retention, + "reject_held_evidence_deletion" + )); + assert!(contains_unsupported_guard_routine_mutation( + &format!( + "{retention} DROP ROUTINE IF EXISTS public . reject_held_evidence_deletion() CASCADE ;" + ), + "reject_held_evidence_deletion" + )); + assert!(!contains_unsupported_guard_routine_mutation( + &format!( + "{retention} CREATE OR REPLACE FUNCTION audit_support.reject_held_evidence_deletion() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NEW END ;" + ), + "reject_held_evidence_deletion" + )); + } + + #[test] + fn table_final_state_mutation_detection_is_statement_token_and_action_bounded() { + for sql in [ + "DROP\nTABLE tenant_record ;", + "DROP TABLE IF EXISTS tenant_record CASCADE ;", + "DROP TRIGGER source_artifact_reject_mutation ON source_artifact ;", + "DROP TRIGGER IF EXISTS source_artifact_reject_mutation ON source_artifact RESTRICT ;", + "ALTER TABLE tenant_record RENAME TO tenant_record_archive ;", + "ALTER TABLE IF EXISTS ONLY tenant_record RENAME COLUMN tenant_record_id TO tenant_key ;", + "ALTER TABLE tenant_record DROP COLUMN tenant_record_id CASCADE ;", + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean, DROP COLUMN tenant_record_id CASCADE ;", + "ALTER TABLE source_artifact DISABLE TRIGGER source_artifact_reject_mutation ;", + "ALTER TABLE source_artifact DISABLE TRIGGER USER ;", + "ALTER TABLE source_artifact ENABLE REPLICA TRIGGER source_artifact_reject_mutation ;", + "ALTER TABLE source_artifact ADD COLUMN auxiliary_flag boolean, DISABLE TRIGGER source_artifact_reject_mutation ;", + ] { + assert!(contains_unsupported_table_final_state_mutation(sql)); + } + for sql in [ + "SELECT drop_table_marker ; CREATE TABLE tenant_record ( tenant_record_id uuid ) ;", + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY ;", + "ALTER TABLE rename ENABLE ROW LEVEL SECURITY ;", + "ALTER TABLE tenant_record ADD COLUMN drop_flag boolean ;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (some_func(a, drop_flag)) ;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_array_shape CHECK (some_func(ARRAY[a, drop_flag])) ;", + "ALTER TABLE source_artifact ENABLE TRIGGER source_artifact_reject_mutation ;", + "ALTER TABLE source_artifact ENABLE ALWAYS TRIGGER source_artifact_reject_mutation ;", + ] { + assert!(!contains_unsupported_table_final_state_mutation(sql)); + } + } + + #[test] + fn alter_table_add_column_naming_detection_is_action_bounded() { + for sql in [ + "ALTER TABLE tenant_record ADD COLUMN flag boolean ;", + "ALTER TABLE tenant_record ADD COLUMN IF NOT EXISTS flag boolean ;", + "ALTER TABLE tenant_record ADD flag boolean ;", + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean, ADD COLUMN flag boolean ;", + ] { + assert!(contains_invalid_alter_table_added_column_name(sql)); + } + for sql in [ + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean ;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (some_func(a, b)) ;", + "ALTER TABLE tenant_record ADD CHECK (tenant_record_id IS NOT NULL) ;", + "ALTER TABLE tenant_record ADD NOT NULL tenant_record_id ;", + "ALTER TABLE tenant_record ADD UNIQUE (tenant_record_id) ;", + "ALTER TABLE tenant_record ADD PRIMARY KEY (tenant_record_id) ;", + "ALTER TABLE tenant_record ADD FOREIGN KEY (tenant_record_id) REFERENCES tenant_record (tenant_record_id) ;", + ] { + assert!(!contains_invalid_alter_table_added_column_name(sql)); + } + } + + #[test] + fn policy_mutation_detection_is_statement_and_token_bounded() { + assert!(contains_unsupported_policy_mutation( + "ALTER\nPOLICY tenant_isolation ON tenant_record USING ( true ) ;" + )); + assert!(contains_unsupported_policy_mutation( + "DROP\tPOLICY tenant_isolation ON tenant_record ;" + )); + assert!(!contains_unsupported_policy_mutation( + "SELECT alter_policy_marker, drop_policy_marker ; CREATE POLICY tenant_isolation ON tenant_record USING ( true ) ;" + )); + } +} diff --git a/crates/persistence_postgres/src/migration_core_impl.rs b/crates/persistence_postgres/src/migration_core_impl.rs new file mode 100644 index 000000000..0c46b918b --- /dev/null +++ b/crates/persistence_postgres/src/migration_core_impl.rs @@ -0,0 +1,1483 @@ +//! Embedded migration catalog and fail-closed SQL contracts. + +use crate::MigrationContractError; +use crate::naming::is_multi_word_snake_case; +use std::collections::BTreeSet; + +const FOUNDATION_UP: &str = include_str!("../../../migrations/0001_bitemporal_foundation.up.sql"); +const FOUNDATION_DOWN: &str = + include_str!("../../../migrations/0001_bitemporal_foundation.down.sql"); +const RLS_UP: &str = include_str!("../../../migrations/0002_tenant_row_level_security.up.sql"); +const RLS_DOWN: &str = include_str!("../../../migrations/0002_tenant_row_level_security.down.sql"); +const MODEL_RUN_UP: &str = include_str!("../../../migrations/0003_model_run_artifact_chain.up.sql"); +const MODEL_RUN_DOWN: &str = + include_str!("../../../migrations/0003_model_run_artifact_chain.down.sql"); +const APPEND_ONLY_UP: &str = + include_str!("../../../migrations/0004_append_only_immutability_triggers.up.sql"); +const APPEND_ONLY_DOWN: &str = + include_str!("../../../migrations/0004_append_only_immutability_triggers.down.sql"); +const TEMPORAL_ORDER_UP: &str = + include_str!("../../../migrations/0005_temporal_interval_ordering.up.sql"); +const TEMPORAL_ORDER_DOWN: &str = + include_str!("../../../migrations/0005_temporal_interval_ordering.down.sql"); +const MEMBERSHIP_UP: &str = + include_str!("../../../migrations/0006_typed_membership_assignment.up.sql"); +const MEMBERSHIP_DOWN: &str = + include_str!("../../../migrations/0006_typed_membership_assignment.down.sql"); +const RETENTION_UP: &str = + include_str!("../../../migrations/0007_retention_deletion_legal_hold.up.sql"); +const RETENTION_DOWN: &str = + include_str!("../../../migrations/0007_retention_deletion_legal_hold.down.sql"); + +/// Forward and rollback SQL for one migration unit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MigrationCatalog { + up_sql: String, + down_sql: String, +} + +impl MigrationCatalog { + /// Load the embedded foundation and tenant RLS migrations shipped with this crate. + /// + /// # Errors + /// + /// Returns [`MigrationContractError::EmptyMigrationSql`] when embedded + /// sources are unexpectedly empty. + pub fn from_embedded() -> Result { + let up_sql = format!( + "{FOUNDATION_UP}\n{RLS_UP}\n{MODEL_RUN_UP}\n{APPEND_ONLY_UP}\n{TEMPORAL_ORDER_UP}\n{MEMBERSHIP_UP}\n{RETENTION_UP}" + ); + let down_sql = format!( + "{RETENTION_DOWN}\n{MEMBERSHIP_DOWN}\n{TEMPORAL_ORDER_DOWN}\n{APPEND_ONLY_DOWN}\n{MODEL_RUN_DOWN}\n{RLS_DOWN}\n{FOUNDATION_DOWN}" + ); + Self::from_sources(&up_sql, &down_sql) + } + + /// Construct a catalog from non-empty forward and rollback SQL sources. + /// + /// This constructor is the checked internal counterpart to [`Self::from_sql`] + /// used for embedded production migrations, where an empty direction means + /// the shipped migration bundle is incomplete rather than a valid no-op. + fn from_sources(up_sql: &str, down_sql: &str) -> Result { + if up_sql.trim().is_empty() || down_sql.trim().is_empty() { + return Err(MigrationContractError::EmptyMigrationSql); + } + Ok(Self { + up_sql: up_sql.to_owned(), + down_sql: down_sql.to_owned(), + }) + } + + /// Construct a catalog from raw SQL strings (used by contract tests). + #[must_use] + pub fn from_sql(up_sql: &str, down_sql: &str) -> Self { + Self { + up_sql: up_sql.to_owned(), + down_sql: down_sql.to_owned(), + } + } + + /// Borrow the forward migration SQL. + #[must_use] + pub fn up_sql(&self) -> &str { + &self.up_sql + } + + /// Borrow the rollback migration SQL. + #[must_use] + pub fn down_sql(&self) -> &str { + &self.down_sql + } +} + +/// Validate migration SQL against TEPP persistence contracts. +/// +/// When the catalog declares row-level security, every tenant-scoped table must +/// enable RLS and name multi-word isolation policies. +/// +/// # Errors +/// +/// Returns naming, tenant, temporal, RLS, or emptiness failures. +pub fn validate_migration_catalog( + catalog: &MigrationCatalog, +) -> Result<(), MigrationContractError> { + if catalog.up_sql.trim().is_empty() || catalog.down_sql.trim().is_empty() { + return Err(MigrationContractError::EmptyMigrationSql); + } + + let tables = parse_create_table_names(catalog.up_sql()); + if tables.is_empty() { + return Err(MigrationContractError::EmptyMigrationSql); + } + + for table in &tables { + if !is_multi_word_snake_case(table) { + return Err(MigrationContractError::SingleWordObjectName); + } + // Lookups below match case-folded SQL; the contract check above used + // the declared spelling so `Document_Record` cannot pass as lowercase. + let folded = table.to_ascii_lowercase(); + let body = table_body(catalog.up_sql(), &folded) + .ok_or(MigrationContractError::EmptyMigrationSql)?; + validate_table_body(&folded, body)?; + } + + for object in parse_created_object_names(catalog.up_sql()) { + if !is_multi_word_snake_case(&object) { + return Err(MigrationContractError::SingleWordObjectName); + } + } + for constraint in parse_constraint_names(catalog.up_sql()) { + if !is_multi_word_snake_case(&constraint) { + return Err(MigrationContractError::SingleWordObjectName); + } + } + + if declares_row_level_security(catalog.up_sql()) { + validate_tenant_rls_contract(catalog.up_sql(), &tables)?; + } + if declares_append_only_immutability(catalog.up_sql()) { + validate_append_only_immutability(catalog.up_sql())?; + } + if declares_temporal_interval_ordering(catalog.up_sql()) { + validate_temporal_interval_ordering(catalog.up_sql())?; + } + if declares_retention_legal_hold(catalog.up_sql()) { + validate_retention_legal_hold(catalog.up_sql())?; + } + + Ok(()) +} + +/// Detect whether migration text opts into TEPP's append-only mutation contract. +/// +/// Detection is intentionally broad; once append-only vocabulary appears, the +/// validator requires the complete function/trigger/revoke bundle rather than +/// treating a partial declaration as harmless text. +fn declares_append_only_immutability(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + lower.contains("reject_append_only_mutation") || lower.contains("_reject_mutation") +} + +/// Require the complete append-only trigger and privilege-revocation bundle. +/// +/// # Errors +/// +/// Returns [`MigrationContractError::MissingAppendOnlyTrigger`] when any +/// protected table lacks its mutation trigger or UPDATE/DELETE revocation. +fn validate_append_only_immutability(up_sql: &str) -> Result<(), MigrationContractError> { + let lower = up_sql.to_ascii_lowercase(); + if !lower.contains("create or replace function reject_append_only_mutation") { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + let required = [ + "source_artifact", + "audit_event", + "reproducibility_manifest", + "corpus_split_manifest", + "model_run", + "model_artifact", + ]; + for table in required { + let trigger = format!("{table}_reject_mutation"); + if !lower.contains(&format!("create trigger {trigger}")) { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + if !lower.contains(&format!("revoke update, delete on table {table}")) { + return Err(MigrationContractError::MissingAppendOnlyTrigger); + } + } + Ok(()) +} + +/// Detect whether named temporal ordering constraints are being declared. +/// +/// Partial adoption activates the full temporal contract so one well-named +/// constraint cannot make an otherwise incomplete migration appear governed. +fn declares_temporal_interval_ordering(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + lower.contains("_valid_order") || lower.contains("_system_order") +} + +/// Require TEPP's named interval-order and positive-revision invariants. +/// +/// # Errors +/// +/// Returns [`MigrationContractError::MissingTemporalIntervalConstraint`] when +/// a required constraint name or its essential ordering predicate is absent. +fn validate_temporal_interval_ordering(up_sql: &str) -> Result<(), MigrationContractError> { + let lower = up_sql.to_ascii_lowercase(); + let required = [ + "document_record_valid_order", + "document_record_system_order", + "document_record_revision_positive", + "event_instance_valid_order", + "event_instance_system_order", + "membership_assignment_valid_order", + ]; + for constraint in required { + if !lower.contains(&format!("constraint {constraint}")) { + return Err(MigrationContractError::MissingTemporalIntervalConstraint); + } + } + if !lower.contains("valid_to is null or valid_from <=") { + return Err(MigrationContractError::MissingTemporalIntervalConstraint); + } + if !lower.contains("system_to is null or system_from <=") { + return Err(MigrationContractError::MissingTemporalIntervalConstraint); + } + if !lower.contains("revision_number > 0") { + return Err(MigrationContractError::MissingTemporalIntervalConstraint); + } + Ok(()) +} + +/// Detect whether retention, legal-hold, or tombstone vocabulary is present. +/// +/// Any one of these owner concepts activates validation of the whole deletion +/// governance bundle because partial retention enforcement is not admissible. +fn declares_retention_legal_hold(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + lower.contains("retention_policy") + || lower.contains("legal_hold") + || lower.contains("evidence_tombstone") +} + +/// Require the retention/legal-hold tables, guards, triggers, and constraints. +/// +/// # Errors +/// +/// Returns [`MigrationContractError::MissingRetentionLegalHold`] when any +/// component needed for fail-closed retention/deletion semantics is absent. +fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractError> { + let lower = up_sql.to_ascii_lowercase(); + let required_tables = [ + "retention_policy", + "legal_hold", + "deletion_request", + "evidence_tombstone", + ]; + for table in required_tables { + if !lower.contains(&format!("create table {table}")) { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + } + if !lower.contains("create or replace function reject_held_evidence_deletion") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if !lower.contains("create or replace function reject_tombstoned_evidence_restore") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if !lower.contains("create trigger deletion_request_reject_held_deletion") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if !lower.contains("create trigger document_record_reject_tombstone_restore") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if !lower.contains("constraint retention_policy_period_positive") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + if !lower.contains("constraint legal_hold_document_scope_consistent") { + return Err(MigrationContractError::MissingRetentionLegalHold); + } + Ok(()) +} + +/// Validate one explicit `CREATE TABLE` body against TEPP structural invariants. +/// +/// This is the locality boundary for column naming, tenant ownership, and the +/// required system/domain clocks. Registry/audit tables use the explicitly +/// narrower temporal contract below rather than inheriting an accidental +/// exception from parser behavior. +/// +/// # Errors +/// +/// Returns the first structural naming, tenant, temporal, or malformed-body +/// contract error encountered for the table. +fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { + if has_unbalanced_square_brackets(body) || has_empty_table_element(body) { + return Err(MigrationContractError::EmptyMigrationSql); + } + let columns = parse_column_names(body); + for column in &columns { + if !is_multi_word_snake_case(column) { + return Err(MigrationContractError::SingleWordObjectName); + } + } + if requires_tenant_boundary(table) && !columns.contains("tenant_record_id") { + return Err(MigrationContractError::MissingTenantBoundary); + } + + if !has_system_time_column(body) { + return Err(MigrationContractError::MissingTemporalColumns); + } + + // Registry and immutable audit tables may omit availability/valid windows. + if is_registry_or_audit_table(table) { + return Ok(()); + } + + if !has_domain_time_column(body) { + return Err(MigrationContractError::MissingTemporalColumns); + } + Ok(()) +} + +/// Validate table-local RLS enablement and tenant-policy presence. +/// +/// This structural pass is deliberately conservative and is complemented by +/// the lexical/relational policy validation in the facade. It must not infer a +/// tenant boundary from a policy on a sibling table. +/// +/// # Errors +/// +/// Returns missing role/GUC/policy/enablement errors when the declared RLS +/// surface is incomplete for any created table. +fn validate_tenant_rls_contract( + up_sql: &str, + tables: &BTreeSet, +) -> Result<(), MigrationContractError> { + let lower = up_sql.to_ascii_lowercase(); + if !lower.contains("tepp_app_runtime") { + return Err(MigrationContractError::MissingAppRuntimeRole); + } + if !lower.contains("'tepp.current_tenant_record_id'") { + return Err(MigrationContractError::MissingTenantSessionGuc); + } + + // Policy names are contract-checked with every other created object in + // `validate_migration_catalog`; this scan only proves a policy exists. + let policies = parse_create_policy_names(up_sql); + if policies.is_empty() { + return Err(MigrationContractError::MissingRlsPolicy); + } + for table in tables { + let folded = table.to_ascii_lowercase(); + if !table_has_rls_enabled(&lower, &folded) { + return Err(MigrationContractError::MissingRlsEnable); + } + if !table_has_tenant_policy(&lower, &folded) { + return Err(MigrationContractError::MissingRlsPolicy); + } + } + Ok(()) +} + +/// Detect whether migration text declares any row-level-security surface. +/// +/// A single enablement or policy declaration is enough to activate the full +/// RLS validation path; partial RLS text cannot remain unchecked. +fn declares_row_level_security(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + let has_enable = lower.contains("enable row level security"); + let has_policy = lower.contains("create policy"); + has_enable | has_policy +} + +/// Return whether one table is both enabled and forced into PostgreSQL RLS. +/// +/// The literal space following `{table}` is part of each search needle, so a +/// longer identifier prefix such as `document_record$shadow` cannot donate +/// enablement evidence for `document_record`. +fn table_has_rls_enabled(lower_sql: &str, table: &str) -> bool { + let enable = format!("alter table {table} enable row level security"); + let force = format!("alter table {table} force row level security"); + lower_sql.contains(&enable) & lower_sql.contains(&force) +} + +/// Return whether the target table has a policy mentioning the exact tenant key. +/// +/// Policy statements are scanned independently so an identifier in a previous +/// policy cannot satisfy the target table's tenant evidence. +fn table_has_tenant_policy(lower_sql: &str, table: &str) -> bool { + let mut search_from = 0usize; + while let Some(rel) = lower_sql[search_from..].find("create policy") { + let abs = search_from + rel; + let after_policy = &lower_sql[abs..]; + let window_end = after_policy[13..] + .find("create policy") + .map_or(after_policy.len(), |idx| 13 + idx); + let window = &after_policy[..window_end]; + if policy_targets_table(window, table) + && contains_unquoted_identifier(window, "tenant_record_id") + { + return true; + } + search_from = abs + "create policy".len(); + } + false +} + +/// Return whether one policy statement targets exactly `table`. +/// +/// PostgreSQL identifier continuation is checked after the candidate target so +/// an identifier prefix cannot impersonate the requested relation. +fn policy_targets_table(policy_sql: &str, table: &str) -> bool { + let needle = format!(" on {table}"); + let mut search_from = 0usize; + while let Some(rel) = policy_sql[search_from..].find(&needle) { + let end = search_from + rel + needle.len(); + if !identifier_continues_after(policy_sql, end) { + return true; + } + search_from = end; + } + false +} + +/// Return whether normalized SQL contains an exact unquoted identifier token. +/// +/// Atomic string literals are excluded and both token boundaries use the same +/// PostgreSQL continuation authority, preventing suffix/prefix lookalikes from +/// donating contract evidence. +fn contains_unquoted_identifier(sql: &str, identifier: &str) -> bool { + let mut search_from = 0usize; + while let Some(rel) = sql[search_from..].find(identifier) { + let start = search_from + rel; + let end = start + identifier.len(); + let inside_atomic_literal = sql[..start] + .bytes() + .filter(|byte| *byte == b'\'') + .count() + % 2 + == 1; + if !inside_atomic_literal + && is_word_start(sql, start) + && !identifier_continues_after(sql, end) + { + return true; + } + search_from = end; + } + false +} + +/// Return whether a table must carry the canonical tenant foreign key. +/// +/// The tenant registry itself is the root of the tenancy graph and therefore +/// is the only table exempted by this structural contract. +fn requires_tenant_boundary(table: &str) -> bool { + table != "tenant_record" +} + +/// Return whether a table uses the narrower registry/audit temporal contract. +fn is_registry_or_audit_table(table: &str) -> bool { + table == "tenant_record" || table == "audit_event" +} + +/// Return whether a table body declares an accepted system-time column. +fn has_system_time_column(body: &str) -> bool { + let columns = parse_column_names(body); + columns.contains("system_time") + || columns.contains("system_from") + || columns.contains("recorded_system_time") +} + +/// Return whether a table body declares an accepted domain/availability clock. +fn has_domain_time_column(body: &str) -> bool { + let columns = parse_column_names(body); + columns.contains("available_time") || columns.contains("valid_from") +} + +/// Object kinds whose `CREATE` statements name a database object. +const CREATE_KEYWORDS: [&str; 10] = [ + "CREATE TABLE", + "CREATE POLICY", + "CREATE INDEX", + "CREATE UNIQUE INDEX", + "CREATE TRIGGER", + "CREATE FUNCTION", + "CREATE OR REPLACE FUNCTION", + "CREATE TYPE", + "CREATE VIEW", + "CREATE SEQUENCE", +]; + +/// Leading words of a table-level constraint clause, which names no column. +const TABLE_CONSTRAINT_KEYWORDS: [&str; 7] = [ + "constraint", + "primary", + "foreign", + "unique", + "check", + "exclude", + "like", +]; + +/// Return whether `ch` can continue a PostgreSQL unquoted identifier. +/// +/// PostgreSQL's scanner permits ASCII letters/digits, `_`, `$`, and high-bit +/// bytes after the first identifier byte. A non-ASCII Rust `char` is encoded +/// from high-bit UTF-8 bytes, so treating it as continuation preserves the +/// durable token for TEPP's stricter naming authority instead of truncating a +/// valid PostgreSQL identifier to a safe-looking ASCII prefix. +fn is_postgresql_identifier_continuation(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() +} + +/// Return whether `index` starts a keyword rather than continuing an identifier. +fn is_word_start(sql: &str, index: usize) -> bool { + sql[..index] + .chars() + .next_back() + .is_none_or(|ch| !is_postgresql_identifier_continuation(ch)) +} + +/// Return the full unquoted identifier at the start of `rest`, skipping an existence clause. +/// +/// This deliberately preserves PostgreSQL-valid continuation bytes such as `$` +/// and non-ASCII text. TEPP's naming contract is evaluated afterwards against +/// the complete durable spelling; this parser must not sanitize a forbidden +/// spelling by truncating it. +fn leading_identifier(rest: &str) -> String { + let rest = rest.trim_start(); + let lower = rest.to_ascii_lowercase(); + let rest = lower + .strip_prefix("if not exists") + .or_else(|| lower.strip_prefix("if exists")) + .map_or(rest, |stripped| &rest[rest.len() - stripped.len()..]) + .trim_start(); + rest.chars() + .take_while(|ch| is_postgresql_identifier_continuation(*ch)) + .collect() +} + +/// Return the declared names that follow each occurrence of `keyword`. +/// +/// Names keep their declared spelling so the `snake_case` half of the naming +/// contract stays observable; callers fold their own lookup keys. +fn parse_names_after(sql: &str, keyword: &str) -> BTreeSet { + let mut names = BTreeSet::new(); + let upper = sql.to_ascii_uppercase(); + let mut search_from = 0usize; + while let Some(rel) = upper[search_from..].find(keyword) { + let keyword_start = search_from + rel; + let abs = keyword_start + keyword.len(); + search_from = abs; + // Reject `integrity_constraint_violation` and `CREATE TABLEX`: the + // keyword must stand alone on both sides. + if !is_word_start(sql, keyword_start) { + continue; + } + if sql[abs..] + .chars() + .next() + .is_some_and(|ch| !ch.is_whitespace()) + { + continue; + } + let name = leading_identifier(&sql[abs..]); + if !name.is_empty() { + names.insert(name); + } + } + names +} + +/// Return the set of table names declared by complete `CREATE TABLE` tokens. +fn parse_create_table_names(sql: &str) -> BTreeSet { + parse_names_after(sql, "CREATE TABLE") +} + +/// Return the set of RLS policy names declared by complete `CREATE POLICY` tokens. +fn parse_create_policy_names(sql: &str) -> BTreeSet { + parse_names_after(sql, "CREATE POLICY") +} + +/// Return every object name declared by a `CREATE` statement in `sql`. +fn parse_created_object_names(sql: &str) -> BTreeSet { + let mut names = BTreeSet::new(); + for keyword in CREATE_KEYWORDS { + names.extend(parse_names_after(sql, keyword)); + } + names +} + +/// Return every explicitly named constraint in `sql`. +fn parse_constraint_names(sql: &str) -> BTreeSet { + parse_names_after(sql, "CONSTRAINT") +} + +/// Split one explicit `CREATE TABLE (...)` body at structural element commas. +/// +/// Parenthesized type arguments and table-constraint column lists remain within +/// their owning element because their commas occur below the outer table-body +/// depth. Square-bracket array constructors/subscripts are tracked separately, +/// so `ARRAY[1, 2]` cannot manufacture a pseudo table element. +fn split_table_elements(body: &str) -> Vec<&str> { + let mut depth = 0i32; + let mut bracket_depth = 0i32; + let mut start = 0usize; + let mut segments = Vec::new(); + for (index, ch) in body.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + ',' if depth == 1 && bracket_depth == 0 => { + segments.push(&body[start..index]); + start = index + 1; + } + _ => {} + } + } + segments.push(&body[start..]); + segments +} + +/// Return whether square-bracket nesting is malformed in an explicit table body. +/// +/// The lexical facade has already masked quoted/comment content, so remaining +/// brackets are structural PostgreSQL array constructor, subscript, or type +/// syntax. Rejecting underflow/unclosed nesting prevents a malformed `[` from +/// swallowing later real table-element separators. +fn has_unbalanced_square_brackets(body: &str) -> bool { + let mut depth = 0i32; + for ch in body.chars() { + match ch { + '[' => depth += 1, + ']' if depth == 0 => return true, + ']' => depth -= 1, + _ => {} + } + } + depth != 0 +} + +/// Return whether a comma-separated `CREATE TABLE` body contains an empty +/// element rather than a column, table constraint, or `LIKE` clause. +/// +/// PostgreSQL permits an entirely empty element list (`CREATE TABLE x ()`), but +/// once a comma is present each side must contain an element. Stripping only the +/// single outer table-body parenthesis from the first/last segment preserves +/// nested type and constraint parentheses while exposing leading, interior, and +/// trailing comma gaps. +fn has_empty_table_element(body: &str) -> bool { + let segments = split_table_elements(body); + if segments.len() < 2 { + return false; + } + let last = segments.len() - 1; + segments.iter().enumerate().any(|(index, segment)| { + let mut payload = segment.trim(); + if index == 0 { + payload = payload.strip_prefix('(').unwrap_or(payload).trim_start(); + } + if index == last { + payload = payload.strip_suffix(')').unwrap_or(payload).trim_end(); + } + payload.is_empty() + }) +} + +/// Return the column names declared directly in a `CREATE TABLE` body. +/// +/// Table-level constraint clauses name no column and are skipped. +fn parse_column_names(body: &str) -> BTreeSet { + let mut names = BTreeSet::new(); + for segment in split_table_elements(body) { + let segment = segment.trim_start_matches(['(', ')']).trim(); + let name = leading_identifier(segment); + let lowered = name.to_ascii_lowercase(); + if !name.is_empty() && !TABLE_CONSTRAINT_KEYWORDS.contains(&lowered.as_str()) { + names.insert(name); + } + } + names +} + +/// Return whether the byte immediately after `end` continues the same PostgreSQL identifier. +fn identifier_continues_after(sql: &str, end: usize) -> bool { + sql[end..] + .chars() + .next() + .is_some_and(is_postgresql_identifier_continuation) +} + +/// Locate an exact table-declaration prefix without accepting longer identifiers. +/// +/// The caller supplies a normalized declaration needle. Candidates followed by +/// PostgreSQL identifier continuation bytes are skipped rather than allowing a +/// table-name prefix to borrow the next declaration's body. +fn find_table_declaration_end(lower_sql: &str, needle: &str) -> Option { + let mut search_from = 0usize; + while let Some(rel) = lower_sql[search_from..].find(needle) { + let start = search_from + rel; + let end = start + needle.len(); + if !identifier_continues_after(lower_sql, end) { + return Some(end); + } + search_from = end; + } + None +} + +/// Return whether `sql` begins with a complete keyword rather than an identifier prefix. +fn starts_with_keyword(sql: &str, keyword: &str) -> bool { + sql.get(..keyword.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword)) + && sql[keyword.len()..] + .chars() + .next() + .is_none_or(|ch| !is_postgresql_identifier_continuation(ch)) +} + +/// Return the explicit parenthesized body for one exact `CREATE TABLE` target. +/// +/// A `CREATE TABLE ... AS` declaration deliberately maps to an empty local body +/// so tenant/temporal contracts fail closed rather than borrowing parentheses +/// from a later statement. Semicolons or unbalanced parentheses also refuse the +/// parse instead of widening the structural evidence window. +fn table_body<'a>(sql: &'a str, table: &str) -> Option<&'a str> { + let lower = sql.to_ascii_lowercase(); + let needles = [ + format!("create table if not exists {table}"), + format!("create table {table}"), + ]; + let declaration_end = needles + .iter() + .find_map(|needle| find_table_declaration_end(&lower, needle))?; + let after = sql[declaration_end..].trim_start(); + if !after.starts_with('(') { + // `CREATE TABLE ... AS query` has no explicit column body. Represent it + // as an empty local body so temporal/tenant contracts fail closed, + // rather than borrowing a parenthesis from a later SQL statement. + return starts_with_keyword(after, "AS").then_some(""); + } + let mut depth = 0i32; + for (idx, ch) in after.char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(&after[..=idx]); + } + } + ';' => return None, + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{MigrationCatalog, validate_migration_catalog}; + use crate::MigrationContractError; + + #[test] + fn embedded_catalog_is_non_empty_and_valid() { + let catalog = MigrationCatalog::from_embedded().expect("embedded"); + validate_migration_catalog(&catalog).expect("valid"); + assert!(catalog.up_sql().contains("CREATE TABLE")); + assert!(catalog.up_sql().contains("ENABLE ROW LEVEL SECURITY")); + assert!(catalog.up_sql().contains("CREATE POLICY")); + assert!(catalog.up_sql().contains("tepp_app_runtime")); + assert!(catalog.up_sql().contains("tepp.current_tenant_record_id")); + assert!(catalog.down_sql().contains("DROP TABLE")); + assert!(catalog.down_sql().contains("DROP POLICY")); + assert!(catalog.down_sql().contains("DROP ROLE")); + } + + #[test] + fn helper_predicates_are_exhaustive() { + use super::{ + declares_row_level_security, has_domain_time_column, has_system_time_column, + is_registry_or_audit_table, parse_create_policy_names, requires_tenant_boundary, + table_has_rls_enabled, table_has_tenant_policy, + }; + assert!(requires_tenant_boundary("document_record")); + assert!(!requires_tenant_boundary("tenant_record")); + assert!(is_registry_or_audit_table("tenant_record")); + assert!(is_registry_or_audit_table("audit_event")); + assert!(!is_registry_or_audit_table("document_record")); + assert!(has_system_time_column("system_time timestamptz")); + assert!(has_system_time_column("system_from timestamptz")); + assert!(has_system_time_column("recorded_system_time timestamptz")); + assert!(!has_system_time_column("available_time timestamptz")); + assert!(has_domain_time_column("available_time timestamptz")); + assert!(has_domain_time_column("valid_from timestamptz")); + assert!(!has_domain_time_column("system_time timestamptz")); + assert!(declares_row_level_security("ENABLE ROW LEVEL SECURITY")); + assert!(declares_row_level_security("CREATE POLICY x ON y")); + assert!(!declares_row_level_security( + "CREATE TABLE document_record ()" + )); + assert!(table_has_rls_enabled( + "alter table document_record enable row level security; alter table document_record force row level security;", + "document_record" + )); + assert!(!table_has_rls_enabled( + "alter table document_record enable row level security;", + "document_record" + )); + assert!(table_has_tenant_policy( + "create policy document_record_tenant_isolation on document_record using (tenant_record_id = 'x'::uuid)", + "document_record" + )); + assert!(!table_has_tenant_policy( + "create policy other_table_policy on other_table using (tenant_record_id = 'x'::uuid)", + "document_record" + )); + let policies = parse_create_policy_names( + "CREATE POLICY document_record_tenant_isolation ON document_record FOR ALL USING (true);", + ); + assert!(policies.contains("document_record_tenant_isolation")); + } + + /// A valid single-table migration that the added clause is appended to. + fn conforming_up_sql(extra: &str) -> String { + format!( + "CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {extra}" + ) + } + + #[test] + fn every_created_object_kind_must_be_multi_word_snake_case() { + for clause in [ + "CREATE INDEX idx ON tenant_record (tenant_record_id);", + "CREATE UNIQUE INDEX Tenant_Idx ON tenant_record (tenant_record_id);", + "CREATE TRIGGER guard BEFORE UPDATE ON tenant_record;", + "CREATE FUNCTION reject() RETURNS trigger;", + "CREATE OR REPLACE FUNCTION Reject_Mutation() RETURNS trigger;", + "CREATE TYPE kind AS ENUM ('a');", + "CREATE VIEW records AS SELECT 1;", + "CREATE SEQUENCE counter;", + "CREATE POLICY isolation ON tenant_record FOR ALL USING (true);", + ] { + let catalog = + MigrationCatalog::from_sql(&conforming_up_sql(clause), "DROP TABLE tenant_record;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "{clause} was accepted" + ); + } + } + + #[test] + fn column_and_constraint_names_must_be_multi_word_snake_case() { + let single_word_column = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record (id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&single_word_column), + Err(MigrationContractError::SingleWordObjectName) + ); + + let mixed_case_column = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record (Tenant_Id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&mixed_case_column), + Err(MigrationContractError::SingleWordObjectName) + ); + + let named_constraint = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL, + CONSTRAINT pk UNIQUE (tenant_record_id) + );", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&named_constraint), + Err(MigrationContractError::SingleWordObjectName) + ); + } + + #[test] + fn parenthesised_types_and_table_constraints_do_not_shift_column_names() { + let catalog = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + run_cost numeric(12, 4) NOT NULL, + system_time timestamptz NOT NULL, + PRIMARY KEY (tenant_record_id), + CHECK (run_cost > 0) + );", + "DROP TABLE tenant_record;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); + } + + #[test] + fn keywords_inside_identifiers_and_literals_name_no_object() { + // `integrity_constraint_violation` embeds CONSTRAINT; `CREATE TABLEX` + // embeds CREATE TABLE. Neither declares an object. + let catalog = MigrationCatalog::from_sql( + &conforming_up_sql( + "RAISE EXCEPTION 'x' USING ERRCODE = 'integrity_constraint_violation'; + -- CREATE TABLEX nothing; + -- 1CONSTRAINT digit_prefixed_word; + ALTER TABLE tenant_record DROP CONSTRAINT IF EXISTS tenant_record_unique;", + ), + "DROP TABLE tenant_record;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); + } + + #[test] + fn a_create_keyword_with_no_following_name_declares_nothing() { + let catalog = MigrationCatalog::from_sql( + &conforming_up_sql("CREATE VIEW (broken;"), + "DROP TABLE tenant_record;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); + } + + #[test] + fn mixed_case_table_names_are_rejected() { + let catalog = MigrationCatalog::from_sql( + "CREATE TABLE Document_Record (document_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL, valid_from timestamptz NOT NULL);", + "DROP TABLE Document_Record;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); + } + + #[test] + fn mixed_case_policy_names_are_rejected() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + GRANT SELECT ON tenant_record TO tepp_app_runtime; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY Tenant_Isolation ON tenant_record + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); + } + + #[test] + fn naming_and_column_contracts_fail_closed() { + let single_word = MigrationCatalog::from_sql( + "CREATE TABLE documents (document_id uuid PRIMARY KEY);", + "DROP TABLE documents;", + ); + assert_eq!( + validate_migration_catalog(&single_word), + Err(MigrationContractError::SingleWordObjectName) + ); + let no_tenant = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + available_time timestamptz NOT NULL, + system_time timestamptz NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + assert_eq!( + validate_migration_catalog(&no_tenant), + Err(MigrationContractError::MissingTenantBoundary) + ); + let no_system = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + available_time timestamptz NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + assert_eq!( + validate_migration_catalog(&no_system), + Err(MigrationContractError::MissingTemporalColumns) + ); + let missing_domain_time = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_domain_time), + Err(MigrationContractError::MissingTemporalColumns) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn rls_contracts_fail_closed_when_declared() { + let missing_role = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_role), + Err(MigrationContractError::MissingAppRuntimeRole) + ); + + let missing_guc = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL USING (tenant_record_id IS NOT NULL); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_guc), + Err(MigrationContractError::MissingTenantSessionGuc) + ); + + let missing_enable = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_enable), + Err(MigrationContractError::MissingRlsEnable) + ); + + let single_word_policy = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY isolation ON tenant_record + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&single_word_policy), + Err(MigrationContractError::SingleWordObjectName) + ); + + let missing_policy = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER; + SELECT current_setting('tepp.current_tenant_record_id', true); + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_policy), + Err(MigrationContractError::MissingRlsPolicy) + ); + + // Policy exists and is multi-word, but does not mention tenant_record_id. + let policy_without_tenant_predicate = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER; + SELECT current_setting('tepp.current_tenant_record_id', true); + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL USING (true); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&policy_without_tenant_predicate), + Err(MigrationContractError::MissingRlsPolicy) + ); + + // Second CREATE POLICY window + IF NOT EXISTS / empty policy name edges. + assert!(!super::table_has_tenant_policy( + "create policy other_table_isolation on other_table using (tenant_record_id = 1); \ + create policy tenant_record_tenant_isolation on tenant_record using (true);", + "tenant_record", + )); + assert!(super::table_has_tenant_policy( + "create policy other_table_isolation on other_table using (true); \ + create policy tenant_record_tenant_isolation on tenant_record using (tenant_record_id = 1);", + "tenant_record", + )); + assert!(super::parse_create_policy_names("CREATE POLICY \"weird\" ON t;").is_empty()); + assert!(super::table_body( + "CREATE TABLE IF NOT EXISTS tenant_record (tenant_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", + "tenant_record", + ) + .is_some()); + assert!( + super::table_body("CREATE TABLE tenant_record NO_PARENS;", "tenant_record").is_none() + ); + } + + #[test] + fn append_only_immutability_contract_fails_closed() { + let missing_function = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE TRIGGER source_artifact_reject_mutation + BEFORE UPDATE ON source_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_function), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + let missing_trigger = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE OR REPLACE FUNCTION reject_append_only_mutation() + RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_trigger), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + // All triggers present; REVOKE omitted only for model_artifact so the + // last revoke branch returns MissingAppendOnlyTrigger. + let missing_revoke = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE OR REPLACE FUNCTION reject_append_only_mutation() + RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$; + CREATE TRIGGER source_artifact_reject_mutation + BEFORE UPDATE ON source_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE source_artifact FROM tepp_app_runtime; + CREATE TRIGGER audit_event_reject_mutation + BEFORE UPDATE ON audit_event + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE audit_event FROM tepp_app_runtime; + CREATE TRIGGER reproducibility_manifest_reject_mutation + BEFORE UPDATE ON reproducibility_manifest + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE reproducibility_manifest FROM tepp_app_runtime; + CREATE TRIGGER corpus_split_manifest_reject_mutation + BEFORE UPDATE ON corpus_split_manifest + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE corpus_split_manifest FROM tepp_app_runtime; + CREATE TRIGGER model_run_reject_mutation + BEFORE UPDATE ON model_run + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + REVOKE UPDATE, DELETE ON TABLE model_run FROM tepp_app_runtime; + CREATE TRIGGER model_artifact_reject_mutation + BEFORE UPDATE ON model_artifact + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation(); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_revoke), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + + assert!(super::declares_append_only_immutability( + "CREATE TRIGGER source_artifact_reject_mutation" + )); + assert!(!super::declares_append_only_immutability("CREATE TABLE x")); + assert_eq!( + super::validate_append_only_immutability( + "CREATE TRIGGER source_artifact_reject_mutation BEFORE UPDATE ON source_artifact \ + FOR EACH ROW EXECUTE FUNCTION reject_append_only_mutation();" + ), + Err(MigrationContractError::MissingAppendOnlyTrigger) + ); + } + + #[test] + fn temporal_interval_ordering_contract_fails_closed() { + assert!(super::declares_temporal_interval_ordering( + "CONSTRAINT document_record_valid_order CHECK (true)" + )); + assert!(!super::declares_temporal_interval_ordering( + "CREATE TABLE x" + )); + + assert_eq!( + super::validate_temporal_interval_ordering( + "CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to)" + ), + Err(MigrationContractError::MissingTemporalIntervalConstraint) + ); + + // Named constraints present; fail each predicate branch independently. + let names = r" + CONSTRAINT document_record_valid_order CHECK (true) + CONSTRAINT document_record_system_order CHECK (true) + CONSTRAINT document_record_revision_positive CHECK (true) + CONSTRAINT event_instance_valid_order CHECK (true) + CONSTRAINT event_instance_system_order CHECK (true) + CONSTRAINT membership_assignment_valid_order CHECK (true) + "; + assert_eq!( + super::validate_temporal_interval_ordering(names), + Err(MigrationContractError::MissingTemporalIntervalConstraint) + ); + let missing_system_order = format!( + "{names}\nCHECK (valid_to IS NULL OR valid_from <= valid_to)\n\ + CHECK (revision_number > 0)" + ); + assert_eq!( + super::validate_temporal_interval_ordering(&missing_system_order), + Err(MigrationContractError::MissingTemporalIntervalConstraint) + ); + let missing_revision = format!( + "{names}\nCHECK (valid_to IS NULL OR valid_from <= valid_to)\n\ + CHECK (system_to IS NULL OR system_from <= system_to)" + ); + assert_eq!( + super::validate_temporal_interval_ordering(&missing_revision), + Err(MigrationContractError::MissingTemporalIntervalConstraint) + ); + + // Predicates present but last named constraint missing. + let missing_membership = r" + CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) + CONSTRAINT document_record_system_order CHECK (system_to IS NULL OR system_from <= system_to) + CONSTRAINT document_record_revision_positive CHECK (revision_number > 0) + CONSTRAINT event_instance_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) + CONSTRAINT event_instance_system_order CHECK (system_to IS NULL OR system_from <= system_to) + "; + assert_eq!( + super::validate_temporal_interval_ordering(missing_membership), + Err(MigrationContractError::MissingTemporalIntervalConstraint) + ); + + let complete = r" + CONSTRAINT document_record_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) + CONSTRAINT document_record_system_order CHECK (system_to IS NULL OR system_from <= system_to) + CONSTRAINT document_record_revision_positive CHECK (revision_number > 0) + CONSTRAINT event_instance_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) + CONSTRAINT event_instance_system_order CHECK (system_to IS NULL OR system_from <= system_to) + CONSTRAINT membership_assignment_valid_order CHECK (valid_to IS NULL OR valid_from <= valid_to) + "; + assert_eq!(super::validate_temporal_interval_ordering(complete), Ok(())); + } + + #[test] + fn retention_legal_hold_contract_fails_closed() { + assert!(super::declares_retention_legal_hold( + "CREATE TABLE retention_policy (retention_policy_id uuid PRIMARY KEY)" + )); + assert!(super::declares_retention_legal_hold( + "CREATE TABLE legal_hold ()" + )); + assert!(super::declares_retention_legal_hold( + "CREATE TABLE evidence_tombstone ()" + )); + assert!(!super::declares_retention_legal_hold("CREATE TABLE x")); + + let missing_table = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE TABLE retention_policy ( + retention_policy_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + ", + "DROP TABLE tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&missing_table), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + + let tables_only = r" + CREATE TABLE retention_policy (x int); + CREATE TABLE legal_hold (x int); + CREATE TABLE deletion_request (x int); + CREATE TABLE evidence_tombstone (x int); + "; + assert_eq!( + super::validate_retention_legal_hold(tables_only), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let with_hold_fn = + format!("{tables_only} CREATE OR REPLACE FUNCTION reject_held_evidence_deletion()"); + assert_eq!( + super::validate_retention_legal_hold(&with_hold_fn), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let with_restore_fn = format!( + "{with_hold_fn} CREATE OR REPLACE FUNCTION reject_tombstoned_evidence_restore()" + ); + assert_eq!( + super::validate_retention_legal_hold(&with_restore_fn), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let with_hold_trigger = + format!("{with_restore_fn} CREATE TRIGGER deletion_request_reject_held_deletion"); + assert_eq!( + super::validate_retention_legal_hold(&with_hold_trigger), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let with_restore_trigger = + format!("{with_hold_trigger} CREATE TRIGGER document_record_reject_tombstone_restore"); + assert_eq!( + super::validate_retention_legal_hold(&with_restore_trigger), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let with_period = + format!("{with_restore_trigger} CONSTRAINT retention_policy_period_positive"); + assert_eq!( + super::validate_retention_legal_hold(&with_period), + Err(MigrationContractError::MissingRetentionLegalHold) + ); + let complete = format!("{with_period} CONSTRAINT legal_hold_document_scope_consistent"); + super::validate_retention_legal_hold(&complete).expect("complete 0007 contract"); + } + + #[test] + fn empty_and_malformed_sql_fail_closed() { + let empty = MigrationCatalog::from_sql(" ", "DROP TABLE x;"); + assert_eq!( + validate_migration_catalog(&empty), + Err(MigrationContractError::EmptyMigrationSql) + ); + assert_eq!( + MigrationCatalog::from_sources("", "DROP TABLE x_y;"), + Err(MigrationContractError::EmptyMigrationSql) + ); + assert_eq!( + MigrationCatalog::from_sources( + "CREATE TABLE tenant_record (tenant_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL);", + "", + ), + Err(MigrationContractError::EmptyMigrationSql) + ); + let empty_down = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + ", + " ", + ); + assert_eq!( + validate_migration_catalog(&empty_down), + Err(MigrationContractError::EmptyMigrationSql) + ); + let no_tables = MigrationCatalog::from_sql( + "-- comment only without table definitions", + "DROP TABLE IF EXISTS none_present;", + ); + assert_eq!( + validate_migration_catalog(&no_tables), + Err(MigrationContractError::EmptyMigrationSql) + ); + let if_not_exists = MigrationCatalog::from_sql( + r" + CREATE TABLE IF NOT EXISTS tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + ", + "DROP TABLE tenant_record;", + ); + validate_migration_catalog(&if_not_exists).expect("if not exists parse"); + let unclosed = MigrationCatalog::from_sql( + "CREATE TABLE broken_table (tenant_record_id uuid, system_time timestamptz", + "DROP TABLE broken_table;", + ); + assert_eq!( + validate_migration_catalog(&unclosed), + Err(MigrationContractError::EmptyMigrationSql) + ); + let nested = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL, + CONSTRAINT document_record_positive CHECK (revision_number > 0) + );", + "DROP TABLE document_record;", + ); + validate_migration_catalog(&nested).expect("nested parentheses"); + let trailing = MigrationCatalog::from_sql("CREATE TABLE ", "DROP TABLE none_present;"); + assert_eq!( + validate_migration_catalog(&trailing), + Err(MigrationContractError::EmptyMigrationSql) + ); + } +} diff --git a/crates/persistence_postgres/src/migration_rls_table_state.rs b/crates/persistence_postgres/src/migration_rls_table_state.rs new file mode 100644 index 000000000..2bc09f611 --- /dev/null +++ b/crates/persistence_postgres/src/migration_rls_table_state.rs @@ -0,0 +1,196 @@ +//! Final PostgreSQL row-level-security table-state projection. +//! +//! The caller supplies SQL after lexical normalization and committed-statement +//! projection. This module owns only the order-sensitive `ALTER TABLE` state +//! needed to prevent historical `ENABLE`/`FORCE` statements from certifying a +//! table whose durable state was later changed to `DISABLE` or `NO FORCE`. + +use std::collections::BTreeMap; + +/// Final RLS enablement flags tracked for one normalized relation identity. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct RlsTableState { + enabled: bool, + forced: bool, +} + +/// Require every table touched by an RLS table-state action to finish enabled and forced. +/// +/// Structural validation separately proves that every created table in an RLS +/// migration has the required actions. This projection adds the missing temporal +/// property: later committed actions override earlier ones. Unsupported target +/// grammar on a statement that contains an RLS state action fails closed rather +/// than donating ambiguous final-state evidence. +#[must_use] +pub(super) fn final_rls_table_states_are_safe(sql: &str) -> bool { + let tokenizable = sql.replace(';', " ; ").replace(',', " , "); + let tokens = tokenizable.split_whitespace().collect::>(); + let mut states = BTreeMap::::new(); + let mut index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + let statement = &tokens[index..end]; + index = end.saturating_add(1); + + if statement.is_empty() || !is_alter_table(statement) { + continue; + } + let actions = rls_actions(statement); + if actions.is_empty() { + continue; + } + let Some(table) = direct_table_target(statement) else { + return false; + }; + let state = states.entry(table.to_ascii_lowercase()).or_default(); + for action in actions { + match action { + RlsTableAction::Enable => state.enabled = true, + RlsTableAction::Disable => state.enabled = false, + RlsTableAction::Force => state.forced = true, + RlsTableAction::NoForce => state.forced = false, + } + } + } + + states + .values() + .all(|state| state.enabled && state.forced) +} + +/// Find one semicolon-delimited normalized statement boundary. +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Return whether a normalized statement begins with direct `ALTER TABLE` syntax. +fn is_alter_table(statement: &[&str]) -> bool { + statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("ALTER")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TABLE")) +} + +/// Extract the direct unqualified table target owned by the existing structural validator. +/// +/// `ONLY`, `IF EXISTS`, schema qualification, and quoted-identity sentinels remain +/// outside this bounded grammar. PostgreSQL may separate a schema-qualification +/// period with whitespace, so both a period inside the target token and a period +/// beginning the following token are rejected. Otherwise two qualified sibling +/// relations could be collapsed into the schema name and overwrite each other's +/// final RLS state. If unsupported target grammar carries an RLS action, the +/// caller fails closed rather than guessing which durable relation changed. +fn direct_table_target<'a>(statement: &'a [&'a str]) -> Option<&'a str> { + let table = *statement.get(2)?; + let next_begins_qualification = statement + .get(3) + .is_some_and(|token| token.starts_with('.')); + if table.eq_ignore_ascii_case("ONLY") + || table.eq_ignore_ascii_case("IF") + || table.contains('.') + || next_begins_qualification + || table == "," + || !table + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii()) + { + return None; + } + Some(table) +} + +/// One PostgreSQL table-level RLS state mutation in execution order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RlsTableAction { + Enable, + Disable, + Force, + NoForce, +} + +/// Parse order-sensitive RLS state actions from one normalized `ALTER TABLE` statement. +fn rls_actions(statement: &[&str]) -> Vec { + let mut actions = Vec::new(); + let mut index = 3usize; + while index < statement.len() { + if keyword_sequence(statement, index, &["ENABLE", "ROW", "LEVEL", "SECURITY"]) { + actions.push(RlsTableAction::Enable); + index += 4; + continue; + } + if keyword_sequence(statement, index, &["DISABLE", "ROW", "LEVEL", "SECURITY"]) { + actions.push(RlsTableAction::Disable); + index += 4; + continue; + } + if keyword_sequence(statement, index, &["NO", "FORCE", "ROW", "LEVEL", "SECURITY"]) { + actions.push(RlsTableAction::NoForce); + index += 5; + continue; + } + if keyword_sequence(statement, index, &["FORCE", "ROW", "LEVEL", "SECURITY"]) { + actions.push(RlsTableAction::Force); + index += 4; + continue; + } + index += 1; + } + actions +} + +/// Match one case-insensitive SQL keyword sequence without reinterpreting identifiers. +fn keyword_sequence(statement: &[&str], start: usize, expected: &[&str]) -> bool { + statement + .get(start..start.saturating_add(expected.len())) + .is_some_and(|actual| { + actual + .iter() + .zip(expected) + .all(|(token, keyword)| token.eq_ignore_ascii_case(keyword)) + }) +} + +#[cfg(test)] +mod tests { + use super::final_rls_table_states_are_safe; + + #[test] + fn trailing_disable_or_no_force_overrides_historical_positive_state() { + for sql in [ + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY;", + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record NO FORCE ROW LEVEL SECURITY;", + ] { + assert!(!final_rls_table_states_are_safe(sql)); + } + } + + #[test] + fn later_enable_and_force_restore_final_state() { + assert!(final_rls_table_states_are_safe( + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record NO FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY;" + )); + } + + #[test] + fn sibling_table_state_does_not_overwrite_another_table() { + assert!(!final_rls_table_states_are_safe( + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record_shadow ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_record_shadow FORCE ROW LEVEL SECURITY; ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY;" + )); + } + + #[test] + fn schema_qualified_targets_fail_closed_instead_of_aliasing_the_schema() { + for sql in [ + "ALTER TABLE public . tenant_record DISABLE ROW LEVEL SECURITY; ALTER TABLE public . event_instance ENABLE ROW LEVEL SECURITY; ALTER TABLE public . event_instance FORCE ROW LEVEL SECURITY;", + "ALTER TABLE public .tenant_record DISABLE ROW LEVEL SECURITY; ALTER TABLE public .event_instance ENABLE ROW LEVEL SECURITY; ALTER TABLE public .event_instance FORCE ROW LEVEL SECURITY;", + ] { + assert!(!final_rls_table_states_are_safe(sql)); + } + } +} diff --git a/crates/persistence_postgres/src/migration_runtime_role_executor.rs b/crates/persistence_postgres/src/migration_runtime_role_executor.rs new file mode 100644 index 000000000..9ff923614 --- /dev/null +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -0,0 +1,649 @@ +//! Execution-state projection for PostgreSQL pseudo-grantor role specifications. +//! +//! The shared lexical authority has already removed comments/literals from +//! structural consideration before this boundary runs. This module therefore +//! does not lex SQL again: it tracks normalized current/session authorization, +//! transaction-local overrides, and rewrites executor-relative `GRANTED BY` +//! pseudo-targets to validation-only provenance identities. + +const EXECUTOR_GRANTOR_PREFIX: &str = "__tepp_executor_grantor_"; +const INVALID_QUOTED_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; + +/// PostgreSQL role identity used by one executor-authority slot. +/// +/// The connection-time current-role setting and originally authenticated user +/// are distinct because `RESET ROLE` and `RESET SESSION AUTHORIZATION` restore +/// different PostgreSQL concepts. Named roles are exact normalized identities. +/// Unknown targets are scoped to the statement that introduced them so later +/// independently unknown authorization changes cannot alias each other. +#[derive(Clone, Debug, Eq, PartialEq)] +enum EffectiveRoleProjection { + InitialCurrentUser, + AuthenticatedUser, + Named(String), + Unknown(usize), +} + +impl EffectiveRoleProjection { + /// Produce a validation-only token that cannot collide with valid unquoted SQL identifiers. + /// + /// Known role names remain their normalized PostgreSQL identity so a later + /// named `GRANTED BY` can address the same membership row. Connection- and + /// uncertainty-relative identities use an `@` terminator, which PostgreSQL + /// cannot emit as part of an unquoted identifier; quoted names already use + /// the shared hex sentinel and therefore cannot alias these tokens. + fn provenance_token(&self) -> String { + match self { + Self::InitialCurrentUser => { + format!("{EXECUTOR_GRANTOR_PREFIX}initial_current_user@") + } + Self::AuthenticatedUser => { + format!("{EXECUTOR_GRANTOR_PREFIX}session_user@") + } + Self::Named(role_name) => role_name.clone(), + Self::Unknown(statement_index) => { + format!("{EXECUTOR_GRANTOR_PREFIX}unknown_{statement_index}@") + } + } + } +} + +/// Session-level `role` setting used to derive PostgreSQL `CURRENT_USER`. +/// +/// `SET ROLE NONE` follows the current session user rather than freezing its +/// identity at the time of the command. `RESET ROLE` is kept as a separate +/// connection-default state because the startup `role` setting is outside this +/// bounded migration validator. +#[derive(Clone, Debug, Eq, PartialEq)] +enum CurrentRoleSetting { + ConnectionDefault, + FollowSessionUser, + Explicit(EffectiveRoleProjection), +} + +impl CurrentRoleSetting { + /// Resolve the effective current-user identity against one session-user projection. + fn resolve(&self, session_role: &EffectiveRoleProjection) -> EffectiveRoleProjection { + match self { + Self::ConnectionDefault => EffectiveRoleProjection::InitialCurrentUser, + Self::FollowSessionUser => session_role.clone(), + Self::Explicit(role) => role.clone(), + } + } +} + +/// Session-persistent executor settings captured at transaction entry. +/// +/// Ordinary `SET` changes inside a transaction survive COMMIT but disappear on +/// ROLLBACK. The snapshot therefore covers only session-persistent settings; +/// `SET LOCAL` overlays are discarded at every transaction end. +#[derive(Clone, Debug, Eq, PartialEq)] +struct SessionSettingsSnapshot { + session_role: EffectiveRoleProjection, + current_setting: CurrentRoleSetting, +} + +/// Current/session authorization state used for grantor provenance projection. +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExecutorRoleState { + session_role: EffectiveRoleProjection, + current_setting: CurrentRoleSetting, + local_session_role: Option, + local_current_setting: Option, + transaction_baseline: Option, + savepoint_uncertain: bool, +} + +impl ExecutorRoleState { + /// Start with separate opaque identities for startup current role and authenticated user. + fn initial() -> Self { + Self { + session_role: EffectiveRoleProjection::AuthenticatedUser, + current_setting: CurrentRoleSetting::ConnectionDefault, + local_session_role: None, + local_current_setting: None, + transaction_baseline: None, + savepoint_uncertain: false, + } + } + + /// Return the session-user identity visible to the current statement. + fn active_session_role(&self) -> EffectiveRoleProjection { + self.local_session_role + .clone() + .unwrap_or_else(|| self.session_role.clone()) + } + + /// Return the current-user identity after applying a transaction-local role overlay. + fn active_current_role(&self) -> EffectiveRoleProjection { + let session_role = self.active_session_role(); + self.local_current_setting + .as_ref() + .unwrap_or(&self.current_setting) + .resolve(&session_role) + } + + /// Enter an explicit transaction without replacing an existing transaction baseline. + fn begin_transaction(&mut self) { + if self.transaction_baseline.is_none() { + self.transaction_baseline = Some(SessionSettingsSnapshot { + session_role: self.session_role.clone(), + current_setting: self.current_setting.clone(), + }); + self.local_session_role = None; + self.local_current_setting = None; + self.savepoint_uncertain = false; + } + } + + /// Commit session settings while discarding transaction-local authorization overlays. + fn commit_transaction(&mut self, and_chain: bool) { + self.local_session_role = None; + self.local_current_setting = None; + self.transaction_baseline = None; + self.savepoint_uncertain = false; + if and_chain { + self.begin_transaction(); + } + } + + /// Restore transaction-entry session settings and discard local authorization overlays. + fn rollback_transaction(&mut self, and_chain: bool) { + if let Some(snapshot) = self.transaction_baseline.take() { + self.session_role = snapshot.session_role; + self.current_setting = snapshot.current_setting; + } + self.local_session_role = None; + self.local_current_setting = None; + self.savepoint_uncertain = false; + if and_chain { + self.begin_transaction(); + } + } + + /// Apply a session-authorization target using PostgreSQL SESSION/LOCAL scope. + fn set_session_authorization( + &mut self, + projection: EffectiveRoleProjection, + local: bool, + ) { + if local { + if self.transaction_baseline.is_some() { + self.local_session_role = Some(projection); + self.local_current_setting = Some(CurrentRoleSetting::FollowSessionUser); + } + return; + } + + self.session_role = projection; + self.current_setting = CurrentRoleSetting::FollowSessionUser; + self.local_session_role = None; + self.local_current_setting = None; + } + + /// Apply one `SET ROLE` target while preserving PostgreSQL LOCAL transaction scope. + fn set_role(&mut self, setting: CurrentRoleSetting, local: bool) { + if local { + if self.transaction_baseline.is_some() { + self.local_current_setting = Some(setting); + } + return; + } + + self.current_setting = setting; + self.local_current_setting = None; + } +} + +/// Project executor-relative grantors onto stable validation-only identities. +/// +/// PostgreSQL records the role denoted by `GRANTED BY`, not the literal text of +/// `CURRENT_USER`, `CURRENT_ROLE`, or `SESSION_USER`. `SET ROLE` can change the +/// effective current role; `SET SESSION AUTHORIZATION` can change both session +/// and current identities; `SET LOCAL` overlays disappear at transaction end. +/// Savepoint control is deliberately not modeled as a partial transaction stack: +/// once encountered, pseudo-target uses receive statement-local opaque identities +/// until transaction end so an uncertain rollback path cannot donate false revoke +/// evidence. Executable migration SQL is unchanged. +pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { + if sql.contains(EXECUTOR_GRANTOR_PREFIX) { + return None; + } + + let tokenized = sql.replace(';', " ; ").replace(',', " , "); + let tokens = tokenized.split_whitespace().collect::>(); + let mut output = Vec::::with_capacity(tokens.len()); + let mut state = ExecutorRoleState::initial(); + let mut statement_index = 0usize; + let mut index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + let mut statement = tokens[index..end] + .iter() + .map(|token| (*token).to_owned()) + .collect::>(); + + update_executor_role_state(&statement, statement_index, &mut state); + rewrite_granted_by_pseudo_target(&mut statement, statement_index, &state); + output.extend(statement); + if end < tokens.len() { + output.push(";".to_owned()); + } + + index = end.saturating_add(1); + statement_index = statement_index.saturating_add(1); + } + + Some(output.join(" ")) +} + +/// Find the end of one already-normalized semicolon-delimited statement. +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Update PostgreSQL executor state for one normalized top-level statement. +fn update_executor_role_state( + statement: &[String], + statement_index: usize, + state: &mut ExecutorRoleState, +) { + if is_transaction_start(statement) { + state.begin_transaction(); + return; + } + + if is_savepoint_control(statement) { + if state.transaction_baseline.is_some() { + state.savepoint_uncertain = true; + } + return; + } + + if let Some((commit, and_chain)) = transaction_end(statement) { + if commit { + state.commit_transaction(and_chain); + } else { + state.rollback_transaction(and_chain); + } + return; + } + + if is_reset_session_authorization(statement) { + state.set_session_authorization(EffectiveRoleProjection::AuthenticatedUser, false); + return; + } + + if let Some((target_index, local)) = session_authorization_target(statement) { + let Some(target) = statement.get(target_index) else { + return; + }; + let projection = if target.eq_ignore_ascii_case("DEFAULT") { + EffectiveRoleProjection::AuthenticatedUser + } else { + target_role_projection(target, statement_index) + }; + state.set_session_authorization(projection, local); + return; + } + + if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + state.set_role(CurrentRoleSetting::ConnectionDefault, false); + return; + } + + let Some((target_index, local)) = role_target(statement) else { + return; + }; + let Some(target) = statement.get(target_index) else { + return; + }; + let setting = if target.eq_ignore_ascii_case("NONE") { + CurrentRoleSetting::FollowSessionUser + } else { + CurrentRoleSetting::Explicit(target_role_projection(target, statement_index)) + }; + state.set_role(setting, local); +} + +/// Return whether the statement starts an explicit PostgreSQL transaction block. +fn is_transaction_start(statement: &[String]) -> bool { + statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("BEGIN")) + || (statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("START")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TRANSACTION"))) +} + +/// Mark savepoint-sensitive state as uncertain rather than pretending to model a stack. +fn is_savepoint_control(statement: &[String]) -> bool { + statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SAVEPOINT")) + || statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("RELEASE")) + || (statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("ROLLBACK")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TO"))) +} + +/// Return transaction-end kind and whether PostgreSQL immediately chains a new transaction. +fn transaction_end(statement: &[String]) -> Option<(bool, bool)> { + let first = statement.first()?; + let commit = if first.eq_ignore_ascii_case("COMMIT") { + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("PREPARED")) + { + return None; + } + true + } else if first.eq_ignore_ascii_case("END") { + true + } else if first.eq_ignore_ascii_case("ROLLBACK") || first.eq_ignore_ascii_case("ABORT") { + if statement.get(1).is_some_and(|token| { + token.eq_ignore_ascii_case("TO") || token.eq_ignore_ascii_case("PREPARED") + }) { + return None; + } + false + } else { + return None; + }; + + let and_chain = statement + .windows(2) + .any(|pair| pair[0].eq_ignore_ascii_case("AND") && pair[1].eq_ignore_ascii_case("CHAIN")); + Some((commit, and_chain)) +} + +/// Return target index and LOCAL scope for PostgreSQL session-authorization syntax. +fn session_authorization_target(statement: &[String]) -> Option<(usize, bool)> { + if !statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return None; + } + + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("AUTHORIZATION")) + { + return Some((3, false)); + } + + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("LOCAL")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("AUTHORIZATION")) + { + return Some((4, true)); + } + + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("AUTHORIZATION")) + { + return Some((4, false)); + } + + None +} + +/// Recognize PostgreSQL `RESET SESSION AUTHORIZATION` without broad RESET parsing. +fn is_reset_session_authorization(statement: &[String]) -> bool { + statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("AUTHORIZATION")) +} + +/// Return target index and LOCAL scope for PostgreSQL `SET [SESSION|LOCAL] ROLE`. +fn role_target(statement: &[String]) -> Option<(usize, bool)> { + if !statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return None; + } + + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + return Some((2, false)); + } + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("LOCAL")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + return Some((3, true)); + } + if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SESSION")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + return Some((3, false)); + } + None +} + +/// Project one normalized authorization target without interpreting raw SQL again. +fn target_role_projection(target: &str, statement_index: usize) -> EffectiveRoleProjection { + if role_target_is_statically_named(target) { + EffectiveRoleProjection::Named(target.to_ascii_lowercase()) + } else { + EffectiveRoleProjection::Unknown(statement_index) + } +} + +/// Return whether the normalized role target still carries a plain role identity. +fn role_target_is_statically_named(target: &str) -> bool { + target != INVALID_QUOTED_IDENTIFIER + && !target.starts_with('\'') + && !target.contains('@') + && !target.starts_with("__tepp_quoted_grantor_identity_") +} + +/// Replace pseudo-target spellings only in an explicit trailing `GRANTED BY` slot. +/// +/// Savepoint-sensitive state receives a statement-local identity because this +/// bounded authority intentionally does not guess which earlier SET operation a +/// later `ROLLBACK TO` preserved. That can reject an otherwise safe migration, +/// but it cannot turn uncertain provenance into false revocation evidence. +fn rewrite_granted_by_pseudo_target( + statement: &mut [String], + statement_index: usize, + state: &ExecutorRoleState, +) { + let mut index = 0usize; + while index + 2 < statement.len() { + if statement[index].eq_ignore_ascii_case("GRANTED") + && statement[index + 1].eq_ignore_ascii_case("BY") + { + let replacement = if statement[index + 2].eq_ignore_ascii_case("CURRENT_USER") + || statement[index + 2].eq_ignore_ascii_case("CURRENT_ROLE") + { + if state.savepoint_uncertain { + Some(EffectiveRoleProjection::Unknown(statement_index).provenance_token()) + } else { + Some(state.active_current_role().provenance_token()) + } + } else if statement[index + 2].eq_ignore_ascii_case("SESSION_USER") { + if state.savepoint_uncertain { + Some(EffectiveRoleProjection::Unknown(statement_index).provenance_token()) + } else { + Some(state.active_session_role().provenance_token()) + } + } else { + None + }; + if let Some(replacement) = replacement { + statement[index + 2] = replacement; + } + return; + } + index += 1; + } +} + +#[cfg(test)] +mod tests { + use super::project_executor_relative_grantors; + + #[test] + fn current_user_follows_set_role_while_session_user_stays_authenticated() { + let projected = project_executor_relative_grantors( + "SET ROLE grantor_a; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER; SET ROLE grantor_b; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_ROLE; SET ROLE NONE; GRANT reporting_owner TO tepp_app_runtime GRANTED BY SESSION_USER;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY grantor_a")); + assert!(projected.contains("GRANTED BY grantor_b")); + assert!(projected.contains("GRANTED BY __tepp_executor_grantor_session_user@")); + } + + #[test] + fn session_authorization_changes_session_and_current_grantor_identity() { + let projected = project_executor_relative_grantors( + "SET SESSION AUTHORIZATION grantor_a; GRANT reporting_owner TO tepp_app_runtime GRANTED BY SESSION_USER; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER; SET SESSION AUTHORIZATION grantor_b; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_ROLE;", + ) + .expect("normalized SQL must project"); + + assert_eq!(projected.matches("GRANTED BY grantor_a").count(), 2); + assert_eq!(projected.matches("GRANTED BY grantor_b").count(), 2); + } + + #[test] + fn set_role_none_uses_the_current_session_authorization() { + let projected = project_executor_relative_grantors( + "SET SESSION AUTHORIZATION grantor_a; SET ROLE grantor_b; SET ROLE NONE; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY grantor_a")); + } + + #[test] + fn reset_session_authorization_restores_authenticated_identity() { + let projected = project_executor_relative_grantors( + "SET SESSION AUTHORIZATION grantor_a; RESET SESSION AUTHORIZATION; GRANT reporting_owner TO tepp_app_runtime GRANTED BY SESSION_USER; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER;", + ) + .expect("normalized SQL must project"); + + assert_eq!( + projected + .matches("GRANTED BY __tepp_executor_grantor_session_user@") + .count(), + 2 + ); + } + + #[test] + fn local_role_is_discarded_at_commit() { + let projected = project_executor_relative_grantors( + "SET ROLE grantor_a; BEGIN; SET LOCAL ROLE grantor_b; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER; COMMIT; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY grantor_b")); + assert!(projected.contains("GRANTED BY grantor_a")); + } + + #[test] + fn and_no_chain_ends_transaction_before_later_local_role() { + let projected = project_executor_relative_grantors( + "SET ROLE grantor_a; BEGIN; COMMIT AND NO CHAIN; SET LOCAL ROLE grantor_b; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER;", + ) + .expect("NO CHAIN must leave explicit transaction scope"); + + assert!(projected.contains("GRANTED BY grantor_a")); + assert!(!projected.contains("GRANTED BY grantor_b")); + } + + #[test] + fn local_session_authorization_is_discarded_at_rollback() { + let projected = project_executor_relative_grantors( + "SET SESSION AUTHORIZATION grantor_a; BEGIN; SET LOCAL SESSION AUTHORIZATION grantor_b; GRANT reporting_owner TO tepp_app_runtime GRANTED BY SESSION_USER; ROLLBACK; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY grantor_b")); + assert!(projected.contains("GRANTED BY grantor_a")); + } + + #[test] + fn regular_transaction_setting_rolls_back_to_entry_state() { + let projected = project_executor_relative_grantors( + "SET ROLE grantor_a; BEGIN; SET ROLE grantor_b; ROLLBACK; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY grantor_a")); + } + + #[test] + fn savepoint_control_uses_statement_local_opaque_grantors_until_transaction_end() { + let projected = project_executor_relative_grantors( + "BEGIN; SAVEPOINT before_role; SET ROLE grantor_a; GRANT reporting_owner TO tepp_app_runtime GRANTED BY CURRENT_USER; ROLLBACK TO before_role; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; COMMIT;", + ) + .expect("normalized SQL must project"); + + assert!(projected.contains("GRANTED BY __tepp_executor_grantor_unknown_3@")); + assert!(projected.contains("GRANTED BY __tepp_executor_grantor_unknown_5@")); + } + + #[test] + fn reserved_projection_prefix_fails_closed() { + assert!( + project_executor_relative_grantors( + "GRANT reporting_owner TO tepp_app_runtime GRANTED BY __tepp_executor_grantor_session_user@;" + ) + .is_none() + ); + } +} diff --git a/crates/persistence_postgres/src/migration_runtime_role_grantor.rs b/crates/persistence_postgres/src/migration_runtime_role_grantor.rs new file mode 100644 index 000000000..c41e31b31 --- /dev/null +++ b/crates/persistence_postgres/src/migration_runtime_role_grantor.rs @@ -0,0 +1,352 @@ +//! Grantor-aware safety evidence for PostgreSQL runtime-role memberships. +//! +//! `pg_auth_members` records one row per role/member/grantor relationship. The +//! aggregate membership validator intentionally models the effective runtime +//! edge, while this companion boundary preserves explicit `GRANTED BY` +//! provenance so revoking one grantor cannot erase a still-dangerous grant from +//! another grantor. Statements without explicit grantor provenance never erase +//! explicit rows here; the bounded validator cannot prove which recorded grant +//! an implicit executor would revoke. + +use std::collections::BTreeMap; + +const RUNTIME_ROLE: &str = "tepp_app_runtime"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct GrantorMembershipState { + set_enabled: bool, + admin_enabled: bool, + inherit_enabled: bool, +} + +impl GrantorMembershipState { + /// Conservative PostgreSQL defaults for a newly observed membership row. + /// + /// SET defaults true and ADMIN false. INHERIT on a new membership depends + /// on the member role's inheritance attribute; this bounded authority does + /// not independently prove runtime NOINHERIT, so omitted INHERIT remains + /// unsafe until explicit false evidence appears. + const fn new() -> Self { + Self { + set_enabled: true, + admin_enabled: false, + inherit_enabled: true, + } + } + + const fn can_escape_runtime_identity(self) -> bool { + self.set_enabled || self.admin_enabled || self.inherit_enabled + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ExplicitMembershipOptions { + set_enabled: Option, + admin_enabled: Option, + inherit_enabled: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RevokedMembershipOption { + Set, + Admin, + Inherit, +} + +/// Return whether every explicitly grantor-attributed runtime membership is safe. +/// +/// PostgreSQL can retain more than one membership row for the same granted role +/// and member when grantors differ. Each explicit grantor path is therefore +/// tracked independently. An implicit REVOKE is deliberately not allowed to +/// delete explicit rows because executor/grantor selection is outside this +/// static migration boundary; that uncertainty must fail closed rather than +/// donate false RLS-safety evidence. +pub(super) fn runtime_membership_grantors_are_rls_safe(sql: &str) -> bool { + let tokenized = sql.replace(';', " ; ").replace(',', " , "); + let tokens = tokenized.split_whitespace().collect::>(); + let mut grants = BTreeMap::<(String, String), GrantorMembershipState>::new(); + let mut index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + let statement = &tokens[index..end]; + if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("GRANT")) + { + apply_explicit_grant(statement, &mut grants); + } else if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("REVOKE")) + { + apply_explicit_revoke(statement, &mut grants); + } + index = end.saturating_add(1); + } + + grants + .values() + .all(|state| !state.can_escape_runtime_identity()) +} + +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Locate the membership `TO`/`FROM` delimiter after a complete role list. +fn membership_delimiter( + statement: &[&str], + roles_start: usize, + delimiter_keyword: &str, +) -> Option { + let mut index = roles_start; + let mut expects_role = true; + let mut saw_role = false; + + while let Some(token) = statement.get(index) { + if expects_role { + if *token == "," { + return None; + } + saw_role = true; + expects_role = false; + } else if *token == "," { + expects_role = true; + } else if token.eq_ignore_ascii_case(delimiter_keyword) { + return saw_role.then_some(index); + } else { + return None; + } + index += 1; + } + None +} + +/// Parse the complete grantee list and return whether it contains the runtime. +fn runtime_grantee_list(statement: &[&str], delimiter: usize) -> Option<(bool, usize)> { + let mut index = delimiter + 1; + let mut expects_role = true; + let mut saw_role = false; + let mut runtime_is_grantee = false; + + while let Some(token) = statement.get(index) { + if expects_role { + if *token == "," { + return None; + } + saw_role = true; + runtime_is_grantee |= token.eq_ignore_ascii_case(RUNTIME_ROLE); + expects_role = false; + index += 1; + continue; + } + if *token == "," { + expects_role = true; + index += 1; + continue; + } + break; + } + + if !saw_role || expects_role { + None + } else { + Some((runtime_is_grantee, index)) + } +} + +fn membership_role_names(tokens: &[&str]) -> Vec { + tokens + .iter() + .filter(|token| **token != ",") + .map(|token| token.to_ascii_lowercase()) + .collect() +} + +/// Return the explicit grantor following a trailing `GRANTED BY` clause. +/// +/// The scan starts only after the complete grantee list, so quoted grantee names +/// projected to keyword-looking spellings cannot impersonate this clause. +fn explicit_grantor(statement: &[&str], trailing_start: usize) -> Option { + statement[trailing_start..] + .windows(3) + .find(|window| { + window[0].eq_ignore_ascii_case("GRANTED") + && window[1].eq_ignore_ascii_case("BY") + }) + .map(|window| window[2].to_ascii_lowercase()) +} + +fn explicit_membership_options( + statement: &[&str], + trailing_start: usize, +) -> ExplicitMembershipOptions { + let Some(with_index) = statement[trailing_start..] + .iter() + .position(|token| token.eq_ignore_ascii_case("WITH")) + .map(|relative| trailing_start + relative) + else { + return ExplicitMembershipOptions::default(); + }; + + let mut options = ExplicitMembershipOptions::default(); + let mut index = with_index + 1; + while index < statement.len() { + if statement[index].eq_ignore_ascii_case("GRANTED") { + break; + } + let value = statement + .get(index + 1) + .map(|token| !token.eq_ignore_ascii_case("FALSE")) + .unwrap_or(true); + if statement[index].eq_ignore_ascii_case("SET") { + options.set_enabled = Some(value); + index += 2; + continue; + } + if statement[index].eq_ignore_ascii_case("ADMIN") { + options.admin_enabled = Some(value); + index += 2; + continue; + } + if statement[index].eq_ignore_ascii_case("INHERIT") { + options.inherit_enabled = Some(value); + index += 2; + continue; + } + index += 1; + } + options +} + +fn apply_explicit_grant( + statement: &[&str], + grants: &mut BTreeMap<(String, String), GrantorMembershipState>, +) { + let Some(to_index) = membership_delimiter(statement, 1, "TO") else { + return; + }; + let Some((targets_runtime, trailing_start)) = runtime_grantee_list(statement, to_index) else { + return; + }; + if !targets_runtime { + return; + } + let Some(grantor) = explicit_grantor(statement, trailing_start) else { + return; + }; + let options = explicit_membership_options(statement, trailing_start); + + for role in membership_role_names(&statement[1..to_index]) { + let state = grants + .entry((role, grantor.clone())) + .or_insert_with(GrantorMembershipState::new); + if let Some(set_enabled) = options.set_enabled { + state.set_enabled = set_enabled; + } + if let Some(admin_enabled) = options.admin_enabled { + state.admin_enabled = admin_enabled; + } + if let Some(inherit_enabled) = options.inherit_enabled { + state.inherit_enabled = inherit_enabled; + } + } +} + +fn apply_explicit_revoke( + statement: &[&str], + grants: &mut BTreeMap<(String, String), GrantorMembershipState>, +) { + let (roles_start, revoked_option) = if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Set)) + } else if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("ADMIN")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Admin)) + } else if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("INHERIT")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Inherit)) + } else { + (1usize, None) + }; + + let Some(from_index) = membership_delimiter(statement, roles_start, "FROM") else { + return; + }; + let Some((targets_runtime, trailing_start)) = runtime_grantee_list(statement, from_index) else { + return; + }; + if !targets_runtime { + return; + } + let Some(grantor) = explicit_grantor(statement, trailing_start) else { + // The static boundary cannot prove which explicit grantor row an + // executor-relative REVOKE would select, so explicit rows stay intact. + return; + }; + + for role in membership_role_names(&statement[roles_start..from_index]) { + let key = (role, grantor.clone()); + match revoked_option { + Some(RevokedMembershipOption::Set) => { + if let Some(state) = grants.get_mut(&key) { + state.set_enabled = false; + } + } + Some(RevokedMembershipOption::Admin) => { + if let Some(state) = grants.get_mut(&key) { + state.admin_enabled = false; + } + } + Some(RevokedMembershipOption::Inherit) => { + if let Some(state) = grants.get_mut(&key) { + state.inherit_enabled = false; + } + } + None => { + grants.remove(&key); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::runtime_membership_grantors_are_rls_safe; + + #[test] + fn distinct_grantors_remain_independent_until_each_path_is_safe_or_revoked() { + let unsafe_path_remains = "GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE , SET TRUE , ADMIN FALSE GRANTED BY grantor_a ; GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN FALSE GRANTED BY grantor_b ; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_b ;"; + assert!(!runtime_membership_grantors_are_rls_safe(unsafe_path_remains)); + + let both_paths_removed = "GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE , SET TRUE , ADMIN FALSE GRANTED BY grantor_a ; GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN FALSE GRANTED BY grantor_b ; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_b ; REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_a ;"; + assert!(runtime_membership_grantors_are_rls_safe(both_paths_removed)); + } +} diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs new file mode 100644 index 000000000..fd0651ba3 --- /dev/null +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -0,0 +1,478 @@ +//! Fail-closed PostgreSQL role-membership boundary for the application runtime. +//! +//! Direct role attributes and lifecycle remain owned by `migration_validation`. +//! This module owns the complementary membership invariant: an RLS-protected +//! application runtime must not be able to become another role with `SET ROLE`, +//! hold `ADMIN` on that role, or inherit privileges from a role whose SQL-object +//! ownership is not proven safe by this bounded validator. + +use std::collections::BTreeMap; + +const RUNTIME_ROLE: &str = "tepp_app_runtime"; +const CREATE_IN_ROLE_SENTINEL: &str = "__create_in_role_membership__"; +const MALFORMED_GRANTEE_SENTINEL: &str = "__malformed_membership_grantee_list__"; + +/// Security-relevant options for one runtime membership edge. +/// +/// `SET` is the direct `SET ROLE` capability. `ADMIN` is equally security +/// relevant because PostgreSQL allows an ADMIN member to grant the role back +/// to itself with a different SET value. `INHERIT` is also security relevant: +/// PostgreSQL warns that a member which inherits a role but cannot SET ROLE may +/// still gain full access by manipulating SQL objects owned by that role. TEPP's +/// bounded migration validator has no global ownership proof, so all three +/// options must be false before a runtime membership is certified RLS-safe. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct MembershipSecurityState { + set_enabled: bool, + admin_enabled: bool, + inherit_enabled: bool, +} + +impl MembershipSecurityState { + /// Fail-closed defaults for a newly created runtime membership. + /// + /// PostgreSQL defaults SET to true and ADMIN to false. Omitted INHERIT uses + /// the member role's role-level inheritance attribute; PostgreSQL roles are + /// INHERIT by default, and this membership authority does not prove a + /// runtime-level NOINHERIT state, so missing INHERIT evidence remains true. + const fn new() -> Self { + Self { + set_enabled: true, + admin_enabled: false, + inherit_enabled: true, + } + } + + /// Return whether the membership can cross the bounded runtime RLS identity boundary. + const fn can_escape_runtime_identity(self) -> bool { + self.set_enabled || self.admin_enabled || self.inherit_enabled + } +} + +/// Explicit security-relevant options supplied by one membership GRANT. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ExplicitMembershipOptions { + set_enabled: Option, + admin_enabled: Option, + inherit_enabled: Option, +} + +/// Return whether the normalized migration's final runtime memberships are RLS-safe. +/// +/// Object-privilege grants/revokes fall outside the bounded membership grammar +/// because their privilege/object tokens do not form a comma-separated role +/// list before `TO`/`FROM`. New memberships conservatively begin as +/// `SET TRUE, ADMIN FALSE, INHERIT TRUE`; later GRANTs retain omitted options. +/// SET, ADMIN, and INHERIT must all be false in the final state. PostgreSQL +/// documents that ADMIN can manufacture a SET path and that INHERIT without SET +/// can still expose an owning role through manipulation of its existing SQL +/// objects. `CREATE ROLE ... IN ROLE ...` and deprecated `IN GROUP` therefore +/// remain conservatively unsafe as well. +pub(super) fn runtime_membership_is_rls_safe(sql: &str) -> bool { + let tokenized = sql.replace(';', " ; ").replace(',', " , "); + let tokens = tokenized.split_whitespace().collect::>(); + let mut memberships = BTreeMap::::new(); + let mut index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + let statement = &tokens[index..end]; + if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("GRANT")) + { + apply_runtime_membership_grant(statement, &mut memberships); + } else if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("REVOKE")) + { + apply_runtime_membership_revoke(statement, &mut memberships); + } else if create_runtime_role_in_role(statement) { + memberships.insert( + CREATE_IN_ROLE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); + } + index = end.saturating_add(1); + } + + memberships + .values() + .all(|state| !state.can_escape_runtime_identity()) +} + +/// Return the exclusive end of the semicolon-delimited statement containing `start`. +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Locate `TO`/`FROM` only after a complete comma-separated membership role list. +/// +/// The lexical boundary intentionally projects identity-equivalent lowercase +/// quoted identifiers onto bare tokens, so a role literally named `"to"` or +/// `"from"` is indistinguishable by spelling alone. Position resolves that +/// ambiguity: while a role is expected the token is a role name; only after a +/// complete role and before another comma can the delimiter keyword terminate +/// the granted-role list. Object-privilege statements naturally return `None` +/// because `ON`/object syntax interrupts this membership list grammar. +fn membership_delimiter( + statement: &[&str], + roles_start: usize, + delimiter_keyword: &str, +) -> Option { + let mut index = roles_start; + let mut expects_role = true; + let mut saw_role = false; + + while let Some(token) = statement.get(index) { + if expects_role { + if *token == "," { + return None; + } + saw_role = true; + expects_role = false; + } else if *token == "," { + expects_role = true; + } else if token.eq_ignore_ascii_case(delimiter_keyword) { + return saw_role.then_some(index); + } else { + return None; + } + index += 1; + } + None +} + +/// Parse the comma-separated membership grantee list after `TO`/`FROM`. +/// +/// Returns whether the runtime is one of the grantees plus the first token after +/// the role list. The parser uses list position rather than keyword spelling so +/// valid quoted grantees such as `"with"`, `"granted"`, or `"cascade"` cannot +/// impersonate trailing clauses after lexical projection. Empty, leading-comma, +/// and trailing-comma lists return `None` so the membership boundary fails closed. +fn runtime_grantee_list(statement: &[&str], delimiter: usize) -> Option<(bool, usize)> { + let mut index = delimiter + 1; + let mut expects_role = true; + let mut saw_role = false; + let mut runtime_is_grantee = false; + + while let Some(token) = statement.get(index) { + if expects_role { + if *token == "," { + return None; + } + saw_role = true; + runtime_is_grantee |= token.eq_ignore_ascii_case(RUNTIME_ROLE); + expects_role = false; + index += 1; + continue; + } + if *token == "," { + expects_role = true; + index += 1; + continue; + } + break; + } + + if !saw_role || expects_role { + None + } else { + Some((runtime_is_grantee, index)) + } +} + +/// Apply one PostgreSQL role-membership GRANT that targets the application runtime. +/// +/// The granted-role and grantee lists are parsed positionally before optional +/// clauses are inspected. New memberships use fail-closed PostgreSQL defaults; +/// later GRANTs update only explicitly supplied security options. +fn apply_runtime_membership_grant( + statement: &[&str], + memberships: &mut BTreeMap, +) { + let Some(to_index) = membership_delimiter(statement, 1, "TO") else { + return; + }; + let Some((targets_runtime, trailing_start)) = runtime_grantee_list(statement, to_index) else { + memberships.insert( + MALFORMED_GRANTEE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); + return; + }; + if !targets_runtime { + return; + } + + let options = explicit_membership_options(statement, trailing_start); + for role in membership_role_names(&statement[1..to_index]) { + match memberships.get_mut(&role) { + Some(state) => { + if let Some(set_enabled) = options.set_enabled { + state.set_enabled = set_enabled; + } + if let Some(admin_enabled) = options.admin_enabled { + state.admin_enabled = admin_enabled; + } + if let Some(inherit_enabled) = options.inherit_enabled { + state.inherit_enabled = inherit_enabled; + } + } + None => { + let mut state = MembershipSecurityState::new(); + if let Some(set_enabled) = options.set_enabled { + state.set_enabled = set_enabled; + } + if let Some(admin_enabled) = options.admin_enabled { + state.admin_enabled = admin_enabled; + } + if let Some(inherit_enabled) = options.inherit_enabled { + state.inherit_enabled = inherit_enabled; + } + memberships.insert(role, state); + } + } + } +} + +/// Security-relevant option targeted by a membership-option REVOKE. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RevokedMembershipOption { + Set, + Admin, + Inherit, +} + +/// Apply one PostgreSQL role-membership REVOKE that targets the application runtime. +/// +/// Plain membership REVOKE removes the edge. Option-specific REVOKEs mutate only +/// an already-tracked membership and therefore cannot create the phantom-safe +/// state repaired by #546. PostgreSQL defines SET, ADMIN, and INHERIT option +/// revocation as setting that membership option to false. Object privilege +/// revokes stay outside this state map because they do not match the bounded +/// role-list grammar. +fn apply_runtime_membership_revoke( + statement: &[&str], + memberships: &mut BTreeMap, +) { + let (roles_start, revoked_option) = if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Set)) + } else if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("ADMIN")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Admin)) + } else if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("INHERIT")) + && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("OPTION")) + && statement + .get(3) + .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) + { + (4usize, Some(RevokedMembershipOption::Inherit)) + } else { + (1usize, None) + }; + + let Some(from_index) = membership_delimiter(statement, roles_start, "FROM") else { + return; + }; + let Some((targets_runtime, _trailing_start)) = runtime_grantee_list(statement, from_index) else { + memberships.insert( + MALFORMED_GRANTEE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); + return; + }; + if !targets_runtime { + return; + } + + for role in membership_role_names(&statement[roles_start..from_index]) { + match revoked_option { + Some(RevokedMembershipOption::Set) => { + if let Some(state) = memberships.get_mut(&role) { + state.set_enabled = false; + } + } + Some(RevokedMembershipOption::Admin) => { + if let Some(state) = memberships.get_mut(&role) { + state.admin_enabled = false; + } + } + Some(RevokedMembershipOption::Inherit) => { + if let Some(state) = memberships.get_mut(&role) { + state.inherit_enabled = false; + } + } + None => { + memberships.remove(&role); + } + } + } +} + +/// Return normalized role names from a validated comma-separated membership role list. +/// +/// PostgreSQL's role-membership GRANT form does not allow `GROUP` as a noise +/// word in the granted-role specification. A normalized `group` token at a role +/// position can therefore represent a quoted role named `"group"` and must be +/// preserved rather than discarded. +fn membership_role_names(tokens: &[&str]) -> Vec { + tokens + .iter() + .filter(|token| **token != ",") + .map(|token| token.to_ascii_lowercase()) + .collect() +} + +/// Return explicitly supplied SET, ADMIN, and INHERIT membership options. +/// +/// `trailing_start` is the first token after the complete grantee list, so role +/// names projected from quoted keywords cannot impersonate the `WITH` clause. +/// PostgreSQL accepts `OPTION` as the true spelling. Malformed or missing values +/// after a recognized security option map to true so the boundary fails closed. +fn explicit_membership_options( + statement: &[&str], + trailing_start: usize, +) -> ExplicitMembershipOptions { + let Some(with_index) = statement[trailing_start..] + .iter() + .position(|token| token.eq_ignore_ascii_case("WITH")) + .map(|relative| trailing_start + relative) + else { + return ExplicitMembershipOptions::default(); + }; + + let mut options = ExplicitMembershipOptions::default(); + let mut index = with_index + 1; + while index < statement.len() { + if statement[index].eq_ignore_ascii_case("GRANTED") { + break; + } + let value = statement + .get(index + 1) + .map(|token| !token.eq_ignore_ascii_case("FALSE")) + .unwrap_or(true); + if statement[index].eq_ignore_ascii_case("SET") { + options.set_enabled = Some(value); + index += 2; + continue; + } + if statement[index].eq_ignore_ascii_case("ADMIN") { + options.admin_enabled = Some(value); + index += 2; + continue; + } + if statement[index].eq_ignore_ascii_case("INHERIT") { + options.inherit_enabled = Some(value); + index += 2; + continue; + } + index += 1; + } + options +} + +/// Return whether canonicalized role creation adds the runtime to another role. +/// +/// `migration_validation` maps CREATE ROLE/USER/GROUP to CREATE TYPE for the +/// shared object-name parser while leaving role attributes in place. PostgreSQL +/// creates both `IN ROLE` and deprecated `IN GROUP` memberships with SET +/// enabled; the runtime is also normally INHERIT unless created NOINHERIT, so +/// either shortcut violates this bounded RLS contract. `ROLE` and `ADMIN` +/// clauses point in the opposite membership direction and are not treated as +/// runtime escape paths here. +fn create_runtime_role_in_role(statement: &[&str]) -> bool { + if statement.len() < 3 + || !statement[0].eq_ignore_ascii_case("CREATE") + || !statement[1].eq_ignore_ascii_case("TYPE") + || !statement[2].eq_ignore_ascii_case(RUNTIME_ROLE) + { + return false; + } + statement[3..].windows(2).any(|window| { + window[0].eq_ignore_ascii_case("IN") + && (window[1].eq_ignore_ascii_case("ROLE") + || window[1].eq_ignore_ascii_case("GROUP")) + }) +} + +#[cfg(test)] +mod tests { + use super::runtime_membership_is_rls_safe; + + #[test] + fn object_grants_and_inverse_membership_do_not_give_runtime_membership_escape() { + for sql in [ + "GRANT SELECT ON TABLE tenant_record TO tepp_app_runtime ;", + "GRANT SET ON PARAMETER work_mem TO tepp_app_runtime ;", + "GRANT tepp_app_runtime TO CURRENT_USER ;", + ] { + assert!(runtime_membership_is_rls_safe(sql), "{sql}"); + } + } + + #[test] + fn set_admin_or_inherit_capable_runtime_memberships_fail_closed() { + for sql in [ + "GRANT reporting_operator TO tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime WITH SET TRUE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH SET OPTION ;", + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE , ADMIN FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN TRUE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH ADMIN TRUE , INHERIT FALSE , SET FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE ; GRANT reporting_operator TO tepp_app_runtime WITH ADMIN TRUE ;", + "GRANT on TO tepp_app_runtime ;", + "GRANT reporting_operator , on TO tepp_app_runtime ;", + "GRANT to TO tepp_app_runtime ;", + "GRANT group TO tepp_app_runtime ;", + "GRANT reporting_operator TO with , tepp_app_runtime ;", + "GRANT reporting_operator TO granted , tepp_app_runtime ;", + "CREATE TYPE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN ROLE reporting_operator ;", + "CREATE TYPE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN GROUP reporting_operator ;", + "REVOKE SET OPTION FOR reporting_operator FROM tepp_app_runtime ; GRANT reporting_operator TO tepp_app_runtime ;", + "REVOKE INHERIT OPTION FOR reporting_operator FROM tepp_app_runtime ; GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE , ADMIN FALSE ;", + ] { + assert!(!runtime_membership_is_rls_safe(sql), "{sql}"); + } + } + + #[test] + fn explicit_security_option_repair_or_later_revoke_restores_membership_safety() { + for sql in [ + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN FALSE ;", + "GRANT reporting_operator TO with , tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime ; REVOKE SET OPTION FOR reporting_operator FROM tepp_app_runtime ; REVOKE INHERIT OPTION FOR reporting_operator FROM tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime ; REVOKE reporting_operator FROM tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime ; REVOKE reporting_operator FROM cascade , tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime ; GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN TRUE ; REVOKE ADMIN OPTION FOR reporting_operator FROM tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE , SET FALSE , ADMIN TRUE ; GRANT reporting_operator TO tepp_app_runtime WITH ADMIN FALSE ;", + ] { + assert!(runtime_membership_is_rls_safe(sql), "{sql}"); + } + } +} diff --git a/crates/persistence_postgres/src/migration_transaction_projection.rs b/crates/persistence_postgres/src/migration_transaction_projection.rs new file mode 100644 index 000000000..33c15804a --- /dev/null +++ b/crates/persistence_postgres/src/migration_transaction_projection.rs @@ -0,0 +1,214 @@ +//! Committed-statement projection for PostgreSQL migration safety evidence. +//! +//! The shared lexical authority has already masked comments, strings, quoted +//! bodies, and quoted semicolons before this boundary runs. This module therefore +//! owns only top-level transaction outcome: statements in a committed explicit +//! transaction survive, statements in a rolled-back transaction disappear, and +//! ambiguous savepoint/two-phase shapes fail closed instead of donating safety +//! evidence to downstream runtime-role validators. + +/// Project normalized SQL onto statements whose effects survive transaction outcome. +/// +/// Statements outside an explicit transaction model PostgreSQL autocommit and +/// are retained immediately. `BEGIN` / `START TRANSACTION` opens a buffer; +/// `COMMIT` / `END` flushes it and `ROLLBACK` / `ABORT` discards it. `AND CHAIN` +/// begins a fresh transaction after the boundary. Savepoints and prepared +/// transactions require a state stack or external prepared-state proof that this +/// bounded validator does not own, so those shapes return `None`. An unterminated +/// explicit transaction also returns `None` because durability is unresolved. +#[must_use] +pub(super) fn project_committed_statements(sql: &str) -> Option { + let tokenized = sql.replace(';', " ; "); + let tokens = tokenized.split_whitespace().collect::>(); + let mut committed = Vec::::new(); + let mut pending = Vec::::new(); + let mut in_transaction = false; + let mut index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + let statement = &tokens[index..end]; + index = end.saturating_add(1); + + if statement.is_empty() { + continue; + } + if is_unsupported_transaction_control(statement) { + return None; + } + if is_transaction_start(statement) { + if !in_transaction { + in_transaction = true; + pending.clear(); + } + continue; + } + if let Some((outcome, and_chain)) = transaction_end(statement) { + if in_transaction { + if matches!(outcome, TransactionOutcome::Commit) { + committed.append(&mut pending); + } else { + pending.clear(); + } + in_transaction = false; + } else if and_chain { + return None; + } + if and_chain { + in_transaction = true; + } + continue; + } + + let rendered = render_statement(statement); + if in_transaction { + pending.push(rendered); + } else { + committed.push(rendered); + } + } + + if in_transaction { + return None; + } + Some(committed.join(" ")) +} + +/// Find the end of one already-normalized semicolon-delimited statement. +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Render one retained normalized statement with an explicit delimiter. +fn render_statement(statement: &[&str]) -> String { + format!("{} ;", statement.join(" ")) +} + +/// Return whether a statement starts an explicit PostgreSQL transaction block. +fn is_transaction_start(statement: &[&str]) -> bool { + statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("BEGIN")) + || (statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("START")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TRANSACTION"))) +} + +/// Transaction end whose mutations either survive or are discarded. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TransactionOutcome { + Commit, + Rollback, +} + +/// Parse a transaction end and whether it immediately chains a new transaction. +fn transaction_end(statement: &[&str]) -> Option<(TransactionOutcome, bool)> { + let first = statement.first()?; + let outcome = if first.eq_ignore_ascii_case("COMMIT") || first.eq_ignore_ascii_case("END") { + TransactionOutcome::Commit + } else if first.eq_ignore_ascii_case("ROLLBACK") || first.eq_ignore_ascii_case("ABORT") { + TransactionOutcome::Rollback + } else { + return None; + }; + + let and_chain = statement.windows(2).any(|pair| { + pair[0].eq_ignore_ascii_case("AND") && pair[1].eq_ignore_ascii_case("CHAIN") + }); + Some((outcome, and_chain)) +} + +/// Reject transaction controls whose durable outcome cannot be proven locally. +fn is_unsupported_transaction_control(statement: &[&str]) -> bool { + if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SAVEPOINT")) + || statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("RELEASE")) + || (statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("ROLLBACK")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TO"))) + { + return true; + } + + (statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("PREPARE")) + && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("TRANSACTION"))) + || ((statement.first().is_some_and(|token| { + token.eq_ignore_ascii_case("COMMIT") || token.eq_ignore_ascii_case("ROLLBACK") + })) && statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("PREPARED"))) +} + +#[cfg(test)] +mod tests { + use super::project_committed_statements; + + #[test] + fn rollback_discards_transaction_statements() { + let projected = project_committed_statements( + "GRANT role_a TO member_a; BEGIN; REVOKE role_a FROM member_a; ROLLBACK; GRANT role_b TO member_b;", + ) + .expect("simple transaction outcome must project"); + + assert!(projected.contains("GRANT role_a TO member_a ;")); + assert!(!projected.contains("REVOKE role_a FROM member_a")); + assert!(projected.contains("GRANT role_b TO member_b ;")); + } + + #[test] + fn commit_retains_transaction_statements() { + let projected = project_committed_statements( + "BEGIN; REVOKE role_a FROM member_a; COMMIT;", + ) + .expect("committed transaction must project"); + assert!(projected.contains("REVOKE role_a FROM member_a ;")); + } + + #[test] + fn commit_and_chain_opens_a_fresh_transaction() { + let projected = project_committed_statements( + "BEGIN; GRANT role_a TO member_a; COMMIT AND CHAIN; REVOKE role_a FROM member_a; ROLLBACK;", + ) + .expect("chained transaction must project"); + assert!(projected.contains("GRANT role_a TO member_a ;")); + assert!(!projected.contains("REVOKE role_a FROM member_a")); + } + + #[test] + fn and_no_chain_does_not_open_a_new_transaction() { + let projected = project_committed_statements( + "BEGIN; GRANT role_a TO member_a; COMMIT AND NO CHAIN; REVOKE role_a FROM member_a;", + ) + .expect("NO CHAIN must return to autocommit"); + assert!(projected.contains("GRANT role_a TO member_a ;")); + assert!(projected.contains("REVOKE role_a FROM member_a ;")); + } + + #[test] + fn savepoints_prepared_transactions_and_unfinished_blocks_fail_closed() { + for sql in [ + "BEGIN; SAVEPOINT safety; COMMIT;", + "PREPARE TRANSACTION 'tx';", + "COMMIT PREPARED 'tx';", + "BEGIN; GRANT role_a TO member_a;", + ] { + assert_eq!(project_committed_statements(sql), None); + } + } +} diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs new file mode 100644 index 000000000..a3f0cc1ae --- /dev/null +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -0,0 +1,1153 @@ +//! PostgreSQL lexical normalization facade and role-identity guards. +//! +//! The implementation remains the single lexical/structural authority. This +//! facade keeps lifecycle-specific PostgreSQL pseudo-target handling separate +//! from grantor provenance: exact quoted grantor identity is now emitted by the +//! shared lexer itself rather than reconstructed by a second quoted-name parser. + +#[path = "migration_validation_impl.rs"] +mod implementation; + +/// Normalize migration SQL for bounded structural contract parsing. +/// +/// Exact PostgreSQL quoted grantor identity is preserved only in an explicit +/// `GRANTED BY` slot by the shared lexical authority. Every other quoted +/// identifier follows the historical structural projection, so naming and +/// lifecycle fail-closed behavior is unchanged. +pub(super) fn normalize_migration_sql(sql: &str) -> Option { + implementation::normalize_migration_sql_with_grantor_identity(sql) +} + +/// Return whether one character can continue PostgreSQL's bounded unquoted function identity. +fn is_function_identifier_continuation(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() +} + +/// Return whether a `set_config` occurrence is PostgreSQL's unqualified or `pg_catalog` builtin. +/// +/// Whitespace before the function name is preserved for identity boundaries; +/// arbitrary schema qualification fails closed as unrelated, while explicit +/// `pg_catalog . set_config` resolves to PostgreSQL's canonical implementation. +fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { + let before = &statement[..start]; + let trimmed = before.trim_end(); + let had_whitespace_boundary = trimmed.len() != before.len(); + let previous = trimmed.chars().next_back(); + + if previous != Some('.') { + return had_whitespace_boundary + || previous.is_none_or(|ch| !is_function_identifier_continuation(ch)); + } + + let before_dot = trimmed[..trimmed.len() - 1].trim_end(); + let schema_end = before_dot.len(); + let schema_start = before_dot + .char_indices() + .rev() + .find(|(_, ch)| !is_function_identifier_continuation(*ch)) + .map_or(0, |(index, ch)| index + ch.len_utf8()); + let schema = &before_dot[schema_start..schema_end]; + if !schema.eq_ignore_ascii_case("pg_catalog") { + return false; + } + before_dot[..schema_start] + .chars() + .next_back() + .is_none_or(|ch| ch != '.' && !is_function_identifier_continuation(ch)) +} + +/// Project Unicode-escaped builtin identity onto the bounded `set_config` authority. +/// +/// This runs only after the shared PostgreSQL lexical pass. At that point a +/// Unicode-escaped quoted identifier is represented by the structural `U&` +/// marker plus either a safely projected lowercase identifier or the +/// `INVALID_QUOTED_IDENTIFIER` sentinel. Exact `set_config` / `pg_catalog` +/// projections are canonical; an invalid projection is conservatively treated +/// as potentially canonical only when it occupies the corresponding function or +/// schema position. Safely projected unrelated names remain unrelated. Optional +/// `UESCAPE` syntax is consumed from the already-normalized representation; its +/// one-character literal may already have been masked by the shared lexer when +/// it is not a preserved atomic literal. This helper therefore does not become a +/// second raw-SQL lexer. +fn project_potential_unicode_set_config_identity(statement: &str) -> String { + const INVALID_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; + let delimited = statement.replace('.', " . ").replace('(', " ( "); + let tokens = delimited.split_whitespace().collect::>(); + let mut projected = Vec::with_capacity(tokens.len()); + let mut index = 0usize; + + let unicode_identifier_end = |start: usize| -> Option { + if !tokens + .get(start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + return None; + } + tokens.get(start + 1)?; + let mut end = start + 2; + if tokens + .get(end) + .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + { + end += 1; + if tokens.get(end).is_some_and(|escape| { + escape.len() >= 2 && escape.starts_with('\'') && escape.ends_with('\'') + }) { + end += 1; + } + } + Some(end) + }; + let is_possible_identity = |token: &str, expected: &str| { + token.eq_ignore_ascii_case(expected) + || token.eq_ignore_ascii_case(INVALID_IDENTIFIER) + }; + + while index < tokens.len() { + if !tokens[index].eq_ignore_ascii_case("U&") { + projected.push(tokens[index].to_owned()); + index += 1; + continue; + } + + let Some(identity) = tokens.get(index + 1).copied() else { + projected.push(tokens[index].to_owned()); + index += 1; + continue; + }; + let Some(identity_end) = unicode_identifier_end(index) else { + projected.push(tokens[index].to_owned()); + index += 1; + continue; + }; + + if tokens.get(identity_end) == Some(&"(") + && is_possible_identity(identity, "set_config") + { + projected.push("set_config".to_owned()); + index = identity_end; + continue; + } + + if tokens.get(identity_end) == Some(&".") + && is_possible_identity(identity, "pg_catalog") + { + let function_start = identity_end + 1; + let plain_function = tokens + .get(function_start) + .is_some_and(|token| token.eq_ignore_ascii_case("set_config")); + let unicode_function = if tokens + .get(function_start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + unicode_identifier_end(function_start).is_some_and(|function_end| { + tokens.get(function_start + 1).is_some_and(|function_identity| { + is_possible_identity(function_identity, "set_config") + }) && tokens.get(function_end) == Some(&"(") + }) + } else { + false + }; + + if plain_function || unicode_function { + projected.push("pg_catalog".to_owned()); + index = identity_end; + continue; + } + } + + projected.extend( + tokens[index..identity_end] + .iter() + .map(|token| (*token).to_owned()), + ); + index = identity_end; + } + + projected.join(" ") +} + +/// Return whether one normalized statement can set `session_replication_role` +/// to a value that is not statically proven safe for ordinary triggers. +/// +/// The shared lexical pass already made comments, quoted marker text, and dollar +/// bodies opaque. Function identity is resolved before whitespace compaction so +/// identifier prefixes and unrelated schemas cannot impersonate PostgreSQL's +/// builtin. Unicode-escaped builtin spellings first cross a bounded projection +/// that recognizes exact or still-ambiguous canonical `set_config` / `pg_catalog` +/// identity without decoding raw SQL again. Positional, named (`=>` / `:=`), and +/// mixed notation are folded into the canonical `setting_name` / `new_value` +/// slots. A direct quoted setting name can prove an unrelated target only when +/// it is one lexical atom; compacted adjacent string constants retain an interior +/// quote and therefore fail closed. A dynamic setting-name expression cannot +/// prove an unrelated target either. For direct `session_replication_role`, only +/// direct `origin` and `local` values are proven safe; other or dynamic values +/// fail closed because `set_config` is PostgreSQL's function equivalent of `SET`. +fn statement_calls_unsafe_set_config(statement: &str) -> bool { + const FUNCTION_NAME: &str = "set_config"; + const CALL_PREFIX: &str = "set_config("; + let unicode_projected = project_potential_unicode_set_config_identity(statement); + let statement = unicode_projected.as_str(); + let lower = statement.to_ascii_lowercase(); + let mut search_from = 0usize; + + while let Some(relative) = lower[search_from..].find(FUNCTION_NAME) { + let start = search_from + relative; + if is_builtin_set_config_occurrence(statement, start) { + let compact_call = statement[start..] + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + + if let Some(arguments_and_rest) = compact_call.strip_prefix(CALL_PREFIX) { + let bytes = arguments_and_rest.as_bytes(); + let mut arguments = Vec::new(); + let mut argument_start = 0usize; + let mut parenthesis_depth = 0usize; + let mut bracket_depth = 0usize; + let mut in_single_quote = false; + let mut call_closed = false; + let mut index = 0usize; + + while index < bytes.len() { + match bytes[index] { + b'\'' => { + if in_single_quote + && bytes.get(index + 1).is_some_and(|next| *next == b'\'') + { + index += 2; + continue; + } + in_single_quote = !in_single_quote; + } + b'(' if !in_single_quote => { + parenthesis_depth = parenthesis_depth.saturating_add(1); + } + b')' if !in_single_quote && parenthesis_depth > 0 => { + parenthesis_depth -= 1; + } + b'[' if !in_single_quote => { + bracket_depth = bracket_depth.saturating_add(1); + } + b']' if !in_single_quote && bracket_depth > 0 => { + bracket_depth -= 1; + } + b',' if !in_single_quote + && parenthesis_depth == 0 + && bracket_depth == 0 => + { + arguments.push(&arguments_and_rest[argument_start..index]); + argument_start = index + 1; + } + b')' if !in_single_quote + && parenthesis_depth == 0 + && bracket_depth == 0 => + { + arguments.push(&arguments_and_rest[argument_start..index]); + call_closed = true; + break; + } + _ => {} + } + index += 1; + } + + if call_closed { + let mut positional_index = 0usize; + let mut setting_name = None; + let mut new_value = None; + + for argument in arguments { + let argument = argument.trim(); + if let Some(value) = argument + .strip_prefix("setting_name=>") + .or_else(|| argument.strip_prefix("setting_name:=")) + { + setting_name = Some(value); + continue; + } + if let Some(value) = argument + .strip_prefix("new_value=>") + .or_else(|| argument.strip_prefix("new_value:=")) + { + new_value = Some(value); + continue; + } + if argument.contains("=>") || argument.contains(":=") { + continue; + } + + match positional_index { + 0 => setting_name = Some(argument), + 1 => new_value = Some(argument), + _ => {} + } + positional_index += 1; + } + + match setting_name { + Some("'session_replication_role'") => { + let safe = matches!(new_value, Some("'origin'") | Some("'local'")); + if !safe { + return true; + } + } + Some(name) + if name.len() >= 2 + && name.starts_with('\'') + && name.ends_with('\'') + && !name[1..name.len() - 1].contains('\'') => {} + Some(_) | None => return true, + } + } + } + } + search_from = start + FUNCTION_NAME.len(); + } + false +} + +/// Return whether an exact `UPDATE` command token starts at one byte offset. +/// +/// The shared lexical pass has already masked comments, strings, dollar bodies, +/// and unsafe quoted identifiers. This boundary therefore only prevents a +/// substring such as `my_update` or `updates` from becoming a DML candidate. +fn is_update_command_token(statement: &str, start: usize) -> bool { + let before = statement[..start].chars().next_back(); + let after_start = start + "update".len(); + let after = statement[after_start..].chars().next(); + before.is_none_or(|ch| !is_function_identifier_continuation(ch)) + && after.is_none_or(|ch| !is_function_identifier_continuation(ch)) +} + +/// Find a keyword token outside nested parenthesized or bracketed expressions. +/// +/// This helper operates only after lexical normalization and punctuation +/// delimiting, so quoted/comment/dollar-body text cannot contribute keyword +/// tokens. It is used to distinguish the UPDATE target `WHERE` from a `WHERE` +/// inside a scalar subquery or array expression in the assignment value. +fn top_level_keyword_index(tokens: &[&str], start: usize, keyword: &str) -> Option { + let mut parenthesis_depth = 0usize; + let mut bracket_depth = 0usize; + + for (index, token) in tokens.iter().enumerate().skip(start) { + match *token { + "(" => parenthesis_depth = parenthesis_depth.saturating_add(1), + ")" => { + if parenthesis_depth == 0 { + return None; + } + parenthesis_depth -= 1; + } + "[" => bracket_depth = bracket_depth.saturating_add(1), + "]" => { + if bracket_depth == 0 { + return None; + } + bracket_depth -= 1; + } + _ if parenthesis_depth == 0 + && bracket_depth == 0 + && token.eq_ignore_ascii_case(keyword) => + { + return Some(index); + } + _ => {} + } + } + None +} + +/// Detect one unsafe `pg_settings` update from an exact normalized `UPDATE` token. +/// +/// PostgreSQL documents `UPDATE pg_settings SET setting = ...` as equivalent to +/// `SET`. The input has already crossed the shared lexical authority, so this +/// bounded parser resolves only the canonical unqualified or `pg_catalog` +/// relation identity plus PostgreSQL's optional `ONLY`, `*`, and target alias +/// forms. Both scalar `setting = value` and PostgreSQL's single-column row +/// assignment `(setting) = [ROW] (value)` cross the same boundary. Direct +/// `origin` and `local` assignment atoms are always safe for ordinary triggers. +/// PostgreSQL Unicode-escaped quoted identifiers currently reach this layer as a +/// structural `U&` marker followed by the shared quoted-identifier projection. +/// Directly equivalent lowercase `pg_catalog` / `pg_settings` spellings reuse the +/// same authority, while an invalid escaped projection fails closed because its +/// decoded identity is not yet owned. Safe projected unrelated relation names +/// remain unrelated. Once canonical `pg_settings` is established, any remaining +/// `U&` structural marker fails closed rather than letting an escaped alias or +/// assignment-column identity bypass the setting/value fold. No second raw-SQL +/// lexer is introduced here. +/// For any other value, only a complete direct equality to one unrelated quoted +/// setting name proves that `session_replication_role` is excluded; protected +/// equality is recognized in either operand order, and any unsupported predicate +/// shape fails closed instead of being treated as unrelated. +fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str) -> bool { + let delimited = update_statement + .replace('.', " . ") + .replace('=', " = ") + .replace('(', " ( ") + .replace(')', " ) ") + .replace('[', " [ ") + .replace(']', " ] "); + let tokens = delimited.split_whitespace().collect::>(); + if !tokens + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("UPDATE")) + { + return false; + } + + let mut index = 1usize; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) + { + index += 1; + } + + let skip_unicode_uescape = |mut end: usize| -> usize { + if tokens + .get(end) + .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + { + end += 1; + if tokens.get(end).is_some_and(|token| { + token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') + }) { + end += 1; + } + } + end + }; + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) + { + index += 1; + } else if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(projected_name) = tokens.get(index + 1) else { + return true; + }; + if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + return true; + } + if projected_name.eq_ignore_ascii_case("pg_settings") { + index = skip_unicode_uescape(index + 2); + } else if projected_name.eq_ignore_ascii_case("pg_catalog") { + let schema_end = skip_unicode_uescape(index + 2); + if tokens.get(schema_end) != Some(&".") { + return false; + } + let relation_start = schema_end + 1; + if tokens + .get(relation_start) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) + { + index = relation_start + 1; + } else if tokens + .get(relation_start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(relation_name) = tokens.get(relation_start + 1) else { + return true; + }; + if relation_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + return true; + } + if !relation_name.eq_ignore_ascii_case("pg_settings") { + return false; + } + index = skip_unicode_uescape(relation_start + 2); + } else { + return false; + } + } else { + return false; + } + } else if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_catalog")) + && tokens.get(index + 1) == Some(&".") + { + let relation_start = index + 2; + if tokens + .get(relation_start) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) + { + index = relation_start + 1; + } else if tokens + .get(relation_start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(projected_name) = tokens.get(relation_start + 1) else { + return true; + }; + if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + return true; + } + if !projected_name.eq_ignore_ascii_case("pg_settings") { + return false; + } + index = skip_unicode_uescape(relation_start + 2); + } else { + return false; + } + } else { + return false; + } + + if tokens + .iter() + .skip(index) + .any(|token| token.eq_ignore_ascii_case("U&")) + { + return true; + } + + if tokens.get(index) == Some(&"*") { + index += 1; + } + let alias = if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("AS")) + { + let alias = tokens.get(index + 1).copied(); + index += 2; + alias + } else if tokens + .get(index) + .is_some_and(|token| !token.eq_ignore_ascii_case("SET")) + { + let alias = tokens.get(index).copied(); + index += 1; + alias + } else { + None + }; + + if !tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return false; + } + + let value_start = if tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("setting")) + && tokens.get(index + 2) == Some(&"=") + { + index + 3 + } else if tokens.get(index + 1) == Some(&"(") + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("setting")) + && tokens.get(index + 3) == Some(&")") + && tokens.get(index + 4) == Some(&"=") + { + index + 5 + } else { + return false; + }; + + let where_index = top_level_keyword_index(&tokens, value_start, "WHERE"); + let value_end = where_index + .or_else(|| top_level_keyword_index(&tokens, value_start, "RETURNING")) + .unwrap_or(tokens.len()); + let value_tokens = &tokens[value_start..value_end]; + let direct_value_atom = if value_tokens.len() == 1 { + Some(value_tokens[0]) + } else if value_tokens.len() == 3 + && value_tokens[0] == "(" + && value_tokens[2] == ")" + { + Some(value_tokens[1]) + } else if value_tokens.len() == 4 + && value_tokens[0].eq_ignore_ascii_case("ROW") + && value_tokens[1] == "(" + && value_tokens[3] == ")" + { + Some(value_tokens[2]) + } else { + None + }; + let value_is_safe = direct_value_atom.is_some_and(|value| { + value.trim_matches('\'').eq_ignore_ascii_case("origin") + || value.trim_matches('\'').eq_ignore_ascii_case("local") + }); + if value_is_safe { + return false; + } + + let Some(where_index) = where_index else { + return true; + }; + let predicate_end = top_level_keyword_index(&tokens, where_index + 1, "RETURNING") + .unwrap_or(tokens.len()); + + let name_operand_end = |start: usize| -> Option { + if tokens.get(start + 1) == Some(&".") { + let qualifier = *tokens.get(start)?; + let qualifier_matches = alias + .is_some_and(|expected| qualifier.eq_ignore_ascii_case(expected)) + || alias.is_none() && qualifier.eq_ignore_ascii_case("pg_settings"); + if !qualifier_matches + || !tokens + .get(start + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("name")) + { + return None; + } + Some(start + 3) + } else if tokens + .get(start) + .is_some_and(|token| token.eq_ignore_ascii_case("name")) + { + Some(start + 1) + } else { + None + } + }; + + let is_direct_quoted_setting_name = |token: &str| -> bool { + token.len() >= 2 + && token.starts_with('\'') + && token.ends_with('\'') + && !token[1..token.len() - 1].contains('\'') + }; + let is_protected_setting_name = |token: &str| -> bool { + is_direct_quoted_setting_name(token) + && token[1..token.len() - 1].eq_ignore_ascii_case("session_replication_role") + }; + + let predicate_start = where_index + 1; + if let Some(after_name) = name_operand_end(predicate_start) { + if tokens.get(after_name) == Some(&"=") && after_name + 2 == predicate_end { + if let Some(setting_name) = tokens.get(after_name + 1) { + if is_direct_quoted_setting_name(setting_name) { + return is_protected_setting_name(setting_name); + } + } + } + return true; + } + + if let Some(setting_name) = tokens.get(predicate_start) { + if is_direct_quoted_setting_name(setting_name) + && tokens.get(predicate_start + 1) == Some(&"=") + { + if let Some(after_name) = name_operand_end(predicate_start + 2) { + if after_name == predicate_end { + return is_protected_setting_name(setting_name); + } + } + } + } + + true +} + +/// Detect an unsafe `pg_settings` update anywhere in one normalized statement. +/// +/// PostgreSQL permits a leading `WITH` clause before `UPDATE` and permits +/// data-modifying statements inside a CTE. Because lexical opacity is already +/// resolved upstream, scanning exact `UPDATE` command-token candidates lets both +/// executable forms reuse one target/assignment/predicate authority without a +/// second SQL lexer. Identifier-prefixed occurrences are ignored, and a candidate +/// must still parse as canonical `pg_settings` before it can affect this policy. +fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> bool { + const UPDATE: &str = "update"; + let lower = statement.to_ascii_lowercase(); + let mut search_from = 0usize; + + while let Some(relative) = lower[search_from..].find(UPDATE) { + let start = search_from + relative; + if is_update_command_token(statement, start) + && update_targets_unsafe_replication_role_via_pg_settings(&statement[start..]) + { + return true; + } + search_from = start + UPDATE.len(); + } + false +} + +/// Detect persistent PostgreSQL defaults that can make later application sessions +/// enter replica execution mode before TEPP's runtime trigger contracts run. +/// +/// The statement has already crossed the shared lexical authority and committed +/// transaction projection. This fold therefore handles only normalized `ALTER` +/// configuration commands; it does not re-lex raw SQL. `ALTER USER` is treated as +/// PostgreSQL's documented alias for `ALTER ROLE`; role-specific defaults are +/// relevant for `tepp_app_runtime`, PostgreSQL pseudo-current-role targets, and +/// `ALL`. Unicode-escaped quoted role/parameter identities reuse the shared +/// lexical projection: exact protected spellings are canonical, invalid quoted +/// projections fail closed only in the protected identity slot, and safely +/// projected unrelated names remain unrelated. Database and system defaults are +/// conservatively relevant because this validator does not own deployment +/// database/cluster identity. Direct `origin` and `local` are the only values +/// that prove ordinary triggers remain enabled. `FROM CURRENT`, `DEFAULT`, and +/// `RESET` fail closed because the inherited/current value and precedence chain +/// are not yet represented by a first-class default-state aggregate. `ALTER +/// SYSTEM` is evaluated here only as durable SQL state; PostgreSQL itself forbids +/// running it inside a transaction block. +fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> bool { + const INVALID_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; + let delimited = statement.replace('=', " = "); + let tokens = delimited.split_whitespace().collect::>(); + if !tokens + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("ALTER")) + { + return false; + } + + let unicode_identifier_end = |start: usize| -> Option { + if !tokens + .get(start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + return None; + } + tokens.get(start + 1)?; + let mut end = start + 2; + if tokens + .get(end) + .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + { + end += 1; + if tokens.get(end).is_some_and(|escape| { + escape.len() >= 2 && escape.starts_with('\'') && escape.ends_with('\'') + }) { + end += 1; + } + } + Some(end) + }; + let unicode_identifier_may_match = |start: usize, expected: &str| -> Option<(bool, usize)> { + let end = unicode_identifier_end(start)?; + let projected = *tokens.get(start + 1)?; + Some(( + projected.eq_ignore_ascii_case(expected) + || projected.eq_ignore_ascii_case(INVALID_IDENTIFIER), + end, + )) + }; + + let mut index = 1usize; + let scope = tokens.get(index).copied(); + let role_scoped = scope.is_some_and(|token| { + token.eq_ignore_ascii_case("ROLE") || token.eq_ignore_ascii_case("USER") + }); + let database_scoped = scope.is_some_and(|token| token.eq_ignore_ascii_case("DATABASE")); + let system_scoped = scope.is_some_and(|token| token.eq_ignore_ascii_case("SYSTEM")); + if !role_scoped && !database_scoped && !system_scoped { + return false; + } + index += 1; + + if role_scoped { + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some((target_may_be_runtime, target_end)) = + unicode_identifier_may_match(index, "tepp_app_runtime") + else { + return true; + }; + if !target_may_be_runtime { + return false; + } + index = target_end; + } else { + let Some(target) = tokens.get(index).copied() else { + return false; + }; + let target_may_be_runtime = target.eq_ignore_ascii_case("tepp_app_runtime") + || target.eq_ignore_ascii_case("ALL") + || target.eq_ignore_ascii_case("CURRENT_ROLE") + || target.eq_ignore_ascii_case("CURRENT_USER") + || target.eq_ignore_ascii_case("SESSION_USER"); + if !target_may_be_runtime { + return false; + } + index += 1; + } + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("IN")) + { + if !tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("DATABASE")) + { + return true; + } + let database_start = index + 2; + if tokens + .get(database_start) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(database_end) = unicode_identifier_end(database_start) else { + return true; + }; + index = database_end; + } else if tokens.get(database_start).is_some() { + index = database_start + 1; + } else { + return true; + } + } + } else if database_scoped { + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(database_end) = unicode_identifier_end(index) else { + return true; + }; + index = database_end; + } else if tokens.get(index).is_some() { + index += 1; + } else { + return false; + } + } + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) + { + index += 1; + if tokens + .get(index) + .is_some_and(|parameter| parameter.eq_ignore_ascii_case("ALL")) + { + return true; + } + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + return unicode_identifier_may_match(index, "session_replication_role") + .is_none_or(|(matches, _)| matches); + } + return tokens.get(index).is_some_and(|parameter| { + parameter.eq_ignore_ascii_case("session_replication_role") + }); + } + if !tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return false; + } + index += 1; + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("session_replication_role")) + { + index += 1; + } else if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some((parameter_may_be_protected, parameter_end)) = + unicode_identifier_may_match(index, "session_replication_role") + else { + return true; + }; + if !parameter_may_be_protected { + return false; + } + index = parameter_end; + } else { + return false; + } + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("FROM")) + { + return tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("CURRENT")); + } + if !tokens + .get(index) + .is_some_and(|token| *token == "=" || token.eq_ignore_ascii_case("TO")) + { + return true; + } + index += 1; + + !tokens.get(index).is_some_and(|value| { + value.trim_matches('\'').eq_ignore_ascii_case("origin") + || value.trim_matches('\'').eq_ignore_ascii_case("local") + }) +} + +/// Detect committed PostgreSQL execution modes that cannot prove ordinary triggers stayed enabled. +/// +/// The input has already crossed the shared lexical authority and committed-state +/// projection. Comments and data literals are therefore opaque, while rolled-back +/// statements are absent. PostgreSQL `DO` and `CALL` immediately execute opaque +/// procedural code or a procedure whose effects are not proven by this bounded +/// validator, so committed top-level forms fail closed. Direct SQL settings, +/// `set_config`, writable `pg_settings.setting`, and persistent role/database/ +/// system login defaults retain one execution-context boundary. Unicode-escaped +/// direct `SET` parameter names reuse the shared quoted projection: canonical +/// `session_replication_role` is protected, an invalid escaped projection is +/// treated as potentially protected, and a safely projected unrelated identifier +/// remains unrelated. Direct `origin` and `local` retain their statically safe +/// ordinary-trigger semantics. +fn committed_replica_trigger_execution_mode(sql: &str) -> bool { + sql.split(';').any(|statement| { + if statement + .split_whitespace() + .next() + .is_some_and(|token| { + token.eq_ignore_ascii_case("DO") || token.eq_ignore_ascii_case("CALL") + }) + { + return true; + } + + if statement_calls_unsafe_set_config(statement) + || statement_updates_unsafe_replication_role_via_pg_settings(statement) + || statement_sets_unsafe_persistent_replication_role_default(statement) + { + return true; + } + + let delimited = statement.replace('=', " = "); + let tokens = delimited.split_whitespace().collect::>(); + if !tokens + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return false; + } + + let mut index = 1usize; + if tokens.get(index).is_some_and(|token| { + token.eq_ignore_ascii_case("LOCAL") || token.eq_ignore_ascii_case("SESSION") + }) { + index += 1; + } + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("session_replication_role")) + { + index += 1; + } else if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(projected_name) = tokens.get(index + 1) else { + return true; + }; + if !projected_name.eq_ignore_ascii_case("session_replication_role") + && !projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") + { + return false; + } + index += 2; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + { + index += 1; + if tokens.get(index).is_some_and(|token| { + token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') + }) { + index += 1; + } + } + } else { + return false; + } + + if !tokens.get(index).is_some_and(|token| { + *token == "=" || token.eq_ignore_ascii_case("TO") + }) { + return false; + } + index += 1; + + tokens.get(index).is_some_and(|value| { + value + .trim_matches('\'') + .eq_ignore_ascii_case("replica") + }) + }) +} + +/// Return whether the expected runtime role exists in PostgreSQL's durable final migration state +/// and remains subject to row-level security. +/// +/// Lowercase quoted spellings of PostgreSQL's special role specifications are +/// projected to case-distinct quoted spellings only for this lifecycle scan. +/// The existing lexical authority then maps them to its fail-closed quoted-name +/// sentinel, keeping a named role such as `"current_user"` distinct from the +/// unquoted `CURRENT_USER` pseudo-target without changing executable SQL or the +/// general structural-normalization contract. Transaction outcome is applied +/// after that shared lexical projection and before lifecycle folding, so a +/// rolled-back `ALTER ROLE ... NOBYPASSRLS` cannot certify an actually unsafe +/// runtime role. A committed unsafe `session_replication_role` mutation through +/// `SET`, PostgreSQL's `set_config` equivalent, canonical `pg_settings` update, +/// or a persistent role/database/system login default also fails the runtime-role +/// contract because it can suppress ordinary enforcement triggers while durable +/// catalog definitions remain enabled. +pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { + let lifecycle_sql = preserve_quoted_special_role_specifications(sql); + let normalized_lifecycle = implementation::normalize_migration_sql_with_grantor_identity( + &lifecycle_sql, + )?; + let committed_lifecycle = super::core::project_committed_sql(&normalized_lifecycle)?; + if committed_replica_trigger_execution_mode(&committed_lifecycle) { + return Some(false); + } + implementation::declares_created_role(&committed_lifecycle, expected_role) +} + +/// Detect whether normalized migration SQL declares an RLS surface. +pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { + implementation::declares_row_level_security(normalized_sql) +} + +/// Preserve quoted named-role identity against unquoted PostgreSQL pseudo-targets. +/// +/// The replacement is deliberately limited to lowercase quoted spellings, the +/// only form that the lifecycle projection would otherwise dequote as +/// identity-equivalent. A quoted identifier carrying PostgreSQL's immediate +/// `U&` Unicode prefix is already a named-role identity and is left untouched; +/// converting its payload to uppercase would turn a safely projected unrelated +/// Unicode role into the lexer's invalid-identifier sentinel. Replacements +/// inside comments, literals, or dollar bodies remain inert because the shared +/// lexical pass still owns those regions. +fn preserve_quoted_special_role_specifications(sql: &str) -> String { + let replace_unless_unicode_prefixed = |input: String, from: &str, to: &str| -> String { + let mut output = String::with_capacity(input.len()); + let mut cursor = 0usize; + for (start, _) in input.match_indices(from) { + output.push_str(&input[cursor..start]); + let bytes = input.as_bytes(); + let unicode_prefixed = start >= 2 + && (bytes[start - 2] == b'U' || bytes[start - 2] == b'u') + && bytes[start - 1] == b'&'; + output.push_str(if unicode_prefixed { from } else { to }); + cursor = start + from.len(); + } + output.push_str(&input[cursor..]); + output + }; + + let preserved = replace_unless_unicode_prefixed( + sql.to_owned(), + "\"current_role\"", + "\"CURRENT_ROLE\"", + ); + let preserved = replace_unless_unicode_prefixed( + preserved, + "\"current_user\"", + "\"CURRENT_USER\"", + ); + replace_unless_unicode_prefixed( + preserved, + "\"session_user\"", + "\"SESSION_USER\"", + ) +} + +#[cfg(test)] +mod tests { + use super::{declares_created_role, normalize_migration_sql}; + + #[test] + fn quoted_special_role_name_stays_distinct_from_unquoted_pseudo_target_in_lifecycle() { + let sql = r#" + CREATE ROLE "current_user" BYPASSRLS; + ALTER ROLE CURRENT_USER NOBYPASSRLS; + ALTER ROLE "current_user" RENAME TO tepp_app_runtime; + "#; + assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); + } + + #[test] + fn rolled_back_runtime_role_hardening_does_not_change_final_lifecycle_state() { + let sql = r#" + CREATE ROLE tepp_app_runtime BYPASSRLS; + BEGIN; + ALTER ROLE tepp_app_runtime NOBYPASSRLS; + ROLLBACK; + "#; + assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); + } + + #[test] + fn committed_replica_mode_invalidates_runtime_role_safety_after_projection() { + for sql in [ + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SET session_replication_role=replica;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SET SESSION session_replication_role TO 'replica';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; BEGIN; SET LOCAL session_replication_role = replica; COMMIT;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SELECT set_config('session_replication_role', 'replica', false);", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SELECT pg_catalog . set_config('session_replication_role', 'replica', false);", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; BEGIN; SELECT set_config('session_replication_role', 'replica', true); COMMIT;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_catalog . pg_settings SET setting = lower('REPLICA') WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE ONLY pg_settings AS p SET setting = 'replica' WHERE p.name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH changed_setting AS (UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = (SELECT 'replica' WHERE true) WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH changed_setting AS (UPDATE pg_settings SET setting = (SELECT 'replica' WHERE true) WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings SET (setting) = ('replica') WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_catalog . pg_settings AS p SET (setting) = ROW('replica') WHERE p.name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE U&\"pg_settings\" SET setting = 'replica' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE U&\"pg_\\0073ettings\" SET setting = 'replica' WHERE name = 'session_replication_role';", + ] { + assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); + } + } + + #[test] + fn rolled_back_replica_mode_and_safe_modes_preserve_runtime_role_safety() { + for sql in [ + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; BEGIN; SET LOCAL session_replication_role = replica; ROLLBACK;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; BEGIN; SELECT set_config('session_replication_role', 'replica', true); ROLLBACK;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; BEGIN; UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role'; ROLLBACK;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SET session_replication_role = origin;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SET SESSION session_replication_role TO local;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SELECT set_config('session_replication_role', 'origin', false);", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SELECT audit_support.set_config('session_replication_role', 'replica', false);", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings SET setting = 'origin' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings AS p SET setting = 'local' WHERE p.name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings SET (setting) = ('origin') WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE pg_settings AS p SET (setting) = ROW('local') WHERE p.name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE audit_support.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; UPDATE U&\"audit_settings\" SET setting = 'replica' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'origin' WHERE name = 'session_replication_role';", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; WITH changed_setting AS (UPDATE pg_settings SET setting = 'local' WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + ] { + assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(true)); + } + } + + #[test] + fn grantor_identity_comes_from_the_shared_lexical_authority() { + let normalized = normalize_migration_sql( + r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor""A"; CREATE ROLE "Grantor""B";"#, + ) + .expect("well-formed quoted identities"); + assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); + assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); + } +} diff --git a/crates/persistence_postgres/src/migration_validation_impl.rs b/crates/persistence_postgres/src/migration_validation_impl.rs new file mode 100644 index 000000000..5cd661f8d --- /dev/null +++ b/crates/persistence_postgres/src/migration_validation_impl.rs @@ -0,0 +1,1145 @@ +//! PostgreSQL lexical normalization for migration contract parsing. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; +const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; +const QUOTED_GRANTOR_IDENTITY_PREFIX: &str = "__tepp_quoted_grantor_identity_"; + +/// Final security-relevant attributes tracked for one PostgreSQL role. +/// +/// The migration contract does not model the full PostgreSQL role catalog; it +/// keeps only attributes that can bypass TEPP tenant row-level security. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct RoleSecurityState { + is_superuser: bool, + bypasses_rls: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QuotedIdentifierProjection { + Structural, + GrantorIdentity, +} + +/// Normalize migration SQL for bounded structural contract parsing. +/// +/// The lexical pass removes declaration-shaped trivia while preserving the few +/// atomic literals required by downstream contracts. The structural pass then +/// canonicalizes PostgreSQL aliases without pretending to be a full SQL parser. +/// Returns `None` when any lexical region is malformed or unterminated. +pub(super) fn normalize_migration_sql(sql: &str) -> Option { + let normalized = lexically_normalize_migration_sql(sql)?; + Some(canonicalize_structural_keywords(&normalized)) +} + +/// Normalize SQL while preserving exact quoted identity only for `GRANTED BY`. +/// +/// PostgreSQL quoted role identifiers may contain punctuation, non-ASCII bytes, +/// and doubled quotes. Grantor provenance needs that exact identity, while the +/// ordinary migration naming boundary intentionally collapses unsupported quoted +/// spellings to `INVALID_QUOTED_IDENTIFIER`. This entry point uses the same +/// lexical scanner for both concerns: quoted identifiers are temporarily hex +/// encoded during the lexical pass, then retained only in an explicit grantor +/// slot and restored to the historical structural projection everywhere else. +/// Caller-supplied occurrences of the reserved token prefix fail closed so SQL +/// text cannot manufacture provenance state. +pub(super) fn normalize_migration_sql_with_grantor_identity(sql: &str) -> Option { + if sql.contains(QUOTED_GRANTOR_IDENTITY_PREFIX) { + return None; + } + let normalized = lexically_normalize_migration_sql_with_projection( + sql, + QuotedIdentifierProjection::GrantorIdentity, + )?; + let normalized = restore_quoted_identifier_projection(&normalized)?; + Some(canonicalize_structural_keywords(&normalized)) +} + +/// Return whether the expected runtime role exists in the final migration state +/// and remains subject to PostgreSQL row-level security. Role creation aliases, +/// drops, renames, and later ALTER ROLE/USER/GROUP attribute changes share this +/// lifecycle scan so SUPERUSER/BYPASSRLS cannot survive under the expected name. +pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { + let normalized = lexically_normalize_migration_sql(sql)?; + // The lexical pass has already masked literals/comments and converted + // representable quoted identifiers. PostgreSQL statement/list delimiters + // remain structural, so make them explicit tokens before role lifecycle + // scanning; whitespace is not required around either delimiter. + let role_tokens = normalized.replace(';', " ; ").replace(',', " , "); + let tokens = role_tokens.split_whitespace().collect::>(); + let mut declared_roles = BTreeMap::new(); + let mut index = 0usize; + while index < tokens.len() { + if is_role_creation_alias(&tokens, index) { + let name = normalized_role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); + if name.is_empty() { + return Some(false); + } + let mut state = RoleSecurityState::default(); + apply_role_security_attributes( + &tokens[index + 3..statement_end(&tokens, index + 3)], + &mut state, + ); + declared_roles.insert(name, state); + } else if is_role_drop_alias(&tokens, index) { + let Some(names) = drop_statement_role_names(&tokens, index) else { + return Some(false); + }; + for name in names { + declared_roles.remove(&name); + } + } else if is_role_rename_alias(&tokens, index) { + let source = normalized_role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); + let target = normalized_role_identifier(tokens.get(index + 5).copied().unwrap_or_default()); + if source.is_empty() || target.is_empty() { + return Some(false); + } + if let Some(state) = declared_roles.remove(&source) { + declared_roles.insert(target, state); + } + } else if is_role_alter_alias(&tokens, index) { + let name = normalized_role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); + if name.is_empty() { + return Some(false); + } + let end = statement_end(&tokens, index + 3); + let attributes = &tokens[index + 3..end]; + if attributes.is_empty() + || attributes + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) + { + return Some(false); + } + if let Some(state) = declared_roles.get_mut(&name) { + apply_role_security_attributes(attributes, state); + } + } + index += 1; + } + Some( + declared_roles + .get(&expected_role.to_ascii_lowercase()) + .is_some_and(|state| !state.is_superuser && !state.bypasses_rls), + ) +} + +/// Detect whether normalized migration SQL declares an RLS surface. +/// +/// Either table enablement or policy creation activates downstream tenant RLS +/// validation so partially declared isolation cannot remain outside the gate. +pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { + let lower = normalized_sql.to_ascii_lowercase(); + lower.contains("enable row level security") || lower.contains("create policy") +} + +/// Mask quoted/comment bodies and preserve contract-relevant lexical atoms. +/// +/// This pass maintains byte positions only insofar as needed for scanning; it +/// intentionally inserts spaces around removed trivia so adjacent SQL tokens +/// cannot become a synthetic declaration. Any unterminated lexical construct +/// fails closed by returning `None`. +fn lexically_normalize_migration_sql(sql: &str) -> Option { + lexically_normalize_migration_sql_with_projection(sql, QuotedIdentifierProjection::Structural) +} + +/// Run the single PostgreSQL lexical scanner with the selected quoted projection. +/// +/// Grantor identity is a validation-only representation choice, not a second +/// lexer. Comments, strings, nested comments, dollar bodies, and quoted +/// identifier escapes are scanned once here regardless of downstream consumer. +fn lexically_normalize_migration_sql_with_projection( + sql: &str, + quoted_projection: QuotedIdentifierProjection, +) -> Option { + let bytes = sql.as_bytes(); + let mut normalized = Vec::with_capacity(bytes.len()); + let mut index = 0usize; + + while index < bytes.len() { + match bytes[index] { + b'E' | b'e' if bytes.get(index + 1) == Some(&b'\'') => { + let (next, literal) = scan_escape_quoted_literal(bytes, index + 1)?; + normalized.push(b' '); + if literal_is_atomic(literal) { + normalized.push(b'\''); + normalized.extend_from_slice(literal); + normalized.push(b'\''); + } + normalized.push(b' '); + index = next; + } + b'\'' => { + let (next, literal) = scan_single_quoted_literal(bytes, index)?; + normalized.push(b' '); + if literal_is_atomic(literal) { + normalized.push(b'\''); + normalized.extend_from_slice(literal); + normalized.push(b'\''); + } + normalized.push(b' '); + index = next; + } + b'"' => { + let (next, identifier) = scan_quoted_identifier(bytes, index)?; + normalized.push(b' '); + match quoted_projection { + QuotedIdentifierProjection::Structural => { + append_structural_quoted_identifier(&mut normalized, &identifier); + } + QuotedIdentifierProjection::GrantorIdentity => { + normalized.extend_from_slice( + quoted_grantor_identity_sentinel(&identifier).as_bytes(), + ); + } + } + normalized.push(b' '); + index = next; + } + b'-' if bytes.get(index + 1) == Some(&b'-') => { + normalized.push(b' '); + index += 2; + while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { + index += 1; + } + if index < bytes.len() { + normalized.push(bytes[index]); + index += 1; + } + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + normalized.push(b' '); + index = scan_block_comment(bytes, index)?; + normalized.push(b' '); + } + b'$' => { + if let Some(delimiter) = dollar_quote_delimiter(bytes, index) { + normalized.push(b' '); + index = scan_dollar_quoted_body(bytes, index, delimiter)?; + normalized.push(b' '); + } else { + normalized.push(bytes[index]); + index += 1; + } + } + byte => { + normalized.push(byte); + index += 1; + } + } + } + + String::from_utf8(normalized).ok() +} + +/// Apply the historical structural projection for one parsed quoted identifier. +fn append_structural_quoted_identifier(normalized: &mut Vec, identifier: &[u8]) { + if quoted_identifier_is_structurally_safe(identifier) + && !quoted_identifier_collides_with_table_syntax(identifier) + { + normalized.extend_from_slice(identifier); + } else { + normalized.extend_from_slice(INVALID_QUOTED_IDENTIFIER); + } +} + +/// Encode exact PostgreSQL quoted identity into one whitespace-free token. +/// +/// The scanner has already unescaped doubled quotes, so the hex payload is the +/// identifier PostgreSQL stores rather than its SQL source spelling. The `@` +/// terminator is outside unquoted identifier grammar and bounds decoding. +fn quoted_grantor_identity_sentinel(identifier: &[u8]) -> String { + let mut sentinel = + String::with_capacity(QUOTED_GRANTOR_IDENTITY_PREFIX.len() + identifier.len() * 2 + 1); + sentinel.push_str(QUOTED_GRANTOR_IDENTITY_PREFIX); + for byte in identifier { + write!(&mut sentinel, "{byte:02x}").expect("writing to String cannot fail"); + } + sentinel.push('@'); + sentinel +} + +/// Decode one shared-lexer quoted identity token back to PostgreSQL identifier bytes. +fn decode_quoted_grantor_identity(token: &str) -> Option> { + let hex = token + .strip_prefix(QUOTED_GRANTOR_IDENTITY_PREFIX)? + .strip_suffix('@')?; + if hex.len() % 2 != 0 { + return None; + } + let mut decoded = Vec::with_capacity(hex.len() / 2); + for pair in hex.as_bytes().chunks_exact(2) { + let pair = std::str::from_utf8(pair).ok()?; + decoded.push(u8::from_str_radix(pair, 16).ok()?); + } + Some(decoded) +} + +/// Return whether the normalized prefix ends at a `GRANTED BY` grantor slot. +/// +/// PostgreSQL treats spaces, tabs, newlines, and comments as token separators. +/// The shared lexical pass has already replaced comments with whitespace and +/// masked literal/dollar-quoted bodies, so inspecting the final two normalized +/// tokens is whitespace-insensitive without letting trivia manufacture syntax. +fn is_explicit_grantor_position(normalized_prefix: &str) -> bool { + let mut tokens = normalized_prefix.split_whitespace().rev(); + tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("BY")) + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("GRANTED")) +} + +/// Restore shared quoted-identity tokens outside an explicit grantor position. +/// +/// Lowercase ASCII quoted role names that are identity-equivalent to ordinary +/// unquoted identifiers retain the historical canonical spelling. PostgreSQL's +/// special unquoted role specifications are excluded from that collapse because +/// a quoted name such as `"current_user"` denotes a named role, not the +/// executor-relative pseudo-target. All other exact identities remain encoded in +/// `GRANTED BY` and return to the historical fail-closed structural projection +/// elsewhere. +fn restore_quoted_identifier_projection(normalized_sql: &str) -> Option { + let mut restored = normalized_sql.to_owned(); + let mut search_from = 0usize; + + while let Some(relative) = restored[search_from..].find(QUOTED_GRANTOR_IDENTITY_PREFIX) { + let start = search_from + relative; + let suffix_start = start + QUOTED_GRANTOR_IDENTITY_PREFIX.len(); + let relative_end = restored[suffix_start..].find('@')?; + let end = suffix_start + relative_end + 1; + let token = &restored[start..end]; + let identifier = decode_quoted_grantor_identity(token)?; + let is_explicit_grantor = is_explicit_grantor_position(&restored[..start]); + let is_special_role_specification = [b"current_role".as_slice(), b"current_user".as_slice(), b"session_user".as_slice()] + .iter() + .any(|special| identifier.eq_ignore_ascii_case(special)); + + if is_explicit_grantor + && (!quoted_identifier_is_structurally_safe(&identifier) + || is_special_role_specification) + { + search_from = end; + continue; + } + + let replacement = if quoted_identifier_is_structurally_safe(&identifier) + && !quoted_identifier_collides_with_table_syntax(&identifier) + { + String::from_utf8(identifier).ok()? + } else { + String::from_utf8(INVALID_QUOTED_IDENTIFIER.to_vec()).ok()? + }; + restored.replace_range(start..end, &replacement); + search_from = start + replacement.len(); + } + + Some(restored) +} + +/// Return whether `tokens[create_index..]` starts PostgreSQL CREATE ROLE/USER/GROUP. +/// +/// `CREATE USER MAPPING` is deliberately excluded because it is an SQL/MED +/// object rather than a role alias and must not enter role lifecycle evidence. +fn is_role_creation_alias(tokens: &[&str], create_index: usize) -> bool { + if !tokens + .get(create_index) + .is_some_and(|token| token.eq_ignore_ascii_case("CREATE")) + { + return false; + } + let Some(kind) = tokens.get(create_index + 1) else { + return false; + }; + if kind.eq_ignore_ascii_case("USER") + && tokens + .get(create_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("MAPPING")) + { + return false; + } + kind.eq_ignore_ascii_case("ROLE") + || kind.eq_ignore_ascii_case("USER") + || kind.eq_ignore_ascii_case("GROUP") +} + +/// Return whether `tokens[drop_index..]` starts PostgreSQL DROP ROLE/USER/GROUP. +/// +/// `DROP USER MAPPING` remains outside the role lifecycle for the same SQL/MED +/// ownership reason as its CREATE counterpart. +fn is_role_drop_alias(tokens: &[&str], drop_index: usize) -> bool { + if !tokens + .get(drop_index) + .is_some_and(|token| token.eq_ignore_ascii_case("DROP")) + { + return false; + } + let Some(kind) = tokens.get(drop_index + 1) else { + return false; + }; + if kind.eq_ignore_ascii_case("USER") + && tokens + .get(drop_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("MAPPING")) + { + return false; + } + kind.eq_ignore_ascii_case("ROLE") + || kind.eq_ignore_ascii_case("USER") + || kind.eq_ignore_ascii_case("GROUP") +} + +/// Return whether an ALTER ROLE/USER/GROUP statement has a complete RENAME TO shape. +/// +/// A complete target is required because rename changes the durable role name; +/// malformed rename syntax must not be reinterpreted as a generic attribute +/// change or leave stale role evidence alive. +fn is_role_rename_alias(tokens: &[&str], alter_index: usize) -> bool { + if !tokens + .get(alter_index) + .is_some_and(|token| token.eq_ignore_ascii_case("ALTER")) + { + return false; + } + let Some(kind) = tokens.get(alter_index + 1) else { + return false; + }; + if kind.eq_ignore_ascii_case("USER") + && tokens + .get(alter_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("MAPPING")) + { + return false; + } + (kind.eq_ignore_ascii_case("ROLE") + || kind.eq_ignore_ascii_case("USER") + || kind.eq_ignore_ascii_case("GROUP")) + && tokens + .get(alter_index + 3) + .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) + && tokens + .get(alter_index + 4) + .is_some_and(|token| token.eq_ignore_ascii_case("TO")) + && tokens.get(alter_index + 5).is_some() +} + +/// Return whether `tokens[alter_index..]` starts a role-alias ALTER statement. +/// +/// `ALTER USER MAPPING` is excluded so SQL/MED options cannot be interpreted as +/// security attributes on `tepp_app_runtime`. +fn is_role_alter_alias(tokens: &[&str], alter_index: usize) -> bool { + if !tokens + .get(alter_index) + .is_some_and(|token| token.eq_ignore_ascii_case("ALTER")) + { + return false; + } + let Some(kind) = tokens.get(alter_index + 1) else { + return false; + }; + if kind.eq_ignore_ascii_case("USER") + && tokens + .get(alter_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("MAPPING")) + { + return false; + } + kind.eq_ignore_ascii_case("ROLE") + || kind.eq_ignore_ascii_case("USER") + || kind.eq_ignore_ascii_case("GROUP") +} + +/// Return the exclusive token index of the current semicolon-delimited statement. +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Apply the security attributes that determine whether PostgreSQL RLS can be bypassed. +/// +/// Later contradictory attributes intentionally win because PostgreSQL ALTER +/// statements mutate role state in order; this helper models that final state. +fn apply_role_security_attributes(tokens: &[&str], state: &mut RoleSecurityState) { + for token in tokens { + if token.eq_ignore_ascii_case("SUPERUSER") { + state.is_superuser = true; + } else if token.eq_ignore_ascii_case("NOSUPERUSER") { + state.is_superuser = false; + } else if token.eq_ignore_ascii_case("BYPASSRLS") { + state.bypasses_rls = true; + } else if token.eq_ignore_ascii_case("NOBYPASSRLS") { + state.bypasses_rls = false; + } + } +} + +/// Return whether `ch` may start a PostgreSQL unquoted role identifier. +/// +/// PostgreSQL's scanner accepts ASCII letters, underscore, and high-bit bytes +/// at identifier start. Rust presents valid UTF-8 high-bit sequences as +/// non-ASCII `char`s, which is the representation this lexical boundary sees. +fn is_postgresql_role_identifier_start(ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' || !ch.is_ascii() +} + +/// Return whether `ch` may continue a PostgreSQL unquoted role identifier. +/// +/// Dollar signs and non-ASCII continuations are part of the same PostgreSQL +/// token. Preserving them here prevents a distinct role such as +/// `tepp_app_runtime$shadow` from aliasing the protected runtime in lifecycle +/// state. TEPP's stricter durable naming policy remains a separate authority. +fn is_postgresql_role_identifier_continuation(ch: char) -> bool { + is_postgresql_role_identifier_start(ch) || ch.is_ascii_digit() || ch == '$' +} + +/// Extract one complete PostgreSQL unquoted role identifier from a lifecycle token. +/// +/// The lifecycle tokenizer has already separated commas and semicolons. This +/// helper preserves PostgreSQL identifier continuations instead of truncating +/// them to an ASCII prefix, so CREATE/ALTER/RENAME/DROP share the same exact +/// role identity before TEPP applies any stricter naming policy elsewhere. +fn role_identifier(fragment: &str) -> String { + let mut chars = fragment.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + if !is_postgresql_role_identifier_start(first) { + return String::new(); + } + + let mut identifier = String::from(first); + identifier.extend(chars.take_while(|ch| is_postgresql_role_identifier_continuation(*ch))); + identifier +} + +/// Extract and case-fold a role identifier for PostgreSQL lifecycle comparison. +fn normalized_role_identifier(fragment: &str) -> String { + role_identifier(fragment).to_ascii_lowercase() +} + +/// Parse the `DROP ROLE|USER|GROUP [IF EXISTS] name [, ...]` target list. +/// +/// The lifecycle validator must reject malformed separators instead of silently +/// compacting them: PostgreSQL requires an alternating `name (, name)*` list, +/// and accepting leading, trailing, adjacent, or missing commas would let an +/// invalid migration retain a previously safe runtime-role state in evidence. +fn drop_statement_role_names(tokens: &[&str], drop_index: usize) -> Option> { + let mut name_index = drop_index + 2; + if tokens + .get(name_index) + .is_some_and(|token| token.eq_ignore_ascii_case("IF")) + && tokens + .get(name_index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) + { + name_index += 2; + } + + let mut names = Vec::new(); + let mut expects_name = true; + while let Some(token) = tokens.get(name_index) { + if *token == ";" { + break; + } + if expects_name { + if *token == "," { + return None; + } + let name = role_identifier(token); + if name.is_empty() || name.len() != token.len() { + return None; + } + names.push(name.to_ascii_lowercase()); + expects_name = false; + } else { + if *token != "," { + return None; + } + expects_name = true; + } + name_index += 1; + } + if names.is_empty() || expects_name { + None + } else { + Some(names) + } +} + +/// Canonicalize syntax variants that the structural migration parser owns as one concept. +/// +/// Role aliases are projected through the existing one-name scanner; materialized +/// and replaceable views are collapsed to `CREATE VIEW`; and schema-qualified +/// created names are replaced with an invalid sentinel so downstream parsing +/// fails closed instead of truncating to a locally valid prefix. +fn canonicalize_structural_keywords(sql: &str) -> String { + let tokens = sql.split_whitespace().collect::>(); + let mut canonical = Vec::with_capacity(tokens.len()); + let mut index = 0usize; + while index < tokens.len() { + if is_role_creation_alias(&tokens, index) { + // ROLE is a cluster-level object used by TEPP's shipped RLS migration; + // USER and GROUP are PostgreSQL aliases for CREATE ROLE. CREATE USER + // MAPPING is a distinct SQL/MED statement and must not enter this path. + // The downstream structural parser only needs a one-name CREATE shape, + // so route role aliases through its existing CREATE TYPE name scanner. + canonical.push(tokens[index]); + canonical.push("TYPE"); + index += 2; + } else if is_role_rename_alias(&tokens, index) { + // RENAME changes the durable database-object name. Project the target + // through the same one-name scanner so ALTER ROLE/USER/GROUP cannot + // bypass the canonical snake_case naming authority. + canonical.push("CREATE"); + canonical.push("TYPE"); + canonical.push(tokens[index + 5]); + index += 6; + } else if tokens[index].eq_ignore_ascii_case("CREATE") + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("MATERIALIZED")) + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("VIEW")) + { + canonical.push(tokens[index]); + canonical.push(tokens[index + 2]); + index += 3; + } else if tokens[index].eq_ignore_ascii_case("CREATE") + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("OR")) + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("REPLACE")) + && tokens + .get(index + 3) + .is_some_and(|token| token.eq_ignore_ascii_case("VIEW")) + { + canonical.push(tokens[index]); + canonical.push(tokens[index + 3]); + index += 4; + } else { + canonical.push(tokens[index]); + index += 1; + } + } + + let mut guarded = canonical + .into_iter() + .map(str::to_owned) + .collect::>(); + let mut index = 0usize; + while index < guarded.len() { + if guarded[index].eq_ignore_ascii_case("CREATE") { + let kind_index = if guarded + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("OR")) + && guarded + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("REPLACE")) + { + index + 3 + } else if guarded + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("UNIQUE")) + { + index + 2 + } else { + index + 1 + }; + let mut name_index = kind_index + 1; + if guarded + .get(name_index) + .is_some_and(|token| token.eq_ignore_ascii_case("IF")) + && guarded + .get(name_index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("NOT")) + && guarded + .get(name_index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) + { + name_index += 3; + } + let qualified = guarded + .get(name_index) + .is_some_and(|token| token.contains('.')) + || guarded + .get(name_index + 1) + .is_some_and(|token| token.starts_with('.')); + if qualified && name_index < guarded.len() { + guarded[name_index] = INVALID_QUALIFIED_IDENTIFIER.to_owned(); + } + } + index += 1; + } + guarded.join(" ") +} + +/// Scan a standard PostgreSQL single-quoted literal, honoring doubled quotes. +/// +/// Returns the index immediately after the closing quote plus the raw literal +/// payload. Unterminated literals return `None` and fail the outer lexical pass. +fn scan_single_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8])> { + let mut index = start + 1; + let content_start = index; + while index < bytes.len() { + if bytes[index] == b'\'' { + if bytes.get(index + 1) == Some(&b'\'') { + index += 2; + continue; + } + return Some((index + 1, &bytes[content_start..index])); + } + index += 1; + } + None +} + +/// Scan a PostgreSQL `E'...'` literal with backslash and doubled-quote escapes. +/// +/// `start` points at the opening quote rather than the preceding `E`. A trailing +/// escape or missing close quote is treated as malformed SQL and returns `None`. +fn scan_escape_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8])> { + let mut index = start + 1; + let content_start = index; + while index < bytes.len() { + match bytes[index] { + b'\\' => { + index += 2; + } + b'\'' => { + if bytes.get(index + 1) == Some(&b'\'') { + index += 2; + continue; + } + return Some((index + 1, &bytes[content_start..index])); + } + _ => { + index += 1; + } + } + } + None +} + +/// Scan a PostgreSQL double-quoted identifier and unescape doubled quotes. +/// +/// The returned bytes retain declared spelling for later naming checks. Missing +/// closing quotes return `None` rather than donating partial identifier evidence. +fn scan_quoted_identifier(bytes: &[u8], start: usize) -> Option<(usize, Vec)> { + let mut index = start + 1; + let mut identifier = Vec::new(); + while index < bytes.len() { + if bytes[index] == b'"' { + if bytes.get(index + 1) == Some(&b'"') { + identifier.push(b'"'); + index += 2; + continue; + } + return Some((index + 1, identifier)); + } + identifier.push(bytes[index]); + index += 1; + } + None +} + +/// Scan a possibly nested PostgreSQL block comment. +/// +/// PostgreSQL permits nested `/* ... */` comments, so depth is tracked until the +/// matching outer terminator. An unterminated comment returns `None`. +fn scan_block_comment(bytes: &[u8], start: usize) -> Option { + let mut depth = 1usize; + let mut index = start + 2; + while index < bytes.len() { + if bytes.get(index) == Some(&b'/') && bytes.get(index + 1) == Some(&b'*') { + depth += 1; + index += 2; + } else if bytes.get(index) == Some(&b'*') && bytes.get(index + 1) == Some(&b'/') { + depth -= 1; + index += 2; + if depth == 0 { + return Some(index); + } + } else { + index += 1; + } + } + None +} + +/// Return the PostgreSQL dollar-quote delimiter beginning at `start`, if any. +/// +/// A `$...$` sequence attached to a preceding unquoted identifier is not a +/// delimiter. Tags follow PostgreSQL's scanner-level ASCII/high-bit byte rules, +/// which keeps UTF-8 tag bytes valid while positional parameters such as `$1` +/// remain ordinary SQL text. +fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { + if bytes.get(start) != Some(&b'$') { + return None; + } + if start > 0 + && bytes.get(start - 1).is_some_and(|byte| { + byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'$') || *byte >= 0x80 + }) + { + return None; + } + let mut index = start + 1; + if bytes.get(index) == Some(&b'$') { + return Some(&bytes[start..=index]); + } + let first = *bytes.get(index)?; + if first != b'_' && !first.is_ascii_alphabetic() && first < 0x80 { + return None; + } + index += 1; + while let Some(byte) = bytes.get(index) { + if *byte == b'$' { + return Some(&bytes[start..=index]); + } + if *byte != b'_' && !byte.is_ascii_alphanumeric() && *byte < 0x80 { + return None; + } + index += 1; + } + None +} + +/// Scan to the closing delimiter of a PostgreSQL dollar-quoted body. +/// +/// The body is opaque to migration contract parsing. Missing closing delimiters +/// return `None` so declaration-shaped text cannot escape an unterminated body. +fn scan_dollar_quoted_body(bytes: &[u8], start: usize, delimiter: &[u8]) -> Option { + let mut index = start + delimiter.len(); + while index + delimiter.len() <= bytes.len() { + if &bytes[index..index + delimiter.len()] == delimiter { + return Some(index + delimiter.len()); + } + index += 1; + } + None +} + +/// Return whether a quoted literal is safe to preserve as a contract atom. +/// +/// Only non-empty alphanumeric/underscore/dot payloads survive normalization; +/// arbitrary literal SQL remains masked and cannot donate structural evidence. +fn literal_is_atomic(literal: &[u8]) -> bool { + !literal.is_empty() + && literal + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.')) +} + +/// Return whether a quoted identifier can be projected onto an unquoted token +/// without changing PostgreSQL identity inside the bounded structural parser. +/// +/// PostgreSQL folds unquoted identifiers to lower case but preserves quoted case. +/// The lexical boundary strips quotes only for lowercase ASCII spellings whose +/// quoted and unquoted identities are therefore equivalent. Mixed/uppercase or +/// otherwise unsupported quoted identifiers fail closed through the invalid +/// sentinel instead of aliasing a distinct PostgreSQL object or role. +fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { + !identifier.is_empty() + && identifier.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' + }) +} + +/// Return whether a quoted identifier would collide with table-clause syntax. +/// +/// Quoted spellings such as `"primary"` are valid identifiers in PostgreSQL but +/// cannot safely enter the structural column parser because that parser uses the +/// same words to identify table constraints. They therefore fail closed. +fn quoted_identifier_collides_with_table_syntax(identifier: &[u8]) -> bool { + const TABLE_CONSTRAINT_KEYWORDS: [&[u8]; 7] = [ + b"constraint", + b"primary", + b"foreign", + b"unique", + b"check", + b"exclude", + b"like", + ]; + TABLE_CONSTRAINT_KEYWORDS + .iter() + .any(|keyword| identifier.eq_ignore_ascii_case(keyword)) +} + +#[cfg(test)] +mod tests { + use super::{ + INVALID_QUALIFIED_IDENTIFIER, QUOTED_GRANTOR_IDENTITY_PREFIX, declares_created_role, + declares_row_level_security, normalize_migration_sql, + normalize_migration_sql_with_grantor_identity, + }; + + #[test] + fn lexical_normalization_masks_declaration_shaped_trivia() { + let sql = r#" + -- CREATE INDEX Bad ON tenant_record (tenant_record_id); + SELECT 'CREATE INDEX Bad ON tenant_record (tenant_record_id)'; + /* outer /* CREATE VIEW Bad AS SELECT 1 */ still comment */ + CREATE INDEX "good_index" ON tenant_record (tenant_record_id); + "#; + let normalized = normalize_migration_sql(sql).expect("well-formed SQL"); + assert!(!normalized.contains("CREATE INDEX Bad")); + assert!(!normalized.contains("CREATE VIEW Bad")); + assert!(normalized.contains("CREATE INDEX good_index ON tenant_record")); + } + + #[test] + fn lexical_normalization_preserves_atomic_contract_literals() { + let sql = "SELECT current_setting('tepp.current_tenant_record_id', true), 'x', '';"; + let normalized = normalize_migration_sql(sql).expect("well-formed SQL"); + assert!(normalized.contains("'tepp.current_tenant_record_id'")); + assert!(normalized.contains("'x'")); + assert!(!normalized.contains("''")); + } + + #[test] + fn postgres_escape_strings_respect_backslash_and_doubled_quote_boundaries() { + let normalized = normalize_migration_sql( + r"SELECT E'it\'s ''still'' one literal', e'tepp.current_tenant_record_id';", + ) + .expect("well-formed PostgreSQL escape strings"); + assert!(!normalized.contains("still")); + assert!(normalized.contains("'tepp.current_tenant_record_id'")); + } + + #[test] + fn atomic_literals_keep_boundaries_between_sql_keywords() { + let normalized = normalize_migration_sql("SELECT 'CREATE'\n'INDEX' AS literal_text;") + .expect("well-formed adjacent literals"); + assert!(!normalized.contains("CREATE INDEX")); + assert!(normalized.contains("'CREATE' 'INDEX'")); + } + + #[test] + fn quoted_identifiers_preserve_equivalent_spelling_and_reject_case_distinct_content() { + let normalized = normalize_migration_sql( + "CREATE INDEX \"good_index\" ON tenant_record (\"good_name\"); CREATE VIEW \"Bad\" AS SELECT 1; CREATE VIEW \"a\"\"b\" AS SELECT 1;", + ) + .expect("well-formed quoted identifiers"); + assert!(normalized.contains("CREATE INDEX good_index ON tenant_record ( good_name )")); + assert!(normalized.contains("CREATE VIEW INVALID_QUOTED_IDENTIFIER AS SELECT 1")); + } + + #[test] + fn shared_lexer_preserves_arbitrary_quoted_identity_only_for_explicit_grantors() { + let normalized = normalize_migration_sql_with_grantor_identity( + r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor-A"; GRANT reporting_owner TO tepp_app_runtime GRANTED BY "권한A"; GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor""A"; CREATE ROLE "Grantor-B";"#, + ) + .expect("well-formed quoted grantor identities"); + assert_eq!(normalized.matches(QUOTED_GRANTOR_IDENTITY_PREFIX).count(), 3); + assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); + } + + #[test] + fn grantor_identity_projection_keeps_lowercase_equivalence_but_not_special_role_specs() { + let normalized = normalize_migration_sql_with_grantor_identity( + r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "grantor_a"; GRANT reporting_owner TO tepp_app_runtime GRANTED BY "current_user";"#, + ) + .expect("well-formed quoted grantors"); + assert!(normalized.contains("GRANTED BY grantor_a")); + assert_eq!(normalized.matches(QUOTED_GRANTOR_IDENTITY_PREFIX).count(), 1); + } + + #[test] + fn role_creation_aliases_share_the_created_object_name_scanner() { + for statement in [ + "CREATE ROLE role_name NOSUPERUSER;", + "CREATE USER user_name NOSUPERUSER;", + "CREATE GROUP group_name NOSUPERUSER;", + ] { + let normalized = normalize_migration_sql(statement).expect("well-formed role declaration"); + assert!(normalized.starts_with("CREATE TYPE "), "{statement}"); + } + let user_mapping = normalize_migration_sql( + "CREATE USER MAPPING FOR CURRENT_USER SERVER foreign_server;", + ) + .expect("well-formed user mapping"); + assert!(user_mapping.starts_with("CREATE USER MAPPING ")); + } + + #[test] + fn role_declaration_evidence_uses_the_lexical_boundary() { + assert_eq!( + declares_created_role("CREATE ROLE tepp_app_runtime NOSUPERUSER;", "tepp_app_runtime"), + Some(true) + ); + assert_eq!( + declares_created_role("CREATE USER \"tepp_app_runtime\" NOSUPERUSER;", "tepp_app_runtime"), + Some(true) + ); + assert_eq!( + declares_created_role( + "-- CREATE ROLE tepp_app_runtime;\nSELECT 'CREATE ROLE tepp_app_runtime';", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE USER MAPPING FOR tepp_app_runtime SERVER foreign_server;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime; DROP ROLE tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "DROP ROLE IF EXISTS tepp_app_runtime; CREATE USER tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(true) + ); + assert_eq!( + declares_created_role( + "CREATE GROUP tepp_app_runtime; DROP GROUP IF EXISTS other_role,tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime; DROP USER tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime; DROP USER MAPPING FOR tepp_app_runtime SERVER foreign_server;", + "tepp_app_runtime" + ), + Some(true) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime;DROP ROLE tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime; ALTER ROLE tepp_app_runtime RENAME TO archived_runtime_role;", + "tepp_app_runtime" + ), + Some(false) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE archived_runtime_role; ALTER USER archived_runtime_role RENAME TO tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(true) + ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime; ALTER GROUP absent_role RENAME TO other_role;", + "tepp_app_runtime" + ), + Some(true) + ); + } + + #[test] + fn role_rename_targets_share_the_created_object_name_scanner() { + for statement in [ + "ALTER ROLE role_name RENAME TO renamed_role;", + "ALTER USER user_name RENAME TO renamed_user;", + "ALTER GROUP group_name RENAME TO renamed_group;", + ] { + let normalized = normalize_migration_sql(statement).expect("well-formed role rename"); + assert!(normalized.starts_with("CREATE TYPE renamed_"), "{statement}"); + } + let user_mapping = normalize_migration_sql( + "ALTER USER MAPPING FOR CURRENT_USER SERVER foreign_server OPTIONS (SET user 'x');", + ) + .expect("well-formed user mapping alteration"); + assert!(user_mapping.starts_with("ALTER USER MAPPING ")); + } + + #[test] + fn rls_detection_runs_on_lexically_normalized_sql() { + let normalized = normalize_migration_sql( + "-- ENABLE ROW LEVEL SECURITY\nCREATE POLICY tenant_record_isolation ON tenant_record USING (true);", + ) + .expect("well-formed RLS SQL"); + assert!(declares_row_level_security(&normalized)); + let trivia = normalize_migration_sql("SELECT 'CREATE POLICY hidden';") + .expect("well-formed literal"); + assert!(!declares_row_level_security(&trivia)); + } + + #[test] + fn view_modifiers_share_the_view_object_parser() { + let normalized = normalize_migration_sql( + "create materialized view materialized_view AS SELECT 1; CREATE OR REPLACE VIEW replaceable_view AS SELECT 1; CREATE VIEW ordinary_view AS SELECT 1;", + ) + .expect("well-formed view declarations"); + assert!(normalized.contains("create view materialized_view AS SELECT 1;")); + assert!(normalized.contains("CREATE VIEW replaceable_view AS SELECT 1;")); + assert!(normalized.contains("CREATE VIEW ordinary_view AS SELECT 1;")); + let upper = normalized.to_ascii_uppercase(); + assert!(!upper.contains("MATERIALIZED VIEW")); + assert!(!upper.contains("OR REPLACE VIEW")); + } + + #[test] + fn qualified_created_names_fail_closed_before_prefix_truncation() { + for sql in [ + "CREATE VIEW audit_schema.Bad AS SELECT 1;", + "CREATE VIEW \"audit_schema\".\"Bad\" AS SELECT 1;", + "CREATE OR REPLACE FUNCTION audit_schema.Bad() RETURNS void AS $$ SELECT 1 $$ LANGUAGE sql;", + "CREATE UNIQUE INDEX IF NOT EXISTS audit_schema.Bad ON tenant_record (tenant_record_id);", + ] { + let normalized = normalize_migration_sql(sql).expect("well-formed qualified declaration"); + assert!(normalized.contains(INVALID_QUALIFIED_IDENTIFIER), "{sql}"); + } + } + + #[test] + fn dollar_quoted_bodies_do_not_declare_migration_objects() { + let normalized = normalize_migration_sql( + "CREATE FUNCTION good_function() RETURNS void AS $body$ CREATE INDEX Bad ON x(y); $body$ LANGUAGE sql;", + ) + .expect("well-formed dollar quote"); + assert!(normalized.contains("CREATE FUNCTION good_function() RETURNS void AS")); + assert!(!normalized.contains("CREATE INDEX Bad")); + } + + #[test] + fn malformed_lexical_regions_fail_closed() { + for sql in [ + "SELECT 'unterminated", + "SELECT E'unterminated", + "CREATE TABLE \"unterminated", + "/* unterminated", + "DO $body$ unterminated", + ] { + assert!(normalize_migration_sql(sql).is_none(), "{sql}"); + } + } + + #[test] + fn positional_dollar_parameters_are_not_dollar_quotes() { + let normalized = normalize_migration_sql("SELECT $1, $2;").expect("parameters"); + assert_eq!(normalized, "SELECT $1, $2;"); + } +} diff --git a/crates/persistence_postgres/src/naming.rs b/crates/persistence_postgres/src/naming.rs index e70059ff6..e790aa480 100644 --- a/crates/persistence_postgres/src/naming.rs +++ b/crates/persistence_postgres/src/naming.rs @@ -1,12 +1,27 @@ //! Database object naming contracts for TEPP persistence. +/// TEPP's portable PostgreSQL identifier ceiling in bytes. +/// +/// PostgreSQL's default `NAMEDATALEN = 64` truncates identifiers after 63 bytes. +/// TEPP pins that default ceiling even if a custom server build raises it, so +/// migration identity remains stable across supported environments. +const POSTGRESQL_IDENTIFIER_BYTE_LIMIT: usize = 63; + /// Return whether `name` is descriptive multi-word `snake_case`. /// /// TEPP requires at least two underscore-separated lowercase segments so -/// physical schema objects remain self-describing in reviews and audits. +/// physical schema objects remain self-describing in reviews and audits. Names +/// must also fit PostgreSQL's default 63-byte identifier ceiling; accepting a +/// longer spelling would let the server silently truncate the durable object +/// identity. #[must_use] pub fn is_multi_word_snake_case(name: &str) -> bool { - if name.is_empty() || name.starts_with('_') || name.ends_with('_') || name.contains("__") { + if name.is_empty() + || name.len() > POSTGRESQL_IDENTIFIER_BYTE_LIMIT + || name.starts_with('_') + || name.ends_with('_') + || name.contains("__") + { return false; } let mut parts = 0usize; @@ -31,9 +46,15 @@ mod tests { use super::is_multi_word_snake_case; #[test] - fn multi_word_names_pass_and_single_word_names_fail() { + fn multi_word_names_pass_and_invalid_names_fail() { assert!(is_multi_word_snake_case("document_record")); assert!(is_multi_word_snake_case("audit_event")); + assert!(is_multi_word_snake_case( + "document_record_projection_snapshot_archive_registry_history_v1" + )); + assert!(!is_multi_word_snake_case( + "document_record_projection_snapshot_archive_registry_history_v1x" + )); assert!(!is_multi_word_snake_case("documents")); assert!(!is_multi_word_snake_case("Document_Record")); assert!(!is_multi_word_snake_case("_leading_underscore")); diff --git a/crates/persistence_postgres/tests/migration_alter_table_add_column_naming_contract.rs b/crates/persistence_postgres/tests/migration_alter_table_add_column_naming_contract.rs new file mode 100644 index 000000000..744d78af1 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_alter_table_add_column_naming_contract.rs @@ -0,0 +1,72 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn table_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_add_column_must_apply_the_durable_naming_contract() { + for mutation in [ + "ALTER TABLE tenant_record ADD COLUMN flag boolean;", + "ALTER TABLE tenant_record ADD COLUMN IF NOT EXISTS flag boolean;", + "ALTER TABLE tenant_record ADD flag boolean;", + ] { + let catalog = table_catalog(mutation); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "ALTER TABLE ADD COLUMN must not bypass the CREATE TABLE column naming contract: {mutation}" + ); + } +} + +#[test] +fn later_add_column_action_cannot_hide_behind_a_safe_first_action() { + let catalog = table_catalog( + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean, ADD COLUMN flag boolean;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + ); +} + +#[test] +fn rolled_back_add_column_does_not_change_durable_naming_evidence() { + let catalog = table_catalog("BEGIN; ALTER TABLE tenant_record ADD COLUMN flag boolean; ROLLBACK;"); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn safe_add_column_and_table_constraint_forms_remain_supported() { + for mutation in [ + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (tenant_record_id IS NOT NULL);", + "ALTER TABLE tenant_record ADD CHECK (tenant_record_id IS NOT NULL);", + "ALTER TABLE tenant_record ADD FOREIGN KEY (tenant_record_id) REFERENCES tenant_record (tenant_record_id);", + ] { + let catalog = table_catalog(mutation); + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "table-constraint forms must not be misclassified as added columns: {mutation}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_alter_table_drop_final_state_contract.rs b/crates/persistence_postgres/tests/migration_alter_table_drop_final_state_contract.rs new file mode 100644 index 000000000..3407ba0c9 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_alter_table_drop_final_state_contract.rs @@ -0,0 +1,58 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn table_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_drop_column_cannot_reuse_historical_tenant_boundary_evidence() { + let catalog = table_catalog("ALTER TABLE tenant_record DROP COLUMN tenant_record_id CASCADE;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "a committed column drop must not leave the historical tenant_record_id declaration authoritative" + ); +} + +#[test] +fn later_drop_action_in_one_alter_table_statement_cannot_hide_behind_additive_action() { + let catalog = table_catalog( + "ALTER TABLE tenant_record ADD COLUMN auxiliary_flag boolean, DROP COLUMN tenant_record_id CASCADE;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "PostgreSQL action lists must be evaluated beyond the first additive action" + ); +} + +#[test] +fn rolled_back_drop_column_does_not_remove_the_durable_tenant_boundary() { + let catalog = table_catalog( + "BEGIN; ALTER TABLE tenant_record DROP COLUMN tenant_record_id CASCADE; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn create_table_and_rls_only_catalog_remains_supported() { + let catalog = table_catalog(""); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs new file mode 100644 index 000000000..416b4b4ec --- /dev/null +++ b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs @@ -0,0 +1,61 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn table_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_table_rename_cannot_reuse_historical_table_evidence() { + let catalog = table_catalog("ALTER TABLE tenant_record RENAME TO tenant_record_archive;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "a committed table rename must not leave the historical CREATE TABLE identity authoritative" + ); +} + +#[test] +fn committed_tenant_column_rename_cannot_reuse_historical_column_evidence() { + let catalog = table_catalog( + "ALTER TABLE tenant_record RENAME COLUMN tenant_record_id TO tenant_key;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "a committed tenant-column rename must not reuse the historical tenant_record_id declaration" + ); +} + +#[test] +fn rolled_back_table_identity_mutations_do_not_change_the_durable_contract() { + for mutation in [ + "BEGIN; ALTER TABLE tenant_record RENAME TO tenant_record_archive; ROLLBACK;", + "BEGIN; ALTER TABLE tenant_record RENAME COLUMN tenant_record_id TO tenant_key; ROLLBACK;", + ] { + let catalog = table_catalog(mutation); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); + } +} + +#[test] +fn create_table_and_rls_only_catalog_remains_supported() { + let catalog = table_catalog(""); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_append_only_guard_routine_final_state_contract.rs b/crates/persistence_postgres/tests/migration_append_only_guard_routine_final_state_contract.rs new file mode 100644 index 000000000..b70d7d905 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_append_only_guard_routine_final_state_contract.rs @@ -0,0 +1,86 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_guard_routine_removal_cannot_reuse_historical_append_only_evidence() { + for final_sql in [ + "DROP FUNCTION reject_append_only_mutation() CASCADE;", + "DROP FUNCTION IF EXISTS reject_append_only_mutation() CASCADE;", + "DROP ROUTINE reject_append_only_mutation() CASCADE;", + "DROP ROUTINE IF EXISTS reject_append_only_mutation() CASCADE;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "committed guard-routine removal must invalidate historical trigger evidence: {final_sql}", + ); + } +} + +#[test] +fn committed_guard_routine_replacement_cannot_reuse_the_original_definition() { + let catalog = embedded_with( + r#" +CREATE OR REPLACE FUNCTION reject_append_only_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NULL; +END +$tepp$; +"#, + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + ); +} + +#[test] +fn rolled_back_guard_routine_mutations_do_not_change_durable_enforcement() { + for final_sql in [ + "BEGIN; DROP FUNCTION reject_append_only_mutation() CASCADE; ROLLBACK;", + "BEGIN; DROP ROUTINE reject_append_only_mutation() CASCADE; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } + + let replaced = embedded_with( + r#" +BEGIN; +CREATE OR REPLACE FUNCTION reject_append_only_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NULL; +END +$tepp$; +ROLLBACK; +"#, + ); + assert_eq!(validate_migration_catalog(&replaced), Ok(())); +} + +#[test] +fn marker_like_guard_routine_mutations_are_not_statements() { + let catalog = embedded_with( + r#" +SELECT 'DROP FUNCTION reject_append_only_mutation() CASCADE'; +SELECT 'DROP ROUTINE reject_append_only_mutation() CASCADE'; +SELECT $$CREATE OR REPLACE FUNCTION reject_append_only_mutation()$$; +-- DROP FUNCTION reject_append_only_mutation() CASCADE; +-- DROP ROUTINE reject_append_only_mutation() CASCADE; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_append_only_guard_schema_qualification_contract.rs b/crates/persistence_postgres/tests/migration_append_only_guard_schema_qualification_contract.rs new file mode 100644 index 000000000..0be96986d --- /dev/null +++ b/crates/persistence_postgres/tests/migration_append_only_guard_schema_qualification_contract.rs @@ -0,0 +1,83 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn whitespace_separated_schema_qualification_cannot_hide_guard_routine_removal() { + for final_sql in [ + "DROP FUNCTION public . reject_append_only_mutation() CASCADE;", + "DROP ROUTINE IF EXISTS public . reject_append_only_mutation() CASCADE;", + "DROP FUNCTION IF EXISTS unrelated_helper(), public . reject_append_only_mutation() CASCADE;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "schema qualification whitespace must not hide guard-routine removal: {final_sql}", + ); + } +} + +#[test] +fn whitespace_separated_schema_qualification_cannot_hide_guard_routine_replacement() { + let catalog = embedded_with( + r#" +CREATE OR REPLACE FUNCTION public . reject_append_only_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NULL; +END +$tepp$; +"#, + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + ); +} + +#[test] +fn rolled_back_schema_qualified_guard_mutations_do_not_change_durable_enforcement() { + for final_sql in [ + "BEGIN; DROP FUNCTION public . reject_append_only_mutation() CASCADE; ROLLBACK;", + "BEGIN; DROP ROUTINE public . reject_append_only_mutation() CASCADE; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } + + let replaced = embedded_with( + r#" +BEGIN; +CREATE OR REPLACE FUNCTION public . reject_append_only_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NULL; +END +$tepp$; +ROLLBACK; +"#, + ); + assert_eq!(validate_migration_catalog(&replaced), Ok(())); +} + +#[test] +fn schema_qualified_guard_markers_inside_opaque_regions_are_not_mutations() { + let catalog = embedded_with( + r#" +SELECT 'DROP FUNCTION public . reject_append_only_mutation() CASCADE'; +SELECT $$DROP ROUTINE public . reject_append_only_mutation() CASCADE$$; +-- CREATE OR REPLACE FUNCTION public . reject_append_only_mutation(); +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_array_constructor_contract.rs b/crates/persistence_postgres/tests/migration_array_constructor_contract.rs new file mode 100644 index 000000000..da992bd4c --- /dev/null +++ b/crates/persistence_postgres/tests/migration_array_constructor_contract.rs @@ -0,0 +1,51 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn array_constructor_commas_are_not_table_element_separators() { + for up_sql in [ + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + retry_schedule integer[] DEFAULT ARRAY[1, 2], + system_time timestamptz NOT NULL + ); + ", + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + retry_matrix integer[][] DEFAULT ARRAY[[1, 2], [3, 4]], + system_time timestamptz NOT NULL + ); + ", + ] { + let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + assert_eq!(validate_migration_catalog(&catalog), Ok(()), "{up_sql}"); + } +} + +#[test] +fn malformed_square_bracket_nesting_fails_closed() { + for up_sql in [ + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + retry_schedule integer[] DEFAULT ARRAY[1, 2, + system_time timestamptz NOT NULL + ); + ", + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + retry_schedule integer[] DEFAULT 1], + system_time timestamptz NOT NULL + ); + ", + ] { + let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::EmptyMigrationSql), + "{up_sql}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_call_execution_context_contract.rs b/crates/persistence_postgres/tests/migration_call_execution_context_contract.rs new file mode 100644 index 000000000..6fe358751 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_call_execution_context_contract.rs @@ -0,0 +1,62 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_call_statements_fail_closed_when_procedure_effects_are_unproven() { + for final_sql in [ + "CALL disable_enforcement();", + "CALL audit_support.disable_enforcement();", + "CALL audit_support.disable_enforcement(mode => 'replica');", + "SELECT 1;CALL audit_support.disable_enforcement();", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "committed CALL must fail closed because called procedure effects are not owned: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_call_does_not_change_durable_migration_effects() { + let final_sql = r#" +BEGIN; +CALL audit_support.disable_enforcement(); +ROLLBACK; +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn call_marker_text_in_opaque_regions_is_not_executed_procedure_evidence() { + let final_sql = r#" +SELECT 'CALL audit_support.disable_enforcement()'; +SELECT $marker$CALL audit_support.disable_enforcement();$marker$; +-- CALL audit_support.disable_enforcement(); +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn opaque_procedure_definition_is_not_immediate_call_execution() { + let final_sql = r#" +CREATE PROCEDURE audit_support_helper() +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', false); +END +$$; +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs b/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs new file mode 100644 index 000000000..584ecf1b5 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs @@ -0,0 +1,49 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn conforming_catalog(extra_sql: &str) -> MigrationCatalog { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n{extra_sql}" + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn concurrently_modifier_does_not_hide_a_valid_index_name() { + for statement in [ + "CREATE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record (tenant_record_id);", + "CREATE INDEX CONCURRENTLY IF NOT EXISTS tenant_record_lookup_index ON tenant_record (tenant_record_id);", + "CREATE UNIQUE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record (tenant_record_id);", + "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS tenant_record_lookup_index ON tenant_record (tenant_record_id);", + ] { + let catalog = conforming_catalog(statement); + assert_eq!(validate_migration_catalog(&catalog), Ok(()), "{statement}"); + } +} + +#[test] +fn concurrently_modifier_is_recognized_after_statement_delimiter_without_whitespace() { + let catalog = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record (tenant_record_id uuid PRIMARY KEY, system_time timestamptz NOT NULL);CREATE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record (tenant_record_id);", + "DROP TABLE tenant_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn concurrently_modifier_cannot_hide_an_invalid_index_name() { + for statement in [ + "CREATE INDEX CONCURRENTLY Bad ON tenant_record (tenant_record_id);", + "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS Bad ON tenant_record (tenant_record_id);", + ] { + let catalog = conforming_catalog(statement); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "{statement}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_create_table_as_contract.rs b/crates/persistence_postgres/tests/migration_create_table_as_contract.rs new file mode 100644 index 000000000..94389cf07 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_create_table_as_contract.rs @@ -0,0 +1,24 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn table_body_lookup_cannot_cross_a_create_table_as_statement_boundary() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record AS SELECT 1; + CREATE TABLE tenant_record_archive ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL, + valid_from timestamptz NOT NULL + ); + ", + r" + DROP TABLE tenant_record; + DROP TABLE tenant_record_archive; + ", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTemporalColumns) + ); +} diff --git a/crates/persistence_postgres/tests/migration_do_execution_context_contract.rs b/crates/persistence_postgres/tests/migration_do_execution_context_contract.rs new file mode 100644 index 000000000..7d4586612 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_do_execution_context_contract.rs @@ -0,0 +1,84 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_do_blocks_fail_closed_when_execution_context_is_opaque() { + for final_sql in [ + r#" +DO $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', false); +END +$$; +"#, + r#" +DO LANGUAGE plpgsql $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', false); +END +$$; +"#, + r#" +DO $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', false); +END +$$ LANGUAGE plpgsql; +"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "committed DO body must fail closed because immediate procedural execution is opaque: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_do_block_does_not_change_durable_migration_effects() { + let final_sql = r#" +BEGIN; +DO $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', true); +END +$$; +ROLLBACK; +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn dollar_quoted_or_comment_marker_text_does_not_impersonate_an_executed_do_block() { + let final_sql = r#" +SELECT $marker$DO $$ BEGIN PERFORM set_config('session_replication_role', 'replica', false); END $$;$marker$; +SELECT 'DO LANGUAGE plpgsql'; +-- DO $$ BEGIN PERFORM set_config('session_replication_role', 'replica', false); END $$; +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn opaque_function_definition_is_not_immediate_do_execution() { + let final_sql = r#" +CREATE FUNCTION audit_support_helper() +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', false); +END +$$; +"#; + + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_down_lexical_contract.rs b/crates/persistence_postgres/tests/migration_down_lexical_contract.rs new file mode 100644 index 000000000..b577df1aa --- /dev/null +++ b/crates/persistence_postgres/tests/migration_down_lexical_contract.rs @@ -0,0 +1,15 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn malformed_rollback_sql_fails_closed_at_the_same_lexical_boundary() { + let embedded = MigrationCatalog::from_embedded().expect("embedded migration catalog"); + let malformed = MigrationCatalog::from_sql( + embedded.up_sql(), + "DROP TABLE tenant_record; /* unterminated rollback comment", + ); + + assert_eq!( + validate_migration_catalog(&malformed), + Err(MigrationContractError::EmptyMigrationSql) + ); +} diff --git a/crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs b/crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs new file mode 100644 index 000000000..2825bb88b --- /dev/null +++ b/crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs @@ -0,0 +1,44 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn table_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_drop_table_cannot_reuse_historical_create_and_rls_evidence() { + let catalog = table_catalog("DROP TABLE tenant_record;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "a committed table removal must fail with an explicit final-state limitation rather than masquerading as empty SQL" + ); +} + +#[test] +fn rolled_back_drop_table_does_not_remove_the_durable_table() { + let catalog = table_catalog("BEGIN; DROP TABLE tenant_record; ROLLBACK;"); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn create_table_only_catalog_remains_supported() { + let catalog = table_catalog(""); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_drop_trigger_final_state_contract.rs b/crates/persistence_postgres/tests/migration_drop_trigger_final_state_contract.rs new file mode 100644 index 000000000..f2743cee1 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_drop_trigger_final_state_contract.rs @@ -0,0 +1,39 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_drop_trigger_cannot_reuse_historical_create_trigger_evidence() { + for final_sql in [ + "DROP TRIGGER source_artifact_reject_mutation ON source_artifact;", + "DROP TRIGGER IF EXISTS source_artifact_reject_mutation ON source_artifact RESTRICT;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "committed DROP TRIGGER must invalidate historical trigger evidence: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_drop_trigger_does_not_change_durable_trigger_state() { + let catalog = embedded_with( + "BEGIN; DROP TRIGGER source_artifact_reject_mutation ON source_artifact; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn marker_like_drop_trigger_text_is_not_a_statement() { + let catalog = embedded_with( + "SELECT 'DROP TRIGGER source_artifact_reject_mutation ON source_artifact'; -- DROP TRIGGER audit_event_reject_mutation ON audit_event", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_escape_string_contract.rs b/crates/persistence_postgres/tests/migration_escape_string_contract.rs new file mode 100644 index 000000000..f4ab365cd --- /dev/null +++ b/crates/persistence_postgres/tests/migration_escape_string_contract.rs @@ -0,0 +1,31 @@ +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +fn conforming_forward(extra_sql: &str) -> String { + format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n{extra_sql}" + ) +} + +#[test] +fn postgres_escape_strings_do_not_break_forward_lexical_validation() { + let up_sql = conforming_forward( + r"SELECT E'it\'s forward metadata'; SELECT e'can\'t declare objects';", + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;"); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn postgres_escape_strings_do_not_break_rollback_lexical_validation() { + let up_sql = conforming_forward(""); + let catalog = MigrationCatalog::from_sql( + &up_sql, + r"SELECT E'it\'s rollback metadata'; DROP TABLE tenant_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_exact_column_contract.rs b/crates/persistence_postgres/tests/migration_exact_column_contract.rs new file mode 100644 index 000000000..ac0e24c5b --- /dev/null +++ b/crates/persistence_postgres/tests/migration_exact_column_contract.rs @@ -0,0 +1,59 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn tenant_boundary_requires_the_exact_tenant_record_id_column() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id_shadow uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTenantBoundary) + ); +} + +#[test] +fn system_time_requires_an_exact_supported_column_name() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time_shadow timestamptz NOT NULL + ); + ", + "DROP TABLE tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTemporalColumns) + ); +} + +#[test] +fn domain_time_requires_an_exact_supported_column_name() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time_shadow timestamptz NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTemporalColumns) + ); +} diff --git a/crates/persistence_postgres/tests/migration_high_bit_dollar_quote_contract.rs b/crates/persistence_postgres/tests/migration_high_bit_dollar_quote_contract.rs new file mode 100644 index 000000000..884c4a20d --- /dev/null +++ b/crates/persistence_postgres/tests/migration_high_bit_dollar_quote_contract.rs @@ -0,0 +1,21 @@ +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +#[test] +fn postgres_high_bit_dollar_quote_tags_mask_declaration_shaped_body_text() { + for tag in ["́¸¡́ •", "€", "đŸ˜€"] { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n\ + SELECT ${tag}$ CREATE INDEX Bad ON tenant_record (tenant_record_id); ${tag}$;" + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "valid PostgreSQL dollar-quote tag {tag} leaked body SQL into validation" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs new file mode 100644 index 000000000..dc787c9bf --- /dev/null +++ b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs @@ -0,0 +1,81 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn postgresql_identifier_continuations_cannot_truncate_to_valid_table_prefixes() { + for table_name in ["tenant_record$shadow", "tenant_record$1", "tenant_record́¸¡́ •"] { + let up_sql = format!( + "CREATE TABLE {table_name} (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );" + ); + let down_sql = format!("DROP TABLE {table_name};"); + let catalog = MigrationCatalog::from_sql(&up_sql, &down_sql); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "PostgreSQL identifier continuation was truncated for {table_name}" + ); + } +} + +#[test] +fn postgresql_identifier_continuations_cannot_donate_rls_target_evidence() { + for policy_target in ["document_record$shadow", "document_record́¸¡́ •"] { + let up_sql = format!( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON {policy_target} + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + " + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE document_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "RLS policy target prefix was accepted for {policy_target}" + ); + } +} + +#[test] +fn postgresql_identifier_continuations_cannot_donate_rls_enable_evidence() { + for alter_target in ["document_record$shadow", "document_record́¸¡́ •"] { + let up_sql = format!( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE {alter_target} ENABLE ROW LEVEL SECURITY; + ALTER TABLE {alter_target} FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + " + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE document_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsEnable), + "RLS enable target prefix was accepted for {alter_target}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_identifier_length_contract.rs b/crates/persistence_postgres/tests/migration_identifier_length_contract.rs new file mode 100644 index 000000000..79007437f --- /dev/null +++ b/crates/persistence_postgres/tests/migration_identifier_length_contract.rs @@ -0,0 +1,33 @@ +use persistence_postgres::{ + MigrationCatalog, MigrationContractError, validate_migration_catalog, +}; + +fn catalog_with_table(table_name: &str) -> MigrationCatalog { + MigrationCatalog::from_sql( + &format!( + "CREATE TABLE {table_name} (\n tenant_record_id uuid NOT NULL,\n system_time timestamptz NOT NULL,\n valid_from timestamptz NOT NULL\n );" + ), + &format!("DROP TABLE {table_name};"), + ) +} + +#[test] +fn postgres_identifier_byte_limit_is_enforced_before_server_truncation() { + let maximum_length_name = + "document_record_projection_snapshot_archive_registry_history_v1"; + let truncated_by_postgres = + "document_record_projection_snapshot_archive_registry_history_v1x"; + + assert_eq!(maximum_length_name.len(), 63); + assert_eq!(truncated_by_postgres.len(), 64); + assert_eq!( + validate_migration_catalog(&catalog_with_table(maximum_length_name)), + Ok(()), + "the PostgreSQL default 63-byte identifier boundary remains admissible" + ); + assert_eq!( + validate_migration_catalog(&catalog_with_table(truncated_by_postgres)), + Err(MigrationContractError::SingleWordObjectName), + "TEPP must reject identifiers PostgreSQL would silently truncate" + ); +} diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs new file mode 100644 index 000000000..4829ff745 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -0,0 +1,267 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn conforming_catalog(extra_sql: &str) -> MigrationCatalog { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n{extra_sql}" + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn quoted_created_object_cannot_bypass_the_naming_contract() { + let catalog = conforming_catalog( + "CREATE INDEX \"Bad\" ON tenant_record (tenant_record_id);", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} + +#[test] +fn quoted_identifier_with_escaped_quote_cannot_truncate_to_a_valid_prefix() { + let catalog = conforming_catalog( + "CREATE INDEX \"good_index\"\"suffix\" ON tenant_record (tenant_record_id);", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} + +#[test] +fn quoted_column_cannot_bypass_the_naming_contract() { + let catalog = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL,\n\ + \"Bad\" text\n\ + );", + "DROP TABLE tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} + +#[test] +fn quoted_column_named_like_a_table_constraint_keyword_is_still_an_identifier() { + for keyword in [ + "constraint", + "primary", + "foreign", + "unique", + "check", + "exclude", + "like", + ] { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL,\n\ + \"{keyword}\" uuid\n\ + );" + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "quoted identifier {keyword} was reinterpreted as table syntax" + ); + } +} + +#[test] +fn dollar_quote_like_bytes_inside_identifiers_do_not_bypass_the_naming_contract() { + for statement in [ + "CREATE INDEX good_index$tag$bad$tag$ ON tenant_record (tenant_record_id);", + "CREATE INDEX bad_index$tag$ ON tenant_record (tenant_record_id);", + "CREATE INDEX bad_index$$tag$ ON tenant_record (tenant_record_id);", + "CREATE INDEX bad_́¸¡́ •$tag$ ON tenant_record (tenant_record_id);", + ] { + let catalog = conforming_catalog(statement); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "{statement}" + ); + } +} + +#[test] +fn declaration_shaped_text_inside_sql_trivia_is_not_an_object() { + let catalog = conforming_catalog( + "-- CREATE INDEX Bad ON tenant_record (tenant_record_id);\n\ + SELECT 'CREATE INDEX Bad ON tenant_record (tenant_record_id);';\n\ + /* CREATE INDEX Bad ON tenant_record (tenant_record_id); */", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn adjacent_atomic_literals_cannot_splice_a_create_keyword() { + let catalog = conforming_catalog( + "SELECT 'CREATE'\n\ + 'INDEX' AS literal_text;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn materialized_view_names_are_covered_by_the_object_naming_contract() { + let catalog = conforming_catalog( + "CREATE MATERIALIZED VIEW Bad AS SELECT tenant_record_id FROM tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} + +#[test] +fn replaceable_view_names_are_covered_by_the_object_naming_contract() { + let catalog = conforming_catalog( + "CREATE OR REPLACE VIEW Bad AS SELECT tenant_record_id FROM tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} + +#[test] +fn qualified_created_object_cannot_hide_an_invalid_object_segment() { + for statement in [ + "CREATE VIEW audit_schema.Bad AS SELECT tenant_record_id FROM tenant_record;", + "CREATE VIEW \"audit_schema\".\"Bad\" AS SELECT tenant_record_id FROM tenant_record;", + ] { + let catalog = conforming_catalog(statement); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "{statement}" + ); + } +} + +#[test] +fn created_role_aliases_are_covered_by_the_object_naming_contract() { + for statement in [ + "CREATE ROLE Bad NOSUPERUSER NOBYPASSRLS;", + "CREATE USER Bad NOSUPERUSER NOBYPASSRLS;", + "CREATE GROUP Bad NOSUPERUSER NOBYPASSRLS;", + ] { + let catalog = conforming_catalog(statement); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "{statement}" + ); + } +} + +#[test] +fn create_user_mapping_is_not_a_role_alias() { + let catalog = conforming_catalog( + "CREATE USER MAPPING FOR CURRENT_USER SERVER foreign_server;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn runtime_role_reference_does_not_substitute_for_role_declaration() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + GRANT SELECT ON TABLE tenant_record TO tepp_app_runtime; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole) + ); +} + +#[test] +fn runtime_role_must_still_exist_after_the_forward_migration() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + DROP ROLE tepp_app_runtime; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE tenant_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole) + ); +} + +#[test] +fn adjacent_statement_delimiters_cannot_hide_runtime_role_drops() { + for drop_alias in ["DROP ROLE", "DROP USER", "DROP GROUP"] { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n\ + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;{drop_alias} tepp_app_runtime;\n\ + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY;\n\ + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY;\n\ + CREATE POLICY tenant_record_tenant_isolation ON tenant_record\n\ + FOR ALL\n\ + USING (tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), ''))\n\ + WITH CHECK (tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), ''));" + ); + let catalog = MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{drop_alias}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_non_ascii_dollar_quote_contract.rs b/crates/persistence_postgres/tests/migration_non_ascii_dollar_quote_contract.rs new file mode 100644 index 000000000..67eabb653 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_non_ascii_dollar_quote_contract.rs @@ -0,0 +1,15 @@ +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +#[test] +fn non_ascii_dollar_quote_body_cannot_declare_migration_objects() { + let catalog = MigrationCatalog::from_sql( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n\ + SELECT $́¸¡́ •$ CREATE INDEX Bad ON tenant_record (tenant_record_id); $́¸¡́ •$;", + "DROP TABLE tenant_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_pg_settings_predicate_contract.rs b/crates/persistence_postgres/tests/migration_pg_settings_predicate_contract.rs new file mode 100644 index 000000000..a48e07291 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_pg_settings_predicate_contract.rs @@ -0,0 +1,64 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn reversed_pg_settings_name_equality_cannot_bypass_replica_guard() { + for final_sql in [ + "UPDATE pg_settings SET setting = 'replica' WHERE 'session_replication_role' = name;", + "UPDATE pg_settings AS p SET setting = 'replica' WHERE 'session_replication_role' = p.name;", + "WITH marker AS (SELECT 1) UPDATE pg_settings AS p SET setting = 'replica' WHERE 'session_replication_role' = p.name;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "operand-reversed equality targets the same protected pg_settings row: {final_sql}", + ); + } +} + +#[test] +fn unproven_pg_settings_predicate_fails_closed_for_unsafe_value() { + for final_sql in [ + "UPDATE pg_settings SET setting = 'replica' WHERE name IN ('session_replication_role');", + "UPDATE pg_settings SET setting = lower('REPLICA') WHERE name LIKE 'session_replication_role';", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "bounded predicate parsing must not treat an unproven target as unrelated: {final_sql}", + ); + } +} + +#[test] +fn direct_unrelated_pg_settings_equality_remains_outside_the_guard() { + for final_sql in [ + "UPDATE pg_settings SET setting = 'replica' WHERE name = 'application_name';", + "UPDATE pg_settings AS p SET setting = lower('REPLICA') WHERE 'application_name' = p.name;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn safe_replication_modes_remain_accepted_for_broad_predicates() { + for final_sql in [ + "UPDATE pg_settings SET setting = 'origin' WHERE name IN ('session_replication_role');", + "UPDATE pg_settings SET setting = 'local' WHERE 'session_replication_role' = name;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_reversed_pg_settings_mutation_is_non_durable() { + let final_sql = "BEGIN; UPDATE pg_settings SET setting = 'replica' WHERE 'session_replication_role' = name; ROLLBACK;"; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_pg_settings_row_assignment_contract.rs b/crates/persistence_postgres/tests/migration_pg_settings_row_assignment_contract.rs new file mode 100644 index 000000000..1af040563 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_pg_settings_row_assignment_contract.rs @@ -0,0 +1,47 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn pg_settings_row_assignment_cannot_bypass_replica_guard() { + for final_sql in [ + "UPDATE pg_settings SET (setting) = ('replica') WHERE name = 'session_replication_role';", + "UPDATE pg_catalog . pg_settings AS p SET (setting) = ROW('replica') WHERE p.name = 'session_replication_role';", + "WITH marker AS (SELECT 1) UPDATE ONLY pg_settings AS p SET (setting) = (SELECT 'replica') WHERE p.name = 'session_replication_role';", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "PostgreSQL row assignment to pg_settings.setting is still a configuration mutation: {final_sql}", + ); + } +} + +#[test] +fn safe_pg_settings_row_assignment_modes_remain_accepted() { + for final_sql in [ + "UPDATE pg_settings SET (setting) = ('origin') WHERE name = 'session_replication_role';", + "UPDATE pg_settings AS p SET (setting) = ROW('local') WHERE p.name = 'session_replication_role';", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unrelated_pg_settings_row_assignment_remains_outside_the_guard() { + let final_sql = + "UPDATE pg_settings SET (setting) = ('replica') WHERE name = 'application_name';"; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn rolled_back_pg_settings_row_assignment_is_non_durable() { + let final_sql = "BEGIN; UPDATE pg_settings SET (setting) = ('replica') WHERE name = 'session_replication_role'; ROLLBACK;"; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs b/crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs new file mode 100644 index 000000000..826bf907e --- /dev/null +++ b/crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs @@ -0,0 +1,99 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_role_replica_login_defaults_cannot_bypass_runtime_trigger_enforcement() { + for final_sql in [ + "ALTER ROLE tepp_app_runtime SET session_replication_role = replica;", + "ALTER ROLE tepp_app_runtime IN DATABASE tepp_database SET session_replication_role TO replica;", + "ALTER USER tepp_app_runtime SET session_replication_role = replica;", + "ALTER USER tepp_app_runtime IN DATABASE tepp_database SET session_replication_role TO replica;", + "ALTER ROLE ALL SET session_replication_role = replica;", + "ALTER USER ALL IN DATABASE tepp_database SET session_replication_role = replica;", + "ALTER ROLE ALL IN DATABASE tepp_database SET session_replication_role TO replica;", + "ALTER ROLE tepp_app_runtime SET session_replication_role FROM CURRENT;", + "ALTER USER tepp_app_runtime SET session_replication_role FROM CURRENT;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "persistent role login default must not suppress ordinary trigger enforcement in later sessions: {final_sql}", + ); + } +} + +#[test] +fn committed_database_or_system_replica_defaults_fail_closed() { + for final_sql in [ + "ALTER DATABASE tepp_database SET session_replication_role = replica;", + "ALTER SYSTEM SET session_replication_role = replica;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "persistent database or system default must not suppress ordinary trigger enforcement in later sessions: {final_sql}", + ); + } +} + +#[test] +fn ordinary_trigger_safe_login_defaults_remain_accepted() { + for final_sql in [ + "ALTER ROLE tepp_app_runtime SET session_replication_role = origin;", + "ALTER USER tepp_app_runtime SET session_replication_role = origin;", + "ALTER ROLE tepp_app_runtime SET session_replication_role TO local;", + "ALTER USER ALL IN DATABASE tepp_database SET session_replication_role = local;", + "ALTER ROLE ALL IN DATABASE tepp_database SET session_replication_role = origin;", + "ALTER DATABASE tepp_database SET session_replication_role = local;", + "ALTER SYSTEM SET session_replication_role = origin;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unrelated_persistent_defaults_do_not_impersonate_replication_role() { + for final_sql in [ + "ALTER ROLE tepp_app_runtime SET application_name = 'replica';", + "ALTER USER tepp_app_runtime SET application_name = 'replica';", + "ALTER ROLE audit_runtime SET session_replication_role = replica;", + "ALTER USER audit_runtime SET session_replication_role = replica;", + "ALTER USER MAPPING FOR tepp_app_runtime SERVER foreign_server OPTIONS (SET user 'replica');", + "ALTER DATABASE tepp_database SET application_name = 'replica';", + "ALTER SYSTEM SET application_name = 'replica';", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_role_or_database_default_mutation_is_not_durable() { + for final_sql in [ + "BEGIN; ALTER ROLE tepp_app_runtime SET session_replication_role = replica; ROLLBACK;", + "BEGIN; ALTER USER tepp_app_runtime SET session_replication_role = replica; ROLLBACK;", + "BEGIN; ALTER ROLE ALL IN DATABASE tepp_database SET session_replication_role = replica; ROLLBACK;", + "BEGIN; ALTER DATABASE tepp_database SET session_replication_role = replica; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn marker_like_persistent_default_text_is_not_a_configuration_change() { + let catalog = embedded_with( + r#" +SELECT 'ALTER ROLE tepp_app_runtime SET session_replication_role = replica'; +-- ALTER USER tepp_app_runtime SET session_replication_role = replica; +SELECT $$ALTER DATABASE tepp_database SET session_replication_role = replica$$; +SELECT 'ALTER SYSTEM SET session_replication_role = replica'; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_retention_guard_routine_final_state_contract.rs b/crates/persistence_postgres/tests/migration_retention_guard_routine_final_state_contract.rs new file mode 100644 index 000000000..5955663ce --- /dev/null +++ b/crates/persistence_postgres/tests/migration_retention_guard_routine_final_state_contract.rs @@ -0,0 +1,111 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_retention_guard_removal_cannot_reuse_historical_retention_evidence() { + for final_sql in [ + "DROP FUNCTION reject_held_evidence_deletion() CASCADE;", + "DROP ROUTINE IF EXISTS public . reject_held_evidence_deletion() CASCADE;", + "DROP FUNCTION reject_tombstoned_evidence_restore() CASCADE;", + "DROP ROUTINE IF EXISTS public . reject_tombstoned_evidence_restore() CASCADE;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingRetentionLegalHold), + "committed retention guard removal must invalidate historical retention evidence: {final_sql}", + ); + } +} + +#[test] +fn committed_retention_guard_replacement_cannot_reuse_the_original_definition() { + for guard in [ + "reject_held_evidence_deletion", + "reject_tombstoned_evidence_restore", + ] { + let catalog = embedded_with(&format!( + r#" +CREATE OR REPLACE FUNCTION public . {guard}() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NEW; +END +$tepp$; +"#, + )); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRetentionLegalHold), + "committed replacement must invalidate historical retention guard evidence: {guard}", + ); + } +} + +#[test] +fn unrelated_schema_routine_mutations_do_not_target_public_retention_guards() { + let dropped = embedded_with( + "DROP FUNCTION audit_support.reject_held_evidence_deletion() CASCADE;", + ); + assert_eq!(validate_migration_catalog(&dropped), Ok(())); + + let replaced = embedded_with( + r#" +CREATE OR REPLACE FUNCTION audit_support . reject_tombstoned_evidence_restore() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NEW; +END +$tepp$; +"#, + ); + assert_eq!(validate_migration_catalog(&replaced), Ok(())); +} + +#[test] +fn rolled_back_retention_guard_mutations_do_not_change_durable_enforcement() { + for final_sql in [ + "BEGIN; DROP FUNCTION reject_held_evidence_deletion() CASCADE; ROLLBACK;", + "BEGIN; DROP ROUTINE reject_tombstoned_evidence_restore() CASCADE; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } + + let replaced = embedded_with( + r#" +BEGIN; +CREATE OR REPLACE FUNCTION reject_held_evidence_deletion() +RETURNS trigger +LANGUAGE plpgsql +AS $tepp$ +BEGIN + RETURN NEW; +END +$tepp$; +ROLLBACK; +"#, + ); + assert_eq!(validate_migration_catalog(&replaced), Ok(())); +} + +#[test] +fn marker_like_retention_guard_mutations_are_not_statements() { + let catalog = embedded_with( + r#" +SELECT 'DROP FUNCTION reject_held_evidence_deletion() CASCADE'; +SELECT $$CREATE OR REPLACE FUNCTION reject_tombstoned_evidence_restore()$$; +-- DROP ROUTINE reject_tombstoned_evidence_restore() CASCADE; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_rls_alter_policy_final_state_contract.rs b/crates/persistence_postgres/tests/migration_rls_alter_policy_final_state_contract.rs new file mode 100644 index 000000000..9103440d3 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_alter_policy_final_state_contract.rs @@ -0,0 +1,48 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn policy_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_alter_policy_cannot_reuse_historical_safe_create_policy_evidence() { + let catalog = policy_catalog( + "ALTER POLICY tenant_record_tenant_isolation ON tenant_record USING (true);", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "a committed policy mutation must not inherit stale safe CREATE POLICY evidence" + ); +} + +#[test] +fn rolled_back_alter_policy_does_not_poison_the_durable_policy_definition() { + let catalog = policy_catalog( + "BEGIN; ALTER POLICY tenant_record_tenant_isolation ON tenant_record USING (true); ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn create_policy_only_catalog_remains_supported() { + let catalog = policy_catalog(""); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_rls_drop_policy_final_state_contract.rs b/crates/persistence_postgres/tests/migration_rls_drop_policy_final_state_contract.rs new file mode 100644 index 000000000..896c13a37 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_drop_policy_final_state_contract.rs @@ -0,0 +1,48 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn policy_catalog(final_mutation: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutation} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_drop_policy_cannot_reuse_historical_safe_create_policy_evidence() { + let catalog = policy_catalog( + "DROP POLICY tenant_record_tenant_isolation ON tenant_record;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "a committed policy removal must not inherit stale safe CREATE POLICY evidence" + ); +} + +#[test] +fn rolled_back_drop_policy_does_not_remove_the_durable_policy_definition() { + let catalog = policy_catalog( + "BEGIN; DROP POLICY tenant_record_tenant_isolation ON tenant_record; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn create_policy_only_catalog_remains_supported() { + let catalog = policy_catalog(""); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs new file mode 100644 index 000000000..2aa33a1bd --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -0,0 +1,269 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(policy_predicate: &str, tenant_setting: &str) -> MigrationCatalog { + let up_sql = format!( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + tenant_record_id_shadow uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + {policy_predicate}::text = nullif(current_setting('{tenant_setting}', true), '') + ) + WITH CHECK ( + {policy_predicate}::text = nullif(current_setting('{tenant_setting}', true), '') + ); + " + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE document_record;") +} + +#[test] +fn tenant_policy_requires_the_exact_tenant_record_id_identifier() { + let catalog = rls_catalog( + "tenant_record_id_shadow", + "tepp.current_tenant_record_id", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn tenant_policy_requires_the_exact_session_guc_key() { + let catalog = rls_catalog( + "tenant_record_id", + "tepp.current_tenant_record_id_shadow", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTenantSessionGuc) + ); +} + +#[test] +fn tenant_guc_literal_without_current_setting_does_not_satisfy_the_contract() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + SELECT 'tepp.current_tenant_record_id'; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING (tenant_record_id IS NOT NULL) + WITH CHECK (tenant_record_id IS NOT NULL); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTenantSessionGuc) + ); +} + +#[test] +fn tenant_setting_call_outside_policy_cannot_cover_policy() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + SELECT current_setting('tepp.current_tenant_record_id', true); + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING (tenant_record_id IS NOT NULL) + WITH CHECK (tenant_record_id IS NOT NULL); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn arbitrary_schema_qualified_current_setting_does_not_satisfy_the_contract() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = + tenant_schema.current_setting('tepp.current_tenant_record_id', true) + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTenantSessionGuc) + ); +} + +#[test] +fn later_statement_tenant_identifier_cannot_cover_a_weak_policy() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + document_record_id::text = + current_setting('tepp.current_tenant_record_id', true) + ); + SELECT tenant_record_id FROM document_record; + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn restrictive_supplemental_policy_need_not_repeat_the_tenant_session_predicate() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + CREATE POLICY document_record_visibility_guard ON document_record + AS RESTRICTIVE + FOR SELECT + USING (document_record_id IS NOT NULL); + ", + "DROP TABLE document_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn restrictive_alias_inside_using_does_not_change_permissive_policy_composition() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + SELECT current_setting('tepp.current_tenant_record_id', true); + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_visibility_guard ON document_record + FOR SELECT + USING ( + tenant_record_id IS NOT NULL + AND EXISTS (SELECT 1 AS restrictive) + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn tenant_policy_on_a_longer_table_name_cannot_cover_a_prefix_table() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE TABLE document_record_archive ( + document_record_archive_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + ALTER TABLE document_record_archive ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record_archive FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_archive_tenant_isolation ON document_record_archive + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE document_record_archive; DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} diff --git a/crates/persistence_postgres/tests/migration_rls_policy_header_spoof_contract.rs b/crates/persistence_postgres/tests/migration_rls_policy_header_spoof_contract.rs new file mode 100644 index 000000000..387a827ad --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_policy_header_spoof_contract.rs @@ -0,0 +1,30 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn tenant_identifier_in_policy_name_cannot_substitute_for_predicate_binding() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_id ON document_record + FOR ALL + USING ( + document_record_id::text = + current_setting('tepp.current_tenant_record_id', true) + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs new file mode 100644 index 000000000..3fa288c26 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -0,0 +1,253 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn tenant_identifier_and_session_guc_must_form_the_same_equality_binding() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id IS NOT NULL + AND current_setting('tepp.current_tenant_record_id', true) IS NOT NULL + ) + WITH CHECK ( + tenant_record_id IS NOT NULL + AND current_setting('tepp.current_tenant_record_id', true) IS NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn explicit_with_check_cannot_weaken_a_tenant_bound_using_clause() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id IS NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn update_policy_cannot_omit_tenant_bound_using_clause() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR UPDATE + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn all_policy_cannot_omit_tenant_bound_using_clause() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn all_policy_detects_punctuation_adjacent_explicit_weak_check() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + )WITH CHECK ( + tenant_record_id IS NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn update_policy_detects_punctuation_adjacent_explicit_weak_check() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR UPDATE + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + )WITH CHECK ( + tenant_record_id IS NOT NULL + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn permissive_policy_rejects_unbound_top_level_or_path() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + OR true + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + OR true + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn permissive_policy_rejects_unbound_or_inside_boolean_wrapper() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + coalesce( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + OR true, + false + ) + ) + WITH CHECK ( + coalesce( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + OR true, + false + ) + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} diff --git a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs new file mode 100644 index 000000000..8ad7de5b6 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs @@ -0,0 +1,151 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +const TABLE_AND_ROLE: &str = r" +CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL +); +CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; +CREATE ROLE writer_role NOSUPERUSER NOBYPASSRLS; +CREATE ROLE reader_role NOSUPERUSER NOBYPASSRLS; +ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_record FORCE ROW LEVEL SECURITY; +"; + +const TENANT_BINDING: &str = + "tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '')"; + +fn catalog_with_policies(policies: &str) -> MigrationCatalog { + MigrationCatalog::from_sql( + &format!("{TABLE_AND_ROLE}\n{policies}"), + "DROP TABLE document_record;", + ) +} + +#[test] +fn restrictive_only_policy_cannot_satisfy_the_tenant_access_contract() { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_tenant_guard ON document_record + AS RESTRICTIVE + FOR ALL + USING ({TENANT_BINDING}) + WITH CHECK ({TENANT_BINDING}); +" + )); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn restrictive_policy_requires_permissive_coverage_for_the_same_command() { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_insert_isolation ON document_record + AS PERMISSIVE + FOR INSERT + WITH CHECK ({TENANT_BINDING}); +CREATE POLICY document_record_read_guard ON document_record + AS RESTRICTIVE + FOR SELECT + USING (document_record_id IS NOT NULL); +" + )); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn restrictive_policy_requires_permissive_coverage_for_the_same_role() { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_writer_isolation ON document_record + AS PERMISSIVE + FOR SELECT + TO writer_role + USING ({TENANT_BINDING}); +CREATE POLICY document_record_reader_guard ON document_record + AS RESTRICTIVE + FOR SELECT + TO reader_role + USING (document_record_id IS NOT NULL); +" + )); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} + +#[test] +fn malformed_policy_role_lists_fail_closed() { + for role_list in ["reader_role,", "reader_role,,writer_role", ",reader_role"] { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_reader_isolation ON document_record + AS PERMISSIVE + FOR SELECT + TO reader_role + USING ({TENANT_BINDING}); +CREATE POLICY document_record_reader_guard ON document_record + AS RESTRICTIVE + FOR SELECT + TO {role_list} + USING (document_record_id IS NOT NULL); +" + )); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "malformed TO role list must fail closed: {role_list}" + ); + } +} + +#[test] +fn restrictive_policy_may_narrow_matching_permissive_access() { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_read_isolation ON document_record + AS PERMISSIVE + FOR SELECT + USING ({TENANT_BINDING}); +CREATE POLICY document_record_read_guard ON document_record + AS RESTRICTIVE + FOR SELECT + USING (document_record_id IS NOT NULL); +" + )); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn restrictive_policy_may_narrow_matching_role_access() { + let catalog = catalog_with_policies(&format!( + r" +CREATE POLICY document_record_reader_isolation ON document_record + AS PERMISSIVE + FOR SELECT + TO reader_role + USING ({TENANT_BINDING}); +CREATE POLICY document_record_reader_guard ON document_record + AS RESTRICTIVE + FOR SELECT + TO reader_role + USING (document_record_id IS NOT NULL); +" + )); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs b/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs new file mode 100644 index 000000000..fc58b10d4 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs @@ -0,0 +1,49 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn policy_catalog(transaction_outcome: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + AS PERMISSIVE + FOR SELECT + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + + BEGIN; + CREATE POLICY tenant_record_transactional_bad_policy ON tenant_record + AS PERMISSIVE + FOR SELECT + USING (tenant_record_id IS NOT NULL); + {transaction_outcome}; + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn rolled_back_invalid_policy_cannot_reject_valid_final_rls_state() { + let catalog = policy_catalog("ROLLBACK"); + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "policy evidence that PostgreSQL rolls back must not reject a valid final tenant-isolation policy set" + ); +} + +#[test] +fn committed_invalid_policy_still_rejects_the_final_rls_state() { + let catalog = policy_catalog("COMMIT"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "the committed-state projection must not hide an invalid permissive policy that PostgreSQL keeps" + ); +} diff --git a/crates/persistence_postgres/tests/migration_rls_session_expression_contract.rs b/crates/persistence_postgres/tests/migration_rls_session_expression_contract.rs new file mode 100644 index 000000000..1f19ad4c1 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_session_expression_contract.rs @@ -0,0 +1,38 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn tenant_session_operand_cannot_fallback_to_the_row_tenant_identifier() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE document_record ( + document_record_id uuid PRIMARY KEY, + tenant_record_id uuid NOT NULL, + system_time timestamptz NOT NULL, + available_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE document_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE document_record FORCE ROW LEVEL SECURITY; + CREATE POLICY document_record_tenant_isolation ON document_record + FOR ALL + USING ( + tenant_record_id::text = coalesce( + current_setting('tepp.current_tenant_record_id', true), + tenant_record_id::text + ) + ) + WITH CHECK ( + tenant_record_id::text = coalesce( + current_setting('tepp.current_tenant_record_id', true), + tenant_record_id::text + ) + ); + ", + "DROP TABLE document_record;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy) + ); +} diff --git a/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs new file mode 100644 index 000000000..7ee258bc5 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs @@ -0,0 +1,109 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(final_mutations: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {final_mutations} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn committed_disable_row_level_security_removes_final_enablement() { + let catalog = rls_catalog("ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsEnable), + "historical ENABLE evidence must not survive a committed final DISABLE" + ); +} + +#[test] +fn committed_no_force_row_level_security_removes_final_owner_enforcement() { + let catalog = rls_catalog("ALTER TABLE tenant_record NO FORCE ROW LEVEL SECURITY;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsEnable), + "historical FORCE evidence must not survive a committed final NO FORCE" + ); +} + +#[test] +fn rolled_back_disable_does_not_change_the_durable_rls_state() { + let catalog = rls_catalog( + "BEGIN; ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn later_enable_and_force_restore_the_required_final_state() { + let catalog = rls_catalog( + r#" + ALTER TABLE tenant_record DISABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record NO FORCE ROW LEVEL SECURITY; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + "#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +fn assert_qualified_sibling_does_not_repair_disabled_target( + disabled_target: &str, + sibling_target: &str, +) { + let embedded = MigrationCatalog::from_embedded().expect("embedded catalog must load"); + let up_sql = format!( + "{}\nALTER TABLE {disabled_target} DISABLE ROW LEVEL SECURITY;\nALTER TABLE {sibling_target} ENABLE ROW LEVEL SECURITY;\nALTER TABLE {sibling_target} FORCE ROW LEVEL SECURITY;", + embedded.up_sql() + ); + let catalog = MigrationCatalog::from_sql(&up_sql, embedded.down_sql()); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsEnable), + "schema qualification must not collapse tenant_record and event_instance into one RLS state bucket" + ); +} + +#[test] +fn whitespace_qualified_sibling_table_cannot_repair_disabled_rls_state() { + assert_qualified_sibling_does_not_repair_disabled_target( + "public . tenant_record", + "public . event_instance", + ); +} + +#[test] +fn period_adjacent_qualified_sibling_table_cannot_repair_disabled_rls_state() { + assert_qualified_sibling_does_not_repair_disabled_target( + "public .tenant_record", + "public .event_instance", + ); +} + +#[test] +fn rolled_back_whitespace_qualified_disable_remains_non_durable() { + let embedded = MigrationCatalog::from_embedded().expect("embedded catalog must load"); + let up_sql = format!( + "{}\nBEGIN; ALTER TABLE public . tenant_record DISABLE ROW LEVEL SECURITY; ROLLBACK;", + embedded.up_sql() + ); + let catalog = MigrationCatalog::from_sql(&up_sql, embedded.down_sql()); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs b/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs new file mode 100644 index 000000000..b5e6a2703 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs @@ -0,0 +1,48 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn policy_catalog(transaction_outcome: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + + SELECT current_setting('tepp.current_tenant_record_id', true); + + CREATE POLICY tenant_record_visibility_guard ON tenant_record + AS RESTRICTIVE + FOR SELECT + USING (tenant_record_id IS NOT NULL); + + BEGIN; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + AS PERMISSIVE + FOR SELECT + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {transaction_outcome}; + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn rolled_back_permissive_policy_cannot_cover_surviving_restrictive_policy() { + let catalog = policy_catalog("ROLLBACK"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "rolled-back permissive policy must not satisfy final restrictive-policy composition" + ); +} + +#[test] +fn committed_permissive_policy_can_cover_surviving_restrictive_policy() { + let catalog = policy_catalog("COMMIT"); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_role_rename_contract.rs b/crates/persistence_postgres/tests/migration_role_rename_contract.rs new file mode 100644 index 000000000..8174e2564 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_role_rename_contract.rs @@ -0,0 +1,51 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn renaming_the_runtime_role_away_invalidates_final_state_evidence() { + for alter_alias in ["ALTER ROLE", "ALTER USER", "ALTER GROUP"] { + let catalog = rls_catalog(&format!( + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\n{alter_alias} tepp_app_runtime RENAME TO archived_runtime_role;" + )); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{alter_alias}" + ); + } +} + +#[test] +fn role_rename_target_cannot_bypass_the_object_naming_contract() { + let catalog = rls_catalog( + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE tepp_app_runtime RENAME TO Bad;", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs new file mode 100644 index 000000000..d5b3f5d78 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs @@ -0,0 +1,81 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn grant(grantor: &str, set_enabled: bool) -> String { + format!( + "GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE, SET {set_enabled}, ADMIN FALSE GRANTED BY {grantor};" + ) +} + +fn revoke(grantor: &str) -> String { + format!("REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY {grantor};") +} + +#[test] +fn arbitrary_distinct_quoted_grantors_do_not_collapse_to_one_provenance_key() { + for (unsafe_grantor, safe_grantor) in [ + (r#""Grantor-A""#, r#""Grantor-B""#), + (r#""권한A""#, r#""권한B""#), + (r#""Grantor""A""#, r#""Grantor""B""#), + ] { + let role_sql = format!( + "{}\n{}\n{}", + grant(unsafe_grantor, true), + grant(safe_grantor, false), + revoke(safe_grantor), + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "revoking {safe_grantor} must not erase distinct unsafe grantor {unsafe_grantor}" + ); + } +} + +#[test] +fn revoking_every_arbitrary_quoted_grantor_path_restores_safety() { + for (unsafe_grantor, safe_grantor) in [ + (r#""Grantor-A""#, r#""Grantor-B""#), + (r#""권한A""#, r#""권한B""#), + (r#""Grantor""A""#, r#""Grantor""B""#), + ] { + let role_sql = format!( + "{}\n{}\n{}\n{}", + grant(unsafe_grantor, true), + grant(safe_grantor, false), + revoke(safe_grantor), + revoke(unsafe_grantor), + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "every distinct grantor row has been revoked for {unsafe_grantor} / {safe_grantor}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs new file mode 100644 index 000000000..a09864181 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs @@ -0,0 +1,61 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn distinct_quoted_grantor_paths() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY "GrantorA"; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET FALSE, ADMIN FALSE + GRANTED BY "GrantorB"; + "# +} + +#[test] +fn revoking_one_case_distinct_quoted_grantor_does_not_erase_another_unsafe_path() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY \"GrantorB\";", + distinct_quoted_grantor_paths() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "GrantorA and GrantorB are distinct PostgreSQL quoted role identities" + ); +} + +#[test] +fn revoking_both_case_distinct_quoted_grantor_paths_restores_safety() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY \"GrantorB\";\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY \"GrantorA\";", + distinct_quoted_grantor_paths() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs new file mode 100644 index 000000000..3fea9fd65 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs @@ -0,0 +1,66 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn quoted_current_user_and_pseudo_target_grants() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE "current_user" NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + GRANT reporting_owner TO "current_user" WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + GRANT reporting_owner TO CURRENT_USER WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY "current_user"; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET FALSE, ADMIN FALSE + GRANTED BY CURRENT_USER; + "# +} + +#[test] +fn quoted_named_grantor_does_not_alias_the_current_user_pseudo_target() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER;", + quoted_current_user_and_pseudo_target_grants() + ); + + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "the quoted named grantor remains SET-capable after only CURRENT_USER is revoked" + ); +} + +#[test] +fn explicitly_revoking_the_quoted_and_pseudo_grantor_paths_restores_safety() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER;\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY \"current_user\";", + quoted_current_user_and_pseudo_target_grants() + ); + + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_local_transaction_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_local_transaction_contract.rs new file mode 100644 index 000000000..09df86dbb --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_local_transaction_contract.rs @@ -0,0 +1,125 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn role_declarations() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_b NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + "# +} + +#[test] +fn commit_restores_session_role_after_local_role_grantor() { + let role_sql = format!( + r#" + {} + SET ROLE grantor_a; + BEGIN; + SET LOCAL ROLE grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY CURRENT_USER; + COMMIT; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "post-COMMIT grantor_a revoke must not erase the unsafe row recorded under local grantor_b" + ); +} + +#[test] +fn explicit_local_role_grantor_cleanup_restores_safety() { + let role_sql = format!( + r#" + {} + SET ROLE grantor_a; + BEGIN; + SET LOCAL ROLE grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY CURRENT_USER; + COMMIT; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; + SET ROLE grantor_b; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn commit_restores_session_authorization_after_local_override() { + let role_sql = format!( + r#" + {} + SET SESSION AUTHORIZATION grantor_a; + BEGIN; + SET LOCAL SESSION AUTHORIZATION grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY SESSION_USER; + COMMIT; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "post-COMMIT grantor_a revoke must not erase the unsafe row recorded under local session grantor_b" + ); +} + +#[test] +fn explicit_local_session_grantor_cleanup_restores_safety() { + let role_sql = format!( + r#" + {} + SET SESSION AUTHORIZATION grantor_a; + BEGIN; + SET LOCAL SESSION AUTHORIZATION grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY SESSION_USER; + COMMIT; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER; + SET SESSION AUTHORIZATION grantor_b; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs new file mode 100644 index 000000000..6ccf9b4ff --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs @@ -0,0 +1,67 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn grantor_roles() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_b NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + GRANT reporting_owner TO grantor_a WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + GRANT reporting_owner TO grantor_b WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY grantor_a; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET FALSE, ADMIN FALSE + GRANTED BY grantor_b; + "# +} + +#[test] +fn revoking_one_grantor_does_not_erase_an_unsafe_alternative_membership_grant() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_b;", + grantor_roles() + ); + + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "a SET-capable grantor_a membership remains after only grantor_b is revoked" + ); +} + +#[test] +fn explicitly_revoking_every_grantor_path_restores_runtime_membership_safety() { + let role_sql = format!( + "{}\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_b;\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_a;", + grantor_roles() + ); + + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_session_authorization_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_session_authorization_contract.rs new file mode 100644 index 000000000..658e373cb --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_session_authorization_contract.rs @@ -0,0 +1,64 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn session_switching_grants() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_b NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + + SET SESSION AUTHORIZATION grantor_a; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY SESSION_USER; + + SET SESSION AUTHORIZATION grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET FALSE, ADMIN FALSE + GRANTED BY SESSION_USER; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER; + "# +} + +#[test] +fn session_user_grantor_tracks_set_session_authorization() { + let catalog = rls_catalog(session_switching_grants()); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "revoking grantor_b must not erase the earlier SET-capable row recorded under grantor_a" + ); +} + +#[test] +fn explicitly_revoking_each_session_user_grantor_restores_safety() { + let role_sql = format!( + "{}\nSET SESSION AUTHORIZATION grantor_a;\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY SESSION_USER;\nRESET SESSION AUTHORIZATION;", + session_switching_grants() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_set_role_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_set_role_contract.rs new file mode 100644 index 000000000..308d22cd3 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_set_role_contract.rs @@ -0,0 +1,69 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn role_switching_current_user_grants() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_b NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + + GRANT reporting_owner TO grantor_a WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + GRANT reporting_owner TO grantor_b WITH INHERIT FALSE, SET FALSE, ADMIN TRUE; + + SET ROLE grantor_a; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY CURRENT_USER; + RESET ROLE; + + SET ROLE grantor_b; + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET FALSE, ADMIN FALSE + GRANTED BY CURRENT_USER; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; + RESET ROLE; + "# +} + +#[test] +fn current_user_grantor_tracks_the_effective_role_after_set_role() { + let catalog = rls_catalog(role_switching_current_user_grants()); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "revoking grantor_b must not erase the earlier SET-capable grant recorded under grantor_a" + ); +} + +#[test] +fn explicitly_revoking_each_effective_current_user_grantor_restores_safety() { + let role_sql = format!( + "{}\nSET ROLE grantor_a;\nREVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER;\nRESET ROLE;", + role_switching_current_user_grants() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs new file mode 100644 index 000000000..a5f639efc --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs @@ -0,0 +1,38 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING (tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '')) + WITH CHECK (tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '')); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn quoted_grantor_identity_is_whitespace_insensitive_after_granted_keyword() { + for separator in [" ", "\n", "\t"] { + let role_sql = format!( + "GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE, SET TRUE, ADMIN FALSE GRANTED{separator}BY \"Grantor-A\";\n\ + GRANT reporting_owner TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN FALSE GRANTED{separator}BY \"Grantor-B\";\n\ + REVOKE reporting_owner FROM tepp_app_runtime GRANTED{separator}BY \"Grantor-B\";" + ); + assert_eq!( + validate_migration_catalog(&catalog(&role_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "whitespace between GRANTED and BY must not collapse distinct grantor provenance" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_inherit_safety_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_inherit_safety_contract.rs new file mode 100644 index 000000000..08eaa1836 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_inherit_safety_contract.rs @@ -0,0 +1,39 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn inherited_membership_without_owner_safety_evidence_fails_closed() { + for role_sql in [ + "CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_owner TO tepp_app_runtime WITH INHERIT TRUE, SET FALSE, ADMIN FALSE;", + "CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_owner TO tepp_app_runtime WITH SET FALSE, ADMIN FALSE;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs new file mode 100644 index 000000000..cd40f67cd --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -0,0 +1,240 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn runtime_role_cannot_bypass_rls_or_be_superuser() { + for role_sql in [ + "CREATE ROLE tepp_app_runtime BYPASSRLS;", + "CREATE ROLE tepp_app_runtime SUPERUSER;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE tepp_app_runtime BYPASSRLS;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER USER tepp_app_runtime SUPERUSER;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER GROUP tepp_app_runtime BYPASSRLS;", + "CREATE ROLE staged_runtime_role BYPASSRLS;\nALTER ROLE staged_runtime_role RENAME TO tepp_app_runtime;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn role_identifier_continuations_cannot_retarget_runtime_security_state() { + for role_sql in [ + "CREATE ROLE tepp_app_runtime BYPASSRLS;\nALTER ROLE tepp_app_runtime$shadow NOSUPERUSER NOBYPASSRLS;", + "CREATE ROLE tepp_app_runtime BYPASSRLS;\nALTER ROLE tepp_app_runtim鸡́ • NOSUPERUSER NOBYPASSRLS;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn quoted_role_case_cannot_retarget_runtime_security_state() { + for role_sql in [ + "CREATE ROLE tepp_app_runtime BYPASSRLS;\nALTER ROLE \"TEPP_APP_RUNTIME\" NOSUPERUSER NOBYPASSRLS;", + "CREATE ROLE tepp_app_runtime BYPASSRLS;\nALTER ROLE \"tepp_app_Runtime\" NOSUPERUSER NOBYPASSRLS;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn quoted_lowercase_runtime_name_keeps_postgresql_identity() { + let catalog = rls_catalog( + "CREATE ROLE tepp_app_runtime BYPASSRLS;\nALTER ROLE \"tepp_app_runtime\" NOSUPERUSER NOBYPASSRLS;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn quoted_special_role_names_cannot_alias_unquoted_role_specifications() { + for special_role in ["current_user", "current_role", "session_user"] { + let role_sql = format!( + "CREATE ROLE \"{special_role}\" BYPASSRLS;\nALTER ROLE {special_role} NOBYPASSRLS;\nALTER ROLE \"{special_role}\" RENAME TO tepp_app_runtime;" + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn quoted_membership_grantee_case_cannot_remove_runtime_escape_path() { + let catalog = rls_catalog( + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime;\nREVOKE rls_bypass_operator FROM \"TEPP_APP_RUNTIME\";", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole) + ); +} + +#[test] +fn quoted_lowercase_membership_grantee_keeps_postgresql_identity() { + let catalog = rls_catalog( + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime;\nREVOKE rls_bypass_operator FROM \"tepp_app_runtime\";", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn quoted_membership_keywords_do_not_hide_runtime_set_paths() { + for role_sql in [ + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO \"with\", tepp_app_runtime;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO \"granted\", tepp_app_runtime;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT \"to\" TO tepp_app_runtime;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT \"group\" TO tepp_app_runtime;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn quoted_revoke_clause_keyword_before_runtime_still_removes_membership() { + let catalog = rls_catalog( + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime;\nREVOKE rls_bypass_operator FROM \"cascade\", tepp_app_runtime;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn runtime_role_cannot_gain_a_set_role_path_around_rls() { + for role_sql in [ + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH SET TRUE;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH SET OPTION;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT \"on\" TO tepp_app_runtime;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN ROLE rls_bypass_operator;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN GROUP rls_bypass_operator;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE USER tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN GROUP rls_bypass_operator;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE GROUP tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN GROUP rls_bypass_operator;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nREVOKE SET OPTION FOR rls_bypass_operator FROM tepp_app_runtime;\nGRANT rls_bypass_operator TO tepp_app_runtime;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn admin_membership_can_self_enable_set_role() { + for role_sql in [ + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN TRUE;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH ADMIN TRUE, INHERIT FALSE, SET FALSE;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH ADMIN TRUE;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn explicit_noninherit_membership_and_existing_grant_direction_remain_rls_safe() { + for role_sql in [ + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN FALSE;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT tepp_app_runtime TO CURRENT_USER;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT SELECT ON TABLE tenant_record TO tepp_app_runtime;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(()), "{role_sql}"); + } +} + +#[test] +fn final_runtime_membership_state_may_explicitly_restore_rls_safety() { + for role_sql in [ + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime;\nGRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN FALSE;", + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime;\nREVOKE SET OPTION FOR reporting_operator FROM tepp_app_runtime;\nREVOKE INHERIT OPTION FOR reporting_operator FROM tepp_app_runtime;", + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime;\nREVOKE reporting_operator FROM tepp_app_runtime;", + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN TRUE;\nREVOKE ADMIN OPTION FOR reporting_operator FROM tepp_app_runtime;", + "CREATE ROLE reporting_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT reporting_operator TO tepp_app_runtime WITH INHERIT FALSE, SET FALSE, ADMIN TRUE;\nGRANT reporting_operator TO tepp_app_runtime WITH ADMIN FALSE;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(()), "{role_sql}"); + } +} + +#[test] +fn malformed_role_lifecycle_statements_fail_closed_without_panicking() { + for role_sql in [ + "CREATE ROLE;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nDROP ROLE;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nDROP ROLE , other_role;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nDROP ROLE other_role,;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nDROP ROLE other_role,,another_role;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nDROP ROLE other_role another_role;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE tepp_app_runtime;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE tepp_app_runtime RENAME;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE tepp_app_runtime RENAME TO;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn final_runtime_role_state_may_explicitly_restore_rls_safety() { + let catalog = rls_catalog( + "CREATE ROLE tepp_app_runtime SUPERUSER BYPASSRLS;\nALTER ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_runtime_role_transaction_outcome_contract.rs b/crates/persistence_postgres/tests/migration_runtime_role_transaction_outcome_contract.rs new file mode 100644 index 000000000..135141a00 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_transaction_outcome_contract.rs @@ -0,0 +1,108 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn rls_catalog(role_sql: &str) -> MigrationCatalog { + let up_sql = format!( + r#" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +fn role_declarations() -> &'static str { + r#" + CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; + CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; + CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; + "# +} + +#[test] +fn rolled_back_membership_revoke_cannot_donate_safety() { + let role_sql = format!( + r#" + {} + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE; + BEGIN; + REVOKE reporting_owner FROM tepp_app_runtime; + ROLLBACK; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole) + ); +} + +#[test] +fn committed_membership_revoke_restores_safety() { + let role_sql = format!( + r#" + {} + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE; + BEGIN; + REVOKE reporting_owner FROM tepp_app_runtime; + COMMIT; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn rolled_back_explicit_grantor_revoke_cannot_donate_safety() { + let role_sql = format!( + r#" + {} + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY grantor_a; + BEGIN; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_a; + ROLLBACK; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole) + ); +} + +#[test] +fn committed_explicit_grantor_revoke_restores_safety() { + let role_sql = format!( + r#" + {} + GRANT reporting_owner TO tepp_app_runtime + WITH INHERIT FALSE, SET TRUE, ADMIN FALSE + GRANTED BY grantor_a; + BEGIN; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_a; + COMMIT; + "#, + role_declarations() + ); + let catalog = rls_catalog(&role_sql); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs new file mode 100644 index 000000000..8775e1f41 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -0,0 +1,225 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_replica_execution_mode_cannot_bypass_runtime_trigger_enforcement() { + for final_sql in [ + "SET session_replication_role = replica;", + "SET SESSION session_replication_role TO replica;", + "BEGIN; SET LOCAL session_replication_role = replica; COMMIT;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "committed replica execution mode must invalidate the runtime-role safety contract: {final_sql}", + ); + } +} + +#[test] +fn committed_set_config_replica_mode_cannot_bypass_runtime_trigger_enforcement() { + for final_sql in [ + "SELECT set_config('session_replication_role', 'replica', false);", + "SELECT pg_catalog . set_config('session_replication_role', 'replica', false);", + "BEGIN; SELECT set_config('session_replication_role', 'replica', true); COMMIT;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "committed set_config replica mode must invalidate runtime-role safety: {final_sql}", + ); + } +} + +#[test] +fn committed_pg_settings_replica_mode_cannot_bypass_runtime_trigger_enforcement() { + for final_sql in [ + "UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "UPDATE pg_catalog . pg_settings SET setting='replica' WHERE name='session_replication_role';", + "UPDATE pg_settings SET setting = lower('REPLICA') WHERE name = 'session_replication_role';", + "UPDATE pg_settings AS p SET setting = 'replica' WHERE p . name = 'session_replication_role';", + "UPDATE ONLY pg_catalog . pg_settings AS p SET setting = lower('REPLICA') WHERE p.name = 'session_replication_role';", + "WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "WITH changed_setting AS (UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + "WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = (SELECT 'replica' WHERE true) WHERE name = 'session_replication_role';", + "WITH changed_setting AS (UPDATE pg_settings SET setting = (SELECT 'replica' WHERE true) WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "committed pg_settings mutation must not suppress ordinary trigger enforcement: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_replica_execution_mode_does_not_change_durable_migration_effects() { + for final_sql in [ + "BEGIN; SET LOCAL session_replication_role = replica; TRUNCATE TABLE source_artifact; ROLLBACK;", + "BEGIN; SELECT set_config('session_replication_role', 'replica', true); TRUNCATE TABLE source_artifact; ROLLBACK;", + "BEGIN; UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role'; TRUNCATE TABLE source_artifact; ROLLBACK;", + "BEGIN; UPDATE pg_settings AS p SET setting = 'replica' WHERE p.name = 'session_replication_role'; ROLLBACK;", + "BEGIN; WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role'; ROLLBACK;", + "BEGIN; WITH changed_setting AS (UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting; ROLLBACK;", + "BEGIN; WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = (SELECT 'replica' WHERE true) WHERE name = 'session_replication_role'; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn origin_and_local_execution_modes_preserve_ordinary_trigger_enforcement() { + for final_sql in [ + "SET session_replication_role = origin;", + "SET SESSION session_replication_role TO local;", + "SELECT set_config('session_replication_role', 'origin', false);", + "SELECT set_config('session_replication_role', 'local', false);", + "UPDATE pg_settings SET setting = 'origin' WHERE name = 'session_replication_role';", + "UPDATE pg_catalog . pg_settings SET setting = 'local' WHERE name = 'session_replication_role';", + "UPDATE pg_settings AS p SET setting = 'origin' WHERE p.name = 'session_replication_role';", + "WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'origin' WHERE name = 'session_replication_role';", + "WITH changed_setting AS (UPDATE pg_settings SET setting = 'local' WHERE name = 'session_replication_role' RETURNING name) SELECT count(*) FROM changed_setting;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unrelated_pg_settings_identity_or_parameter_does_not_impersonate_replica_mode_change() { + for final_sql in [ + "UPDATE audit_support.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "UPDATE pg_catalog_shadow.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "UPDATE pg_settings SET setting = 'replica' WHERE name = 'application_name';", + "WITH marker AS (SELECT 1) UPDATE audit_support.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", + "WITH changed_setting AS (UPDATE pg_settings SET setting = 'replica' WHERE name = 'application_name' RETURNING name) SELECT count(*) FROM changed_setting;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn keyword_and_parameter_prefixes_do_not_impersonate_replica_mode_changes() { + for final_sql in [ + "SETSESSION session_replication_role = replica;", + "SET session_replication_role_shadow = replica;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unrelated_function_identity_does_not_impersonate_the_postgresql_builtin() { + for final_sql in [ + "SELECT audit_support.set_config('session_replication_role', 'replica', false);", + "SELECT pg_catalog_shadow.set_config('session_replication_role', 'replica', false);", + "SELECT myset_config('session_replication_role', 'replica', false);", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn marker_like_replication_role_text_is_not_an_execution_mode_change() { + let catalog = embedded_with( + r#" +SELECT 'SET session_replication_role = replica'; +-- SET session_replication_role = replica; +SELECT $$SET LOCAL session_replication_role TO replica$$; +SELECT 'set_config(session_replication_role, replica, false)'; +-- SELECT set_config('session_replication_role', 'replica', false); +SELECT 'UPDATE pg_settings SET setting = replica WHERE name = session_replication_role'; +-- UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role'; +SELECT 'WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = replica WHERE name = session_replication_role'; +-- WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = 'replica' WHERE name = 'session_replication_role'; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + +#[test] +fn committed_dynamic_set_config_value_fails_closed_for_replication_role() { + for final_sql in [ + "SELECT set_config('session_replication_role', lower('REPLICA'), false);", + "SELECT pg_catalog . set_config('session_replication_role', (SELECT 'replica'), false);", + "WITH desired(value) AS (VALUES ('replica')) SELECT set_config('session_replication_role', (SELECT value FROM desired), false);", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "non-atomic set_config value must fail closed for session_replication_role: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_dynamic_set_config_value_does_not_change_durable_migration_effects() { + for final_sql in [ + "BEGIN; SELECT set_config('session_replication_role', lower('REPLICA'), true); ROLLBACK;", + "BEGIN; SELECT pg_catalog . set_config('session_replication_role', (SELECT 'replica'), false); ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn dynamic_unrelated_set_config_identity_or_parameter_remains_unrelated() { + for final_sql in [ + "SELECT audit_support.set_config('session_replication_role', lower('REPLICA'), false);", + "SELECT set_config('application_name', lower('REPLICA'), false);", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn named_and_mixed_set_config_notation_cannot_bypass_replication_role_guard() { + for final_sql in [ + "SELECT set_config(setting_name => 'session_replication_role', new_value => lower('REPLICA'), is_local => false);", + "SELECT pg_catalog . set_config(new_value => 'replica', is_local => false, setting_name => 'session_replication_role');", + "SELECT set_config('session_replication_role', new_value := (SELECT 'replica'), is_local := false);", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "named or mixed set_config notation must not bypass session_replication_role enforcement: {final_sql}", + ); + } +} + +#[test] +fn named_set_config_safe_atoms_and_unrelated_parameter_remain_accepted() { + for final_sql in [ + "SELECT set_config(setting_name => 'session_replication_role', new_value => 'origin', is_local => false);", + "SELECT set_config(new_value => 'local', setting_name => 'session_replication_role', is_local => true);", + "SELECT set_config(setting_name => 'application_name', new_value => lower('REPLICA'), is_local => false);", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn dynamic_set_config_setting_name_fails_closed_when_target_cannot_be_proven_unrelated() { + for final_sql in [ + "SELECT set_config(lower('SESSION_REPLICATION_ROLE'), 'replica', false);", + "SELECT set_config(setting_name => (SELECT 'session_replication_role'), new_value => 'replica', is_local => false);", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "dynamic set_config setting_name must fail closed because the protected target cannot be excluded: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_dynamic_set_config_setting_name_remains_non_durable() { + let final_sql = "BEGIN; SELECT set_config(lower('SESSION_REPLICATION_ROLE'), 'replica', true); ROLLBACK;"; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_set_config_adjacent_string_contract.rs b/crates/persistence_postgres/tests/migration_set_config_adjacent_string_contract.rs new file mode 100644 index 000000000..72ba64453 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_set_config_adjacent_string_contract.rs @@ -0,0 +1,29 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn newline_concatenated_setting_name_cannot_bypass_replication_role_guard() { + for final_sql in [ + "SELECT set_config(\n 'session_'\n 'replication_role',\n 'replica',\n false\n);", + "SELECT pg_catalog . set_config(\n new_value => 'replica',\n setting_name => 'session_'\n 'replication_role',\n is_local => false\n);", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "PostgreSQL newline-concatenated string constants must not hide session_replication_role: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_newline_concatenated_setting_name_is_not_durable() { + let final_sql = "BEGIN; SELECT set_config(\n 'session_'\n 'replication_role',\n 'replica',\n true\n); ROLLBACK;"; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_table_element_syntax_contract.rs b/crates/persistence_postgres/tests/migration_table_element_syntax_contract.rs new file mode 100644 index 000000000..f14753e29 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_table_element_syntax_contract.rs @@ -0,0 +1,38 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn assert_empty_table_element_rejected(up_sql: &str) { + let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::EmptyMigrationSql), + "invalid CREATE TABLE element list was accepted: {up_sql}" + ); +} + +#[test] +fn create_table_element_lists_fail_closed_on_empty_elements() { + for up_sql in [ + r" + CREATE TABLE tenant_record ( + , + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + ", + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + , + system_time timestamptz NOT NULL + ); + ", + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL, + ); + ", + ] { + assert_empty_table_element_rejected(up_sql); + } +} diff --git a/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs new file mode 100644 index 000000000..23318db58 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs @@ -0,0 +1,88 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn catalog_with(extra_sql: &str) -> MigrationCatalog { + let up_sql = format!( + "CREATE TABLE tenant_record (\n\ + tenant_record_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL\n\ + );\n{extra_sql}" + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn table_persistence_modifiers_cannot_bypass_object_naming() { + for statement in [ + "CREATE UNLOGGED TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE TEMP TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE TEMPORARY TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE GLOBAL TEMP TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE GLOBAL TEMPORARY TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE LOCAL TEMP TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + "CREATE LOCAL TEMPORARY TABLE Bad (bad_id uuid PRIMARY KEY, tenant_record_id uuid NOT NULL, system_time timestamptz NOT NULL, available_time timestamptz NOT NULL);", + ] { + let catalog = catalog_with(statement); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName), + "modifier-bearing table escaped the naming contract: {statement}" + ); + } +} + +#[test] +fn unlogged_table_still_traverses_table_local_tenant_contracts() { + let catalog = catalog_with( + "CREATE UNLOGGED TABLE derived_cache (\n\ + derived_cache_id uuid PRIMARY KEY,\n\ + system_time timestamptz NOT NULL,\n\ + available_time timestamptz NOT NULL\n\ + );", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTenantBoundary) + ); +} + +#[test] +fn valid_modifier_bearing_tables_reuse_the_existing_table_contract() { + for modifier in [ + "UNLOGGED", + "TEMP", + "TEMPORARY", + "GLOBAL TEMP", + "GLOBAL TEMPORARY", + "LOCAL TEMP", + "LOCAL TEMPORARY", + ] { + let statement = format!( + "CREATE {modifier} TABLE derived_cache (\n\ + derived_cache_id uuid PRIMARY KEY,\n\ + tenant_record_id uuid NOT NULL,\n\ + system_time timestamptz NOT NULL,\n\ + available_time timestamptz NOT NULL\n\ + );" + ); + let catalog = catalog_with(&statement); + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "valid modifier-bearing table was not routed through the shared contract: {modifier}" + ); + } +} + +#[test] +fn lexical_spacing_before_a_modifier_is_preserved_as_structure() { + let catalog = catalog_with( + "CREATE /* persistence class */\nUNLOGGED\tTABLE derived_cache (\n\ + derived_cache_id uuid PRIMARY KEY,\n\ + tenant_record_id uuid NOT NULL,\n\ + system_time timestamptz NOT NULL,\n\ + available_time timestamptz NOT NULL\n\ + );", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_table_prefix_contract.rs b/crates/persistence_postgres/tests/migration_table_prefix_contract.rs new file mode 100644 index 000000000..91492de11 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_table_prefix_contract.rs @@ -0,0 +1,26 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn table_body_lookup_must_not_reuse_a_longer_table_name_prefix() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record_archive ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL, + valid_from timestamptz NOT NULL + ); + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY + ); + ", + r" + DROP TABLE tenant_record; + DROP TABLE tenant_record_archive; + ", + ); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingTemporalColumns) + ); +} diff --git a/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs b/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs new file mode 100644 index 000000000..3fa8e666c --- /dev/null +++ b/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs @@ -0,0 +1,62 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn tenant_policy_bundle( + role_sql: &str, + transaction_prefix: &str, + transaction_suffix: &str, +) -> MigrationCatalog { + let up_sql = format!( + r#" + {transaction_prefix} + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + system_time timestamptz NOT NULL + ); + {role_sql} + ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_record FORCE ROW LEVEL SECURITY; + CREATE POLICY tenant_record_tenant_isolation ON tenant_record + FOR ALL + USING ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ) + WITH CHECK ( + tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') + ); + {transaction_suffix} + "# + ); + MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") +} + +#[test] +fn rolled_back_runtime_role_hardening_cannot_certify_bypassrls_role() { + let catalog = tenant_policy_bundle( + r#" + CREATE ROLE tepp_app_runtime BYPASSRLS; + BEGIN; + ALTER ROLE tepp_app_runtime NOBYPASSRLS; + ROLLBACK; + "#, + "", + "", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "rolled-back NOBYPASSRLS evidence must not change final runtime-role security state" + ); +} + +#[test] +fn rolled_back_structural_bundle_cannot_satisfy_final_migration_state() { + let catalog = tenant_policy_bundle( + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;", + "BEGIN;", + "ROLLBACK;", + ); + assert!( + validate_migration_catalog(&catalog).is_err(), + "DDL and RLS evidence that PostgreSQL rolls back must not satisfy the durable migration contract" + ); +} diff --git a/crates/persistence_postgres/tests/migration_trigger_final_state_contract.rs b/crates/persistence_postgres/tests/migration_trigger_final_state_contract.rs new file mode 100644 index 000000000..b9eeaf5b5 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_trigger_final_state_contract.rs @@ -0,0 +1,54 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn committed_trigger_weakening_modes_fail_closed() { + for final_sql in [ + "ALTER TABLE source_artifact DISABLE TRIGGER source_artifact_reject_mutation;", + "ALTER TABLE source_artifact DISABLE TRIGGER USER;", + "ALTER TABLE source_artifact DISABLE TRIGGER ALL;", + "ALTER TABLE source_artifact ENABLE REPLICA TRIGGER source_artifact_reject_mutation;", + "ALTER TABLE source_artifact ADD COLUMN auxiliary_flag boolean, DISABLE TRIGGER source_artifact_reject_mutation;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "committed trigger weakening must not reuse historical CREATE TRIGGER evidence: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_trigger_weakening_does_not_change_durable_state() { + for final_sql in [ + "BEGIN; ALTER TABLE source_artifact DISABLE TRIGGER source_artifact_reject_mutation; ROLLBACK;", + "BEGIN; ALTER TABLE source_artifact ENABLE REPLICA TRIGGER source_artifact_reject_mutation; ROLLBACK;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Ok(()), + "rolled-back trigger mode must not alter durable enforcement: {final_sql}", + ); + } +} + +#[test] +fn ordinary_and_always_enable_modes_remain_supported() { + for final_sql in [ + "ALTER TABLE source_artifact ENABLE TRIGGER source_artifact_reject_mutation;", + "ALTER TABLE source_artifact ENABLE ALWAYS TRIGGER source_artifact_reject_mutation;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Ok(()), + "non-weakening trigger enablement must remain supported: {final_sql}", + ); + } +} diff --git a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs new file mode 100644 index 000000000..4a74675ef --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -0,0 +1,58 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn unicode_escaped_pg_settings_identity_cannot_bypass_replication_role_guard() { + for final_sql in [ + r#"UPDATE U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"pg_\0073ettings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"pg_!0073ettings" UESCAPE '!' SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"pg_settings" UESCAPE '_' SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE pg_catalog . U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"pg_catalog" . pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"pg_catalog" . U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE pg_settings AS U&"p" SET setting = 'replica' WHERE U&"p".name = 'session_replication_role';"#, + r#"UPDATE pg_settings SET U&"setting" = 'replica' WHERE name = 'session_replication_role';"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "Unicode-escaped canonical pg_settings identity must not bypass trigger enforcement: {final_sql}", + ); + } +} + +#[test] +fn unrelated_safe_unicode_escaped_relation_identity_remains_unrelated() { + for final_sql in [ + r#"UPDATE U&"audit_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, + r#"UPDATE U&"audit_schema" . pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_unicode_escaped_pg_settings_mutation_is_not_durable() { + let final_sql = r#"BEGIN; UPDATE U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role'; ROLLBACK;"#; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn unicode_escaped_identifier_markers_in_opaque_regions_are_inert() { + let catalog = embedded_with( + r#" +SELECT 'UPDATE U&"pg_settings" SET setting = replica WHERE name = session_replication_role'; +-- UPDATE U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role'; +SELECT $$UPDATE U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role'$$; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_unicode_replication_role_login_default_contract.rs b/crates/persistence_postgres/tests/migration_unicode_replication_role_login_default_contract.rs new file mode 100644 index 000000000..3af97ae93 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_replication_role_login_default_contract.rs @@ -0,0 +1,89 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn unicode_escaped_runtime_role_identity_cannot_bypass_persistent_default_guard() { + for final_sql in [ + r#"ALTER ROLE U&"tepp_app_runtime" SET session_replication_role = replica;"#, + r#"ALTER USER U&"tepp_app_runtime" IN DATABASE tepp_database SET session_replication_role = replica;"#, + r#"ALTER ROLE U&"tepp_app!005fruntime" UESCAPE '!' SET session_replication_role = replica;"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "Unicode-escaped runtime role identity must not bypass persistent trigger-default enforcement: {final_sql}", + ); + } +} + +#[test] +fn unicode_escaped_replication_parameter_identity_is_protected_on_all_default_surfaces() { + for final_sql in [ + r#"ALTER ROLE tepp_app_runtime SET U&"session_replication_role" = replica;"#, + r#"ALTER USER tepp_app_runtime SET U&"session_replication_\0072ole" TO replica;"#, + r#"ALTER DATABASE tepp_database SET U&"session_replication_role" = replica;"#, + r#"ALTER SYSTEM SET U&"session_replication!005frole" UESCAPE '!' = replica;"#, + r#"ALTER ROLE tepp_app_runtime RESET U&"session_replication_role";"#, + r#"ALTER USER tepp_app_runtime RESET U&"session_replication_\0072ole";"#, + r#"ALTER DATABASE tepp_database RESET U&"session_replication_role";"#, + r#"ALTER SYSTEM RESET U&"session_replication!005frole" UESCAPE '!';"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "Unicode-escaped session_replication_role identity must not bypass persistent defaults: {final_sql}", + ); + } +} + +#[test] +fn unicode_escaped_persistent_defaults_preserve_safe_and_unrelated_controls() { + for final_sql in [ + r#"ALTER ROLE U&"tepp_app_runtime" SET U&"session_replication_role" = origin;"#, + r#"ALTER USER U&"tepp_app_runtime" SET U&"session_replication_role" TO local;"#, + r#"ALTER ROLE U&"audit_runtime" SET session_replication_role = replica;"#, + r#"ALTER ROLE U&"current_user" SET session_replication_role = replica;"#, + r#"ALTER ROLE U&"current_role" UESCAPE '!' SET session_replication_role = replica;"#, + r#"ALTER USER U&"session_user" SET session_replication_role = replica;"#, + r#"ALTER ROLE tepp_app_runtime SET U&"application_name" = replica;"#, + r#"ALTER DATABASE tepp_database SET U&"application_name" = replica;"#, + r#"ALTER SYSTEM SET U&"application_name" = replica;"#, + r#"ALTER ROLE tepp_app_runtime RESET U&"application_name";"#, + r#"ALTER DATABASE tepp_database RESET U&"application_name";"#, + r#"ALTER SYSTEM RESET U&"application_name";"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_unicode_persistent_default_is_not_durable() { + for final_sql in [ + r#"BEGIN; ALTER ROLE U&"tepp_app_runtime" SET session_replication_role = replica; ROLLBACK;"#, + r#"BEGIN; ALTER USER tepp_app_runtime SET U&"session_replication_role" = replica; ROLLBACK;"#, + r#"BEGIN; ALTER DATABASE tepp_database SET U&"session_replication_role" = replica; ROLLBACK;"#, + r#"BEGIN; ALTER ROLE tepp_app_runtime RESET U&"session_replication_role"; ROLLBACK;"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unicode_persistent_default_markers_in_opaque_regions_are_inert() { + let catalog = embedded_with( + r#" +SELECT 'ALTER ROLE U&"tepp_app_runtime" SET session_replication_role = replica'; +-- ALTER USER tepp_app_runtime SET U&"session_replication_role" = replica; +SELECT $$ALTER DATABASE tepp_database SET U&"session_replication_role" = replica$$; +SELECT 'ALTER SYSTEM SET U&"session_replication_role" = replica'; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs b/crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs new file mode 100644 index 000000000..4b31da429 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs @@ -0,0 +1,66 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn unicode_escaped_set_config_builtin_identity_cannot_bypass_replication_role_guard() { + for final_sql in [ + r#"SELECT U&"set_\0063onfig"('session_replication_role', 'replica', false);"#, + r#"SELECT U&"pg_\0063atalog".set_config('session_replication_role', 'replica', false);"#, + r#"SELECT pg_catalog.U&"set_\0063onfig"('session_replication_role', 'replica', false);"#, + r#"SELECT U&"pg_\0063atalog".U&"set_\0063onfig"('session_replication_role', 'replica', false);"#, + r#"SELECT U&"set_!0063onfig" UESCAPE '!'(setting_name => 'session_replication_role', new_value => 'replica', is_local => false);"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "Unicode-escaped canonical set_config identity must not bypass trigger enforcement: {final_sql}", + ); + } +} + +#[test] +fn unicode_escaped_set_config_safe_values_remain_allowed() { + for final_sql in [ + r#"SELECT U&"set_\0063onfig"('session_replication_role', 'origin', false);"#, + r#"SELECT U&"pg_\0063atalog".set_config('session_replication_role', 'local', false);"#, + r#"SELECT U&"set_!0063onfig" UESCAPE '!'(setting_name => 'session_replication_role', new_value => 'origin', is_local => false);"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn unrelated_unicode_escaped_function_identity_remains_unrelated() { + for final_sql in [ + r#"SELECT U&"audit_set_config"('session_replication_role', 'replica', false);"#, + r#"SELECT U&"audit_support".set_config('session_replication_role', 'replica', false);"#, + r#"SELECT U&"audit_support".U&"set_config"('session_replication_role', 'replica', false);"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_unicode_escaped_set_config_replica_mode_is_not_durable() { + let final_sql = r#"BEGIN; SELECT U&"set_\0063onfig"('session_replication_role', 'replica', true); ROLLBACK;"#; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn unicode_escaped_set_config_markers_in_opaque_regions_are_inert() { + let catalog = embedded_with( + r#" +SELECT 'U&"set_\0063onfig"(''session_replication_role'', ''replica'', false)'; +-- SELECT U&"set_\0063onfig"('session_replication_role', 'replica', false); +SELECT $$U&"set_\0063onfig"('session_replication_role', 'replica', false)$$; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} diff --git a/crates/persistence_postgres/tests/migration_unicode_set_parameter_contract.rs b/crates/persistence_postgres/tests/migration_unicode_set_parameter_contract.rs new file mode 100644 index 000000000..468e47801 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_set_parameter_contract.rs @@ -0,0 +1,53 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +fn embedded_with(final_sql: &str) -> MigrationCatalog { + let embedded = MigrationCatalog::from_embedded().expect("embedded migrations must load"); + MigrationCatalog::from_sql( + &format!("{}\n{final_sql}", embedded.up_sql()), + embedded.down_sql(), + ) +} + +#[test] +fn unicode_escaped_set_parameter_identity_cannot_bypass_replication_role_guard() { + for final_sql in [ + r#"SET U&"session_replication_role" = replica;"#, + r#"SET SESSION U&"session_replication_\0072ole" TO replica;"#, + r#"SET LOCAL U&"session_replication_!0072ole" UESCAPE '!' = replica;"#, + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::MissingAppRuntimeRole), + "Unicode-escaped canonical SET parameter must not bypass trigger enforcement: {final_sql}", + ); + } +} + +#[test] +fn unicode_escaped_set_safe_modes_and_unrelated_parameter_remain_allowed() { + for final_sql in [ + r#"SET U&"session_replication_role" = origin;"#, + r#"SET SESSION U&"session_replication_\0072ole" TO local;"#, + r#"SET U&"application_name" = replica;"#, + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } +} + +#[test] +fn rolled_back_unicode_escaped_set_replica_mode_is_not_durable() { + let final_sql = r#"BEGIN; SET U&"session_replication_role" = replica; ROLLBACK;"#; + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); +} + +#[test] +fn unicode_escaped_set_markers_in_opaque_regions_are_inert() { + let catalog = embedded_with( + r#" +SELECT 'SET U&"session_replication_role" = replica'; +-- SET U&"session_replication_role" = replica; +SELECT $$SET U&"session_replication_role" = replica$$; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +}