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                    fk_dependency_related: false,
71                });
72            }
73
74            // Case 2: GRANT ALL PRIVILEGES to a non-owner role -> Tier 2
75            let is_all_privs = match &grant.privileges {
76                crate::analysis::facts::PrivilegeSpec::All => true,
77                crate::analysis::facts::PrivilegeSpec::List(privs) => privs
78                    .iter()
79                    .any(|p| matches!(p, crate::analysis::facts::PrivilegeFact::All)),
80            };
81
82            if is_all_privs {
83                let mut is_owner = false;
84                if let crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) =
85                    &grant.target
86                {
87                    for table_id in tables {
88                        if let Some(crate::model::relation::RelationOverlay::Present(rel)) =
89                            state.local.relations.get(table_id)
90                            && grant.grantees.iter().any(|g| {
91                                if let crate::analysis::facts::RoleFact::Named { name, .. } = g {
92                                    // Simple name match for owner check
93                                    rel.owner.name == *name
94                                } else {
95                                    false
96                                }
97                            })
98                        {
99                            is_owner = true;
100                            break;
101                        }
102                    }
103                }
104                if !is_owner {
105                    violations.push(Violation {
106                        source_range: None,
107                        rule_id: self.id(),
108                        operation_kind: OperationKind::Grant,
109                        object_kind: obj_kind.clone(),
110                        object_name: obj_name.clone(),
111                        tier: ViolationTier::Tier2,
112                        reason: "Overbroad Grant: ALL PRIVILEGES".to_string(),
113                        recipe: "GRANT ALL PRIVILEGES to a role that is not the owner is risky.",
114                        dedup_key: None,
115                        sql: None,
116                        fk_dependency_related: false,
117                    });
118                }
119            }
120
121            // Case 3: WITH GRANT OPTION -> Tier 2
122            if grant.with_grant_option {
123                violations.push(Violation { source_range: None,
124                    rule_id: self.id(),
125                    operation_kind: OperationKind::Grant,
126                    object_kind: obj_kind,
127                    object_name: obj_name,
128                    tier: ViolationTier::Tier2,
129                    reason: "Overbroad Grant: WITH GRANT OPTION".to_string(),
130                    recipe: "WITH GRANT OPTION allows the grantee to re-grant privileges, widening the blast radius.",
131                    dedup_key: None,
132                            sql: None,
133                            fk_dependency_related: false,
134                });
135            }
136        }
137        violations
138    }
139}