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            let (obj_kind, obj_name) = match &grant.target {
36                crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) => (
37                    ObjectKind::Table,
38                    tables
39                        .iter()
40                        .map(|t| t.to_string())
41                        .collect::<Vec<_>>()
42                        .join(", "),
43                ),
44                crate::analysis::mutations::ResolvedGrantTarget::AllTablesInSchema(schemas) => {
45                    (ObjectKind::Schema, schemas.join(", "))
46                }
47            };
48
49            let is_public = grant.grantees.iter().any(|g| {
50                if let crate::analysis::facts::RoleFact::Named { name, .. } = g {
51                    name == "public"
52                } else {
53                    false
54                }
55            });
56            if is_public {
57                violations.push(Violation {
58                    source_range: None,
59                    rule_id: self.id(),
60                    operation_kind: OperationKind::Grant,
61                    object_kind: obj_kind.clone(),
62                    object_name: obj_name.clone(),
63                    tier: ViolationTier::Tier1,
64                    reason: "Grant to PUBLIC".to_string(),
65                    recipe: "GRANT to PUBLIC is almost never intended as it applies to every role.",
66                    dedup_key: None,
67                    sql: None,
68                    fk_dependency_related: false,
69                });
70            }
71
72            let is_all_privs = match &grant.privileges {
73                crate::analysis::facts::PrivilegeSpec::All => true,
74                crate::analysis::facts::PrivilegeSpec::List(privs) => privs
75                    .iter()
76                    .any(|p| matches!(p, crate::analysis::facts::PrivilegeFact::All)),
77            };
78
79            if is_all_privs {
80                let every_grantee_owns_every_table = match &grant.target {
81                    crate::analysis::mutations::ResolvedGrantTarget::Tables(tables)
82                        if !tables.is_empty() && !grant.grantees.is_empty() =>
83                    {
84                        grant.grantees.iter().all(|grantee| {
85                            let crate::analysis::facts::RoleFact::Named { name, .. } = grantee
86                            else {
87                                return false;
88                            };
89                            // PostgreSQL roles are global, so owner comparison
90                            // uses the role name.
91                            tables.iter().all(|table_id| {
92                                matches!(
93                                    state.local.relations.get(table_id),
94                                    Some(crate::model::relation::RelationOverlay::Present(relation))
95                                        if relation.owner.name == *name
96                                )
97                            })
98                        })
99                    }
100                    _ => false,
101                };
102                if !every_grantee_owns_every_table {
103                    violations.push(Violation {
104                        source_range: None,
105                        rule_id: self.id(),
106                        operation_kind: OperationKind::Grant,
107                        object_kind: obj_kind.clone(),
108                        object_name: obj_name.clone(),
109                        tier: ViolationTier::Tier2,
110                        reason: "Overbroad Grant: ALL PRIVILEGES".to_string(),
111                        recipe: "GRANT ALL PRIVILEGES to a role that is not the owner is risky.",
112                        dedup_key: None,
113                        sql: None,
114                        fk_dependency_related: false,
115                    });
116                }
117            }
118
119            if grant.with_grant_option {
120                violations.push(Violation { source_range: None,
121                    rule_id: self.id(),
122                    operation_kind: OperationKind::Grant,
123                    object_kind: obj_kind,
124                    object_name: obj_name,
125                    tier: ViolationTier::Tier2,
126                    reason: "Overbroad Grant: WITH GRANT OPTION".to_string(),
127                    recipe: "WITH GRANT OPTION allows the grantee to re-grant privileges, widening the blast radius.",
128                    dedup_key: None,
129                            sql: None,
130                            fk_dependency_related: false,
131                });
132            }
133        }
134        violations
135    }
136}