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    /// Role-level configuration parameter defaults (`ALTER ROLE ... SET`),
33    /// keyed by lowercase parameter name. Mirrors the cluster-wide entries in
34    /// `pg_roles.rolconfig` (per-database `ALTER ROLE ... IN DATABASE` settings
35    /// are not managed).
36    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
37    pub config: BTreeMap<String, String>,
38}
39
40impl Default for RoleState {
41    fn default() -> Self {
42        Self {
43            login: false,
44            superuser: false,
45            createdb: false,
46            createrole: false,
47            inherit: true, // PostgreSQL default
48            replication: false,
49            bypassrls: false,
50            connection_limit: -1, // unlimited
51            comment: None,
52            password_valid_until: None,
53            config: BTreeMap::new(),
54        }
55    }
56}
57
58impl RoleState {
59    /// Build a `RoleState` from a manifest `RoleDefinition`, using PostgreSQL
60    /// defaults for any unspecified attribute.
61    pub fn from_definition(definition: &RoleDefinition) -> Self {
62        let defaults = Self::default();
63        Self {
64            login: definition.login.unwrap_or(defaults.login),
65            superuser: definition.superuser.unwrap_or(defaults.superuser),
66            createdb: definition.createdb.unwrap_or(defaults.createdb),
67            createrole: definition.createrole.unwrap_or(defaults.createrole),
68            inherit: definition.inherit.unwrap_or(defaults.inherit),
69            replication: definition.replication.unwrap_or(defaults.replication),
70            bypassrls: definition.bypassrls.unwrap_or(defaults.bypassrls),
71            connection_limit: definition
72                .connection_limit
73                .unwrap_or(defaults.connection_limit),
74            comment: definition.comment.clone(),
75            password_valid_until: definition.password_valid_until.clone(),
76            // GUC names are case-insensitive; normalize to lowercase so the
77            // desired state compares cleanly against pg_roles.rolconfig.
78            // List-quoted parameters (search_path, ...) are canonicalized
79            // element-wise so quoting and spacing differences don't diff.
80            config: definition
81                .config
82                .iter()
83                .map(|(name, value)| {
84                    let name = name.to_ascii_lowercase();
85                    let value = if crate::guc::is_list_quote_parameter(&name) {
86                        crate::guc::canonicalize_list_guc_value(&value.0)
87                    } else {
88                        value.0.clone()
89                    };
90                    (name, value)
91                })
92                .collect(),
93        }
94    }
95
96    /// Return a list of attribute names that differ between `self` and `other`.
97    pub fn changed_attributes(&self, other: &RoleState) -> Vec<RoleAttribute> {
98        let mut changes = Vec::new();
99        if self.login != other.login {
100            changes.push(RoleAttribute::Login(other.login));
101        }
102        if self.superuser != other.superuser {
103            changes.push(RoleAttribute::Superuser(other.superuser));
104        }
105        if self.createdb != other.createdb {
106            changes.push(RoleAttribute::Createdb(other.createdb));
107        }
108        if self.createrole != other.createrole {
109            changes.push(RoleAttribute::Createrole(other.createrole));
110        }
111        if self.inherit != other.inherit {
112            changes.push(RoleAttribute::Inherit(other.inherit));
113        }
114        if self.replication != other.replication {
115            changes.push(RoleAttribute::Replication(other.replication));
116        }
117        if self.bypassrls != other.bypassrls {
118            changes.push(RoleAttribute::Bypassrls(other.bypassrls));
119        }
120        if self.connection_limit != other.connection_limit {
121            changes.push(RoleAttribute::ConnectionLimit(other.connection_limit));
122        }
123        if self.password_valid_until != other.password_valid_until {
124            changes.push(RoleAttribute::ValidUntil(
125                other.password_valid_until.clone(),
126            ));
127        }
128        for (parameter, value) in &other.config {
129            if self.config.get(parameter) != Some(value) {
130                changes.push(RoleAttribute::SetConfig(parameter.clone(), value.clone()));
131            }
132        }
133        for parameter in self.config.keys() {
134            if !other.config.contains_key(parameter) {
135                changes.push(RoleAttribute::ResetConfig(parameter.clone()));
136            }
137        }
138        changes
139    }
140}
141
142/// A single attribute change on a role, used by `AlterRole`.
143#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
144pub enum RoleAttribute {
145    Login(bool),
146    Superuser(bool),
147    Createdb(bool),
148    Createrole(bool),
149    Inherit(bool),
150    Replication(bool),
151    Bypassrls(bool),
152    ConnectionLimit(i32),
153    /// Password expiration change. `None` removes the expiration (`VALID UNTIL 'infinity'`).
154    ValidUntil(Option<String>),
155    /// Set a role-level configuration default (`ALTER ROLE ... SET name = value`).
156    SetConfig(String, String),
157    /// Remove a role-level configuration default (`ALTER ROLE ... RESET name`).
158    ResetConfig(String),
159}
160
161// ---------------------------------------------------------------------------
162// Schemas
163// ---------------------------------------------------------------------------
164
165/// The schema state managed by pgroles.
166#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
167pub struct SchemaState {
168    /// Desired owner for the schema. `None` means ensure existence only.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub owner: Option<String>,
171    /// The schema owner's ordinary privileges on the schema itself.
172    ///
173    /// PostgreSQL lets owners revoke their own CREATE/USAGE privileges, so we
174    /// track the effective state separately from grant rows.
175    #[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
176    pub owner_privileges: BTreeSet<Privilege>,
177}
178
179// ---------------------------------------------------------------------------
180// Grants
181// ---------------------------------------------------------------------------
182
183/// Unique key identifying a grant target — (grantee, object_type, schema, name).
184///
185/// We use `Ord` so these can live in a `BTreeMap` for deterministic output.
186#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
187pub struct GrantKey {
188    /// The role receiving the privilege.
189    pub role: String,
190    /// The kind of object.
191    pub object_type: ObjectType,
192    /// Schema name. `None` for schema-level and database-level grants.
193    pub schema: Option<String>,
194    /// Object name, `"*"` for all-objects wildcard, `None` for schema-level grants.
195    pub name: Option<String>,
196}
197
198/// The privilege set on a particular grant target.
199#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
200pub struct GrantState {
201    pub privileges: BTreeSet<Privilege>,
202}
203
204// ---------------------------------------------------------------------------
205// Default privileges
206// ---------------------------------------------------------------------------
207
208/// Unique key identifying a default privilege rule.
209#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
210pub struct DefaultPrivKey {
211    /// The owner role context (whose newly-created objects get these defaults).
212    pub owner: String,
213    /// The schema where the default applies.
214    pub schema: String,
215    /// The type of object affected.
216    pub on_type: ObjectType,
217    /// The grantee role.
218    pub grantee: String,
219}
220
221/// The privilege set for a default privilege rule.
222#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
223pub struct DefaultPrivState {
224    pub privileges: BTreeSet<Privilege>,
225}
226
227// ---------------------------------------------------------------------------
228// Memberships
229// ---------------------------------------------------------------------------
230
231/// A membership edge — "member belongs to role".
232#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
233pub struct MembershipEdge {
234    /// The group role.
235    pub role: String,
236    /// The member role (may be external, e.g. an email address).
237    pub member: String,
238    /// Whether the member inherits the role's privileges.
239    pub inherit: bool,
240    /// Whether the member can administer the role.
241    pub admin: bool,
242}
243
244// ---------------------------------------------------------------------------
245// RoleGraph — the top-level state container
246// ---------------------------------------------------------------------------
247
248/// Complete state of managed roles, grants, default privileges, and memberships.
249///
250/// Both the manifest expander and the database inspector produce this type.
251/// The diff engine compares two `RoleGraph` instances to compute changes.
252#[derive(Debug, Clone, Default)]
253pub struct RoleGraph {
254    /// Managed roles, keyed by role name.
255    pub roles: BTreeMap<String, RoleState>,
256    /// Managed schemas, keyed by schema name.
257    pub schemas: BTreeMap<String, SchemaState>,
258    /// Object privilege grants, keyed by grant target.
259    pub grants: BTreeMap<GrantKey, GrantState>,
260    /// Default privilege rules, keyed by (owner, schema, type, grantee).
261    pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
262    /// Membership edges.
263    pub memberships: BTreeSet<MembershipEdge>,
264}
265
266impl RoleGraph {
267    /// Build a `RoleGraph` from an `ExpandedManifest`.
268    ///
269    /// This converts the manifest's user-facing types into the normalized model
270    /// that the diff engine operates on.
271    pub fn from_expanded(
272        expanded: &ExpandedManifest,
273        default_owner: Option<&str>,
274    ) -> Result<Self, crate::manifest::ManifestError> {
275        let mut graph = Self::default();
276
277        // --- Roles ---
278        for role_def in &expanded.roles {
279            let state = RoleState::from_definition(role_def);
280            graph.roles.insert(role_def.name.clone(), state);
281        }
282
283        // --- Schemas ---
284        for schema in &expanded.schemas {
285            let owner = schema.owner.clone();
286            graph.schemas.insert(
287                schema.name.clone(),
288                SchemaState {
289                    owner_privileges: owner
290                        .as_deref()
291                        .map(default_schema_owner_privileges)
292                        .unwrap_or_default(),
293                    owner,
294                },
295            );
296        }
297
298        // --- Grants ---
299        for grant in &expanded.grants {
300            let key = grant_key_from_manifest(grant);
301            let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
302                privileges: BTreeSet::new(),
303            });
304            for privilege in &grant.privileges {
305                entry.privileges.insert(*privilege);
306            }
307        }
308
309        // --- Default privileges ---
310        for default_priv in &expanded.default_privileges {
311            let owner = default_priv
312                .owner
313                .as_deref()
314                .or(default_owner)
315                .unwrap_or("postgres")
316                .to_string();
317
318            for grant in &default_priv.grant {
319                let grantee = grant.role.clone().ok_or_else(|| {
320                    crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
321                        schema: default_priv.schema.clone(),
322                    }
323                })?;
324
325                let key = DefaultPrivKey {
326                    owner: owner.clone(),
327                    schema: default_priv.schema.clone(),
328                    on_type: grant.on_type,
329                    grantee,
330                };
331
332                let entry =
333                    graph
334                        .default_privileges
335                        .entry(key)
336                        .or_insert_with(|| DefaultPrivState {
337                            privileges: BTreeSet::new(),
338                        });
339                for privilege in &grant.privileges {
340                    entry.privileges.insert(*privilege);
341                }
342            }
343        }
344
345        // --- Memberships ---
346        for membership in &expanded.memberships {
347            for member_spec in &membership.members {
348                graph.memberships.insert(MembershipEdge {
349                    role: membership.role.clone(),
350                    member: member_spec.name.clone(),
351                    inherit: member_spec.inherit(),
352                    admin: member_spec.admin(),
353                });
354            }
355        }
356
357        Ok(graph)
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Helpers
363// ---------------------------------------------------------------------------
364
365fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
366    GrantKey {
367        role: grant.role.clone(),
368        object_type: grant.object.object_type,
369        schema: grant.object.schema.clone(),
370        name: grant.object.name.clone(),
371    }
372}
373
374pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
375    [Privilege::Create, Privilege::Usage].into_iter().collect()
376}
377
378// ---------------------------------------------------------------------------
379// Implement Ord for ObjectType and Privilege so we can use them in BTreeSet/BTreeMap
380// ---------------------------------------------------------------------------
381
382impl PartialOrd for ObjectType {
383    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
384        Some(self.cmp(other))
385    }
386}
387
388impl Ord for ObjectType {
389    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
390        self.to_string().cmp(&other.to_string())
391    }
392}
393
394impl PartialOrd for Privilege {
395    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
396        Some(self.cmp(other))
397    }
398}
399
400impl Ord for Privilege {
401    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
402        self.to_string().cmp(&other.to_string())
403    }
404}
405
406// ---------------------------------------------------------------------------
407// Tests
408// ---------------------------------------------------------------------------
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::manifest::{expand_manifest, parse_manifest};
414
415    #[test]
416    fn role_state_defaults_match_postgres() {
417        let state = RoleState::default();
418        assert!(!state.login);
419        assert!(!state.superuser);
420        assert!(!state.createdb);
421        assert!(!state.createrole);
422        assert!(state.inherit); // PG default is INHERIT
423        assert!(!state.replication);
424        assert!(!state.bypassrls);
425        assert_eq!(state.connection_limit, -1);
426    }
427
428    #[test]
429    fn role_state_from_definition_applies_overrides() {
430        let definition = RoleDefinition {
431            name: "test".to_string(),
432            external: false,
433            login: Some(true),
434            superuser: None,
435            createdb: Some(true),
436            createrole: None,
437            inherit: Some(false),
438            replication: None,
439            bypassrls: None,
440            connection_limit: Some(10),
441            comment: Some("test role".to_string()),
442            password: None,
443            password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
444            config: Default::default(),
445        };
446        let state = RoleState::from_definition(&definition);
447        assert!(state.login);
448        assert!(!state.superuser); // default
449        assert!(state.createdb);
450        assert!(!state.createrole); // default
451        assert!(!state.inherit); // overridden
452        assert_eq!(state.connection_limit, 10);
453        assert_eq!(state.comment, Some("test role".to_string()));
454        assert_eq!(
455            state.password_valid_until,
456            Some("2025-12-31T00:00:00Z".to_string())
457        );
458    }
459
460    #[test]
461    fn changed_attributes_detects_differences() {
462        let current = RoleState::default();
463        let desired = RoleState {
464            login: true,
465            connection_limit: 5,
466            ..RoleState::default()
467        };
468        let changes = current.changed_attributes(&desired);
469        assert_eq!(changes.len(), 2);
470        assert!(changes.contains(&RoleAttribute::Login(true)));
471        assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
472    }
473
474    #[test]
475    fn changed_attributes_empty_when_equal() {
476        let state = RoleState::default();
477        assert!(state.changed_attributes(&state.clone()).is_empty());
478    }
479
480    #[test]
481    fn changed_attributes_detects_config_set_change_and_reset() {
482        let current = RoleState {
483            config: [
484                ("role".to_string(), "combined".to_string()),
485                ("statement_timeout".to_string(), "30s".to_string()),
486            ]
487            .into_iter()
488            .collect(),
489            ..RoleState::default()
490        };
491        let desired = RoleState {
492            config: [
493                ("role".to_string(), "combined".to_string()),
494                ("search_path".to_string(), "app".to_string()),
495            ]
496            .into_iter()
497            .collect(),
498            ..RoleState::default()
499        };
500        let changes = current.changed_attributes(&desired);
501        assert_eq!(changes.len(), 2);
502        assert!(changes.contains(&RoleAttribute::SetConfig(
503            "search_path".to_string(),
504            "app".to_string()
505        )));
506        assert!(changes.contains(&RoleAttribute::ResetConfig("statement_timeout".to_string())));
507        // Unchanged `role` setting produces no change.
508        assert!(
509            !changes
510                .iter()
511                .any(|c| matches!(c, RoleAttribute::SetConfig(p, _) if p == "role"))
512        );
513    }
514
515    #[test]
516    fn profile_config_flows_through_to_generated_role_state() {
517        // The generated role's `config` comes entirely from `expand_manifest`
518        // substituting the profile's config — `RoleState::from_definition`
519        // and `RoleGraph::from_expanded` need no changes to support it, since
520        // generated roles are ordinary `RoleDefinition`s by the time they
521        // reach this layer. This test is the proof, not an assumption.
522        let yaml = r#"
523profiles:
524  editor:
525    login: true
526    config:
527      search_path: "{schema}"
528      statement_timeout: "30s"
529
530schemas:
531  - name: inventory
532    profiles: [editor]
533"#;
534        let manifest = parse_manifest(yaml).unwrap();
535        let expanded = expand_manifest(&manifest).unwrap();
536        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
537
538        let role = graph
539            .roles
540            .get("inventory-editor")
541            .expect("generated role should be present");
542        assert_eq!(
543            role.config.get("search_path").map(String::as_str),
544            Some("inventory")
545        );
546        assert_eq!(
547            role.config.get("statement_timeout").map(String::as_str),
548            Some("30s")
549        );
550    }
551
552    #[test]
553    fn from_definition_lowercases_config_parameter_names() {
554        let yaml = r#"
555roles:
556  - name: blue
557    login: true
558    config:
559      Role: combined
560      statement_timeout: "30000"
561"#;
562        let manifest = parse_manifest(yaml).unwrap();
563        let graph = RoleGraph::from_expanded(&expand_manifest(&manifest).unwrap(), None).unwrap();
564        let config = &graph.roles["blue"].config;
565        assert_eq!(config.get("role").map(String::as_str), Some("combined"));
566        assert_eq!(
567            config.get("statement_timeout").map(String::as_str),
568            Some("30000")
569        );
570    }
571
572    #[test]
573    fn role_graph_from_expanded_manifest() {
574        let yaml = r#"
575default_owner: app_owner
576
577profiles:
578  editor:
579    grants:
580      - privileges: [USAGE]
581        object: { type: schema }
582      - privileges: [SELECT, INSERT]
583        object: { type: table, name: "*" }
584    default_privileges:
585      - privileges: [SELECT, INSERT]
586        on_type: table
587
588schemas:
589  - name: inventory
590    profiles: [editor]
591
592roles:
593  - name: analytics
594    login: true
595
596memberships:
597  - role: inventory-editor
598    members:
599      - name: "user@example.com"
600        inherit: true
601"#;
602        let manifest = parse_manifest(yaml).unwrap();
603        let expanded = expand_manifest(&manifest).unwrap();
604        let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
605
606        // Two roles: inventory-editor (from profile) + analytics (one-off)
607        assert_eq!(graph.roles.len(), 2);
608        assert!(graph.roles.contains_key("inventory-editor"));
609        assert!(graph.roles.contains_key("analytics"));
610
611        // Managed schema state includes the declared schema and resolved owner.
612        assert_eq!(graph.schemas.len(), 1);
613        assert_eq!(
614            graph.schemas["inventory"].owner.as_deref(),
615            Some("app_owner")
616        );
617
618        // inventory-editor is NOLOGIN, analytics is LOGIN
619        assert!(!graph.roles["inventory-editor"].login);
620        assert!(graph.roles["analytics"].login);
621
622        // Two grant targets: schema USAGE + table SELECT,INSERT
623        assert_eq!(graph.grants.len(), 2);
624
625        // One default privilege entry: SELECT,INSERT on tables for inventory-editor
626        assert_eq!(graph.default_privileges.len(), 1);
627        let dp_key = graph.default_privileges.keys().next().unwrap();
628        assert_eq!(dp_key.owner, "app_owner");
629        assert_eq!(dp_key.schema, "inventory");
630        assert_eq!(dp_key.on_type, ObjectType::Table);
631        assert_eq!(dp_key.grantee, "inventory-editor");
632        let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
633        assert!(dp_privs.contains(&Privilege::Select));
634        assert!(dp_privs.contains(&Privilege::Insert));
635
636        // One membership edge
637        assert_eq!(graph.memberships.len(), 1);
638        let edge = graph.memberships.iter().next().unwrap();
639        assert_eq!(edge.role, "inventory-editor");
640        assert_eq!(edge.member, "user@example.com");
641        assert!(edge.inherit);
642        assert!(!edge.admin);
643    }
644
645    #[test]
646    fn grant_privileges_merge_for_same_target() {
647        let yaml = r#"
648roles:
649  - name: testrole
650
651grants:
652  - role: testrole
653    privileges: [SELECT]
654    object: { type: table, schema: public, name: "*" }
655  - role: testrole
656    privileges: [INSERT, UPDATE]
657    object: { type: table, schema: public, name: "*" }
658"#;
659        let manifest = parse_manifest(yaml).unwrap();
660        let expanded = expand_manifest(&manifest).unwrap();
661        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
662
663        // Both grants target the same key, so privileges should merge
664        assert_eq!(graph.grants.len(), 1);
665        let grant_state = graph.grants.values().next().unwrap();
666        assert_eq!(grant_state.privileges.len(), 3);
667        assert!(grant_state.privileges.contains(&Privilege::Select));
668        assert!(grant_state.privileges.contains(&Privilege::Insert));
669        assert!(grant_state.privileges.contains(&Privilege::Update));
670    }
671}