use crate::config::Tenancy;
use sqlx::PgPool;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TableName {
pub schema: String,
pub table: String,
}
impl fmt::Display for TableName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.schema, self.table)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PolicyRef {
pub schema: String,
pub table: String,
pub policy: String,
}
impl fmt::Display for PolicyRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}::{}", self.schema, self.table, self.policy)
}
}
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct Report {
pub rls_no_policy: Vec<TableName>,
pub policy_rls_off: Vec<TableName>,
pub policy_no_force: Vec<TableName>,
pub policy_no_with_check: Vec<PolicyRef>,
pub policy_fail_open: Vec<PolicyRef>,
pub tenant_col_no_policy: Vec<TableName>,
}
impl Report {
pub fn is_clean(&self) -> bool {
self.rls_no_policy.is_empty()
&& self.policy_rls_off.is_empty()
&& self.policy_no_force.is_empty()
&& self.policy_no_with_check.is_empty()
&& self.policy_fail_open.is_empty()
&& self.tenant_col_no_policy.is_empty()
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_clean() {
return write!(f, "tenaxum audit: clean.");
}
writeln!(f, "tenaxum audit: invariants broken")?;
if !self.rls_no_policy.is_empty() {
writeln!(f, " RLS enabled but no policy attached:")?;
for t in &self.rls_no_policy {
writeln!(f, " - {t}")?;
}
}
if !self.policy_rls_off.is_empty() {
writeln!(
f,
" policy attached but RLS not enabled (silently ignored):"
)?;
for t in &self.policy_rls_off {
writeln!(f, " - {t}")?;
}
}
if !self.policy_no_force.is_empty() {
writeln!(
f,
" policy + RLS but no FORCE (owner-role bypass — see ALTER TABLE ... FORCE):"
)?;
for t in &self.policy_no_force {
writeln!(f, " - {t}")?;
}
}
if !self.policy_no_with_check.is_empty() {
writeln!(
f,
" policy without explicit WITH CHECK on a write command (house-rule recommendation):"
)?;
for p in &self.policy_no_with_check {
writeln!(f, " - {p}")?;
}
}
if !self.policy_fail_open.is_empty() {
writeln!(f, " policy uses fail-open COALESCE pattern:")?;
for p in &self.policy_fail_open {
writeln!(f, " - {p}")?;
}
}
if !self.tenant_col_no_policy.is_empty() {
writeln!(f, " table has tenant column but no policy attached:")?;
for t in &self.tenant_col_no_policy {
writeln!(f, " - {t}")?;
}
}
Ok(())
}
}
impl Tenancy {
pub async fn ensure_isolation(&self, pool: &PgPool) -> sqlx::Result<Report> {
let schemas: Vec<String> = self.schemas.iter().map(|s| s.to_string()).collect();
let tenant_col = self.tenant_column.as_ref();
let mut report = Report::default();
let rows: Vec<(String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table"
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
AND c.relkind = 'r'
AND c.relrowsecurity
AND NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
ORDER BY n.nspname, c.relname
"#,
)
.bind(&schemas)
.fetch_all(pool)
.await?;
report.rls_no_policy = rows
.into_iter()
.map(|(schema, table)| TableName { schema, table })
.collect();
let rows: Vec<(String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table"
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
AND c.relkind = 'r'
AND NOT c.relrowsecurity
AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
ORDER BY n.nspname, c.relname
"#,
)
.bind(&schemas)
.fetch_all(pool)
.await?;
report.policy_rls_off = rows
.into_iter()
.map(|(schema, table)| TableName { schema, table })
.collect();
let rows: Vec<(String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table"
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
AND c.relkind = 'r'
AND c.relrowsecurity
AND NOT c.relforcerowsecurity
AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
ORDER BY n.nspname, c.relname
"#,
)
.bind(&schemas)
.fetch_all(pool)
.await?;
report.policy_no_force = rows
.into_iter()
.map(|(schema, table)| TableName { schema, table })
.collect();
let rows: Vec<(String, String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table", p.polname::text AS policy
FROM pg_policy p
JOIN pg_class c ON c.oid = p.polrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
AND p.polcmd IN ('a', 'w', '*')
AND p.polwithcheck IS NULL
ORDER BY n.nspname, c.relname, p.polname
"#,
)
.bind(&schemas)
.fetch_all(pool)
.await?;
report.policy_no_with_check = rows
.into_iter()
.map(|(schema, table, policy)| PolicyRef {
schema,
table,
policy,
})
.collect();
let rows: Vec<(String, String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table", p.polname::text AS policy
FROM pg_policy p
JOIN pg_class c ON c.oid = p.polrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ANY($1)
AND (
COALESCE(pg_get_expr(p.polqual, p.polrelid), '') ILIKE '%coalesce%current_setting%'
OR COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), '') ILIKE '%coalesce%current_setting%'
)
ORDER BY n.nspname, c.relname, p.polname
"#,
)
.bind(&schemas)
.fetch_all(pool)
.await?;
report.policy_fail_open = rows
.into_iter()
.map(|(schema, table, policy)| PolicyRef {
schema,
table,
policy,
})
.collect();
let rows: Vec<(String, String)> = sqlx::query_as(
r#"
SELECT n.nspname::text AS schema, c.relname::text AS "table"
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE n.nspname = ANY($1)
AND c.relkind = 'r'
AND a.attname = $2
AND a.attnum > 0
AND NOT a.attisdropped
AND NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
ORDER BY n.nspname, c.relname
"#,
)
.bind(&schemas)
.bind(tenant_col)
.fetch_all(pool)
.await?;
report.tenant_col_no_policy = rows
.into_iter()
.map(|(schema, table)| TableName { schema, table })
.collect();
Ok(report)
}
pub fn scan_migrations<P: AsRef<Path>>(&self, dir: P) -> std::io::Result<Vec<Lint>> {
scan_migrations(dir)
}
}
pub async fn ensure_isolation(pool: &PgPool) -> sqlx::Result<Report> {
Tenancy::default().ensure_isolation(pool).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Lint {
pub file: PathBuf,
pub line: usize,
pub kind: LintKind,
pub snippet: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LintKind {
PolicyMissingWithCheck,
}
impl fmt::Display for LintKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LintKind::PolicyMissingWithCheck => write!(f, "CREATE POLICY without WITH CHECK"),
}
}
}
impl fmt::Display for Lint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}:{}: {} — {}",
self.file.display(),
self.line,
self.kind,
self.snippet
)
}
}
pub fn scan_migrations<P: AsRef<Path>>(dir: P) -> std::io::Result<Vec<Lint>> {
let mut out = Vec::new();
let dir = dir.as_ref();
let entries = std::fs::read_dir(dir)?;
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.is_file()
&& p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("sql"))
})
.collect();
paths.sort();
for path in paths {
let body = std::fs::read_to_string(&path)?;
scan_sql(&path, &body, &mut out);
}
Ok(out)
}
fn scan_sql(file: &Path, body: &str, out: &mut Vec<Lint>) {
let stripped = strip_sql_comments(body);
for stmt in split_statements(&stripped) {
let upper = stmt.text.to_ascii_uppercase();
if !upper.contains("CREATE POLICY") {
continue;
}
if upper.contains("FOR SELECT") || upper.contains("FOR DELETE") {
continue;
}
if upper.contains("WITH CHECK") {
continue;
}
out.push(Lint {
file: file.to_path_buf(),
line: stmt.line,
kind: LintKind::PolicyMissingWithCheck,
snippet: shorten(&collapse_ws(stmt.text)),
});
}
}
struct Stmt<'a> {
text: &'a str,
line: usize,
}
fn split_statements(body: &str) -> Vec<Stmt<'_>> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut line_at_start = 1usize;
let mut current_line = 1usize;
for (i, ch) in body.char_indices() {
if start.is_none() && !ch.is_whitespace() {
start = Some(i);
line_at_start = current_line;
}
if ch == ';' {
if let Some(s) = start {
out.push(Stmt {
text: &body[s..i],
line: line_at_start,
});
start = None;
}
}
if ch == '\n' {
current_line += 1;
}
}
if let Some(s) = start {
let text = &body[s..];
if !text.trim().is_empty() {
out.push(Stmt {
text,
line: line_at_start,
});
}
}
out
}
fn strip_sql_comments(body: &str) -> String {
let mut out = String::with_capacity(body.len());
for line in body.split_inclusive('\n') {
if let Some(idx) = line.find("--") {
out.push_str(&line[..idx]);
if line.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str(line);
}
}
out
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn shorten(s: &str) -> String {
const MAX: usize = 120;
if s.len() <= MAX {
s.to_string()
} else {
let mut t = s[..MAX].to_string();
t.push_str("...");
t
}
}
#[cfg(test)]
mod scan_tests {
use super::*;
use std::path::PathBuf;
fn lint(body: &str) -> Vec<Lint> {
let mut out = Vec::new();
scan_sql(&PathBuf::from("test.sql"), body, &mut out);
out
}
#[test]
fn flags_missing_with_check() {
let body =
"CREATE POLICY p ON t USING (tenant_id = current_setting('app.tenant_id')::uuid);";
let lints = lint(body);
assert_eq!(lints.len(), 1);
assert_eq!(lints[0].kind, LintKind::PolicyMissingWithCheck);
}
#[test]
fn passes_with_check_present() {
let body = "CREATE POLICY p ON t \
USING (tenant_id = current_setting('app.tenant_id')::uuid) \
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);";
assert!(lint(body).is_empty());
}
#[test]
fn skips_for_select() {
let body = "CREATE POLICY p ON t FOR SELECT \
USING (tenant_id = current_setting('app.tenant_id')::uuid);";
assert!(lint(body).is_empty());
}
#[test]
fn skips_for_delete() {
let body = "CREATE POLICY p ON t FOR DELETE \
USING (tenant_id = current_setting('app.tenant_id')::uuid);";
assert!(lint(body).is_empty());
}
#[test]
fn ignores_create_policy_in_line_comment() {
let body = "-- CREATE POLICY p ON t USING (true);\nSELECT 1;";
assert!(lint(body).is_empty());
}
#[test]
fn reports_line_number_of_statement_start() {
let body = "SELECT 1;\n\nCREATE POLICY p ON t \n USING (true);\n";
let lints = lint(body);
assert_eq!(lints.len(), 1);
assert_eq!(lints[0].line, 3);
}
#[test]
fn flags_each_offender_in_a_multi_statement_file() {
let body = "\
CREATE POLICY a ON t USING (true);\n\
CREATE POLICY b ON t USING (true) WITH CHECK (true);\n\
CREATE POLICY c ON u USING (true);\n\
";
let lints = lint(body);
assert_eq!(lints.len(), 2);
assert!(lints[0].snippet.contains(" a "));
assert!(lints[1].snippet.contains(" c "));
}
#[test]
fn quoted_semicolon_causes_a_false_positive() {
let body = "\
CREATE POLICY p ON t \
USING ('value;still a string' = tenant_id::text) \
WITH CHECK ('value;still a string' = tenant_id::text);\
";
let lints = lint(body);
assert_eq!(lints.len(), 1);
assert_eq!(lints[0].kind, LintKind::PolicyMissingWithCheck);
}
#[test]
fn double_dash_inside_string_causes_a_false_positive() {
let body = "\
CREATE POLICY p ON t \
USING ('value -- not a comment' = tenant_id::text) \
WITH CHECK ('value -- not a comment' = tenant_id::text);\
";
let lints = lint(body);
assert_eq!(lints.len(), 1);
assert_eq!(lints[0].kind, LintKind::PolicyMissingWithCheck);
}
}