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