From a6b25ed2491abcf865f5428b9e2999def7ef3f8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:16:38 +0900 Subject: [PATCH 001/309] fix(persistence): enforce the case half of the object naming contract `parse_create_table_names` and `parse_create_policy_names` folded every parsed name to lowercase before `validate_migration_catalog` applied `is_multi_word_snake_case`. The predicate rejects `Document_Record`, but the caller never saw that spelling, so any CamelCase or PascalCase name containing an underscore passed the gate. AGENTS.md contract 12 requires both at least two words and `snake_case`; only the word count was actually enforced. The parsers now preserve the declared spelling and each consumer folds its own lookup key, so the case-folded matching against the SQL text is unchanged while the contract check sees what the migration really wrote. Two tests pin the previously unenforced half, one for table names and one for policy names. Both fail on the parent commit. Co-Authored-By: Claude Opus 5 --- crates/persistence_postgres/src/migration.rs | 54 +++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 60ef41617..796bb8512 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -109,9 +109,12 @@ pub fn validate_migration_catalog( if !is_multi_word_snake_case(table) { return Err(MigrationContractError::SingleWordObjectName); } - let body = - table_body(catalog.up_sql(), table).ok_or(MigrationContractError::EmptyMigrationSql)?; - validate_table_body(table, body)?; + // 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)?; } if declares_row_level_security(catalog.up_sql()) { @@ -277,10 +280,11 @@ fn validate_tenant_rls_contract( } for table in tables { - if !table_has_rls_enabled(&lower, table) { + let folded = table.to_ascii_lowercase(); + if !table_has_rls_enabled(&lower, &folded) { return Err(MigrationContractError::MissingRlsEnable); } - if !table_has_tenant_policy(&lower, table) { + if !table_has_tenant_policy(&lower, &folded) { return Err(MigrationContractError::MissingRlsPolicy); } } @@ -362,7 +366,7 @@ fn parse_create_table_names(sql: &str) -> BTreeSet { search_from = abs; continue; } - names.insert(name.to_ascii_lowercase()); + names.insert(name); search_from = abs; } names @@ -384,7 +388,7 @@ fn parse_create_policy_names(sql: &str) -> BTreeSet { }) .collect(); if !name.is_empty() { - names.insert(name.to_ascii_lowercase()); + names.insert(name); } search_from = abs; } @@ -483,6 +487,42 @@ mod tests { assert!(policies.contains("document_record_tenant_isolation")); } + #[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( From 6a2c35ed0c81c1838bcdb9b5b068a5ce5c3983ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 03:25:20 +0900 Subject: [PATCH 002/309] feat(persistence): cover every declared object in the naming contract `validate_migration_catalog` checked table and policy names only. Column, constraint, index, trigger, function, type, view, and sequence names were never measured against AGENTS.md contract 12, so a single-word or CamelCase name in any of those positions entered the schema unchallenged. One boundary-aware helper, `parse_names_after`, now backs every name parser and replaces the two near-duplicate scanners. It requires the keyword to stand alone on both sides, so `integrity_constraint_violation` no longer reads as a CONSTRAINT declaration. Column names come from the table body with parenthesis-depth tracking, so a parenthesised type and a table-level constraint clause do not shift the extraction. The policy-name check inside the row-level-security contract became redundant once every created object is validated, and is removed. Policy names are now checked whether or not the migration declares RLS, which is strictly stronger. The shipped migrations already satisfy the extended contract, so this is a regression guard. Tests exercise each object kind with a name that must be rejected. Co-Authored-By: Claude Opus 5 --- crates/persistence_postgres/src/migration.rs | 279 ++++++++++++++++--- 1 file changed, 239 insertions(+), 40 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 796bb8512..a19f5461f 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -117,6 +117,17 @@ pub fn validate_migration_catalog( 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)?; } @@ -237,6 +248,11 @@ fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractEr } fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { + for column in parse_column_names(body) { + if !is_multi_word_snake_case(&column) { + return Err(MigrationContractError::SingleWordObjectName); + } + } let lower = body.to_ascii_lowercase(); if requires_tenant_boundary(table) && !lower.contains("tenant_record_id") { return Err(MigrationContractError::MissingTenantBoundary); @@ -269,16 +285,12 @@ fn validate_tenant_rls_contract( 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 policy in &policies { - if !is_multi_word_snake_case(policy) { - return Err(MigrationContractError::SingleWordObjectName); - } - } - for table in tables { let folded = table.to_ascii_lowercase(); if !table_has_rls_enabled(&lower, &folded) { @@ -343,54 +355,134 @@ fn has_domain_time_column(lower_body: &str) -> bool { has_available | has_valid_from } -fn parse_create_table_names(sql: &str) -> BTreeSet { +/// 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 `index` starts a keyword rather than continuing a word. +fn is_word_start(sql: &str, index: usize) -> bool { + sql[..index] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_') +} + +/// Return the identifier at the start of `rest`, skipping an existence clause. +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| ch.is_ascii_alphanumeric() || *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("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 + 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() - .take_while(|ch| { - let alphanumeric = ch.is_ascii_alphanumeric(); - let underscore = *ch == '_'; - alphanumeric | underscore - }) - .collect(); - if name.is_empty() { - search_from = abs; + .next() + .is_some_and(|ch| !ch.is_whitespace()) + { continue; } - names.insert(name); - search_from = abs; + let name = leading_identifier(&sql[abs..]); + if !name.is_empty() { + names.insert(name); + } } names } +fn parse_create_table_names(sql: &str) -> BTreeSet { + parse_names_after(sql, "CREATE TABLE") +} + 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(); - 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() { + 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") +} + +/// 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(); + let mut depth = 0i32; + let mut start = 0usize; + let mut segments = Vec::new(); + for (index, ch) in body.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 1 => { + segments.push(&body[start..index]); + start = index + 1; + } + _ => {} + } + } + segments.push(&body[start..]); + for segment in segments { + 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); } - search_from = abs; } names } @@ -487,6 +579,113 @@ mod tests { 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; + 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( From 73d23fafc20661fa8bbf4a01c1dd52f2ccb4754b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 15 Sep 2026 10:31:24 +0900 Subject: [PATCH 003/309] test(persistence): cover both arms of the new name-parsing guards `Production line and branch coverage` failed on this branch. Reproducing it with the pinned nightly showed two arms that no fixture reached: - `is_word_start` never saw a keyword preceded by an alphanumeric character, so the first operand of its boundary test never evaluated false. Every existing fixture preceded the keyword with whitespace or an underscore. - `parse_column_names` never saw a table-body segment whose leading identifier is empty, so the emptiness half of its guard never evaluated false. Two fixture lines reach both: a comment containing `1CONSTRAINT`, and an empty top-level segment inside a `CREATE TABLE` body. Neither declares an object, so the assertions are unchanged. After the change `migration.rs` has no uncovered arm. The only remaining uncovered arms in the crate are in `sqlx_live.rs`, which CI excludes. Co-Authored-By: Claude Opus 5 --- crates/persistence_postgres/src/migration.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index a19f5461f..29d6b5fd3 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -655,6 +655,7 @@ mod tests { 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;", @@ -670,6 +671,7 @@ mod tests { &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;", From 67f5c3ff42a9cb9ee8587f32374fb0bb3ad616f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 16:02:45 +0900 Subject: [PATCH 004/309] test(persistence): expose SQL identifier lexical bypasses --- .../migration_identifier_lexing_contract.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs 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..0b62c466b --- /dev/null +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -0,0 +1,51 @@ +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_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 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(())); +} From a9666ca111f3588abf56347f087deaf9ecd090d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:07:56 +0900 Subject: [PATCH 005/309] feat(persistence): add SQL lexical normalization boundary --- .../src/migration_validation.rs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_validation.rs diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs new file mode 100644 index 000000000..5cbf5e9f8 --- /dev/null +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -0,0 +1,250 @@ +//! Lexical normalization boundary for migration contract validation. + +use crate::migration::{MigrationCatalog, validate_migration_catalog as validate_normalized_catalog}; +use crate::MigrationContractError; + +/// Validate migration SQL after removing PostgreSQL lexical trivia from the +/// structural view consumed by the migration contract parser. +/// +/// Quoted identifiers are exposed with their declared spelling, while comments, +/// quoted SQL bodies, and non-atomic string literals cannot introduce synthetic +/// `CREATE` or `CONSTRAINT` declarations. Atomic literal values are retained so +/// contracts such as `tepp.current_tenant_record_id` remain observable. +/// +/// # Errors +/// +/// Returns [`MigrationContractError::EmptyMigrationSql`] when the forward SQL +/// has an unterminated quoted string, identifier, block comment, or dollar-quoted +/// body. Otherwise returns the same migration contract errors as the normalized +/// parser. +pub fn validate_migration_catalog( + catalog: &MigrationCatalog, +) -> Result<(), MigrationContractError> { + let normalized_up = normalize_migration_sql(catalog.up_sql()) + .ok_or(MigrationContractError::EmptyMigrationSql)?; + let normalized = MigrationCatalog::from_sql(&normalized_up, catalog.down_sql()); + validate_normalized_catalog(&normalized) +} + +fn normalize_migration_sql(sql: &str) -> 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'\'' => { + let (next, literal) = scan_single_quoted_literal(bytes, index)?; + normalized.push(b' '); + if literal_is_atomic(literal) { + normalized.extend_from_slice(literal); + } + normalized.push(b' '); + index = next; + } + b'"' => { + let (next, identifier) = scan_quoted_identifier(bytes, index)?; + normalized.push(b' '); + normalized.extend_from_slice(&identifier); + 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; + } + } + } + + let normalized = String::from_utf8(normalized).ok()?; + Some(normalized.split_whitespace().collect::>().join(" ")) +} + +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 +} + +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 +} + +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 +} + +fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { + if bytes.get(start) != Some(&b'$') { + 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() { + 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() { + return None; + } + index += 1; + } + None +} + +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 +} + +fn literal_is_atomic(literal: &[u8]) -> bool { + !literal.is_empty() + && literal + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::normalize_migration_sql; + + #[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 quoted_identifiers_preserve_declared_spelling_and_escaped_quotes() { + let normalized = normalize_migration_sql( + "CREATE INDEX \"Bad\" ON tenant_record (\"good_name\"); CREATE VIEW \"a\"\"b\" AS SELECT 1;", + ) + .expect("well-formed quoted identifiers"); + assert!(normalized.contains("CREATE INDEX Bad ON tenant_record ( good_name )")); + assert!(normalized.contains("CREATE VIEW a\"b AS SELECT 1")); + } + + #[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", + "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;"); + } +} From 1464502d4de4cea24ff1905a289461ef5aeaa8f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:08:27 +0900 Subject: [PATCH 006/309] fix(persistence): route migration validation through lexical boundary --- crates/persistence_postgres/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/lib.rs b/crates/persistence_postgres/src/lib.rs index a6038d280..ce2de2d59 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -50,6 +50,7 @@ mod manifest_sql; mod membership_sql; mod mention_sql; mod migration; +mod migration_validation; mod model_run_sql; mod naming; mod project_sql; @@ -160,7 +161,7 @@ pub use mention_sql::insert_event_mention_sql; /// Embedded and ad-hoc migration catalogs. pub use migration::MigrationCatalog; /// Validate migration SQL against TEPP contracts. -pub use migration::validate_migration_catalog; +pub use migration_validation::validate_migration_catalog; /// Append-only corpus split manifest row. pub use model_run_sql::CorpusSplitManifestRecord; /// Append-only model artifact row. From 5364cfe02e4c0fb9209259c8f5ee6a05b206d49d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:09:22 +0900 Subject: [PATCH 007/309] fix(persistence): dereference lexical literal bytes --- crates/persistence_postgres/src/migration_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5cbf5e9f8..d27aae344 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -180,7 +180,7 @@ fn literal_is_atomic(literal: &[u8]) -> bool { !literal.is_empty() && literal .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) + .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.')) } #[cfg(test)] From 4ea7f737de2d5f2af14b28c8c216bef1d27c9cd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:11:38 +0900 Subject: [PATCH 008/309] refactor(persistence): make lexical validation the canonical migration boundary --- crates/persistence_postgres/src/lib.rs | 3 +- crates/persistence_postgres/src/migration.rs | 1241 +---------------- .../src/migration_core.rs | 1237 ++++++++++++++++ .../src/migration_validation.rs | 30 +- 4 files changed, 1256 insertions(+), 1255 deletions(-) create mode 100644 crates/persistence_postgres/src/migration_core.rs diff --git a/crates/persistence_postgres/src/lib.rs b/crates/persistence_postgres/src/lib.rs index ce2de2d59..a6038d280 100644 --- a/crates/persistence_postgres/src/lib.rs +++ b/crates/persistence_postgres/src/lib.rs @@ -50,7 +50,6 @@ mod manifest_sql; mod membership_sql; mod mention_sql; mod migration; -mod migration_validation; mod model_run_sql; mod naming; mod project_sql; @@ -161,7 +160,7 @@ pub use mention_sql::insert_event_mention_sql; /// Embedded and ad-hoc migration catalogs. pub use migration::MigrationCatalog; /// Validate migration SQL against TEPP contracts. -pub use migration_validation::validate_migration_catalog; +pub use migration::validate_migration_catalog; /// Append-only corpus split manifest row. pub use model_run_sql::CorpusSplitManifestRecord; /// Append-only model artifact row. diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 29d6b5fd3..04159d9fa 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -1,1237 +1,28 @@ //! 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. /// /// # 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 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(()) -} - -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") -} - -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(()) -} - -fn declares_temporal_interval_ordering(up_sql: &str) -> bool { - let lower = up_sql.to_ascii_lowercase(); - lower.contains("_valid_order") || lower.contains("_system_order") -} - -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(()) -} - -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") -} - -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(()) -} - -fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { - for column in parse_column_names(body) { - if !is_multi_word_snake_case(&column) { - return Err(MigrationContractError::SingleWordObjectName); - } - } - let lower = body.to_ascii_lowercase(); - if requires_tenant_boundary(table) && !lower.contains("tenant_record_id") { - return Err(MigrationContractError::MissingTenantBoundary); - } - - if !has_system_time_column(&lower) { - 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(&lower) { - return Err(MigrationContractError::MissingTemporalColumns); - } - Ok(()) -} - -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(()) -} - -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 -} - -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) -} - -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(); - } - false -} - -fn requires_tenant_boundary(table: &str) -> bool { - table != "tenant_record" -} - -fn is_registry_or_audit_table(table: &str) -> bool { - table == "tenant_record" || table == "audit_event" -} - -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 -} - -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 -} - -/// 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 `index` starts a keyword rather than continuing a word. -fn is_word_start(sql: &str, index: usize) -> bool { - sql[..index] - .chars() - .next_back() - .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_') -} - -/// Return the identifier at the start of `rest`, skipping an existence clause. -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| ch.is_ascii_alphanumeric() || *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 -} - -fn parse_create_table_names(sql: &str) -> BTreeSet { - parse_names_after(sql, "CREATE TABLE") -} - -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") -} - -/// 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(); - let mut depth = 0i32; - let mut start = 0usize; - let mut segments = Vec::new(); - for (index, ch) in body.char_indices() { - match ch { - '(' => depth += 1, - ')' => depth -= 1, - ',' if depth == 1 => { - segments.push(&body[start..index]); - start = index + 1; - } - _ => {} - } - } - segments.push(&body[start..]); - for segment in segments { - 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 -} - -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 - .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 -} - -#[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; - -- 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) - ); - - // 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) - ); - - // 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) - ); - } + let normalized_up = validation::normalize_migration_sql(catalog.up_sql()) + .ok_or(MigrationContractError::EmptyMigrationSql)?; + let normalized = MigrationCatalog::from_sql(&normalized_up, catalog.down_sql()); + core::validate_migration_catalog(&normalized) } diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs new file mode 100644 index 000000000..29d6b5fd3 --- /dev/null +++ b/crates/persistence_postgres/src/migration_core.rs @@ -0,0 +1,1237 @@ +//! 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(), + } + } + + /// 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(()) +} + +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") +} + +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(()) +} + +fn declares_temporal_interval_ordering(up_sql: &str) -> bool { + let lower = up_sql.to_ascii_lowercase(); + lower.contains("_valid_order") || lower.contains("_system_order") +} + +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(()) +} + +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") +} + +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(()) +} + +fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { + for column in parse_column_names(body) { + if !is_multi_word_snake_case(&column) { + return Err(MigrationContractError::SingleWordObjectName); + } + } + let lower = body.to_ascii_lowercase(); + if requires_tenant_boundary(table) && !lower.contains("tenant_record_id") { + return Err(MigrationContractError::MissingTenantBoundary); + } + + if !has_system_time_column(&lower) { + 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(&lower) { + return Err(MigrationContractError::MissingTemporalColumns); + } + Ok(()) +} + +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(()) +} + +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 +} + +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) +} + +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(); + } + false +} + +fn requires_tenant_boundary(table: &str) -> bool { + table != "tenant_record" +} + +fn is_registry_or_audit_table(table: &str) -> bool { + table == "tenant_record" || table == "audit_event" +} + +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 +} + +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 +} + +/// 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 `index` starts a keyword rather than continuing a word. +fn is_word_start(sql: &str, index: usize) -> bool { + sql[..index] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_') +} + +/// Return the identifier at the start of `rest`, skipping an existence clause. +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| ch.is_ascii_alphanumeric() || *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 +} + +fn parse_create_table_names(sql: &str) -> BTreeSet { + parse_names_after(sql, "CREATE TABLE") +} + +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") +} + +/// 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(); + let mut depth = 0i32; + let mut start = 0usize; + let mut segments = Vec::new(); + for (index, ch) in body.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 1 => { + segments.push(&body[start..index]); + start = index + 1; + } + _ => {} + } + } + segments.push(&body[start..]); + for segment in segments { + 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 +} + +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 + .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 +} + +#[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; + -- 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) + ); + + // 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) + ); + + // 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_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d27aae344..f59c58b84 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,32 +1,6 @@ -//! Lexical normalization boundary for migration contract validation. - -use crate::migration::{MigrationCatalog, validate_migration_catalog as validate_normalized_catalog}; -use crate::MigrationContractError; - -/// Validate migration SQL after removing PostgreSQL lexical trivia from the -/// structural view consumed by the migration contract parser. -/// -/// Quoted identifiers are exposed with their declared spelling, while comments, -/// quoted SQL bodies, and non-atomic string literals cannot introduce synthetic -/// `CREATE` or `CONSTRAINT` declarations. Atomic literal values are retained so -/// contracts such as `tepp.current_tenant_record_id` remain observable. -/// -/// # Errors -/// -/// Returns [`MigrationContractError::EmptyMigrationSql`] when the forward SQL -/// has an unterminated quoted string, identifier, block comment, or dollar-quoted -/// body. Otherwise returns the same migration contract errors as the normalized -/// parser. -pub fn validate_migration_catalog( - catalog: &MigrationCatalog, -) -> Result<(), MigrationContractError> { - let normalized_up = normalize_migration_sql(catalog.up_sql()) - .ok_or(MigrationContractError::EmptyMigrationSql)?; - let normalized = MigrationCatalog::from_sql(&normalized_up, catalog.down_sql()); - validate_normalized_catalog(&normalized) -} +//! PostgreSQL lexical normalization for migration contract parsing. -fn normalize_migration_sql(sql: &str) -> Option { +pub(super) fn normalize_migration_sql(sql: &str) -> Option { let bytes = sql.as_bytes(); let mut normalized = Vec::with_capacity(bytes.len()); let mut index = 0usize; From 2ba1f2cdd13b91eab89a7946a4291f04f9b7e245 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:13:28 +0900 Subject: [PATCH 009/309] test(persistence): expose adjacent literal declaration splice --- .../tests/migration_identifier_lexing_contract.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 0b62c466b..a417af5a7 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -49,3 +49,13 @@ fn declaration_shaped_text_inside_sql_trivia_is_not_an_object() { 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(())); +} From 5e745976c40c824f4fa2b8a321064dc3a3baee75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:13:52 +0900 Subject: [PATCH 010/309] fix(persistence): preserve atomic literal boundaries --- .../src/migration_validation.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index f59c58b84..52a63e0da 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -11,7 +11,9 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { 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; @@ -179,11 +181,19 @@ mod tests { 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("'tepp.current_tenant_record_id'")); + assert!(normalized.contains("'x'")); assert!(!normalized.contains("''")); } + #[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_declared_spelling_and_escaped_quotes() { let normalized = normalize_migration_sql( From 9b47019aef8ed2d251e6b288fe110cef6b342bcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:16:33 +0900 Subject: [PATCH 011/309] test(persistence): expose escaped quoted-identifier truncation --- .../tests/migration_identifier_lexing_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index a417af5a7..fd1513914 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -22,6 +22,18 @@ fn quoted_created_object_cannot_bypass_the_naming_contract() { ); } +#[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( From 6b1afbce8fdb90c7a7e6c4a613d4ef2dcbbe8bfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 17:17:02 +0900 Subject: [PATCH 012/309] fix(persistence): fail closed on non-snake quoted identifier bytes --- .../src/migration_validation.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 52a63e0da..d96b83888 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,5 +1,7 @@ //! PostgreSQL lexical normalization for migration contract parsing. +const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; + pub(super) fn normalize_migration_sql(sql: &str) -> Option { let bytes = sql.as_bytes(); let mut normalized = Vec::with_capacity(bytes.len()); @@ -21,7 +23,11 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { b'"' => { let (next, identifier) = scan_quoted_identifier(bytes, index)?; normalized.push(b' '); - normalized.extend_from_slice(&identifier); + if quoted_identifier_is_structurally_safe(&identifier) { + normalized.extend_from_slice(&identifier); + } else { + normalized.extend_from_slice(INVALID_QUOTED_IDENTIFIER); + } normalized.push(b' '); index = next; } @@ -159,6 +165,13 @@ fn literal_is_atomic(literal: &[u8]) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.')) } +fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { + !identifier.is_empty() + && identifier + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') +} + #[cfg(test)] mod tests { use super::normalize_migration_sql; @@ -195,13 +208,13 @@ mod tests { } #[test] - fn quoted_identifiers_preserve_declared_spelling_and_escaped_quotes() { + fn quoted_identifiers_preserve_safe_spelling_and_reject_unrepresentable_content() { let normalized = normalize_migration_sql( "CREATE INDEX \"Bad\" ON tenant_record (\"good_name\"); CREATE VIEW \"a\"\"b\" AS SELECT 1;", ) .expect("well-formed quoted identifiers"); assert!(normalized.contains("CREATE INDEX Bad ON tenant_record ( good_name )")); - assert!(normalized.contains("CREATE VIEW a\"b AS SELECT 1")); + assert!(normalized.contains("CREATE VIEW INVALID_QUOTED_IDENTIFIER AS SELECT 1")); } #[test] From 77fbd5dc6031f2f2a52ee15ea4273ef2f7d471dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 18:02:38 +0900 Subject: [PATCH 013/309] test(persistence): cover materialized view names --- .../tests/migration_identifier_lexing_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index fd1513914..376113d99 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -71,3 +71,15 @@ fn adjacent_atomic_literals_cannot_splice_a_create_keyword() { 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) + ); +} From 2526233ccd3a915d7f2662535cbf4c39a67ea335 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 18:03:44 +0900 Subject: [PATCH 014/309] fix(persistence): route materialized view names through view validation --- .../src/migration_validation.rs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d96b83888..f829dca09 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -65,7 +65,31 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { } let normalized = String::from_utf8(normalized).ok()?; - Some(normalized.split_whitespace().collect::>().join(" ")) + Some(canonicalize_structural_keywords(&normalized)) +} + +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 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 { + canonical.push(tokens[index]); + index += 1; + } + } + canonical.join(" ") } fn scan_single_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8])> { @@ -217,6 +241,17 @@ mod tests { assert!(normalized.contains("CREATE VIEW INVALID_QUOTED_IDENTIFIER AS SELECT 1")); } + #[test] + fn materialized_views_share_the_view_object_parser() { + let normalized = normalize_migration_sql( + "create materialized view bad_name AS SELECT 1; CREATE VIEW good_name AS SELECT 1;", + ) + .expect("well-formed materialized view"); + assert!(normalized.contains("create view bad_name AS SELECT 1;")); + assert!(normalized.contains("CREATE VIEW good_name AS SELECT 1;")); + assert!(!normalized.to_ascii_uppercase().contains("MATERIALIZED VIEW")); + } + #[test] fn dollar_quoted_bodies_do_not_declare_migration_objects() { let normalized = normalize_migration_sql( From cb856ec570fc9c2a68562b1212fc2c3915ccae32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:00:22 +0900 Subject: [PATCH 015/309] test(persistence): cover CREATE OR REPLACE VIEW naming --- .../tests/migration_identifier_lexing_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 376113d99..d52c58760 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -83,3 +83,15 @@ fn materialized_view_names_are_covered_by_the_object_naming_contract() { 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) + ); +} From 3f272890da4dbc527de247eff6faa8427171e4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:01:01 +0900 Subject: [PATCH 016/309] fix(persistence): canonicalize replaceable view declarations --- .../src/migration_validation.rs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index f829dca09..cc5d8e88e 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -84,6 +84,20 @@ fn canonicalize_structural_keywords(sql: &str) -> String { 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; @@ -242,14 +256,17 @@ mod tests { } #[test] - fn materialized_views_share_the_view_object_parser() { + fn view_modifiers_share_the_view_object_parser() { let normalized = normalize_migration_sql( - "create materialized view bad_name AS SELECT 1; CREATE VIEW good_name AS SELECT 1;", + "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 materialized view"); - assert!(normalized.contains("create view bad_name AS SELECT 1;")); - assert!(normalized.contains("CREATE VIEW good_name AS SELECT 1;")); - assert!(!normalized.to_ascii_uppercase().contains("MATERIALIZED VIEW")); + .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] From 5de7ad0974846c42183d22684e79bbf0fdc240ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:02:52 +0900 Subject: [PATCH 017/309] test(persistence): fail closed on qualified object names --- .../tests/migration_identifier_lexing_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index d52c58760..6a36077cc 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -95,3 +95,18 @@ fn replaceable_view_names_are_covered_by_the_object_naming_contract() { 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}" + ); + } +} From e0d3c0def34d264321cb501bc5d2ab39ecffff08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:04:36 +0900 Subject: [PATCH 018/309] fix(persistence): fail closed on qualified create names --- .../src/migration_validation.rs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index cc5d8e88e..e111e439c 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,6 +1,7 @@ //! PostgreSQL lexical normalization for migration contract parsing. const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; +const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; pub(super) fn normalize_migration_sql(sql: &str) -> Option { let bytes = sql.as_bytes(); @@ -103,7 +104,56 @@ fn canonicalize_structural_keywords(sql: &str) -> String { index += 1; } } - canonical.join(" ") + + 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(" ") } fn scan_single_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8])> { @@ -269,6 +319,19 @@ mod tests { 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( From 2d330a2e69bd87f403e3a5c2ebddc5c2d38df1c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:05:10 +0900 Subject: [PATCH 019/309] fix(persistence): import qualified-name sentinel in tests --- crates/persistence_postgres/src/migration_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index e111e439c..d0fc4cbff 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -262,7 +262,7 @@ fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { #[cfg(test)] mod tests { - use super::normalize_migration_sql; + use super::{INVALID_QUALIFIED_IDENTIFIER, normalize_migration_sql}; #[test] fn lexical_normalization_masks_declaration_shaped_trivia() { From 0a82de4607b4a34ef7943eb163b4269b0f10afec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 19:58:42 +0900 Subject: [PATCH 020/309] test(persistence): cover created role naming contract --- .../tests/migration_identifier_lexing_contract.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 6a36077cc..b7da19ae3 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -110,3 +110,13 @@ fn qualified_created_object_cannot_hide_an_invalid_object_segment() { ); } } + +#[test] +fn created_role_names_are_covered_by_the_object_naming_contract() { + let catalog = conforming_catalog("CREATE ROLE Bad NOSUPERUSER NOBYPASSRLS;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::SingleWordObjectName) + ); +} From de2a6391477ac2253e31397e07d935eda79e4e77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:00:13 +0900 Subject: [PATCH 021/309] test(persistence): cover PostgreSQL role creation aliases --- .../migration_identifier_lexing_contract.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index b7da19ae3..0529dbac7 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -112,11 +112,17 @@ fn qualified_created_object_cannot_hide_an_invalid_object_segment() { } #[test] -fn created_role_names_are_covered_by_the_object_naming_contract() { - let catalog = conforming_catalog("CREATE ROLE Bad NOSUPERUSER NOBYPASSRLS;"); - - assert_eq!( - validate_migration_catalog(&catalog), - Err(MigrationContractError::SingleWordObjectName) - ); +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}" + ); + } } From ab7a92a2db0b7b018ab2d854413feb588d879d3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:00:48 +0900 Subject: [PATCH 022/309] fix(persistence): route role aliases through object naming authority --- .../src/migration_validation.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d0fc4cbff..259511e58 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -75,6 +75,20 @@ fn canonicalize_structural_keywords(sql: &str) -> String { let mut index = 0usize; 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("ROLE") + || token.eq_ignore_ascii_case("USER") + || token.eq_ignore_ascii_case("GROUP") + }) + { + // ROLE is a cluster-level object used by TEPP's shipped RLS migration; + // USER and GROUP are PostgreSQL aliases for CREATE ROLE. The downstream + // structural parser only needs a one-name CREATE shape, so route all + // three spellings through its existing generic CREATE TYPE name scanner. + canonical.push(tokens[index]); + canonical.push("TYPE"); + index += 2; + } else if tokens[index].eq_ignore_ascii_case("CREATE") && tokens .get(index + 1) .is_some_and(|token| token.eq_ignore_ascii_case("MATERIALIZED")) @@ -305,6 +319,18 @@ mod tests { assert!(normalized.contains("CREATE VIEW INVALID_QUOTED_IDENTIFIER AS SELECT 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}"); + } + } + #[test] fn view_modifiers_share_the_view_object_parser() { let normalized = normalize_migration_sql( From 7f63a667dae1eec4057c5cf6de7eada8cf385526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:05:06 +0900 Subject: [PATCH 023/309] test(persistence): require runtime role declaration evidence --- .../migration_identifier_lexing_contract.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 0529dbac7..6e32ed6ce 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -126,3 +126,32 @@ fn created_role_aliases_are_covered_by_the_object_naming_contract() { ); } } + +#[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) + ); +} From dffe2de9fde3019dd2d6e8e78c4d7f8e42e0be1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:06:29 +0900 Subject: [PATCH 024/309] fix(persistence): distinguish runtime role declaration evidence --- .../src/migration_validation.rs | 74 ++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 259511e58..e98a8a243 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -4,6 +4,41 @@ const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; pub(super) fn normalize_migration_sql(sql: &str) -> Option { + let normalized = lexically_normalize_migration_sql(sql)?; + Some(canonicalize_structural_keywords(&normalized)) +} + +pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { + let normalized = lexically_normalize_migration_sql(sql)?; + let tokens = normalized.split_whitespace().collect::>(); + let mut index = 0usize; + while index + 2 < tokens.len() { + if tokens[index].eq_ignore_ascii_case("CREATE") + && tokens.get(index + 1).is_some_and(|token| { + token.eq_ignore_ascii_case("ROLE") + || token.eq_ignore_ascii_case("USER") + || token.eq_ignore_ascii_case("GROUP") + }) + { + let name = tokens[index + 2] + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + if name.eq_ignore_ascii_case(expected_role) { + return Some(true); + } + } + index += 1; + } + Some(false) +} + +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") +} + +fn lexically_normalize_migration_sql(sql: &str) -> Option { let bytes = sql.as_bytes(); let mut normalized = Vec::with_capacity(bytes.len()); let mut index = 0usize; @@ -65,8 +100,7 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { } } - let normalized = String::from_utf8(normalized).ok()?; - Some(canonicalize_structural_keywords(&normalized)) + String::from_utf8(normalized).ok() } fn canonicalize_structural_keywords(sql: &str) -> String { @@ -276,7 +310,10 @@ fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { #[cfg(test)] mod tests { - use super::{INVALID_QUALIFIED_IDENTIFIER, normalize_migration_sql}; + use super::{ + INVALID_QUALIFIED_IDENTIFIER, declares_created_role, declares_row_level_security, + normalize_migration_sql, + }; #[test] fn lexical_normalization_masks_declaration_shaped_trivia() { @@ -331,6 +368,37 @@ mod tests { } } + #[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) + ); + } + + #[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( From 0cd03111bfef95aadab49b486c84eb11c0a2b8e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:06:36 +0900 Subject: [PATCH 025/309] fix(persistence): require declared runtime role for RLS --- crates/persistence_postgres/src/migration.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 04159d9fa..515a83722 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -21,8 +21,16 @@ pub use core::MigrationCatalog; pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { + let runtime_role_declared = + validation::declares_created_role(catalog.up_sql(), "tepp_app_runtime") + .ok_or(MigrationContractError::EmptyMigrationSql)?; let normalized_up = validation::normalize_migration_sql(catalog.up_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; + let requires_runtime_role = validation::declares_row_level_security(&normalized_up); let normalized = MigrationCatalog::from_sql(&normalized_up, catalog.down_sql()); - core::validate_migration_catalog(&normalized) + core::validate_migration_catalog(&normalized)?; + if requires_runtime_role && !runtime_role_declared { + return Err(MigrationContractError::MissingAppRuntimeRole); + } + Ok(()) } From 9e800184d3e5e546d0e8fd5096b1840204ed8402 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:09:32 +0900 Subject: [PATCH 026/309] test(persistence): distinguish user mappings from role aliases --- .../tests/migration_identifier_lexing_contract.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 6e32ed6ce..eeb8464d3 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -127,6 +127,15 @@ fn created_role_aliases_are_covered_by_the_object_naming_contract() { } } +#[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( From facb48566c59a88fda8d45b2c22358f48d29f0d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 20:10:17 +0900 Subject: [PATCH 027/309] fix(persistence): keep user mappings out of role alias parsing --- .../src/migration_validation.rs | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index e98a8a243..23610a300 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -13,13 +13,7 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option>(); let mut index = 0usize; while index + 2 < tokens.len() { - if tokens[index].eq_ignore_ascii_case("CREATE") - && tokens.get(index + 1).is_some_and(|token| { - token.eq_ignore_ascii_case("ROLE") - || token.eq_ignore_ascii_case("USER") - || token.eq_ignore_ascii_case("GROUP") - }) - { + if is_role_creation_alias(&tokens, index) { let name = tokens[index + 2] .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') @@ -103,22 +97,39 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { String::from_utf8(normalized).ok() } +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") +} + 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 tokens[index].eq_ignore_ascii_case("CREATE") - && tokens.get(index + 1).is_some_and(|token| { - token.eq_ignore_ascii_case("ROLE") - || token.eq_ignore_ascii_case("USER") - || token.eq_ignore_ascii_case("GROUP") - }) - { + 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. The downstream - // structural parser only needs a one-name CREATE shape, so route all - // three spellings through its existing generic CREATE TYPE name scanner. + // 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; @@ -366,6 +377,11 @@ mod tests { 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] @@ -385,6 +401,13 @@ mod tests { ), Some(false) ); + assert_eq!( + declares_created_role( + "CREATE USER MAPPING FOR tepp_app_runtime SERVER foreign_server;", + "tepp_app_runtime" + ), + Some(false) + ); } #[test] From cd82ba1afca5ff0cc19195a35328c3f6fe3324d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 21:04:07 +0900 Subject: [PATCH 028/309] test(persistence): require runtime role final-state presence --- .../migration_identifier_lexing_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index eeb8464d3..a95e2b5e8 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -164,3 +164,33 @@ fn runtime_role_reference_does_not_substitute_for_role_declaration() { 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) + ); +} From 1fbe7e5324cd43c1f8508400aac22a7c8806368e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 21:07:25 +0900 Subject: [PATCH 029/309] fix(persistence): track runtime role drops in migration evidence --- .../src/migration_validation.rs | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 23610a300..970bfe9e6 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -11,20 +11,22 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { let normalized = lexically_normalize_migration_sql(sql)?; let tokens = normalized.split_whitespace().collect::>(); + let mut declared = false; let mut index = 0usize; - while index + 2 < tokens.len() { + while index < tokens.len() { if is_role_creation_alias(&tokens, index) { - let name = tokens[index + 2] - .chars() - .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') - .collect::(); + let name = role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); if name.eq_ignore_ascii_case(expected_role) { - return Some(true); + declared = true; } + } else if is_role_drop_alias(&tokens, index) + && drop_statement_mentions_role(&tokens, index, expected_role) + { + declared = false; } index += 1; } - Some(false) + Some(declared) } pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { @@ -119,6 +121,62 @@ fn is_role_creation_alias(tokens: &[&str], create_index: usize) -> bool { || kind.eq_ignore_ascii_case("GROUP") } +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") +} + +fn role_identifier(fragment: &str) -> String { + fragment + .trim_start_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect() +} + +fn drop_statement_mentions_role(tokens: &[&str], drop_index: usize, expected_role: &str) -> bool { + 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; + } + + while let Some(token) = tokens.get(name_index) { + for fragment in token.split(',') { + if role_identifier(fragment).eq_ignore_ascii_case(expected_role) { + return true; + } + } + if token.contains(';') { + break; + } + name_index += 1; + } + false +} + fn canonicalize_structural_keywords(sql: &str) -> String { let tokens = sql.split_whitespace().collect::>(); let mut canonical = Vec::with_capacity(tokens.len()); @@ -408,6 +466,41 @@ mod tests { ), 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) + ); } #[test] From e8cc37de946f6bb1a10fe680c1c37e938ac9b5e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 21:10:43 +0900 Subject: [PATCH 030/309] test(persistence): cover adjacent runtime role drop statements --- .../migration_identifier_lexing_contract.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index a95e2b5e8..86cddf584 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -194,3 +194,29 @@ fn runtime_role_must_still_exist_after_the_forward_migration() { 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}" + ); + } +} From f5d3c956d6662bab7e64711d5e509917a73d0f8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 21:11:37 +0900 Subject: [PATCH 031/309] fix(persistence): tokenize role statement delimiters explicitly --- .../src/migration_validation.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 970bfe9e6..dd8259452 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -10,7 +10,12 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { let normalized = lexically_normalize_migration_sql(sql)?; - let tokens = normalized.split_whitespace().collect::>(); + // 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 = false; let mut index = 0usize; while index < tokens.len() { @@ -501,6 +506,13 @@ mod tests { ), Some(true) ); + assert_eq!( + declares_created_role( + "CREATE ROLE tepp_app_runtime;DROP ROLE tepp_app_runtime;", + "tepp_app_runtime" + ), + Some(false) + ); } #[test] From 6d276133e45b4780d9756f263855e343abd0d159 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 22:00:28 +0900 Subject: [PATCH 032/309] test(persistence): cover role rename final-state contracts --- .../tests/migration_role_rename_contract.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_role_rename_contract.rs 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) + ); +} From fd09a73c48ec033556bcf4d465fc9276a2bff450 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 22:01:55 +0900 Subject: [PATCH 033/309] fix(persistence): track role renames in migration contracts --- .../src/migration_validation.rs | 123 +++++++++++++++--- 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index dd8259452..57a831d5d 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,5 +1,7 @@ //! PostgreSQL lexical normalization for migration contract parsing. +use std::collections::BTreeSet; + const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; @@ -16,22 +18,28 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option>(); - let mut declared = false; + let mut declared_roles = BTreeSet::new(); let mut index = 0usize; while index < tokens.len() { if is_role_creation_alias(&tokens, index) { - let name = role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); - if name.eq_ignore_ascii_case(expected_role) { - declared = true; + let name = normalized_role_identifier(tokens.get(index + 2).copied().unwrap_or_default()); + if !name.is_empty() { + declared_roles.insert(name); + } + } else if is_role_drop_alias(&tokens, index) { + for name in drop_statement_role_names(&tokens, index) { + 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 declared_roles.remove(&source) && !target.is_empty() { + declared_roles.insert(target); } - } else if is_role_drop_alias(&tokens, index) - && drop_statement_mentions_role(&tokens, index, expected_role) - { - declared = false; } index += 1; } - Some(declared) + Some(declared_roles.contains(&expected_role.to_ascii_lowercase())) } pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { @@ -148,6 +156,35 @@ fn is_role_drop_alias(tokens: &[&str], drop_index: usize) -> bool { || kind.eq_ignore_ascii_case("GROUP") } +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() +} + fn role_identifier(fragment: &str) -> String { fragment .trim_start_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') @@ -156,7 +193,11 @@ fn role_identifier(fragment: &str) -> String { .collect() } -fn drop_statement_mentions_role(tokens: &[&str], drop_index: usize, expected_role: &str) -> bool { +fn normalized_role_identifier(fragment: &str) -> String { + role_identifier(fragment).to_ascii_lowercase() +} + +fn drop_statement_role_names(tokens: &[&str], drop_index: usize) -> Vec { let mut name_index = drop_index + 2; if tokens .get(name_index) @@ -168,18 +209,20 @@ fn drop_statement_mentions_role(tokens: &[&str], drop_index: usize, expected_rol name_index += 2; } + let mut names = Vec::new(); while let Some(token) = tokens.get(name_index) { - for fragment in token.split(',') { - if role_identifier(fragment).eq_ignore_ascii_case(expected_role) { - return true; - } - } - if token.contains(';') { + if *token == ";" { break; } + if *token != "," { + let name = normalized_role_identifier(token); + if !name.is_empty() { + names.push(name); + } + } name_index += 1; } - false + names } fn canonicalize_structural_keywords(sql: &str) -> String { @@ -196,6 +239,14 @@ fn canonicalize_structural_keywords(sql: &str) -> String { 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) @@ -513,6 +564,44 @@ mod tests { ), 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] From 941626f0b767434f04034fda3eaa93c07e80f639 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 23:00:16 +0900 Subject: [PATCH 034/309] test(persistence): reject malformed rollback SQL --- .../tests/migration_down_lexical_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_down_lexical_contract.rs 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) + ); +} From 01665ac0e1e16433bb930594bcd6ed0670c49543 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 16 Sep 2026 23:00:34 +0900 Subject: [PATCH 035/309] fix(persistence): normalize rollback SQL before validation --- crates/persistence_postgres/src/migration.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 515a83722..bdb0e58e7 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -14,6 +14,8 @@ pub use core::MigrationCatalog; /// 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 /// @@ -26,8 +28,10 @@ pub fn validate_migration_catalog( .ok_or(MigrationContractError::EmptyMigrationSql)?; let normalized_up = validation::normalize_migration_sql(catalog.up_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; + let normalized_down = validation::normalize_migration_sql(catalog.down_sql()) + .ok_or(MigrationContractError::EmptyMigrationSql)?; let requires_runtime_role = validation::declares_row_level_security(&normalized_up); - let normalized = MigrationCatalog::from_sql(&normalized_up, catalog.down_sql()); + 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); From b712bbad13751e28f7e34874fb180347c36a9b4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 00:03:33 +0900 Subject: [PATCH 036/309] test(persistence): cover PostgreSQL escape strings --- .../tests/migration_escape_string_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_escape_string_contract.rs 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(())); +} From 23d85f0524398110930973de36f1e707ab92795e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 00:06:55 +0900 Subject: [PATCH 037/309] fix(persistence): parse PostgreSQL escape strings lexically --- .../src/migration_validation.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 57a831d5d..2d6c94ba6 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -54,6 +54,17 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { 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' '); @@ -345,6 +356,29 @@ fn scan_single_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8 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 +} + fn scan_quoted_identifier(bytes: &[u8], start: usize) -> Option<(usize, Vec)> { let mut index = start + 1; let mut identifier = Vec::new(); @@ -463,6 +497,16 @@ mod tests { 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;") @@ -657,6 +701,7 @@ mod tests { fn malformed_lexical_regions_fail_closed() { for sql in [ "SELECT 'unterminated", + "SELECT E'unterminated", "CREATE TABLE \"unterminated", "/* unterminated", "DO $body$ unterminated", From 3b6496d1bb342741eec6c51eb4ec989340595198 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 01:04:55 +0900 Subject: [PATCH 038/309] test(persistence): reject PostgreSQL-truncated identifiers --- .../migration_identifier_length_contract.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_identifier_length_contract.rs 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" + ); +} From 151b1e6faa6e9979dbc1e3b5752bdbfbbf5bc7b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 01:05:13 +0900 Subject: [PATCH 039/309] fix(persistence): bound database identifiers to PostgreSQL limit --- crates/persistence_postgres/src/naming.rs | 27 ++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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")); From b401895961e7bb8fb929f38fbc06a20822bcbb35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 02:03:34 +0900 Subject: [PATCH 040/309] test(persistence): cover CREATE INDEX CONCURRENTLY naming --- .../migration_concurrent_index_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_concurrent_index_contract.rs 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..e5a4504f6 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs @@ -0,0 +1,39 @@ +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_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}" + ); + } +} From 5e9683295b8f8f9a41bcf74e18c0ad85d2bd521a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 02:04:17 +0900 Subject: [PATCH 041/309] fix(persistence): canonicalize concurrent index modifiers --- crates/persistence_postgres/src/migration.rs | 82 +++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index bdb0e58e7..64745d6c4 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -26,9 +26,9 @@ pub fn validate_migration_catalog( let runtime_role_declared = validation::declares_created_role(catalog.up_sql(), "tepp_app_runtime") .ok_or(MigrationContractError::EmptyMigrationSql)?; - let normalized_up = validation::normalize_migration_sql(catalog.up_sql()) + let normalized_up = normalize_catalog_sql(catalog.up_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; - let normalized_down = validation::normalize_migration_sql(catalog.down_sql()) + let normalized_down = normalize_catalog_sql(catalog.down_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; let requires_runtime_role = validation::declares_row_level_security(&normalized_up); let normalized = MigrationCatalog::from_sql(&normalized_up, &normalized_down); @@ -38,3 +38,81 @@ pub fn validate_migration_catalog( } Ok(()) } + +/// 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) + } +} + +fn canonicalize_concurrent_index_modifier(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 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; + } + } + + canonical.join(" ") +} + +#[cfg(test)] +mod tests { + use super::canonicalize_concurrent_index_modifier; + + #[test] + fn concurrent_index_modifier_is_removed_without_changing_the_declared_name() { + assert_eq!( + canonicalize_concurrent_index_modifier( + "CREATE INDEX CONCURRENTLY tenant_record_lookup_index ON tenant_record" + ), + "CREATE INDEX tenant_record_lookup_index ON tenant_record" + ); + assert_eq!( + 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" + ); + } +} From 81b4f0b9d3f42db3b31c6a9e6b21f79a9b7f7e1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 03:03:16 +0900 Subject: [PATCH 042/309] test(persistence): cover delimiter-adjacent concurrent index --- .../tests/migration_concurrent_index_contract.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs b/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs index e5a4504f6..584ecf1b5 100644 --- a/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs +++ b/crates/persistence_postgres/tests/migration_concurrent_index_contract.rs @@ -23,6 +23,16 @@ fn concurrently_modifier_does_not_hide_a_valid_index_name() { } } +#[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 [ From 71eedd7edda5c12de5e815d95d12e326b1138639 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 03:03:34 +0900 Subject: [PATCH 043/309] fix(persistence): tokenize statement delimiters before index canonicalization --- crates/persistence_postgres/src/migration.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 64745d6c4..c19f82fa2 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -56,7 +56,12 @@ fn normalize_catalog_sql(sql: &str) -> Option { } fn canonicalize_concurrent_index_modifier(sql: &str) -> String { - let tokens = sql.split_whitespace().collect::>(); + // 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; @@ -114,5 +119,11 @@ mod tests { ), "CREATE UNIQUE INDEX IF NOT EXISTS tenant_record_lookup_index ON tenant_record" ); + assert_eq!( + 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" + ); } } From 3bf289efd99844fab74c01603a4962ae922a9cc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 04:04:14 +0900 Subject: [PATCH 044/309] test(persistence): reject quoted constraint-keyword columns --- .../migration_identifier_lexing_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 86cddf584..0bafd7f7e 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -51,6 +51,34 @@ fn quoted_column_cannot_bypass_the_naming_contract() { ); } +#[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 declaration_shaped_text_inside_sql_trivia_is_not_an_object() { let catalog = conforming_catalog( From 2c54e7a362f9a48f7a49e000e09753df8d29cfe6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 04:07:26 +0900 Subject: [PATCH 045/309] fix(persistence): preserve quoted keyword token class --- .../src/migration_validation.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 2d6c94ba6..c697976ea 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -79,7 +79,9 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { b'"' => { let (next, identifier) = scan_quoted_identifier(bytes, index)?; normalized.push(b' '); - if quoted_identifier_is_structurally_safe(&identifier) { + 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); @@ -467,6 +469,21 @@ fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') } +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::{ From f1f88a48cee578e00301384cfac39e76452aa88d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 05:00:15 +0900 Subject: [PATCH 046/309] test(persistence): expose table prefix body aliasing --- .../tests/migration_table_prefix_contract.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_table_prefix_contract.rs 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) + ); +} From da34c21d44554fbdaffeb416c79f04523d211c73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 05:03:57 +0900 Subject: [PATCH 047/309] fix(persistence): bind table body to exact identifier --- .../src/migration_core.rs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 29d6b5fd3..b3dc025fd 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -487,16 +487,36 @@ fn parse_column_names(body: &str) -> BTreeSet { names } +fn identifier_continues_after(sql: &str, end: usize) -> bool { + sql[end..] + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +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 +} + 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 + let declaration_end = needles .iter() - .find_map(|needle| lower.find(needle).map(|idx| (idx, needle.len())))?; - let after = &sql[start.0 + start.1..]; + .find_map(|needle| find_table_declaration_end(&lower, needle))?; + let after = &sql[declaration_end..]; let open = after.find('(')?; let mut depth = 0i32; for (idx, ch) in after[open..].char_indices() { From bcd2f8a3fc8a63d4792f91c0a279d48e78e9a718 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 05:06:48 +0900 Subject: [PATCH 048/309] test(persistence): expose cross-statement table body lookup --- .../migration_create_table_as_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_create_table_as_contract.rs 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) + ); +} From 0529ca1965ae3fd0a7b77642d09ba62541126f2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 05:10:42 +0900 Subject: [PATCH 049/309] fix(persistence): keep table body lookup statement-local --- .../src/migration_core.rs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index b3dc025fd..ad804cc70 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -507,6 +507,15 @@ fn find_table_declaration_end(lower_sql: &str, needle: &str) -> Option { None } +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| !ch.is_ascii_alphanumeric() && ch != '_') +} + fn table_body<'a>(sql: &'a str, table: &str) -> Option<&'a str> { let lower = sql.to_ascii_lowercase(); let needles = [ @@ -516,18 +525,24 @@ fn table_body<'a>(sql: &'a str, table: &str) -> Option<&'a str> { let declaration_end = needles .iter() .find_map(|needle| find_table_declaration_end(&lower, needle))?; - let after = &sql[declaration_end..]; - let open = after.find('(')?; + 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[open..].char_indices() { + for (idx, ch) in after.char_indices() { match ch { '(' => depth += 1, ')' => { depth -= 1; if depth == 0 { - return Some(&after[open..=open + idx]); + return Some(&after[..=idx]); } } + ';' => return None, _ => {} } } From b37b1aa2956dffe66db4096cb72f91f62ad7efea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:02:23 +0900 Subject: [PATCH 050/309] test(persistence): require exact contract column identities --- .../tests/migration_exact_column_contract.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_exact_column_contract.rs 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) + ); +} From 24739ca4716aa7095a676ccefecdb930bb1357da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:07:31 +0900 Subject: [PATCH 051/309] fix(persistence): validate exact required column identities --- .../src/migration_core.rs | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index ad804cc70..342a8fdf6 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -248,17 +248,17 @@ fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractEr } fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { - for column in parse_column_names(body) { - if !is_multi_word_snake_case(&column) { + let columns = parse_column_names(body); + for column in &columns { + if !is_multi_word_snake_case(column) { return Err(MigrationContractError::SingleWordObjectName); } } - let lower = body.to_ascii_lowercase(); - if requires_tenant_boundary(table) && !lower.contains("tenant_record_id") { + if requires_tenant_boundary(table) && !columns.contains("tenant_record_id") { return Err(MigrationContractError::MissingTenantBoundary); } - if !has_system_time_column(&lower) { + if !has_system_time_column(body) { return Err(MigrationContractError::MissingTemporalColumns); } @@ -267,7 +267,7 @@ fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractE return Ok(()); } - if !has_domain_time_column(&lower) { + if !has_domain_time_column(body) { return Err(MigrationContractError::MissingTemporalColumns); } Ok(()) @@ -342,17 +342,16 @@ fn is_registry_or_audit_table(table: &str) -> bool { table == "tenant_record" || table == "audit_event" } -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 +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") } -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 +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. From e8fc96bc2cb8bec509a3a67ea0df1fa07da60593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:10:12 +0900 Subject: [PATCH 052/309] test(persistence): require exact RLS tenant identities --- .../migration_rls_exact_identity_contract.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs 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..d8eade324 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -0,0 +1,53 @@ +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) + ); +} From 142e4fb3120eb07fdf9c60121df17c3730ddd509 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:10:50 +0900 Subject: [PATCH 053/309] test(persistence): reject RLS table-prefix aliases --- .../migration_rls_exact_identity_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index d8eade324..97e42f327 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -51,3 +51,42 @@ fn tenant_policy_requires_the_exact_session_guc_key() { Err(MigrationContractError::MissingTenantSessionGuc) ); } + +#[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) + ); +} From a9fd7601adc365de602acd4398c76ce5402a85e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:12:45 +0900 Subject: [PATCH 054/309] fix(persistence): bind RLS contracts to exact identities --- .../src/migration_core.rs | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 342a8fdf6..828d04b5e 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -281,7 +281,7 @@ fn validate_tenant_rls_contract( if !lower.contains("tepp_app_runtime") { return Err(MigrationContractError::MissingAppRuntimeRole); } - if !lower.contains("tepp.current_tenant_record_id") { + if !lower.contains("'tepp.current_tenant_record_id'") { return Err(MigrationContractError::MissingTenantSessionGuc); } @@ -317,7 +317,6 @@ fn table_has_rls_enabled(lower_sql: &str, table: &str) -> bool { } 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; @@ -326,7 +325,9 @@ fn table_has_tenant_policy(lower_sql: &str, table: &str) -> bool { .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") { + if policy_targets_table(window, table) + && contains_unquoted_identifier(window, "tenant_record_id") + { return true; } search_from = abs + "create policy".len(); @@ -334,6 +335,39 @@ fn table_has_tenant_policy(lower_sql: &str, table: &str) -> bool { false } +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 +} + +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 +} + fn requires_tenant_boundary(table: &str) -> bool { table != "tenant_record" } From 9a8b26eef847cdddb682509e9cfb055e7b9aaf44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:14:26 +0900 Subject: [PATCH 055/309] test(persistence): use real tenant GUC evidence --- crates/persistence_postgres/src/migration_core.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 828d04b5e..90191965a 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -359,7 +359,9 @@ fn contains_unquoted_identifier(sql: &str, identifier: &str) -> bool { .count() % 2 == 1; - if !inside_atomic_literal && is_word_start(sql, start) && !identifier_continues_after(sql, end) + if !inside_atomic_literal + && is_word_start(sql, start) + && !identifier_continues_after(sql, end) { return true; } @@ -935,7 +937,7 @@ mod tests { system_time timestamptz NOT NULL ); CREATE ROLE tepp_app_runtime NOSUPERUSER; - -- tepp.current_tenant_record_id referenced for GUC scan; isolation policy omitted + 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; ", @@ -954,7 +956,7 @@ mod tests { system_time timestamptz NOT NULL ); CREATE ROLE tepp_app_runtime NOSUPERUSER; - -- bind GUC name for scan: tepp.current_tenant_record_id + 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 From f6089eac3d33d723d0c55252804fd164fd691253 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:16:57 +0900 Subject: [PATCH 056/309] test(persistence): require actual tenant GUC lookup --- .../migration_rls_exact_identity_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index 97e42f327..91f4a9b32 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -52,6 +52,34 @@ fn tenant_policy_requires_the_exact_session_guc_key() { ); } +#[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_policy_on_a_longer_table_name_cannot_cover_a_prefix_table() { let catalog = MigrationCatalog::from_sql( From 94b628827af9437cdbbeb0cae562a19e0d25d380 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:17:41 +0900 Subject: [PATCH 057/309] fix(persistence): require actual tenant GUC lookup --- crates/persistence_postgres/src/migration.rs | 58 +++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index c19f82fa2..3b4cd7e8a 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -31,6 +31,9 @@ pub fn validate_migration_catalog( let normalized_down = normalize_catalog_sql(catalog.down_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; let requires_runtime_role = validation::declares_row_level_security(&normalized_up); + if requires_runtime_role && !declares_tenant_session_guc(&normalized_up) { + return Err(MigrationContractError::MissingTenantSessionGuc); + } let normalized = MigrationCatalog::from_sql(&normalized_up, &normalized_down); core::validate_migration_catalog(&normalized)?; if requires_runtime_role && !runtime_role_declared { @@ -39,6 +42,43 @@ pub fn validate_migration_catalog( Ok(()) } +/// Require the tenant setting key to be the first argument of PostgreSQL's +/// `current_setting` call rather than accepting the same literal anywhere in +/// the migration text. +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 starts_at_boundary = normalized_sql[..start] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + let ends_at_boundary = normalized_sql[end..] + .chars() + .next() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + + if starts_at_boundary && 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; + } + } + } + } + search_from = end; + } + false +} + /// Normalize SQL and then remove PostgreSQL's `CONCURRENTLY` index modifier /// from the structural parser view without treating it as the index name. /// @@ -103,7 +143,23 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { #[cfg(test)] mod tests { - use super::canonicalize_concurrent_index_modifier; + use super::{canonicalize_concurrent_index_modifier, declares_tenant_session_guc}; + + #[test] + 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 )" + )); + } #[test] fn concurrent_index_modifier_is_removed_without_changing_the_declared_name() { From fb0a1355beef45d95d19c4dc5112f747afa63555 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:57:22 +0900 Subject: [PATCH 058/309] test(persistence): bind tenant setting evidence to policy --- .../migration_rls_exact_identity_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index 91f4a9b32..b3303c4d3 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -80,6 +80,34 @@ fn tenant_guc_literal_without_current_setting_does_not_satisfy_the_contract() { ); } +#[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 tenant_policy_on_a_longer_table_name_cannot_cover_a_prefix_table() { let catalog = MigrationCatalog::from_sql( From dff009f2aacf76d13fd456e592aad4295327a4d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 06:58:20 +0900 Subject: [PATCH 059/309] fix(persistence): bind tenant GUC evidence to each policy --- crates/persistence_postgres/src/migration.rs | 41 +++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 3b4cd7e8a..ba1a67678 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -34,6 +34,9 @@ pub fn validate_migration_catalog( if requires_runtime_role && !declares_tenant_session_guc(&normalized_up) { return Err(MigrationContractError::MissingTenantSessionGuc); } + if requires_runtime_role && !tenant_policies_bind_session_guc(&normalized_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 { @@ -79,6 +82,29 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } +/// Bind tenant-session evidence to each policy statement instead of allowing a +/// real `current_setting` call elsewhere in the migration to vouch for a policy +/// whose predicate never consults the tenant session key. +fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { + const CREATE_POLICY: &str = "create policy"; + + let lower = normalized_sql.to_ascii_lowercase(); + let mut search_from = 0usize; + let mut saw_policy = false; + 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]; + if !declares_tenant_session_guc(statement) { + return false; + } + search_from = start + CREATE_POLICY.len(); + } + saw_policy +} + /// Normalize SQL and then remove PostgreSQL's `CONCURRENTLY` index modifier /// from the structural parser view without treating it as the index name. /// @@ -143,7 +169,10 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { #[cfg(test)] mod tests { - use super::{canonicalize_concurrent_index_modifier, declares_tenant_session_guc}; + use super::{ + canonicalize_concurrent_index_modifier, declares_tenant_session_guc, + tenant_policies_bind_session_guc, + }; #[test] fn tenant_guc_requires_a_current_setting_call() { @@ -161,6 +190,16 @@ mod tests { )); } + #[test] + fn tenant_guc_must_be_bound_to_the_policy_statement() { + 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);" + )); + } + #[test] fn concurrent_index_modifier_is_removed_without_changing_the_declared_name() { assert_eq!( From 48d55d9ab16757abac97e807bc313f34818f5f10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 07:03:25 +0900 Subject: [PATCH 060/309] test(persistence): allow supplemental RLS policies --- .../migration_rls_exact_identity_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index b3303c4d3..b74158451 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -108,6 +108,37 @@ fn tenant_setting_call_outside_policy_cannot_cover_policy() { ); } +#[test] +fn 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 + FOR SELECT + USING (document_record_id IS NOT NULL); + ", + "DROP TABLE document_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + #[test] fn tenant_policy_on_a_longer_table_name_cannot_cover_a_prefix_table() { let catalog = MigrationCatalog::from_sql( From 4f65057e03306838cedae249c6d845030890d1e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 07:04:48 +0900 Subject: [PATCH 061/309] test(persistence): limit supplemental RLS allowance to restrictive policy --- .../tests/migration_rls_exact_identity_contract.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index b74158451..783c4b1c5 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -109,7 +109,7 @@ fn tenant_setting_call_outside_policy_cannot_cover_policy() { } #[test] -fn supplemental_policy_need_not_repeat_the_tenant_session_predicate() { +fn restrictive_supplemental_policy_need_not_repeat_the_tenant_session_predicate() { let catalog = MigrationCatalog::from_sql( r" CREATE TABLE document_record ( @@ -130,6 +130,7 @@ fn supplemental_policy_need_not_repeat_the_tenant_session_predicate() { 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); ", From 849fd8fa1ffa45e24d056b1e25a15991f88e6eab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 07:05:15 +0900 Subject: [PATCH 062/309] fix(persistence): respect restrictive RLS composition --- crates/persistence_postgres/src/migration.rs | 34 ++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index ba1a67678..4f5196863 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -82,9 +82,22 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } -/// Bind tenant-session evidence to each policy statement instead of allowing a -/// real `current_setting` call elsewhere in the migration to vouch for a policy -/// whose predicate never consults the tenant session key. +/// Return whether the normalized policy explicitly declares PostgreSQL's +/// restrictive policy composition mode. Restrictive policies can only narrow +/// rows already admitted by permissive policies, so they need not repeat the +/// tenant-session predicate themselves. +fn policy_is_restrictive(policy_sql: &str) -> bool { + let tokens = policy_sql.split_whitespace().collect::>(); + tokens.windows(2).any(|pair| { + pair[0].eq_ignore_ascii_case("AS") && pair[1].eq_ignore_ascii_case("RESTRICTIVE") + }) +} + +/// Bind tenant-session evidence to every policy that can independently admit +/// rows. PostgreSQL permissive policies are OR-composed, so a permissive policy +/// without the tenant-session predicate could widen access even when another +/// tenant-isolation policy is correct. Restrictive policies are AND-composed +/// and may add narrower conditions without duplicating the tenant key lookup. fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { const CREATE_POLICY: &str = "create policy"; @@ -97,7 +110,7 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { let statement_tail = &normalized_sql[start..]; let statement_end = statement_tail.find(';').unwrap_or(statement_tail.len()); let statement = &statement_tail[..statement_end]; - if !declares_tenant_session_guc(statement) { + if !policy_is_restrictive(statement) && !declares_tenant_session_guc(statement) { return false; } search_from = start + CREATE_POLICY.len(); @@ -171,7 +184,7 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { mod tests { use super::{ canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - tenant_policies_bind_session_guc, + policy_is_restrictive, tenant_policies_bind_session_guc, }; #[test] @@ -191,13 +204,22 @@ mod tests { } #[test] - fn tenant_guc_must_be_bound_to_the_policy_statement() { + 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 (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!(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)" + )); } #[test] From 7215c51c8afd4769a9c28c0030762b607127f2e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:00:22 +0900 Subject: [PATCH 063/309] test(persistence): reject restrictive alias spoofing --- .../migration_rls_exact_identity_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index 783c4b1c5..451f5bc39 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -140,6 +140,36 @@ fn restrictive_supplemental_policy_need_not_repeat_the_tenant_session_predicate( 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( From 17a72a7cd94a47a5ddd5a155358701710dea7729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:01:02 +0900 Subject: [PATCH 064/309] fix(persistence): bind restrictive mode to policy header --- crates/persistence_postgres/src/migration.rs | 29 +++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 4f5196863..e342385b8 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -82,15 +82,27 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } -/// Return whether the normalized policy explicitly declares PostgreSQL's -/// restrictive policy composition mode. Restrictive policies can only narrow -/// rows already admitted by permissive policies, so they need not repeat the -/// tenant-session predicate themselves. +/// 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::>(); - tokens.windows(2).any(|pair| { - pair[0].eq_ignore_ascii_case("AS") && pair[1].eq_ignore_ascii_case("RESTRICTIVE") - }) + let Some(on_index) = tokens + .iter() + .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")) } /// Bind tenant-session evidence to every policy that can independently admit @@ -220,6 +232,9 @@ mod tests { 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] From 5e1867eb5297febe5ca5a13d0978fc9001825519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:04:53 +0900 Subject: [PATCH 065/309] test(persistence): close remaining RLS evidence boundaries --- .../migration_rls_exact_identity_contract.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs index 451f5bc39..2aa33a1bd 100644 --- a/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_exact_identity_contract.rs @@ -108,6 +108,65 @@ fn tenant_setting_call_outside_policy_cannot_cover_policy() { ); } +#[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( From 5ccce5343adc83e8e173d5df1e914640a50bdc32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:05:26 +0900 Subject: [PATCH 066/309] fix(persistence): reject qualified tenant-setting lookalikes --- crates/persistence_postgres/src/migration.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index e342385b8..a27af65e9 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -46,8 +46,9 @@ pub fn validate_migration_catalog( } /// Require the tenant setting key to be the first argument of PostgreSQL's -/// `current_setting` call rather than accepting the same literal anywhere in -/// the migration text. +/// 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'"; @@ -56,16 +57,18 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { 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 != '_'); - if starts_at_boundary && ends_at_boundary { + 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(); @@ -213,6 +216,12 @@ mod tests { 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 )" + )); } #[test] From dc4c678666fe97aac43ddacc88ea03611ddc0daa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:07:11 +0900 Subject: [PATCH 067/309] fix(persistence): bind RLS evidence to policy statements --- crates/persistence_postgres/src/migration.rs | 65 +++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index a27af65e9..5e074dad2 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -85,6 +85,38 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } +/// Return whether the normalized policy statement contains the exact unquoted +/// tenant key identifier. Atomic string literals are retained by normalization, +/// so quote parity prevents the GUC literal itself from impersonating a column. +fn policy_binds_tenant_identifier(policy_sql: &str) -> bool { + const TENANT_IDENTIFIER: &str = "tenant_record_id"; + + let mut search_from = 0usize; + while let Some(relative) = policy_sql[search_from..].find(TENANT_IDENTIFIER) { + let start = search_from + relative; + let end = start + TENANT_IDENTIFIER.len(); + let inside_atomic_literal = policy_sql[..start] + .bytes() + .filter(|byte| *byte == b'\'') + .count() + % 2 + == 1; + let starts_at_boundary = policy_sql[..start] + .chars() + .next_back() + .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); + let ends_at_boundary = policy_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; + } + false +} + /// 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 @@ -108,11 +140,11 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { .is_some_and(|token| token.eq_ignore_ascii_case("RESTRICTIVE")) } -/// Bind tenant-session evidence to every policy that can independently admit -/// rows. PostgreSQL permissive policies are OR-composed, so a permissive policy -/// without the tenant-session predicate could widen access even when another -/// tenant-isolation policy is correct. Restrictive policies are AND-composed -/// and may add narrower conditions without duplicating the tenant key lookup. +/// Bind tenant identity and tenant-session evidence to every policy that can +/// independently admit rows. PostgreSQL permissive policies are OR-composed, +/// so a permissive policy must carry both witnesses inside its own structural +/// statement. Restrictive policies are AND-composed and may add narrower +/// conditions without duplicating the tenant predicate. fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { const CREATE_POLICY: &str = "create policy"; @@ -125,7 +157,10 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { let statement_tail = &normalized_sql[start..]; let statement_end = statement_tail.find(';').unwrap_or(statement_tail.len()); let statement = &statement_tail[..statement_end]; - if !policy_is_restrictive(statement) && !declares_tenant_session_guc(statement) { + if !policy_is_restrictive(statement) + && (!declares_tenant_session_guc(statement) + || !policy_binds_tenant_identifier(statement)) + { return false; } search_from = start + CREATE_POLICY.len(); @@ -199,7 +234,7 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { mod tests { use super::{ canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - policy_is_restrictive, tenant_policies_bind_session_guc, + policy_binds_tenant_identifier, policy_is_restrictive, tenant_policies_bind_session_guc, }; #[test] @@ -224,6 +259,19 @@ mod tests { )); } + #[test] + fn tenant_identifier_must_be_structural_policy_evidence() { + assert!(policy_binds_tenant_identifier( + "using (tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + assert!(!policy_binds_tenant_identifier( + "using (document_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + assert!(!policy_binds_tenant_identifier( + "using (tenant_record_id_shadow is not null)" + )); + } + #[test] fn tenant_guc_is_required_for_permissive_but_not_restrictive_policies() { assert!(tenant_policies_bind_session_guc( @@ -232,6 +280,9 @@ mod tests { 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::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);" )); From 523a2f78b04b41bb754e1012fbc2dc14780459dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:08:58 +0900 Subject: [PATCH 068/309] test(persistence): reject RLS header tenant spoof --- ...ration_rls_policy_header_spoof_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_policy_header_spoof_contract.rs 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) + ); +} From ac299d511265087b577c4b9203bd0f7e40792902 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:09:42 +0900 Subject: [PATCH 069/309] fix(persistence): exclude RLS headers from tenant evidence --- crates/persistence_postgres/src/migration.rs | 38 ++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 5e074dad2..21455b3ef 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -85,27 +85,42 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } -/// Return whether the normalized policy statement contains the exact unquoted -/// tenant key identifier. Atomic string literals are retained by normalization, -/// so quote parity prevents the GUC literal itself from impersonating a column. +/// 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"; + let lower = policy_sql.to_ascii_lowercase(); + let Some(on_start) = lower.find(" on ") else { + return false; + }; + let predicate_search_start = on_start + " on ".len(); + let tail = &lower[predicate_search_start..]; + let using_start = tail.find(" using ").map(|index| predicate_search_start + index); + let check_start = tail + .find(" with check ") + .map(|index| predicate_search_start + index); + let Some(predicate_start) = [using_start, check_start].into_iter().flatten().min() else { + return false; + }; + let predicate_sql = &policy_sql[predicate_start..]; + let mut search_from = 0usize; - while let Some(relative) = policy_sql[search_from..].find(TENANT_IDENTIFIER) { + 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 = policy_sql[..start] + let inside_atomic_literal = predicate_sql[..start] .bytes() .filter(|byte| *byte == b'\'') .count() % 2 == 1; - let starts_at_boundary = policy_sql[..start] + let starts_at_boundary = predicate_sql[..start] .chars() .next_back() .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); - let ends_at_boundary = policy_sql[end..] + let ends_at_boundary = predicate_sql[end..] .chars() .next() .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_'); @@ -262,13 +277,16 @@ mod tests { #[test] fn tenant_identifier_must_be_structural_policy_evidence() { assert!(policy_binds_tenant_identifier( - "using (tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + "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( - "using (document_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + "create policy tenant_record_id on document_record using (document_record_id is not null)" )); assert!(!policy_binds_tenant_identifier( - "using (tenant_record_id_shadow is not null)" + "create policy document_record_tenant_isolation on document_record using (tenant_record_id_shadow is not null)" )); } From 9592d62950393c562a1314e1d210c55f6d48bfa6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:58:01 +0900 Subject: [PATCH 070/309] test(persistence): require relational tenant RLS binding --- ...gration_rls_relational_binding_contract.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs 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..fa338c85b --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -0,0 +1,34 @@ +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) + ); +} From 8c4aaefdfdd18db51ec6250c8ad7fb5a38820dd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 08:59:46 +0900 Subject: [PATCH 071/309] fix(persistence): bind tenant RLS identity to session key --- crates/persistence_postgres/src/migration.rs | 103 ++++++++++++++++++- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 21455b3ef..c270001e9 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -132,6 +132,77 @@ fn policy_binds_tenant_identifier(policy_sql: &str) -> bool { false } +fn direct_tenant_operand(side: &str) -> bool { + let compact = side + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + let trimmed = compact.trim_matches(|ch: char| matches!(ch, '(' | ')')); + matches!(trimmed, "tenant_record_id" | "tenant_record_id::text") +} + +fn previous_predicate_boundary(lower_sql: &str, end: usize) -> usize { + const BOUNDARIES: [&str; 4] = [" and ", " or ", " using ", " with check "]; + BOUNDARIES + .iter() + .filter_map(|boundary| { + lower_sql[..end] + .rfind(boundary) + .map(|index| index + boundary.len()) + }) + .max() + .unwrap_or(0) +} + +fn next_predicate_boundary(lower_sql: &str, start: usize) -> usize { + const BOUNDARIES: [&str; 3] = [" and ", " or ", " with check "]; + BOUNDARIES + .iter() + .filter_map(|boundary| { + lower_sql[start..] + .find(boundary) + .map(|index| start + index) + }) + .min() + .unwrap_or(lower_sql.len()) +} + +/// Require the tenant column and tenant session key to participate in the same +/// equality comparison. Co-presence in unrelated boolean terms is not tenant +/// isolation evidence. This bounded recognizer intentionally accepts only the +/// direct tenant identifier (optionally cast to text) on one side; the other +/// side may wrap the exact `current_setting(...)` call, as the shipped migration +/// does with `nullif`. +fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { + let lower = policy_sql.to_ascii_lowercase(); + let bytes = policy_sql.as_bytes(); + + for equality in 0..bytes.len() { + if bytes[equality] != b'=' { + continue; + } + 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; + } + + let left_start = previous_predicate_boundary(&lower, equality); + let right_end = next_predicate_boundary(&lower, equality + 1); + let left = &policy_sql[left_start..equality]; + let right = &policy_sql[equality + 1..right_end]; + if (direct_tenant_operand(left) && declares_tenant_session_guc(right)) + || (declares_tenant_session_guc(left) && direct_tenant_operand(right)) + { + return true; + } + } + false +} + /// 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 @@ -157,9 +228,10 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { /// Bind tenant identity and tenant-session evidence to every policy that can /// independently admit rows. PostgreSQL permissive policies are OR-composed, -/// so a permissive policy must carry both witnesses inside its own structural -/// statement. Restrictive policies are AND-composed and may add narrower -/// conditions without duplicating the tenant predicate. +/// so a permissive policy must carry the exact tenant identifier, the tenant +/// session witness, and a direct equality binding between them inside its own +/// structural statement. Restrictive policies are AND-composed and may add +/// narrower conditions without duplicating the tenant predicate. fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { const CREATE_POLICY: &str = "create policy"; @@ -174,7 +246,8 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { let statement = &statement_tail[..statement_end]; if !policy_is_restrictive(statement) && (!declares_tenant_session_guc(statement) - || !policy_binds_tenant_identifier(statement)) + || !policy_binds_tenant_identifier(statement) + || !policy_binds_tenant_session_equality(statement)) { return false; } @@ -249,7 +322,8 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { mod tests { use super::{ canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - policy_binds_tenant_identifier, policy_is_restrictive, tenant_policies_bind_session_guc, + policy_binds_tenant_identifier, policy_binds_tenant_session_equality, + policy_is_restrictive, tenant_policies_bind_session_guc, }; #[test] @@ -290,6 +364,22 @@ mod tests { )); } + #[test] + fn tenant_session_witness_must_be_relationally_bound() { + assert!(policy_binds_tenant_session_equality( + "create policy document_record_tenant_isolation on document_record using (tenant_record_id::text = nullif ( current_setting ( 'tepp.current_tenant_record_id' , true ) , ))" + )); + assert!(policy_binds_tenant_session_equality( + "create policy document_record_tenant_isolation on document_record using (current_setting ( 'tepp.current_tenant_record_id' , true ) = tenant_record_id::text)" + )); + assert!(!policy_binds_tenant_session_equality( + "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!(!policy_binds_tenant_session_equality( + "create policy document_record_tenant_isolation on document_record using (tenant_record_id::text = document_record_id::text and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null)" + )); + } + #[test] fn tenant_guc_is_required_for_permissive_but_not_restrictive_policies() { assert!(tenant_policies_bind_session_guc( @@ -301,6 +391,9 @@ mod tests { 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 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);" )); From 70967e9d2106d4b75e62303cbb4f1976f0eb4fb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:02:55 +0900 Subject: [PATCH 072/309] test(persistence): reject weak explicit RLS write check --- ...gration_rls_relational_binding_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs index fa338c85b..ec6733efd 100644 --- a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -32,3 +32,34 @@ fn tenant_identifier_and_session_guc_must_form_the_same_equality_binding() { 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) + ); +} From d53453e11dfe0fd0ed9d3721cb4ce49bdae58267 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:04:10 +0900 Subject: [PATCH 073/309] fix(persistence): bind every explicit RLS predicate --- crates/persistence_postgres/src/migration.rs | 54 +++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index c270001e9..af7dfba5b 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -203,6 +203,28 @@ fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { false } +fn policy_row_predicates_bind_tenant_session(policy_sql: &str) -> bool { + const USING_CLAUSE: &str = " using "; + const CHECK_CLAUSE: &str = " with check "; + + let lower = policy_sql.to_ascii_lowercase(); + let using_start = lower.find(USING_CLAUSE).map(|index| index + USING_CLAUSE.len()); + let check_start = lower.find(CHECK_CLAUSE).map(|index| index + CHECK_CLAUSE.len()); + + let using_binds = using_start.is_none_or(|start| { + let end = lower[start..] + .find(CHECK_CLAUSE) + .map(|index| start + index) + .unwrap_or(policy_sql.len()); + policy_binds_tenant_session_equality(&policy_sql[start..end]) + }); + let check_binds = check_start.is_none_or(|start| { + policy_binds_tenant_session_equality(&policy_sql[start..]) + }); + + (using_start.is_some() || check_start.is_some()) && using_binds && check_binds +} + /// 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 @@ -227,11 +249,12 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { } /// Bind tenant identity and tenant-session evidence to every policy that can -/// independently admit rows. PostgreSQL permissive policies are OR-composed, -/// so a permissive policy must carry the exact tenant identifier, the tenant -/// session witness, and a direct equality binding between them inside its own -/// structural statement. Restrictive policies are AND-composed and may add -/// narrower conditions without duplicating the tenant predicate. +/// independently admit rows. PostgreSQL permissive policies are OR-composed. +/// Every explicit `USING` and `WITH CHECK` predicate therefore has to preserve +/// the direct tenant/session equality: `USING` controls row visibility while an +/// explicit `WITH CHECK` independently controls rows admitted by INSERT/UPDATE. +/// Restrictive policies are AND-composed and may add narrower conditions +/// without duplicating the tenant predicate. fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { const CREATE_POLICY: &str = "create policy"; @@ -247,7 +270,7 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { if !policy_is_restrictive(statement) && (!declares_tenant_session_guc(statement) || !policy_binds_tenant_identifier(statement) - || !policy_binds_tenant_session_equality(statement)) + || !policy_row_predicates_bind_tenant_session(statement)) { return false; } @@ -323,7 +346,8 @@ mod tests { use super::{ canonicalize_concurrent_index_modifier, declares_tenant_session_guc, policy_binds_tenant_identifier, policy_binds_tenant_session_equality, - policy_is_restrictive, tenant_policies_bind_session_guc, + policy_is_restrictive, policy_row_predicates_bind_tenant_session, + tenant_policies_bind_session_guc, }; #[test] @@ -380,6 +404,19 @@ mod tests { )); } + #[test] + fn each_explicit_row_predicate_preserves_tenant_binding() { + assert!(policy_row_predicates_bind_tenant_session( + "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::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + assert!(!policy_row_predicates_bind_tenant_session( + "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!(!policy_row_predicates_bind_tenant_session( + "create policy document_record_tenant_isolation on document_record for all using (tenant_record_id is not null) with check (tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" + )); + } + #[test] fn tenant_guc_is_required_for_permissive_but_not_restrictive_policies() { assert!(tenant_policies_bind_session_guc( @@ -394,6 +431,9 @@ mod tests { 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);" )); From edb9ccf8dadabdd0e9600e2a0fc577f1c5dd42bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:07:02 +0900 Subject: [PATCH 074/309] test(persistence): require RLS using for read-capable policies --- ...gration_rls_relational_binding_contract.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs index ec6733efd..0480740dc 100644 --- a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -63,3 +63,59 @@ fn explicit_with_check_cannot_weaken_a_tenant_bound_using_clause() { 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) + ); +} From 5f5b5e2d9a9b363864118674afafb6c88f0ce939 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:08:08 +0900 Subject: [PATCH 075/309] fix(persistence): enforce command-aware RLS predicates --- crates/persistence_postgres/src/migration.rs | 133 +++++++++++++++++-- 1 file changed, 120 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index af7dfba5b..fd8ef5475 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -203,26 +203,72 @@ fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { false } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PolicyCommand { + All, + Select, + Insert, + Update, + Delete, +} + +fn policy_command(policy_sql: &str) -> Option { + const USING_CLAUSE: &str = " using "; + const CHECK_CLAUSE: &str = " with check "; + + let lower = policy_sql.to_ascii_lowercase(); + let header_end = [lower.find(USING_CLAUSE), lower.find(CHECK_CLAUSE)] + .into_iter() + .flatten() + .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 policy_row_predicates_bind_tenant_session(policy_sql: &str) -> bool { const USING_CLAUSE: &str = " using "; const CHECK_CLAUSE: &str = " with check "; + let Some(command) = policy_command(policy_sql) else { + return false; + }; let lower = policy_sql.to_ascii_lowercase(); let using_start = lower.find(USING_CLAUSE).map(|index| index + USING_CLAUSE.len()); let check_start = lower.find(CHECK_CLAUSE).map(|index| index + CHECK_CLAUSE.len()); - let using_binds = using_start.is_none_or(|start| { + let using_sql = using_start.map(|start| { let end = lower[start..] .find(CHECK_CLAUSE) .map(|index| start + index) .unwrap_or(policy_sql.len()); - policy_binds_tenant_session_equality(&policy_sql[start..end]) - }); - let check_binds = check_start.is_none_or(|start| { - policy_binds_tenant_session_equality(&policy_sql[start..]) + &policy_sql[start..end] }); + let check_sql = check_start.map(|start| &policy_sql[start..]); + 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); - (using_start.is_some() || check_start.is_some()) && using_binds && check_binds + match command { + PolicyCommand::All | PolicyCommand::Update => { + using_binds && (check_sql.is_none() || check_binds) + } + PolicyCommand::Select | PolicyCommand::Delete => using_binds && check_sql.is_none(), + PolicyCommand::Insert => using_sql.is_none() && check_binds, + } } /// Return whether the normalized policy header explicitly declares @@ -250,11 +296,11 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { /// Bind tenant identity and tenant-session evidence to every policy that can /// independently admit rows. PostgreSQL permissive policies are OR-composed. -/// Every explicit `USING` and `WITH CHECK` predicate therefore has to preserve -/// the direct tenant/session equality: `USING` controls row visibility while an -/// explicit `WITH CHECK` independently controls rows admitted by INSERT/UPDATE. -/// Restrictive policies are AND-composed and may add narrower conditions -/// without duplicating the tenant predicate. +/// Command semantics determine which tenant-bound predicates are mandatory: +/// read-capable policies require `USING`, insert requires `WITH CHECK`, and +/// `ALL`/`UPDATE` reuse a valid `USING` for writes only when `WITH CHECK` is +/// omitted. Restrictive policies are AND-composed and may add narrower +/// conditions without duplicating the tenant predicate. fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { const CREATE_POLICY: &str = "create policy"; @@ -344,8 +390,8 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { #[cfg(test)] mod tests { use super::{ - canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - policy_binds_tenant_identifier, policy_binds_tenant_session_equality, + PolicyCommand, canonicalize_concurrent_index_modifier, declares_tenant_session_guc, + policy_binds_tenant_identifier, policy_binds_tenant_session_equality, policy_command, policy_is_restrictive, policy_row_predicates_bind_tenant_session, tenant_policies_bind_session_guc, }; @@ -404,6 +450,67 @@ mod tests { )); } + #[test] + fn policy_command_defaults_to_all_and_rejects_unknown_commands() { + assert_eq!( + policy_command("create policy tenant_policy on tenant_record using (true)"), + Some(PolicyCommand::All) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for all using (true)"), + Some(PolicyCommand::All) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for select using (true)"), + Some(PolicyCommand::Select) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for insert with check (true)"), + Some(PolicyCommand::Insert) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for update using (true)"), + Some(PolicyCommand::Update) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for delete using (true)"), + Some(PolicyCommand::Delete) + ); + assert_eq!( + policy_command("create policy tenant_policy on tenant_record for merge using (true)"), + None + ); + } + + #[test] + 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})" + ))); + } + #[test] fn each_explicit_row_predicate_preserves_tenant_binding() { assert!(policy_row_predicates_bind_tenant_session( From 781d7694a729a39aed89422eb84270cf0a8dabf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:11:20 +0900 Subject: [PATCH 076/309] test(persistence): catch punctuation-adjacent RLS checks --- ...gration_rls_relational_binding_contract.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs index 0480740dc..7e04a34cb 100644 --- a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -119,3 +119,63 @@ fn all_policy_cannot_omit_tenant_bound_using_clause() { 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) + ); +} From 253628d8b5611d92dab87720a3f8ded62af062d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:12:45 +0900 Subject: [PATCH 077/309] fix(persistence): parse RLS clauses at token boundaries --- crates/persistence_postgres/src/migration.rs | 246 +++++++++++++------ 1 file changed, 169 insertions(+), 77 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index fd8ef5475..8f0a326d0 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -85,23 +85,109 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PolicyClauseSpan { + start: usize, + end: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PolicyClause { + Using, + WithCheck, +} + +fn is_sql_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +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 start > 0 && is_sql_identifier_byte(bytes[start - 1]) { + return None; + } + if end < bytes.len() && is_sql_identifier_byte(bytes[end]) { + return None; + } + Some(end) +} + +/// 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; + + 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 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; + } + None +} + /// 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"; - let lower = policy_sql.to_ascii_lowercase(); - let Some(on_start) = lower.find(" on ") else { - return false; - }; - let predicate_search_start = on_start + " on ".len(); - let tail = &lower[predicate_search_start..]; - let using_start = tail.find(" using ").map(|index| predicate_search_start + index); - let check_start = tail - .find(" with check ") - .map(|index| predicate_search_start + index); - let Some(predicate_start) = [using_start, check_start].into_iter().flatten().min() else { + 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..]; @@ -143,7 +229,7 @@ fn direct_tenant_operand(side: &str) -> bool { } fn previous_predicate_boundary(lower_sql: &str, end: usize) -> usize { - const BOUNDARIES: [&str; 4] = [" and ", " or ", " using ", " with check "]; + const BOUNDARIES: [&str; 2] = [" and ", " or "]; BOUNDARIES .iter() .filter_map(|boundary| { @@ -156,7 +242,7 @@ fn previous_predicate_boundary(lower_sql: &str, end: usize) -> usize { } fn next_predicate_boundary(lower_sql: &str, start: usize) -> usize { - const BOUNDARIES: [&str; 3] = [" and ", " or ", " with check "]; + const BOUNDARIES: [&str; 2] = [" and ", " or "]; BOUNDARIES .iter() .filter_map(|boundary| { @@ -213,13 +299,12 @@ enum PolicyCommand { } fn policy_command(policy_sql: &str) -> Option { - const USING_CLAUSE: &str = " using "; - const CHECK_CLAUSE: &str = " with check "; - - let lower = policy_sql.to_ascii_lowercase(); - let header_end = [lower.find(USING_CLAUSE), lower.find(CHECK_CLAUSE)] + 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::>(); @@ -241,24 +326,25 @@ fn policy_command(policy_sql: &str) -> Option { } fn policy_row_predicates_bind_tenant_session(policy_sql: &str) -> bool { - const USING_CLAUSE: &str = " using "; - const CHECK_CLAUSE: &str = " with check "; - let Some(command) = policy_command(policy_sql) else { return false; }; - let lower = policy_sql.to_ascii_lowercase(); - let using_start = lower.find(USING_CLAUSE).map(|index| index + USING_CLAUSE.len()); - let check_start = lower.find(CHECK_CLAUSE).map(|index| index + CHECK_CLAUSE.len()); + 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_start.map(|start| { - let end = lower[start..] - .find(CHECK_CLAUSE) - .map(|index| start + index) + 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[start..end] + &policy_sql[using_span.end..end] }); - let check_sql = check_start.map(|start| &policy_sql[start..]); + 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); @@ -390,8 +476,9 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { #[cfg(test)] mod tests { use super::{ - PolicyCommand, canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - policy_binds_tenant_identifier, policy_binds_tenant_session_equality, policy_command, + PolicyClause, PolicyCommand, canonicalize_concurrent_index_modifier, + declares_tenant_session_guc, policy_binds_tenant_identifier, + policy_binds_tenant_session_equality, policy_clause_span, policy_command, policy_is_restrictive, policy_row_predicates_bind_tenant_session, tenant_policies_bind_session_guc, }; @@ -418,66 +505,78 @@ mod tests { )); } + #[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()); + } + #[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 ))" + "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 ))" + "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)" + "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)" + "create policy document_record_tenant_isolation on document_record using(tenant_record_id_shadow is not null)" )); } #[test] fn tenant_session_witness_must_be_relationally_bound() { assert!(policy_binds_tenant_session_equality( - "create policy document_record_tenant_isolation on document_record using (tenant_record_id::text = nullif ( current_setting ( 'tepp.current_tenant_record_id' , true ) , ))" + "tenant_record_id::text = nullif ( current_setting ( 'tepp.current_tenant_record_id' , true ) , )" )); assert!(policy_binds_tenant_session_equality( - "create policy document_record_tenant_isolation on document_record using (current_setting ( 'tepp.current_tenant_record_id' , true ) = tenant_record_id::text)" + "current_setting ( 'tepp.current_tenant_record_id' , true ) = tenant_record_id::text" )); assert!(!policy_binds_tenant_session_equality( - "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)" + "tenant_record_id is not null and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null" )); assert!(!policy_binds_tenant_session_equality( - "create policy document_record_tenant_isolation on document_record using (tenant_record_id::text = document_record_id::text and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null)" + "tenant_record_id::text = document_record_id::text and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null" )); } #[test] fn policy_command_defaults_to_all_and_rejects_unknown_commands() { assert_eq!( - policy_command("create policy tenant_policy on tenant_record using (true)"), + policy_command("create policy tenant_policy on tenant_record using(true)"), Some(PolicyCommand::All) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for all using (true)"), + policy_command("create policy tenant_policy on tenant_record for all using(true)"), Some(PolicyCommand::All) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for select using (true)"), + policy_command("create policy tenant_policy on tenant_record for select using(true)"), Some(PolicyCommand::Select) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for insert with check (true)"), + policy_command("create policy tenant_policy on tenant_record for insert with check(true)"), Some(PolicyCommand::Insert) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for update using (true)"), + policy_command("create policy tenant_policy on tenant_record for update using(true)"), Some(PolicyCommand::Update) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for delete using (true)"), + policy_command("create policy tenant_policy on tenant_record for delete using(true)"), Some(PolicyCommand::Delete) ); assert_eq!( - policy_command("create policy tenant_policy on tenant_record for merge using (true)"), + policy_command("create policy tenant_policy on tenant_record for merge using(true)"), None ); } @@ -486,72 +585,65 @@ mod tests { 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})" + "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})" + "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})" + "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})" + "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})" + "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})" + "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})" + "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})" + "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 each_explicit_row_predicate_preserves_tenant_binding() { - assert!(policy_row_predicates_bind_tenant_session( - "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::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" - )); - assert!(!policy_row_predicates_bind_tenant_session( - "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!(!policy_row_predicates_bind_tenant_session( - "create policy document_record_tenant_isolation on document_record for all using (tenant_record_id is not null) with check (tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true ))" - )); } #[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 ));" + "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);" + "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;" + "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);" + "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);" + "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);" + "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!(policy_is_restrictive( - "create policy document_record_visibility_guard on document_record AS RESTRICTIVE for select using (true)" + "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)" + "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))" + "create policy document_record_visibility_guard on document_record for select using(exists (select 1 AS restrictive))" )); } From 1541e3889cfb35b850c1644999d1ab1aa1b2f726 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:15:55 +0900 Subject: [PATCH 078/309] test(persistence): reject unbound RLS OR path --- ...gration_rls_relational_binding_contract.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs index 7e04a34cb..e42aa05ce 100644 --- a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -179,3 +179,36 @@ fn update_policy_detects_punctuation_adjacent_explicit_weak_check() { 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) + ); +} From 0f601b43b50d206b0cfcf479289249b1e5bb1f25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:17:07 +0900 Subject: [PATCH 079/309] fix(persistence): require tenant binding on each RLS OR path --- crates/persistence_postgres/src/migration.rs | 121 ++++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 8f0a326d0..aa33cfef7 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -254,15 +254,9 @@ fn next_predicate_boundary(lower_sql: &str, start: usize) -> usize { .unwrap_or(lower_sql.len()) } -/// Require the tenant column and tenant session key to participate in the same -/// equality comparison. Co-presence in unrelated boolean terms is not tenant -/// isolation evidence. This bounded recognizer intentionally accepts only the -/// direct tenant identifier (optionally cast to text) on one side; the other -/// side may wrap the exact `current_setting(...)` call, as the shipped migration -/// does with `nullif`. -fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { - let lower = policy_sql.to_ascii_lowercase(); - let bytes = policy_sql.as_bytes(); +fn predicate_contains_tenant_session_equality(predicate_sql: &str) -> bool { + let lower = predicate_sql.to_ascii_lowercase(); + let bytes = predicate_sql.as_bytes(); for equality in 0..bytes.len() { if bytes[equality] != b'=' { @@ -278,8 +272,8 @@ fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { let left_start = previous_predicate_boundary(&lower, equality); let right_end = next_predicate_boundary(&lower, equality + 1); - let left = &policy_sql[left_start..equality]; - let right = &policy_sql[equality + 1..right_end]; + let left = &predicate_sql[left_start..equality]; + let right = &predicate_sql[equality + 1..right_end]; if (direct_tenant_operand(left) && declares_tenant_session_guc(right)) || (declares_tenant_session_guc(left) && direct_tenant_operand(right)) { @@ -289,6 +283,87 @@ fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { false } +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 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]; + } +} + +fn all_top_level_or_paths_bind_tenant_session(predicate_sql: &str) -> bool { + let predicate_sql = strip_enclosing_predicate_parentheses(predicate_sql); + let bytes = predicate_sql.as_bytes(); + let mut depth = 0usize; + let mut branch_start = 0usize; + let mut index = 0usize; + + 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 depth == 0 { + if let Some(or_end) = bounded_ascii_keyword(bytes, index, b"or") { + if !predicate_contains_tenant_session_equality( + &predicate_sql[branch_start..index], + ) { + return false; + } + branch_start = or_end; + index = or_end; + continue; + } + } + index += 1; + } + + predicate_contains_tenant_session_equality(&predicate_sql[branch_start..]) +} + +/// Require the tenant column and tenant session key to participate in the same +/// equality comparison on every top-level row-admitting OR 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` +/// fails closed because one disjunct can admit rows without tenant equality. +fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { + all_top_level_or_paths_bind_tenant_session(policy_sql) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PolicyCommand { All, @@ -476,11 +551,11 @@ fn canonicalize_concurrent_index_modifier(sql: &str) -> String { #[cfg(test)] mod tests { use super::{ - PolicyClause, PolicyCommand, canonicalize_concurrent_index_modifier, - declares_tenant_session_guc, policy_binds_tenant_identifier, - policy_binds_tenant_session_equality, policy_clause_span, policy_command, - policy_is_restrictive, policy_row_predicates_bind_tenant_session, - tenant_policies_bind_session_guc, + PolicyClause, PolicyCommand, all_top_level_or_paths_bind_tenant_session, + canonicalize_concurrent_index_modifier, declares_tenant_session_guc, + policy_binds_tenant_identifier, policy_binds_tenant_session_equality, + policy_clause_span, policy_command, policy_is_restrictive, + policy_row_predicates_bind_tenant_session, tenant_policies_bind_session_guc, }; #[test] @@ -549,6 +624,20 @@ mod tests { )); } + #[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))" + ))); + } + #[test] fn policy_command_defaults_to_all_and_rejects_unknown_commands() { assert_eq!( From 24de8e7d0221844d8750c103c9886273febd0771 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:21:10 +0900 Subject: [PATCH 080/309] test(persistence): reject nested unbound RLS OR path --- ...gration_rls_relational_binding_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs index e42aa05ce..3fa288c26 100644 --- a/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_relational_binding_contract.rs @@ -212,3 +212,42 @@ fn permissive_policy_rejects_unbound_top_level_or_path() { 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) + ); +} From 222f45b06a32510e16b627c1fc2d30ca79e7fc0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:27:26 +0900 Subject: [PATCH 081/309] fix(persistence): parse RLS boolean guards structurally --- crates/persistence_postgres/src/migration.rs | 106 ++++++++++--------- 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index aa33cfef7..893771a05 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -228,40 +228,24 @@ fn direct_tenant_operand(side: &str) -> bool { matches!(trimmed, "tenant_record_id" | "tenant_record_id::text") } -fn previous_predicate_boundary(lower_sql: &str, end: usize) -> usize { - const BOUNDARIES: [&str; 2] = [" and ", " or "]; - BOUNDARIES - .iter() - .filter_map(|boundary| { - lower_sql[..end] - .rfind(boundary) - .map(|index| index + boundary.len()) - }) - .max() - .unwrap_or(0) -} - -fn next_predicate_boundary(lower_sql: &str, start: usize) -> usize { - const BOUNDARIES: [&str; 2] = [" and ", " or "]; - BOUNDARIES - .iter() - .filter_map(|boundary| { - lower_sql[start..] - .find(boundary) - .map(|index| start + index) - }) - .min() - .unwrap_or(lower_sql.len()) -} - -fn predicate_contains_tenant_session_equality(predicate_sql: &str) -> bool { - let lower = predicate_sql.to_ascii_lowercase(); +fn predicate_contains_top_level_tenant_session_equality(predicate_sql: &str) -> bool { let bytes = predicate_sql.as_bytes(); + let mut depth = 0usize; for equality in 0..bytes.len() { - if bytes[equality] != b'=' { - continue; + match bytes[equality] { + b'(' => { + depth = depth.saturating_add(1); + continue; + } + b')' => { + depth = depth.saturating_sub(1); + continue; + } + b'=' if depth == 0 => {} + _ => continue, } + 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'=')) @@ -270,10 +254,8 @@ fn predicate_contains_tenant_session_equality(predicate_sql: &str) -> bool { continue; } - let left_start = previous_predicate_boundary(&lower, equality); - let right_end = next_predicate_boundary(&lower, equality + 1); - let left = &predicate_sql[left_start..equality]; - let right = &predicate_sql[equality + 1..right_end]; + let left = &predicate_sql[..equality]; + let right = &predicate_sql[equality + 1..]; if (direct_tenant_operand(left) && declares_tenant_session_guc(right)) || (declares_tenant_session_guc(left) && direct_tenant_operand(right)) { @@ -316,12 +298,12 @@ fn strip_enclosing_predicate_parentheses(mut predicate_sql: &str) -> &str { } } -fn all_top_level_or_paths_bind_tenant_session(predicate_sql: &str) -> bool { - let predicate_sql = strip_enclosing_predicate_parentheses(predicate_sql); +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 branch_start = 0usize; + let mut segment_start = 0usize; let mut index = 0usize; + let mut segments = Vec::new(); while index < bytes.len() { match bytes[index] { @@ -338,28 +320,51 @@ fn all_top_level_or_paths_bind_tenant_session(predicate_sql: &str) -> bool { _ => {} } if depth == 0 { - if let Some(or_end) = bounded_ascii_keyword(bytes, index, b"or") { - if !predicate_contains_tenant_session_equality( - &predicate_sql[branch_start..index], - ) { - return false; - } - branch_start = or_end; - index = or_end; + 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; } - predicate_contains_tenant_session_equality(&predicate_sql[branch_start..]) + if segments.is_empty() { + None + } else { + segments.push(&predicate_sql[segment_start..]); + Some(segments) + } +} + +/// 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); + + 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); + } + 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) } /// Require the tenant column and tenant session key to participate in the same -/// equality comparison on every top-level row-admitting OR path. A tenant-bound +/// 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` -/// fails closed because one disjunct can admit rows without tenant equality. +/// `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) } @@ -636,6 +641,9 @@ mod tests { 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] From 1d33374822a345336e3a0fa4edbcb38102283392 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:29:19 +0900 Subject: [PATCH 082/309] test(persistence): reject tenant GUC fallback RLS bypass --- ...gration_rls_session_expression_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_session_expression_contract.rs 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) + ); +} From e6c359c6fce614e011f85cc265b7d7ba08c09da7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 09:32:09 +0900 Subject: [PATCH 083/309] fix(persistence): bind RLS tenant to direct session value --- crates/persistence_postgres/src/migration.rs | 57 +++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 893771a05..adc7dca1a 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -219,13 +219,34 @@ fn policy_binds_tenant_identifier(policy_sql: &str) -> bool { } fn direct_tenant_operand(side: &str) -> bool { - let compact = side + let compact = strip_enclosing_predicate_parentheses(side) .chars() .filter(|ch| !ch.is_whitespace()) .collect::() .to_ascii_lowercase(); - let trimmed = compact.trim_matches(|ch: char| matches!(ch, '(' | ')')); - matches!(trimmed, "tenant_record_id" | "tenant_record_id::text") + matches!(compact.as_str(), "tenant_record_id" | "tenant_record_id::text") +} + +/// 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 predicate_contains_top_level_tenant_session_equality(predicate_sql: &str) -> bool { @@ -256,8 +277,8 @@ fn predicate_contains_top_level_tenant_session_equality(predicate_sql: &str) -> let left = &predicate_sql[..equality]; let right = &predicate_sql[equality + 1..]; - if (direct_tenant_operand(left) && declares_tenant_session_guc(right)) - || (declares_tenant_session_guc(left) && direct_tenant_operand(right)) + if (direct_tenant_operand(left) && direct_tenant_session_operand(right)) + || (direct_tenant_session_operand(left) && direct_tenant_operand(right)) { return true; } @@ -558,9 +579,10 @@ mod tests { use super::{ PolicyClause, PolicyCommand, all_top_level_or_paths_bind_tenant_session, canonicalize_concurrent_index_modifier, declares_tenant_session_guc, - policy_binds_tenant_identifier, policy_binds_tenant_session_equality, - policy_clause_span, policy_command, policy_is_restrictive, - policy_row_predicates_bind_tenant_session, tenant_policies_bind_session_guc, + direct_tenant_session_operand, policy_binds_tenant_identifier, + policy_binds_tenant_session_equality, policy_clause_span, policy_command, + policy_is_restrictive, policy_row_predicates_bind_tenant_session, + tenant_policies_bind_session_guc, }; #[test] @@ -613,6 +635,22 @@ mod tests { )); } + #[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 )" + )); + } + #[test] fn tenant_session_witness_must_be_relationally_bound() { assert!(policy_binds_tenant_session_equality( @@ -621,6 +659,9 @@ mod tests { 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!(!policy_binds_tenant_session_equality( "tenant_record_id is not null and current_setting ( 'tepp.current_tenant_record_id' , true ) is not null" )); From c8f25657671f519fb95327494d050e78e298f0f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:02:15 +0900 Subject: [PATCH 084/309] test(persistence): require permissive RLS coverage for restrictive policies --- ...ation_rls_restrictive_liveness_contract.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs 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..41985f4f1 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs @@ -0,0 +1,80 @@ +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; +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_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(())); +} From 46af4185b1c6eda5b7c75f51586a95fc662117bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:05:13 +0900 Subject: [PATCH 085/309] fix(persistence): require permissive RLS coverage before restrictive guards --- crates/persistence_postgres/src/migration.rs | 91 +++++++++++++++++--- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index adc7dca1a..a4a558102 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -426,6 +426,28 @@ fn policy_command(policy_sql: &str) -> Option { } } +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 policy_command_applies(policy_command: PolicyCommand, requested_command: PolicyCommand) -> bool { + policy_command == PolicyCommand::All || policy_command == requested_command +} + fn policy_row_predicates_bind_tenant_session(policy_sql: &str) -> bool { let Some(command) = policy_command(policy_sql) else { return false; @@ -482,34 +504,75 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { } /// Bind tenant identity and tenant-session evidence to every policy that can -/// independently admit rows. PostgreSQL permissive policies are OR-composed. +/// 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 it can constrain, otherwise PostgreSQL's default-deny composition +/// makes that command path operationally inaccessible. +/// /// Command semantics determine which tenant-bound predicates are mandatory: -/// read-capable policies require `USING`, insert requires `WITH CHECK`, and -/// `ALL`/`UPDATE` reuse a valid `USING` for writes only when `WITH CHECK` is -/// omitted. Restrictive policies are AND-composed and may add narrower -/// conditions without duplicating the tenant predicate. +/// 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, + ]; 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(); + 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]; - if !policy_is_restrictive(statement) - && (!declares_tenant_session_guc(statement) - || !policy_binds_tenant_identifier(statement) - || !policy_row_predicates_bind_tenant_session(statement)) - { + let Some(table) = policy_target_table(statement) else { return false; + }; + let Some(command) = policy_command(statement) else { + return false; + }; + + if policy_is_restrictive(statement) { + restrictive_requirements.push((table.to_ascii_lowercase(), command)); + } 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)); } search_from = start + CREATE_POLICY.len(); } - saw_policy + + if !saw_policy { + return false; + } + + restrictive_requirements.into_iter().all(|(table, restrictive_command)| { + CONCRETE_COMMANDS + .into_iter() + .filter(|command| policy_command_applies(restrictive_command, *command)) + .all(|command| { + permissive_coverage.iter().any(|(permissive_table, permissive_command)| { + permissive_table == &table + && policy_command_applies(*permissive_command, command) + }) + }) + }) } /// Normalize SQL and then remove PostgreSQL's `CONCURRENTLY` index modifier @@ -774,6 +837,12 @@ mod tests { 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!(policy_is_restrictive( "create policy document_record_visibility_guard on document_record AS RESTRICTIVE for select using(true)" )); From 47354ec28761341302b547194dc073f5587fcfd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:08:18 +0900 Subject: [PATCH 086/309] test(persistence): bind restrictive RLS coverage to policy roles --- ...ation_rls_restrictive_liveness_contract.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs index 41985f4f1..d74e5ec70 100644 --- a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs @@ -61,6 +61,29 @@ CREATE POLICY document_record_read_guard ON document_record ); } +#[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 restrictive_policy_may_narrow_matching_permissive_access() { let catalog = catalog_with_policies(&format!( @@ -78,3 +101,23 @@ CREATE POLICY document_record_read_guard ON document_record 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(())); +} From 21a018874ed63e124dd8be2d10340a29b83bc2f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:08:52 +0900 Subject: [PATCH 087/309] test(persistence): make RLS role-liveness fixture executable --- .../tests/migration_rls_restrictive_liveness_contract.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs index d74e5ec70..44727ed99 100644 --- a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs @@ -8,6 +8,8 @@ CREATE TABLE document_record ( 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; "; From f08d75c243aa425983ae5074aaf41a6ac421e310 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:11:04 +0900 Subject: [PATCH 088/309] fix(persistence): match restrictive RLS liveness by policy role --- crates/persistence_postgres/src/migration.rs | 124 ++++++++++++++++--- 1 file changed, 109 insertions(+), 15 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index a4a558102..3677f5d9c 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -444,6 +444,38 @@ fn policy_target_table(policy_sql: &str) -> Option<&str> { tokens.get(on_index + 1).copied() } +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(); + for token in tokens.iter().skip(to_index + 1) { + let role = *token; + if role == "," { + continue; + } + if role.is_empty() || !role.bytes().all(is_sql_identifier_byte) { + return None; + } + roles.push(role.to_ascii_lowercase()); + } + (!roles.is_empty()).then_some(roles) +} + fn policy_command_applies(policy_command: PolicyCommand, requested_command: PolicyCommand) -> bool { policy_command == PolicyCommand::All || policy_command == requested_command } @@ -507,8 +539,11 @@ fn policy_is_restrictive(policy_sql: &str) -> bool { /// 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 it can constrain, otherwise PostgreSQL's default-deny composition -/// makes that command path operationally inaccessible. +/// 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 @@ -543,9 +578,12 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { 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)); + restrictive_requirements.push((table.to_ascii_lowercase(), command, roles)); } else { if !declares_tenant_session_guc(statement) || !policy_binds_tenant_identifier(statement) @@ -553,7 +591,7 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { { return false; } - permissive_coverage.push((table.to_ascii_lowercase(), command)); + permissive_coverage.push((table.to_ascii_lowercase(), command, roles)); } search_from = start + CREATE_POLICY.len(); } @@ -562,17 +600,37 @@ fn tenant_policies_bind_session_guc(normalized_sql: &str) -> bool { return false; } - restrictive_requirements.into_iter().all(|(table, restrictive_command)| { - CONCRETE_COMMANDS - .into_iter() - .filter(|command| policy_command_applies(restrictive_command, *command)) - .all(|command| { - permissive_coverage.iter().any(|(permissive_table, permissive_command)| { - permissive_table == &table - && policy_command_applies(*permissive_command, command) + 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") + }, + ); + } + + 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 + }) + }, + ) + }) }) - }) - }) + }, + ) } /// Normalize SQL and then remove PostgreSQL's `CONCURRENTLY` index modifier @@ -644,7 +702,7 @@ mod tests { 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_row_predicates_bind_tenant_session, + policy_is_restrictive, policy_roles, policy_row_predicates_bind_tenant_session, tenant_policies_bind_session_guc, }; @@ -782,6 +840,30 @@ mod tests { ); } + #[test] + fn policy_roles_default_to_public_and_preserve_explicit_targets() { + assert_eq!( + policy_roles("create policy tenant_policy on tenant_record using(true)"), + Some(vec!["public".to_owned()]) + ); + assert_eq!( + 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()]) + ); + assert_eq!( + policy_roles("create policy tenant_policy on tenant_record for select to using(true)"), + None + ); + assert_eq!( + policy_roles( + "create policy tenant_policy on tenant_record for select to reader-role using(true)" + ), + None + ); + } + #[test] fn explicit_policy_command_requires_the_correct_tenant_predicate() { let binding = "tenant_record_id::text = current_setting ( 'tepp.current_tenant_record_id' , true )"; @@ -843,6 +925,18 @@ mod tests { 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)" )); From 81a4b97c1ff3dbd83adc202af1f8c4231d75f375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:15:11 +0900 Subject: [PATCH 089/309] test(persistence): reject malformed RLS policy role lists --- ...ation_rls_restrictive_liveness_contract.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs index 44727ed99..8ad7de5b6 100644 --- a/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_restrictive_liveness_contract.rs @@ -86,6 +86,32 @@ CREATE POLICY document_record_reader_guard ON document_record ); } +#[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!( From cbb11ee05197fb20fcec63924be58e66c1801610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:17:20 +0900 Subject: [PATCH 090/309] fix(persistence): validate RLS policy role-list grammar --- crates/persistence_postgres/src/migration.rs | 41 ++++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 3677f5d9c..48f5f7fda 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -463,17 +463,22 @@ fn policy_roles(policy_sql: &str) -> Option> { }; let mut roles = Vec::new(); + let mut expect_role = true; for token in tokens.iter().skip(to_index + 1) { - let role = *token; - if role == "," { - continue; - } - if role.is_empty() || !role.bytes().all(is_sql_identifier_byte) { - return None; + 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; } - roles.push(role.to_ascii_lowercase()); } - (!roles.is_empty()).then_some(roles) + (!roles.is_empty() && !expect_role).then_some(roles) } fn policy_command_applies(policy_command: PolicyCommand, requested_command: PolicyCommand) -> bool { @@ -862,6 +867,26 @@ mod tests { ), None ); + assert_eq!( + policy_roles("create policy tenant_policy on tenant_record for select to ,reader_role using(true)"), + None + ); + assert_eq!( + policy_roles("create policy tenant_policy on tenant_record for select to reader_role, using(true)"), + None + ); + assert_eq!( + policy_roles( + "create policy tenant_policy on tenant_record for select to reader_role,,writer_role using(true)" + ), + None + ); + assert_eq!( + policy_roles( + "create policy tenant_policy on tenant_record for select to reader_role writer_role using(true)" + ), + None + ); } #[test] From ffc147a1c7e6fd0383ae2b06965193cb2a3bc0bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 10:59:00 +0900 Subject: [PATCH 091/309] test(persistence): reject RLS-bypassing runtime roles --- ...ration_runtime_role_rls_safety_contract.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs 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..9f340e18c --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_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 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 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 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(())); +} From e561d27a5387475f68c82c4964ee0aa3126cb159 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:01:52 +0900 Subject: [PATCH 092/309] fix(persistence): preserve runtime role RLS safety --- .../src/migration_validation.rs | 84 +++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index c697976ea..de899435d 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,15 +1,25 @@ //! PostgreSQL lexical normalization for migration contract parsing. -use std::collections::BTreeSet; +use std::collections::BTreeMap; const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct RoleSecurityState { + is_superuser: bool, + bypasses_rls: bool, +} + pub(super) fn normalize_migration_sql(sql: &str) -> Option { let normalized = lexically_normalize_migration_sql(sql)?; 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 @@ -18,13 +28,18 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option>(); - let mut declared_roles = BTreeSet::new(); + 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() { - declared_roles.insert(name); + 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) { for name in drop_statement_role_names(&tokens, index) { @@ -33,13 +48,27 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option bool { @@ -198,6 +227,49 @@ fn is_role_rename_alias(tokens: &[&str], alter_index: usize) -> bool { && tokens.get(alter_index + 5).is_some() } +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") +} + +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +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; + } + } +} + fn role_identifier(fragment: &str) -> String { fragment .trim_start_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') From c6383c1c270ec5b1d5ebacf2ac1523b154afa62d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:03:57 +0900 Subject: [PATCH 093/309] test(persistence): fail closed on malformed role lifecycle --- ...migration_runtime_role_rls_safety_contract.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 9f340e18c..08638a1c1 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -30,6 +30,7 @@ fn runtime_role_cannot_bypass_rls_or_be_superuser() { "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); @@ -41,6 +42,21 @@ fn runtime_role_cannot_bypass_rls_or_be_superuser() { } } +#[test] +fn malformed_role_lifecycle_statements_fail_closed_without_panicking() { + for role_sql in [ + "CREATE ROLE;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nALTER ROLE;", + ] { + 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( From 5de2342ceab5a89d633b3d87743a764a92ad72a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:11:23 +0900 Subject: [PATCH 094/309] fix(persistence): fail closed malformed role lifecycle --- .../src/migration_validation.rs | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index de899435d..e08391408 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -33,33 +33,48 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option Date: Thu, 17 Sep 2026 11:12:50 +0900 Subject: [PATCH 095/309] fix(persistence): restore user mapping regression assertion --- crates/persistence_postgres/src/migration_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index e08391408..d2e80858f 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -749,7 +749,7 @@ mod tests { "ALTER USER MAPPING FOR CURRENT_USER SERVER foreign_server OPTIONS (SET user 'x');", ) .expect("well-formed user mapping alteration"); - assert!(normalized.starts_with("ALTER USER MAPPING ")); + assert!(user_mapping.starts_with("ALTER USER MAPPING ")); } #[test] From 5474d33b179825950f0380ece84a180018f10f0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:17:03 +0900 Subject: [PATCH 096/309] test(persistence): cover malformed role lifecycle variants --- .../tests/migration_runtime_role_rls_safety_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) 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 index 08638a1c1..8fd678996 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -46,7 +46,11 @@ fn runtime_role_cannot_bypass_rls_or_be_superuser() { 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;\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!( From 892f78b540cf1c7590ca0f888df993a42de5d796 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:57:31 +0900 Subject: [PATCH 097/309] test(persistence): reject dollar-like identifier bypass --- .../migration_identifier_lexing_contract.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index 0bafd7f7e..bd144fe2d 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -79,6 +79,22 @@ fn quoted_column_named_like_a_table_constraint_keyword_is_still_an_identifier() } } +#[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);", + ] { + 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( From 6e9d57b9056dd860b3ccd20878b0972a3c42ec5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:59:10 +0900 Subject: [PATCH 098/309] fix(persistence): preserve dollar signs inside identifiers --- crates/persistence_postgres/src/migration_validation.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d2e80858f..7b89417c3 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -510,6 +510,13 @@ 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]); From 223bfbd66527e8335d994d55867f20861ba50c4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 11:59:46 +0900 Subject: [PATCH 099/309] test(persistence): cover non-ASCII identifier dollar boundary --- .../tests/migration_identifier_lexing_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs index bd144fe2d..4829ff745 100644 --- a/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_lexing_contract.rs @@ -85,6 +85,7 @@ fn dollar_quote_like_bytes_inside_identifiers_do_not_bypass_the_naming_contract( "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!( From 5f3e555b67c1183f3fef0d5e78f8ac1a9570d3eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:02:35 +0900 Subject: [PATCH 100/309] test(persistence): preserve non-ascii dollar quote bodies --- .../migration_non_ascii_dollar_quote_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_non_ascii_dollar_quote_contract.rs 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(())); +} From 866e9e349d2da458f45c918abef39fdf9106e29e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:03:46 +0900 Subject: [PATCH 101/309] fix(persistence): recognize non-ascii dollar quote tags --- crates/persistence_postgres/src/migration_validation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 7b89417c3..5fc2bc231 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -522,7 +522,7 @@ fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { return Some(&bytes[start..=index]); } let first = *bytes.get(index)?; - if first != b'_' && !first.is_ascii_alphabetic() { + if first != b'_' && !first.is_ascii_alphabetic() && first < 0x80 { return None; } index += 1; @@ -530,7 +530,7 @@ fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { if *byte == b'$' { return Some(&bytes[start..=index]); } - if *byte != b'_' && !byte.is_ascii_alphanumeric() { + if *byte != b'_' && !byte.is_ascii_alphanumeric() && *byte < 0x80 { return None; } index += 1; From d2ec8cc915bcfc072593d6777017be75738fa7e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:06:26 +0900 Subject: [PATCH 102/309] test(persistence): reject non-identifier dollar quote tags --- ...ion_invalid_unicode_dollar_tag_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs diff --git a/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs b/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs new file mode 100644 index 000000000..fa19aecd2 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs @@ -0,0 +1,21 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn non_identifier_unicode_cannot_mask_declarations_as_dollar_quote_tags() { + 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), + Err(MigrationContractError::SingleWordObjectName), + "non-identifier tag {tag} masked declaration-shaped SQL" + ); + } +} From a6e3f4f90a91cd792c4957b23d7b4289c95e911b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:07:31 +0900 Subject: [PATCH 103/309] fix(persistence): validate unicode dollar quote tags --- .../src/migration_validation.rs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5fc2bc231..caf8251f5 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -506,6 +506,9 @@ fn scan_block_comment(bytes: &[u8], start: usize) -> Option { None } +/// Return the exact PostgreSQL dollar-quote delimiter starting at `start`. +/// Tags follow unquoted-identifier rules, while an opening `$` attached to a +/// preceding identifier remains part of that identifier rather than a quote. fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { if bytes.get(start) != Some(&b'$') { return None; @@ -517,23 +520,26 @@ fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { { return None; } - let mut index = start + 1; - if bytes.get(index) == Some(&b'$') { - return Some(&bytes[start..=index]); + let tag_start = start + 1; + if bytes.get(tag_start) == Some(&b'$') { + return Some(&bytes[start..=tag_start]); } - let first = *bytes.get(index)?; - if first != b'_' && !first.is_ascii_alphabetic() && first < 0x80 { + + let tag_suffix = std::str::from_utf8(bytes.get(tag_start..)?).ok()?; + let mut characters = tag_suffix.char_indices(); + let (_, first) = characters.next()?; + if first != '_' && !first.is_alphabetic() { return None; } - index += 1; - while let Some(byte) = bytes.get(index) { - if *byte == b'$' { - return Some(&bytes[start..=index]); + + for (offset, character) in characters { + if character == '$' { + let delimiter_end = tag_start + offset; + return Some(&bytes[start..=delimiter_end]); } - if *byte != b'_' && !byte.is_ascii_alphanumeric() && *byte < 0x80 { + if character != '_' && !character.is_alphabetic() && !character.is_ascii_digit() { return None; } - index += 1; } None } From 600c514ff9c4d81b5c0152ed3c98c8acf1074e69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:10:55 +0900 Subject: [PATCH 104/309] test(persistence): remove invalid unicode dollar tag assumption --- ...ion_invalid_unicode_dollar_tag_contract.rs | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs diff --git a/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs b/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs deleted file mode 100644 index fa19aecd2..000000000 --- a/crates/persistence_postgres/tests/migration_invalid_unicode_dollar_tag_contract.rs +++ /dev/null @@ -1,21 +0,0 @@ -use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; - -#[test] -fn non_identifier_unicode_cannot_mask_declarations_as_dollar_quote_tags() { - 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), - Err(MigrationContractError::SingleWordObjectName), - "non-identifier tag {tag} masked declaration-shaped SQL" - ); - } -} From b7a891bf75fa7b98e8013487fd96287b45bade7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:11:02 +0900 Subject: [PATCH 105/309] test(persistence): accept postgres high-bit dollar quote tags --- ...igration_high_bit_dollar_quote_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_high_bit_dollar_quote_contract.rs 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" + ); + } +} From a16b91941d19a772a4df3cbbb79382009a4a7319 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 12:11:52 +0900 Subject: [PATCH 106/309] fix(persistence): match postgres high-bit dollar tag grammar --- .../src/migration_validation.rs | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index caf8251f5..5fc2bc231 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -506,9 +506,6 @@ fn scan_block_comment(bytes: &[u8], start: usize) -> Option { None } -/// Return the exact PostgreSQL dollar-quote delimiter starting at `start`. -/// Tags follow unquoted-identifier rules, while an opening `$` attached to a -/// preceding identifier remains part of that identifier rather than a quote. fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { if bytes.get(start) != Some(&b'$') { return None; @@ -520,26 +517,23 @@ fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { { return None; } - let tag_start = start + 1; - if bytes.get(tag_start) == Some(&b'$') { - return Some(&bytes[start..=tag_start]); + let mut index = start + 1; + if bytes.get(index) == Some(&b'$') { + return Some(&bytes[start..=index]); } - - let tag_suffix = std::str::from_utf8(bytes.get(tag_start..)?).ok()?; - let mut characters = tag_suffix.char_indices(); - let (_, first) = characters.next()?; - if first != '_' && !first.is_alphabetic() { + let first = *bytes.get(index)?; + if first != b'_' && !first.is_ascii_alphabetic() && first < 0x80 { return None; } - - for (offset, character) in characters { - if character == '$' { - let delimiter_end = tag_start + offset; - return Some(&bytes[start..=delimiter_end]); + index += 1; + while let Some(byte) = bytes.get(index) { + if *byte == b'$' { + return Some(&bytes[start..=index]); } - if character != '_' && !character.is_alphabetic() && !character.is_ascii_digit() { + if *byte != b'_' && !byte.is_ascii_alphanumeric() && *byte < 0x80 { return None; } + index += 1; } None } From 97b0bf5fe8664d83db621889d19e9e005c35bbf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 13:03:22 +0900 Subject: [PATCH 107/309] test(persistence): reject malformed DROP ROLE lists --- .../tests/migration_runtime_role_rls_safety_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) 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 index 8fd678996..d96937021 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -47,6 +47,10 @@ 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;", From 391510c57a930987a42ec2685100ddcb1e735a01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 13:05:23 +0900 Subject: [PATCH 108/309] fix(persistence): validate DROP ROLE separators --- .../src/migration_validation.rs | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5fc2bc231..64550c8b6 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -43,10 +43,9 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option String { role_identifier(fragment).to_ascii_lowercase() } -fn drop_statement_role_names(tokens: &[&str], drop_index: usize) -> Vec { +/// 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) @@ -310,19 +315,34 @@ fn drop_statement_role_names(tokens: &[&str], drop_index: usize) -> Vec } let mut names = Vec::new(); + let mut expects_name = true; while let Some(token) = tokens.get(name_index) { if *token == ";" { break; } - if *token != "," { - let name = normalized_role_identifier(token); - if !name.is_empty() { - names.push(name); + 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; } - names + if names.is_empty() || expects_name { + None + } else { + Some(names) + } } fn canonicalize_structural_keywords(sql: &str) -> String { From 8f0238d18dc7d8b68592642f39b888da7abd33f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:03:19 +0900 Subject: [PATCH 109/309] test(persistence): reject empty CREATE TABLE elements --- ...migration_table_element_syntax_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_table_element_syntax_contract.rs 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); + } +} From f2e35499af8829bc99a99ff8ad723618fa2ef047 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:05:25 +0900 Subject: [PATCH 110/309] fix(persistence): fail closed on empty table elements --- .../src/migration_core.rs | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 90191965a..3d2256aba 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -248,6 +248,9 @@ fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractEr } fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { + if 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) { @@ -491,11 +494,11 @@ fn parse_constraint_names(sql: &str) -> BTreeSet { parse_names_after(sql, "CONSTRAINT") } -/// Return the column names declared directly in a `CREATE TABLE` body. +/// Split one explicit `CREATE TABLE (...)` body at depth-one commas. /// -/// Table-level constraint clauses name no column and are skipped. -fn parse_column_names(body: &str) -> BTreeSet { - let mut names = BTreeSet::new(); +/// Parenthesized type arguments and table-constraint column lists remain within +/// their owning element because their commas occur at depth two or deeper. +fn split_table_elements(body: &str) -> Vec<&str> { let mut depth = 0i32; let mut start = 0usize; let mut segments = Vec::new(); @@ -511,7 +514,41 @@ fn parse_column_names(body: &str) -> BTreeSet { } } segments.push(&body[start..]); - for segment in segments { + segments +} + +/// 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(); @@ -725,7 +762,6 @@ mod tests { 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;", From e380cae2ea1376dd47051f66fddd68e28a15aee6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:08:45 +0900 Subject: [PATCH 111/309] test(persistence): preserve array constructor commas --- .../migration_array_constructor_contract.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_array_constructor_contract.rs 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..4f48efada --- /dev/null +++ b/crates/persistence_postgres/tests/migration_array_constructor_contract.rs @@ -0,0 +1,17 @@ +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +#[test] +fn array_constructor_commas_are_not_table_element_separators() { + let catalog = MigrationCatalog::from_sql( + r" + CREATE TABLE tenant_record ( + tenant_record_id uuid PRIMARY KEY, + retry_schedule integer[] DEFAULT ARRAY[1, 2], + system_time timestamptz NOT NULL + ); + ", + "DROP TABLE tenant_record;", + ); + + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} From 54a60d220f5a925f70d0b41894690e27ccefbeb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:09:30 +0900 Subject: [PATCH 112/309] chore(persistence): park array-constructor RED for owner repair --- .../migration_array_constructor_contract.rs | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_array_constructor_contract.rs diff --git a/crates/persistence_postgres/tests/migration_array_constructor_contract.rs b/crates/persistence_postgres/tests/migration_array_constructor_contract.rs deleted file mode 100644 index 4f48efada..000000000 --- a/crates/persistence_postgres/tests/migration_array_constructor_contract.rs +++ /dev/null @@ -1,17 +0,0 @@ -use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; - -#[test] -fn array_constructor_commas_are_not_table_element_separators() { - let catalog = MigrationCatalog::from_sql( - r" - CREATE TABLE tenant_record ( - tenant_record_id uuid PRIMARY KEY, - retry_schedule integer[] DEFAULT ARRAY[1, 2], - system_time timestamptz NOT NULL - ); - ", - "DROP TABLE tenant_record;", - ); - - assert_eq!(validate_migration_catalog(&catalog), Ok(())); -} From fcc509a81427c49e5edc1fc9b9e1bbd180e95959 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:13:04 +0900 Subject: [PATCH 113/309] fix(persistence): preserve bracketed expression commas --- .../src/migration_core.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 3d2256aba..4f6643cde 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -248,7 +248,7 @@ fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractEr } fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractError> { - if has_empty_table_element(body) { + if has_unbalanced_square_brackets(body) || has_empty_table_element(body) { return Err(MigrationContractError::EmptyMigrationSql); } let columns = parse_column_names(body); @@ -494,19 +494,24 @@ fn parse_constraint_names(sql: &str) -> BTreeSet { parse_names_after(sql, "CONSTRAINT") } -/// Split one explicit `CREATE TABLE (...)` body at depth-one commas. +/// 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 at depth two or deeper. +/// 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, - ',' if depth == 1 => { + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + ',' if depth == 1 && bracket_depth == 0 => { segments.push(&body[start..index]); start = index + 1; } @@ -517,6 +522,25 @@ fn split_table_elements(body: &str) -> Vec<&str> { 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. /// From 0228c0dc94b1a931d5a261e3124305a59cfbf50b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 15:13:14 +0900 Subject: [PATCH 114/309] test(persistence): cover bracketed table expressions --- .../migration_array_constructor_contract.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_array_constructor_contract.rs 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}" + ); + } +} From 1d815629724d74ff6a008d379f43b5c006a6801a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 16:10:26 +0900 Subject: [PATCH 115/309] test(persistence): reject truncated PostgreSQL identifier continuations --- ...ration_identifier_continuation_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs 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..b007da341 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs @@ -0,0 +1,21 @@ +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}" + ); + } +} From c990f1ea9d1ea9387c1563ebb609384e1e64212b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 17:02:08 +0900 Subject: [PATCH 116/309] fix(persistence): preserve PostgreSQL identifier continuations --- .../src/migration_core.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 4f6643cde..5fd196db4 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -418,15 +418,31 @@ const TABLE_CONSTRAINT_KEYWORDS: [&str; 7] = [ "like", ]; -/// Return whether `index` starts a keyword rather than continuing a word. +/// 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| !ch.is_ascii_alphanumeric() && ch != '_') + .is_none_or(|ch| !is_postgresql_identifier_continuation(ch)) } -/// Return the identifier at the start of `rest`, skipping an existence clause. +/// 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(); @@ -436,7 +452,7 @@ fn leading_identifier(rest: &str) -> String { .map_or(rest, |stripped| &rest[rest.len() - stripped.len()..]) .trim_start(); rest.chars() - .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .take_while(|ch| is_postgresql_identifier_continuation(*ch)) .collect() } @@ -583,11 +599,12 @@ fn parse_column_names(body: &str) -> BTreeSet { 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(|ch| ch.is_ascii_alphanumeric() || ch == '_') + .is_some_and(is_postgresql_identifier_continuation) } fn find_table_declaration_end(lower_sql: &str, needle: &str) -> Option { @@ -603,13 +620,14 @@ fn find_table_declaration_end(lower_sql: &str, needle: &str) -> Option { 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| !ch.is_ascii_alphanumeric() && ch != '_') + .is_none_or(|ch| !is_postgresql_identifier_continuation(ch)) } fn table_body<'a>(sql: &'a str, table: &str) -> Option<&'a str> { From cde6312235c8f1d5231fe53ab61731713d456142 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 17:02:29 +0900 Subject: [PATCH 117/309] test(persistence): cover identifier-prefixed RLS targets --- ...ration_identifier_continuation_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs index b007da341..25ead1b16 100644 --- a/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs @@ -19,3 +19,33 @@ fn postgresql_identifier_continuations_cannot_truncate_to_valid_table_prefixes() ); } } + +#[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}" + ); + } +} From 5284bf3dd851f0f228f66a82b6a819d40461a92d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 17:06:13 +0900 Subject: [PATCH 118/309] test(persistence): pin exact RLS enable targets --- ...ration_identifier_continuation_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs index 25ead1b16..dc787c9bf 100644 --- a/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs +++ b/crates/persistence_postgres/tests/migration_identifier_continuation_contract.rs @@ -49,3 +49,33 @@ fn postgresql_identifier_continuations_cannot_donate_rls_target_evidence() { ); } } + +#[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}" + ); + } +} From 0056932d1feb2d8ba965f2837d91595cf0303d29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 18:01:55 +0900 Subject: [PATCH 119/309] docs(persistence): document migration core contracts --- .../src/migration_core.rs | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 5fd196db4..43a526d95 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -53,6 +53,11 @@ impl MigrationCatalog { 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); @@ -144,11 +149,22 @@ pub fn validate_migration_catalog( 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") { @@ -174,11 +190,21 @@ fn validate_append_only_immutability(up_sql: &str) -> Result<(), MigrationContra 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 = [ @@ -206,6 +232,10 @@ fn validate_temporal_interval_ordering(up_sql: &str) -> Result<(), MigrationCont 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") @@ -213,6 +243,12 @@ fn declares_retention_legal_hold(up_sql: &str) -> bool { || 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 = [ @@ -247,6 +283,17 @@ fn validate_retention_legal_hold(up_sql: &str) -> Result<(), MigrationContractEr 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); @@ -276,6 +323,16 @@ fn validate_table_body(table: &str, body: &str) -> Result<(), MigrationContractE 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, @@ -306,6 +363,10 @@ fn validate_tenant_rls_contract( 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"); @@ -313,12 +374,21 @@ fn declares_row_level_security(up_sql: &str) -> bool { 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") { @@ -338,6 +408,10 @@ fn table_has_tenant_policy(lower_sql: &str, table: &str) -> bool { 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; @@ -351,6 +425,11 @@ fn policy_targets_table(policy_sql: &str, table: &str) -> bool { 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) { @@ -373,14 +452,20 @@ fn contains_unquoted_identifier(sql: &str, identifier: &str) -> bool { 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") @@ -388,6 +473,7 @@ fn has_system_time_column(body: &str) -> bool { || 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") @@ -488,10 +574,12 @@ fn parse_names_after(sql: &str, keyword: &str) -> BTreeSet { 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") } @@ -607,6 +695,11 @@ fn identifier_continues_after(sql: &str, end: usize) -> bool { .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) { @@ -630,6 +723,12 @@ fn starts_with_keyword(sql: &str, keyword: &str) -> bool { .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 = [ @@ -1208,7 +1307,7 @@ mod tests { 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 document_record_revision_positive CHECK (true) 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) "; From e2e03b05052dc503e203acb7891674ec6b8d00be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 18:04:01 +0900 Subject: [PATCH 120/309] test(persistence): restore temporal fixture after rustdoc pass --- crates/persistence_postgres/src/migration_core.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 43a526d95..0c46b918b 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -1307,7 +1307,7 @@ mod tests { 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 (true) + 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) "; @@ -1470,8 +1470,7 @@ mod tests { 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"); From 7eb03afda018bc07842989fef693ce80feba7642 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 18:06:49 +0900 Subject: [PATCH 121/309] docs(persistence): document migration lexical contracts --- .../src/migration_validation.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 64550c8b6..ab4714d2c 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -5,12 +5,22 @@ use std::collections::BTreeMap; const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; +/// 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, } +/// 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)) @@ -85,11 +95,21 @@ pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option 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 { let bytes = sql.as_bytes(); let mut normalized = Vec::with_capacity(bytes.len()); @@ -168,6 +188,10 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { String::from_utf8(normalized).ok() } +/// 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) @@ -190,6 +214,10 @@ fn is_role_creation_alias(tokens: &[&str], create_index: usize) -> bool { || 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) @@ -212,6 +240,11 @@ fn is_role_drop_alias(tokens: &[&str], drop_index: usize) -> bool { || 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) @@ -241,6 +274,10 @@ fn is_role_rename_alias(tokens: &[&str], alter_index: usize) -> bool { && 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) @@ -263,6 +300,7 @@ fn is_role_alter_alias(tokens: &[&str], alter_index: usize) -> bool { || 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() @@ -270,6 +308,10 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { .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") { @@ -284,6 +326,11 @@ fn apply_role_security_attributes(tokens: &[&str], state: &mut RoleSecurityState } } +/// Extract the bounded unquoted role identifier from one lifecycle token. +/// +/// The lifecycle tokenizer has already separated commas and semicolons; this +/// helper therefore accepts only the existing ASCII identifier subset instead +/// of silently extending the role grammar beyond the validator's contract. fn role_identifier(fragment: &str) -> String { fragment .trim_start_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') @@ -292,6 +339,7 @@ fn role_identifier(fragment: &str) -> String { .collect() } +/// Extract and case-fold a role identifier for PostgreSQL lifecycle comparison. fn normalized_role_identifier(fragment: &str) -> String { role_identifier(fragment).to_ascii_lowercase() } @@ -345,6 +393,12 @@ fn drop_statement_role_names(tokens: &[&str], drop_index: usize) -> Option String { let tokens = sql.split_whitespace().collect::>(); let mut canonical = Vec::with_capacity(tokens.len()); @@ -449,6 +503,10 @@ fn canonicalize_structural_keywords(sql: &str) -> String { 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; @@ -465,6 +523,10 @@ fn scan_single_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8 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; @@ -488,6 +550,10 @@ fn scan_escape_quoted_literal(bytes: &[u8], start: usize) -> Option<(usize, &[u8 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(); @@ -506,6 +572,10 @@ fn scan_quoted_identifier(bytes: &[u8], start: usize) -> Option<(usize, Vec) 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; @@ -526,6 +596,12 @@ fn scan_block_comment(bytes: &[u8], start: usize) -> Option { 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; @@ -558,6 +634,10 @@ fn dollar_quote_delimiter(bytes: &[u8], start: usize) -> Option<&[u8]> { 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() { @@ -569,6 +649,10 @@ fn scan_dollar_quoted_body(bytes: &[u8], start: usize, delimiter: &[u8]) -> Opti 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 @@ -576,6 +660,10 @@ fn literal_is_atomic(literal: &[u8]) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.')) } +/// Return whether a quoted identifier can be represented by the bounded parser. +/// +/// Unsupported punctuation is replaced by an invalid sentinel rather than +/// normalized into a different durable PostgreSQL object name. fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { !identifier.is_empty() && identifier @@ -583,6 +671,11 @@ fn quoted_identifier_is_structurally_safe(identifier: &[u8]) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || *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", From 17986cf75d2e6eb371f9df3e5df71dea12cbcc10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 18:10:56 +0900 Subject: [PATCH 122/309] docs(persistence): document tenant RLS parser contracts --- crates/persistence_postgres/src/migration.rs | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index 48f5f7fda..a43cb899c 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -85,22 +85,38 @@ fn declares_tenant_session_guc(normalized_sql: &str) -> bool { false } +/// 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) { @@ -218,6 +234,11 @@ fn policy_binds_tenant_identifier(policy_sql: &str) -> bool { false } +/// 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() @@ -249,6 +270,11 @@ fn direct_tenant_session_operand(side: &str) -> bool { ) } +/// 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; @@ -286,6 +312,11 @@ fn predicate_contains_top_level_tenant_session_equality(predicate_sql: &str) -> false } +/// 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(); @@ -319,6 +350,11 @@ fn strip_enclosing_predicate_parentheses(mut predicate_sql: &str) -> &str { } } +/// 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; @@ -390,6 +426,7 @@ fn policy_binds_tenant_session_equality(policy_sql: &str) -> bool { all_top_level_or_paths_bind_tenant_session(policy_sql) } +/// PostgreSQL row-level-security commands represented by the bounded validator. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PolicyCommand { All, @@ -399,6 +436,10 @@ enum PolicyCommand { Delete, } +/// 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); @@ -426,6 +467,10 @@ fn policy_command(policy_sql: &str) -> Option { } } +/// 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); @@ -444,6 +489,11 @@ fn policy_target_table(policy_sql: &str) -> Option<&str> { tokens.get(on_index + 1).copied() } +/// 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); @@ -481,10 +531,20 @@ fn policy_roles(policy_sql: &str) -> Option> { (!roles.is_empty() && !expect_role).then_some(roles) } +/// 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; @@ -654,6 +714,12 @@ fn normalize_catalog_sql(sql: &str) -> Option { } } +/// Remove PostgreSQL's `CONCURRENTLY` modifier from CREATE INDEX structural syntax. +/// +/// The lexical pass has already masked quoted/comment 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 From fc34f7fcaf0c6e7279578f16d63477c63a0cf48c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 19:07:50 +0900 Subject: [PATCH 123/309] test(persistence): expose table modifier validation bypass --- ...ion_table_persistence_modifier_contract.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs 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..326ed26dd --- /dev/null +++ b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs @@ -0,0 +1,47 @@ +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) + ); +} From 0cfca06f9e595e051f1fce1472352f2056e5cbe2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 19:09:51 +0900 Subject: [PATCH 124/309] test(persistence): park table modifier RED pending parser repair --- ...ion_table_persistence_modifier_contract.rs | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs diff --git a/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs deleted file mode 100644 index 326ed26dd..000000000 --- a/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs +++ /dev/null @@ -1,47 +0,0 @@ -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) - ); -} From 6e186dea81ca1a46c546f64b62007975f8a31ff5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 20:01:35 +0900 Subject: [PATCH 125/309] fix(persistence): canonicalize CREATE TABLE persistence modifiers --- .../src/migration_core.rs | 1548 ++--------------- .../src/migration_core_impl.rs | 1483 ++++++++++++++++ ...ion_table_persistence_modifier_contract.rs | 47 + 3 files changed, 1653 insertions(+), 1425 deletions(-) create mode 100644 crates/persistence_postgres/src/migration_core_impl.rs create mode 100644 crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 0c46b918b..bc706207d 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -1,1483 +1,181 @@ -//! Embedded migration catalog and fail-closed SQL contracts. +//! 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; use crate::MigrationContractError; -use crate::naming::is_multi_word_snake_case; -use std::collections::BTreeSet; +pub use implementation::MigrationCatalog; -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. +/// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. /// -/// When the catalog declares row-level security, every tenant-scoped table must -/// enable RLS and name multi-word isolation policies. +/// `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. /// /// # Errors /// -/// Returns naming, tenant, temporal, RLS, or emptiness failures. +/// Returns the same naming, tenant, temporal, RLS, or structural contract +/// errors as the underlying migration validator. 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); - } + let canonical_up = canonicalize_table_persistence_modifiers(catalog.up_sql()); + if canonical_up == catalog.up_sql() { + return implementation::validate_migration_catalog(catalog); } - Ok(()) + let canonical_catalog = MigrationCatalog::from_sql(&canonical_up, catalog.down_sql()); + implementation::validate_migration_catalog(&canonical_catalog) } -/// Detect whether named temporal ordering constraints are being declared. +/// Return whether `ch` can continue a PostgreSQL unquoted identifier. /// -/// 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") +/// 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() } -/// 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); - } +/// 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 !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 sql[..start] + .chars() + .next_back() + .is_some_and(is_postgresql_identifier_continuation) + { + return None; } - if !lower.contains("revision_number > 0") { - return Err(MigrationContractError::MissingTemporalIntervalConstraint); + if sql[end..] + .chars() + .next() + .is_some_and(is_postgresql_identifier_continuation) + { + return None; } - Ok(()) + Some(end) } -/// 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); +/// 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 !lower.contains("create or replace function reject_held_evidence_deletion") { - return Err(MigrationContractError::MissingRetentionLegalHold); + if consumed == 0 { + return None; } - 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(()) + bounded_keyword_end(sql, from + consumed, keyword) } -/// 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. +/// Return the byte immediately after `TABLE` for one supported modifier-bearing declaration. /// -/// # 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); - } +/// 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")?; - // 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); + 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 !table_has_tenant_policy(&lower, &folded) { - return Err(MigrationContractError::MissingRlsPolicy); + if consumed == 0 { + return None; } - } - 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) -} + create_end + consumed + }; -/// 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(); + if let Some(unlogged_end) = bounded_keyword_end(sql, first_start, "UNLOGGED") { + return keyword_after_required_whitespace(sql, unlogged_end, "TABLE"); } - 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; + if let Some(temp_end) = bounded_keyword_end(sql, first_start, "TEMP") { + return keyword_after_required_whitespace(sql, temp_end, "TABLE"); } - 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; + if let Some(temporary_end) = bounded_keyword_end(sql, first_start, "TEMPORARY") { + return keyword_after_required_whitespace(sql, temporary_end, "TABLE"); } - 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") + 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") } -/// 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. +/// Canonicalize supported PostgreSQL table persistence modifiers for validation only. /// -/// 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() -} +/// 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; -/// 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; + 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; } - _ => {} - } - } - 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; + index += 1; } - 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(""); + if copied_through == 0 { + return sql.to_owned(); } - 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 + output.push_str(&sql[copied_through..]); + output } #[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")); - } + use super::canonicalize_table_persistence_modifiers; #[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);", + 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);" ); - 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);", + 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);", ] { - 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" - ); + assert_eq!(canonicalize_table_persistence_modifiers(sql), sql); } } - - #[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_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/tests/migration_table_persistence_modifier_contract.rs b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs new file mode 100644 index 000000000..326ed26dd --- /dev/null +++ b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs @@ -0,0 +1,47 @@ +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) + ); +} From 1e6318d3e72d21e9b3e3e312027af3973052c058 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 20:02:56 +0900 Subject: [PATCH 126/309] test(persistence): prove valid table modifiers traverse shared contracts --- ...ion_table_persistence_modifier_contract.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs index 326ed26dd..23318db58 100644 --- a/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs +++ b/crates/persistence_postgres/tests/migration_table_persistence_modifier_contract.rs @@ -45,3 +45,44 @@ fn unlogged_table_still_traverses_table_local_tenant_contracts() { 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(())); +} From 154d1c2a94853954fe961facd64ddbe93fe13e24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:09:14 +0900 Subject: [PATCH 127/309] test(persistence): reject runtime SET ROLE RLS bypass paths --- ...ration_runtime_role_rls_safety_contract.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 index d96937021..19f5cdb2e 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -42,6 +42,35 @@ fn runtime_role_cannot_bypass_rls_or_be_superuser() { } } +#[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 rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN ROLE rls_bypass_operator;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + +#[test] +fn set_false_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 SET 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 malformed_role_lifecycle_statements_fail_closed_without_panicking() { for role_sql in [ From c3dfacaa4c2d00ba8bcae0061520ecaf73e34f8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:09:49 +0900 Subject: [PATCH 128/309] fix(persistence): model runtime SET ROLE membership safety --- .../src/migration_runtime_role_membership.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_runtime_role_membership.rs 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..0b5a89c6f --- /dev/null +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -0,0 +1,164 @@ +//! 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`. +//! PostgreSQL makes `SET` membership transitive and enables it by default, so a +//! runtime with any SET-capable membership can escape the direct +//! `NOSUPERUSER NOBYPASSRLS` proof if the target role is or later becomes +//! privileged. + +const RUNTIME_ROLE: &str = "tepp_app_runtime"; + +/// Return whether the normalized migration keeps the runtime free of SET-capable memberships. +/// +/// Object-privilege grants are excluded by the structural `ON` token before +/// `TO`. Role-membership grants to the runtime are accepted only when they +/// explicitly say `WITH SET FALSE`; PostgreSQL defaults a new membership's SET +/// option to true. `CREATE ROLE ... IN ROLE ...` is represented as `CREATE TYPE` +/// by the existing structural alias canonicalizer, so the same normalized copy +/// is checked for that SET-enabled creation form 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 index = 0usize; + + while index < tokens.len() { + let end = statement_end(&tokens, index); + if tokens[index].eq_ignore_ascii_case("GRANT") + && grant_gives_runtime_set_role(&tokens[index..end]) + { + return false; + } + if tokens[index].eq_ignore_ascii_case("CREATE") + && create_runtime_role_in_role(&tokens[index..end]) + { + return false; + } + index = end.saturating_add(1); + } + true +} + +/// 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) +} + +/// Return whether a normalized GRANT gives `tepp_app_runtime` SET ROLE authority. +/// +/// PostgreSQL object grants contain `ON` before the grantee `TO`; role +/// membership grants do not. For a new membership SET defaults to true, so the +/// only admitted runtime membership is one that explicitly fixes SET to false. +fn grant_gives_runtime_set_role(statement: &[&str]) -> bool { + let Some(to_index) = statement + .iter() + .position(|token| token.eq_ignore_ascii_case("TO")) + else { + return false; + }; + if statement[..to_index] + .iter() + .any(|token| token.eq_ignore_ascii_case("ON")) + { + return false; + } + + let grantee_end = statement[to_index + 1..] + .iter() + .position(|token| { + token.eq_ignore_ascii_case("WITH") || token.eq_ignore_ascii_case("GRANTED") + }) + .map_or(statement.len(), |relative| to_index + 1 + relative); + let runtime_is_grantee = statement[to_index + 1..grantee_end] + .iter() + .any(|token| token.eq_ignore_ascii_case(RUNTIME_ROLE)); + if !runtime_is_grantee { + return false; + } + + !membership_explicitly_disables_set(statement) +} + +/// Return whether the role-membership options contain exactly a `SET FALSE` refusal. +/// +/// `SET OPTION` is PostgreSQL's spelling for `SET TRUE`. Missing SET is also +/// unsafe because SET defaults to true when the membership is created. Any +/// malformed or contradictory SET sequence therefore fails closed. +fn membership_explicitly_disables_set(statement: &[&str]) -> bool { + let mut saw_false = false; + let mut index = 0usize; + while index < statement.len() { + if statement[index].eq_ignore_ascii_case("SET") { + let Some(value) = statement.get(index + 1) else { + return false; + }; + if value.eq_ignore_ascii_case("FALSE") { + saw_false = true; + } else { + return false; + } + index += 2; + continue; + } + index += 1; + } + saw_false +} + +/// Return whether canonicalized role creation adds the runtime `IN 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 `IN ROLE` memberships with SET enabled, so the runtime cannot use +/// that creation shortcut under the RLS contract. +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") + }) +} + +#[cfg(test)] +mod tests { + use super::runtime_membership_is_rls_safe; + + #[test] + fn object_grants_and_inverse_membership_do_not_give_runtime_set_role() { + 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_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 ;", + "CREATE TYPE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN ROLE reporting_operator ;", + ] { + assert!(!runtime_membership_is_rls_safe(sql), "{sql}"); + } + } + + #[test] + fn explicit_set_false_membership_is_not_a_set_role_path() { + assert!(runtime_membership_is_rls_safe( + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;" + )); + } +} From 108adf53f76dac35bd3e0925cc48ca564ba963e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:10:15 +0900 Subject: [PATCH 129/309] fix(persistence): enforce runtime SET ROLE isolation --- crates/persistence_postgres/src/migration_core.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index bc706207d..bd19735ee 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -9,6 +9,8 @@ #[path = "migration_core_impl.rs"] mod implementation; +#[path = "migration_runtime_role_membership.rs"] +mod runtime_role_membership; use crate::MigrationContractError; pub use implementation::MigrationCatalog; @@ -19,14 +21,21 @@ pub use implementation::MigrationCatalog; /// `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 is checked on the same normalized validation copy so +/// a SET-capable role grant cannot route around the direct RLS role-state proof. /// /// # Errors /// /// Returns the same naming, tenant, temporal, RLS, or structural contract -/// errors as the underlying migration validator. +/// errors as the underlying migration validator, plus `MissingAppRuntimeRole` +/// when the application runtime can switch roles through PostgreSQL membership. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { + if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) { + return Err(MigrationContractError::MissingAppRuntimeRole); + } + let canonical_up = canonicalize_table_persistence_modifiers(catalog.up_sql()); if canonical_up == catalog.up_sql() { return implementation::validate_migration_catalog(catalog); From 53b8514102d35144614708053a322882404f1552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:11:52 +0900 Subject: [PATCH 130/309] fix(persistence): preserve final SET membership state --- .../src/migration_runtime_role_membership.rs | 203 +++++++++++++----- 1 file changed, 150 insertions(+), 53 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index 0b5a89c6f..33f327ad4 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -8,36 +8,45 @@ //! `NOSUPERUSER NOBYPASSRLS` proof if the target role is or later becomes //! privileged. +use std::collections::BTreeMap; + const RUNTIME_ROLE: &str = "tepp_app_runtime"; +const CREATE_IN_ROLE_SENTINEL: &str = "__create_in_role_membership__"; -/// Return whether the normalized migration keeps the runtime free of SET-capable memberships. +/// Return whether the normalized migration's final runtime memberships disable SET ROLE. /// -/// Object-privilege grants are excluded by the structural `ON` token before -/// `TO`. Role-membership grants to the runtime are accepted only when they -/// explicitly say `WITH SET FALSE`; PostgreSQL defaults a new membership's SET -/// option to true. `CREATE ROLE ... IN ROLE ...` is represented as `CREATE TYPE` -/// by the existing structural alias canonicalizer, so the same normalized copy -/// is checked for that SET-enabled creation form as well. +/// Object-privilege grants/revokes are excluded by the structural `ON` token. +/// For role membership, PostgreSQL defaults SET to true on creation but retains +/// the current option when a later GRANT omits SET; the small state map mirrors +/// that ordering so an explicit later `WITH SET FALSE` can restore safety. +/// `CREATE ROLE ... IN ROLE ...` is represented as `CREATE TYPE` by the existing +/// structural alias canonicalizer and is conservatively SET-capable. 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); - if tokens[index].eq_ignore_ascii_case("GRANT") - && grant_gives_runtime_set_role(&tokens[index..end]) + let statement = &tokens[index..end]; + if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("GRANT")) { - return false; - } - if tokens[index].eq_ignore_ascii_case("CREATE") - && create_runtime_role_in_role(&tokens[index..end]) + apply_runtime_membership_grant(statement, &mut memberships); + } else if statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("REVOKE")) { - return false; + apply_runtime_membership_revoke(statement, &mut memberships); + } else if create_runtime_role_in_role(statement) { + memberships.insert(CREATE_IN_ROLE_SENTINEL.to_owned(), true); } index = end.saturating_add(1); } - true + + memberships.values().all(|set_enabled| !set_enabled) } /// Return the exclusive end of the semicolon-delimited statement containing `start`. @@ -48,65 +57,148 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { .map_or(tokens.len(), |relative| start + relative) } -/// Return whether a normalized GRANT gives `tepp_app_runtime` SET ROLE authority. +/// Apply one PostgreSQL role-membership GRANT that targets the application runtime. /// -/// PostgreSQL object grants contain `ON` before the grantee `TO`; role -/// membership grants do not. For a new membership SET defaults to true, so the -/// only admitted runtime membership is one that explicitly fixes SET to false. -fn grant_gives_runtime_set_role(statement: &[&str]) -> bool { +/// A structural `ON` before `TO` identifies object privileges and leaves role +/// membership state untouched. New memberships default SET to true; on an +/// existing membership, an omitted SET option retains the previous value. +fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap) { let Some(to_index) = statement .iter() .position(|token| token.eq_ignore_ascii_case("TO")) else { - return false; + return; }; if statement[..to_index] .iter() .any(|token| token.eq_ignore_ascii_case("ON")) { - return false; + return; + } + if !runtime_is_grantee(statement, to_index, &["WITH", "GRANTED"]) { + return; + } + + let set_option = explicit_set_option(statement); + for role in membership_role_names(&statement[1..to_index]) { + match set_option { + Some(set_enabled) => { + memberships.insert(role, set_enabled); + } + None => { + memberships.entry(role).or_insert(true); + } + } } +} - let grantee_end = statement[to_index + 1..] +/// Apply one PostgreSQL role-membership REVOKE that targets the application runtime. +/// +/// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` preserves +/// membership but disables SET ROLE. ADMIN/INHERIT option revocation does not +/// change SET state. Object privilege revokes contain `ON` and are ignored. +fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMap) { + let Some(from_index) = statement + .iter() + .position(|token| token.eq_ignore_ascii_case("FROM")) + else { + return; + }; + if statement[..from_index] + .iter() + .any(|token| token.eq_ignore_ascii_case("ON")) + { + return; + } + if !runtime_is_grantee(statement, from_index, &["GRANTED", "CASCADE", "RESTRICT"]) { + return; + } + + let (roles_start, revoke_set_only) = 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, true) + } else if statement + .get(1) + .is_some_and(|token| { + token.eq_ignore_ascii_case("ADMIN") || 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")) + { + return; + } else { + (1usize, false) + }; + + for role in membership_role_names(&statement[roles_start..from_index]) { + if revoke_set_only { + memberships.insert(role, false); + } else { + memberships.remove(&role); + } + } +} + +/// Return whether `tepp_app_runtime` appears in the bounded grantee list. +fn runtime_is_grantee(statement: &[&str], delimiter: usize, stop_keywords: &[&str]) -> bool { + let grantee_end = statement[delimiter + 1..] .iter() .position(|token| { - token.eq_ignore_ascii_case("WITH") || token.eq_ignore_ascii_case("GRANTED") + stop_keywords + .iter() + .any(|keyword| token.eq_ignore_ascii_case(keyword)) }) - .map_or(statement.len(), |relative| to_index + 1 + relative); - let runtime_is_grantee = statement[to_index + 1..grantee_end] + .map_or(statement.len(), |relative| delimiter + 1 + relative); + statement[delimiter + 1..grantee_end] .iter() - .any(|token| token.eq_ignore_ascii_case(RUNTIME_ROLE)); - if !runtime_is_grantee { - return false; - } + .any(|token| token.eq_ignore_ascii_case(RUNTIME_ROLE)) +} - !membership_explicitly_disables_set(statement) +/// Return normalized role names from a comma-separated membership role list. +fn membership_role_names(tokens: &[&str]) -> Vec { + tokens + .iter() + .filter(|token| **token != "," && !token.eq_ignore_ascii_case("GROUP")) + .map(|token| token.to_ascii_lowercase()) + .collect() } -/// Return whether the role-membership options contain exactly a `SET FALSE` refusal. +/// Return an explicitly stated SET membership option, or `None` when omitted. /// -/// `SET OPTION` is PostgreSQL's spelling for `SET TRUE`. Missing SET is also -/// unsafe because SET defaults to true when the membership is created. Any -/// malformed or contradictory SET sequence therefore fails closed. -fn membership_explicitly_disables_set(statement: &[&str]) -> bool { - let mut saw_false = false; - let mut index = 0usize; +/// PostgreSQL accepts `OPTION` as the true spelling. Malformed SET values map +/// to true so the validation boundary fails closed rather than treating an +/// unrecognized membership clause as a safety restoration. +fn explicit_set_option(statement: &[&str]) -> Option { + let with_index = statement + .iter() + .position(|token| token.eq_ignore_ascii_case("WITH"))?; + let mut index = with_index + 1; while index < statement.len() { if statement[index].eq_ignore_ascii_case("SET") { - let Some(value) = statement.get(index + 1) else { - return false; - }; - if value.eq_ignore_ascii_case("FALSE") { - saw_false = true; - } else { - return false; - } - index += 2; - continue; + return Some( + !statement + .get(index + 1) + .is_some_and(|value| value.eq_ignore_ascii_case("FALSE")), + ); + } + if statement[index].eq_ignore_ascii_case("GRANTED") { + break; } index += 1; } - saw_false + None } /// Return whether canonicalized role creation adds the runtime `IN ROLE`. @@ -156,9 +248,14 @@ mod tests { } #[test] - fn explicit_set_false_membership_is_not_a_set_role_path() { - assert!(runtime_membership_is_rls_safe( - "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;" - )); + fn explicit_set_false_or_later_revoke_restores_membership_safety() { + for sql in [ + "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime ; REVOKE SET 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 ; GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE ;", + ] { + assert!(runtime_membership_is_rls_safe(sql), "{sql}"); + } } } From 50b88e4d1e67dd8713e6d156cace587e2ef7370e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:12:14 +0900 Subject: [PATCH 131/309] test(persistence): pin final SET membership restoration --- .../migration_runtime_role_rls_safety_contract.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 19f5cdb2e..8772d90cc 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -71,6 +71,18 @@ fn set_false_membership_and_existing_grant_direction_remain_rls_safe() { } } +#[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 SET 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;", + "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;", + ] { + 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 [ From 840443c394bbc4a00877e2fa105bcdffe4026d79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:14:52 +0900 Subject: [PATCH 132/309] test(persistence): reject quoted ON role membership spoof --- .../tests/migration_runtime_role_rls_safety_contract.rs | 1 + 1 file changed, 1 insertion(+) 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 index 8772d90cc..e9ef11b3e 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -48,6 +48,7 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { "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;", ] { let catalog = rls_catalog(role_sql); From 116ac775ce5d3497ae2aa9c5d2ea69b879bf2071 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 21:15:26 +0900 Subject: [PATCH 133/309] fix(persistence): preserve quoted ON role membership evidence --- .../src/migration_runtime_role_membership.rs | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index 33f327ad4..387b13b60 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -15,7 +15,7 @@ const CREATE_IN_ROLE_SENTINEL: &str = "__create_in_role_membership__"; /// Return whether the normalized migration's final runtime memberships disable SET ROLE. /// -/// Object-privilege grants/revokes are excluded by the structural `ON` token. +/// Object-privilege grants/revokes are excluded by their structural `ON` token. /// For role membership, PostgreSQL defaults SET to true on creation but retains /// the current option when a later GRANT omits SET; the small state map mirrors /// that ordering so an explicit later `WITH SET FALSE` can restore safety. @@ -59,9 +59,9 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { /// Apply one PostgreSQL role-membership GRANT that targets the application runtime. /// -/// A structural `ON` before `TO` identifies object privileges and leaves role -/// membership state untouched. New memberships default SET to true; on an -/// existing membership, an omitted SET option retains the previous value. +/// A structural object-privilege `ON` before `TO` leaves role membership state +/// untouched. New memberships default SET to true; on an existing membership, +/// an omitted SET option retains the previous value. fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap) { let Some(to_index) = statement .iter() @@ -69,10 +69,7 @@ fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap else { return; }; - if statement[..to_index] - .iter() - .any(|token| token.eq_ignore_ascii_case("ON")) - { + if has_object_privilege_on(statement, to_index) { return; } if !runtime_is_grantee(statement, to_index, &["WITH", "GRANTED"]) { @@ -96,7 +93,7 @@ fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap /// /// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` preserves /// membership but disables SET ROLE. ADMIN/INHERIT option revocation does not -/// change SET state. Object privilege revokes contain `ON` and are ignored. +/// change SET state. Object privilege revokes are kept outside this state map. fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMap) { let Some(from_index) = statement .iter() @@ -104,10 +101,7 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa else { return; }; - if statement[..from_index] - .iter() - .any(|token| token.eq_ignore_ascii_case("ON")) - { + if has_object_privilege_on(statement, from_index) { return; } if !runtime_is_grantee(statement, from_index, &["GRANTED", "CASCADE", "RESTRICT"]) { @@ -151,6 +145,44 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa } } +/// Return whether `ON` is acting as the object-privilege separator before a grantee delimiter. +/// +/// Quoted role identifiers are intentionally projected to bare text by the +/// lexical boundary. A role literally named `"on"` must therefore not be +/// mistaken for the object-grant separator. In a role-name list `on` is either +/// the first target or follows a comma; a real object-privilege `ON` follows a +/// privilege token. Membership-option REVOKE forms are also excluded explicitly. +fn has_object_privilege_on(statement: &[&str], delimiter: usize) -> bool { + let membership_option_revoke = statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("REVOKE")) + && statement + .get(1) + .is_some_and(|token| { + token.eq_ignore_ascii_case("ADMIN") + || token.eq_ignore_ascii_case("INHERIT") + || 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")); + if membership_option_revoke { + return false; + } + + statement[..delimiter] + .iter() + .enumerate() + .any(|(index, token)| { + index > 1 + && token.eq_ignore_ascii_case("ON") + && statement.get(index.wrapping_sub(1)) != Some(&",") + }) +} + /// Return whether `tepp_app_runtime` appears in the bounded grantee list. fn runtime_is_grantee(statement: &[&str], delimiter: usize, stop_keywords: &[&str]) -> bool { let grantee_end = statement[delimiter + 1..] @@ -241,6 +273,8 @@ mod tests { "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 on TO tepp_app_runtime ;", + "GRANT reporting_operator , on TO tepp_app_runtime ;", "CREATE TYPE tepp_app_runtime NOSUPERUSER NOBYPASSRLS IN ROLE reporting_operator ;", ] { assert!(!runtime_membership_is_rls_safe(sql), "{sql}"); From 25ef96a38a85ac0b50ec11759a71033d55f85b52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 22:02:09 +0900 Subject: [PATCH 134/309] test(persistence): expose IN GROUP runtime membership bypass --- .../tests/migration_runtime_role_rls_safety_contract.rs | 2 ++ 1 file changed, 2 insertions(+) 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 index e9ef11b3e..f49776b61 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -50,6 +50,8 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { "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;", ] { let catalog = rls_catalog(role_sql); assert_eq!( From 50ce1ab4fc3701b7a1aee035540cde68ec5ecf90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 22:02:40 +0900 Subject: [PATCH 135/309] fix(persistence): cover IN GROUP runtime membership alias --- .../src/migration_runtime_role_membership.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index 387b13b60..6811d2063 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -19,8 +19,9 @@ const CREATE_IN_ROLE_SENTINEL: &str = "__create_in_role_membership__"; /// For role membership, PostgreSQL defaults SET to true on creation but retains /// the current option when a later GRANT omits SET; the small state map mirrors /// that ordering so an explicit later `WITH SET FALSE` can restore safety. -/// `CREATE ROLE ... IN ROLE ...` is represented as `CREATE TYPE` by the existing -/// structural alias canonicalizer and is conservatively SET-capable. +/// `CREATE ROLE ... IN ROLE ...` and its deprecated PostgreSQL `IN GROUP` alias +/// are represented as `CREATE TYPE` by the existing structural alias +/// canonicalizer and are conservatively SET-capable. pub(super) fn runtime_membership_is_rls_safe(sql: &str) -> bool { let tokenized = sql.replace(';', " ; ").replace(',', " , "); let tokens = tokenized.split_whitespace().collect::>(); @@ -233,12 +234,14 @@ fn explicit_set_option(statement: &[&str]) -> Option { None } -/// Return whether canonicalized role creation adds the runtime `IN ROLE`. +/// 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 `IN ROLE` memberships with SET enabled, so the runtime cannot use -/// that creation shortcut under the RLS contract. +/// creates both `IN ROLE` and deprecated `IN GROUP` memberships with SET +/// enabled, so either creation shortcut violates the 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") @@ -248,7 +251,9 @@ fn create_runtime_role_in_role(statement: &[&str]) -> bool { return false; } statement[3..].windows(2).any(|window| { - window[0].eq_ignore_ascii_case("IN") && window[1].eq_ignore_ascii_case("ROLE") + window[0].eq_ignore_ascii_case("IN") + && (window[1].eq_ignore_ascii_case("ROLE") + || window[1].eq_ignore_ascii_case("GROUP")) }) } @@ -276,6 +281,7 @@ mod tests { "GRANT on TO tepp_app_runtime ;", "GRANT reporting_operator , on TO 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 ;", ] { assert!(!runtime_membership_is_rls_safe(sql), "{sql}"); } From 216a1145f30ca4f85a9a6f8257ecfd5426256856 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 22:03:04 +0900 Subject: [PATCH 136/309] test(persistence): pin IN GROUP across role aliases --- .../tests/migration_runtime_role_rls_safety_contract.rs | 1 + 1 file changed, 1 insertion(+) 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 index f49776b61..333e1dc2b 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -52,6 +52,7 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { "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;", ] { let catalog = rls_catalog(role_sql); assert_eq!( From e7ee6ff1831677b650512093d4ca4340edb0db7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 22:04:36 +0900 Subject: [PATCH 137/309] test(persistence): expose phantom SET revoke state bypass --- .../tests/migration_runtime_role_rls_safety_contract.rs | 1 + 1 file changed, 1 insertion(+) 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 index 333e1dc2b..39cca9765 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -53,6 +53,7 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { "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!( From 1849ff63da4f5364d60a44275d568eab0fc06e60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 22:05:17 +0900 Subject: [PATCH 138/309] fix(persistence): ignore phantom SET option revokes --- .../src/migration_runtime_role_membership.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index 6811d2063..324b89624 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -93,8 +93,11 @@ fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap /// Apply one PostgreSQL role-membership REVOKE that targets the application runtime. /// /// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` preserves -/// membership but disables SET ROLE. ADMIN/INHERIT option revocation does not -/// change SET state. Object privilege revokes are kept outside this state map. +/// an existing membership but disables SET ROLE; it must not create a phantom +/// SET-false entry when no membership exists, because a later bare GRANT would +/// create a fresh membership whose PostgreSQL SET default is true. ADMIN/INHERIT +/// option revocation does not change SET state. Object privilege revokes stay +/// outside this state map. fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMap) { let Some(from_index) = statement .iter() @@ -139,7 +142,9 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa for role in membership_role_names(&statement[roles_start..from_index]) { if revoke_set_only { - memberships.insert(role, false); + if let Some(set_enabled) = memberships.get_mut(&role) { + *set_enabled = false; + } } else { memberships.remove(&role); } @@ -282,6 +287,7 @@ mod tests { "GRANT reporting_operator , on TO 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 ;", ] { assert!(!runtime_membership_is_rls_safe(sql), "{sql}"); } From 670edfce0f76acc459379c91ecafe941884844ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:01:27 +0900 Subject: [PATCH 139/309] test(persistence): pin role identifier continuation aliasing --- .../migration_runtime_role_rls_safety_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index 39cca9765..eca61f8b0 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -42,6 +42,21 @@ fn runtime_role_cannot_bypass_rls_or_be_superuser() { } } +#[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_runtime측정 NOSUPERUSER NOBYPASSRLS;", + ] { + let catalog = rls_catalog(role_sql); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingAppRuntimeRole), + "{role_sql}" + ); + } +} + #[test] fn runtime_role_cannot_gain_a_set_role_path_around_rls() { for role_sql in [ From 29e9414ac271fa739e6a37bdf558d63381366f14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:03:20 +0900 Subject: [PATCH 140/309] fix(persistence): preserve role identifier continuations --- .../src/migration_validation.rs | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index ab4714d2c..cd18ff4d2 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -326,17 +326,43 @@ fn apply_role_security_attributes(tokens: &[&str], state: &mut RoleSecurityState } } -/// Extract the bounded unquoted role identifier from one lifecycle token. +/// Return whether `ch` may start a PostgreSQL unquoted role identifier. /// -/// The lifecycle tokenizer has already separated commas and semicolons; this -/// helper therefore accepts only the existing ASCII identifier subset instead -/// of silently extending the role grammar beyond the validator's contract. +/// 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 { - fragment - .trim_start_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') - .chars() - .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') - .collect() + 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. @@ -763,7 +789,7 @@ mod tests { "CREATE USER MAPPING FOR CURRENT_USER SERVER foreign_server;", ) .expect("well-formed user mapping"); - assert!(user_mapping.starts_with("CREATE USER MAPPING ")); + assert!(normalized.starts_with("CREATE USER MAPPING ")); } #[test] From 52dbbddbc1331f47bbc361565acdfbe90035ccc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:06:48 +0900 Subject: [PATCH 141/309] test(persistence): restore user mapping assertion --- crates/persistence_postgres/src/migration_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index cd18ff4d2..5d3ea1a0a 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -789,7 +789,7 @@ mod tests { "CREATE USER MAPPING FOR CURRENT_USER SERVER foreign_server;", ) .expect("well-formed user mapping"); - assert!(normalized.starts_with("CREATE USER MAPPING ")); + assert!(user_mapping.starts_with("CREATE USER MAPPING ")); } #[test] From 91864ad9d6602e9728a0d93098ed66b028f8cff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:12:36 +0900 Subject: [PATCH 142/309] test(persistence): pin quoted role case identity --- ...ration_runtime_role_rls_safety_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index eca61f8b0..ccb4366ec 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -57,6 +57,30 @@ fn role_identifier_continuations_cannot_retarget_runtime_security_state() { } } +#[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 runtime_role_cannot_gain_a_set_role_path_around_rls() { for role_sql in [ From 1005128844ae11507d9013ce121cd5afdad1512a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:15:09 +0900 Subject: [PATCH 143/309] test(persistence): pin quoted membership role identity --- ...ration_runtime_role_rls_safety_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index ccb4366ec..648fe3f37 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -81,6 +81,27 @@ fn quoted_lowercase_runtime_name_keeps_postgresql_identity() { assert_eq!(validate_migration_catalog(&catalog), Ok(())); } +#[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 runtime_role_cannot_gain_a_set_role_path_around_rls() { for role_sql in [ From 798ee28842528be94405ea75dd56350850688480 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:18:50 +0900 Subject: [PATCH 144/309] fix(persistence): fail closed on case-distinct quoted identifiers --- .../src/migration_validation.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5d3ea1a0a..10bf10b34 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -686,15 +686,19 @@ fn literal_is_atomic(literal: &[u8]) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'.')) } -/// Return whether a quoted identifier can be represented by the bounded parser. +/// Return whether a quoted identifier can be projected onto an unquoted token +/// without changing PostgreSQL identity inside the bounded structural parser. /// -/// Unsupported punctuation is replaced by an invalid sentinel rather than -/// normalized into a different durable PostgreSQL object name. +/// 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_alphanumeric() || *byte == b'_') + && 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. @@ -766,12 +770,12 @@ mod tests { } #[test] - fn quoted_identifiers_preserve_safe_spelling_and_reject_unrepresentable_content() { + fn quoted_identifiers_preserve_equivalent_spelling_and_reject_case_distinct_content() { let normalized = normalize_migration_sql( - "CREATE INDEX \"Bad\" ON tenant_record (\"good_name\"); CREATE VIEW \"a\"\"b\" AS SELECT 1;", + "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 Bad ON tenant_record ( good_name )")); + assert!(normalized.contains("CREATE INDEX good_index ON tenant_record ( good_name )")); assert!(normalized.contains("CREATE VIEW INVALID_QUOTED_IDENTIFIER AS SELECT 1")); } From 0f63d80bbfb6945d2b54e83687298b4e3a3107b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:29:06 +0900 Subject: [PATCH 145/309] test(persistence): cover quoted grantee keyword positions --- ...ration_runtime_role_rls_safety_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index 648fe3f37..ebe065312 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -102,6 +102,30 @@ fn quoted_lowercase_membership_grantee_keeps_postgresql_identity() { assert_eq!(validate_migration_catalog(&catalog), Ok(())); } +#[test] +fn quoted_grantee_clause_keywords_do_not_hide_runtime_membership() { + 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;", + ] { + 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 [ From b75729eea0196b87a08c52475ddd8dbc25b7981d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:30:29 +0900 Subject: [PATCH 146/309] test(persistence): pin quoted membership keyword targets --- .../tests/migration_runtime_role_rls_safety_contract.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index ebe065312..3a8143c56 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -103,10 +103,12 @@ fn quoted_lowercase_membership_grantee_keeps_postgresql_identity() { } #[test] -fn quoted_grantee_clause_keywords_do_not_hide_runtime_membership() { +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!( From 0d176c704acefc34a39a25e2115372ed37c9f818 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:32:05 +0900 Subject: [PATCH 147/309] fix(persistence): parse membership role lists positionally --- .../src/migration_runtime_role_membership.rs | 217 ++++++++++-------- 1 file changed, 126 insertions(+), 91 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index 324b89624..ee354fce6 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -12,16 +12,18 @@ 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__"; /// Return whether the normalized migration's final runtime memberships disable SET ROLE. /// -/// Object-privilege grants/revokes are excluded by their structural `ON` token. -/// For role membership, PostgreSQL defaults SET to true on creation but retains -/// the current option when a later GRANT omits SET; the small state map mirrors -/// that ordering so an explicit later `WITH SET FALSE` can restore safety. -/// `CREATE ROLE ... IN ROLE ...` and its deprecated PostgreSQL `IN GROUP` alias -/// are represented as `CREATE TYPE` by the existing structural alias -/// canonicalizer and are conservatively SET-capable. +/// 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`. For role membership, PostgreSQL defaults SET to true +/// on creation but retains the current option when a later GRANT omits SET; the +/// small state map mirrors that ordering so an explicit later `WITH SET FALSE` +/// can restore safety. `CREATE ROLE ... IN ROLE ...` and its deprecated +/// PostgreSQL `IN GROUP` alias are represented as `CREATE TYPE` by the existing +/// structural alias canonicalizer and are conservatively SET-capable. pub(super) fn runtime_membership_is_rls_safe(sql: &str) -> bool { let tokenized = sql.replace(';', " ; ").replace(',', " , "); let tokens = tokenized.split_whitespace().collect::>(); @@ -58,26 +60,100 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { .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. /// -/// A structural object-privilege `ON` before `TO` leaves role membership state -/// untouched. New memberships default SET to true; on an existing membership, -/// an omitted SET option retains the previous value. +/// The granted-role and grantee lists are parsed positionally before optional +/// clauses are inspected. New memberships default SET to true; on an existing +/// membership, an omitted SET option retains the previous value. fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap) { - let Some(to_index) = statement - .iter() - .position(|token| token.eq_ignore_ascii_case("TO")) - else { + let Some(to_index) = membership_delimiter(statement, 1, "TO") else { return; }; - if has_object_privilege_on(statement, to_index) { + let Some((targets_runtime, trailing_start)) = runtime_grantee_list(statement, to_index) else { + memberships.insert(MALFORMED_GRANTEE_SENTINEL.to_owned(), true); return; - } - if !runtime_is_grantee(statement, to_index, &["WITH", "GRANTED"]) { + }; + if !targets_runtime { return; } - let set_option = explicit_set_option(statement); + let set_option = explicit_set_option(statement, trailing_start); for role in membership_role_names(&statement[1..to_index]) { match set_option { Some(set_enabled) => { @@ -97,21 +173,8 @@ fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap /// SET-false entry when no membership exists, because a later bare GRANT would /// create a fresh membership whose PostgreSQL SET default is true. ADMIN/INHERIT /// option revocation does not change SET state. Object privilege revokes stay -/// outside this state map. +/// 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 Some(from_index) = statement - .iter() - .position(|token| token.eq_ignore_ascii_case("FROM")) - else { - return; - }; - if has_object_privilege_on(statement, from_index) { - return; - } - if !runtime_is_grantee(statement, from_index, &["GRANTED", "CASCADE", "RESTRICT"]) { - return; - } - let (roles_start, revoke_set_only) = if statement .get(1) .is_some_and(|token| token.eq_ignore_ascii_case("SET")) @@ -140,6 +203,17 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa (1usize, false) }; + 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(), true); + return; + }; + if !targets_runtime { + return; + } + for role in membership_role_names(&statement[roles_start..from_index]) { if revoke_set_only { if let Some(set_enabled) = memberships.get_mut(&role) { @@ -151,77 +225,32 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa } } -/// Return whether `ON` is acting as the object-privilege separator before a grantee delimiter. +/// Return normalized role names from a validated comma-separated membership role list. /// -/// Quoted role identifiers are intentionally projected to bare text by the -/// lexical boundary. A role literally named `"on"` must therefore not be -/// mistaken for the object-grant separator. In a role-name list `on` is either -/// the first target or follows a comma; a real object-privilege `ON` follows a -/// privilege token. Membership-option REVOKE forms are also excluded explicitly. -fn has_object_privilege_on(statement: &[&str], delimiter: usize) -> bool { - let membership_option_revoke = statement - .first() - .is_some_and(|token| token.eq_ignore_ascii_case("REVOKE")) - && statement - .get(1) - .is_some_and(|token| { - token.eq_ignore_ascii_case("ADMIN") - || token.eq_ignore_ascii_case("INHERIT") - || 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")); - if membership_option_revoke { - return false; - } - - statement[..delimiter] - .iter() - .enumerate() - .any(|(index, token)| { - index > 1 - && token.eq_ignore_ascii_case("ON") - && statement.get(index.wrapping_sub(1)) != Some(&",") - }) -} - -/// Return whether `tepp_app_runtime` appears in the bounded grantee list. -fn runtime_is_grantee(statement: &[&str], delimiter: usize, stop_keywords: &[&str]) -> bool { - let grantee_end = statement[delimiter + 1..] - .iter() - .position(|token| { - stop_keywords - .iter() - .any(|keyword| token.eq_ignore_ascii_case(keyword)) - }) - .map_or(statement.len(), |relative| delimiter + 1 + relative); - statement[delimiter + 1..grantee_end] - .iter() - .any(|token| token.eq_ignore_ascii_case(RUNTIME_ROLE)) -} - -/// Return normalized role names from a 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 != "," && !token.eq_ignore_ascii_case("GROUP")) + .filter(|token| **token != ",") .map(|token| token.to_ascii_lowercase()) .collect() } /// Return an explicitly stated SET membership option, or `None` when omitted. /// -/// PostgreSQL accepts `OPTION` as the true spelling. Malformed SET values map -/// to true so the validation boundary fails closed rather than treating an +/// `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 SET values map to +/// true so the validation boundary fails closed rather than treating an /// unrecognized membership clause as a safety restoration. -fn explicit_set_option(statement: &[&str]) -> Option { - let with_index = statement +fn explicit_set_option(statement: &[&str], trailing_start: usize) -> Option { + let with_index = statement[trailing_start..] .iter() - .position(|token| token.eq_ignore_ascii_case("WITH"))?; + .position(|token| token.eq_ignore_ascii_case("WITH")) + .map(|relative| trailing_start + relative)?; let mut index = with_index + 1; while index < statement.len() { if statement[index].eq_ignore_ascii_case("SET") { @@ -285,6 +314,10 @@ mod tests { "GRANT reporting_operator TO tepp_app_runtime WITH SET OPTION ;", "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 ;", @@ -297,8 +330,10 @@ mod tests { fn explicit_set_false_or_later_revoke_restores_membership_safety() { for sql in [ "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;", + "GRANT reporting_operator TO with , tepp_app_runtime WITH SET FALSE ;", "GRANT reporting_operator TO tepp_app_runtime ; REVOKE SET 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 SET FALSE ;", ] { assert!(runtime_membership_is_rls_safe(sql), "{sql}"); From 6534214e8817cf75a7f0674bef96197b01aa9a60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:35:11 +0900 Subject: [PATCH 148/309] test(persistence): reject admin self-escalation of SET ROLE --- ...gration_runtime_role_rls_safety_contract.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 3a8143c56..1bd462c9a 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -150,6 +150,22 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { } } +#[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 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, SET FALSE;", + "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH 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 set_false_membership_and_existing_grant_direction_remain_rls_safe() { for role_sql in [ @@ -168,6 +184,8 @@ fn final_runtime_membership_state_may_explicitly_restore_rls_safety() { "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 SET 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;", "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 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 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}"); From e67caf91f04ca0194975fdbc7e830e0d43c36c6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 17 Sep 2026 23:36:06 +0900 Subject: [PATCH 149/309] fix(persistence): model admin membership self-escalation --- .../src/migration_runtime_role_membership.rs | 222 +++++++++++++----- 1 file changed, 162 insertions(+), 60 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index ee354fce6..cdbf7bc82 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -2,11 +2,9 @@ //! //! 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`. -//! PostgreSQL makes `SET` membership transitive and enables it by default, so a -//! runtime with any SET-capable membership can escape the direct -//! `NOSUPERUSER NOBYPASSRLS` proof if the target role is or later becomes -//! privileged. +//! application runtime must not be able to become another role with `SET ROLE` +//! or hold `ADMIN` on that role, which would let it grant the role back to +//! itself with `SET TRUE`. use std::collections::BTreeMap; @@ -14,20 +12,53 @@ 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__"; -/// Return whether the normalized migration's final runtime memberships disable SET ROLE. +/// 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. A membership is therefore safe for the +/// RLS runtime only when both options are false. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct MembershipSecurityState { + set_enabled: bool, + admin_enabled: bool, +} + +impl MembershipSecurityState { + /// PostgreSQL defaults for a newly created role membership. + const fn new() -> Self { + Self { + set_enabled: true, + admin_enabled: false, + } + } + + /// Return whether the runtime can reach or manufacture a SET ROLE path. + const fn can_escape_runtime_identity(self) -> bool { + self.set_enabled || self.admin_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, +} + +/// Return whether the normalized migration's final runtime memberships disable SET ROLE escalation. /// /// 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`. For role membership, PostgreSQL defaults SET to true -/// on creation but retains the current option when a later GRANT omits SET; the -/// small state map mirrors that ordering so an explicit later `WITH SET FALSE` -/// can restore safety. `CREATE ROLE ... IN ROLE ...` and its deprecated -/// PostgreSQL `IN GROUP` alias are represented as `CREATE TYPE` by the existing -/// structural alias canonicalizer and are conservatively SET-capable. +/// list before `TO`/`FROM`. New memberships default to `SET TRUE, ADMIN FALSE`; +/// later GRANTs retain omitted options. Both SET and ADMIN must be false in the +/// final state: PostgreSQL documents that ADMIN can be used to grant the role +/// back to oneself with SET enabled. `CREATE ROLE ... IN ROLE ...` and its +/// deprecated `IN GROUP` alias remain conservatively SET-capable. 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 memberships = BTreeMap::::new(); let mut index = 0usize; while index < tokens.len() { @@ -44,12 +75,17 @@ pub(super) fn runtime_membership_is_rls_safe(sql: &str) -> bool { { apply_runtime_membership_revoke(statement, &mut memberships); } else if create_runtime_role_in_role(statement) { - memberships.insert(CREATE_IN_ROLE_SENTINEL.to_owned(), true); + memberships.insert( + CREATE_IN_ROLE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); } index = end.saturating_add(1); } - memberships.values().all(|set_enabled| !set_enabled) + memberships + .values() + .all(|state| !state.can_escape_runtime_identity()) } /// Return the exclusive end of the semicolon-delimited statement containing `start`. @@ -139,43 +175,70 @@ fn runtime_grantee_list(statement: &[&str], delimiter: usize) -> Option<(bool, u /// 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 default SET to true; on an existing -/// membership, an omitted SET option retains the previous value. -fn apply_runtime_membership_grant(statement: &[&str], memberships: &mut BTreeMap) { +/// clauses are inspected. New memberships use PostgreSQL's SET-true/ADMIN-false +/// 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(), true); + memberships.insert( + MALFORMED_GRANTEE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); return; }; if !targets_runtime { return; } - let set_option = explicit_set_option(statement, trailing_start); + let options = explicit_membership_options(statement, trailing_start); for role in membership_role_names(&statement[1..to_index]) { - match set_option { - Some(set_enabled) => { - memberships.insert(role, set_enabled); + 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; + } } None => { - memberships.entry(role).or_insert(true); + 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; + } + memberships.insert(role, state); } } } } +/// Security-relevant option targeted by a membership-option REVOKE. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RevokedMembershipOption { + Set, + Admin, +} + /// Apply one PostgreSQL role-membership REVOKE that targets the application runtime. /// -/// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` preserves -/// an existing membership but disables SET ROLE; it must not create a phantom -/// SET-false entry when no membership exists, because a later bare GRANT would -/// create a fresh membership whose PostgreSQL SET default is true. ADMIN/INHERIT -/// option revocation does not change SET state. 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, revoke_set_only) = if statement +/// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` and +/// `REVOKE ADMIN OPTION FOR` mutate only existing memberships; neither creates a +/// phantom safe state. INHERIT-option revocation is outside the SET/ADMIN escape +/// invariant and leaves this map unchanged. Object privilege revokes stay +/// outside the 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 @@ -185,12 +248,21 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa .get(3) .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) { - (4usize, true) + (4usize, Some(RevokedMembershipOption::Set)) } else if statement .get(1) - .is_some_and(|token| { - token.eq_ignore_ascii_case("ADMIN") || token.eq_ignore_ascii_case("INHERIT") - }) + .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")) @@ -200,14 +272,17 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa { return; } else { - (1usize, false) + (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(), true); + memberships.insert( + MALFORMED_GRANTEE_SENTINEL.to_owned(), + MembershipSecurityState::new(), + ); return; }; if !targets_runtime { @@ -215,12 +290,20 @@ fn apply_runtime_membership_revoke(statement: &[&str], memberships: &mut BTreeMa } for role in membership_role_names(&statement[roles_start..from_index]) { - if revoke_set_only { - if let Some(set_enabled) = memberships.get_mut(&role) { - *set_enabled = false; + 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; + } + } + None => { + memberships.remove(&role); } - } else { - memberships.remove(&role); } } } @@ -239,33 +322,47 @@ fn membership_role_names(tokens: &[&str]) -> Vec { .collect() } -/// Return an explicitly stated SET membership option, or `None` when omitted. +/// Return explicitly supplied SET and ADMIN 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 SET values map to -/// true so the validation boundary fails closed rather than treating an -/// unrecognized membership clause as a safety restoration. -fn explicit_set_option(statement: &[&str], trailing_start: usize) -> Option { - let with_index = statement[trailing_start..] +/// 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)?; + .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("SET") { - return Some( - !statement - .get(index + 1) - .is_some_and(|value| value.eq_ignore_ascii_case("FALSE")), - ); - } 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; + } index += 1; } - None + options } /// Return whether canonicalized role creation adds the runtime to another role. @@ -307,11 +404,14 @@ mod tests { } #[test] - fn set_capable_runtime_memberships_fail_closed() { + fn set_or_admin_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 SET FALSE , ADMIN TRUE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH ADMIN TRUE , SET FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH 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 ;", @@ -327,7 +427,7 @@ mod tests { } #[test] - fn explicit_set_false_or_later_revoke_restores_membership_safety() { + fn explicit_security_option_repair_or_later_revoke_restores_membership_safety() { for sql in [ "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;", "GRANT reporting_operator TO with , tepp_app_runtime WITH SET FALSE ;", @@ -335,6 +435,8 @@ mod tests { "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 SET FALSE ;", + "GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE , ADMIN TRUE ; REVOKE ADMIN OPTION FOR reporting_operator FROM tepp_app_runtime ;", + "GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE , ADMIN TRUE ; GRANT reporting_operator TO tepp_app_runtime WITH ADMIN FALSE ;", ] { assert!(runtime_membership_is_rls_safe(sql), "{sql}"); } From 7600e5fc538d7659169c111352d36c48c27969d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:04:55 +0900 Subject: [PATCH 150/309] test(persistence): expose quoted role-specification alias --- .../migration_runtime_role_rls_safety_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index 1bd462c9a..a02b2401c 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -81,6 +81,21 @@ fn quoted_lowercase_runtime_name_keeps_postgresql_identity() { 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( From a7a4783ceaca499985154f7f29e7c4695b5f01ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:08:59 +0900 Subject: [PATCH 151/309] fix(persistence): preserve role-specification quote identity --- .../src/migration_validation.rs | 992 +----------------- .../src/migration_validation_impl.rs | 972 +++++++++++++++++ 2 files changed, 1007 insertions(+), 957 deletions(-) create mode 100644 crates/persistence_postgres/src/migration_validation_impl.rs diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 10bf10b34..5c8a64b88 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,972 +1,50 @@ -//! PostgreSQL lexical normalization for migration contract parsing. - -use std::collections::BTreeMap; - -const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; -const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; - -/// 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, -} +//! PostgreSQL lexical normalization facade and role-lifecycle identity guard. +//! +//! The implementation remains the single lexical/structural authority. This +//! facade preserves one piece of PostgreSQL grammar information that would +//! otherwise be lost when identity-equivalent lowercase quoted identifiers are +//! projected onto bare tokens: `CURRENT_ROLE`, `CURRENT_USER`, and +//! `SESSION_USER` are special unquoted `role_specification` values for role +//! attribute changes, while the same lowercase spellings in double quotes are +//! ordinary named roles. + +#[path = "migration_validation_impl.rs"] +mod implementation; /// 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)) + implementation::normalize_migration_sql(sql) } /// 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. +/// and remains subject to PostgreSQL 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. 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), - ) + let lifecycle_sql = preserve_quoted_special_role_specifications(sql); + implementation::declares_created_role(&lifecycle_sql, expected_role) } /// 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 { - 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' '); - 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); - } - 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() -} - -/// 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) - } + implementation::declares_row_level_security(normalized_sql) } -/// Canonicalize syntax variants that the structural migration parser owns as one concept. +/// Preserve quoted named-role identity against unquoted PostgreSQL pseudo-targets. /// -/// 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, declares_created_role, declares_row_level_security, - normalize_migration_sql, - }; - - #[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 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;"); - } +/// The replacement is deliberately limited to lowercase quoted spellings, the +/// only form that the shared lexer would otherwise dequote as identity-equivalent. +/// Replacements inside comments, literals, or dollar bodies remain inert because +/// the shared lexical pass still owns masking of those regions. Mixed/uppercase +/// quoted spellings already fail closed through the existing quoted-identifier +/// sentinel and need no special treatment here. +fn preserve_quoted_special_role_specifications(sql: &str) -> String { + sql.replace("\"current_role\"", "\"CURRENT_ROLE\"") + .replace("\"current_user\"", "\"CURRENT_USER\"") + .replace("\"session_user\"", "\"SESSION_USER\"") } 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..10bf10b34 --- /dev/null +++ b/crates/persistence_postgres/src/migration_validation_impl.rs @@ -0,0 +1,972 @@ +//! PostgreSQL lexical normalization for migration contract parsing. + +use std::collections::BTreeMap; + +const INVALID_QUOTED_IDENTIFIER: &[u8] = b"INVALID_QUOTED_IDENTIFIER"; +const INVALID_QUALIFIED_IDENTIFIER: &str = "INVALID_QUALIFIED_IDENTIFIER"; + +/// 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, +} + +/// 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)) +} + +/// 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 { + 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' '); + 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); + } + 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() +} + +/// 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, declares_created_role, declares_row_level_security, + normalize_migration_sql, + }; + + #[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 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;"); + } +} From 465cf0df81e31d800559f9d79e58b91d5f289556 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:17:26 +0900 Subject: [PATCH 152/309] test(persistence): expose inherited role privilege gap --- ...on_runtime_role_inherit_safety_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_inherit_safety_contract.rs 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}" + ); + } +} From bf2d70f111f24b7a1127f509642e3bd1eb913dee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:18:40 +0900 Subject: [PATCH 153/309] fix(persistence): track inherited role privilege paths --- .../src/migration_runtime_role_membership.rs | 108 ++++++++++++------ 1 file changed, 71 insertions(+), 37 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_membership.rs b/crates/persistence_postgres/src/migration_runtime_role_membership.rs index cdbf7bc82..fd0651ba3 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_membership.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_membership.rs @@ -2,9 +2,9 @@ //! //! 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` -//! or hold `ADMIN` on that role, which would let it grant the role back to -//! itself with `SET TRUE`. +//! 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; @@ -16,26 +16,36 @@ const MALFORMED_GRANTEE_SENTINEL: &str = "__malformed_membership_grantee_list__" /// /// `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. A membership is therefore safe for the -/// RLS runtime only when both options are false. +/// 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 { - /// PostgreSQL defaults for a newly created role membership. + /// 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 runtime can reach or manufacture a SET ROLE path. + /// 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.set_enabled || self.admin_enabled || self.inherit_enabled } } @@ -44,17 +54,20 @@ impl MembershipSecurityState { struct ExplicitMembershipOptions { set_enabled: Option, admin_enabled: Option, + inherit_enabled: Option, } -/// Return whether the normalized migration's final runtime memberships disable SET ROLE escalation. +/// 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 default to `SET TRUE, ADMIN FALSE`; -/// later GRANTs retain omitted options. Both SET and ADMIN must be false in the -/// final state: PostgreSQL documents that ADMIN can be used to grant the role -/// back to oneself with SET enabled. `CREATE ROLE ... IN ROLE ...` and its -/// deprecated `IN GROUP` alias remain conservatively SET-capable. +/// 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::>(); @@ -175,8 +188,8 @@ fn runtime_grantee_list(statement: &[&str], delimiter: usize) -> Option<(bool, u /// 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 PostgreSQL's SET-true/ADMIN-false -/// defaults; later GRANTs update only explicitly supplied security options. +/// 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, @@ -205,6 +218,9 @@ fn apply_runtime_membership_grant( 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(); @@ -214,6 +230,9 @@ fn apply_runtime_membership_grant( 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); } } @@ -225,15 +244,17 @@ fn apply_runtime_membership_grant( enum RevokedMembershipOption { Set, Admin, + Inherit, } /// Apply one PostgreSQL role-membership REVOKE that targets the application runtime. /// -/// Plain membership REVOKE removes the edge. `REVOKE SET OPTION FOR` and -/// `REVOKE ADMIN OPTION FOR` mutate only existing memberships; neither creates a -/// phantom safe state. INHERIT-option revocation is outside the SET/ADMIN escape -/// invariant and leaves this map unchanged. Object privilege revokes stay -/// outside the state map because they do not match the bounded role-list grammar. +/// 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, @@ -270,7 +291,7 @@ fn apply_runtime_membership_revoke( .get(3) .is_some_and(|token| token.eq_ignore_ascii_case("FOR")) { - return; + (4usize, Some(RevokedMembershipOption::Inherit)) } else { (1usize, None) }; @@ -301,6 +322,11 @@ fn apply_runtime_membership_revoke( state.admin_enabled = false; } } + Some(RevokedMembershipOption::Inherit) => { + if let Some(state) = memberships.get_mut(&role) { + state.inherit_enabled = false; + } + } None => { memberships.remove(&role); } @@ -322,7 +348,7 @@ fn membership_role_names(tokens: &[&str]) -> Vec { .collect() } -/// Return explicitly supplied SET and ADMIN membership options. +/// 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. @@ -360,6 +386,11 @@ fn explicit_membership_options( index += 2; continue; } + if statement[index].eq_ignore_ascii_case("INHERIT") { + options.inherit_enabled = Some(value); + index += 2; + continue; + } index += 1; } options @@ -370,9 +401,10 @@ fn explicit_membership_options( /// `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, so either creation shortcut violates the RLS contract. `ROLE` and -/// `ADMIN` clauses point in the opposite membership direction and are not -/// treated as runtime escape paths here. +/// 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") @@ -393,7 +425,7 @@ mod tests { use super::runtime_membership_is_rls_safe; #[test] - fn object_grants_and_inverse_membership_do_not_give_runtime_set_role() { + 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 ;", @@ -404,14 +436,15 @@ mod tests { } #[test] - fn set_or_admin_capable_runtime_memberships_fail_closed() { + 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 SET FALSE , ADMIN TRUE ;", - "GRANT reporting_operator TO tepp_app_runtime WITH ADMIN TRUE , SET FALSE ;", - "GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE ; GRANT reporting_operator TO tepp_app_runtime WITH ADMIN TRUE ;", + "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 ;", @@ -421,6 +454,7 @@ mod tests { "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}"); } @@ -429,14 +463,14 @@ mod tests { #[test] fn explicit_security_option_repair_or_later_revoke_restores_membership_safety() { for sql in [ - "GRANT reporting_operator TO tepp_app_runtime WITH INHERIT TRUE , SET FALSE ;", - "GRANT reporting_operator TO with , tepp_app_runtime WITH SET FALSE ;", - "GRANT reporting_operator TO tepp_app_runtime ; REVOKE SET OPTION FOR reporting_operator FROM tepp_app_runtime ;", + "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 SET FALSE ;", - "GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE , ADMIN TRUE ; REVOKE ADMIN OPTION FOR reporting_operator FROM tepp_app_runtime ;", - "GRANT reporting_operator TO tepp_app_runtime WITH SET FALSE , ADMIN TRUE ; GRANT reporting_operator TO tepp_app_runtime WITH ADMIN FALSE ;", + "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}"); } From 0dcfc4b0d3fd099c2c833da78072fc7cdf816c23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:19:12 +0900 Subject: [PATCH 154/309] test(persistence): pin noninherit runtime membership safety --- ...gration_runtime_role_rls_safety_contract.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index a02b2401c..cd40f67cd 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_rls_safety_contract.rs @@ -168,9 +168,9 @@ fn runtime_role_cannot_gain_a_set_role_path_around_rls() { #[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 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, SET FALSE;", - "CREATE ROLE rls_bypass_operator BYPASSRLS;\nCREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH SET FALSE;\nGRANT rls_bypass_operator TO tepp_app_runtime WITH ADMIN TRUE;", + "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!( @@ -182,9 +182,9 @@ fn admin_membership_can_self_enable_set_role() { } #[test] -fn set_false_membership_and_existing_grant_direction_remain_rls_safe() { +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 SET FALSE;", + "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;", ] { @@ -196,11 +196,11 @@ fn set_false_membership_and_existing_grant_direction_remain_rls_safe() { #[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 SET 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;", + "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 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 SET FALSE, ADMIN TRUE;\nGRANT reporting_operator TO tepp_app_runtime WITH ADMIN FALSE;", + "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}"); From a79b73c87f31a737fb7dc0845da9de8f2ac3e2a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 00:58:54 +0900 Subject: [PATCH 155/309] test(persistence): RED preserve role membership grantor provenance --- ...untime_role_grantor_provenance_contract.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs 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..73606af60 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs @@ -0,0 +1,50 @@ +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 revoking_one_grantor_does_not_erase_an_unsafe_alternative_membership_grant() { + let role_sql = 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; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY grantor_b; + "#; + + 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" + ); +} From e266fef2457dc43c7d11a7a3da36f6a7e2ad3401 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:00:23 +0900 Subject: [PATCH 156/309] fix(persistence): track explicit role membership grantors independently --- .../src/migration_runtime_role_grantor.rs | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_runtime_role_grantor.rs 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)); + } +} From f01d24b38ec5ef6b55e81619aa671c1a39d504b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:01:06 +0900 Subject: [PATCH 157/309] fix(persistence): enforce grantor-aware runtime membership safety --- crates/persistence_postgres/src/migration_core.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index bd19735ee..11c74592d 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -9,6 +9,8 @@ #[path = "migration_core_impl.rs"] mod implementation; +#[path = "migration_runtime_role_grantor.rs"] +mod runtime_role_grantor; #[path = "migration_runtime_role_membership.rs"] mod runtime_role_membership; @@ -21,18 +23,22 @@ pub use implementation::MigrationCatalog; /// `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 is checked on the same normalized validation copy so -/// a SET-capable role grant cannot route around the direct RLS role-state proof. +/// Runtime-role membership is checked on the same normalized validation copy. +/// The aggregate membership authority proves effective SET/ADMIN/INHERIT state, +/// while the grantor-provenance authority separately prevents a REVOKE from one +/// PostgreSQL grantor from erasing an unsafe membership row recorded by another. /// /// # Errors /// /// Returns the same naming, tenant, temporal, RLS, or structural contract /// errors as the underlying migration validator, plus `MissingAppRuntimeRole` -/// when the application runtime can switch roles through PostgreSQL membership. +/// when the application runtime has an unsafe PostgreSQL membership path. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { - if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) { + if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) + { return Err(MigrationContractError::MissingAppRuntimeRole); } From ddb97c8e8f563b84ebf8dd61f9f28336d2b1aecb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:01:45 +0900 Subject: [PATCH 158/309] test(persistence): pin grantor-specific membership recovery --- ...untime_role_grantor_provenance_contract.rs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index 73606af60..6ccf9b4ff 100644 --- a/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_provenance_contract.rs @@ -23,9 +23,8 @@ fn rls_catalog(role_sql: &str) -> MigrationCatalog { MigrationCatalog::from_sql(&up_sql, "DROP TABLE tenant_record;") } -#[test] -fn revoking_one_grantor_does_not_erase_an_unsafe_alternative_membership_grant() { - let role_sql = r#" +fn grantor_roles() -> &'static str { + r#" CREATE ROLE reporting_owner NOSUPERUSER NOBYPASSRLS; CREATE ROLE grantor_a NOSUPERUSER NOBYPASSRLS; CREATE ROLE grantor_b NOSUPERUSER NOBYPASSRLS; @@ -38,13 +37,31 @@ fn revoking_one_grantor_does_not_erase_an_unsafe_alternative_membership_grant() 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; - "#; + "# +} + +#[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); + 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(())); +} From ea19db64490a3557ea6e97a3bc757efe06804143 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:11:33 +0900 Subject: [PATCH 159/309] test(persistence): RED preserve quoted GRANTED BY identity --- ..._runtime_role_grantor_identity_contract.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs 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..a2ad1d3c8 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs @@ -0,0 +1,49 @@ +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 quoted_named_grantor_does_not_alias_the_current_user_pseudo_target() { + let role_sql = 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; + REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; + "#; + + 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" + ); +} From 3525435fc6110d82d91565d1d8aeb5e082b9d182 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:12:08 +0900 Subject: [PATCH 160/309] fix(persistence): preserve grantor role-specification identity --- .../src/migration_validation.rs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5c8a64b88..e1622d1b3 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,12 +1,11 @@ -//! PostgreSQL lexical normalization facade and role-lifecycle identity guard. +//! PostgreSQL lexical normalization facade and role-identity guards. //! //! The implementation remains the single lexical/structural authority. This -//! facade preserves one piece of PostgreSQL grammar information that would +//! facade preserves bounded pieces of PostgreSQL grammar information that would //! otherwise be lost when identity-equivalent lowercase quoted identifiers are -//! projected onto bare tokens: `CURRENT_ROLE`, `CURRENT_USER`, and -//! `SESSION_USER` are special unquoted `role_specification` values for role -//! attribute changes, while the same lowercase spellings in double quotes are -//! ordinary named roles. +//! projected onto bare tokens. `CURRENT_ROLE`, `CURRENT_USER`, and +//! `SESSION_USER` are special unquoted `role_specification` values, while the +//! same lowercase spellings in double quotes are ordinary named roles. #[path = "migration_validation_impl.rs"] mod implementation; @@ -16,6 +15,20 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql(sql) } +/// Normalize the dedicated runtime-membership grantor evidence projection. +/// +/// The general structural projection deliberately treats identity-equivalent +/// lowercase quoted identifiers like their unquoted names. `GRANTED BY` is one +/// place where that is not equivalent: an unquoted `CURRENT_USER` is a +/// PostgreSQL pseudo-target, while `"current_user"` names an ordinary role. This +/// grantor-only projection reuses the shared lexer after making those quoted +/// spellings case-distinct, so the existing quoted-name sentinel preserves the +/// identity boundary without globally changing valid object-name parsing. +pub(super) fn normalize_runtime_membership_grantor_sql(sql: &str) -> Option { + let grantor_sql = preserve_quoted_special_role_specifications(sql); + implementation::normalize_migration_sql(&grantor_sql) +} + /// Return whether the expected runtime role exists in the final migration state /// and remains subject to PostgreSQL row-level security. /// From 5ac8b3598d5b84512665d1abbcf80535aae64efb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:12:33 +0900 Subject: [PATCH 161/309] fix(persistence): separate grantor identity projection from structural SQL --- .../src/migration_core.rs | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 11c74592d..18566bd6c 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -17,28 +17,46 @@ mod runtime_role_membership; use crate::MigrationContractError; pub use implementation::MigrationCatalog; +/// Validate the grantor-preserving runtime-membership projection. +/// +/// The caller supplies a separately normalized copy because PostgreSQL special +/// role specifications such as unquoted `CURRENT_USER` are semantically distinct +/// from quoted named roles such as `"current_user"`. Keeping this check separate +/// prevents the general structural projection from collapsing those grantors. +/// +/// # Errors +/// +/// Returns `MissingAppRuntimeRole` while any explicit grantor-attributed +/// membership row retains SET, ADMIN, or INHERIT escape capability. +pub(super) fn validate_runtime_membership_grantors( + grantor_sql: &str, +) -> Result<(), MigrationContractError> { + if runtime_role_grantor::runtime_membership_grantors_are_rls_safe(grantor_sql) { + Ok(()) + } else { + Err(MigrationContractError::MissingAppRuntimeRole) + } +} + /// 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 is checked on the same normalized validation copy. -/// The aggregate membership authority proves effective SET/ADMIN/INHERIT state, -/// while the grantor-provenance authority separately prevents a REVOKE from one -/// PostgreSQL grantor from erasing an unsafe membership row recorded by another. +/// Aggregate runtime-role membership proves final SET/ADMIN/INHERIT state here; +/// grantor-provenance evidence is validated separately from its identity-preserving +/// lexical projection before the caller enters this structural 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 PostgreSQL membership path. +/// when the aggregate application-runtime membership state is unsafe. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { - if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) - || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) - { + if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) { return Err(MigrationContractError::MissingAppRuntimeRole); } From 731ef5c0316877ed95869ffd76d27a21e9c57576 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:14:11 +0900 Subject: [PATCH 162/309] chore(persistence): park grantor identity projection pending facade wiring --- .../src/migration_validation.rs | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index e1622d1b3..5c8a64b88 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,11 +1,12 @@ -//! PostgreSQL lexical normalization facade and role-identity guards. +//! PostgreSQL lexical normalization facade and role-lifecycle identity guard. //! //! The implementation remains the single lexical/structural authority. This -//! facade preserves bounded pieces of PostgreSQL grammar information that would +//! facade preserves one piece of PostgreSQL grammar information that would //! otherwise be lost when identity-equivalent lowercase quoted identifiers are -//! projected onto bare tokens. `CURRENT_ROLE`, `CURRENT_USER`, and -//! `SESSION_USER` are special unquoted `role_specification` values, while the -//! same lowercase spellings in double quotes are ordinary named roles. +//! projected onto bare tokens: `CURRENT_ROLE`, `CURRENT_USER`, and +//! `SESSION_USER` are special unquoted `role_specification` values for role +//! attribute changes, while the same lowercase spellings in double quotes are +//! ordinary named roles. #[path = "migration_validation_impl.rs"] mod implementation; @@ -15,20 +16,6 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql(sql) } -/// Normalize the dedicated runtime-membership grantor evidence projection. -/// -/// The general structural projection deliberately treats identity-equivalent -/// lowercase quoted identifiers like their unquoted names. `GRANTED BY` is one -/// place where that is not equivalent: an unquoted `CURRENT_USER` is a -/// PostgreSQL pseudo-target, while `"current_user"` names an ordinary role. This -/// grantor-only projection reuses the shared lexer after making those quoted -/// spellings case-distinct, so the existing quoted-name sentinel preserves the -/// identity boundary without globally changing valid object-name parsing. -pub(super) fn normalize_runtime_membership_grantor_sql(sql: &str) -> Option { - let grantor_sql = preserve_quoted_special_role_specifications(sql); - implementation::normalize_migration_sql(&grantor_sql) -} - /// Return whether the expected runtime role exists in the final migration state /// and remains subject to PostgreSQL row-level security. /// From 59b87c793e1242ccdc4847284c8990fbfca779b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:14:31 +0900 Subject: [PATCH 163/309] chore(persistence): restore grantor validation while identity repair is parked --- .../src/migration_core.rs | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 18566bd6c..11c74592d 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -17,46 +17,28 @@ mod runtime_role_membership; use crate::MigrationContractError; pub use implementation::MigrationCatalog; -/// Validate the grantor-preserving runtime-membership projection. -/// -/// The caller supplies a separately normalized copy because PostgreSQL special -/// role specifications such as unquoted `CURRENT_USER` are semantically distinct -/// from quoted named roles such as `"current_user"`. Keeping this check separate -/// prevents the general structural projection from collapsing those grantors. -/// -/// # Errors -/// -/// Returns `MissingAppRuntimeRole` while any explicit grantor-attributed -/// membership row retains SET, ADMIN, or INHERIT escape capability. -pub(super) fn validate_runtime_membership_grantors( - grantor_sql: &str, -) -> Result<(), MigrationContractError> { - if runtime_role_grantor::runtime_membership_grantors_are_rls_safe(grantor_sql) { - Ok(()) - } else { - Err(MigrationContractError::MissingAppRuntimeRole) - } -} - /// 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. -/// Aggregate runtime-role membership proves final SET/ADMIN/INHERIT state here; -/// grantor-provenance evidence is validated separately from its identity-preserving -/// lexical projection before the caller enters this structural boundary. +/// Runtime-role membership is checked on the same normalized validation copy. +/// The aggregate membership authority proves effective SET/ADMIN/INHERIT state, +/// while the grantor-provenance authority separately prevents a REVOKE from one +/// PostgreSQL grantor from erasing an unsafe membership row recorded by another. /// /// # Errors /// /// Returns the same naming, tenant, temporal, RLS, or structural contract /// errors as the underlying migration validator, plus `MissingAppRuntimeRole` -/// when the aggregate application-runtime membership state is unsafe. +/// when the application runtime has an unsafe PostgreSQL membership path. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { - if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) { + if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) + { return Err(MigrationContractError::MissingAppRuntimeRole); } From 5388c80c5351df2f5275a23c6334f60d0ef49fe1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:14:50 +0900 Subject: [PATCH 164/309] test(persistence): park GRANTED BY identity RED pending facade wiring --- ..._runtime_role_grantor_identity_contract.rs | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs 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 deleted file mode 100644 index a2ad1d3c8..000000000 --- a/crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs +++ /dev/null @@ -1,49 +0,0 @@ -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 quoted_named_grantor_does_not_alias_the_current_user_pseudo_target() { - let role_sql = 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; - REVOKE reporting_owner FROM tepp_app_runtime GRANTED BY CURRENT_USER; - "#; - - 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" - ); -} From d6064dc152f2691753dd901ea5cada18a4822f1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:26:10 +0900 Subject: [PATCH 165/309] test(persistence): RED reattach quoted grantor identity contract --- ..._runtime_role_grantor_identity_contract.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_identity_contract.rs 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(())); +} From a8a527c6a1d6dc2727ee0b00cc5125fbec92df70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:26:45 +0900 Subject: [PATCH 166/309] fix(persistence): preserve quoted special-role grantor identity --- .../src/migration_validation.rs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5c8a64b88..af7cbbd68 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,12 +1,11 @@ -//! PostgreSQL lexical normalization facade and role-lifecycle identity guard. +//! PostgreSQL lexical normalization facade and role-identity guards. //! //! The implementation remains the single lexical/structural authority. This -//! facade preserves one piece of PostgreSQL grammar information that would +//! facade preserves bounded pieces of PostgreSQL grammar information that would //! otherwise be lost when identity-equivalent lowercase quoted identifiers are -//! projected onto bare tokens: `CURRENT_ROLE`, `CURRENT_USER`, and -//! `SESSION_USER` are special unquoted `role_specification` values for role -//! attribute changes, while the same lowercase spellings in double quotes are -//! ordinary named roles. +//! projected onto bare tokens. `CURRENT_ROLE`, `CURRENT_USER`, and +//! `SESSION_USER` are special unquoted `role_specification` values, while the +//! same lowercase spellings in double quotes are ordinary named roles. #[path = "migration_validation_impl.rs"] mod implementation; @@ -16,6 +15,20 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql(sql) } +/// Normalize the runtime-membership grantor evidence without aliasing named roles. +/// +/// The general structural projection may safely dequote lowercase identifiers, +/// but `GRANTED BY` gives unquoted `CURRENT_ROLE`, `CURRENT_USER`, and +/// `SESSION_USER` pseudo-target meaning. The same spellings in quotes are named +/// roles, so this grantor-only projection replaces those three quoted spellings +/// with distinct validation sentinels that cannot be valid unquoted PostgreSQL +/// role names, then reuses the shared lexer. Executable migration SQL and the +/// general structural projection are unchanged. +pub(super) fn normalize_runtime_membership_grantor_sql(sql: &str) -> Option { + let grantor_sql = preserve_quoted_special_grantor_specifications(sql); + implementation::normalize_migration_sql(&grantor_sql) +} + /// Return whether the expected runtime role exists in the final migration state /// and remains subject to PostgreSQL row-level security. /// @@ -48,3 +61,24 @@ fn preserve_quoted_special_role_specifications(sql: &str) -> String { .replace("\"current_user\"", "\"CURRENT_USER\"") .replace("\"session_user\"", "\"SESSION_USER\"") } + +/// Preserve the three quoted special-role spellings as distinct grantor tokens. +/// +/// `@` cannot occur in an unquoted PostgreSQL identifier, so these validation +/// sentinels cannot collide with a real unquoted role. Replacements performed +/// inside comments or literal bodies remain non-structural because the shared +/// lexical authority masks those regions afterwards. +fn preserve_quoted_special_grantor_specifications(sql: &str) -> String { + sql.replace( + "\"current_role\"", + "__tepp_quoted_grantor_current_role@", + ) + .replace( + "\"current_user\"", + "__tepp_quoted_grantor_current_user@", + ) + .replace( + "\"session_user\"", + "__tepp_quoted_grantor_session_user@", + ) +} From 114b969b892a972905804f8f2a05992a9c729913 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:27:06 +0900 Subject: [PATCH 167/309] refactor(persistence): separate grantor-provenance validation copy --- crates/persistence_postgres/src/migration_core.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 11c74592d..35c14e0da 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -23,10 +23,10 @@ pub use implementation::MigrationCatalog; /// `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 is checked on the same normalized validation copy. -/// The aggregate membership authority proves effective SET/ADMIN/INHERIT state, -/// while the grantor-provenance authority separately prevents a REVOKE from one -/// PostgreSQL grantor from erasing an unsafe membership row recorded by another. +/// Runtime-role membership is checked on the general normalized validation copy. +/// Grantor provenance is checked on a separate normalized copy whose only extra +/// information is the identity distinction between PostgreSQL pseudo-targets and +/// identically spelled quoted named roles. /// /// # Errors /// @@ -35,9 +35,10 @@ pub use implementation::MigrationCatalog; /// when the application runtime has an unsafe PostgreSQL membership path. pub fn validate_migration_catalog( catalog: &MigrationCatalog, + grantor_sql: &str, ) -> Result<(), MigrationContractError> { if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) - || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(grantor_sql) { return Err(MigrationContractError::MissingAppRuntimeRole); } From 94844714d4e80b82bd20f560e063f9787cd6f56c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:28:28 +0900 Subject: [PATCH 168/309] fix(persistence): wire quoted grantor identity through shared normalization --- .../src/migration_validation.rs | 77 ++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index af7cbbd68..4de43ee8d 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -10,23 +10,21 @@ #[path = "migration_validation_impl.rs"] mod implementation; -/// Normalize migration SQL for bounded structural contract parsing. -pub(super) fn normalize_migration_sql(sql: &str) -> Option { - implementation::normalize_migration_sql(sql) -} +const QUOTED_CURRENT_ROLE_GRANTOR: &str = "__tepp_quoted_grantor_current_role@"; +const QUOTED_CURRENT_USER_GRANTOR: &str = "__tepp_quoted_grantor_current_user@"; +const QUOTED_SESSION_USER_GRANTOR: &str = "__tepp_quoted_grantor_session_user@"; -/// Normalize the runtime-membership grantor evidence without aliasing named roles. +/// Normalize migration SQL for bounded structural contract parsing. /// -/// The general structural projection may safely dequote lowercase identifiers, -/// but `GRANTED BY` gives unquoted `CURRENT_ROLE`, `CURRENT_USER`, and -/// `SESSION_USER` pseudo-target meaning. The same spellings in quotes are named -/// roles, so this grantor-only projection replaces those three quoted spellings -/// with distinct validation sentinels that cannot be valid unquoted PostgreSQL -/// role names, then reuses the shared lexer. Executable migration SQL and the -/// general structural projection are unchanged. -pub(super) fn normalize_runtime_membership_grantor_sql(sql: &str) -> Option { - let grantor_sql = preserve_quoted_special_grantor_specifications(sql); - implementation::normalize_migration_sql(&grantor_sql) +/// A grantor-only preprojection preserves the identity of lowercase quoted +/// spellings that PostgreSQL otherwise distinguishes from unquoted special role +/// specifications. After the shared lexical/structural pass, those sentinels are +/// restored everywhere except the `GRANTED BY` role-specification slot, so the +/// general object/lifecycle parser sees the same normalized names as before. +pub(super) fn normalize_migration_sql(sql: &str) -> Option { + let projected = preserve_quoted_special_grantor_specifications(sql); + let normalized = implementation::normalize_migration_sql(&projected)?; + Some(restore_non_grantor_special_role_sentinels(&normalized)) } /// Return whether the expected runtime role exists in the final migration state @@ -62,23 +60,46 @@ fn preserve_quoted_special_role_specifications(sql: &str) -> String { .replace("\"session_user\"", "\"SESSION_USER\"") } -/// Preserve the three quoted special-role spellings as distinct grantor tokens. +/// Preserve the three lowercase quoted special-role spellings through the lexer. /// /// `@` cannot occur in an unquoted PostgreSQL identifier, so these validation /// sentinels cannot collide with a real unquoted role. Replacements performed /// inside comments or literal bodies remain non-structural because the shared /// lexical authority masks those regions afterwards. fn preserve_quoted_special_grantor_specifications(sql: &str) -> String { - sql.replace( - "\"current_role\"", - "__tepp_quoted_grantor_current_role@", - ) - .replace( - "\"current_user\"", - "__tepp_quoted_grantor_current_user@", - ) - .replace( - "\"session_user\"", - "__tepp_quoted_grantor_session_user@", - ) + sql.replace("\"current_role\"", QUOTED_CURRENT_ROLE_GRANTOR) + .replace("\"current_user\"", QUOTED_CURRENT_USER_GRANTOR) + .replace("\"session_user\"", QUOTED_SESSION_USER_GRANTOR) +} + +/// Keep quoted-role sentinels only in the PostgreSQL `GRANTED BY` grammar slot. +/// +/// The shared structural normalizer has already collapsed whitespace and masked +/// opaque bodies. A sentinel outside this exact slot represents an ordinary +/// named role and is restored to the lowercase spelling that the general lexer +/// historically produced. Grantor evidence retains the sentinel so it cannot +/// alias the unquoted pseudo-target with the same spelling. +fn restore_non_grantor_special_role_sentinels(normalized_sql: &str) -> String { + let mut restored = normalized_sql.to_owned(); + for (sentinel, role_name) in [ + (QUOTED_CURRENT_ROLE_GRANTOR, "current_role"), + (QUOTED_CURRENT_USER_GRANTOR, "current_user"), + (QUOTED_SESSION_USER_GRANTOR, "session_user"), + ] { + let mut search_from = 0usize; + while let Some(relative) = restored[search_from..].find(sentinel) { + let start = search_from + relative; + let is_explicit_grantor = restored[..start] + .trim_end() + .to_ascii_lowercase() + .ends_with("granted by"); + if is_explicit_grantor { + search_from = start + sentinel.len(); + continue; + } + restored.replace_range(start..start + sentinel.len(), role_name); + search_from = start + role_name.len(); + } + } + restored } From f4b4b22168cb7f3265b8879f563fd3546f4a0129 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:28:47 +0900 Subject: [PATCH 169/309] fix(persistence): route grantor sentinels through core authority --- crates/persistence_postgres/src/migration_core.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 35c14e0da..dd5b29aeb 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -23,10 +23,10 @@ pub use implementation::MigrationCatalog; /// `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 is checked on the general normalized validation copy. -/// Grantor provenance is checked on a separate normalized copy whose only extra -/// information is the identity distinction between PostgreSQL pseudo-targets and -/// identically spelled quoted named roles. +/// Runtime-role membership and grantor provenance are checked on the normalized +/// validation copy. The lexical facade preserves quoted special-role identity +/// only in the `GRANTED BY` slot, so grantor evidence remains distinct without +/// changing general object or lifecycle parsing. /// /// # Errors /// @@ -35,10 +35,9 @@ pub use implementation::MigrationCatalog; /// when the application runtime has an unsafe PostgreSQL membership path. pub fn validate_migration_catalog( catalog: &MigrationCatalog, - grantor_sql: &str, ) -> Result<(), MigrationContractError> { if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) - || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(grantor_sql) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) { return Err(MigrationContractError::MissingAppRuntimeRole); } From 01263aa2baf0cc5e177de938362743cd9ccf2dbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:29:54 +0900 Subject: [PATCH 170/309] fix(persistence): bound quoted-grantor projection to complete identifiers --- .../src/migration_validation.rs | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 4de43ee8d..d4ae13d95 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -60,6 +60,31 @@ fn preserve_quoted_special_role_specifications(sql: &str) -> String { .replace("\"session_user\"", "\"SESSION_USER\"") } +/// Replace one complete quoted spelling without entering a doubled-quote escape. +/// +/// PostgreSQL escapes a double quote inside a quoted identifier by doubling it. +/// The grantor preprojection must therefore ignore a target spelling whose +/// opening or closing quote is adjacent to another quote; the shared lexer will +/// later parse that larger identifier as one token. Comments and literal bodies +/// still remain the shared lexer's responsibility. +fn replace_complete_quoted_spelling(sql: &str, quoted: &str, replacement: &str) -> String { + let mut projected = sql.to_owned(); + let mut search_from = 0usize; + while let Some(relative) = projected[search_from..].find(quoted) { + let start = search_from + relative; + let end = start + quoted.len(); + let joins_doubled_quote = projected.as_bytes().get(start.wrapping_sub(1)) == Some(&b'"') + || projected.as_bytes().get(end) == Some(&b'"'); + if joins_doubled_quote { + search_from = end; + continue; + } + projected.replace_range(start..end, replacement); + search_from = start + replacement.len(); + } + projected +} + /// Preserve the three lowercase quoted special-role spellings through the lexer. /// /// `@` cannot occur in an unquoted PostgreSQL identifier, so these validation @@ -67,9 +92,21 @@ fn preserve_quoted_special_role_specifications(sql: &str) -> String { /// inside comments or literal bodies remain non-structural because the shared /// lexical authority masks those regions afterwards. fn preserve_quoted_special_grantor_specifications(sql: &str) -> String { - sql.replace("\"current_role\"", QUOTED_CURRENT_ROLE_GRANTOR) - .replace("\"current_user\"", QUOTED_CURRENT_USER_GRANTOR) - .replace("\"session_user\"", QUOTED_SESSION_USER_GRANTOR) + let projected = replace_complete_quoted_spelling( + sql, + "\"current_role\"", + QUOTED_CURRENT_ROLE_GRANTOR, + ); + let projected = replace_complete_quoted_spelling( + &projected, + "\"current_user\"", + QUOTED_CURRENT_USER_GRANTOR, + ); + replace_complete_quoted_spelling( + &projected, + "\"session_user\"", + QUOTED_SESSION_USER_GRANTOR, + ) } /// Keep quoted-role sentinels only in the PostgreSQL `GRANTED BY` grammar slot. @@ -103,3 +140,18 @@ fn restore_non_grantor_special_role_sentinels(normalized_sql: &str) -> String { } restored } + +#[cfg(test)] +mod tests { + use super::{ + QUOTED_CURRENT_USER_GRANTOR, preserve_quoted_special_grantor_specifications, + }; + + #[test] + fn grantor_projection_does_not_split_a_larger_doubled_quote_identifier() { + let sql = r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "prefix""current_user""suffix";"#; + let projected = preserve_quoted_special_grantor_specifications(sql); + assert_eq!(projected, sql); + assert!(!projected.contains(QUOTED_CURRENT_USER_GRANTOR)); + } +} From 7e4a255b081ea245546655a80c3294ada806ada3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:31:57 +0900 Subject: [PATCH 171/309] test(persistence): RED preserve case-distinct quoted grantors --- ...ime_role_grantor_case_identity_contract.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs 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(())); +} From f6d2236fe56669e81b78998ad6c374d34ff9d8e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:32:09 +0900 Subject: [PATCH 172/309] test(persistence): park case-distinct quoted grantor RED --- ...ime_role_grantor_case_identity_contract.rs | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs 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 deleted file mode 100644 index a09864181..000000000 --- a/crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs +++ /dev/null @@ -1,61 +0,0 @@ -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(())); -} From c0a440551300b1cf4c973254cd4188e9ce185ef5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:55:25 +0900 Subject: [PATCH 173/309] test(persistence): RED preserve case-distinct quoted grantors --- ...ime_role_grantor_case_identity_contract.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_case_identity_contract.rs 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(())); +} From c3de89b4e2630d9931ccc9e3711c0160eaf63e0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 01:57:42 +0900 Subject: [PATCH 174/309] fix(persistence): preserve case-distinct quoted grantor identity --- .../src/migration_validation.rs | 182 ++++++++++++++++-- 1 file changed, 169 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d4ae13d95..a0d93ba5b 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -2,10 +2,12 @@ //! //! The implementation remains the single lexical/structural authority. This //! facade preserves bounded pieces of PostgreSQL grammar information that would -//! otherwise be lost when identity-equivalent lowercase quoted identifiers are -//! projected onto bare tokens. `CURRENT_ROLE`, `CURRENT_USER`, and -//! `SESSION_USER` are special unquoted `role_specification` values, while the -//! same lowercase spellings in double quotes are ordinary named roles. +//! otherwise be lost when quoted role identifiers are projected onto the +//! structural parser's fail-closed representation. `CURRENT_ROLE`, +//! `CURRENT_USER`, and `SESSION_USER` are special unquoted `role_specification` +//! values, while quoted spellings remain ordinary named roles. + +use std::fmt::Write as _; #[path = "migration_validation_impl.rs"] mod implementation; @@ -13,18 +15,30 @@ mod implementation; const QUOTED_CURRENT_ROLE_GRANTOR: &str = "__tepp_quoted_grantor_current_role@"; const QUOTED_CURRENT_USER_GRANTOR: &str = "__tepp_quoted_grantor_current_user@"; const QUOTED_SESSION_USER_GRANTOR: &str = "__tepp_quoted_grantor_session_user@"; +const CASE_DISTINCT_GRANTOR_PREFIX: &str = "__tepp_quoted_grantor_case_"; +const INVALID_QUOTED_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; /// Normalize migration SQL for bounded structural contract parsing. /// -/// A grantor-only preprojection preserves the identity of lowercase quoted -/// spellings that PostgreSQL otherwise distinguishes from unquoted special role -/// specifications. After the shared lexical/structural pass, those sentinels are -/// restored everywhere except the `GRANTED BY` role-specification slot, so the -/// general object/lifecycle parser sees the same normalized names as before. +/// Grantor-only preprojections preserve PostgreSQL role identity that the +/// general fail-closed quoted-identifier projection would otherwise erase. +/// The shared lexical/structural implementation still owns comments, literals, +/// dollar bodies, doubled-quote handling, and every ordinary quoted identifier. +/// After that authority runs, temporary grantor sentinels are retained only in +/// the `GRANTED BY` role-specification slot and are restored to the historical +/// structural representation everywhere else. pub(super) fn normalize_migration_sql(sql: &str) -> Option { + if sql.contains(CASE_DISTINCT_GRANTOR_PREFIX) { + // A caller-supplied token that collides with validation-only provenance + // state could manufacture or erase grantor evidence. Reject it rather + // than attempting to distinguish its origin after lexical normalization. + return None; + } let projected = preserve_quoted_special_grantor_specifications(sql); + let projected = preserve_case_distinct_quoted_grantor_candidates(&projected); let normalized = implementation::normalize_migration_sql(&projected)?; - Some(restore_non_grantor_special_role_sentinels(&normalized)) + let restored = restore_non_grantor_case_distinct_sentinels(&normalized); + Some(restore_non_grantor_special_role_sentinels(&restored)) } /// Return whether the expected runtime role exists in the final migration state @@ -52,8 +66,7 @@ pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { /// only form that the shared lexer would otherwise dequote as identity-equivalent. /// Replacements inside comments, literals, or dollar bodies remain inert because /// the shared lexical pass still owns masking of those regions. Mixed/uppercase -/// quoted spellings already fail closed through the existing quoted-identifier -/// sentinel and need no special treatment here. +/// quoted spellings follow the separate grantor-provenance projection below. fn preserve_quoted_special_role_specifications(sql: &str) -> String { sql.replace("\"current_role\"", "\"CURRENT_ROLE\"") .replace("\"current_user\"", "\"CURRENT_USER\"") @@ -109,6 +122,113 @@ fn preserve_quoted_special_grantor_specifications(sql: &str) -> String { ) } +/// Encode one simple case-distinct quoted role spelling as a provenance token. +/// +/// Hex encoding preserves PostgreSQL's exact quoted case while keeping the +/// temporary token whitespace-free. The trailing `@` keeps it outside the +/// grammar of an unquoted PostgreSQL identifier; the public normalization entry +/// point rejects caller-supplied occurrences of the reserved prefix. +fn case_distinct_grantor_sentinel(identifier: &[u8]) -> String { + let mut sentinel = String::with_capacity(CASE_DISTINCT_GRANTOR_PREFIX.len() + identifier.len() * 2 + 1); + sentinel.push_str(CASE_DISTINCT_GRANTOR_PREFIX); + for byte in identifier { + write!(&mut sentinel, "{byte:02x}").expect("writing to String cannot fail"); + } + sentinel.push('@'); + sentinel +} + +/// Preserve simple mixed/uppercase quoted identifiers through the shared lexer. +/// +/// This is not a second SQL lexer. It deliberately recognizes only complete +/// ASCII alphanumeric/underscore quoted spellings whose contents include an +/// uppercase byte. Such spellings are guaranteed to collapse to the general +/// lexer's invalid quoted-name sentinel, so non-grantor occurrences can be +/// restored exactly to that historical fail-closed representation afterwards. +/// Replacements inside comments, strings, or dollar bodies do not donate +/// evidence because the shared lexer still masks those regions. Doubled-quote +/// identifiers are left untouched and remain owned by that lexer. +fn preserve_case_distinct_quoted_grantor_candidates(sql: &str) -> String { + let bytes = sql.as_bytes(); + let mut projected = String::with_capacity(sql.len()); + let mut copied_through = 0usize; + let mut index = 0usize; + + while index < bytes.len() { + if bytes[index] != b'"' || bytes.get(index.wrapping_sub(1)) == Some(&b'"') { + index += 1; + continue; + } + + let content_start = index + 1; + let mut end = content_start; + while let Some(byte) = bytes.get(end) { + if *byte == b'"' { + break; + } + if !byte.is_ascii_alphanumeric() && *byte != b'_' { + break; + } + end += 1; + } + + let complete = end > content_start + && bytes.get(end) == Some(&b'"') + && bytes.get(end + 1) != Some(&b'"'); + let case_distinct = complete + && bytes[content_start..end] + .iter() + .any(u8::is_ascii_uppercase); + if !case_distinct { + index += 1; + continue; + } + + projected.push_str(&sql[copied_through..index]); + projected.push_str(&case_distinct_grantor_sentinel( + &bytes[content_start..end], + )); + copied_through = end + 1; + index = end + 1; + } + + projected.push_str(&sql[copied_through..]); + projected +} + +/// Keep case-distinct quoted identity only in an explicit `GRANTED BY` slot. +/// +/// Every projected candidate would historically have become +/// `INVALID_QUOTED_IDENTIFIER` in an ordinary object/lifecycle position because +/// it contains uppercase quoted content. Retaining the encoded token only after +/// `GRANTED BY` therefore strengthens provenance evidence without weakening the +/// general fail-closed naming boundary. +fn restore_non_grantor_case_distinct_sentinels(normalized_sql: &str) -> String { + let mut restored = normalized_sql.to_owned(); + let mut search_from = 0usize; + + while let Some(relative) = restored[search_from..].find(CASE_DISTINCT_GRANTOR_PREFIX) { + let start = search_from + relative; + let suffix_start = start + CASE_DISTINCT_GRANTOR_PREFIX.len(); + let Some(relative_end) = restored[suffix_start..].find('@') else { + break; + }; + let end = suffix_start + relative_end + 1; + let is_explicit_grantor = restored[..start] + .trim_end() + .to_ascii_lowercase() + .ends_with("granted by"); + if is_explicit_grantor { + search_from = end; + continue; + } + restored.replace_range(start..end, INVALID_QUOTED_IDENTIFIER); + search_from = start + INVALID_QUOTED_IDENTIFIER.len(); + } + + restored +} + /// Keep quoted-role sentinels only in the PostgreSQL `GRANTED BY` grammar slot. /// /// The shared structural normalizer has already collapsed whitespace and masked @@ -144,7 +264,9 @@ fn restore_non_grantor_special_role_sentinels(normalized_sql: &str) -> String { #[cfg(test)] mod tests { use super::{ - QUOTED_CURRENT_USER_GRANTOR, preserve_quoted_special_grantor_specifications, + CASE_DISTINCT_GRANTOR_PREFIX, INVALID_QUOTED_IDENTIFIER, QUOTED_CURRENT_USER_GRANTOR, + normalize_migration_sql, preserve_case_distinct_quoted_grantor_candidates, + preserve_quoted_special_grantor_specifications, }; #[test] @@ -154,4 +276,38 @@ mod tests { assert_eq!(projected, sql); assert!(!projected.contains(QUOTED_CURRENT_USER_GRANTOR)); } + + #[test] + fn case_distinct_projection_does_not_split_doubled_quote_identifiers() { + let sql = r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor""A";"#; + let projected = preserve_case_distinct_quoted_grantor_candidates(sql); + assert_eq!(projected, sql); + assert!(!projected.contains(CASE_DISTINCT_GRANTOR_PREFIX)); + } + + #[test] + fn case_distinct_identity_survives_only_in_the_grantor_slot() { + let normalized = normalize_migration_sql( + r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "GrantorA"; CREATE ROLE "GrantorB";"#, + ) + .expect("well-formed SQL"); + assert!(normalized.contains(CASE_DISTINCT_GRANTOR_PREFIX)); + assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); + assert_eq!( + normalized.matches(CASE_DISTINCT_GRANTOR_PREFIX).count(), + 1, + "only the explicit grantor keeps exact quoted identity" + ); + } + + #[test] + fn projected_text_inside_literals_remains_owned_by_the_shared_lexer() { + let normalized = normalize_migration_sql( + r#"SELECT 'GRANTED BY "GrantorA"'; GRANT reporting_owner TO tepp_app_runtime GRANTED BY "GrantorB";"#, + ) + .expect("well-formed SQL"); + assert!(!normalized.contains("4772616e746f7241")); + assert!(normalized.contains("4772616e746f7242")); + assert!(!normalized.contains(INVALID_QUOTED_IDENTIFIER)); + } } From afded1e2b7a369ffe2a8a8366dce5c354e0a1b94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 02:02:54 +0900 Subject: [PATCH 175/309] test(persistence): RED preserve arbitrary quoted grantor identity --- ...ole_grantor_arbitrary_identity_contract.rs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs 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}" + ); + } +} From 1d22afbd9121adaf11e55691c80facc6cc4f4e59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 02:03:43 +0900 Subject: [PATCH 176/309] test(persistence): park arbitrary quoted grantor RED --- ...ole_grantor_arbitrary_identity_contract.rs | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs 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 deleted file mode 100644 index d5b3f5d78..000000000 --- a/crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs +++ /dev/null @@ -1,81 +0,0 @@ -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}" - ); - } -} From 87d87fcebf4632d4d992af44726e2ba8fcc0827a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 03:04:52 +0900 Subject: [PATCH 177/309] fix(persistence): expose quoted grantor identity from shared lexer --- .../src/migration_validation_impl.rs | 176 +++++++++++++++++- 1 file changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation_impl.rs b/crates/persistence_postgres/src/migration_validation_impl.rs index 10bf10b34..d408bc1c5 100644 --- a/crates/persistence_postgres/src/migration_validation_impl.rs +++ b/crates/persistence_postgres/src/migration_validation_impl.rs @@ -1,9 +1,11 @@ //! 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. /// @@ -15,6 +17,12 @@ struct RoleSecurityState { 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 @@ -26,6 +34,29 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { 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 @@ -111,6 +142,18 @@ pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { /// 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; @@ -142,12 +185,15 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { b'"' => { let (next, identifier) = scan_quoted_identifier(bytes, index)?; normalized.push(b' '); - 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); + 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; @@ -188,6 +234,99 @@ fn lexically_normalize_migration_sql(sql: &str) -> Option { 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) +} + +/// 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 = restored[..start] + .trim_end() + .to_ascii_lowercase() + .ends_with("granted by"); + 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 @@ -724,8 +863,9 @@ fn quoted_identifier_collides_with_table_syntax(identifier: &[u8]) -> bool { #[cfg(test)] mod tests { use super::{ - INVALID_QUALIFIED_IDENTIFIER, declares_created_role, declares_row_level_security, - normalize_migration_sql, + INVALID_QUALIFIED_IDENTIFIER, QUOTED_GRANTOR_IDENTITY_PREFIX, declares_created_role, + declares_row_level_security, normalize_migration_sql, + normalize_migration_sql_with_grantor_identity, }; #[test] @@ -779,6 +919,26 @@ mod tests { 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 [ From 488adae45ddcdb8b219eba383ee26ce34cd9a5b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 03:05:05 +0900 Subject: [PATCH 178/309] refactor(persistence): route grantor identity through shared lexer --- .../src/migration_validation.rs | 284 ++---------------- 1 file changed, 23 insertions(+), 261 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index a0d93ba5b..c31c0f895 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1,44 +1,21 @@ //! PostgreSQL lexical normalization facade and role-identity guards. //! //! The implementation remains the single lexical/structural authority. This -//! facade preserves bounded pieces of PostgreSQL grammar information that would -//! otherwise be lost when quoted role identifiers are projected onto the -//! structural parser's fail-closed representation. `CURRENT_ROLE`, -//! `CURRENT_USER`, and `SESSION_USER` are special unquoted `role_specification` -//! values, while quoted spellings remain ordinary named roles. - -use std::fmt::Write as _; +//! 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; -const QUOTED_CURRENT_ROLE_GRANTOR: &str = "__tepp_quoted_grantor_current_role@"; -const QUOTED_CURRENT_USER_GRANTOR: &str = "__tepp_quoted_grantor_current_user@"; -const QUOTED_SESSION_USER_GRANTOR: &str = "__tepp_quoted_grantor_session_user@"; -const CASE_DISTINCT_GRANTOR_PREFIX: &str = "__tepp_quoted_grantor_case_"; -const INVALID_QUOTED_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; - /// Normalize migration SQL for bounded structural contract parsing. /// -/// Grantor-only preprojections preserve PostgreSQL role identity that the -/// general fail-closed quoted-identifier projection would otherwise erase. -/// The shared lexical/structural implementation still owns comments, literals, -/// dollar bodies, doubled-quote handling, and every ordinary quoted identifier. -/// After that authority runs, temporary grantor sentinels are retained only in -/// the `GRANTED BY` role-specification slot and are restored to the historical -/// structural representation everywhere else. +/// 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 { - if sql.contains(CASE_DISTINCT_GRANTOR_PREFIX) { - // A caller-supplied token that collides with validation-only provenance - // state could manufacture or erase grantor evidence. Reject it rather - // than attempting to distinguish its origin after lexical normalization. - return None; - } - let projected = preserve_quoted_special_grantor_specifications(sql); - let projected = preserve_case_distinct_quoted_grantor_candidates(&projected); - let normalized = implementation::normalize_migration_sql(&projected)?; - let restored = restore_non_grantor_case_distinct_sentinels(&normalized); - Some(restore_non_grantor_special_role_sentinels(&restored)) + implementation::normalize_migration_sql_with_grantor_identity(sql) } /// Return whether the expected runtime role exists in the final migration state @@ -63,251 +40,36 @@ pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { /// Preserve quoted named-role identity against unquoted PostgreSQL pseudo-targets. /// /// The replacement is deliberately limited to lowercase quoted spellings, the -/// only form that the shared lexer would otherwise dequote as identity-equivalent. -/// Replacements inside comments, literals, or dollar bodies remain inert because -/// the shared lexical pass still owns masking of those regions. Mixed/uppercase -/// quoted spellings follow the separate grantor-provenance projection below. +/// only form that the lifecycle projection would otherwise dequote as +/// identity-equivalent. 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 { sql.replace("\"current_role\"", "\"CURRENT_ROLE\"") .replace("\"current_user\"", "\"CURRENT_USER\"") .replace("\"session_user\"", "\"SESSION_USER\"") } -/// Replace one complete quoted spelling without entering a doubled-quote escape. -/// -/// PostgreSQL escapes a double quote inside a quoted identifier by doubling it. -/// The grantor preprojection must therefore ignore a target spelling whose -/// opening or closing quote is adjacent to another quote; the shared lexer will -/// later parse that larger identifier as one token. Comments and literal bodies -/// still remain the shared lexer's responsibility. -fn replace_complete_quoted_spelling(sql: &str, quoted: &str, replacement: &str) -> String { - let mut projected = sql.to_owned(); - let mut search_from = 0usize; - while let Some(relative) = projected[search_from..].find(quoted) { - let start = search_from + relative; - let end = start + quoted.len(); - let joins_doubled_quote = projected.as_bytes().get(start.wrapping_sub(1)) == Some(&b'"') - || projected.as_bytes().get(end) == Some(&b'"'); - if joins_doubled_quote { - search_from = end; - continue; - } - projected.replace_range(start..end, replacement); - search_from = start + replacement.len(); - } - projected -} - -/// Preserve the three lowercase quoted special-role spellings through the lexer. -/// -/// `@` cannot occur in an unquoted PostgreSQL identifier, so these validation -/// sentinels cannot collide with a real unquoted role. Replacements performed -/// inside comments or literal bodies remain non-structural because the shared -/// lexical authority masks those regions afterwards. -fn preserve_quoted_special_grantor_specifications(sql: &str) -> String { - let projected = replace_complete_quoted_spelling( - sql, - "\"current_role\"", - QUOTED_CURRENT_ROLE_GRANTOR, - ); - let projected = replace_complete_quoted_spelling( - &projected, - "\"current_user\"", - QUOTED_CURRENT_USER_GRANTOR, - ); - replace_complete_quoted_spelling( - &projected, - "\"session_user\"", - QUOTED_SESSION_USER_GRANTOR, - ) -} - -/// Encode one simple case-distinct quoted role spelling as a provenance token. -/// -/// Hex encoding preserves PostgreSQL's exact quoted case while keeping the -/// temporary token whitespace-free. The trailing `@` keeps it outside the -/// grammar of an unquoted PostgreSQL identifier; the public normalization entry -/// point rejects caller-supplied occurrences of the reserved prefix. -fn case_distinct_grantor_sentinel(identifier: &[u8]) -> String { - let mut sentinel = String::with_capacity(CASE_DISTINCT_GRANTOR_PREFIX.len() + identifier.len() * 2 + 1); - sentinel.push_str(CASE_DISTINCT_GRANTOR_PREFIX); - for byte in identifier { - write!(&mut sentinel, "{byte:02x}").expect("writing to String cannot fail"); - } - sentinel.push('@'); - sentinel -} - -/// Preserve simple mixed/uppercase quoted identifiers through the shared lexer. -/// -/// This is not a second SQL lexer. It deliberately recognizes only complete -/// ASCII alphanumeric/underscore quoted spellings whose contents include an -/// uppercase byte. Such spellings are guaranteed to collapse to the general -/// lexer's invalid quoted-name sentinel, so non-grantor occurrences can be -/// restored exactly to that historical fail-closed representation afterwards. -/// Replacements inside comments, strings, or dollar bodies do not donate -/// evidence because the shared lexer still masks those regions. Doubled-quote -/// identifiers are left untouched and remain owned by that lexer. -fn preserve_case_distinct_quoted_grantor_candidates(sql: &str) -> String { - let bytes = sql.as_bytes(); - let mut projected = String::with_capacity(sql.len()); - let mut copied_through = 0usize; - let mut index = 0usize; - - while index < bytes.len() { - if bytes[index] != b'"' || bytes.get(index.wrapping_sub(1)) == Some(&b'"') { - index += 1; - continue; - } - - let content_start = index + 1; - let mut end = content_start; - while let Some(byte) = bytes.get(end) { - if *byte == b'"' { - break; - } - if !byte.is_ascii_alphanumeric() && *byte != b'_' { - break; - } - end += 1; - } - - let complete = end > content_start - && bytes.get(end) == Some(&b'"') - && bytes.get(end + 1) != Some(&b'"'); - let case_distinct = complete - && bytes[content_start..end] - .iter() - .any(u8::is_ascii_uppercase); - if !case_distinct { - index += 1; - continue; - } - - projected.push_str(&sql[copied_through..index]); - projected.push_str(&case_distinct_grantor_sentinel( - &bytes[content_start..end], - )); - copied_through = end + 1; - index = end + 1; - } - - projected.push_str(&sql[copied_through..]); - projected -} - -/// Keep case-distinct quoted identity only in an explicit `GRANTED BY` slot. -/// -/// Every projected candidate would historically have become -/// `INVALID_QUOTED_IDENTIFIER` in an ordinary object/lifecycle position because -/// it contains uppercase quoted content. Retaining the encoded token only after -/// `GRANTED BY` therefore strengthens provenance evidence without weakening the -/// general fail-closed naming boundary. -fn restore_non_grantor_case_distinct_sentinels(normalized_sql: &str) -> String { - let mut restored = normalized_sql.to_owned(); - let mut search_from = 0usize; - - while let Some(relative) = restored[search_from..].find(CASE_DISTINCT_GRANTOR_PREFIX) { - let start = search_from + relative; - let suffix_start = start + CASE_DISTINCT_GRANTOR_PREFIX.len(); - let Some(relative_end) = restored[suffix_start..].find('@') else { - break; - }; - let end = suffix_start + relative_end + 1; - let is_explicit_grantor = restored[..start] - .trim_end() - .to_ascii_lowercase() - .ends_with("granted by"); - if is_explicit_grantor { - search_from = end; - continue; - } - restored.replace_range(start..end, INVALID_QUOTED_IDENTIFIER); - search_from = start + INVALID_QUOTED_IDENTIFIER.len(); - } - - restored -} - -/// Keep quoted-role sentinels only in the PostgreSQL `GRANTED BY` grammar slot. -/// -/// The shared structural normalizer has already collapsed whitespace and masked -/// opaque bodies. A sentinel outside this exact slot represents an ordinary -/// named role and is restored to the lowercase spelling that the general lexer -/// historically produced. Grantor evidence retains the sentinel so it cannot -/// alias the unquoted pseudo-target with the same spelling. -fn restore_non_grantor_special_role_sentinels(normalized_sql: &str) -> String { - let mut restored = normalized_sql.to_owned(); - for (sentinel, role_name) in [ - (QUOTED_CURRENT_ROLE_GRANTOR, "current_role"), - (QUOTED_CURRENT_USER_GRANTOR, "current_user"), - (QUOTED_SESSION_USER_GRANTOR, "session_user"), - ] { - let mut search_from = 0usize; - while let Some(relative) = restored[search_from..].find(sentinel) { - let start = search_from + relative; - let is_explicit_grantor = restored[..start] - .trim_end() - .to_ascii_lowercase() - .ends_with("granted by"); - if is_explicit_grantor { - search_from = start + sentinel.len(); - continue; - } - restored.replace_range(start..start + sentinel.len(), role_name); - search_from = start + role_name.len(); - } - } - restored -} - #[cfg(test)] mod tests { - use super::{ - CASE_DISTINCT_GRANTOR_PREFIX, INVALID_QUOTED_IDENTIFIER, QUOTED_CURRENT_USER_GRANTOR, - normalize_migration_sql, preserve_case_distinct_quoted_grantor_candidates, - preserve_quoted_special_grantor_specifications, - }; - - #[test] - fn grantor_projection_does_not_split_a_larger_doubled_quote_identifier() { - let sql = r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "prefix""current_user""suffix";"#; - let projected = preserve_quoted_special_grantor_specifications(sql); - assert_eq!(projected, sql); - assert!(!projected.contains(QUOTED_CURRENT_USER_GRANTOR)); - } + use super::{declares_created_role, normalize_migration_sql}; #[test] - fn case_distinct_projection_does_not_split_doubled_quote_identifiers() { - let sql = r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor""A";"#; - let projected = preserve_case_distinct_quoted_grantor_candidates(sql); - assert_eq!(projected, sql); - assert!(!projected.contains(CASE_DISTINCT_GRANTOR_PREFIX)); + 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 case_distinct_identity_survives_only_in_the_grantor_slot() { + fn grantor_identity_comes_from_the_shared_lexical_authority() { let normalized = normalize_migration_sql( - r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "GrantorA"; CREATE ROLE "GrantorB";"#, + r#"GRANT reporting_owner TO tepp_app_runtime GRANTED BY "Grantor""A"; CREATE ROLE "Grantor""B";"#, ) - .expect("well-formed SQL"); - assert!(normalized.contains(CASE_DISTINCT_GRANTOR_PREFIX)); + .expect("well-formed quoted identities"); + assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); - assert_eq!( - normalized.matches(CASE_DISTINCT_GRANTOR_PREFIX).count(), - 1, - "only the explicit grantor keeps exact quoted identity" - ); - } - - #[test] - fn projected_text_inside_literals_remains_owned_by_the_shared_lexer() { - let normalized = normalize_migration_sql( - r#"SELECT 'GRANTED BY "GrantorA"'; GRANT reporting_owner TO tepp_app_runtime GRANTED BY "GrantorB";"#, - ) - .expect("well-formed SQL"); - assert!(!normalized.contains("4772616e746f7241")); - assert!(normalized.contains("4772616e746f7242")); - assert!(!normalized.contains(INVALID_QUOTED_IDENTIFIER)); } } From c107d7c79d8d1529b1b4e1bb585099279c87ca8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 03:05:25 +0900 Subject: [PATCH 179/309] test(persistence): reattach arbitrary quoted grantor identity contract --- ...ole_grantor_arbitrary_identity_contract.rs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_arbitrary_identity_contract.rs 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}" + ); + } +} From edbf903da88727c1a06e200fdf933f4afb22d1c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 03:08:52 +0900 Subject: [PATCH 180/309] test(persistence): RED preserve grantor identity across whitespace --- ...untime_role_grantor_whitespace_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs 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" + ); + } +} From ab23f97a68045b12a69448e589712957a3c446e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 03:09:03 +0900 Subject: [PATCH 181/309] test(persistence): park grantor whitespace RED --- ...untime_role_grantor_whitespace_contract.rs | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs 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 deleted file mode 100644 index a5f639efc..000000000 --- a/crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs +++ /dev/null @@ -1,38 +0,0 @@ -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" - ); - } -} From 1b4ecbb9836229e595eee4be8e199e19f3003eb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 04:01:17 +0900 Subject: [PATCH 182/309] fix(persistence): preserve grantor slot across SQL whitespace --- .../src/migration_validation_impl.rs | 21 ++++++++-- ...untime_role_grantor_whitespace_contract.rs | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_whitespace_contract.rs diff --git a/crates/persistence_postgres/src/migration_validation_impl.rs b/crates/persistence_postgres/src/migration_validation_impl.rs index d408bc1c5..5cd661f8d 100644 --- a/crates/persistence_postgres/src/migration_validation_impl.rs +++ b/crates/persistence_postgres/src/migration_validation_impl.rs @@ -277,6 +277,22 @@ fn decode_quoted_grantor_identity(token: &str) -> Option> { 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 @@ -297,10 +313,7 @@ fn restore_quoted_identifier_projection(normalized_sql: &str) -> Option let end = suffix_start + relative_end + 1; let token = &restored[start..end]; let identifier = decode_quoted_grantor_identity(token)?; - let is_explicit_grantor = restored[..start] - .trim_end() - .to_ascii_lowercase() - .ends_with("granted by"); + 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)); 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" + ); + } +} From 49bbb745efe9914add293deb09b8bdb24c5d438b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 04:59:29 +0900 Subject: [PATCH 183/309] test(persistence): expose SET ROLE grantor provenance collapse --- ..._runtime_role_grantor_set_role_contract.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_set_role_contract.rs 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(())); +} From 200adacef0ecd4767bb0d073586b76cccf4a5382 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 05:00:52 +0900 Subject: [PATCH 184/309] fix(persistence): model executor-relative grantor identity --- .../src/migration_runtime_role_executor.rs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_runtime_role_executor.rs 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..711e997e5 --- /dev/null +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -0,0 +1,206 @@ +//! 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 only normalized statement tokens that +//! change the effective role 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"; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum EffectiveRoleProjection { + InitialCurrentUser, + SessionUser, + 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. Unknown and + /// connection-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::SessionUser => format!("{EXECUTOR_GRANTOR_PREFIX}session_user@"), + Self::Named(role_name) => role_name.clone(), + Self::Unknown(statement_index) => { + format!("{EXECUTOR_GRANTOR_PREFIX}unknown_{statement_index}@") + } + } + } +} + +/// Project executor-relative grantors onto stable validation-only identities. +/// +/// PostgreSQL records the role denoted by `GRANTED BY`, not the literal text of +/// `CURRENT_USER` or `CURRENT_ROLE`. `SET ROLE` can therefore make identical +/// pseudo-target spellings refer to different `pg_auth_members.grantor` rows in +/// one migration. `SESSION_USER` is connection-stable and intentionally kept +/// separate from the mutable effective role. Unknown quoted/string role targets +/// receive a statement-local opaque identity instead of donating false revoke +/// evidence. The returned SQL is consumed only by the grantor provenance +/// validator; 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 current_role = EffectiveRoleProjection::InitialCurrentUser; + 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_effective_role(&statement, statement_index, &mut current_role); + rewrite_granted_by_pseudo_target(&mut statement, ¤t_role); + 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(" ")) +} + +fn statement_end(tokens: &[&str], start: usize) -> usize { + tokens[start..] + .iter() + .position(|token| *token == ";") + .map_or(tokens.len(), |relative| start + relative) +} + +/// Update the effective current-role projection for one normalized statement. +fn update_effective_role( + statement: &[String], + statement_index: usize, + current_role: &mut EffectiveRoleProjection, +) { + 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")) + { + *current_role = EffectiveRoleProjection::InitialCurrentUser; + return; + } + + if !statement + .first() + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + { + return; + } + + let role_target_index = if statement + .get(1) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + Some(2usize) + } else if statement.get(1).is_some_and(|token| { + token.eq_ignore_ascii_case("SESSION") || token.eq_ignore_ascii_case("LOCAL") + }) && statement + .get(2) + .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) + { + Some(3usize) + } else { + None + }; + + let Some(target) = role_target_index.and_then(|target_index| statement.get(target_index)) else { + return; + }; + + if target.eq_ignore_ascii_case("NONE") { + *current_role = EffectiveRoleProjection::SessionUser; + } else if role_target_is_statically_named(target) { + *current_role = EffectiveRoleProjection::Named(target.to_ascii_lowercase()); + } else { + *current_role = EffectiveRoleProjection::Unknown(statement_index); + } +} + +/// Return whether the normalized SET 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. +fn rewrite_granted_by_pseudo_target( + statement: &mut [String], + current_role: &EffectiveRoleProjection, +) { + 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") + { + Some(current_role.provenance_token()) + } else if statement[index + 2].eq_ignore_ascii_case("SESSION_USER") { + Some(EffectiveRoleProjection::SessionUser.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_stable() { + 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 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() + ); + } +} From bdd341889c8320370935baa1787d77e253c1a09e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 05:01:14 +0900 Subject: [PATCH 185/309] fix(persistence): project SET ROLE grantor provenance --- .../src/migration_core.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index dd5b29aeb..6e63a8405 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -9,6 +9,8 @@ #[path = "migration_core_impl.rs"] mod implementation; +#[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"] @@ -24,20 +26,27 @@ pub use implementation::MigrationCatalog; /// and table-body contracts as ordinary `CREATE TABLE`. The canonicalized copy /// exists only for validation; executable migration SQL is never rewritten. /// Runtime-role membership and grantor provenance are checked on the normalized -/// validation copy. The lexical facade preserves quoted special-role identity -/// only in the `GRANTED BY` slot, so grantor evidence remains distinct without -/// changing general object or lifecycle parsing. +/// validation copy. Executor-relative `GRANTED BY CURRENT_USER` / `CURRENT_ROLE` +/// evidence is first projected through normalized `SET ROLE` state so identical +/// pseudo-target spellings cannot collapse distinct PostgreSQL grantor rows. /// /// # 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 PostgreSQL membership path. +/// when the application runtime has an unsafe or unprovable PostgreSQL +/// membership path. 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); + }; + if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) - || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(catalog.up_sql()) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(&grantor_sql) { return Err(MigrationContractError::MissingAppRuntimeRole); } From 1df233a2943382aa79f0ab5aa0aced691ad59ece Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 05:01:55 +0900 Subject: [PATCH 186/309] docs(persistence): document executor grantor projection invariants --- .../src/migration_runtime_role_executor.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/persistence_postgres/src/migration_runtime_role_executor.rs b/crates/persistence_postgres/src/migration_runtime_role_executor.rs index 711e997e5..e0c71d5bd 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_executor.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -9,6 +9,13 @@ const EXECUTOR_GRANTOR_PREFIX: &str = "__tepp_executor_grantor_"; const INVALID_QUOTED_IDENTIFIER: &str = "INVALID_QUOTED_IDENTIFIER"; +/// Effective PostgreSQL role identity visible to `CURRENT_USER` / `CURRENT_ROLE`. +/// +/// Initial and session identities remain separate because PostgreSQL can start +/// a connection with a role setting distinct from `SESSION_USER`. Named roles +/// are exact normalized identities. Unknown targets are scoped to the `SET ROLE` +/// statement that introduced them so later statements cannot accidentally +/// alias them to a different executor role. #[derive(Clone, Debug, Eq, PartialEq)] enum EffectiveRoleProjection { InitialCurrentUser, @@ -82,6 +89,7 @@ pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { Some(output.join(" ")) } +/// Find the end of one already-normalized semicolon-delimited statement. fn statement_end(tokens: &[&str], start: usize) -> usize { tokens[start..] .iter() From 070c67d41ed01ce86b0980c57b5d8cbb1cd4944e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 05:59:52 +0900 Subject: [PATCH 187/309] test(persistence): expose session authorization grantor collapse --- ..._grantor_session_authorization_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_session_authorization_contract.rs 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(())); +} From ec47a658df7a176057bec90b9b0cd22505da2461 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:00:46 +0900 Subject: [PATCH 188/309] fix(persistence): track session authorization grantor identity --- .../src/migration_runtime_role_executor.rs | 219 +++++++++++++++--- 1 file changed, 181 insertions(+), 38 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_executor.rs b/crates/persistence_postgres/src/migration_runtime_role_executor.rs index e0c71d5bd..250e98855 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_executor.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -3,23 +3,23 @@ //! 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 only normalized statement tokens that -//! change the effective role and rewrites executor-relative `GRANTED BY` -//! pseudo-targets to validation-only provenance identities. +//! change current/session authorization 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"; -/// Effective PostgreSQL role identity visible to `CURRENT_USER` / `CURRENT_ROLE`. +/// PostgreSQL role identity used by one executor-authority slot. /// -/// Initial and session identities remain separate because PostgreSQL can start -/// a connection with a role setting distinct from `SESSION_USER`. Named roles -/// are exact normalized identities. Unknown targets are scoped to the `SET ROLE` -/// statement that introduced them so later statements cannot accidentally -/// alias them to a different executor role. +/// 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, - SessionUser, + AuthenticatedUser, Named(String), Unknown(usize), } @@ -28,8 +28,8 @@ 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. Unknown and - /// connection-relative identities use an `@` terminator, which PostgreSQL + /// 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 { @@ -37,7 +37,9 @@ impl EffectiveRoleProjection { Self::InitialCurrentUser => { format!("{EXECUTOR_GRANTOR_PREFIX}initial_current_user@") } - Self::SessionUser => format!("{EXECUTOR_GRANTOR_PREFIX}session_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}@") @@ -46,16 +48,43 @@ impl EffectiveRoleProjection { } } +/// Current and session user projections for PostgreSQL executor-relative grantors. +/// +/// PostgreSQL allows `SET ROLE` to change only the current user, while +/// `SET SESSION AUTHORIZATION` changes both session and current users. Keeping +/// both slots prevents `GRANTED BY SESSION_USER` from collapsing rows across a +/// session-authorization change and lets `SET ROLE NONE` restore the then-current +/// session user rather than a fixed process-wide sentinel. +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExecutorRoleState { + current_role: EffectiveRoleProjection, + session_role: EffectiveRoleProjection, +} + +impl ExecutorRoleState { + /// Start with distinct opaque identities for connection-time current role and authenticated user. + /// + /// They are intentionally not assumed equal because PostgreSQL can have a + /// connection-time `role` setting that `RESET ROLE` restores independently + /// from the authenticated/session user restored by session-authorization reset. + const fn initial() -> Self { + Self { + current_role: EffectiveRoleProjection::InitialCurrentUser, + session_role: EffectiveRoleProjection::AuthenticatedUser, + } + } +} + /// Project executor-relative grantors onto stable validation-only identities. /// /// PostgreSQL records the role denoted by `GRANTED BY`, not the literal text of -/// `CURRENT_USER` or `CURRENT_ROLE`. `SET ROLE` can therefore make identical -/// pseudo-target spellings refer to different `pg_auth_members.grantor` rows in -/// one migration. `SESSION_USER` is connection-stable and intentionally kept -/// separate from the mutable effective role. Unknown quoted/string role targets -/// receive a statement-local opaque identity instead of donating false revoke -/// evidence. The returned SQL is consumed only by the grantor provenance -/// validator; executable migration SQL is unchanged. +/// `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. Identical pseudo-target spellings can therefore +/// refer to different `pg_auth_members.grantor` rows in one migration. Unknown +/// quoted/string authorization targets receive statement-scoped opaque identity +/// instead of donating false revoke evidence. The returned SQL is consumed only +/// by the grantor provenance validator; executable migration SQL is unchanged. pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { if sql.contains(EXECUTOR_GRANTOR_PREFIX) { return None; @@ -64,7 +93,7 @@ pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { let tokenized = sql.replace(';', " ; ").replace(',', " , "); let tokens = tokenized.split_whitespace().collect::>(); let mut output = Vec::::with_capacity(tokens.len()); - let mut current_role = EffectiveRoleProjection::InitialCurrentUser; + let mut state = ExecutorRoleState::initial(); let mut statement_index = 0usize; let mut index = 0usize; @@ -75,8 +104,8 @@ pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { .map(|token| (*token).to_owned()) .collect::>(); - update_effective_role(&statement, statement_index, &mut current_role); - rewrite_granted_by_pseudo_target(&mut statement, ¤t_role); + update_executor_role_state(&statement, statement_index, &mut state); + rewrite_granted_by_pseudo_target(&mut statement, &state); output.extend(statement); if end < tokens.len() { output.push(";".to_owned()); @@ -97,12 +126,39 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { .map_or(tokens.len(), |relative| start + relative) } -/// Update the effective current-role projection for one normalized statement. -fn update_effective_role( +/// Update PostgreSQL current/session authorization state for one normalized statement. +/// +/// `SET SESSION AUTHORIZATION` is evaluated before the narrower `SET ROLE` +/// grammar because it owns both identity slots. `RESET SESSION AUTHORIZATION` +/// and `... DEFAULT` restore the originally authenticated identity. `RESET ROLE` +/// intentionally returns to the opaque connection-time current-role setting, +/// whereas `SET ROLE NONE` copies the current session identity. +fn update_executor_role_state( statement: &[String], statement_index: usize, - current_role: &mut EffectiveRoleProjection, + state: &mut ExecutorRoleState, ) { + if is_reset_session_authorization(statement) { + let authenticated = EffectiveRoleProjection::AuthenticatedUser; + state.session_role = authenticated.clone(); + state.current_role = authenticated; + return; + } + + if let Some(target_index) = session_authorization_target_index(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.session_role = projection.clone(); + state.current_role = projection; + return; + } + if statement .first() .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) @@ -110,7 +166,7 @@ fn update_effective_role( .get(1) .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) { - *current_role = EffectiveRoleProjection::InitialCurrentUser; + state.current_role = EffectiveRoleProjection::InitialCurrentUser; return; } @@ -142,15 +198,69 @@ fn update_effective_role( }; if target.eq_ignore_ascii_case("NONE") { - *current_role = EffectiveRoleProjection::SessionUser; - } else if role_target_is_statically_named(target) { - *current_role = EffectiveRoleProjection::Named(target.to_ascii_lowercase()); + state.current_role = state.session_role.clone(); } else { - *current_role = EffectiveRoleProjection::Unknown(statement_index); + state.current_role = target_role_projection(target, statement_index); } } -/// Return whether the normalized SET ROLE target still carries a plain role identity. +/// Return the target index for PostgreSQL `SET [SESSION|LOCAL] SESSION AUTHORIZATION`. +fn session_authorization_target_index(statement: &[String]) -> Option { + 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); + } + + if statement.get(1).is_some_and(|token| { + token.eq_ignore_ascii_case("SESSION") || 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); + } + + 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")) +} + +/// 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('\'') @@ -159,10 +269,7 @@ fn role_target_is_statically_named(target: &str) -> bool { } /// Replace pseudo-target spellings only in an explicit trailing `GRANTED BY` slot. -fn rewrite_granted_by_pseudo_target( - statement: &mut [String], - current_role: &EffectiveRoleProjection, -) { +fn rewrite_granted_by_pseudo_target(statement: &mut [String], state: &ExecutorRoleState) { let mut index = 0usize; while index + 2 < statement.len() { if statement[index].eq_ignore_ascii_case("GRANTED") @@ -171,9 +278,9 @@ fn rewrite_granted_by_pseudo_target( let replacement = if statement[index + 2].eq_ignore_ascii_case("CURRENT_USER") || statement[index + 2].eq_ignore_ascii_case("CURRENT_ROLE") { - Some(current_role.provenance_token()) + Some(state.current_role.provenance_token()) } else if statement[index + 2].eq_ignore_ascii_case("SESSION_USER") { - Some(EffectiveRoleProjection::SessionUser.provenance_token()) + Some(state.session_role.provenance_token()) } else { None }; @@ -191,7 +298,7 @@ mod tests { use super::project_executor_relative_grantors; #[test] - fn current_user_follows_set_role_while_session_user_stays_stable() { + 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;", ) @@ -202,6 +309,42 @@ mod tests { 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 reserved_projection_prefix_fails_closed() { assert!( From d1b19b4c9cd290c0ff6a6d55493576f6b4078994 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:02:07 +0900 Subject: [PATCH 189/309] test(persistence): expose SET LOCAL grantor scope leak --- ...role_grantor_local_transaction_contract.rs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_grantor_local_transaction_contract.rs 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(())); +} From 96fb22c6de9433352458c3b71286d821c1442fcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:03:24 +0900 Subject: [PATCH 190/309] fix(persistence): restore SET LOCAL grantor transaction scope --- .../src/migration_runtime_role_executor.rs | 434 +++++++++++++++--- 1 file changed, 360 insertions(+), 74 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_executor.rs b/crates/persistence_postgres/src/migration_runtime_role_executor.rs index 250e98855..0b4e7bebc 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_executor.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -2,9 +2,9 @@ //! //! 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 only normalized statement tokens that -//! change current/session authorization and rewrites executor-relative -//! `GRANTED BY` pseudo-targets to validation-only provenance identities. +//! 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"; @@ -48,31 +48,151 @@ impl EffectiveRoleProjection { } } -/// Current and session user projections for PostgreSQL executor-relative grantors. +/// Session-level `role` setting used to derive PostgreSQL `CURRENT_USER`. /// -/// PostgreSQL allows `SET ROLE` to change only the current user, while -/// `SET SESSION AUTHORIZATION` changes both session and current users. Keeping -/// both slots prevents `GRANTED BY SESSION_USER` from collapsing rows across a -/// session-authorization change and lets `SET ROLE NONE` restore the then-current -/// session user rather than a fixed process-wide sentinel. +/// `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 { - current_role: EffectiveRoleProjection, session_role: EffectiveRoleProjection, + current_setting: CurrentRoleSetting, + local_session_role: Option, + local_current_setting: Option, + transaction_baseline: Option, + savepoint_uncertain: bool, } impl ExecutorRoleState { - /// Start with distinct opaque identities for connection-time current role and authenticated user. - /// - /// They are intentionally not assumed equal because PostgreSQL can have a - /// connection-time `role` setting that `RESET ROLE` restores independently - /// from the authenticated/session user restored by session-authorization reset. - const fn initial() -> Self { + /// Start with separate opaque identities for startup current role and authenticated user. + fn initial() -> Self { Self { - current_role: EffectiveRoleProjection::InitialCurrentUser, 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. @@ -80,11 +200,11 @@ impl ExecutorRoleState { /// 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. Identical pseudo-target spellings can therefore -/// refer to different `pg_auth_members.grantor` rows in one migration. Unknown -/// quoted/string authorization targets receive statement-scoped opaque identity -/// instead of donating false revoke evidence. The returned SQL is consumed only -/// by the grantor provenance validator; executable migration SQL is unchanged. +/// 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; @@ -105,7 +225,7 @@ pub(super) fn project_executor_relative_grantors(sql: &str) -> Option { .collect::>(); update_executor_role_state(&statement, statement_index, &mut state); - rewrite_granted_by_pseudo_target(&mut statement, &state); + rewrite_granted_by_pseudo_target(&mut statement, statement_index, &state); output.extend(statement); if end < tokens.len() { output.push(";".to_owned()); @@ -126,26 +246,39 @@ fn statement_end(tokens: &[&str], start: usize) -> usize { .map_or(tokens.len(), |relative| start + relative) } -/// Update PostgreSQL current/session authorization state for one normalized statement. -/// -/// `SET SESSION AUTHORIZATION` is evaluated before the narrower `SET ROLE` -/// grammar because it owns both identity slots. `RESET SESSION AUTHORIZATION` -/// and `... DEFAULT` restore the originally authenticated identity. `RESET ROLE` -/// intentionally returns to the opaque connection-time current-role setting, -/// whereas `SET ROLE NONE` copies the current session identity. +/// 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) { - let authenticated = EffectiveRoleProjection::AuthenticatedUser; - state.session_role = authenticated.clone(); - state.current_role = authenticated; + state.set_session_authorization(EffectiveRoleProjection::AuthenticatedUser, false); return; } - if let Some(target_index) = session_authorization_target_index(statement) { + if let Some((target_index, local)) = session_authorization_target(statement) { let Some(target) = statement.get(target_index) else { return; }; @@ -154,8 +287,7 @@ fn update_executor_role_state( } else { target_role_projection(target, statement_index) }; - state.session_role = projection.clone(); - state.current_role = projection; + state.set_session_authorization(projection, local); return; } @@ -166,46 +298,90 @@ fn update_executor_role_state( .get(1) .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) { - state.current_role = EffectiveRoleProjection::InitialCurrentUser; + state.set_role(CurrentRoleSetting::ConnectionDefault, false); return; } - if !statement - .first() - .is_some_and(|token| token.eq_ignore_ascii_case("SET")) - { + let Some((target_index, local)) = role_target(statement) else { return; - } - - let role_target_index = if statement - .get(1) - .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) - { - Some(2usize) - } else if statement.get(1).is_some_and(|token| { - token.eq_ignore_ascii_case("SESSION") || token.eq_ignore_ascii_case("LOCAL") - }) && statement - .get(2) - .is_some_and(|token| token.eq_ignore_ascii_case("ROLE")) - { - Some(3usize) - } else { - None }; - - let Some(target) = role_target_index.and_then(|target_index| statement.get(target_index)) else { + 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); +} - if target.eq_ignore_ascii_case("NONE") { - state.current_role = state.session_role.clone(); +/// 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") { + if statement.get(1).is_some_and(|token| { + token.eq_ignore_ascii_case("TO") || token.eq_ignore_ascii_case("PREPARED") + }) { + return None; + } + false } else { - state.current_role = target_role_projection(target, statement_index); - } + return None; + }; + + let and_chain = statement + .windows(2) + .any(|pair| pair[0].eq_ignore_ascii_case("AND") && pair[1].eq_ignore_ascii_case("CHAIN")) + || statement.windows(3).any(|triple| { + triple[0].eq_ignore_ascii_case("AND") + && triple[1].eq_ignore_ascii_case("NO") + && triple[2].eq_ignore_ascii_case("CHAIN") + }); + Some((commit, and_chain)) } -/// Return the target index for PostgreSQL `SET [SESSION|LOCAL] SESSION AUTHORIZATION`. -fn session_authorization_target_index(statement: &[String]) -> Option { +/// 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")) @@ -220,19 +396,33 @@ fn session_authorization_target_index(statement: &[String]) -> Option { .get(2) .is_some_and(|token| token.eq_ignore_ascii_case("AUTHORIZATION")) { - return Some(3); + return Some((3, false)); } - if statement.get(1).is_some_and(|token| { - token.eq_ignore_ascii_case("SESSION") || token.eq_ignore_ascii_case("LOCAL") - }) && statement - .get(2) + 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); + return Some((4, false)); } None @@ -251,6 +441,42 @@ fn is_reset_session_authorization(statement: &[String]) -> bool { .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) { @@ -269,7 +495,16 @@ fn role_target_is_statically_named(target: &str) -> bool { } /// Replace pseudo-target spellings only in an explicit trailing `GRANTED BY` slot. -fn rewrite_granted_by_pseudo_target(statement: &mut [String], state: &ExecutorRoleState) { +/// +/// 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") @@ -278,9 +513,17 @@ fn rewrite_granted_by_pseudo_target(statement: &mut [String], state: &ExecutorRo let replacement = if statement[index + 2].eq_ignore_ascii_case("CURRENT_USER") || statement[index + 2].eq_ignore_ascii_case("CURRENT_ROLE") { - Some(state.current_role.provenance_token()) + 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") { - Some(state.session_role.provenance_token()) + if state.savepoint_uncertain { + Some(EffectiveRoleProjection::Unknown(statement_index).provenance_token()) + } else { + Some(state.active_session_role().provenance_token()) + } } else { None }; @@ -345,6 +588,49 @@ mod tests { ); } + #[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 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!( From 51d9d4a08924a107cc2ddb35c3784235a3e64669 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:04:21 +0900 Subject: [PATCH 191/309] test(persistence): expose rolled-back membership safety evidence --- ...ntime_role_transaction_outcome_contract.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_runtime_role_transaction_outcome_contract.rs 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(())); +} From 7675c39277ee21aaf20f543ab714a5dec9f1d3a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:06:18 +0900 Subject: [PATCH 192/309] fix(persistence): project committed membership evidence --- .../src/migration_transaction_projection.rs | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_transaction_projection.rs 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..ea1a02114 --- /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` flush 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); + } + } +} From 26600c4f3dc918e7e649b91da9cafc66a2bd5dfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:06:43 +0900 Subject: [PATCH 193/309] fix(persistence): filter rolled-back membership evidence --- .../src/migration_core.rs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 6e63a8405..ef2fc1d1b 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -15,6 +15,8 @@ mod runtime_role_executor; 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; pub use implementation::MigrationCatalog; @@ -25,17 +27,18 @@ pub use implementation::MigrationCatalog; /// `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 and grantor provenance are checked on the normalized -/// validation copy. Executor-relative `GRANTED BY CURRENT_USER` / `CURRENT_ROLE` -/// evidence is first projected through normalized `SET ROLE` state so identical -/// pseudo-target spellings cannot collapse distinct PostgreSQL grantor rows. +/// 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. /// /// # 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. +/// membership path or transaction outcome. pub fn validate_migration_catalog( catalog: &MigrationCatalog, ) -> Result<(), MigrationContractError> { @@ -44,9 +47,21 @@ pub fn validate_migration_catalog( else { return Err(MigrationContractError::MissingAppRuntimeRole); }; + let Some(committed_membership_sql) = + transaction_projection::project_committed_statements(catalog.up_sql()) + else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; + let Some(committed_grantor_sql) = + transaction_projection::project_committed_statements(&grantor_sql) + else { + return Err(MigrationContractError::MissingAppRuntimeRole); + }; - if !runtime_role_membership::runtime_membership_is_rls_safe(catalog.up_sql()) - || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe(&grantor_sql) + if !runtime_role_membership::runtime_membership_is_rls_safe(&committed_membership_sql) + || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe( + &committed_grantor_sql, + ) { return Err(MigrationContractError::MissingAppRuntimeRole); } From 043e8d7e13bc584456a85a617aa913af934ec5e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:08:44 +0900 Subject: [PATCH 194/309] test(persistence): expose rolled-back lifecycle and DDL evidence --- ...ration_transaction_final_state_contract.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs 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..5176fbe7b --- /dev/null +++ b/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs @@ -0,0 +1,58 @@ +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" + ); +} From 8d8a26a89ca0dc53b6ecbad67fc8a187d917a302 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:08:55 +0900 Subject: [PATCH 195/309] test(persistence): park final-state transaction RED --- ...ration_transaction_final_state_contract.rs | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs diff --git a/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs b/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs deleted file mode 100644 index 5176fbe7b..000000000 --- a/crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs +++ /dev/null @@ -1,58 +0,0 @@ -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" - ); -} From a954ccf901a62e35343d2d3ae2fd06c9fd6b0e46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:12:38 +0900 Subject: [PATCH 196/309] fix(persistence): honor transaction NO CHAIN scope --- .../src/migration_runtime_role_executor.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration_runtime_role_executor.rs b/crates/persistence_postgres/src/migration_runtime_role_executor.rs index 0b4e7bebc..9ff923614 100644 --- a/crates/persistence_postgres/src/migration_runtime_role_executor.rs +++ b/crates/persistence_postgres/src/migration_runtime_role_executor.rs @@ -358,7 +358,7 @@ fn transaction_end(statement: &[String]) -> Option<(bool, bool)> { true } else if first.eq_ignore_ascii_case("END") { true - } else if first.eq_ignore_ascii_case("ROLLBACK") { + } 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") }) { @@ -371,12 +371,7 @@ fn transaction_end(statement: &[String]) -> Option<(bool, bool)> { let and_chain = statement .windows(2) - .any(|pair| pair[0].eq_ignore_ascii_case("AND") && pair[1].eq_ignore_ascii_case("CHAIN")) - || statement.windows(3).any(|triple| { - triple[0].eq_ignore_ascii_case("AND") - && triple[1].eq_ignore_ascii_case("NO") - && triple[2].eq_ignore_ascii_case("CHAIN") - }); + .any(|pair| pair[0].eq_ignore_ascii_case("AND") && pair[1].eq_ignore_ascii_case("CHAIN")); Some((commit, and_chain)) } @@ -599,6 +594,17 @@ mod tests { 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( From 88b5d02b1fb5cb804eb5a2b14882aa2cd823ba41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:16:16 +0900 Subject: [PATCH 197/309] fix(persistence): validate structural contracts on committed state --- .../src/migration_core.rs | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index ef2fc1d1b..832aed239 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -21,6 +21,16 @@ mod transaction_projection; use crate::MigrationContractError; 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) +} + /// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. /// /// `UNLOGGED`, `TEMP`/`TEMPORARY`, and PostgreSQL's compatibility @@ -31,7 +41,10 @@ pub use implementation::MigrationCatalog; /// 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. +/// later rollback cannot donate false membership or revocation evidence. The +/// structural validator receives the same committed final-state projection, so +/// rolled-back DDL cannot satisfy naming, tenant, temporal, RLS, or governance +/// contracts. /// /// # Errors /// @@ -47,18 +60,17 @@ pub fn validate_migration_catalog( else { return Err(MigrationContractError::MissingAppRuntimeRole); }; - let Some(committed_membership_sql) = - transaction_projection::project_committed_statements(catalog.up_sql()) - else { + let Some(committed_up) = project_committed_sql(catalog.up_sql()) else { return Err(MigrationContractError::MissingAppRuntimeRole); }; - let Some(committed_grantor_sql) = - transaction_projection::project_committed_statements(&grantor_sql) - else { + 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 !runtime_role_membership::runtime_membership_is_rls_safe(&committed_membership_sql) + if !runtime_role_membership::runtime_membership_is_rls_safe(&committed_up) || !runtime_role_grantor::runtime_membership_grantors_are_rls_safe( &committed_grantor_sql, ) @@ -66,11 +78,8 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); } - let canonical_up = canonicalize_table_persistence_modifiers(catalog.up_sql()); - if canonical_up == catalog.up_sql() { - return implementation::validate_migration_catalog(catalog); - } - let canonical_catalog = MigrationCatalog::from_sql(&canonical_up, catalog.down_sql()); + 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) } @@ -196,7 +205,7 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { #[cfg(test)] mod tests { - use super::canonicalize_table_persistence_modifiers; + use super::{canonicalize_table_persistence_modifiers, project_committed_sql}; #[test] fn table_modifier_canonicalization_preserves_the_declared_name_and_body() { @@ -217,4 +226,14 @@ mod tests { 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")); + } } From e313a497e8c88792fbc0f9e6901ff1cb33faffe1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:16:30 +0900 Subject: [PATCH 198/309] fix(persistence): fold role lifecycle on committed state --- .../src/migration_validation.rs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index c31c0f895..899932199 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -18,18 +18,25 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql_with_grantor_identity(sql) } -/// Return whether the expected runtime role exists in the final migration state -/// and remains subject to PostgreSQL row-level security. +/// 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. +/// 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. pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { let lifecycle_sql = preserve_quoted_special_role_specifications(sql); - implementation::declares_created_role(&lifecycle_sql, expected_role) + let normalized_lifecycle = implementation::normalize_migration_sql_with_grantor_identity( + &lifecycle_sql, + )?; + let committed_lifecycle = super::core::project_committed_sql(&normalized_lifecycle)?; + implementation::declares_created_role(&committed_lifecycle, expected_role) } /// Detect whether normalized migration SQL declares an RLS surface. @@ -63,6 +70,17 @@ mod tests { 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 grantor_identity_comes_from_the_shared_lexical_authority() { let normalized = normalize_migration_sql( From 6b8edb21705926051d86295478435f612b4d6885 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:16:40 +0900 Subject: [PATCH 199/309] test(persistence): reattach transaction final-state contract --- ...ration_transaction_final_state_contract.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_transaction_final_state_contract.rs 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" + ); +} From fd822f1f394fc31ff1e17b19708ba13121f2bbdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:18:13 +0900 Subject: [PATCH 200/309] test(persistence): expose rolled-back RLS policy evidence --- ...on_rls_transaction_final_state_contract.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs 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..a32639f44 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs @@ -0,0 +1,37 @@ +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; + +#[test] +fn rolled_back_permissive_policy_cannot_cover_surviving_restrictive_policy() { + let up_sql = 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), '') + ); + ROLLBACK; + "#; + let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::MissingRlsPolicy), + "rolled-back permissive policy must not satisfy final restrictive-policy composition" + ); +} From 86c4d7e538a8389ce454815ff7df72732a825f02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:18:52 +0900 Subject: [PATCH 201/309] fix(persistence): validate RLS composition on committed state --- crates/persistence_postgres/src/migration_core.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 832aed239..474ea165f 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -42,9 +42,9 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { /// 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 receives the same committed final-state projection, so -/// rolled-back DDL cannot satisfy naming, tenant, temporal, RLS, or governance -/// contracts. +/// 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. /// /// # Errors /// @@ -70,6 +70,15 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; + let committed_requires_runtime_role = + super::validation::declares_row_level_security(&committed_up); + 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, From e992ee905f6f6acbb9ad7d7144c2a78e354b037f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:19:05 +0900 Subject: [PATCH 202/309] test(persistence): prove committed RLS policy composition --- ...on_rls_transaction_final_state_contract.rs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) 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 index a32639f44..b5e6a2703 100644 --- a/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_transaction_final_state_contract.rs @@ -1,8 +1,8 @@ use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; -#[test] -fn rolled_back_permissive_policy_cannot_cover_surviving_restrictive_policy() { - let up_sql = r#" +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 @@ -25,13 +25,24 @@ fn rolled_back_permissive_policy_cannot_cover_surviving_restrictive_policy() { USING ( tenant_record_id::text = nullif(current_setting('tepp.current_tenant_record_id', true), '') ); - ROLLBACK; - "#; - let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + {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(())); +} From 51439f3e2cbbb6b09c863b4081a94e6cd8a5f7f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:21:32 +0900 Subject: [PATCH 203/309] test(persistence): expose rolled-back facade policy rejection --- ...tion_rls_rolled_back_rejection_contract.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs 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..ea6cdff2e --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs @@ -0,0 +1,34 @@ +use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; + +#[test] +fn rolled_back_invalid_policy_cannot_reject_valid_final_rls_state() { + let up_sql = 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_rolled_back_bad_policy ON tenant_record + AS PERMISSIVE + FOR SELECT + USING (tenant_record_id IS NOT NULL); + ROLLBACK; + "#; + let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + + assert_eq!( + validate_migration_catalog(&catalog), + Ok(()), + "policy evidence that PostgreSQL rolls back must not reject a valid final tenant-isolation policy set" + ); +} From 608c7bd9b86218c3743b7bc19f9c132ed6afb6d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:22:19 +0900 Subject: [PATCH 204/309] fix(persistence): share transaction-control detection --- .../src/migration_transaction_projection.rs | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/persistence_postgres/src/migration_transaction_projection.rs b/crates/persistence_postgres/src/migration_transaction_projection.rs index ea1a02114..c3283daee 100644 --- a/crates/persistence_postgres/src/migration_transaction_projection.rs +++ b/crates/persistence_postgres/src/migration_transaction_projection.rs @@ -7,11 +7,41 @@ //! ambiguous savepoint/two-phase shapes fail closed instead of donating safety //! evidence to downstream runtime-role validators. +/// Return whether normalized SQL contains top-level transaction-control syntax. +/// +/// The legacy facade performs relational RLS checks before the committed-state +/// core. When any transaction control is present, that precheck must defer to +/// the core so rolled-back policy text can neither certify nor reject final +/// state. This helper reuses the same statement/token authority as projection; +/// callers do not grow a second BEGIN/COMMIT/ROLLBACK parser. +#[must_use] +pub(super) fn contains_transaction_control(sql: &str) -> bool { + let tokenized = sql.replace(';', " ; "); + let tokens = tokenized.split_whitespace().collect::>(); + 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) + || is_transaction_start(statement) + || transaction_end(statement).is_some() + { + return true; + } + } + false +} + /// 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` flush it and `ROLLBACK` / `ABORT` discards it. `AND CHAIN` +/// `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 @@ -157,7 +187,22 @@ fn is_unsupported_transaction_control(statement: &[&str]) -> bool { #[cfg(test)] mod tests { - use super::project_committed_statements; + use super::{contains_transaction_control, project_committed_statements}; + + #[test] + fn transaction_control_detection_uses_the_same_statement_authority() { + assert!(!contains_transaction_control( + "CREATE TABLE tenant_record (tenant_record_id uuid);" + )); + for sql in [ + "BEGIN; COMMIT;", + "START TRANSACTION; ROLLBACK;", + "SAVEPOINT safety;", + "PREPARE TRANSACTION 'tx';", + ] { + assert!(contains_transaction_control(sql)); + } + } #[test] fn rollback_discards_transaction_statements() { From 68b67bd4b4375739959367f38f0b3933de965981 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:22:47 +0900 Subject: [PATCH 205/309] fix(persistence): expose shared transaction-control boundary --- crates/persistence_postgres/src/migration_core.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 474ea165f..c6779e00d 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -31,6 +31,15 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { transaction_projection::project_committed_statements(sql) } +/// Return whether normalized SQL contains top-level transaction control. +/// +/// Transactional migrations defer the legacy facade RLS precheck to this core, +/// where committed final state is available. Keeping detection here prevents the +/// facade from growing a parallel BEGIN/COMMIT/ROLLBACK parser. +pub(super) fn contains_transaction_control(sql: &str) -> bool { + transaction_projection::contains_transaction_control(sql) +} + /// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. /// /// `UNLOGGED`, `TEMP`/`TEMPORARY`, and PostgreSQL's compatibility From 7848bb235510b9eb77f188bc900eb34fae95a0b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:28:23 +0900 Subject: [PATCH 206/309] fix(persistence): run facade RLS checks on committed state --- crates/persistence_postgres/src/migration.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration.rs b/crates/persistence_postgres/src/migration.rs index a43cb899c..8863a9d48 100644 --- a/crates/persistence_postgres/src/migration.rs +++ b/crates/persistence_postgres/src/migration.rs @@ -30,11 +30,13 @@ pub fn validate_migration_catalog( .ok_or(MigrationContractError::EmptyMigrationSql)?; let normalized_down = normalize_catalog_sql(catalog.down_sql()) .ok_or(MigrationContractError::EmptyMigrationSql)?; - let requires_runtime_role = validation::declares_row_level_security(&normalized_up); - if requires_runtime_role && !declares_tenant_session_guc(&normalized_up) { + 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); } - if requires_runtime_role && !tenant_policies_bind_session_guc(&normalized_up) { + if requires_runtime_role && !tenant_policies_bind_session_guc(&committed_up) { return Err(MigrationContractError::MissingRlsPolicy); } let normalized = MigrationCatalog::from_sql(&normalized_up, &normalized_down); @@ -716,7 +718,7 @@ fn normalize_catalog_sql(sql: &str) -> Option { /// Remove PostgreSQL's `CONCURRENTLY` modifier from CREATE INDEX structural syntax. /// -/// The lexical pass has already masked quoted/comment semicolons, so exposing +/// 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. From 715b9a63e587e6607418b461a03d0e43bdb1e630 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:29:19 +0900 Subject: [PATCH 207/309] refactor(persistence): drop obsolete transaction detector --- .../src/migration_transaction_projection.rs | 47 +------------------ 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/crates/persistence_postgres/src/migration_transaction_projection.rs b/crates/persistence_postgres/src/migration_transaction_projection.rs index c3283daee..33c15804a 100644 --- a/crates/persistence_postgres/src/migration_transaction_projection.rs +++ b/crates/persistence_postgres/src/migration_transaction_projection.rs @@ -7,36 +7,6 @@ //! ambiguous savepoint/two-phase shapes fail closed instead of donating safety //! evidence to downstream runtime-role validators. -/// Return whether normalized SQL contains top-level transaction-control syntax. -/// -/// The legacy facade performs relational RLS checks before the committed-state -/// core. When any transaction control is present, that precheck must defer to -/// the core so rolled-back policy text can neither certify nor reject final -/// state. This helper reuses the same statement/token authority as projection; -/// callers do not grow a second BEGIN/COMMIT/ROLLBACK parser. -#[must_use] -pub(super) fn contains_transaction_control(sql: &str) -> bool { - let tokenized = sql.replace(';', " ; "); - let tokens = tokenized.split_whitespace().collect::>(); - 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) - || is_transaction_start(statement) - || transaction_end(statement).is_some() - { - return true; - } - } - false -} - /// Project normalized SQL onto statements whose effects survive transaction outcome. /// /// Statements outside an explicit transaction model PostgreSQL autocommit and @@ -187,22 +157,7 @@ fn is_unsupported_transaction_control(statement: &[&str]) -> bool { #[cfg(test)] mod tests { - use super::{contains_transaction_control, project_committed_statements}; - - #[test] - fn transaction_control_detection_uses_the_same_statement_authority() { - assert!(!contains_transaction_control( - "CREATE TABLE tenant_record (tenant_record_id uuid);" - )); - for sql in [ - "BEGIN; COMMIT;", - "START TRANSACTION; ROLLBACK;", - "SAVEPOINT safety;", - "PREPARE TRANSACTION 'tx';", - ] { - assert!(contains_transaction_control(sql)); - } - } + use super::project_committed_statements; #[test] fn rollback_discards_transaction_statements() { From 48f6f646dd32f5b63bf1ec1261b4659a93929d79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:30:31 +0900 Subject: [PATCH 208/309] refactor(persistence): retire transaction detector facade --- crates/persistence_postgres/src/migration_core.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index c6779e00d..474ea165f 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -31,15 +31,6 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { transaction_projection::project_committed_statements(sql) } -/// Return whether normalized SQL contains top-level transaction control. -/// -/// Transactional migrations defer the legacy facade RLS precheck to this core, -/// where committed final state is available. Keeping detection here prevents the -/// facade from growing a parallel BEGIN/COMMIT/ROLLBACK parser. -pub(super) fn contains_transaction_control(sql: &str) -> bool { - transaction_projection::contains_transaction_control(sql) -} - /// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. /// /// `UNLOGGED`, `TEMP`/`TEMPORARY`, and PostgreSQL's compatibility From c9265e6aa8f84f3ebc196d7c05bd538af16c39ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 06:31:09 +0900 Subject: [PATCH 209/309] test(persistence): distinguish rolled-back and committed invalid policy --- ...tion_rls_rolled_back_rejection_contract.rs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 index ea6cdff2e..fc58b10d4 100644 --- a/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_rolled_back_rejection_contract.rs @@ -1,8 +1,8 @@ -use persistence_postgres::{MigrationCatalog, validate_migration_catalog}; +use persistence_postgres::{MigrationCatalog, MigrationContractError, validate_migration_catalog}; -#[test] -fn rolled_back_invalid_policy_cannot_reject_valid_final_rls_state() { - let up_sql = r#" +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 @@ -18,17 +18,32 @@ fn rolled_back_invalid_policy_cannot_reject_valid_final_rls_state() { ); BEGIN; - CREATE POLICY tenant_record_rolled_back_bad_policy ON tenant_record + CREATE POLICY tenant_record_transactional_bad_policy ON tenant_record AS PERMISSIVE FOR SELECT USING (tenant_record_id IS NOT NULL); - ROLLBACK; - "#; - let catalog = MigrationCatalog::from_sql(up_sql, "DROP TABLE tenant_record;"); + {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" + ); +} From 78442ad157230f07a7290edb7016df152e5da085 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:00:37 +0900 Subject: [PATCH 210/309] test(persistence): expose stale RLS table-state evidence --- ...igration_rls_table_final_state_contract.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs 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..02b06fbda --- /dev/null +++ b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs @@ -0,0 +1,55 @@ +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 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(())); +} From a1d792abb3e89e3ff2d35e52c03087979ec3cba3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:01:11 +0900 Subject: [PATCH 211/309] fix(persistence): project final RLS table state --- .../src/migration_rls_table_state.rs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 crates/persistence_postgres/src/migration_rls_table_state.rs 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..a8a540377 --- /dev/null +++ b/crates/persistence_postgres/src/migration_rls_table_state.rs @@ -0,0 +1,176 @@ +//! 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; + +#[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`, qualification, and quoted-identity sentinels remain +/// outside this bounded grammar. If such a statement carries an RLS state 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)?; + if table.eq_ignore_ascii_case("ONLY") + || table.eq_ignore_ascii_case("IF") + || table.contains('.') + || table == "," + || !table + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii()) + { + return None; + } + Some(table) +} + +#[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;" + )); + } +} From c6f6ffbb98ded95dfd6cb60112061e907967cf34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:01:35 +0900 Subject: [PATCH 212/309] fix(persistence): enforce final RLS enablement state --- crates/persistence_postgres/src/migration_core.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 474ea165f..6538d188a 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -9,6 +9,8 @@ #[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"] @@ -44,7 +46,9 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { /// 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. +/// 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. /// /// # Errors /// @@ -72,6 +76,11 @@ pub fn validate_migration_catalog( 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); } From fde18222067d791f045c120640b5c5faec9ca00c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:03:30 +0900 Subject: [PATCH 213/309] docs(persistence): explain final RLS state authority --- crates/persistence_postgres/src/migration_rls_table_state.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/persistence_postgres/src/migration_rls_table_state.rs b/crates/persistence_postgres/src/migration_rls_table_state.rs index a8a540377..3b0f50cee 100644 --- a/crates/persistence_postgres/src/migration_rls_table_state.rs +++ b/crates/persistence_postgres/src/migration_rls_table_state.rs @@ -7,6 +7,7 @@ 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, @@ -96,6 +97,7 @@ fn direct_table_target<'a>(statement: &'a [&'a str]) -> Option<&'a str> { Some(table) } +/// One PostgreSQL table-level RLS state mutation in execution order. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum RlsTableAction { Enable, From e9dde8f4d14135b4c0547993fc0998bf37dce0f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:03:38 +0900 Subject: [PATCH 214/309] test(persistence): bind RLS table state to transaction outcome --- .../tests/migration_rls_table_final_state_contract.rs | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 02b06fbda..e74b2fde6 100644 --- a/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs @@ -41,6 +41,14 @@ fn committed_no_force_row_level_security_removes_final_owner_enforcement() { ); } +#[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( From bd8a39e697eb289e5fcf23f0be298b435448ce79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:06:22 +0900 Subject: [PATCH 215/309] test(persistence): expose ALTER POLICY final-state bypass --- ...n_rls_alter_policy_final_state_contract.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_alter_policy_final_state_contract.rs 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(())); +} From d65d02f0633a814e9a83023f11d9f180153efc7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 07:07:12 +0900 Subject: [PATCH 216/309] fix(persistence): fail closed on unmodeled ALTER POLICY state --- .../src/migration_core.rs | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 6538d188a..4daeb5eec 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -33,6 +33,26 @@ 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. Until this +/// bounded validator owns that policy identity/state fold, accepting historical +/// `CREATE POLICY` evidence 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 this marker. +fn contains_unsupported_policy_mutation(sql: &str) -> bool { + sql.split(';').any(|statement| { + let mut tokens = statement.split_whitespace(); + tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("ALTER")) + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) + }) +} + /// Validate migration SQL after canonicalizing PostgreSQL table persistence modifiers. /// /// `UNLOGGED`, `TEMP`/`TEMPORARY`, and PostgreSQL's compatibility @@ -49,6 +69,9 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { /// 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` is temporarily rejected until policy identity and +/// clause replacement have their own final-state authority; a rolled-back ALTER +/// is removed by the transaction projection before this boundary. /// /// # Errors /// @@ -74,6 +97,9 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; + 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 @@ -223,7 +249,10 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { #[cfg(test)] mod tests { - use super::{canonicalize_table_persistence_modifiers, project_committed_sql}; + use super::{ + canonicalize_table_persistence_modifiers, contains_unsupported_policy_mutation, + project_committed_sql, + }; #[test] fn table_modifier_canonicalization_preserves_the_declared_name_and_body() { @@ -254,4 +283,14 @@ mod tests { assert!(projected.contains("CREATE TABLE durable_record")); assert!(!projected.contains("rolled_back_record")); } + + #[test] + fn alter_policy_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( + "SELECT alter_policy_marker ; CREATE POLICY tenant_isolation ON tenant_record USING ( true ) ;" + )); + } } From d30cf599322fb5f9738b53165bf17bd72c34a626 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 08:00:12 +0900 Subject: [PATCH 217/309] test(persistence): expose DROP POLICY final-state bypass --- ...on_rls_drop_policy_final_state_contract.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_rls_drop_policy_final_state_contract.rs 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(())); +} From f8e91e0e9e8a59a657bc9e0439a986636f187f0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 08:01:09 +0900 Subject: [PATCH 218/309] fix(persistence): fail closed on committed DROP POLICY --- .../src/migration_core.rs | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 4daeb5eec..7b6ad79d7 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -36,20 +36,24 @@ pub(super) fn project_committed_sql(sql: &str) -> Option { /// 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. Until this -/// bounded validator owns that policy identity/state fold, accepting historical -/// `CREATE POLICY` evidence 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 this marker. +/// 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(); - tokens + 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("ALTER")) - && tokens - .next() - .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) + .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) }) } @@ -69,9 +73,10 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { /// 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` is temporarily rejected until policy identity and -/// clause replacement have their own final-state authority; a rolled-back ALTER -/// is removed by the transaction projection before this boundary. +/// Committed `ALTER POLICY` and `DROP POLICY` are temporarily rejected until +/// policy identity, clause replacement, and removal have their own final-state +/// authority; a rolled-back mutation is removed by the transaction projection +/// before this boundary. /// /// # Errors /// @@ -285,12 +290,15 @@ mod tests { } #[test] - fn alter_policy_detection_is_statement_and_token_bounded() { + 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 ; CREATE POLICY tenant_isolation ON tenant_record USING ( true ) ;" + "SELECT alter_policy_marker, drop_policy_marker ; CREATE POLICY tenant_isolation ON tenant_record USING ( true ) ;" )); } } From c902e66e3c106fba48270b9b918486c29c28a045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:01:39 +0900 Subject: [PATCH 219/309] test(persistence): expose committed DROP TABLE final-state bypass (#567) --- ...gration_drop_table_final_state_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs 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..69ec0c8aa --- /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::EmptyMigrationSql), + "a committed table removal must invalidate historical CREATE TABLE and dependent RLS evidence" + ); +} + +#[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(())); +} From 3955d852c198a4c2d723a209fb6ef29f408819ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:03:13 +0900 Subject: [PATCH 220/309] fix(persistence): fail closed on committed DROP TABLE (#567) --- .../src/migration_core.rs | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 7b6ad79d7..e5d14f5bc 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -48,12 +48,30 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { let Some(verb) = tokens.next() else { return false; }; - matches!( - verb.to_ascii_uppercase().as_str(), - "ALTER" | "DROP" - ) && tokens + matches!(verb.to_ascii_uppercase().as_str(), "ALTER" | "DROP") + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) + }) +} + +/// Detect a committed table removal not yet owned by the final-table state model. +/// +/// A committed PostgreSQL `DROP TABLE` invalidates the durable table itself and +/// its dependent table-local evidence. Until this bounded validator owns a full +/// create/drop/recreate aggregate, retaining an earlier `CREATE TABLE` as proof +/// would be fail-open. The input has already crossed lexical normalization and +/// transaction projection, so a statement-first `DROP TABLE` token pair is the +/// smallest causal fail-closed boundary; rolled-back removals never reach it. +fn contains_unsupported_table_removal(sql: &str) -> bool { + sql.split(';').any(|statement| { + let mut tokens = statement.split_whitespace(); + tokens .next() - .is_some_and(|token| token.eq_ignore_ascii_case("POLICY")) + .is_some_and(|token| token.eq_ignore_ascii_case("DROP")) + && tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("TABLE")) }) } @@ -75,8 +93,10 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { /// `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; a rolled-back mutation is removed by the transaction projection -/// before this boundary. +/// authority. Committed `DROP TABLE` is likewise rejected until table identity, +/// removal/recreation, multi-target drops, and dependent-object effects are +/// represented by a first-class final-table aggregate. Mutations removed by the +/// transaction projection never reach either bounded fail-closed boundary. /// /// # Errors /// @@ -102,6 +122,9 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; + if contains_unsupported_table_removal(&committed_up) { + return Err(MigrationContractError::EmptyMigrationSql); + } if contains_unsupported_policy_mutation(&committed_up) { return Err(MigrationContractError::MissingRlsPolicy); } @@ -256,7 +279,7 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { mod tests { use super::{ canonicalize_table_persistence_modifiers, contains_unsupported_policy_mutation, - project_committed_sql, + contains_unsupported_table_removal, project_committed_sql, }; #[test] @@ -289,6 +312,19 @@ mod tests { assert!(!projected.contains("rolled_back_record")); } + #[test] + fn table_removal_detection_is_statement_and_token_bounded() { + assert!(contains_unsupported_table_removal( + "DROP\nTABLE tenant_record ;" + )); + assert!(contains_unsupported_table_removal( + "DROP TABLE IF EXISTS tenant_record CASCADE ;" + )); + assert!(!contains_unsupported_table_removal( + "SELECT drop_table_marker ; CREATE TABLE tenant_record ( tenant_record_id uuid ) ;" + )); + } + #[test] fn policy_mutation_detection_is_statement_and_token_bounded() { assert!(contains_unsupported_policy_mutation( From 71c3be46b00402788ed7759497a0652daf00e8a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:07:58 +0900 Subject: [PATCH 221/309] test(persistence): require explicit DROP TABLE final-state error (#567) --- .../tests/migration_drop_table_final_state_contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 69ec0c8aa..2825bb88b 100644 --- a/crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_drop_table_final_state_contract.rs @@ -26,8 +26,8 @@ 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::EmptyMigrationSql), - "a committed table removal must invalidate historical CREATE TABLE and dependent RLS evidence" + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "a committed table removal must fail with an explicit final-state limitation rather than masquerading as empty SQL" ); } From 457698af3db2a80ec3a752209e4bb0f23d723704 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:08:39 +0900 Subject: [PATCH 222/309] fix(persistence): name unsupported table final-state mutation (#567) --- crates/persistence_postgres/src/error.rs | 7 +++++++ 1 file changed, 7 insertions(+) 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" From 9d8ebb3abd1013c1547463c3cb2454b4e99f9fee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 09:09:13 +0900 Subject: [PATCH 223/309] fix(persistence): return explicit DROP TABLE final-state error (#567) --- crates/persistence_postgres/src/migration_core.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index e5d14f5bc..2ef52aa21 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -123,7 +123,7 @@ pub fn validate_migration_catalog( }; if contains_unsupported_table_removal(&committed_up) { - return Err(MigrationContractError::EmptyMigrationSql); + return Err(MigrationContractError::UnsupportedTableFinalStateMutation); } if contains_unsupported_policy_mutation(&committed_up) { return Err(MigrationContractError::MissingRlsPolicy); From fca9deafa774f491aa76c1c8e8db9731682720a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:02:43 +0900 Subject: [PATCH 224/309] test(persistence): pin ALTER TABLE final-state identity gap (#568) --- ...ration_alter_table_final_state_contract.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs 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(())); +} From 9e13e0af0dbb4692fe350f18496c58573ecfbfb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:03:36 +0900 Subject: [PATCH 225/309] fix(persistence): fail closed on committed table identity renames (#568) --- .../src/migration_core.rs | 111 +++++++++++++----- 1 file changed, 82 insertions(+), 29 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 2ef52aa21..84ce8f2d9 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -55,24 +55,70 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { }) } -/// Detect a committed table removal not yet owned by the final-table state model. +/// Return whether one committed statement mutates table identity beyond the bounded final-state model. +/// +/// `DROP TABLE` removes the durable relation outright. PostgreSQL table- and +/// column-level `RENAME` operations also invalidate historical identity evidence +/// even though the relation survives. The parser intentionally consumes only the +/// statement prefix, optional `IF EXISTS` / `ONLY`, the exact table target, and +/// the following action token. This positional boundary keeps a table literally +/// named `rename` from being mistaken for a rename action after lexical quote +/// normalization. +fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool { + let tokens = statement.split_whitespace().collect::>(); + if tokens.len() < 2 { + return false; + } + + if tokens[0].eq_ignore_ascii_case("DROP") && tokens[1].eq_ignore_ascii_case("TABLE") { + return true; + } + if !tokens[0].eq_ignore_ascii_case("ALTER") || !tokens[1].eq_ignore_ascii_case("TABLE") { + return false; + } + + let mut index = 2usize; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("IF")) + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) + { + index += 2; + } + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) + { + index += 1; + } + + if tokens.get(index).is_none() { + return false; + } + index += 1; + if tokens.get(index) == Some(&"*") { + index += 1; + } + + tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) +} + +/// Detect committed table removal or identity mutation not yet owned by the final-table state model. /// /// A committed PostgreSQL `DROP TABLE` invalidates the durable table itself and -/// its dependent table-local evidence. Until this bounded validator owns a full -/// create/drop/recreate aggregate, retaining an earlier `CREATE TABLE` as proof -/// would be fail-open. The input has already crossed lexical normalization and -/// transaction projection, so a statement-first `DROP TABLE` token pair is the -/// smallest causal fail-closed boundary; rolled-back removals never reach it. -fn contains_unsupported_table_removal(sql: &str) -> bool { - sql.split(';').any(|statement| { - let mut tokens = statement.split_whitespace(); - tokens - .next() - .is_some_and(|token| token.eq_ignore_ascii_case("DROP")) - && tokens - .next() - .is_some_and(|token| token.eq_ignore_ascii_case("TABLE")) - }) +/// its dependent table-local evidence. A committed table or column rename makes +/// historical names stale even though the underlying relation remains. Until +/// this bounded validator owns a full create/drop/rename/recreate aggregate, +/// retaining earlier `CREATE TABLE` text as proof would be fail-open. The input +/// has already crossed lexical normalization and transaction projection, so +/// rolled-back mutations never reach this boundary. +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. @@ -93,7 +139,8 @@ fn contains_unsupported_table_removal(sql: &str) -> bool { /// `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` is likewise rejected until table identity, +/// authority. Committed `DROP TABLE` and `ALTER TABLE ... RENAME ...` identity +/// mutations are likewise rejected until table identity, column identity, /// removal/recreation, multi-target drops, and dependent-object effects are /// represented by a first-class final-table aggregate. Mutations removed by the /// transaction projection never reach either bounded fail-closed boundary. @@ -122,7 +169,7 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; - if contains_unsupported_table_removal(&committed_up) { + if contains_unsupported_table_final_state_mutation(&committed_up) { return Err(MigrationContractError::UnsupportedTableFinalStateMutation); } if contains_unsupported_policy_mutation(&committed_up) { @@ -279,7 +326,7 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { mod tests { use super::{ canonicalize_table_persistence_modifiers, contains_unsupported_policy_mutation, - contains_unsupported_table_removal, project_committed_sql, + contains_unsupported_table_final_state_mutation, project_committed_sql, }; #[test] @@ -313,16 +360,22 @@ mod tests { } #[test] - fn table_removal_detection_is_statement_and_token_bounded() { - assert!(contains_unsupported_table_removal( - "DROP\nTABLE tenant_record ;" - )); - assert!(contains_unsupported_table_removal( - "DROP TABLE IF EXISTS tenant_record CASCADE ;" - )); - assert!(!contains_unsupported_table_removal( - "SELECT drop_table_marker ; CREATE TABLE tenant_record ( tenant_record_id uuid ) ;" - )); + fn table_final_state_mutation_detection_is_statement_token_and_position_bounded() { + for sql in [ + "DROP\nTABLE tenant_record ;", + "DROP TABLE IF EXISTS tenant_record CASCADE ;", + "ALTER TABLE tenant_record RENAME TO tenant_record_archive ;", + "ALTER TABLE IF EXISTS ONLY tenant_record RENAME COLUMN tenant_record_id TO tenant_key ;", + ] { + 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 ;", + ] { + assert!(!contains_unsupported_table_final_state_mutation(sql)); + } } #[test] From ed71a71e56d9633bca21dec7e757db64a869cb1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:06:58 +0900 Subject: [PATCH 226/309] test(persistence): pin multi-action ALTER TABLE rename gap (#568) --- .../migration_alter_table_final_state_contract.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index 416b4b4ec..bb1f3382f 100644 --- a/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs @@ -43,11 +43,24 @@ fn committed_tenant_column_rename_cannot_reuse_historical_column_evidence() { ); } +#[test] +fn later_rename_action_in_one_alter_table_statement_cannot_hide_behind_supported_action() { + let catalog = table_catalog( + "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key;", + ); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "PostgreSQL permits multiple ALTER TABLE actions; a supported first action must not hide a later identity mutation" + ); +} + #[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;", + "BEGIN; ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key; ROLLBACK;", ] { let catalog = table_catalog(mutation); assert_eq!(validate_migration_catalog(&catalog), Ok(())); From a8adc9584034e10c7306df8f01f5a12898cedb7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:08:15 +0900 Subject: [PATCH 227/309] fix(persistence): scan every ALTER TABLE rename action (#568) --- .../src/migration_core.rs | 140 ++++++++++++++---- 1 file changed, 110 insertions(+), 30 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 84ce8f2d9..1b8b53b82 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -55,56 +55,132 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { }) } +/// Return the next whitespace-delimited token span beginning at or after `from`. +/// +/// The caller operates only on SQL that already crossed the shared PostgreSQL +/// lexical authority, so this cursor is deliberately structural rather than a +/// second lexer. Byte spans are retained because ALTER TABLE action parsing must +/// continue from the exact end of the target instead of searching for a repeated +/// identifier spelling later in the statement. +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() { + return Some((token_start.expect("token start is set"), from + offset)); + } + } + + token_start.map(|start| (start, sql.len())) +} + +/// Return whether one normalized token span equals 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 an ALTER TABLE action begins with PostgreSQL `RENAME`. +fn alter_table_action_is_rename(action: &str) -> bool { + next_sql_token_span(action, 0).is_some_and(|span| token_span_eq(action, span, "RENAME")) +} + +/// Detect a RENAME action in PostgreSQL's comma-separated ALTER TABLE action list. +/// +/// PostgreSQL permits multiple table alterations in one statement. A supported +/// first action therefore cannot certify a later rename. Commas inside CHECK or +/// expression parentheses and array/subscript brackets are not action separators; +/// lexical comments and literals were already normalized before this boundary. +fn alter_table_actions_include_rename(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 = parenthesis_depth.saturating_add(1), + ')' => parenthesis_depth = parenthesis_depth.saturating_sub(1), + '[' => bracket_depth = bracket_depth.saturating_add(1), + ']' => bracket_depth = bracket_depth.saturating_sub(1), + ',' if parenthesis_depth == 0 && bracket_depth == 0 => { + if alter_table_action_is_rename(&actions[action_start..index]) { + return true; + } + action_start = index + ch.len_utf8(); + } + _ => {} + } + } + + alter_table_action_is_rename(&actions[action_start..]) +} + /// Return whether one committed statement mutates table identity beyond the bounded final-state model. /// /// `DROP TABLE` removes the durable relation outright. PostgreSQL table- and /// column-level `RENAME` operations also invalidate historical identity evidence /// even though the relation survives. The parser intentionally consumes only the /// statement prefix, optional `IF EXISTS` / `ONLY`, the exact table target, and -/// the following action token. This positional boundary keeps a table literally +/// then the bounded action list. This positional boundary keeps a table literally /// named `rename` from being mistaken for a rename action after lexical quote -/// normalization. +/// normalization while still detecting a later RENAME in a multi-action ALTER. fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool { - let tokens = statement.split_whitespace().collect::>(); - if tokens.len() < 2 { + 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 tokens[0].eq_ignore_ascii_case("DROP") && tokens[1].eq_ignore_ascii_case("TABLE") { + if token_span_eq(statement, first, "DROP") && token_span_eq(statement, second, "TABLE") { return true; } - if !tokens[0].eq_ignore_ascii_case("ALTER") || !tokens[1].eq_ignore_ascii_case("TABLE") { + if !token_span_eq(statement, first, "ALTER") || !token_span_eq(statement, second, "TABLE") { return false; } - let mut index = 2usize; - if tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("IF")) - && tokens - .get(index + 1) - .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) - { - index += 2; + let mut cursor = second.1; + let Some(mut target) = next_sql_token_span(statement, cursor) else { + return false; + }; + if token_span_eq(statement, target, "IF") { + let Some(exists) = next_sql_token_span(statement, target.1) else { + return false; + }; + if !token_span_eq(statement, exists, "EXISTS") { + return false; + } + target = match next_sql_token_span(statement, exists.1) { + Some(span) => span, + None => return false, + }; } - if tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) - { - index += 1; + if token_span_eq(statement, target, "ONLY") { + target = match next_sql_token_span(statement, target.1) { + Some(span) => span, + None => return false, + }; } - if tokens.get(index).is_none() { - return false; - } - index += 1; - if tokens.get(index) == Some(&"*") { - index += 1; + cursor = target.1; + if next_sql_token_span(statement, cursor) + .is_some_and(|span| statement.get(span.0..span.1) == Some("*")) + { + cursor = next_sql_token_span(statement, cursor) + .map(|span| span.1) + .unwrap_or(cursor); } - tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) + statement + .get(cursor..) + .is_some_and(alter_table_actions_include_rename) } /// Detect committed table removal or identity mutation not yet owned by the final-table state model. @@ -366,6 +442,8 @@ mod tests { "DROP TABLE IF EXISTS tenant_record CASCADE ;", "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 ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key ;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (tenant_record_id IN (a, b)), RENAME TO tenant_record_archive ;", ] { assert!(contains_unsupported_table_final_state_mutation(sql)); } @@ -373,6 +451,8 @@ mod tests { "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 rename text ;", + "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (some_func(a, rename)) ;", ] { assert!(!contains_unsupported_table_final_state_mutation(sql)); } From 0517e51cf8cbd941b5c50bc8a9e664bc13ff6e29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:09:03 +0900 Subject: [PATCH 228/309] test(persistence): retire invalid multi-action RENAME fixture (#568) --- .../migration_alter_table_final_state_contract.rs | 13 ------------- 1 file changed, 13 deletions(-) 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 index bb1f3382f..416b4b4ec 100644 --- a/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs @@ -43,24 +43,11 @@ fn committed_tenant_column_rename_cannot_reuse_historical_column_evidence() { ); } -#[test] -fn later_rename_action_in_one_alter_table_statement_cannot_hide_behind_supported_action() { - let catalog = table_catalog( - "ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key;", - ); - assert_eq!( - validate_migration_catalog(&catalog), - Err(MigrationContractError::UnsupportedTableFinalStateMutation), - "PostgreSQL permits multiple ALTER TABLE actions; a supported first action must not hide a later identity mutation" - ); -} - #[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;", - "BEGIN; ALTER TABLE tenant_record ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key; ROLLBACK;", ] { let catalog = table_catalog(mutation); assert_eq!(validate_migration_catalog(&catalog), Ok(())); From 157b4d9997762565421029ec9aaa110a358d667f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:09:36 +0900 Subject: [PATCH 229/309] fix(persistence): restore PostgreSQL RENAME form boundary (#568) --- .../src/migration_core.rs | 140 ++++-------------- 1 file changed, 30 insertions(+), 110 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 1b8b53b82..84ce8f2d9 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -55,132 +55,56 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { }) } -/// Return the next whitespace-delimited token span beginning at or after `from`. -/// -/// The caller operates only on SQL that already crossed the shared PostgreSQL -/// lexical authority, so this cursor is deliberately structural rather than a -/// second lexer. Byte spans are retained because ALTER TABLE action parsing must -/// continue from the exact end of the target instead of searching for a repeated -/// identifier spelling later in the statement. -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() { - return Some((token_start.expect("token start is set"), from + offset)); - } - } - - token_start.map(|start| (start, sql.len())) -} - -/// Return whether one normalized token span equals 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 an ALTER TABLE action begins with PostgreSQL `RENAME`. -fn alter_table_action_is_rename(action: &str) -> bool { - next_sql_token_span(action, 0).is_some_and(|span| token_span_eq(action, span, "RENAME")) -} - -/// Detect a RENAME action in PostgreSQL's comma-separated ALTER TABLE action list. -/// -/// PostgreSQL permits multiple table alterations in one statement. A supported -/// first action therefore cannot certify a later rename. Commas inside CHECK or -/// expression parentheses and array/subscript brackets are not action separators; -/// lexical comments and literals were already normalized before this boundary. -fn alter_table_actions_include_rename(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 = parenthesis_depth.saturating_add(1), - ')' => parenthesis_depth = parenthesis_depth.saturating_sub(1), - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - ',' if parenthesis_depth == 0 && bracket_depth == 0 => { - if alter_table_action_is_rename(&actions[action_start..index]) { - return true; - } - action_start = index + ch.len_utf8(); - } - _ => {} - } - } - - alter_table_action_is_rename(&actions[action_start..]) -} - /// Return whether one committed statement mutates table identity beyond the bounded final-state model. /// /// `DROP TABLE` removes the durable relation outright. PostgreSQL table- and /// column-level `RENAME` operations also invalidate historical identity evidence /// even though the relation survives. The parser intentionally consumes only the /// statement prefix, optional `IF EXISTS` / `ONLY`, the exact table target, and -/// then the bounded action list. This positional boundary keeps a table literally +/// the following action token. This positional boundary keeps a table literally /// named `rename` from being mistaken for a rename action after lexical quote -/// normalization while still detecting a later RENAME in a multi-action ALTER. +/// 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 { + let tokens = statement.split_whitespace().collect::>(); + if tokens.len() < 2 { return false; - }; + } - if token_span_eq(statement, first, "DROP") && token_span_eq(statement, second, "TABLE") { + if tokens[0].eq_ignore_ascii_case("DROP") && tokens[1].eq_ignore_ascii_case("TABLE") { return true; } - if !token_span_eq(statement, first, "ALTER") || !token_span_eq(statement, second, "TABLE") { + if !tokens[0].eq_ignore_ascii_case("ALTER") || !tokens[1].eq_ignore_ascii_case("TABLE") { return false; } - let mut cursor = second.1; - let Some(mut target) = next_sql_token_span(statement, cursor) else { - return false; - }; - if token_span_eq(statement, target, "IF") { - let Some(exists) = next_sql_token_span(statement, target.1) else { - return false; - }; - if !token_span_eq(statement, exists, "EXISTS") { - return false; - } - target = match next_sql_token_span(statement, exists.1) { - Some(span) => span, - None => return false, - }; + let mut index = 2usize; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("IF")) + && tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) + { + index += 2; } - if token_span_eq(statement, target, "ONLY") { - target = match next_sql_token_span(statement, target.1) { - Some(span) => span, - None => return false, - }; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) + { + index += 1; } - cursor = target.1; - if next_sql_token_span(statement, cursor) - .is_some_and(|span| statement.get(span.0..span.1) == Some("*")) - { - cursor = next_sql_token_span(statement, cursor) - .map(|span| span.1) - .unwrap_or(cursor); + if tokens.get(index).is_none() { + return false; + } + index += 1; + if tokens.get(index) == Some(&"*") { + index += 1; } - statement - .get(cursor..) - .is_some_and(alter_table_actions_include_rename) + tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) } /// Detect committed table removal or identity mutation not yet owned by the final-table state model. @@ -442,8 +366,6 @@ mod tests { "DROP TABLE IF EXISTS tenant_record CASCADE ;", "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 ENABLE ROW LEVEL SECURITY, RENAME COLUMN tenant_record_id TO tenant_key ;", - "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (tenant_record_id IN (a, b)), RENAME TO tenant_record_archive ;", ] { assert!(contains_unsupported_table_final_state_mutation(sql)); } @@ -451,8 +373,6 @@ mod tests { "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 rename text ;", - "ALTER TABLE tenant_record ADD CONSTRAINT tenant_record_shape CHECK (some_func(a, rename)) ;", ] { assert!(!contains_unsupported_table_final_state_mutation(sql)); } From 25cbb84233bca6ddbeedcae3da30aa55356d63de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:10:26 +0900 Subject: [PATCH 230/309] test(persistence): pin ALTER TABLE DROP final-state gap (#569) --- ...n_alter_table_drop_final_state_contract.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_alter_table_drop_final_state_contract.rs 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(())); +} From 677cb7fd1f3402e962207dd65b11f7c879ae5e74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 10:11:16 +0900 Subject: [PATCH 231/309] fix(persistence): fail closed on ALTER TABLE DROP actions (#569) --- .../src/migration_core.rs | 194 +++++++++++++----- 1 file changed, 146 insertions(+), 48 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 84ce8f2d9..bf99e9a14 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -55,67 +55,160 @@ fn contains_unsupported_policy_mutation(sql: &str) -> bool { }) } -/// Return whether one committed statement mutates table identity beyond the bounded final-state model. +/// Return the next whitespace-delimited token span beginning at or after `from`. /// -/// `DROP TABLE` removes the durable relation outright. PostgreSQL table- and -/// column-level `RENAME` operations also invalidate historical identity evidence -/// even though the relation survives. The parser intentionally consumes only the -/// statement prefix, optional `IF EXISTS` / `ONLY`, the exact table target, and -/// the following action token. This positional boundary keeps a table literally -/// named `rename` from being mistaken for a rename action after lexical quote -/// normalization. -fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool { - let tokens = statement.split_whitespace().collect::>(); - if tokens.len() < 2 { - return false; +/// 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)); + } + } } - if tokens[0].eq_ignore_ascii_case("DROP") && tokens[1].eq_ignore_ascii_case("TABLE") { + 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 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")) +} + +/// Detect destructive DROP 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. Unbalanced delimiters +/// fail closed because malformed structure cannot prove the absence of a later +/// destructive action. +fn alter_table_actions_include_drop(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 => { + if alter_table_action_starts_with_drop(&actions[action_start..index]) { + return true; + } + action_start = index + ch.len_utf8(); + } + _ => {} + } + } + + if parenthesis_depth != 0 || bracket_depth != 0 { return true; } - if !tokens[0].eq_ignore_ascii_case("ALTER") || !tokens[1].eq_ignore_ascii_case("TABLE") { + alter_table_action_starts_with_drop(&actions[action_start..]) +} + +/// Return whether one committed statement mutates table final state beyond the bounded model. +/// +/// `DROP TABLE` removes the durable relation. 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`; every top-level action is +/// inspected so an additive first action cannot hide a later destructive one. +/// 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; + }; - let mut index = 2usize; - if tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("IF")) - && tokens - .get(index + 1) - .is_some_and(|token| token.eq_ignore_ascii_case("EXISTS")) - { - index += 2; + if token_span_eq(statement, first, "DROP") && token_span_eq(statement, second, "TABLE") { + return true; } - if tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) - { - index += 1; + if !token_span_eq(statement, first, "ALTER") || !token_span_eq(statement, second, "TABLE") { + return false; } - if tokens.get(index).is_none() { + let mut cursor = second.1; + let Some(mut target) = next_sql_token_span(statement, cursor) else { return false; + }; + if token_span_eq(statement, target, "IF") { + let Some(exists) = next_sql_token_span(statement, target.1) else { + return true; + }; + if !token_span_eq(statement, exists, "EXISTS") { + return true; + } + target = match next_sql_token_span(statement, exists.1) { + Some(span) => span, + None => return true, + }; } - index += 1; - if tokens.get(index) == Some(&"*") { - index += 1; + if token_span_eq(statement, target, "ONLY") { + target = match next_sql_token_span(statement, target.1) { + Some(span) => span, + None => return true, + }; + } + + cursor = target.1; + if let Some(star) = next_sql_token_span(statement, cursor) { + if statement.get(star.0..star.1) == Some("*") { + cursor = star.1; + } } - tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("RENAME")) + let Some(actions) = statement.get(cursor..) else { + 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_drop(actions) } -/// Detect committed table removal or identity mutation not yet owned by the final-table state model. +/// Detect committed table removals or identity/destructive mutations not yet owned by final-table state. /// -/// A committed PostgreSQL `DROP TABLE` invalidates the durable table itself and -/// its dependent table-local evidence. A committed table or column rename makes -/// historical names stale even though the underlying relation remains. Until -/// this bounded validator owns a full create/drop/rename/recreate aggregate, -/// retaining earlier `CREATE TABLE` text as proof would be fail-open. The input -/// has already crossed lexical normalization and transaction projection, so -/// rolled-back mutations never reach this boundary. +/// 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) @@ -139,9 +232,9 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// `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` and `ALTER TABLE ... RENAME ...` identity -/// mutations are likewise rejected until table identity, column identity, -/// removal/recreation, multi-target drops, and dependent-object effects are +/// authority. Committed `DROP TABLE`, standalone table/column rename forms, and +/// destructive ALTER TABLE DROP actions are likewise rejected until table, +/// column, constraint, removal/recreation, and dependent-object effects are /// represented by a first-class final-table aggregate. Mutations removed by the /// transaction projection never reach either bounded fail-closed boundary. /// @@ -360,12 +453,14 @@ mod tests { } #[test] - fn table_final_state_mutation_detection_is_statement_token_and_position_bounded() { + 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 ;", "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 ;", ] { assert!(contains_unsupported_table_final_state_mutation(sql)); } @@ -373,6 +468,9 @@ mod tests { "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])) ;", ] { assert!(!contains_unsupported_table_final_state_mutation(sql)); } From 2088b165e1bdaa97f636e3263b71f78d27a416bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:03:03 +0900 Subject: [PATCH 232/309] test(persistence): reject unsafe ALTER TABLE ADD COLUMN names (#570) --- ..._alter_table_add_column_naming_contract.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_alter_table_add_column_naming_contract.rs 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}" + ); + } +} From e94f82d0fbb2ef7aa497824519fe9418b844cf87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:15:59 +0900 Subject: [PATCH 233/309] fix(persistence): enforce ALTER TABLE ADD column naming (#570) --- .../src/migration_core.rs | 200 +++++++++++++++--- 1 file changed, 170 insertions(+), 30 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index bf99e9a14..f90311c1c 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -21,6 +21,7 @@ mod runtime_role_membership; 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. @@ -136,62 +137,172 @@ fn alter_table_actions_include_drop(actions: &str) -> bool { alter_table_action_starts_with_drop(&actions[action_start..]) } -/// Return whether one committed statement mutates table final state beyond the bounded model. +/// Return the committed ALTER TABLE action list after its target relation. /// -/// `DROP TABLE` removes the durable relation. 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`; every top-level action is -/// inspected so an additive first action cannot hide a later destructive one. -/// 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 { +/// `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 false; + return Ok(None); }; let Some(second) = next_sql_token_span(statement, first.1) else { - return false; + return Ok(None); }; - - if token_span_eq(statement, first, "DROP") && token_span_eq(statement, second, "TABLE") { - return true; - } if !token_span_eq(statement, first, "ALTER") || !token_span_eq(statement, second, "TABLE") { - return false; + return Ok(None); } let mut cursor = second.1; let Some(mut target) = next_sql_token_span(statement, cursor) else { - return false; + return Err(()); }; if token_span_eq(statement, target, "IF") { let Some(exists) = next_sql_token_span(statement, target.1) else { - return true; + return Err(()); }; if !token_span_eq(statement, exists, "EXISTS") { - return true; + 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; } - target = match next_sql_token_span(statement, exists.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(statement, target, "ONLY") { - target = match next_sql_token_span(statement, target.1) { + + 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; } - cursor = target.1; - if let Some(star) = next_sql_token_span(statement, cursor) { - if statement.get(star.0..star.1) == Some("*") { - cursor = star.1; + 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(); + } + _ => {} } } - let Some(actions) = statement.get(cursor..) else { + 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. 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`; every top-level action is +/// inspected so an additive first action cannot hide a later destructive one. +/// 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") { 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; @@ -235,8 +346,10 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// authority. Committed `DROP TABLE`, standalone table/column rename forms, and /// destructive ALTER TABLE DROP actions are likewise rejected until table, /// column, constraint, removal/recreation, and dependent-object effects are -/// represented by a first-class final-table aggregate. Mutations removed by the -/// transaction projection never reach either bounded fail-closed boundary. +/// represented by a first-class final-table aggregate. 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 /// @@ -265,6 +378,9 @@ pub fn validate_migration_catalog( if contains_unsupported_table_final_state_mutation(&committed_up) { return Err(MigrationContractError::UnsupportedTableFinalStateMutation); } + 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); } @@ -418,7 +534,8 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { #[cfg(test)] mod tests { use super::{ - canonicalize_table_persistence_modifiers, contains_unsupported_policy_mutation, + canonicalize_table_persistence_modifiers, + contains_invalid_alter_table_added_column_name, contains_unsupported_policy_mutation, contains_unsupported_table_final_state_mutation, project_committed_sql, }; @@ -476,6 +593,29 @@ mod tests { } } + #[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( From 21e609d17ca19ee428d0b992b79708b5e67c6a68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:20:08 +0900 Subject: [PATCH 234/309] test(persistence): reject trigger final-state weakening (#571) --- .../migration_trigger_final_state_contract.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_trigger_final_state_contract.rs 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}", + ); + } +} From bf418b9c856d0a91c57024b3ac6ca071b38135b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:21:40 +0900 Subject: [PATCH 235/309] fix(persistence): reject trigger final-state weakening (#571) --- .../src/migration_core.rs | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index f90311c1c..209596021 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -93,14 +93,40 @@ 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")) } -/// Detect destructive DROP actions in PostgreSQL's comma-separated ALTER TABLE action list. +/// 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. Unbalanced delimiters -/// fail closed because malformed structure cannot prove the absence of a later -/// destructive action. -fn alter_table_actions_include_drop(actions: &str) -> bool { +/// 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; @@ -122,7 +148,10 @@ fn alter_table_actions_include_drop(actions: &str) -> bool { bracket_depth -= 1; } ',' if parenthesis_depth == 0 && bracket_depth == 0 => { - if alter_table_action_starts_with_drop(&actions[action_start..index]) { + 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(); @@ -134,7 +163,9 @@ fn alter_table_actions_include_drop(actions: &str) -> bool { if parenthesis_depth != 0 || bracket_depth != 0 { return true; } - alter_table_action_starts_with_drop(&actions[action_start..]) + 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. @@ -283,8 +314,10 @@ fn contains_invalid_alter_table_added_column_name(sql: &str) -> bool { /// `DROP TABLE` removes the durable relation. 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`; every top-level action is -/// inspected so an additive first action cannot hide a later destructive one. +/// 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 { @@ -310,7 +343,7 @@ fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool if token_span_eq(actions, first_action, "RENAME") { return true; } - alter_table_actions_include_drop(actions) + alter_table_actions_include_unsupported_final_state_mutation(actions) } /// Detect committed table removals or identity/destructive mutations not yet owned by final-table state. @@ -343,10 +376,11 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// `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`, standalone table/column rename forms, and -/// destructive ALTER TABLE DROP actions are likewise rejected until table, +/// authority. Committed `DROP TABLE`, standalone table/column rename forms, +/// destructive ALTER TABLE DROP actions, and trigger modes that disable normal +/// application-path enforcement are likewise rejected until table, trigger, /// column, constraint, removal/recreation, and dependent-object effects are -/// represented by a first-class final-table aggregate. Committed ADD-column +/// 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. @@ -578,6 +612,10 @@ mod tests { "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)); } @@ -588,6 +626,8 @@ mod tests { "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)); } From b12c6b2589ed3458a4f8f5727ca84f4a415b13a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:22:42 +0900 Subject: [PATCH 236/309] test(persistence): reject dropped trigger final state (#572) --- ...ation_drop_trigger_final_state_contract.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_drop_trigger_final_state_contract.rs 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(())); +} From 70fb4eaf9ffda29bed0bb51523c2d0e7eff4d702 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 11:25:50 +0900 Subject: [PATCH 237/309] fix(persistence): reject dropped trigger final state (#572) --- .../src/migration_core.rs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 209596021..29ffc3771 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -311,13 +311,13 @@ fn contains_invalid_alter_table_added_column_name(sql: &str) -> bool { /// Return whether one committed statement mutates table final state beyond the bounded model. /// -/// `DROP TABLE` removes the durable relation. 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. +/// `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 { @@ -328,7 +328,10 @@ fn statement_has_unsupported_table_final_state_mutation(statement: &str) -> bool return false; }; - if token_span_eq(statement, first, "DROP") && token_span_eq(statement, second, "TABLE") { + if token_span_eq(statement, first, "DROP") + && (token_span_eq(statement, second, "TABLE") + || token_span_eq(statement, second, "TRIGGER")) + { return true; } @@ -376,14 +379,15 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// `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`, standalone table/column rename forms, -/// destructive ALTER TABLE DROP actions, and trigger modes that disable normal -/// application-path enforcement are likewise rejected until table, trigger, -/// column, constraint, removal/recreation, and dependent-object 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. +/// authority. Committed `DROP TABLE` / `DROP TRIGGER`, standalone table/column +/// rename forms, destructive ALTER TABLE DROP actions, and trigger modes that +/// disable normal application-path enforcement are likewise rejected until +/// table, trigger, column, constraint, removal/recreation, and dependent-object +/// 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 /// @@ -608,6 +612,8 @@ mod tests { 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 ;", From e07994e1cbe8eaec09df6c79b272c8d2592f9747 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 12:02:56 +0900 Subject: [PATCH 238/309] test(persistence): expose append-only guard routine final-state bypass --- ...only_guard_routine_final_state_contract.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_append_only_guard_routine_final_state_contract.rs 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..454d147b6 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_append_only_guard_routine_final_state_contract.rs @@ -0,0 +1,80 @@ +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;", + ] { + assert_eq!( + validate_migration_catalog(&embedded_with(final_sql)), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "committed guard-function 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() { + let dropped = embedded_with( + "BEGIN; DROP FUNCTION reject_append_only_mutation() CASCADE; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&dropped), 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 $$CREATE OR REPLACE FUNCTION reject_append_only_mutation()$$; +-- DROP FUNCTION reject_append_only_mutation() CASCADE; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} From 92aff8963717e89e5c6f8afeae05c8729dd25c1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 12:06:13 +0900 Subject: [PATCH 239/309] fix(persistence): fail closed on append-only guard routine final-state mutation --- .../src/migration_core.rs | 183 ++++++++++++++++-- 1 file changed, 172 insertions(+), 11 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 29ffc3771..53de42b0e 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -88,6 +88,144 @@ fn token_span_eq(sql: &str, span: (usize, usize), keyword: &str) -> bool { .is_some_and(|token| token.eq_ignore_ascii_case(keyword)) } +/// Return whether one normalized token denotes TEPP's append-only guard routine. +/// +/// The shared lexical authority has already handled quoted identifiers. This +/// helper only strips a schema qualifier and attached argument-list punctuation +/// so the final-state guard can recognize both `reject_append_only_mutation()` +/// and schema-qualified spellings without reparsing SQL bodies. +fn token_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) -> bool { + let Some(token) = sql.get(span.0..span.1) else { + return false; + }; + let name = token.split_once('(').map_or(token, |(name, _)| name); + name.rsplit('.') + .next() + .is_some_and(|part| part.eq_ignore_ascii_case("reject_append_only_mutation")) +} + +/// Return whether one committed DROP FUNCTION statement targets the append-only guard routine. +/// +/// PostgreSQL permits multiple function targets separated by top-level commas; +/// commas inside function signatures are not target boundaries. Malformed +/// parenthesis structure fails closed because this bounded authority cannot prove +/// that the append-only guard is absent from an ambiguous DROP statement. +fn statement_drops_append_only_guard_routine(statement: &str) -> bool { + let Some(drop_keyword) = next_sql_token_span(statement, 0) else { + return false; + }; + let Some(function_keyword) = next_sql_token_span(statement, drop_keyword.1) else { + return false; + }; + if !token_span_eq(statement, drop_keyword, "DROP") + || !token_span_eq(statement, function_keyword, "FUNCTION") + { + return false; + } + + let mut cursor = function_keyword.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 next_sql_token_span(target, 0) + .is_some_and(|span| token_span_names_append_only_guard_routine(target, span)) + { + return true; + } + target_start = index + ch.len_utf8(); + } + _ => {} + } + } + if parenthesis_depth != 0 { + return true; + } + let final_target = &targets[target_start..]; + next_sql_token_span(final_target, 0) + .is_some_and(|span| token_span_names_append_only_guard_routine(final_target, span)) +} + +/// Return whether one committed statement defines TEPP's append-only guard routine. +fn statement_defines_append_only_guard_routine(statement: &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; + }; + let Some(routine) = next_sql_token_span(statement, function_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") + && token_span_names_append_only_guard_routine(statement, routine) +} + +/// Detect committed mutations that make historical append-only guard evidence stale. +/// +/// PostgreSQL `DROP FUNCTION ... CASCADE` can remove dependent triggers, while a +/// later `CREATE OR REPLACE FUNCTION` can replace the routine body without +/// changing the function identity referenced by those triggers. Until TEPP owns +/// final routine-body and dependency state, the first canonical guard 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_append_only_guard_routine_mutation(sql: &str) -> bool { + let mut seen_guard_definition = false; + for statement in sql.split(';') { + if statement_drops_append_only_guard_routine(statement) { + return true; + } + if statement_defines_append_only_guard_routine(statement) { + 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")) @@ -380,14 +518,15 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// 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, and trigger modes that -/// disable normal application-path enforcement are likewise rejected until -/// table, trigger, column, constraint, removal/recreation, and dependent-object -/// 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. +/// rename forms, destructive ALTER TABLE DROP actions, trigger modes that disable +/// normal application-path enforcement, append-only guard-routine removal, and +/// a second committed `CREATE OR REPLACE FUNCTION reject_append_only_mutation` +/// 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 /// @@ -413,7 +552,9 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; - if contains_unsupported_table_final_state_mutation(&committed_up) { + if contains_unsupported_append_only_guard_routine_mutation(&committed_up) + || contains_unsupported_table_final_state_mutation(&committed_up) + { return Err(MigrationContractError::UnsupportedTableFinalStateMutation); } if contains_invalid_alter_table_added_column_name(&committed_up) { @@ -573,8 +714,10 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { mod tests { use super::{ canonicalize_table_persistence_modifiers, - contains_invalid_alter_table_added_column_name, contains_unsupported_policy_mutation, - contains_unsupported_table_final_state_mutation, project_committed_sql, + contains_invalid_alter_table_added_column_name, + contains_unsupported_append_only_guard_routine_mutation, + contains_unsupported_policy_mutation, contains_unsupported_table_final_state_mutation, + project_committed_sql, }; #[test] @@ -607,6 +750,24 @@ mod tests { assert!(!projected.contains("rolled_back_record")); } + #[test] + fn append_only_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_append_only_guard_routine_mutation(canonical)); + assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} CREATE OR REPLACE FUNCTION reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" + ))); + assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} DROP FUNCTION reject_append_only_mutation() CASCADE ;" + ))); + assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} DROP FUNCTION other_guard(), public.reject_append_only_mutation() CASCADE ;" + ))); + assert!(!contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} DROP FUNCTION reject_append_only_mutation_shadow() CASCADE ;" + ))); + } + #[test] fn table_final_state_mutation_detection_is_statement_token_and_action_bounded() { for sql in [ From b7dc763c8a6eddd2a600a34ee98a92a915fc796b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 12:10:29 +0900 Subject: [PATCH 240/309] test(persistence): cover DROP ROUTINE alias for append-only guard --- ...pend_only_guard_routine_final_state_contract.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index 454d147b6..b70d7d905 100644 --- 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 @@ -13,11 +13,13 @@ 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-function removal must invalidate historical trigger evidence: {final_sql}", + "committed guard-routine removal must invalidate historical trigger evidence: {final_sql}", ); } } @@ -45,10 +47,12 @@ $tepp$; #[test] fn rolled_back_guard_routine_mutations_do_not_change_durable_enforcement() { - let dropped = embedded_with( + for final_sql in [ "BEGIN; DROP FUNCTION reject_append_only_mutation() CASCADE; ROLLBACK;", - ); - assert_eq!(validate_migration_catalog(&dropped), Ok(())); + "BEGIN; DROP ROUTINE reject_append_only_mutation() CASCADE; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } let replaced = embedded_with( r#" @@ -72,8 +76,10 @@ 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(())); From 6a3f1ec8161764402a93b3aa8b036489a0d489e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 12:13:16 +0900 Subject: [PATCH 241/309] fix(persistence): cover PostgreSQL DROP ROUTINE guard removal --- .../src/migration_core.rs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 53de42b0e..7b2946219 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -104,26 +104,27 @@ fn token_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) - .is_some_and(|part| part.eq_ignore_ascii_case("reject_append_only_mutation")) } -/// Return whether one committed DROP FUNCTION statement targets the append-only guard routine. +/// Return whether one committed DROP FUNCTION / DROP ROUTINE statement targets the append-only guard. /// -/// PostgreSQL permits multiple function targets separated by top-level commas; -/// commas inside function signatures are not target boundaries. Malformed +/// 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 append-only guard is absent from an ambiguous DROP statement. fn statement_drops_append_only_guard_routine(statement: &str) -> bool { let Some(drop_keyword) = next_sql_token_span(statement, 0) else { return false; }; - let Some(function_keyword) = next_sql_token_span(statement, drop_keyword.1) else { + 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, function_keyword, "FUNCTION") + || !(token_span_eq(statement, routine_kind, "FUNCTION") + || token_span_eq(statement, routine_kind, "ROUTINE")) { return false; } - let mut cursor = function_keyword.1; + let mut cursor = routine_kind.1; let Some(mut first_target) = next_sql_token_span(statement, cursor) else { return true; }; @@ -203,13 +204,14 @@ fn statement_defines_append_only_guard_routine(statement: &str) -> bool { /// Detect committed mutations that make historical append-only guard evidence stale. /// -/// PostgreSQL `DROP FUNCTION ... CASCADE` can remove dependent triggers, while a -/// later `CREATE OR REPLACE FUNCTION` can replace the routine body without -/// changing the function identity referenced by those triggers. Until TEPP owns -/// final routine-body and dependency state, the first canonical guard 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. +/// PostgreSQL `DROP FUNCTION` / `DROP ROUTINE ... CASCADE` can remove dependent +/// triggers, while a later `CREATE OR REPLACE FUNCTION` can replace the routine +/// body without changing the function identity referenced by those triggers. +/// Until TEPP owns final routine-body and dependency state, the first canonical +/// guard 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_append_only_guard_routine_mutation(sql: &str) -> bool { let mut seen_guard_definition = false; for statement in sql.split(';') { From 29d0ec8885ebe3fe1469533e433418038d0741a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 13:00:13 +0900 Subject: [PATCH 242/309] test(persistence): expose spaced guard-routine qualification bypass --- ...nly_guard_schema_qualification_contract.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_append_only_guard_schema_qualification_contract.rs 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(())); +} From 9a31382a17ad03cabe6a13826f2d3832a4946ad5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 13:02:41 +0900 Subject: [PATCH 243/309] fix(persistence): recognize spaced guard-routine qualification --- .../src/migration_core.rs | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 7b2946219..12ef1b6e0 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -88,17 +88,28 @@ fn token_span_eq(sql: &str, span: (usize, usize), keyword: &str) -> bool { .is_some_and(|token| token.eq_ignore_ascii_case(keyword)) } -/// Return whether one normalized token denotes TEPP's append-only guard routine. +/// Return whether one normalized SQL span denotes TEPP's append-only guard routine. /// -/// The shared lexical authority has already handled quoted identifiers. This -/// helper only strips a schema qualifier and attached argument-list punctuation -/// so the final-state guard can recognize both `reject_append_only_mutation()` -/// and schema-qualified spellings without reparsing SQL bodies. -fn token_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) -> bool { - let Some(token) = sql.get(span.0..span.1) else { +/// 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. It does not reparse executable SQL. +fn sql_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) -> bool { + let Some(fragment) = sql.get(span.0..span.1) else { return false; }; - let name = token.split_once('(').map_or(token, |(name, _)| name); + 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); + } name.rsplit('.') .next() .is_some_and(|part| part.eq_ignore_ascii_case("reject_append_only_mutation")) @@ -159,9 +170,7 @@ fn statement_drops_append_only_guard_routine(statement: &str) -> bool { } ',' if parenthesis_depth == 0 => { let target = &targets[target_start..index]; - if next_sql_token_span(target, 0) - .is_some_and(|span| token_span_names_append_only_guard_routine(target, span)) - { + if sql_span_names_append_only_guard_routine(target, (0, target.len())) { return true; } target_start = index + ch.len_utf8(); @@ -173,8 +182,7 @@ fn statement_drops_append_only_guard_routine(statement: &str) -> bool { return true; } let final_target = &targets[target_start..]; - next_sql_token_span(final_target, 0) - .is_some_and(|span| token_span_names_append_only_guard_routine(final_target, span)) + sql_span_names_append_only_guard_routine(final_target, (0, final_target.len())) } /// Return whether one committed statement defines TEPP's append-only guard routine. @@ -191,15 +199,15 @@ fn statement_defines_append_only_guard_routine(statement: &str) -> bool { let Some(function_keyword) = next_sql_token_span(statement, replace_keyword.1) else { return false; }; - let Some(routine) = next_sql_token_span(statement, function_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") - && token_span_names_append_only_guard_routine(statement, routine) + && sql_span_names_append_only_guard_routine( + statement, + (function_keyword.1, statement.len()), + ) } /// Detect committed mutations that make historical append-only guard evidence stale. @@ -765,6 +773,12 @@ mod tests { assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( "{canonical} DROP FUNCTION other_guard(), public.reject_append_only_mutation() CASCADE ;" ))); + assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} DROP FUNCTION other_guard(), public . reject_append_only_mutation() CASCADE ;" + ))); + assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( + "{canonical} CREATE OR REPLACE FUNCTION public . reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" + ))); assert!(!contains_unsupported_append_only_guard_routine_mutation(&format!( "{canonical} DROP FUNCTION reject_append_only_mutation_shadow() CASCADE ;" ))); From 5530c3154b180d6d8ed864584d08e708fb31bdba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 13:58:48 +0900 Subject: [PATCH 244/309] test(persistence): expose retention guard final-state stale evidence --- ...tion_guard_routine_final_state_contract.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_retention_guard_routine_final_state_contract.rs 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..2bdf3462e --- /dev/null +++ b/crates/persistence_postgres/tests/migration_retention_guard_routine_final_state_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 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 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(())); +} From 240ee36838de9fe3d13f0c8557a3fc370604bd9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 14:02:33 +0900 Subject: [PATCH 245/309] fix(persistence): preserve retention guard final state --- .../src/migration_core.rs | 144 ++++++++++++------ 1 file changed, 94 insertions(+), 50 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index 12ef1b6e0..f1f36fb93 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -88,14 +88,18 @@ fn token_span_eq(sql: &str, span: (usize, usize), keyword: &str) -> bool { .is_some_and(|token| token.eq_ignore_ascii_case(keyword)) } -/// Return whether one normalized SQL span denotes TEPP's append-only guard routine. +/// Return whether one normalized SQL span denotes `routine_name`. /// /// 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. It does not reparse executable SQL. -fn sql_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) -> bool { +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; }; @@ -112,16 +116,16 @@ fn sql_span_names_append_only_guard_routine(sql: &str, span: (usize, usize)) -> } name.rsplit('.') .next() - .is_some_and(|part| part.eq_ignore_ascii_case("reject_append_only_mutation")) + .is_some_and(|part| part.eq_ignore_ascii_case(routine_name)) } -/// Return whether one committed DROP FUNCTION / DROP ROUTINE statement targets the append-only guard. +/// 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 append-only guard is absent from an ambiguous DROP statement. -fn statement_drops_append_only_guard_routine(statement: &str) -> bool { +/// 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; }; @@ -170,7 +174,7 @@ fn statement_drops_append_only_guard_routine(statement: &str) -> bool { } ',' if parenthesis_depth == 0 => { let target = &targets[target_start..index]; - if sql_span_names_append_only_guard_routine(target, (0, target.len())) { + if sql_span_names_guard_routine(target, (0, target.len()), routine_name) { return true; } target_start = index + ch.len_utf8(); @@ -182,11 +186,11 @@ fn statement_drops_append_only_guard_routine(statement: &str) -> bool { return true; } let final_target = &targets[target_start..]; - sql_span_names_append_only_guard_routine(final_target, (0, final_target.len())) + sql_span_names_guard_routine(final_target, (0, final_target.len()), routine_name) } -/// Return whether one committed statement defines TEPP's append-only guard routine. -fn statement_defines_append_only_guard_routine(statement: &str) -> bool { +/// 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; }; @@ -204,29 +208,30 @@ fn statement_defines_append_only_guard_routine(statement: &str) -> bool { && token_span_eq(statement, or_keyword, "OR") && token_span_eq(statement, replace_keyword, "REPLACE") && token_span_eq(statement, function_keyword, "FUNCTION") - && sql_span_names_append_only_guard_routine( + && sql_span_names_guard_routine( statement, (function_keyword.1, statement.len()), + routine_name, ) } -/// Detect committed mutations that make historical append-only guard evidence stale. +/// 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 the routine +/// 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 -/// guard 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_append_only_guard_routine_mutation(sql: &str) -> bool { +/// 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_append_only_guard_routine(statement) { + if statement_drops_guard_routine(statement, routine_name) { return true; } - if statement_defines_append_only_guard_routine(statement) { + if statement_defines_guard_routine(statement, routine_name) { if seen_guard_definition { return true; } @@ -529,12 +534,12 @@ fn contains_unsupported_table_final_state_mutation(sql: &str) -> bool { /// 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, append-only guard-routine removal, and -/// a second committed `CREATE OR REPLACE FUNCTION reject_append_only_mutation` -/// 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 +/// 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. /// @@ -562,11 +567,22 @@ pub fn validate_migration_catalog( return Err(MigrationContractError::MissingAppRuntimeRole); }; - if contains_unsupported_append_only_guard_routine_mutation(&committed_up) - || contains_unsupported_table_final_state_mutation(&committed_up) + 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); } @@ -724,8 +740,7 @@ fn canonicalize_table_persistence_modifiers(sql: &str) -> String { mod tests { use super::{ canonicalize_table_persistence_modifiers, - contains_invalid_alter_table_added_column_name, - contains_unsupported_append_only_guard_routine_mutation, + 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, }; @@ -761,27 +776,56 @@ mod tests { } #[test] - fn append_only_guard_routine_final_state_detection_is_target_bounded() { + 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_append_only_guard_routine_mutation(canonical)); - assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} CREATE OR REPLACE FUNCTION reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" - ))); - assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} DROP FUNCTION reject_append_only_mutation() CASCADE ;" - ))); - assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} DROP FUNCTION other_guard(), public.reject_append_only_mutation() CASCADE ;" - ))); - assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} DROP FUNCTION other_guard(), public . reject_append_only_mutation() CASCADE ;" - ))); - assert!(contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} CREATE OR REPLACE FUNCTION public . reject_append_only_mutation() RETURNS trigger LANGUAGE plpgsql AS BEGIN RETURN NULL END ;" - ))); - assert!(!contains_unsupported_append_only_guard_routine_mutation(&format!( - "{canonical} DROP FUNCTION reject_append_only_mutation_shadow() CASCADE ;" - ))); + 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" + )); + + 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" + )); } #[test] From 04bf7b7470755612b2f9daac8750a84bf46f3deb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 14:05:51 +0900 Subject: [PATCH 246/309] test(persistence): reject cross-schema retention guard aliases --- ...tion_guard_routine_final_state_contract.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 index 2bdf3462e..5955663ce 100644 --- 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 @@ -50,6 +50,28 @@ $tepp$; } } +#[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 [ From 1c30ecfdd3dd75f9200bf235300774d668bc001c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 14:07:09 +0900 Subject: [PATCH 247/309] fix(persistence): bind protected guard routines to public schema --- .../src/migration_core.rs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration_core.rs b/crates/persistence_postgres/src/migration_core.rs index f1f36fb93..905860ee4 100644 --- a/crates/persistence_postgres/src/migration_core.rs +++ b/crates/persistence_postgres/src/migration_core.rs @@ -88,13 +88,16 @@ fn token_span_eq(sql: &str, span: (usize, usize), keyword: &str) -> bool { .is_some_and(|token| token.eq_ignore_ascii_case(keyword)) } -/// Return whether one normalized SQL span denotes `routine_name`. +/// 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. It does not reparse executable SQL. +/// 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), @@ -114,9 +117,18 @@ fn sql_span_names_guard_routine( } name.push_str(token); } - name.rsplit('.') - .next() - .is_some_and(|part| part.eq_ignore_ascii_case(routine_name)) + + 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`. @@ -814,6 +826,10 @@ mod tests { &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( @@ -826,6 +842,12 @@ mod tests { ), "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] From ac4d1e1305cdd98c897ae60bef7e2d0787b1b0f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:03:38 +0900 Subject: [PATCH 248/309] test(persistence): expose replica trigger bypass #576 --- ...ation_session_replication_role_contract.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_session_replication_role_contract.rs 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..82c397904 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_session_replication_role_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_replica_execution_mode_cannot_bypass_append_only_triggers() { + 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::MissingAppendOnlyTrigger), + "committed replica execution mode must invalidate ordinary trigger enforcement: {final_sql}", + ); + } +} + +#[test] +fn rolled_back_replica_execution_mode_does_not_change_durable_migration_effects() { + let catalog = embedded_with( + "BEGIN; SET LOCAL session_replication_role = replica; TRUNCATE TABLE source_artifact; ROLLBACK;", + ); + assert_eq!(validate_migration_catalog(&catalog), 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;", + ] { + 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$$; +"#, + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} From aa451a5013d9a68706ae271c6289864170e50eab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:08:05 +0900 Subject: [PATCH 249/309] test(persistence): map replica bypass to runtime safety #576 --- .../tests/migration_session_replication_role_contract.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 82c397904..89bd88284 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -9,7 +9,7 @@ fn embedded_with(final_sql: &str) -> MigrationCatalog { } #[test] -fn committed_replica_execution_mode_cannot_bypass_append_only_triggers() { +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;", @@ -17,8 +17,8 @@ fn committed_replica_execution_mode_cannot_bypass_append_only_triggers() { ] { assert_eq!( validate_migration_catalog(&embedded_with(final_sql)), - Err(MigrationContractError::MissingAppendOnlyTrigger), - "committed replica execution mode must invalidate ordinary trigger enforcement: {final_sql}", + Err(MigrationContractError::MissingAppRuntimeRole), + "committed replica execution mode must invalidate the runtime-role safety contract: {final_sql}", ); } } From e86def95ca11d9074ff134a47860833a49a88264 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:09:06 +0900 Subject: [PATCH 250/309] fix(persistence): reject committed replica trigger mode #576 --- .../src/migration_validation.rs | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 899932199..c62565208 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -18,6 +18,54 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql_with_grantor_identity(sql) } +/// Detect committed PostgreSQL replica execution mode that suppresses ordinary triggers. +/// +/// The input has already crossed the shared lexical authority and committed-state +/// projection, so comments, opaque bodies, and rolled-back `SET LOCAL` statements +/// cannot manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, +/// `TO` or `=`, and a quoted enum value; this bounded token fold recognizes those +/// forms without reparsing raw SQL. Entering `replica` is treated as unsafe runtime +/// state because ordinary TEPP append-only and retention triggers do not fire in +/// that mode. +fn committed_replica_trigger_execution_mode(sql: &str) -> bool { + sql.split(';').any(|statement| { + 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")) + { + return false; + } + index += 1; + 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. /// @@ -29,13 +77,18 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { /// 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. +/// runtime role. A committed `session_replication_role = replica` also fails the +/// runtime-role contract because it suppresses ordinary enforcement triggers +/// even when their 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) } @@ -81,6 +134,28 @@ mod tests { 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;", + ] { + 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; SET session_replication_role = origin;", + "CREATE ROLE tepp_app_runtime NOSUPERUSER NOBYPASSRLS; SET SESSION session_replication_role TO local;", + ] { + 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( From d95944c7edae4bc3a00aaae200137eebbb8190df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:10:53 +0900 Subject: [PATCH 251/309] test(persistence): expose set_config replica bypass #577 --- ...ation_session_replication_role_contract.rs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 89bd88284..b6b5c0d50 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -23,12 +23,28 @@ fn committed_replica_execution_mode_cannot_bypass_runtime_trigger_enforcement() } } +#[test] +fn committed_set_config_replica_mode_cannot_bypass_runtime_trigger_enforcement() { + for final_sql in [ + "SELECT 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 rolled_back_replica_execution_mode_does_not_change_durable_migration_effects() { - let catalog = embedded_with( + for final_sql in [ "BEGIN; SET LOCAL session_replication_role = replica; TRUNCATE TABLE source_artifact; ROLLBACK;", - ); - assert_eq!(validate_migration_catalog(&catalog), Ok(())); + "BEGIN; SELECT set_config('session_replication_role', 'replica', true); TRUNCATE TABLE source_artifact; ROLLBACK;", + ] { + assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + } } #[test] @@ -36,11 +52,21 @@ 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);", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } } +#[test] +fn unrelated_schema_set_config_does_not_impersonate_the_postgresql_builtin() { + let catalog = embedded_with( + "SELECT audit_support.set_config('session_replication_role', 'replica', false);", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + #[test] fn marker_like_replication_role_text_is_not_an_execution_mode_change() { let catalog = embedded_with( @@ -48,6 +74,8 @@ fn marker_like_replication_role_text_is_not_an_execution_mode_change() { 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); "#, ); assert_eq!(validate_migration_catalog(&catalog), Ok(())); From 831b6daf61902fe6f63c26389d7d67ec34637d83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:11:40 +0900 Subject: [PATCH 252/309] fix(persistence): reject set_config replica trigger mode #577 --- .../src/migration_validation.rs | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index c62565208..b2375da23 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -18,17 +18,52 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql_with_grantor_identity(sql) } +/// Return whether one normalized statement calls PostgreSQL's unqualified `set_config` +/// with the direct `session_replication_role = replica` contract atoms. +/// +/// Whitespace is erased only after the shared lexical pass has made strings and +/// comments safe to inspect. The function-name boundary excludes identifier +/// prefixes and schema-qualified lookalikes such as `audit_support.set_config`. +/// More dynamic configuration expressions remain outside this bounded matcher +/// and must be owned by a future execution-context aggregate rather than guessed. +fn statement_calls_replica_set_config(statement: &str) -> bool { + const CALL: &str = "set_config('session_replication_role','replica',"; + let compact = statement + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + let mut search_from = 0usize; + + while let Some(relative) = compact[search_from..].find(CALL) { + let start = search_from + relative; + let previous = compact[..start].chars().next_back(); + let is_identifier_continuation = previous.is_some_and(|ch| { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() + }); + if !is_identifier_continuation && previous != Some('.') { + return true; + } + search_from = start + CALL.len(); + } + false +} + /// Detect committed PostgreSQL replica execution mode that suppresses ordinary triggers. /// /// The input has already crossed the shared lexical authority and committed-state -/// projection, so comments, opaque bodies, and rolled-back `SET LOCAL` statements -/// cannot manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, -/// `TO` or `=`, and a quoted enum value; this bounded token fold recognizes those -/// forms without reparsing raw SQL. Entering `replica` is treated as unsafe runtime -/// state because ordinary TEPP append-only and retention triggers do not fire in -/// that mode. +/// projection, so comments, opaque bodies, and rolled-back local settings cannot +/// manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, `TO` or +/// `=`, a quoted enum value, and the equivalent `set_config` function. This +/// bounded fold recognizes direct forms without reparsing raw SQL. Entering +/// `replica` is treated as unsafe runtime state because ordinary TEPP append-only +/// and retention triggers do not fire in that mode. fn committed_replica_trigger_execution_mode(sql: &str) -> bool { sql.split(';').any(|statement| { + if statement_calls_replica_set_config(statement) { + return true; + } + let delimited = statement.replace('=', " = "); let tokens = delimited.split_whitespace().collect::>(); if !tokens @@ -77,7 +112,8 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { /// 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 `session_replication_role = replica` also fails the +/// runtime role. A committed `session_replication_role = replica`, whether via +/// `SET` or the direct unqualified `set_config` equivalent, also fails the /// runtime-role contract because it suppresses ordinary enforcement triggers /// even when their durable catalog definitions remain enabled. pub(super) fn declares_created_role(sql: &str, expected_role: &str) -> Option { @@ -140,6 +176,8 @@ mod tests { "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; BEGIN; SELECT set_config('session_replication_role', 'replica', true); COMMIT;", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } @@ -149,8 +187,11 @@ mod tests { 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; 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);", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(true)); } From ada5131ea3e8d67ea5e7715c0026619803b45a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:12:06 +0900 Subject: [PATCH 253/309] test(persistence): cover pg_catalog set_config #577 --- .../tests/migration_session_replication_role_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index b6b5c0d50..a7597f769 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -27,6 +27,7 @@ fn committed_replica_execution_mode_cannot_bypass_runtime_trigger_enforcement() 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!( From 5f541b6c2cd5e29c3d8c052908493a9cc25a4385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:12:51 +0900 Subject: [PATCH 254/309] fix(persistence): preserve pg_catalog set_config identity #577 --- .../src/migration_validation.rs | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index b2375da23..d28987834 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -18,14 +18,45 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql_with_grantor_identity(sql) } -/// Return whether one normalized statement calls PostgreSQL's unqualified `set_config` +/// Return whether a byte-position starts an unqualified or canonical `pg_catalog` function call. +/// +/// The compact SQL fragment has already crossed the shared PostgreSQL lexical +/// authority. Identifier continuations and arbitrary schema qualifiers cannot +/// donate builtin-function identity; explicit `pg_catalog.` is accepted because +/// it names PostgreSQL's canonical system implementation. +fn is_builtin_function_start(compact: &str, start: usize) -> bool { + const PG_CATALOG_PREFIX: &str = "pg_catalog."; + let previous = compact[..start].chars().next_back(); + let is_identifier_continuation = previous.is_some_and(|ch| { + ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() + }); + if !is_identifier_continuation && previous != Some('.') { + return true; + } + if start < PG_CATALOG_PREFIX.len() { + return false; + } + let schema_start = start - PG_CATALOG_PREFIX.len(); + if !compact[schema_start..start].eq_ignore_ascii_case(PG_CATALOG_PREFIX) { + return false; + } + compact[..schema_start] + .chars() + .next_back() + .is_none_or(|ch| { + !ch.is_ascii_alphanumeric() && ch != '_' && ch != '$' && ch != '.' && ch.is_ascii() + }) +} + +/// Return whether one normalized statement calls PostgreSQL's `set_config` /// with the direct `session_replication_role = replica` contract atoms. /// /// Whitespace is erased only after the shared lexical pass has made strings and -/// comments safe to inspect. The function-name boundary excludes identifier -/// prefixes and schema-qualified lookalikes such as `audit_support.set_config`. -/// More dynamic configuration expressions remain outside this bounded matcher -/// and must be owned by a future execution-context aggregate rather than guessed. +/// comments safe to inspect. The function-name boundary accepts the unqualified +/// builtin and explicit `pg_catalog.set_config`, while excluding identifier +/// prefixes and unrelated schemas such as `audit_support.set_config`. More +/// dynamic configuration expressions remain outside this bounded matcher and +/// must be owned by a future execution-context aggregate rather than guessed. fn statement_calls_replica_set_config(statement: &str) -> bool { const CALL: &str = "set_config('session_replication_role','replica',"; let compact = statement @@ -37,11 +68,7 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { while let Some(relative) = compact[search_from..].find(CALL) { let start = search_from + relative; - let previous = compact[..start].chars().next_back(); - let is_identifier_continuation = previous.is_some_and(|ch| { - ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() - }); - if !is_identifier_continuation && previous != Some('.') { + if is_builtin_function_start(&compact, start) { return true; } search_from = start + CALL.len(); @@ -113,9 +140,9 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { /// 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 `session_replication_role = replica`, whether via -/// `SET` or the direct unqualified `set_config` equivalent, also fails the -/// runtime-role contract because it suppresses ordinary enforcement triggers -/// even when their durable catalog definitions remain enabled. +/// `SET` or PostgreSQL's direct `set_config` equivalent, also fails the runtime- +/// role contract because it suppresses ordinary enforcement triggers even when +/// their 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( @@ -177,6 +204,7 @@ mod tests { "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;", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); From 8327f930fb3fe71d9bfb30aef31a6c0a7350f17f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:13:47 +0900 Subject: [PATCH 255/309] fix(persistence): keep set_config function boundaries #577 --- .../src/migration_validation.rs | 87 +++++++++++-------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index d28987834..3914bde69 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -18,60 +18,73 @@ pub(super) fn normalize_migration_sql(sql: &str) -> Option { implementation::normalize_migration_sql_with_grantor_identity(sql) } -/// Return whether a byte-position starts an unqualified or canonical `pg_catalog` function call. +/// 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. /// -/// The compact SQL fragment has already crossed the shared PostgreSQL lexical -/// authority. Identifier continuations and arbitrary schema qualifiers cannot -/// donate builtin-function identity; explicit `pg_catalog.` is accepted because -/// it names PostgreSQL's canonical system implementation. -fn is_builtin_function_start(compact: &str, start: usize) -> bool { - const PG_CATALOG_PREFIX: &str = "pg_catalog."; - let previous = compact[..start].chars().next_back(); - let is_identifier_continuation = previous.is_some_and(|ch| { - ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || !ch.is_ascii() - }); - if !is_identifier_continuation && previous != Some('.') { - return true; - } - if start < PG_CATALOG_PREFIX.len() { - return false; +/// 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 schema_start = start - PG_CATALOG_PREFIX.len(); - if !compact[schema_start..start].eq_ignore_ascii_case(PG_CATALOG_PREFIX) { + + 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; } - compact[..schema_start] + before_dot[..schema_start] .chars() .next_back() - .is_none_or(|ch| { - !ch.is_ascii_alphanumeric() && ch != '_' && ch != '$' && ch != '.' && ch.is_ascii() - }) + .is_none_or(|ch| ch != '.' && !is_function_identifier_continuation(ch)) } /// Return whether one normalized statement calls PostgreSQL's `set_config` /// with the direct `session_replication_role = replica` contract atoms. /// -/// Whitespace is erased only after the shared lexical pass has made strings and -/// comments safe to inspect. The function-name boundary accepts the unqualified -/// builtin and explicit `pg_catalog.set_config`, while excluding identifier -/// prefixes and unrelated schemas such as `audit_support.set_config`. More -/// dynamic configuration expressions remain outside this bounded matcher and -/// must be owned by a future execution-context aggregate rather than guessed. +/// Argument whitespace is erased only after the shared lexical pass has made +/// strings and comments safe to inspect. Function identity is resolved before +/// compaction so statement whitespace cannot merge `SELECT` with `set_config`. +/// The bounded matcher accepts the unqualified builtin and explicit +/// `pg_catalog.set_config`, while excluding identifier prefixes and unrelated +/// schemas such as `audit_support.set_config`. More dynamic configuration +/// expressions remain for a future execution-context aggregate. fn statement_calls_replica_set_config(statement: &str) -> bool { + const FUNCTION_NAME: &str = "set_config"; const CALL: &str = "set_config('session_replication_role','replica',"; - let compact = statement - .chars() - .filter(|ch| !ch.is_whitespace()) - .collect::() - .to_ascii_lowercase(); + let lower = statement.to_ascii_lowercase(); let mut search_from = 0usize; - while let Some(relative) = compact[search_from..].find(CALL) { + while let Some(relative) = lower[search_from..].find(FUNCTION_NAME) { let start = search_from + relative; - if is_builtin_function_start(&compact, start) { - return true; + if is_builtin_set_config_occurrence(statement, start) { + let compact_call = statement[start..] + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + if compact_call.starts_with(CALL) { + return true; + } } - search_from = start + CALL.len(); + search_from = start + FUNCTION_NAME.len(); } false } From e5ca5f4b37c240df1139e4340526c547ba9e6d05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:18:28 +0900 Subject: [PATCH 256/309] test(persistence): pin replica execution identity boundaries #576 #577 --- ...ation_session_replication_role_contract.rs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index a7597f769..6250393b3 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -61,11 +61,24 @@ fn origin_and_local_execution_modes_preserve_ordinary_trigger_enforcement() { } #[test] -fn unrelated_schema_set_config_does_not_impersonate_the_postgresql_builtin() { - let catalog = embedded_with( +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);", - ); - assert_eq!(validate_migration_catalog(&catalog), Ok(())); + "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] From bbfe82129381ea47b5a59e49017b7528310a2499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:31:31 +0900 Subject: [PATCH 257/309] test(persistence): expose pg_settings replication-role bypass --- ...ation_session_replication_role_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 6250393b3..0c6bde65b 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -38,11 +38,27 @@ fn committed_set_config_replica_mode_cannot_bypass_runtime_trigger_enforcement() } } +#[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';", + ] { + 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;", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } @@ -55,6 +71,19 @@ fn origin_and_local_execution_modes_preserve_ordinary_trigger_enforcement() { "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';", + ] { + 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';", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } @@ -90,6 +119,8 @@ SELECT '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'; "#, ); assert_eq!(validate_migration_catalog(&catalog), Ok(())); From dc73afb100cfb4b93133f16f4249a855c02fc082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:33:14 +0900 Subject: [PATCH 258/309] fix(persistence): reject pg_settings replication-role bypass --- .../src/migration_validation.rs | 105 ++++++++++++++++-- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3914bde69..3fa82322f 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -89,18 +89,100 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { false } +/// Detect an unsafe `pg_settings` update of PostgreSQL's trigger execution mode. +/// +/// PostgreSQL documents `UPDATE pg_settings SET setting = ...` as equivalent to +/// `SET`. The input has already crossed the shared lexical authority, so this +/// bounded parser only resolves the canonical unqualified or `pg_catalog` +/// relation identity and a direct `WHERE name = 'session_replication_role'` +/// target. Atomic `origin` and `local` values are proven safe for ordinary +/// triggers; `replica` and any non-atomic value fail closed because the validator +/// cannot prove that protected DML did not execute while triggers were suppressed. +fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> bool { + let delimited = statement.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("pg_settings")) + { + index += 1; + } else if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_catalog")) + && tokens.get(index + 1) == Some(&".") + && tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) + { + index += 3; + } else { + return false; + } + + if !tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("SET")) + || !tokens + .get(index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("setting")) + || tokens.get(index + 2) != Some(&"=") + { + return false; + } + let value_start = index + 3; + let Some(where_index) = tokens[value_start..] + .iter() + .position(|token| token.eq_ignore_ascii_case("WHERE")) + .map(|relative| value_start + relative) + else { + return false; + }; + + if !tokens + .get(where_index + 1) + .is_some_and(|token| token.eq_ignore_ascii_case("name")) + || tokens.get(where_index + 2) != Some(&"=") + || !tokens.get(where_index + 3).is_some_and(|value| { + value + .trim_matches('\'') + .eq_ignore_ascii_case("session_replication_role") + }) + { + return false; + } + + let value_tokens = &tokens[value_start..where_index]; + value_tokens.len() != 1 + || !value_tokens[0] + .trim_matches('\'') + .eq_ignore_ascii_case("origin") + && !value_tokens[0] + .trim_matches('\'') + .eq_ignore_ascii_case("local") +} + /// Detect committed PostgreSQL replica execution mode that suppresses ordinary triggers. /// /// The input has already crossed the shared lexical authority and committed-state /// projection, so comments, opaque bodies, and rolled-back local settings cannot /// manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, `TO` or -/// `=`, a quoted enum value, and the equivalent `set_config` function. This -/// bounded fold recognizes direct forms without reparsing raw SQL. Entering -/// `replica` is treated as unsafe runtime state because ordinary TEPP append-only -/// and retention triggers do not fire in that mode. +/// `=`, a quoted enum value, the equivalent `set_config` function, and equivalent +/// writes through `pg_settings.setting`. This bounded fold rejects execution modes +/// that are directly `replica` or cannot be statically proven safe while allowing +/// the ordinary-trigger-safe `origin` and `local` atoms. fn committed_replica_trigger_execution_mode(sql: &str) -> bool { sql.split(';').any(|statement| { - if statement_calls_replica_set_config(statement) { + if statement_calls_replica_set_config(statement) + || statement_updates_unsafe_replication_role_via_pg_settings(statement) + { return true; } @@ -152,10 +234,10 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { /// 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 `session_replication_role = replica`, whether via -/// `SET` or PostgreSQL's direct `set_config` equivalent, also fails the runtime- -/// role contract because it suppresses ordinary enforcement triggers even when -/// their durable catalog definitions remain enabled. +/// runtime role. A committed unsafe `session_replication_role` mutation through +/// `SET`, PostgreSQL's `set_config` equivalent, or canonical `pg_settings` update +/// 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( @@ -219,6 +301,8 @@ mod tests { "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';", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } @@ -229,10 +313,13 @@ mod tests { 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 audit_support.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(true)); } From b613461449049f4a73a02719fe1fa9a7e5c03eae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:34:36 +0900 Subject: [PATCH 259/309] test(persistence): cover pg_settings alias execution bypass --- .../tests/migration_session_replication_role_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 0c6bde65b..86da7dc47 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -44,6 +44,8 @@ fn committed_pg_settings_replica_mode_cannot_bypass_runtime_trigger_enforcement( "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';", ] { assert_eq!( validate_migration_catalog(&embedded_with(final_sql)), @@ -59,6 +61,7 @@ fn rolled_back_replica_execution_mode_does_not_change_durable_migration_effects( "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;", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } @@ -73,6 +76,7 @@ fn origin_and_local_execution_modes_preserve_ordinary_trigger_enforcement() { "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';", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } From 19cea5d3e705876c6366b60dd29bcdeed83162b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:35:39 +0900 Subject: [PATCH 260/309] fix(persistence): cover pg_settings aliases and ONLY forms --- .../src/migration_validation.rs | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3fa82322f..243e24ab1 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -93,11 +93,12 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { /// /// PostgreSQL documents `UPDATE pg_settings SET setting = ...` as equivalent to /// `SET`. The input has already crossed the shared lexical authority, so this -/// bounded parser only resolves the canonical unqualified or `pg_catalog` -/// relation identity and a direct `WHERE name = 'session_replication_role'` -/// target. Atomic `origin` and `local` values are proven safe for ordinary -/// triggers; `replica` and any non-atomic value fail closed because the validator -/// cannot prove that protected DML did not execute while triggers were suppressed. +/// bounded parser resolves only the canonical unqualified or `pg_catalog` +/// relation identity plus PostgreSQL's optional `ONLY`, `*`, and target alias +/// forms. A direct equality predicate must target `session_replication_role`. +/// Atomic `origin` and `local` values are proven safe for ordinary triggers; +/// `replica` and any non-atomic value fail closed because the validator cannot +/// prove that protected DML did not execute while triggers were suppressed. fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> bool { let delimited = statement.replace('.', " . ").replace('=', " = "); let tokens = delimited.split_whitespace().collect::>(); @@ -109,6 +110,12 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> } let mut index = 1usize; + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("ONLY")) + { + index += 1; + } if tokens .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) @@ -127,6 +134,27 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> return false; } + 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")) @@ -146,11 +174,22 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> return false; }; + let mut name_index = where_index + 1; + if tokens.get(name_index + 1) == Some(&".") { + let qualifier = tokens[name_index]; + 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 { + return false; + } + name_index += 2; + } if !tokens - .get(where_index + 1) + .get(name_index) .is_some_and(|token| token.eq_ignore_ascii_case("name")) - || tokens.get(where_index + 2) != Some(&"=") - || !tokens.get(where_index + 3).is_some_and(|value| { + || tokens.get(name_index + 1) != Some(&"=") + || !tokens.get(name_index + 2).is_some_and(|value| { value .trim_matches('\'') .eq_ignore_ascii_case("session_replication_role") @@ -303,6 +342,7 @@ mod tests { "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';", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } @@ -319,6 +359,7 @@ mod tests { "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 audit_support.pg_settings SET setting = 'replica' WHERE name = 'session_replication_role';", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(true)); From 39152c10f2366363fc6c05200c0ae38bed7c82a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 15:59:45 +0900 Subject: [PATCH 261/309] test(persistence): expose CTE pg_settings execution bypass --- .../migration_session_replication_role_contract.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 86da7dc47..812f35880 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -46,6 +46,8 @@ fn committed_pg_settings_replica_mode_cannot_bypass_runtime_trigger_enforcement( "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;", ] { assert_eq!( validate_migration_catalog(&embedded_with(final_sql)), @@ -62,6 +64,8 @@ fn rolled_back_replica_execution_mode_does_not_change_durable_migration_effects( "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;", ] { assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); } @@ -77,6 +81,8 @@ fn origin_and_local_execution_modes_preserve_ordinary_trigger_enforcement() { "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(())); } @@ -88,6 +94,8 @@ fn unrelated_pg_settings_identity_or_parameter_does_not_impersonate_replica_mode "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(())); } @@ -125,6 +133,8 @@ 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(())); From f8055c2b41b80fa5df8201d26173a06b2aaf6b3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 16:01:00 +0900 Subject: [PATCH 262/309] fix(persistence): cover CTE pg_settings execution mutations --- .../src/migration_validation.rs | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 243e24ab1..3456178bb 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -89,7 +89,20 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { false } -/// Detect an unsafe `pg_settings` update of PostgreSQL's trigger execution mode. +/// 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)) +} + +/// 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 @@ -99,8 +112,8 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { /// Atomic `origin` and `local` values are proven safe for ordinary triggers; /// `replica` and any non-atomic value fail closed because the validator cannot /// prove that protected DML did not execute while triggers were suppressed. -fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> bool { - let delimited = statement.replace('.', " . ").replace('=', " = "); +fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str) -> bool { + let delimited = update_statement.replace('.', " . ").replace('=', " = "); let tokens = delimited.split_whitespace().collect::>(); if !tokens .first() @@ -208,15 +221,41 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> .eq_ignore_ascii_case("local") } +/// 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 committed PostgreSQL replica execution mode that suppresses ordinary triggers. /// /// The input has already crossed the shared lexical authority and committed-state /// projection, so comments, opaque bodies, and rolled-back local settings cannot /// manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, `TO` or /// `=`, a quoted enum value, the equivalent `set_config` function, and equivalent -/// writes through `pg_settings.setting`. This bounded fold rejects execution modes -/// that are directly `replica` or cannot be statically proven safe while allowing -/// the ordinary-trigger-safe `origin` and `local` atoms. +/// writes through `pg_settings.setting`, including CTE-wrapped UPDATE commands. +/// This bounded fold rejects execution modes that are directly `replica` or cannot +/// be statically proven safe while allowing the ordinary-trigger-safe `origin` +/// and `local` atoms. fn committed_replica_trigger_execution_mode(sql: &str) -> bool { sql.split(';').any(|statement| { if statement_calls_replica_set_config(statement) @@ -343,6 +382,8 @@ mod tests { "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;", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } @@ -361,6 +402,8 @@ mod tests { "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 audit_support.pg_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)); } From 5f2ddc4df28123fd9c475342bf5ca1c4d55b4696 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 16:07:24 +0900 Subject: [PATCH 263/309] test(persistence): expose nested WHERE pg_settings bypass --- .../tests/migration_session_replication_role_contract.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 812f35880..ab5599f1b 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -48,6 +48,8 @@ fn committed_pg_settings_replica_mode_cannot_bypass_runtime_trigger_enforcement( "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)), @@ -66,6 +68,7 @@ fn rolled_back_replica_execution_mode_does_not_change_durable_migration_effects( "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(())); } From 466c088e36ecf7863e721dc8098ef7bbf6af277c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 16:08:56 +0900 Subject: [PATCH 264/309] fix(persistence): bind pg_settings WHERE at top level --- .../src/migration_validation.rs | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3456178bb..3a7913d87 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -102,18 +102,63 @@ fn is_update_command_token(statement: &str, start: usize) -> bool { && 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. A direct equality predicate must target `session_replication_role`. +/// forms. The target `WHERE` is selected only at expression depth zero, so a +/// scalar-subquery predicate cannot hide the actual `pg_settings` row predicate. /// Atomic `origin` and `local` values are proven safe for ordinary triggers; /// `replica` and any non-atomic value fail closed because the validator cannot /// prove that protected DML did not execute while triggers were suppressed. fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str) -> bool { - let delimited = update_statement.replace('.', " . ").replace('=', " = "); + let delimited = update_statement + .replace('.', " . ") + .replace('=', " = ") + .replace('(', " ( ") + .replace(')', " ) ") + .replace('[', " [ ") + .replace(']', " ] "); let tokens = delimited.split_whitespace().collect::>(); if !tokens .first() @@ -179,11 +224,7 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str return false; } let value_start = index + 3; - let Some(where_index) = tokens[value_start..] - .iter() - .position(|token| token.eq_ignore_ascii_case("WHERE")) - .map(|relative| value_start + relative) - else { + let Some(where_index) = top_level_keyword_index(&tokens, value_start, "WHERE") else { return false; }; @@ -384,6 +425,8 @@ mod tests { "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;", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } From 79159f6d62178cc6f84836aa7e7e0fd68ccdb336 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:03:20 +0900 Subject: [PATCH 265/309] test(persistence): expose dynamic set_config replication-role bypass --- ...ation_session_replication_role_contract.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index ab5599f1b..3735e410f 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -142,3 +142,38 @@ SELECT 'WITH marker AS (SELECT 1) UPDATE pg_settings SET setting = replica WHERE ); 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(())); + } +} From 538b7a7b7e40d5350ed26bf3c50d8524fdcbb126 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:05:56 +0900 Subject: [PATCH 266/309] fix(persistence): fail closed on dynamic set_config replication values --- .../src/migration_validation.rs | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3a7913d87..0307b67db 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -56,19 +56,18 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { .is_none_or(|ch| ch != '.' && !is_function_identifier_continuation(ch)) } -/// Return whether one normalized statement calls PostgreSQL's `set_config` -/// with the direct `session_replication_role = replica` contract atoms. +/// Return whether one normalized statement can set `session_replication_role` +/// to a value that is not statically proven safe for ordinary triggers. /// -/// Argument whitespace is erased only after the shared lexical pass has made -/// strings and comments safe to inspect. Function identity is resolved before -/// compaction so statement whitespace cannot merge `SELECT` with `set_config`. -/// The bounded matcher accepts the unqualified builtin and explicit -/// `pg_catalog.set_config`, while excluding identifier prefixes and unrelated -/// schemas such as `audit_support.set_config`. More dynamic configuration -/// expressions remain for a future execution-context aggregate. -fn statement_calls_replica_set_config(statement: &str) -> bool { +/// 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. For the canonical direct setting-name atom, only direct `origin` and +/// `local` values are proven safe; `replica` and non-atomic value expressions 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: &str = "set_config('session_replication_role','replica',"; + const TARGET_PREFIX: &str = "set_config('session_replication_role',"; let lower = statement.to_ascii_lowercase(); let mut search_from = 0usize; @@ -80,8 +79,32 @@ fn statement_calls_replica_set_config(statement: &str) -> bool { .filter(|ch| !ch.is_whitespace()) .collect::() .to_ascii_lowercase(); - if compact_call.starts_with(CALL) { - return true; + if let Some(value_and_rest) = compact_call.strip_prefix(TARGET_PREFIX) { + let mut parenthesis_depth = 0usize; + let mut bracket_depth = 0usize; + let mut value_end = None; + for (index, ch) in value_and_rest.char_indices() { + match ch { + '(' => parenthesis_depth = parenthesis_depth.saturating_add(1), + ')' if parenthesis_depth > 0 => parenthesis_depth -= 1, + '[' => bracket_depth = bracket_depth.saturating_add(1), + ']' if bracket_depth > 0 => bracket_depth -= 1, + ',' if parenthesis_depth == 0 && bracket_depth == 0 => { + value_end = Some(index); + break; + } + ')' | ']' if parenthesis_depth == 0 && bracket_depth == 0 => break, + _ => {} + } + } + + let value = value_end + .map(|end| &value_and_rest[..end]) + .unwrap_or(value_and_rest); + let safe = matches!(value, "'origin'" | "'local'"); + if !safe { + return true; + } } } search_from = start + FUNCTION_NAME.len(); @@ -299,7 +322,7 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// and `local` atoms. fn committed_replica_trigger_execution_mode(sql: &str) -> bool { sql.split(';').any(|statement| { - if statement_calls_replica_set_config(statement) + if statement_calls_unsafe_set_config(statement) || statement_updates_unsafe_replication_role_via_pg_settings(statement) { return true; From 1cbe828ee0bee401e7ca0415dad191ba69c10c11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:08:28 +0900 Subject: [PATCH 267/309] test(persistence): expose named set_config replication-role bypass --- ...ation_session_replication_role_contract.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index 3735e410f..e72c4e5e5 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -177,3 +177,29 @@ fn dynamic_unrelated_set_config_identity_or_parameter_remains_unrelated() { 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(())); + } +} From 5c505745b8b1d9decc84e9763b661f652862d0ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:09:56 +0900 Subject: [PATCH 268/309] fix(persistence): fold named set_config arguments into execution guard --- .../src/migration_validation.rs | 112 ++++++++++++++---- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 0307b67db..216f29a57 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -62,12 +62,14 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { /// 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. For the canonical direct setting-name atom, only direct `origin` and -/// `local` values are proven safe; `replica` and non-atomic value expressions fail -/// closed because `set_config` is PostgreSQL's function equivalent of `SET`. +/// builtin. Positional, named (`=>` / `:=`), and mixed notation are folded into +/// the canonical `setting_name` / `new_value` slots. For the direct +/// `session_replication_role` setting-name atom, only direct `origin` and `local` +/// values are proven safe; `replica` and non-atomic value expressions 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 TARGET_PREFIX: &str = "set_config('session_replication_role',"; + const CALL_PREFIX: &str = "set_config("; let lower = statement.to_ascii_lowercase(); let mut search_from = 0usize; @@ -79,31 +81,99 @@ fn statement_calls_unsafe_set_config(statement: &str) -> bool { .filter(|ch| !ch.is_whitespace()) .collect::() .to_ascii_lowercase(); - if let Some(value_and_rest) = compact_call.strip_prefix(TARGET_PREFIX) { + + 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 value_end = None; - for (index, ch) in value_and_rest.char_indices() { - match ch { - '(' => parenthesis_depth = parenthesis_depth.saturating_add(1), - ')' if parenthesis_depth > 0 => parenthesis_depth -= 1, - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' if bracket_depth > 0 => bracket_depth -= 1, - ',' if parenthesis_depth == 0 && bracket_depth == 0 => { - value_end = Some(index); + 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; } - ')' | ']' if parenthesis_depth == 0 && bracket_depth == 0 => break, _ => {} } + index += 1; } - let value = value_end - .map(|end| &value_and_rest[..end]) - .unwrap_or(value_and_rest); - let safe = matches!(value, "'origin'" | "'local'"); - if !safe { - return true; + 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; + } + + if setting_name == Some("'session_replication_role'") { + let safe = matches!(new_value, Some("'origin'") | Some("'local'")); + if !safe { + return true; + } + } } } } From 81d75857cfb4c1935d78d4d561d3f90e1f0422bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:12:51 +0900 Subject: [PATCH 269/309] test(persistence): expose dynamic set_config setting-name bypass --- ...ation_session_replication_role_contract.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs index e72c4e5e5..8775e1f41 100644 --- a/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs +++ b/crates/persistence_postgres/tests/migration_session_replication_role_contract.rs @@ -203,3 +203,23 @@ fn named_set_config_safe_atoms_and_unrelated_parameter_remain_accepted() { 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(())); +} From cb63e7477333f5d1c86d701a486176c14d3896fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:14:52 +0900 Subject: [PATCH 270/309] fix(persistence): fail closed on dynamic set_config setting names --- .../src/migration_validation.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 216f29a57..5a0012458 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -63,10 +63,11 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { /// bodies opaque. Function identity is resolved before whitespace compaction so /// identifier prefixes and unrelated schemas cannot impersonate PostgreSQL's /// builtin. Positional, named (`=>` / `:=`), and mixed notation are folded into -/// the canonical `setting_name` / `new_value` slots. For the direct -/// `session_replication_role` setting-name atom, only direct `origin` and `local` -/// values are proven safe; `replica` and non-atomic value expressions fail closed -/// because `set_config` is PostgreSQL's function equivalent of `SET`. +/// the canonical `setting_name` / `new_value` slots. A direct quoted setting name +/// can prove an unrelated target. A dynamic setting-name expression cannot, so it +/// fails closed. 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("; @@ -168,11 +169,18 @@ fn statement_calls_unsafe_set_config(statement: &str) -> bool { positional_index += 1; } - if setting_name == Some("'session_replication_role'") { - let safe = matches!(new_value, Some("'origin'") | Some("'local'")); - if !safe { - return true; + 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('\'') => {} + Some(_) | None => return true, } } } From c9f9f3866ea71f831505cda6f617b3c9017bef86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:18:56 +0900 Subject: [PATCH 271/309] test(persistence): expose adjacent-string set_config target bypass --- ...ion_set_config_adjacent_string_contract.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_set_config_adjacent_string_contract.rs 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(())); +} From f241adc0dd7020b966f4011b187e0ceda93b35d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:20:23 +0900 Subject: [PATCH 272/309] fix(persistence): fail closed on compacted adjacent setting-name literals --- .../src/migration_validation.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 5a0012458..48fcfff4e 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -64,10 +64,12 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { /// identifier prefixes and unrelated schemas cannot impersonate PostgreSQL's /// builtin. 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. A dynamic setting-name expression cannot, so it -/// fails closed. 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`. +/// 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("; @@ -179,7 +181,8 @@ fn statement_calls_unsafe_set_config(statement: &str) -> bool { Some(name) if name.len() >= 2 && name.starts_with('\'') - && name.ends_with('\'') => {} + && name.ends_with('\'') + && !name[1..name.len() - 1].contains('\'') => {} Some(_) | None => return true, } } From e52c9b48bea1546128adf3cc90ed9b701a4057ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:31:49 +0900 Subject: [PATCH 273/309] test(persistence): expose pg_settings predicate targeting gap (#581) --- ...igration_pg_settings_predicate_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_pg_settings_predicate_contract.rs 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(())); +} From 68ffc8ac9f242658b5823582d7cdee07c3916692 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:33:39 +0900 Subject: [PATCH 274/309] fix(persistence): fail closed on unproven pg_settings predicates (#581) --- .../src/migration_validation.rs | 113 +++++++++++++----- 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 48fcfff4e..fe9723908 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -250,11 +250,11 @@ fn top_level_keyword_index(tokens: &[&str], start: usize, keyword: &str) -> Opti /// `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. The target `WHERE` is selected only at expression depth zero, so a -/// scalar-subquery predicate cannot hide the actual `pg_settings` row predicate. -/// Atomic `origin` and `local` values are proven safe for ordinary triggers; -/// `replica` and any non-atomic value fail closed because the validator cannot -/// prove that protected DML did not execute while triggers were suppressed. +/// forms. Direct `origin` and `local` assignment atoms are always safe for +/// ordinary triggers. 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('.', " . ") @@ -327,43 +327,90 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str { return false; } + let value_start = index + 3; - let Some(where_index) = top_level_keyword_index(&tokens, value_start, "WHERE") else { + 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 value_is_safe = value_tokens.len() == 1 + && (value_tokens[0] + .trim_matches('\'') + .eq_ignore_ascii_case("origin") + || value_tokens[0] + .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 mut name_index = where_index + 1; - if tokens.get(name_index + 1) == Some(&".") { - let qualifier = tokens[name_index]; - 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 { - return false; + let quoted_setting_name = |token: &str| -> Option<&str> { + if token.len() >= 2 + && token.starts_with('\'') + && token.ends_with('\'') + && !token[1..token.len() - 1].contains('\'') + { + Some(&token[1..token.len() - 1]) + } else { + None } - name_index += 2; + }; + + 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 + && let Some(setting_name) = tokens + .get(after_name + 1) + .and_then(|token| quoted_setting_name(token)) + { + return setting_name.eq_ignore_ascii_case("session_replication_role"); + } + return true; } - if !tokens - .get(name_index) - .is_some_and(|token| token.eq_ignore_ascii_case("name")) - || tokens.get(name_index + 1) != Some(&"=") - || !tokens.get(name_index + 2).is_some_and(|value| { - value - .trim_matches('\'') - .eq_ignore_ascii_case("session_replication_role") - }) + + if let Some(setting_name) = tokens + .get(predicate_start) + .and_then(|token| quoted_setting_name(token)) + && tokens.get(predicate_start + 1) == Some(&"=") + && let Some(after_name) = name_operand_end(predicate_start + 2) + && after_name == predicate_end { - return false; + return setting_name.eq_ignore_ascii_case("session_replication_role"); } - let value_tokens = &tokens[value_start..where_index]; - value_tokens.len() != 1 - || !value_tokens[0] - .trim_matches('\'') - .eq_ignore_ascii_case("origin") - && !value_tokens[0] - .trim_matches('\'') - .eq_ignore_ascii_case("local") + true } /// Detect an unsafe `pg_settings` update anywhere in one normalized statement. From 9522f8aaced730b7fdd6a4535cf0b0c777040a0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 17:35:17 +0900 Subject: [PATCH 275/309] fix(persistence): avoid borrowed predicate closure in #581 repair --- .../src/migration_validation.rs | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index fe9723908..44b6b3387 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -375,39 +375,39 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str } }; - let quoted_setting_name = |token: &str| -> Option<&str> { - if token.len() >= 2 + let is_direct_quoted_setting_name = |token: &str| -> bool { + token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') && !token[1..token.len() - 1].contains('\'') - { - Some(&token[1..token.len() - 1]) - } else { - None - } + }; + 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 - && let Some(setting_name) = tokens - .get(after_name + 1) - .and_then(|token| quoted_setting_name(token)) - { - return setting_name.eq_ignore_ascii_case("session_replication_role"); + 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) - .and_then(|token| quoted_setting_name(token)) - && tokens.get(predicate_start + 1) == Some(&"=") - && let Some(after_name) = name_operand_end(predicate_start + 2) - && after_name == predicate_end - { - return setting_name.eq_ignore_ascii_case("session_replication_role"); + 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 From 08fa13150c47c715de5b134084e45b78ad2e080e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:02:30 +0900 Subject: [PATCH 276/309] test(persistence): expose committed DO execution-context bypass --- ...migration_do_execution_context_contract.rs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_do_execution_context_contract.rs 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(())); +} From 92a4df632986237ca4de571992be585284d9b786 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:06:04 +0900 Subject: [PATCH 277/309] fix(persistence): fail closed on committed opaque DO execution --- .../src/migration_validation.rs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 44b6b3387..718dc1845 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -438,18 +438,26 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> false } -/// Detect committed PostgreSQL replica execution mode that suppresses ordinary triggers. +/// 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, so comments, opaque bodies, and rolled-back local settings cannot -/// manufacture this state. PostgreSQL permits optional `LOCAL`/`SESSION`, `TO` or -/// `=`, a quoted enum value, the equivalent `set_config` function, and equivalent -/// writes through `pg_settings.setting`, including CTE-wrapped UPDATE commands. -/// This bounded fold rejects execution modes that are directly `replica` or cannot -/// be statically proven safe while allowing the ordinary-trigger-safe `origin` -/// and `local` atoms. +/// projection. Comments and data literals are therefore opaque, while rolled-back +/// statements are absent. PostgreSQL `DO` executes an anonymous procedural body +/// immediately; because that body is intentionally opaque to this bounded SQL +/// validator, a committed top-level `DO` fails closed rather than being assumed +/// not to mutate `session_replication_role` or protected data. Direct SQL settings, +/// `set_config`, and writable `pg_settings.setting` retain their existing bounded +/// handling; `origin` and `local` remain the statically proven safe direct modes. 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")) + { + return true; + } + if statement_calls_unsafe_set_config(statement) || statement_updates_unsafe_replication_role_via_pg_settings(statement) { @@ -612,4 +620,4 @@ mod tests { assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); } -} +} \ No newline at end of file From 8d6154fe903632ee8d6be496ffed017d8eaca906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:15:04 +0900 Subject: [PATCH 278/309] test(persistence): expose committed CALL execution-context bypass --- ...gration_call_execution_context_contract.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_call_execution_context_contract.rs 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(())); +} From 5ff180ff521ccee698e6585a9f8f3f6cd86c59d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:17:42 +0900 Subject: [PATCH 279/309] fix(persistence): fail closed on committed procedure calls --- .../src/migration_validation.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 718dc1845..8b646178e 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -442,10 +442,9 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// /// 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` executes an anonymous procedural body -/// immediately; because that body is intentionally opaque to this bounded SQL -/// validator, a committed top-level `DO` fails closed rather than being assumed -/// not to mutate `session_replication_role` or protected data. Direct SQL settings, +/// 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`, and writable `pg_settings.setting` retain their existing bounded /// handling; `origin` and `local` remain the statically proven safe direct modes. fn committed_replica_trigger_execution_mode(sql: &str) -> bool { @@ -453,7 +452,9 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { if statement .split_whitespace() .next() - .is_some_and(|token| token.eq_ignore_ascii_case("DO")) + .is_some_and(|token| { + token.eq_ignore_ascii_case("DO") || token.eq_ignore_ascii_case("CALL") + }) { return true; } @@ -620,4 +621,4 @@ mod tests { assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); } -} \ No newline at end of file +} From c8c070901950e19f4f2a282c621d3ee6f647b96c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:31:23 +0900 Subject: [PATCH 280/309] test(persistence): cover pg_settings row assignment bypass --- ...ion_pg_settings_row_assignment_contract.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_pg_settings_row_assignment_contract.rs 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(())); +} From 1afba084829f5a03c641daf4f341d189bfbc1d68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 18:32:50 +0900 Subject: [PATCH 281/309] fix(persistence): recognize pg_settings row assignments --- .../src/migration_validation.rs | 66 ++++++++++++++----- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 8b646178e..a421a9a4b 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -250,11 +250,13 @@ fn top_level_keyword_index(tokens: &[&str], start: usize, keyword: &str) -> Opti /// `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. Direct `origin` and `local` assignment atoms are always safe for -/// ordinary triggers. 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. +/// 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. +/// 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('.', " . ") @@ -320,27 +322,53 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str if !tokens .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("SET")) - || !tokens - .get(index + 1) - .is_some_and(|token| token.eq_ignore_ascii_case("setting")) - || tokens.get(index + 2) != Some(&"=") { return false; } - let value_start = index + 3; + 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 value_is_safe = value_tokens.len() == 1 - && (value_tokens[0] - .trim_matches('\'') - .eq_ignore_ascii_case("origin") - || value_tokens[0] - .trim_matches('\'') - .eq_ignore_ascii_case("local")); + 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; } @@ -587,6 +615,8 @@ mod tests { "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';", ] { assert_eq!(declares_created_role(sql, "tepp_app_runtime"), Some(false)); } @@ -604,6 +634,8 @@ mod tests { "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; 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;", From c7c4cb281829152b4ad429d61b1d9beef0bc9ef4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:01:18 +0900 Subject: [PATCH 282/309] test(persistence): expose Unicode escaped identifier bypass #585 --- ...ion_unicode_escaped_identifier_contract.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs 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..9c9453c8f --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -0,0 +1,42 @@ +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 pg_catalog . U&"pg_settings" SET 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 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(())); +} From da359c565cebb05604653b4907d5af6824c8c3d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:04:31 +0900 Subject: [PATCH 283/309] test(persistence): cover Unicode identifier UESCAPE form #585 --- .../tests/migration_unicode_escaped_identifier_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs index 9c9453c8f..cc5bf4131 100644 --- a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -13,6 +13,7 @@ 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 pg_catalog . U&"pg_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#, ] { assert_eq!( From 401e9fbb1b073e9a77726dbd017bc5dc6909b9fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:06:01 +0900 Subject: [PATCH 284/309] test(persistence): preserve unrelated Unicode relation control #585 --- .../tests/migration_unicode_escaped_identifier_contract.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs index cc5bf4131..034f7d06f 100644 --- a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -24,6 +24,12 @@ fn unicode_escaped_pg_settings_identity_cannot_bypass_replication_role_guard() { } } +#[test] +fn unrelated_safe_unicode_escaped_relation_identity_remains_unrelated() { + let final_sql = r#"UPDATE U&"audit_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;"#; From 8a2c584ebb1d297635b04940519ad9a18e2bcfc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:07:50 +0900 Subject: [PATCH 285/309] fix(persistence): bound Unicode escaped pg_settings targets #585 --- .../src/migration_validation.rs | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index a421a9a4b..c42b1a637 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -253,6 +253,12 @@ fn top_level_keyword_index(tokens: &[&str], start: usize, keyword: &str) -> Opti /// 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 relation names currently reach this layer as +/// a structural `U&` marker followed by the shared quoted-identifier projection. +/// A directly equivalent lowercase `pg_settings` spelling is folded into this +/// same authority, while an invalid/escaped projection fails closed because its +/// decoded identity is not yet owned. Safe projected unrelated names remain +/// unrelated; 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 @@ -280,24 +286,71 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str { index += 1; } + + let mut unicode_escaped_target = false; 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("pg_settings") { + unicode_escaped_target = true; + index += 2; + } else if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + return true; + } else { + return false; + } } else if tokens .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("pg_catalog")) && tokens.get(index + 1) == Some(&".") - && tokens + { + if tokens .get(index + 2) .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) - { - index += 3; + { + index += 3; + } else if tokens + .get(index + 2) + .is_some_and(|token| token.eq_ignore_ascii_case("U&")) + { + let Some(projected_name) = tokens.get(index + 3) else { + return true; + }; + if projected_name.eq_ignore_ascii_case("pg_settings") { + unicode_escaped_target = true; + index += 4; + } else if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + return true; + } else { + return false; + } + } else { + return false; + } } else { return false; } + if unicode_escaped_target + && tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + { + // The shared lexer has already consumed the one-character UESCAPE + // literal. The identifier has no escapes if it projected to the exact + // lowercase `pg_settings` atom, so the clause does not change identity. + index += 1; + } + if tokens.get(index) == Some(&"*") { index += 1; } @@ -617,6 +670,8 @@ mod tests { "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)); } @@ -637,6 +692,7 @@ mod tests { "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;", ] { From 33f842c7a9919de18acc828d2fbe980d8eb66655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:08:26 +0900 Subject: [PATCH 286/309] test(persistence): expose preserved UESCAPE atom bypass #585 --- .../tests/migration_unicode_escaped_identifier_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs index 034f7d06f..551e1c847 100644 --- a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -14,6 +14,7 @@ fn unicode_escaped_pg_settings_identity_cannot_bypass_replication_role_guard() { 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';"#, ] { assert_eq!( From 5e9f279314ba040e9053b9d4bf1a2fac9e7cb469 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:09:58 +0900 Subject: [PATCH 287/309] fix(persistence): consume preserved UESCAPE atom #585 --- .../persistence_postgres/src/migration_validation.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index c42b1a637..3c03afd66 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -345,10 +345,16 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) { - // The shared lexer has already consumed the one-character UESCAPE - // literal. The identifier has no escapes if it projected to the exact - // lowercase `pg_settings` atom, so the clause does not change identity. + // The shared lexer may preserve an atomic one-character UESCAPE + // literal (for example `_`) or mask a punctuation escape such as `!`. + // Neither changes an identifier that already projected to the exact + // lowercase `pg_settings` atom, so consume the optional preserved atom. index += 1; + if tokens.get(index).is_some_and(|token| { + token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') + }) { + index += 1; + } } if tokens.get(index) == Some(&"*") { From b4a94423c43fec9a0955d7c4c48993b8eed43a83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:11:32 +0900 Subject: [PATCH 288/309] test(persistence): cover Unicode schema alias and column bypasses #585 --- .../migration_unicode_escaped_identifier_contract.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs index 551e1c847..4a74675ef 100644 --- a/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs +++ b/crates/persistence_postgres/tests/migration_unicode_escaped_identifier_contract.rs @@ -16,6 +16,10 @@ fn unicode_escaped_pg_settings_identity_cannot_bypass_replication_role_guard() { 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)), @@ -27,8 +31,12 @@ fn unicode_escaped_pg_settings_identity_cannot_bypass_replication_role_guard() { #[test] fn unrelated_safe_unicode_escaped_relation_identity_remains_unrelated() { - let final_sql = r#"UPDATE U&"audit_settings" SET setting = 'replica' WHERE name = 'session_replication_role';"#; - assert_eq!(validate_migration_catalog(&embedded_with(final_sql)), Ok(())); + 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] From 1880e787bd01b3a3962bcdee29fecfb186b209db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 19:13:19 +0900 Subject: [PATCH 289/309] fix(persistence): bound Unicode escaped schema alias and columns #585 --- .../src/migration_validation.rs | 104 ++++++++++++------ 1 file changed, 70 insertions(+), 34 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3c03afd66..f7b4aa8e4 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -253,12 +253,15 @@ fn top_level_keyword_index(tokens: &[&str], start: usize, keyword: &str) -> Opti /// 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 relation names currently reach this layer as -/// a structural `U&` marker followed by the shared quoted-identifier projection. -/// A directly equivalent lowercase `pg_settings` spelling is folded into this -/// same authority, while an invalid/escaped projection fails closed because its -/// decoded identity is not yet owned. Safe projected unrelated names remain -/// unrelated; no second raw-SQL lexer is introduced here. +/// 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 @@ -287,7 +290,21 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str index += 1; } - let mut unicode_escaped_target = false; + 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")) @@ -300,11 +317,39 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str let Some(projected_name) = tokens.get(index + 1) else { return true; }; - if projected_name.eq_ignore_ascii_case("pg_settings") { - unicode_escaped_target = true; - index += 2; - } else if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + 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; } @@ -313,26 +358,26 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str .is_some_and(|token| token.eq_ignore_ascii_case("pg_catalog")) && tokens.get(index + 1) == Some(&".") { + let relation_start = index + 2; if tokens - .get(index + 2) + .get(relation_start) .is_some_and(|token| token.eq_ignore_ascii_case("pg_settings")) { - index += 3; + index = relation_start + 1; } else if tokens - .get(index + 2) + .get(relation_start) .is_some_and(|token| token.eq_ignore_ascii_case("U&")) { - let Some(projected_name) = tokens.get(index + 3) else { + let Some(projected_name) = tokens.get(relation_start + 1) else { return true; }; - if projected_name.eq_ignore_ascii_case("pg_settings") { - unicode_escaped_target = true; - index += 4; - } else if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { + if projected_name.eq_ignore_ascii_case("INVALID_QUOTED_IDENTIFIER") { return true; - } else { + } + if !projected_name.eq_ignore_ascii_case("pg_settings") { return false; } + index = skip_unicode_uescape(relation_start + 2); } else { return false; } @@ -340,21 +385,12 @@ fn update_targets_unsafe_replication_role_via_pg_settings(update_statement: &str return false; } - if unicode_escaped_target - && tokens - .get(index) - .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) + if tokens + .iter() + .skip(index) + .any(|token| token.eq_ignore_ascii_case("U&")) { - // The shared lexer may preserve an atomic one-character UESCAPE - // literal (for example `_`) or mask a punctuation escape such as `!`. - // Neither changes an identifier that already projected to the exact - // lowercase `pg_settings` atom, so consume the optional preserved atom. - index += 1; - if tokens.get(index).is_some_and(|token| { - token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') - }) { - index += 1; - } + return true; } if tokens.get(index) == Some(&"*") { From aa451a64b529be33a917ef7f32d05d82bca22764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 20:02:34 +0900 Subject: [PATCH 290/309] test(persistence): expose Unicode SET parameter bypass (#586) --- ...igration_unicode_set_parameter_contract.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_unicode_set_parameter_contract.rs 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(())); +} From 7f7a8d83ec8d6a6c4404ccbb49aef91006a89ce4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 20:04:50 +0900 Subject: [PATCH 291/309] fix(persistence): guard Unicode SET parameter identity (#586) --- .../src/migration_validation.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index f7b4aa8e4..800dc668e 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -569,7 +569,11 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// 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`, and writable `pg_settings.setting` retain their existing bounded -/// handling; `origin` and `local` remain the statically proven safe direct modes. +/// handling. 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 @@ -603,13 +607,40 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { }) { index += 1; } - if !tokens + + 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; } - index += 1; + if !tokens.get(index).is_some_and(|token| { *token == "=" || token.eq_ignore_ascii_case("TO") }) { From 8cead208310257e59fcb7779a09049c966a11ff8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 21:00:49 +0900 Subject: [PATCH 292/309] test(persistence): expose Unicode set_config identity bypass --- ...on_unicode_set_config_identity_contract.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs 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..da2f639dc --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_set_config_identity_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 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);"#, + ] { + 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);"#, + ] { + 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);"#, + ] { + 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(())); +} From 6de2b13dcd1432fc3c64eed44890763fcf836f21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 21:05:29 +0900 Subject: [PATCH 293/309] fix(persistence): bound Unicode set_config identity --- .../src/migration_validation.rs | 128 ++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 800dc668e..cdd67f172 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -56,23 +56,135 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { .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 as part of that already-normalized identifier +/// representation, so this helper 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")) + { + let escape = tokens.get(end + 1)?; + if escape.len() < 2 || !escape.starts_with('\'') || !escape.ends_with('\'') { + return None; + } + end += 2; + } + 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. 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`. +/// 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; From 9b47d91c0e7996fef6973b4b1aaaa9c22b6f7b2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 21:05:56 +0900 Subject: [PATCH 294/309] test(persistence): harden Unicode set_config identity controls --- .../tests/migration_unicode_set_config_identity_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) 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 index da2f639dc..4b31da429 100644 --- a/crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs +++ b/crates/persistence_postgres/tests/migration_unicode_set_config_identity_contract.rs @@ -14,6 +14,8 @@ fn unicode_escaped_set_config_builtin_identity_cannot_bypass_replication_role_gu 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)), @@ -28,6 +30,7 @@ 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(())); } @@ -38,6 +41,7 @@ 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(())); } From 0790db3b27bb1eb12ec4cdaf11a78c66c96c8de6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 21:09:51 +0900 Subject: [PATCH 295/309] fix(persistence): accept masked UESCAPE projection --- .../src/migration_validation.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index cdd67f172..3506b9704 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -65,8 +65,10 @@ fn is_builtin_set_config_occurrence(statement: &str, start: usize) -> bool { /// 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 as part of that already-normalized identifier -/// representation, so this helper does not become a second raw-SQL lexer. +/// `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('(', " ( "); @@ -87,11 +89,12 @@ fn project_potential_unicode_set_config_identity(statement: &str) -> String { .get(end) .is_some_and(|token| token.eq_ignore_ascii_case("UESCAPE")) { - let escape = tokens.get(end + 1)?; - if escape.len() < 2 || !escape.starts_with('\'') || !escape.ends_with('\'') { - return None; + end += 1; + if tokens.get(end).is_some_and(|escape| { + escape.len() >= 2 && escape.starts_with('\'') && escape.ends_with('\'') + }) { + end += 1; } - end += 2; } Some(end) }; From 9fdd078f7459a3d1e468f50846d44c43038f4c6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 22:06:24 +0900 Subject: [PATCH 296/309] test(persistence): expose persistent replica login-default bypass --- ...replication_role_login_default_contract.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs 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..6a2d8acd6 --- /dev/null +++ b/crates/persistence_postgres/tests/migration_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 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 ROLE ALL 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;", + ] { + 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 ROLE tepp_app_runtime SET session_replication_role TO 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 ROLE audit_runtime SET session_replication_role = 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 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 ROLE 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(())); +} From 7cf0f42eda53d0bc6452bafc6fc69f4b8afadbfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 22:10:59 +0900 Subject: [PATCH 297/309] fix(persistence): reject persistent replica login defaults --- .../src/migration_validation.rs | 137 ++++++++++++++++-- 1 file changed, 127 insertions(+), 10 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 3506b9704..db4714ef7 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -676,6 +676,120 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> 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. Role-specific defaults are +/// relevant for `tepp_app_runtime`, PostgreSQL pseudo-current-role targets, and +/// `ALL`; 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 { + 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 mut index = 1usize; + let scope = tokens.get(index).copied(); + let role_scoped = scope.is_some_and(|token| token.eq_ignore_ascii_case("ROLE")); + 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 { + 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")) + || tokens.get(index + 2).is_none() + { + return true; + } + index += 3; + } + } else if database_scoped { + if tokens.get(index).is_none() { + return false; + } + index += 1; + } + + if tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) + { + return tokens.get(index + 1).is_some_and(|parameter| { + parameter.eq_ignore_ascii_case("session_replication_role") + || parameter.eq_ignore_ascii_case("ALL") + }); + } + 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")) + { + return false; + } + index += 1; + + 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 @@ -683,12 +797,13 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// 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`, and writable `pg_settings.setting` retain their existing bounded -/// handling. 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. +/// `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 @@ -703,6 +818,7 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { 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; } @@ -783,9 +899,10 @@ fn committed_replica_trigger_execution_mode(sql: &str) -> bool { /// 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, or canonical `pg_settings` update -/// also fails the runtime-role contract because it can suppress ordinary -/// enforcement triggers while durable catalog definitions remain enabled. +/// `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( @@ -897,4 +1014,4 @@ mod tests { assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); } -} +} \ No newline at end of file From d1088e7ed81595665c296c4151c3800e1d42162c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 22:13:32 +0900 Subject: [PATCH 298/309] test(persistence): cover ALTER USER replica defaults --- ...ration_replication_role_login_default_contract.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 6a2d8acd6..826bf907e 100644 --- a/crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs +++ b/crates/persistence_postgres/tests/migration_replication_role_login_default_contract.rs @@ -13,9 +13,13 @@ fn committed_role_replica_login_defaults_cannot_bypass_runtime_trigger_enforceme 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)), @@ -43,7 +47,9 @@ fn committed_database_or_system_replica_defaults_fail_closed() { 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;", @@ -56,7 +62,10 @@ fn ordinary_trigger_safe_login_defaults_remain_accepted() { 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';", ] { @@ -68,6 +77,7 @@ fn unrelated_persistent_defaults_do_not_impersonate_replication_role() { 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;", ] { @@ -80,7 +90,7 @@ 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 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'; "#, From 8c2d868031801874a0fb09108417ba7502138e3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 22:15:19 +0900 Subject: [PATCH 299/309] fix(persistence): include ALTER USER login-default alias --- .../persistence_postgres/src/migration_validation.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index db4714ef7..6ae036a60 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -681,9 +681,10 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// /// 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. Role-specific defaults are +/// 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`; database and system defaults are conservatively relevant because this +/// `ALL`. 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 @@ -702,7 +703,9 @@ fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> let mut index = 1usize; let scope = tokens.get(index).copied(); - let role_scoped = scope.is_some_and(|token| token.eq_ignore_ascii_case("ROLE")); + 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 { @@ -1014,4 +1017,4 @@ mod tests { assert!(normalized.contains("GRANTED BY __tepp_quoted_grantor_identity_")); assert!(normalized.contains("CREATE TYPE INVALID_QUOTED_IDENTIFIER")); } -} \ No newline at end of file +} From 64735626dd86616020cdb60b90028adbf1d2a70a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 22:58:54 +0900 Subject: [PATCH 300/309] test(persistence): expose Unicode persistent default bypass --- ...replication_role_login_default_contract.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 crates/persistence_postgres/tests/migration_unicode_replication_role_login_default_contract.rs 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..0829a7eed --- /dev/null +++ b/crates/persistence_postgres/tests/migration_unicode_replication_role_login_default_contract.rs @@ -0,0 +1,78 @@ +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;"#, + ] { + 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 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;"#, + ] { + 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;"#, + ] { + 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(())); +} From f4e2aabbd844bfd16c3f83a4d18b1253bf60fdc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 23:00:28 +0900 Subject: [PATCH 301/309] test(persistence): cover Unicode reset defaults --- ...ion_unicode_replication_role_login_default_contract.rs | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 0829a7eed..bfd6e6a01 100644 --- 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 @@ -30,6 +30,10 @@ fn unicode_escaped_replication_parameter_identity_is_protected_on_all_default_su 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)), @@ -48,6 +52,9 @@ fn unicode_escaped_persistent_defaults_preserve_safe_and_unrelated_controls() { 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(())); } @@ -59,6 +66,7 @@ fn rolled_back_unicode_persistent_default_is_not_durable() { 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(())); } From 41f9cb437ee5ae114b46e15ab2ed2ff8d957be43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 23:05:16 +0900 Subject: [PATCH 302/309] fix(persistence): bind Unicode persistent defaults --- .../src/migration_validation.rs | 153 +++++++++++++++--- 1 file changed, 127 insertions(+), 26 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 6ae036a60..36d6a6bf0 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -684,14 +684,19 @@ fn statement_updates_unsafe_replication_role_via_pg_settings(statement: &str) -> /// 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`. 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. +/// `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 @@ -701,6 +706,38 @@ fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> 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| { @@ -714,18 +751,34 @@ fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> index += 1; if role_scoped { - 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; + 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; } - index += 1; + if tokens .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("IN")) @@ -733,26 +786,60 @@ fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> if !tokens .get(index + 1) .is_some_and(|token| token.eq_ignore_ascii_case("DATABASE")) - || tokens.get(index + 2).is_none() { return true; } - index += 3; + 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_none() { + 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; } - index += 1; } if tokens .get(index) .is_some_and(|token| token.eq_ignore_ascii_case("RESET")) { - return tokens.get(index + 1).is_some_and(|parameter| { + 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") - || parameter.eq_ignore_ascii_case("ALL") }); } if !tokens @@ -763,13 +850,27 @@ fn statement_sets_unsafe_persistent_replication_role_default(statement: &str) -> } index += 1; - if !tokens + 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; } - index += 1; if tokens .get(index) From 5e356d68116dfb18294a487c505227470067ae0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 23:09:19 +0900 Subject: [PATCH 303/309] test(persistence): preserve Unicode named special roles --- ...igration_unicode_replication_role_login_default_contract.rs | 3 +++ 1 file changed, 3 insertions(+) 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 index bfd6e6a01..3af97ae93 100644 --- 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 @@ -49,6 +49,9 @@ fn unicode_escaped_persistent_defaults_preserve_safe_and_unrelated_controls() { 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;"#, From 6583ac5963665305ba08ab1ac2c5fd52b6450d22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 18 Sep 2026 23:11:06 +0900 Subject: [PATCH 304/309] fix(persistence): preserve Unicode named role identity --- .../src/migration_validation.rs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/persistence_postgres/src/migration_validation.rs b/crates/persistence_postgres/src/migration_validation.rs index 36d6a6bf0..a3f0cc1ae 100644 --- a/crates/persistence_postgres/src/migration_validation.rs +++ b/crates/persistence_postgres/src/migration_validation.rs @@ -1028,12 +1028,44 @@ pub(super) fn declares_row_level_security(normalized_sql: &str) -> bool { /// /// The replacement is deliberately limited to lowercase quoted spellings, the /// only form that the lifecycle projection would otherwise dequote as -/// identity-equivalent. Replacements inside comments, literals, or dollar bodies -/// remain inert because the shared lexical pass still owns those regions. +/// 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 { - sql.replace("\"current_role\"", "\"CURRENT_ROLE\"") - .replace("\"current_user\"", "\"CURRENT_USER\"") - .replace("\"session_user\"", "\"SESSION_USER\"") + 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)] From 49e40000a7f0c85747d9f94593b865dfbb2fb975 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 00:01:28 +0900 Subject: [PATCH 305/309] test(persistence): expose whitespace-qualified RLS state aliasing --- ...igration_rls_table_final_state_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 index e74b2fde6..acff3cd3d 100644 --- a/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs @@ -61,3 +61,31 @@ fn later_enable_and_force_restore_the_required_final_state() { ); assert_eq!(validate_migration_catalog(&catalog), Ok(())); } + +#[test] +fn whitespace_qualified_sibling_table_cannot_repair_disabled_rls_state() { + let embedded = MigrationCatalog::from_embedded().expect("embedded catalog must load"); + let up_sql = format!( + "{}\nALTER TABLE public . tenant_record DISABLE ROW LEVEL SECURITY;\nALTER TABLE public . event_instance ENABLE ROW LEVEL SECURITY;\nALTER TABLE public . event_instance 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 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(())); +} From 156b873cef8bf3fe91ee729c50f82da817ed684c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 00:01:54 +0900 Subject: [PATCH 306/309] test(persistence): cover adjacent qualified RLS target alias --- ...igration_rls_table_final_state_contract.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 index acff3cd3d..7ee258bc5 100644 --- a/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_rls_table_final_state_contract.rs @@ -62,11 +62,13 @@ fn later_enable_and_force_restore_the_required_final_state() { assert_eq!(validate_migration_catalog(&catalog), Ok(())); } -#[test] -fn whitespace_qualified_sibling_table_cannot_repair_disabled_rls_state() { +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 public . tenant_record DISABLE ROW LEVEL SECURITY;\nALTER TABLE public . event_instance ENABLE ROW LEVEL SECURITY;\nALTER TABLE public . event_instance FORCE ROW LEVEL SECURITY;", + "{}\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()); @@ -78,6 +80,22 @@ fn whitespace_qualified_sibling_table_cannot_repair_disabled_rls_state() { ); } +#[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"); From 2279ede859f304b70d43aded832a7dfca84b0de6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 00:03:24 +0900 Subject: [PATCH 307/309] fix(persistence): fail closed on qualified RLS targets --- .../src/migration_rls_table_state.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/persistence_postgres/src/migration_rls_table_state.rs b/crates/persistence_postgres/src/migration_rls_table_state.rs index 3b0f50cee..2bc09f611 100644 --- a/crates/persistence_postgres/src/migration_rls_table_state.rs +++ b/crates/persistence_postgres/src/migration_rls_table_state.rs @@ -79,14 +79,22 @@ fn is_alter_table(statement: &[&str]) -> bool { /// Extract the direct unqualified table target owned by the existing structural validator. /// -/// `ONLY`, `IF EXISTS`, qualification, and quoted-identity sentinels remain -/// outside this bounded grammar. If such a statement carries an RLS state action, -/// the caller fails closed rather than guessing which durable relation changed. +/// `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() @@ -175,4 +183,14 @@ mod tests { "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)); + } + } } From b741bca4d72b35857b78645d07d81f96b96ab451 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 01:06:37 +0900 Subject: [PATCH 308/309] test(persistence): expose ALTER TABLE SET SCHEMA stale identity --- ...ration_alter_table_final_state_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index 416b4b4ec..aa53aa9d9 100644 --- a/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs @@ -43,17 +43,41 @@ fn committed_tenant_column_rename_cannot_reuse_historical_column_evidence() { ); } +#[test] +fn committed_table_schema_move_cannot_reuse_historical_relation_identity() { + for mutation in [ + "ALTER TABLE tenant_record SET SCHEMA archive;", + "ALTER TABLE IF EXISTS tenant_record SET SCHEMA archive;", + ] { + let catalog = table_catalog(mutation); + assert_eq!( + validate_migration_catalog(&catalog), + Err(MigrationContractError::UnsupportedTableFinalStateMutation), + "moving the relation to another schema must invalidate historical unqualified table evidence" + ); + } +} + #[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;", + "BEGIN; ALTER TABLE tenant_record SET SCHEMA archive; ROLLBACK;", ] { let catalog = table_catalog(mutation); assert_eq!(validate_migration_catalog(&catalog), Ok(())); } } +#[test] +fn set_schema_marker_text_outside_structure_remains_inert() { + let catalog = table_catalog( + "SELECT 'ALTER TABLE tenant_record SET SCHEMA archive'; -- ALTER TABLE tenant_record SET SCHEMA archive", + ); + assert_eq!(validate_migration_catalog(&catalog), Ok(())); +} + #[test] fn create_table_and_rls_only_catalog_remains_supported() { let catalog = table_catalog(""); From 2e3c57bcabff660a69c2ce5fcae45ffd1424bbd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 01:08:37 +0900 Subject: [PATCH 309/309] revert(test): retire unsupported SET SCHEMA hypothesis --- ...ration_alter_table_final_state_contract.rs | 24 ------------------- 1 file changed, 24 deletions(-) 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 index aa53aa9d9..416b4b4ec 100644 --- a/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs +++ b/crates/persistence_postgres/tests/migration_alter_table_final_state_contract.rs @@ -43,41 +43,17 @@ fn committed_tenant_column_rename_cannot_reuse_historical_column_evidence() { ); } -#[test] -fn committed_table_schema_move_cannot_reuse_historical_relation_identity() { - for mutation in [ - "ALTER TABLE tenant_record SET SCHEMA archive;", - "ALTER TABLE IF EXISTS tenant_record SET SCHEMA archive;", - ] { - let catalog = table_catalog(mutation); - assert_eq!( - validate_migration_catalog(&catalog), - Err(MigrationContractError::UnsupportedTableFinalStateMutation), - "moving the relation to another schema must invalidate historical unqualified table evidence" - ); - } -} - #[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;", - "BEGIN; ALTER TABLE tenant_record SET SCHEMA archive; ROLLBACK;", ] { let catalog = table_catalog(mutation); assert_eq!(validate_migration_catalog(&catalog), Ok(())); } } -#[test] -fn set_schema_marker_text_outside_structure_remains_inert() { - let catalog = table_catalog( - "SELECT 'ALTER TABLE tenant_record SET SCHEMA archive'; -- ALTER TABLE tenant_record SET SCHEMA archive", - ); - assert_eq!(validate_migration_catalog(&catalog), Ok(())); -} - #[test] fn create_table_and_rls_only_catalog_remains_supported() { let catalog = table_catalog("");