Skip to main content

pgroles_core/
model.rs

1//! Normalized role-graph model.
2//!
3//! These types represent the **desired state** or the **current state** of a
4//! PostgreSQL cluster's roles, privileges, default privileges, and memberships.
5//! Both the manifest expansion and the database inspector produce these types,
6//! and the diff engine compares two `RoleGraph` instances.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::manifest::{ExpandedManifest, Grant, ObjectType, Privilege, RoleDefinition};
11
12// ---------------------------------------------------------------------------
13// Role attributes
14// ---------------------------------------------------------------------------
15
16/// The set of PostgreSQL role attributes we manage.
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
18pub struct RoleState {
19    pub login: bool,
20    pub superuser: bool,
21    pub createdb: bool,
22    pub createrole: bool,
23    pub inherit: bool,
24    pub replication: bool,
25    pub bypassrls: bool,
26    pub connection_limit: i32,
27    pub comment: Option<String>,
28}
29
30impl Default for RoleState {
31    fn default() -> Self {
32        Self {
33            login: false,
34            superuser: false,
35            createdb: false,
36            createrole: false,
37            inherit: true, // PostgreSQL default
38            replication: false,
39            bypassrls: false,
40            connection_limit: -1, // unlimited
41            comment: None,
42        }
43    }
44}
45
46impl RoleState {
47    /// Build a `RoleState` from a manifest `RoleDefinition`, using PostgreSQL
48    /// defaults for any unspecified attribute.
49    pub fn from_definition(definition: &RoleDefinition) -> Self {
50        let defaults = Self::default();
51        Self {
52            login: definition.login.unwrap_or(defaults.login),
53            superuser: definition.superuser.unwrap_or(defaults.superuser),
54            createdb: definition.createdb.unwrap_or(defaults.createdb),
55            createrole: definition.createrole.unwrap_or(defaults.createrole),
56            inherit: definition.inherit.unwrap_or(defaults.inherit),
57            replication: definition.replication.unwrap_or(defaults.replication),
58            bypassrls: definition.bypassrls.unwrap_or(defaults.bypassrls),
59            connection_limit: definition
60                .connection_limit
61                .unwrap_or(defaults.connection_limit),
62            comment: definition.comment.clone(),
63        }
64    }
65
66    /// Return a list of attribute names that differ between `self` and `other`.
67    pub fn changed_attributes(&self, other: &RoleState) -> Vec<RoleAttribute> {
68        let mut changes = Vec::new();
69        if self.login != other.login {
70            changes.push(RoleAttribute::Login(other.login));
71        }
72        if self.superuser != other.superuser {
73            changes.push(RoleAttribute::Superuser(other.superuser));
74        }
75        if self.createdb != other.createdb {
76            changes.push(RoleAttribute::Createdb(other.createdb));
77        }
78        if self.createrole != other.createrole {
79            changes.push(RoleAttribute::Createrole(other.createrole));
80        }
81        if self.inherit != other.inherit {
82            changes.push(RoleAttribute::Inherit(other.inherit));
83        }
84        if self.replication != other.replication {
85            changes.push(RoleAttribute::Replication(other.replication));
86        }
87        if self.bypassrls != other.bypassrls {
88            changes.push(RoleAttribute::Bypassrls(other.bypassrls));
89        }
90        if self.connection_limit != other.connection_limit {
91            changes.push(RoleAttribute::ConnectionLimit(other.connection_limit));
92        }
93        changes
94    }
95}
96
97/// A single attribute change on a role, used by `AlterRole`.
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
99pub enum RoleAttribute {
100    Login(bool),
101    Superuser(bool),
102    Createdb(bool),
103    Createrole(bool),
104    Inherit(bool),
105    Replication(bool),
106    Bypassrls(bool),
107    ConnectionLimit(i32),
108}
109
110// ---------------------------------------------------------------------------
111// Grants
112// ---------------------------------------------------------------------------
113
114/// Unique key identifying a grant target — (grantee, object_type, schema, name).
115///
116/// We use `Ord` so these can live in a `BTreeMap` for deterministic output.
117#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
118pub struct GrantKey {
119    /// The role receiving the privilege.
120    pub role: String,
121    /// The kind of object.
122    pub object_type: ObjectType,
123    /// Schema name. `None` for schema-level and database-level grants.
124    pub schema: Option<String>,
125    /// Object name, `"*"` for all-objects wildcard, `None` for schema-level grants.
126    pub name: Option<String>,
127}
128
129/// The privilege set on a particular grant target.
130#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
131pub struct GrantState {
132    pub privileges: BTreeSet<Privilege>,
133}
134
135// ---------------------------------------------------------------------------
136// Default privileges
137// ---------------------------------------------------------------------------
138
139/// Unique key identifying a default privilege rule.
140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
141pub struct DefaultPrivKey {
142    /// The owner role context (whose newly-created objects get these defaults).
143    pub owner: String,
144    /// The schema where the default applies.
145    pub schema: String,
146    /// The type of object affected.
147    pub on_type: ObjectType,
148    /// The grantee role.
149    pub grantee: String,
150}
151
152/// The privilege set for a default privilege rule.
153#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
154pub struct DefaultPrivState {
155    pub privileges: BTreeSet<Privilege>,
156}
157
158// ---------------------------------------------------------------------------
159// Memberships
160// ---------------------------------------------------------------------------
161
162/// A membership edge — "member belongs to role".
163#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
164pub struct MembershipEdge {
165    /// The group role.
166    pub role: String,
167    /// The member role (may be external, e.g. an email address).
168    pub member: String,
169    /// Whether the member inherits the role's privileges.
170    pub inherit: bool,
171    /// Whether the member can administer the role.
172    pub admin: bool,
173}
174
175// ---------------------------------------------------------------------------
176// RoleGraph — the top-level state container
177// ---------------------------------------------------------------------------
178
179/// Complete state of managed roles, grants, default privileges, and memberships.
180///
181/// Both the manifest expander and the database inspector produce this type.
182/// The diff engine compares two `RoleGraph` instances to compute changes.
183#[derive(Debug, Clone, Default)]
184pub struct RoleGraph {
185    /// Managed roles, keyed by role name.
186    pub roles: BTreeMap<String, RoleState>,
187    /// Object privilege grants, keyed by grant target.
188    pub grants: BTreeMap<GrantKey, GrantState>,
189    /// Default privilege rules, keyed by (owner, schema, type, grantee).
190    pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
191    /// Membership edges.
192    pub memberships: BTreeSet<MembershipEdge>,
193}
194
195impl RoleGraph {
196    /// Build a `RoleGraph` from an `ExpandedManifest`.
197    ///
198    /// This converts the manifest's user-facing types into the normalized model
199    /// that the diff engine operates on.
200    pub fn from_expanded(
201        expanded: &ExpandedManifest,
202        default_owner: Option<&str>,
203    ) -> Result<Self, crate::manifest::ManifestError> {
204        let mut graph = Self::default();
205
206        // --- Roles ---
207        for role_def in &expanded.roles {
208            let state = RoleState::from_definition(role_def);
209            graph.roles.insert(role_def.name.clone(), state);
210        }
211
212        // --- Grants ---
213        for grant in &expanded.grants {
214            let key = grant_key_from_manifest(grant);
215            let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
216                privileges: BTreeSet::new(),
217            });
218            for privilege in &grant.privileges {
219                entry.privileges.insert(*privilege);
220            }
221        }
222
223        // --- Default privileges ---
224        for default_priv in &expanded.default_privileges {
225            let owner = default_priv
226                .owner
227                .as_deref()
228                .or(default_owner)
229                .unwrap_or("postgres")
230                .to_string();
231
232            for grant in &default_priv.grant {
233                let grantee = grant.role.clone().ok_or_else(|| {
234                    crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
235                        schema: default_priv.schema.clone(),
236                    }
237                })?;
238
239                let key = DefaultPrivKey {
240                    owner: owner.clone(),
241                    schema: default_priv.schema.clone(),
242                    on_type: grant.on_type,
243                    grantee,
244                };
245
246                let entry =
247                    graph
248                        .default_privileges
249                        .entry(key)
250                        .or_insert_with(|| DefaultPrivState {
251                            privileges: BTreeSet::new(),
252                        });
253                for privilege in &grant.privileges {
254                    entry.privileges.insert(*privilege);
255                }
256            }
257        }
258
259        // --- Memberships ---
260        for membership in &expanded.memberships {
261            for member_spec in &membership.members {
262                graph.memberships.insert(MembershipEdge {
263                    role: membership.role.clone(),
264                    member: member_spec.name.clone(),
265                    inherit: member_spec.inherit,
266                    admin: member_spec.admin,
267                });
268            }
269        }
270
271        Ok(graph)
272    }
273}
274
275// ---------------------------------------------------------------------------
276// Helpers
277// ---------------------------------------------------------------------------
278
279fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
280    GrantKey {
281        role: grant.role.clone(),
282        object_type: grant.on.object_type,
283        schema: grant.on.schema.clone(),
284        name: grant.on.name.clone(),
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Implement Ord for ObjectType and Privilege so we can use them in BTreeSet/BTreeMap
290// ---------------------------------------------------------------------------
291
292impl PartialOrd for ObjectType {
293    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
294        Some(self.cmp(other))
295    }
296}
297
298impl Ord for ObjectType {
299    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
300        self.to_string().cmp(&other.to_string())
301    }
302}
303
304impl PartialOrd for Privilege {
305    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
306        Some(self.cmp(other))
307    }
308}
309
310impl Ord for Privilege {
311    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
312        self.to_string().cmp(&other.to_string())
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Tests
318// ---------------------------------------------------------------------------
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::manifest::{expand_manifest, parse_manifest};
324
325    #[test]
326    fn role_state_defaults_match_postgres() {
327        let state = RoleState::default();
328        assert!(!state.login);
329        assert!(!state.superuser);
330        assert!(!state.createdb);
331        assert!(!state.createrole);
332        assert!(state.inherit); // PG default is INHERIT
333        assert!(!state.replication);
334        assert!(!state.bypassrls);
335        assert_eq!(state.connection_limit, -1);
336    }
337
338    #[test]
339    fn role_state_from_definition_applies_overrides() {
340        let definition = RoleDefinition {
341            name: "test".to_string(),
342            login: Some(true),
343            superuser: None,
344            createdb: Some(true),
345            createrole: None,
346            inherit: Some(false),
347            replication: None,
348            bypassrls: None,
349            connection_limit: Some(10),
350            comment: Some("test role".to_string()),
351        };
352        let state = RoleState::from_definition(&definition);
353        assert!(state.login);
354        assert!(!state.superuser); // default
355        assert!(state.createdb);
356        assert!(!state.createrole); // default
357        assert!(!state.inherit); // overridden
358        assert_eq!(state.connection_limit, 10);
359        assert_eq!(state.comment, Some("test role".to_string()));
360    }
361
362    #[test]
363    fn changed_attributes_detects_differences() {
364        let current = RoleState::default();
365        let desired = RoleState {
366            login: true,
367            connection_limit: 5,
368            ..RoleState::default()
369        };
370        let changes = current.changed_attributes(&desired);
371        assert_eq!(changes.len(), 2);
372        assert!(changes.contains(&RoleAttribute::Login(true)));
373        assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
374    }
375
376    #[test]
377    fn changed_attributes_empty_when_equal() {
378        let state = RoleState::default();
379        assert!(state.changed_attributes(&state.clone()).is_empty());
380    }
381
382    #[test]
383    fn role_graph_from_expanded_manifest() {
384        let yaml = r#"
385default_owner: app_owner
386
387profiles:
388  editor:
389    grants:
390      - privileges: [USAGE]
391        on: { type: schema }
392      - privileges: [SELECT, INSERT]
393        on: { type: table, name: "*" }
394    default_privileges:
395      - privileges: [SELECT, INSERT]
396        on_type: table
397
398schemas:
399  - name: inventory
400    profiles: [editor]
401
402roles:
403  - name: analytics
404    login: true
405
406memberships:
407  - role: inventory-editor
408    members:
409      - name: "user@example.com"
410        inherit: true
411"#;
412        let manifest = parse_manifest(yaml).unwrap();
413        let expanded = expand_manifest(&manifest).unwrap();
414        let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
415
416        // Two roles: inventory-editor (from profile) + analytics (one-off)
417        assert_eq!(graph.roles.len(), 2);
418        assert!(graph.roles.contains_key("inventory-editor"));
419        assert!(graph.roles.contains_key("analytics"));
420
421        // inventory-editor is NOLOGIN, analytics is LOGIN
422        assert!(!graph.roles["inventory-editor"].login);
423        assert!(graph.roles["analytics"].login);
424
425        // Two grant targets: schema USAGE + table SELECT,INSERT
426        assert_eq!(graph.grants.len(), 2);
427
428        // One default privilege entry: SELECT,INSERT on tables for inventory-editor
429        assert_eq!(graph.default_privileges.len(), 1);
430        let dp_key = graph.default_privileges.keys().next().unwrap();
431        assert_eq!(dp_key.owner, "app_owner");
432        assert_eq!(dp_key.schema, "inventory");
433        assert_eq!(dp_key.on_type, ObjectType::Table);
434        assert_eq!(dp_key.grantee, "inventory-editor");
435        let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
436        assert!(dp_privs.contains(&Privilege::Select));
437        assert!(dp_privs.contains(&Privilege::Insert));
438
439        // One membership edge
440        assert_eq!(graph.memberships.len(), 1);
441        let edge = graph.memberships.iter().next().unwrap();
442        assert_eq!(edge.role, "inventory-editor");
443        assert_eq!(edge.member, "user@example.com");
444        assert!(edge.inherit);
445        assert!(!edge.admin);
446    }
447
448    #[test]
449    fn grant_privileges_merge_for_same_target() {
450        let yaml = r#"
451roles:
452  - name: testrole
453
454grants:
455  - role: testrole
456    privileges: [SELECT]
457    on: { type: table, schema: public, name: "*" }
458  - role: testrole
459    privileges: [INSERT, UPDATE]
460    on: { type: table, schema: public, name: "*" }
461"#;
462        let manifest = parse_manifest(yaml).unwrap();
463        let expanded = expand_manifest(&manifest).unwrap();
464        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
465
466        // Both grants target the same key, so privileges should merge
467        assert_eq!(graph.grants.len(), 1);
468        let grant_state = graph.grants.values().next().unwrap();
469        assert_eq!(grant_state.privileges.len(), 3);
470        assert!(grant_state.privileges.contains(&Privilege::Select));
471        assert!(grant_state.privileges.contains(&Privilege::Insert));
472        assert!(grant_state.privileges.contains(&Privilege::Update));
473    }
474}