Skip to main content

safe_migrate/rules/
security.rs

1use crate::analysis::mutations::Mutation;
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult, PreState};
3use crate::engine::config::Config;
4use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
5use crate::rules::Rule;
6
7pub struct OverbroadGrantRule;
8
9impl Rule for OverbroadGrantRule {
10    fn id(&self) -> &'static str {
11        "overbroad-grant"
12    }
13    fn default_tier(&self) -> ViolationTier {
14        ViolationTier::Tier2
15    }
16    fn recipe(&self) -> &'static str {
17        "Avoid GRANT ALL to public roles. Use granular privileges."
18    }
19
20    fn evaluate(
21        &self,
22        mutation: &Mutation,
23        result: &MutationResult,
24        _pre_state: &PreState,
25        state: &AnalysisState,
26        _config: &Config,
27        _cascade_closure: Option<&CascadeResult>,
28    ) -> Vec<Violation> {
29        if *result == MutationResult::Skipped {
30            return vec![];
31        }
32        let mut violations = Vec::new();
33
34        if let Mutation::Grant(grant) = mutation {
35            // Determine object_name and object_kind if possible
36            let (obj_kind, obj_name) = match &grant.target {
37                crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) => (
38                    ObjectKind::Table,
39                    tables
40                        .iter()
41                        .map(|t| t.to_string())
42                        .collect::<Vec<_>>()
43                        .join(", "),
44                ),
45                crate::analysis::mutations::ResolvedGrantTarget::AllTablesInSchema(schemas) => {
46                    (ObjectKind::Schema, schemas.join(", "))
47                }
48            };
49
50            // Case 1: GRANT ... TO PUBLIC -> Tier 1
51            let is_public = grant.grantees.iter().any(|g| {
52                if let crate::analysis::facts::RoleFact::Named { name, .. } = g {
53                    name == "public"
54                } else {
55                    false
56                }
57            });
58            if is_public {
59                violations.push(Violation {
60                    source_range: None,
61                    rule_id: self.id(),
62                    operation_kind: OperationKind::Grant,
63                    object_kind: obj_kind.clone(),
64                    object_name: obj_name.clone(),
65                    tier: ViolationTier::Tier1,
66                    reason: "Grant to PUBLIC".to_string(),
67                    recipe: "GRANT to PUBLIC is almost never intended as it applies to every role.",
68                    dedup_key: None,
69                    sql: None,
70                });
71            }
72
73            // Case 2: GRANT ALL PRIVILEGES to a non-owner role -> Tier 2
74            let is_all_privs = match &grant.privileges {
75                crate::analysis::facts::PrivilegeSpec::All => true,
76                crate::analysis::facts::PrivilegeSpec::List(privs) => privs
77                    .iter()
78                    .any(|p| matches!(p, crate::analysis::facts::PrivilegeFact::All)),
79            };
80
81            if is_all_privs {
82                let mut is_owner = false;
83                if let crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) =
84                    &grant.target
85                {
86                    for table_id in tables {
87                        if let Some(crate::model::relation::RelationOverlay::Present(rel)) =
88                            state.local.relations.get(table_id)
89                            && grant.grantees.iter().any(|g| {
90                                if let crate::analysis::facts::RoleFact::Named { name, .. } = g {
91                                    // Simple name match for owner check
92                                    rel.owner.name == *name
93                                } else {
94                                    false
95                                }
96                            })
97                        {
98                            is_owner = true;
99                            break;
100                        }
101                    }
102                }
103                if !is_owner {
104                    violations.push(Violation {
105                        source_range: None,
106                        rule_id: self.id(),
107                        operation_kind: OperationKind::Grant,
108                        object_kind: obj_kind.clone(),
109                        object_name: obj_name.clone(),
110                        tier: ViolationTier::Tier2,
111                        reason: "Overbroad Grant: ALL PRIVILEGES".to_string(),
112                        recipe: "GRANT ALL PRIVILEGES to a role that is not the owner is risky.",
113                        dedup_key: None,
114                        sql: None,
115                    });
116                }
117            }
118
119            // Case 3: WITH GRANT OPTION -> Tier 2
120            if grant.with_grant_option {
121                violations.push(Violation { source_range: None,
122                    rule_id: self.id(),
123                    operation_kind: OperationKind::Grant,
124                    object_kind: obj_kind,
125                    object_name: obj_name,
126                    tier: ViolationTier::Tier2,
127                    reason: "Overbroad Grant: WITH GRANT OPTION".to_string(),
128                    recipe: "WITH GRANT OPTION allows the grantee to re-grant privileges, widening the blast radius.",
129                    dedup_key: None,
130                            sql: None,
131                });
132            }
133        }
134        violations
135    }
136}