1use std::collections::{BTreeMap, BTreeSet};
2
3use thiserror::Error;
4
5use crate::diff::Change;
6use crate::manifest::{ObjectType, SchemaBindingFacet};
7use crate::model::{DefaultPrivKey, GrantKey};
8
9#[derive(Debug, Clone, Default)]
10pub struct OwnershipIndex {
11 pub roles: BTreeMap<String, String>,
12 pub schema_facets: BTreeMap<SchemaFacetKey, String>,
13 pub grants: BTreeMap<GrantKey, String>,
14 pub default_privileges: BTreeMap<DefaultPrivKey, String>,
15 pub memberships: BTreeMap<MembershipKey, String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
19pub struct SchemaFacetKey {
20 pub schema: String,
21 pub facet: SchemaBindingFacet,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
25pub struct MembershipKey {
26 pub role: String,
27 pub member: String,
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct ManagedScope {
32 pub roles: BTreeSet<String>,
33 pub schemas: BTreeMap<String, ManagedSchemaScope>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct ManagedSchemaScope {
38 pub owner: bool,
39 pub bindings: bool,
40}
41
42#[derive(Debug, Clone, Default)]
43pub struct ManagedChangeSurface {
44 pub roles: BTreeSet<String>,
45 pub owner_schemas: BTreeSet<String>,
46 pub binding_schemas: BTreeSet<String>,
47 pub explicit_grants: BTreeSet<GrantKey>,
48 pub explicit_default_privileges: BTreeSet<DefaultPrivKey>,
49 pub explicit_memberships: BTreeSet<MembershipKey>,
50}
51
52#[derive(Debug, Error)]
53pub enum ManagedChangeError {
54 #[error("change falls outside managed bundle scope: {change}")]
55 OutOfScope { change: String },
56}
57
58impl OwnershipIndex {
59 pub fn managed_scope(&self) -> ManagedScope {
60 let mut scope = ManagedScope {
61 roles: self.roles.keys().cloned().collect(),
62 schemas: BTreeMap::new(),
63 };
64
65 for key in self.schema_facets.keys() {
66 let entry = scope.schemas.entry(key.schema.clone()).or_default();
67 match key.facet {
68 SchemaBindingFacet::Owner => entry.owner = true,
69 SchemaBindingFacet::Bindings => entry.bindings = true,
70 }
71 }
72
73 scope
74 }
75
76 pub fn managed_change_surface(&self) -> ManagedChangeSurface {
77 let mut surface = ManagedChangeSurface {
78 roles: self.roles.keys().cloned().collect(),
79 explicit_grants: self.grants.keys().cloned().collect(),
80 explicit_default_privileges: self.default_privileges.keys().cloned().collect(),
81 explicit_memberships: self.memberships.keys().cloned().collect(),
82 ..ManagedChangeSurface::default()
83 };
84
85 for key in self.schema_facets.keys() {
86 match key.facet {
87 SchemaBindingFacet::Owner => {
88 surface.owner_schemas.insert(key.schema.clone());
89 }
90 SchemaBindingFacet::Bindings => {
91 surface.binding_schemas.insert(key.schema.clone());
92 }
93 }
94 }
95
96 surface
97 }
98}
99
100pub fn validate_changes_against_managed_surface(
101 changes: &[Change],
102 surface: &ManagedChangeSurface,
103) -> Result<(), ManagedChangeError> {
104 for change in changes {
105 if !surface.allows_change(change) {
106 return Err(ManagedChangeError::OutOfScope {
107 change: describe_change(change),
108 });
109 }
110 }
111
112 Ok(())
113}
114
115impl ManagedChangeSurface {
116 pub fn needs_database_privilege_inspection(&self) -> bool {
117 !self.roles.is_empty()
118 }
119
120 fn allows_change(&self, change: &Change) -> bool {
121 match change {
122 Change::CreateRole { name, .. }
123 | Change::AlterRole { name, .. }
124 | Change::SetComment { name, .. }
125 | Change::SetPassword { name, .. }
126 | Change::DropRole { name } => self.roles.contains(name),
127 Change::TerminateSessions { role } | Change::DropOwned { role } => {
128 self.roles.contains(role)
129 }
130 Change::ReassignOwned { from_role, to_role } => {
131 self.roles.contains(from_role) && !to_role.is_empty()
132 }
133 Change::CreateSchema { name, .. } => {
134 self.owner_schemas.contains(name) || self.binding_schemas.contains(name)
135 }
136 Change::AlterSchemaOwner { name, owner } => {
137 self.owner_schemas.contains(name) && !owner.is_empty()
138 }
139 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
140 self.owner_schemas.contains(name) && !owner.is_empty()
141 }
142 Change::Grant {
143 role,
144 object_type,
145 schema,
146 name,
147 ..
148 }
149 | Change::Revoke {
150 role,
151 object_type,
152 schema,
153 name,
154 ..
155 } => self.allows_grant_change(&GrantKey {
156 role: role.clone(),
157 object_type: *object_type,
158 schema: schema.clone(),
159 name: name.clone(),
160 }),
161 Change::SetDefaultPrivilege {
162 owner,
163 schema,
164 on_type,
165 grantee,
166 ..
167 }
168 | Change::RevokeDefaultPrivilege {
169 owner,
170 schema,
171 on_type,
172 grantee,
173 ..
174 } => self.allows_default_privilege_change(&DefaultPrivKey {
175 owner: owner.clone(),
176 schema: schema.clone(),
177 on_type: *on_type,
178 grantee: grantee.clone(),
179 }),
180 Change::AddMember { role, member, .. } | Change::RemoveMember { role, member } => {
181 self.roles.contains(role)
182 || self.explicit_memberships.contains(&MembershipKey {
183 role: role.clone(),
184 member: member.clone(),
185 })
186 }
187 }
188 }
189
190 fn allows_grant_change(&self, key: &GrantKey) -> bool {
191 if self.explicit_grants.contains(key) {
192 return true;
193 }
194
195 if is_binding_schema_object(key.object_type)
196 && grant_schema_name(key)
197 .as_deref()
198 .is_some_and(|schema| self.binding_schemas.contains(schema))
199 {
200 return true;
201 }
202
203 key.object_type == ObjectType::Database && self.roles.contains(&key.role)
204 }
205
206 fn allows_default_privilege_change(&self, key: &DefaultPrivKey) -> bool {
207 self.explicit_default_privileges.contains(key) || self.binding_schemas.contains(&key.schema)
208 }
209}
210
211fn is_binding_schema_object(object_type: ObjectType) -> bool {
212 !matches!(object_type, ObjectType::Database)
213}
214
215pub(crate) fn grant_schema_name(key: &GrantKey) -> Option<String> {
216 match key.object_type {
217 ObjectType::Schema => key.name.clone(),
218 ObjectType::Database => None,
219 _ => key.schema.clone(),
220 }
221}
222
223pub(crate) fn describe_change(change: &Change) -> String {
224 match change {
225 Change::CreateRole { name, .. } => format!("create role \"{name}\""),
226 Change::AlterRole { name, .. } => format!("alter role \"{name}\""),
227 Change::SetComment { name, .. } => format!("set comment on role \"{name}\""),
228 Change::SetPassword { name, .. } => format!("set password for role \"{name}\""),
229 Change::DropRole { name } => format!("drop role \"{name}\""),
230 Change::CreateSchema { name, .. } => format!("create schema \"{name}\""),
231 Change::AlterSchemaOwner { name, owner } => {
232 format!("alter schema \"{name}\" owner to \"{owner}\"")
233 }
234 Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
235 format!("ensure owner privileges on schema \"{name}\" for \"{owner}\"")
236 }
237 Change::Grant {
238 role,
239 object_type,
240 schema,
241 name,
242 ..
243 } => format_grant_action(
244 "grant",
245 role,
246 *object_type,
247 schema.as_deref(),
248 name.as_deref(),
249 ),
250 Change::Revoke {
251 role,
252 object_type,
253 schema,
254 name,
255 ..
256 } => format_grant_action(
257 "revoke",
258 role,
259 *object_type,
260 schema.as_deref(),
261 name.as_deref(),
262 ),
263 Change::SetDefaultPrivilege {
264 owner,
265 schema,
266 on_type,
267 grantee,
268 ..
269 } => format!(
270 "set default privilege for owner \"{owner}\" schema \"{schema}\" on {on_type} to \"{grantee}\""
271 ),
272 Change::RevokeDefaultPrivilege {
273 owner,
274 schema,
275 on_type,
276 grantee,
277 ..
278 } => format!(
279 "revoke default privilege for owner \"{owner}\" schema \"{schema}\" on {on_type} from \"{grantee}\""
280 ),
281 Change::AddMember { role, member, .. } => {
282 format!("add membership \"{role}\" -> \"{member}\"")
283 }
284 Change::RemoveMember { role, member } => {
285 format!("remove membership \"{role}\" -> \"{member}\"")
286 }
287 Change::TerminateSessions { role } => format!("terminate sessions for role \"{role}\""),
288 Change::ReassignOwned { from_role, to_role } => {
289 format!("reassign owned from \"{from_role}\" to \"{to_role}\"")
290 }
291 Change::DropOwned { role } => format!("drop owned by role \"{role}\""),
292 }
293}
294
295fn format_grant_action(
296 action: &str,
297 role: &str,
298 object_type: ObjectType,
299 schema: Option<&str>,
300 name: Option<&str>,
301) -> String {
302 let target = match (schema, name) {
303 (Some(schema), Some(name)) => format!("{schema}.{name}"),
304 (Some(schema), None) => schema.to_string(),
305 (None, Some(name)) => name.to_string(),
306 (None, None) => "<unnamed>".to_string(),
307 };
308 format!("{action} for role \"{role}\" on {object_type} \"{target}\"")
309}