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 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 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}