use pg_query::protobuf::CreatePolicyStmt;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::default_expr::NormalizedExpr;
use crate::ir::grant::GrantTarget;
use crate::ir::policy::{Policy, PolicyCommand};
use crate::parse::builder::shared;
use crate::parse::error::{ParseError, SourceLocation};
pub(crate) fn apply(
s: &CreatePolicyStmt,
cat: &mut Catalog,
loc: &SourceLocation,
) -> Result<(), ParseError> {
let name = Identifier::from_unquoted(&s.policy_name).map_err(|e| ParseError::Structural {
location: loc.clone(),
message: format!("invalid policy name {:?}: {e}", s.policy_name),
})?;
let rv = s.table.as_ref().ok_or_else(|| ParseError::Structural {
location: loc.clone(),
message: "CREATE POLICY missing target table".into(),
})?;
let table_qname = qname_from_rangevar(rv, loc)?;
let command = decode_cmd(&s.cmd_name, loc)?;
let permissive = s.permissive;
let mut roles = decode_role_targets(&s.roles, loc)?;
if roles.is_empty() {
roles.push(GrantTarget::Public);
}
let using = s
.qual
.as_ref()
.map(|expr| decode_expr_node(expr, loc))
.transpose()?;
let with_check = s
.with_check
.as_ref()
.map(|expr| decode_expr_node(expr, loc))
.transpose()?;
if with_check.is_some() && !command.allows_with_check() {
return Err(ParseError::Structural {
location: loc.clone(),
message: format!(
"WITH CHECK is invalid on FOR {} policies; PG rejects",
command.sql_keyword()
),
});
}
let table = cat
.tables
.iter_mut()
.find(|t| t.qname == table_qname)
.ok_or_else(|| ParseError::Structural {
location: loc.clone(),
message: format!(
"CREATE POLICY {name} ON {table_qname} — unknown table; \
declare with CREATE TABLE first"
),
})?;
if table.policies.iter().any(|p| p.name == name) {
return Err(ParseError::Structural {
location: loc.clone(),
message: format!("policy {name} declared more than once on {table_qname}"),
});
}
table.policies.push(Policy {
name,
permissive,
command,
roles,
using,
with_check,
});
Ok(())
}
fn decode_cmd(s: &str, loc: &SourceLocation) -> Result<PolicyCommand, ParseError> {
match s.to_ascii_lowercase().as_str() {
"all" => Ok(PolicyCommand::All),
"select" => Ok(PolicyCommand::Select),
"insert" => Ok(PolicyCommand::Insert),
"update" => Ok(PolicyCommand::Update),
"delete" => Ok(PolicyCommand::Delete),
other => Err(ParseError::Structural {
location: loc.clone(),
message: format!("unknown policy command kind {other:?}"),
}),
}
}
fn decode_role_targets(
nodes: &[pg_query::protobuf::Node],
loc: &SourceLocation,
) -> Result<Vec<GrantTarget>, ParseError> {
use pg_query::protobuf::RoleSpecType;
let mut out = Vec::with_capacity(nodes.len());
for n in nodes {
let Some(pg_query::NodeEnum::RoleSpec(rs)) = n.node.as_ref() else {
return Err(ParseError::Structural {
location: loc.clone(),
message: format!("expected RoleSpec in TO clause, got {n:?}"),
});
};
let role_type = RoleSpecType::try_from(rs.roletype).unwrap_or(RoleSpecType::Undefined);
if role_type == RoleSpecType::RolespecPublic {
out.push(GrantTarget::Public);
} else {
let ident =
Identifier::from_unquoted(&rs.rolename).map_err(|e| ParseError::Structural {
location: loc.clone(),
message: format!("invalid role name {:?}: {e}", rs.rolename),
})?;
out.push(GrantTarget::Role(ident));
}
}
Ok(out)
}
fn qname_from_rangevar(
rv: &pg_query::protobuf::RangeVar,
loc: &SourceLocation,
) -> Result<QualifiedName, ParseError> {
shared::resolve_qname(rv, None, loc)
}
fn decode_expr_node(
expr: &pg_query::protobuf::Node,
loc: &SourceLocation,
) -> Result<NormalizedExpr, ParseError> {
let inner_enum = expr.node.as_ref().ok_or_else(|| ParseError::Structural {
location: loc.clone(),
message: "policy expression node has no inner node".into(),
})?;
crate::parse::normalize_expr::from_pg_node(inner_enum, None, loc).map_err(|e| {
ParseError::Structural {
location: loc.clone(),
message: format!("failed to canonicalize policy expression: {e}"),
}
})
}
#[cfg(test)]
mod tests {
use std::path::Path;
use tempfile::tempdir;
use crate::ir::grant::GrantTarget;
use crate::ir::policy::PolicyCommand;
use crate::parse::ParseError;
use crate::parse::parse_directory;
fn write(dir: &Path, rel: &str, contents: &str) {
let p = dir.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, contents).unwrap();
}
fn parse_source(sql: &str) -> Result<crate::ir::catalog::Catalog, ParseError> {
let tmp = tempdir().expect("tempdir");
write(tmp.path(), "schema.sql", sql);
parse_directory(tmp.path(), &[])
}
#[test]
fn create_simple_policy() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY docs_sel ON app.docs USING (true);
";
let cat = parse_source(sql).expect("parses");
let table = cat
.tables
.iter()
.find(|t| t.qname.to_string() == "app.docs")
.unwrap();
assert_eq!(table.policies.len(), 1);
let p = &table.policies[0];
assert_eq!(p.name.as_str(), "docs_sel");
assert!(p.permissive);
assert_eq!(p.command, PolicyCommand::All);
assert_eq!(p.roles, vec![GrantTarget::Public]);
assert!(p.using.is_some());
assert!(p.with_check.is_none());
}
#[test]
fn restrictive_with_check_on_select_errors() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY p ON app.docs FOR SELECT WITH CHECK (true);
";
let err = parse_source(sql).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("WITH CHECK"), "got: {msg}");
}
#[test]
fn create_policy_on_unknown_table_errors() {
let sql = "
CREATE SCHEMA app;
CREATE POLICY p ON app.missing_table USING (true);
";
let err = parse_source(sql).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("unknown table") || msg.contains("missing_table"),
"got: {msg}"
);
}
#[test]
fn duplicate_policy_name_errors() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY p ON app.docs USING (true);
CREATE POLICY p ON app.docs USING (false);
";
let err = parse_source(sql).expect_err("should fail");
let msg = err.to_string();
assert!(
msg.contains("more than once") || msg.contains('p'),
"got: {msg}"
);
}
#[test]
fn alter_table_enable_rls() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY;
";
let cat = parse_source(sql).expect("parses");
let table = cat
.tables
.iter()
.find(|t| t.qname.to_string() == "app.docs")
.unwrap();
assert!(table.rls_enabled, "rls_enabled should be true");
assert!(!table.rls_forced, "rls_forced should be false");
}
#[test]
fn alter_table_force_rls() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
ALTER TABLE app.docs FORCE ROW LEVEL SECURITY;
";
let cat = parse_source(sql).expect("parses");
let table = cat
.tables
.iter()
.find(|t| t.qname.to_string() == "app.docs")
.unwrap();
assert!(table.rls_forced, "rls_forced should be true");
}
#[test]
fn alter_table_disable_after_enable() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
ALTER TABLE app.docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.docs DISABLE ROW LEVEL SECURITY;
";
let cat = parse_source(sql).expect("parses");
let table = cat
.tables
.iter()
.find(|t| t.qname.to_string() == "app.docs")
.unwrap();
assert!(
!table.rls_enabled,
"rls_enabled should be false after disable"
);
}
#[test]
fn alter_policy_in_source_errors() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY p ON app.docs USING (true);
ALTER POLICY p ON app.docs USING (false);
";
let err = parse_source(sql).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("ALTER POLICY"), "got: {msg}");
}
#[test]
fn drop_policy_in_source_errors() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY p ON app.docs USING (true);
DROP POLICY p ON app.docs;
";
let err = parse_source(sql).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("DROP POLICY"), "got: {msg}");
}
#[test]
fn restrictive_with_roles_round_trips() {
let sql = "
CREATE SCHEMA app;
CREATE TABLE app.docs (id bigint);
CREATE POLICY docs_write
ON app.docs AS RESTRICTIVE FOR INSERT
TO app_writer
WITH CHECK (true);
";
let cat = parse_source(sql).expect("parses");
let table = cat
.tables
.iter()
.find(|t| t.qname.to_string() == "app.docs")
.unwrap();
assert_eq!(table.policies.len(), 1);
let p = &table.policies[0];
assert!(!p.permissive);
assert_eq!(p.command, PolicyCommand::Insert);
assert_eq!(p.roles.len(), 1);
assert!(
matches!(&p.roles[0], GrantTarget::Role(r) if r.as_str() == "app_writer"),
"got: {:?}",
p.roles[0]
);
assert!(p.with_check.is_some());
}
}