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