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// Schemas
125// ---------------------------------------------------------------------------
126
127/// The schema state managed by pgroles.
128#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
129pub struct SchemaState {
130    /// Desired owner for the schema. `None` means ensure existence only.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub owner: Option<String>,
133    /// The schema owner's ordinary privileges on the schema itself.
134    ///
135    /// PostgreSQL lets owners revoke their own CREATE/USAGE privileges, so we
136    /// track the effective state separately from grant rows.
137    #[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
138    pub owner_privileges: BTreeSet<Privilege>,
139}
140
141// ---------------------------------------------------------------------------
142// Grants
143// ---------------------------------------------------------------------------
144
145/// Unique key identifying a grant target — (grantee, object_type, schema, name).
146///
147/// We use `Ord` so these can live in a `BTreeMap` for deterministic output.
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
149pub struct GrantKey {
150    /// The role receiving the privilege.
151    pub role: String,
152    /// The kind of object.
153    pub object_type: ObjectType,
154    /// Schema name. `None` for schema-level and database-level grants.
155    pub schema: Option<String>,
156    /// Object name, `"*"` for all-objects wildcard, `None` for schema-level grants.
157    pub name: Option<String>,
158}
159
160/// The privilege set on a particular grant target.
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162pub struct GrantState {
163    pub privileges: BTreeSet<Privilege>,
164}
165
166// ---------------------------------------------------------------------------
167// Default privileges
168// ---------------------------------------------------------------------------
169
170/// Unique key identifying a default privilege rule.
171#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
172pub struct DefaultPrivKey {
173    /// The owner role context (whose newly-created objects get these defaults).
174    pub owner: String,
175    /// The schema where the default applies.
176    pub schema: String,
177    /// The type of object affected.
178    pub on_type: ObjectType,
179    /// The grantee role.
180    pub grantee: String,
181}
182
183/// The privilege set for a default privilege rule.
184#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
185pub struct DefaultPrivState {
186    pub privileges: BTreeSet<Privilege>,
187}
188
189// ---------------------------------------------------------------------------
190// Memberships
191// ---------------------------------------------------------------------------
192
193/// A membership edge — "member belongs to role".
194#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
195pub struct MembershipEdge {
196    /// The group role.
197    pub role: String,
198    /// The member role (may be external, e.g. an email address).
199    pub member: String,
200    /// Whether the member inherits the role's privileges.
201    pub inherit: bool,
202    /// Whether the member can administer the role.
203    pub admin: bool,
204}
205
206// ---------------------------------------------------------------------------
207// RoleGraph — the top-level state container
208// ---------------------------------------------------------------------------
209
210/// Complete state of managed roles, grants, default privileges, and memberships.
211///
212/// Both the manifest expander and the database inspector produce this type.
213/// The diff engine compares two `RoleGraph` instances to compute changes.
214#[derive(Debug, Clone, Default)]
215pub struct RoleGraph {
216    /// Managed roles, keyed by role name.
217    pub roles: BTreeMap<String, RoleState>,
218    /// Managed schemas, keyed by schema name.
219    pub schemas: BTreeMap<String, SchemaState>,
220    /// Object privilege grants, keyed by grant target.
221    pub grants: BTreeMap<GrantKey, GrantState>,
222    /// Default privilege rules, keyed by (owner, schema, type, grantee).
223    pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
224    /// Membership edges.
225    pub memberships: BTreeSet<MembershipEdge>,
226}
227
228impl RoleGraph {
229    /// Build a `RoleGraph` from an `ExpandedManifest`.
230    ///
231    /// This converts the manifest's user-facing types into the normalized model
232    /// that the diff engine operates on.
233    pub fn from_expanded(
234        expanded: &ExpandedManifest,
235        default_owner: Option<&str>,
236    ) -> Result<Self, crate::manifest::ManifestError> {
237        let mut graph = Self::default();
238
239        // --- Roles ---
240        for role_def in &expanded.roles {
241            let state = RoleState::from_definition(role_def);
242            graph.roles.insert(role_def.name.clone(), state);
243        }
244
245        // --- Schemas ---
246        for schema in &expanded.schemas {
247            let owner = schema.owner.clone();
248            graph.schemas.insert(
249                schema.name.clone(),
250                SchemaState {
251                    owner_privileges: owner
252                        .as_deref()
253                        .map(default_schema_owner_privileges)
254                        .unwrap_or_default(),
255                    owner,
256                },
257            );
258        }
259
260        // --- Grants ---
261        for grant in &expanded.grants {
262            let key = grant_key_from_manifest(grant);
263            let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
264                privileges: BTreeSet::new(),
265            });
266            for privilege in &grant.privileges {
267                entry.privileges.insert(*privilege);
268            }
269        }
270
271        // --- Default privileges ---
272        for default_priv in &expanded.default_privileges {
273            let owner = default_priv
274                .owner
275                .as_deref()
276                .or(default_owner)
277                .unwrap_or("postgres")
278                .to_string();
279
280            for grant in &default_priv.grant {
281                let grantee = grant.role.clone().ok_or_else(|| {
282                    crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
283                        schema: default_priv.schema.clone(),
284                    }
285                })?;
286
287                let key = DefaultPrivKey {
288                    owner: owner.clone(),
289                    schema: default_priv.schema.clone(),
290                    on_type: grant.on_type,
291                    grantee,
292                };
293
294                let entry =
295                    graph
296                        .default_privileges
297                        .entry(key)
298                        .or_insert_with(|| DefaultPrivState {
299                            privileges: BTreeSet::new(),
300                        });
301                for privilege in &grant.privileges {
302                    entry.privileges.insert(*privilege);
303                }
304            }
305        }
306
307        // --- Memberships ---
308        for membership in &expanded.memberships {
309            for member_spec in &membership.members {
310                graph.memberships.insert(MembershipEdge {
311                    role: membership.role.clone(),
312                    member: member_spec.name.clone(),
313                    inherit: member_spec.inherit(),
314                    admin: member_spec.admin(),
315                });
316            }
317        }
318
319        Ok(graph)
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Helpers
325// ---------------------------------------------------------------------------
326
327fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
328    GrantKey {
329        role: grant.role.clone(),
330        object_type: grant.object.object_type,
331        schema: grant.object.schema.clone(),
332        name: grant.object.name.clone(),
333    }
334}
335
336pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
337    [Privilege::Create, Privilege::Usage].into_iter().collect()
338}
339
340// ---------------------------------------------------------------------------
341// Implement Ord for ObjectType and Privilege so we can use them in BTreeSet/BTreeMap
342// ---------------------------------------------------------------------------
343
344impl PartialOrd for ObjectType {
345    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
346        Some(self.cmp(other))
347    }
348}
349
350impl Ord for ObjectType {
351    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
352        self.to_string().cmp(&other.to_string())
353    }
354}
355
356impl PartialOrd for Privilege {
357    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
358        Some(self.cmp(other))
359    }
360}
361
362impl Ord for Privilege {
363    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
364        self.to_string().cmp(&other.to_string())
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Tests
370// ---------------------------------------------------------------------------
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::manifest::{expand_manifest, parse_manifest};
376
377    #[test]
378    fn role_state_defaults_match_postgres() {
379        let state = RoleState::default();
380        assert!(!state.login);
381        assert!(!state.superuser);
382        assert!(!state.createdb);
383        assert!(!state.createrole);
384        assert!(state.inherit); // PG default is INHERIT
385        assert!(!state.replication);
386        assert!(!state.bypassrls);
387        assert_eq!(state.connection_limit, -1);
388    }
389
390    #[test]
391    fn role_state_from_definition_applies_overrides() {
392        let definition = RoleDefinition {
393            name: "test".to_string(),
394            external: false,
395            login: Some(true),
396            superuser: None,
397            createdb: Some(true),
398            createrole: None,
399            inherit: Some(false),
400            replication: None,
401            bypassrls: None,
402            connection_limit: Some(10),
403            comment: Some("test role".to_string()),
404            password: None,
405            password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
406        };
407        let state = RoleState::from_definition(&definition);
408        assert!(state.login);
409        assert!(!state.superuser); // default
410        assert!(state.createdb);
411        assert!(!state.createrole); // default
412        assert!(!state.inherit); // overridden
413        assert_eq!(state.connection_limit, 10);
414        assert_eq!(state.comment, Some("test role".to_string()));
415        assert_eq!(
416            state.password_valid_until,
417            Some("2025-12-31T00:00:00Z".to_string())
418        );
419    }
420
421    #[test]
422    fn changed_attributes_detects_differences() {
423        let current = RoleState::default();
424        let desired = RoleState {
425            login: true,
426            connection_limit: 5,
427            ..RoleState::default()
428        };
429        let changes = current.changed_attributes(&desired);
430        assert_eq!(changes.len(), 2);
431        assert!(changes.contains(&RoleAttribute::Login(true)));
432        assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
433    }
434
435    #[test]
436    fn changed_attributes_empty_when_equal() {
437        let state = RoleState::default();
438        assert!(state.changed_attributes(&state.clone()).is_empty());
439    }
440
441    #[test]
442    fn role_graph_from_expanded_manifest() {
443        let yaml = r#"
444default_owner: app_owner
445
446profiles:
447  editor:
448    grants:
449      - privileges: [USAGE]
450        object: { type: schema }
451      - privileges: [SELECT, INSERT]
452        object: { type: table, name: "*" }
453    default_privileges:
454      - privileges: [SELECT, INSERT]
455        on_type: table
456
457schemas:
458  - name: inventory
459    profiles: [editor]
460
461roles:
462  - name: analytics
463    login: true
464
465memberships:
466  - role: inventory-editor
467    members:
468      - name: "user@example.com"
469        inherit: true
470"#;
471        let manifest = parse_manifest(yaml).unwrap();
472        let expanded = expand_manifest(&manifest).unwrap();
473        let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
474
475        // Two roles: inventory-editor (from profile) + analytics (one-off)
476        assert_eq!(graph.roles.len(), 2);
477        assert!(graph.roles.contains_key("inventory-editor"));
478        assert!(graph.roles.contains_key("analytics"));
479
480        // Managed schema state includes the declared schema and resolved owner.
481        assert_eq!(graph.schemas.len(), 1);
482        assert_eq!(
483            graph.schemas["inventory"].owner.as_deref(),
484            Some("app_owner")
485        );
486
487        // inventory-editor is NOLOGIN, analytics is LOGIN
488        assert!(!graph.roles["inventory-editor"].login);
489        assert!(graph.roles["analytics"].login);
490
491        // Two grant targets: schema USAGE + table SELECT,INSERT
492        assert_eq!(graph.grants.len(), 2);
493
494        // One default privilege entry: SELECT,INSERT on tables for inventory-editor
495        assert_eq!(graph.default_privileges.len(), 1);
496        let dp_key = graph.default_privileges.keys().next().unwrap();
497        assert_eq!(dp_key.owner, "app_owner");
498        assert_eq!(dp_key.schema, "inventory");
499        assert_eq!(dp_key.on_type, ObjectType::Table);
500        assert_eq!(dp_key.grantee, "inventory-editor");
501        let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
502        assert!(dp_privs.contains(&Privilege::Select));
503        assert!(dp_privs.contains(&Privilege::Insert));
504
505        // One membership edge
506        assert_eq!(graph.memberships.len(), 1);
507        let edge = graph.memberships.iter().next().unwrap();
508        assert_eq!(edge.role, "inventory-editor");
509        assert_eq!(edge.member, "user@example.com");
510        assert!(edge.inherit);
511        assert!(!edge.admin);
512    }
513
514    #[test]
515    fn grant_privileges_merge_for_same_target() {
516        let yaml = r#"
517roles:
518  - name: testrole
519
520grants:
521  - role: testrole
522    privileges: [SELECT]
523    object: { type: table, schema: public, name: "*" }
524  - role: testrole
525    privileges: [INSERT, UPDATE]
526    object: { type: table, schema: public, name: "*" }
527"#;
528        let manifest = parse_manifest(yaml).unwrap();
529        let expanded = expand_manifest(&manifest).unwrap();
530        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
531
532        // Both grants target the same key, so privileges should merge
533        assert_eq!(graph.grants.len(), 1);
534        let grant_state = graph.grants.values().next().unwrap();
535        assert_eq!(grant_state.privileges.len(), 3);
536        assert!(grant_state.privileges.contains(&Privilege::Select));
537        assert!(grant_state.privileges.contains(&Privilege::Insert));
538        assert!(grant_state.privileges.contains(&Privilege::Update));
539    }
540}