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