Skip to main content

pgroles_core/
ownership.rs

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}