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