use sqlparser::ast::Statement;
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;
const ALLOWED_DDL_STATEMENTS: &[&str] = &[
"CreateTable",
"AlterTable",
"DropTable",
"CreateIndex",
"DropIndex",
"CreateView",
"DropView",
"Truncate",
"Query", "Insert",
"Update",
"Delete",
];
#[derive(Debug, Clone)]
pub enum DdlValidationResult {
Allowed,
Forbidden(String),
ParseError(String),
}
#[derive(Debug, Clone)]
pub struct DdlAuditRecord {
pub sql: String,
pub allowed: bool,
pub reason: Option<String>,
}
impl DdlAuditRecord {
fn from_result(sql: &str, result: &DdlValidationResult) -> Self {
match result {
DdlValidationResult::Allowed => Self {
sql: sql.to_string(),
allowed: true,
reason: None,
},
DdlValidationResult::Forbidden(reason) => Self {
sql: sql.to_string(),
allowed: false,
reason: Some(reason.clone()),
},
DdlValidationResult::ParseError(error) => Self {
sql: sql.to_string(),
allowed: false,
reason: Some(error.clone()),
},
}
}
}
pub trait DdlGuardPolicy: Send + Sync {
fn validate(&self, sql: &str) -> Result<DdlValidationResult, String>;
fn audit(&self, _sql: &str, _result: &DdlValidationResult) {}
}
pub struct AuditingDdlGuard {
inner: std::sync::Arc<dyn DdlGuardPolicy>,
sink: std::sync::Arc<dyn Fn(&DdlAuditRecord) + Send + Sync>,
}
impl AuditingDdlGuard {
pub fn new(
inner: std::sync::Arc<dyn DdlGuardPolicy>,
sink: std::sync::Arc<dyn Fn(&DdlAuditRecord) + Send + Sync>,
) -> Self {
Self { inner, sink }
}
}
impl DdlGuardPolicy for AuditingDdlGuard {
fn validate(&self, sql: &str) -> Result<DdlValidationResult, String> {
self.inner.validate(sql)
}
fn audit(&self, sql: &str, result: &DdlValidationResult) {
(self.sink)(&DdlAuditRecord::from_result(sql, result));
}
}
pub struct DryRunDdlGuard {
inner: std::sync::Arc<dyn DdlGuardPolicy>,
records: std::sync::Mutex<Vec<DdlAuditRecord>>,
}
impl DryRunDdlGuard {
pub fn new(inner: std::sync::Arc<dyn DdlGuardPolicy>) -> Self {
Self {
inner,
records: std::sync::Mutex::new(Vec::new()),
}
}
pub fn plan(&self, statements: &[&str]) -> Vec<DdlAuditRecord> {
statements
.iter()
.map(|sql| {
let record = match self.inner.validate(sql) {
Ok(decision) => DdlAuditRecord::from_result(sql, &decision),
Err(error) => DdlAuditRecord {
sql: sql.to_string(),
allowed: false,
reason: Some(error),
},
};
self.records
.lock()
.expect("dry-run records")
.push(record.clone());
record
})
.collect()
}
pub fn records(&self) -> Vec<DdlAuditRecord> {
self.records.lock().expect("dry-run records").clone()
}
pub fn would_allow(&self, sql: &str) -> bool {
matches!(
DdlGuardPolicy::validate(self, sql),
Ok(DdlValidationResult::Allowed)
)
}
}
impl DdlGuardPolicy for DryRunDdlGuard {
fn validate(&self, sql: &str) -> Result<DdlValidationResult, String> {
let result = self.inner.validate(sql);
let record = match &result {
Ok(decision) => DdlAuditRecord::from_result(sql, decision),
Err(error) => DdlAuditRecord {
sql: sql.to_string(),
allowed: false,
reason: Some(error.clone()),
},
};
self.records.lock().expect("dry-run records").push(record);
result
}
}
impl DdlGuardPolicy for DdlGuard {
fn validate(&self, sql: &str) -> Result<DdlValidationResult, String> {
DdlGuard::validate(self, sql)
}
}
pub struct DdlGuard {
dialect: GenericDialect,
}
impl DdlGuard {
pub fn new() -> Self {
Self {
dialect: GenericDialect {},
}
}
pub fn validate(&self, sql: &str) -> Result<DdlValidationResult, String> {
let sql_trimmed = sql.trim();
if sql_trimmed.is_empty() {
return Ok(DdlValidationResult::Forbidden(
"Empty SQL statement".to_string(),
));
}
if let Some(rule) = crate::access::InjectionEngine::global()
.scan_ddl(sql_trimmed)
.first()
{
return Ok(DdlValidationResult::Forbidden(format!(
"Contains forbidden pattern: {}",
rule.pattern
)));
}
let statements = Parser::parse_sql(&self.dialect, sql_trimmed)
.map_err(|e| format!("Failed to parse SQL: {}", e))?;
if statements.is_empty() {
return Ok(DdlValidationResult::Forbidden(
"Empty SQL statement after parsing".to_string(),
));
}
for stmt in &statements {
if !Self::is_allowed_statement(stmt) {
return Ok(DdlValidationResult::Forbidden(format!(
"Statement type '{}' is not in the allowed DDL whitelist",
Self::statement_type_name(stmt)
)));
}
}
Ok(DdlValidationResult::Allowed)
}
fn is_allowed_statement(stmt: &Statement) -> bool {
let type_name = Self::statement_type_name(stmt);
ALLOWED_DDL_STATEMENTS.contains(&type_name.as_str())
}
fn statement_type_name(stmt: &Statement) -> String {
match stmt {
Statement::CreateTable(_) => "CreateTable".to_string(),
Statement::AlterTable(_) => "AlterTable".to_string(),
Statement::CreateIndex(_) => "CreateIndex".to_string(),
Statement::CreateView(_) => "CreateView".to_string(),
Statement::Drop { object_type, .. } => {
let type_str = format!("{:?}", object_type);
if type_str.contains("Table") {
"DropTable".to_string()
} else if type_str.contains("Index") {
"DropIndex".to_string()
} else if type_str.contains("View") {
"DropView".to_string()
} else if type_str.contains("Database") {
"DropDatabase".to_string()
} else {
format!("Drop{:?}", object_type)
}
}
Statement::Truncate(_) => "Truncate".to_string(),
Statement::Query(_) => "Query".to_string(),
Statement::Insert(_) => "Insert".to_string(),
Statement::Update(_) => "Update".to_string(),
Statement::Delete(_) => "Delete".to_string(),
Statement::Set(_) => "Set".to_string(),
_ => format!("{:?}", stmt),
}
}
}
impl Default for DdlGuard {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> DdlGuard {
DdlGuard::new()
}
#[test]
fn test_valid_create_table() {
let result = guard()
.validate("CREATE TABLE users (id INT PRIMARY KEY)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_table_lowercase() {
let result = guard().validate("create table users (id int)").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_or_replace() {
let result = guard()
.validate("CREATE OR REPLACE TABLE users (id INT)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_alter_table() {
let result = guard()
.validate("ALTER TABLE users ADD COLUMN name VARCHAR(255)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_index() {
let result = guard()
.validate("CREATE INDEX idx_name ON users (name)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_drop_index() {
let result = guard().validate("DROP INDEX idx_name").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_drop_view() {
let result = guard().validate("DROP VIEW active_users").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_view() {
let result = guard()
.validate("CREATE VIEW active_users AS SELECT * FROM users WHERE active = true")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_select() {
let result = guard().validate("SELECT 1").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_drop_database_rejected() {
let result = guard().validate("DROP DATABASE production").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("DROP DATABASE")
));
}
#[test]
fn test_drop_database_lowercase_rejected() {
let result = guard().validate("drop database production").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("forbidden")
));
}
#[test]
fn test_drop_all_rejected() {
let result = guard().validate("DROP ALL TABLES").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("DROP ALL")
));
}
#[test]
fn test_drop_table_allowed_for_admin() {
let result = guard().validate("DROP TABLE users").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_insert_allowed_for_admin() {
let result = guard()
.validate("INSERT INTO users (id) VALUES (1)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_update_allowed_for_admin() {
let result = guard()
.validate("UPDATE users SET name = 'test' WHERE id = 1")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_delete_allowed_for_admin() {
let result = guard().validate("DELETE FROM users WHERE id = 1").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_delete_lowercase_allowed_for_admin() {
let result = guard().validate("delete from users where id = 1").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_empty_sql() {
let result = guard().validate("").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Empty")
));
}
#[test]
fn test_whitespace_sql() {
let result = guard().validate(" \n\t ").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Empty")
));
}
use std::sync::Arc;
struct DenyAllGuard;
impl DdlGuardPolicy for DenyAllGuard {
fn validate(&self, _sql: &str) -> Result<DdlValidationResult, String> {
Ok(DdlValidationResult::Forbidden("deny all".to_string()))
}
}
#[test]
fn test_policy_trait_object_whitelist_guard() {
let policy: Arc<dyn DdlGuardPolicy> = Arc::new(DdlGuard::new());
assert!(matches!(
policy.validate("CREATE TABLE t (id INT)"),
Ok(DdlValidationResult::Allowed)
));
assert!(matches!(
policy.validate("DROP DATABASE x"),
Ok(DdlValidationResult::Forbidden(_))
));
}
#[test]
fn test_policy_trait_object_custom_injection() {
let policy: Arc<dyn DdlGuardPolicy> = Arc::new(DenyAllGuard);
assert!(matches!(
policy.validate("CREATE TABLE t (id INT)"),
Ok(DdlValidationResult::Forbidden(ref msg)) if msg.contains("deny all")
));
}
#[test]
fn test_auditing_guard_forwards_decisions_to_sink() {
let events: Arc<std::sync::Mutex<Vec<DdlAuditRecord>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let sink_events = events.clone();
let auditing = AuditingDdlGuard::new(
Arc::new(DdlGuard::new()),
Arc::new(move |record: &DdlAuditRecord| {
sink_events.lock().unwrap().push(DdlAuditRecord {
sql: record.sql.clone(),
allowed: record.allowed,
reason: record.reason.clone(),
});
}),
);
let allowed = auditing.validate("CREATE TABLE t (id INT)").unwrap();
auditing.audit("CREATE TABLE t (id INT)", &allowed);
let forbidden = auditing.validate("DROP DATABASE prod").unwrap();
auditing.audit("DROP DATABASE prod", &forbidden);
let events = events.lock().unwrap();
assert_eq!(events.len(), 2, "audit 钩子应逐决策转发");
assert!(events[0].allowed);
assert!(!events[1].allowed);
assert!(
events[1]
.reason
.as_deref()
.unwrap()
.contains("DROP DATABASE")
);
}
#[test]
fn test_dry_run_guard_records_and_plans() {
let dry = DryRunDdlGuard::new(Arc::new(DdlGuard::new()));
assert!(dry.would_allow("CREATE TABLE t (id INT)"));
assert!(!dry.would_allow("DROP DATABASE prod"));
assert_eq!(dry.records().len(), 2, "would_allow 应记录决策");
let plan = dry.plan(&["ALTER TABLE t ADD COLUMN c INT", "GRANT ALL ON t TO x"]);
assert_eq!(plan.len(), 2);
assert!(plan[0].allowed);
assert!(!plan[1].allowed, "GRANT 不在白名单,应标记拦截");
assert_eq!(dry.records().len(), 4, "plan 决策并入记录");
assert!(matches!(
DdlGuardPolicy::validate(&dry, "CREATE INDEX i ON t (c)"),
Ok(DdlValidationResult::Allowed)
));
}
}