safe_migrate/rules/
security.rs1use 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 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 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}