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            login: Some(true),
395            superuser: None,
396            createdb: Some(true),
397            createrole: None,
398            inherit: Some(false),
399            replication: None,
400            bypassrls: None,
401            connection_limit: Some(10),
402            comment: Some("test role".to_string()),
403            password: None,
404            password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
405        };
406        let state = RoleState::from_definition(&definition);
407        assert!(state.login);
408        assert!(!state.superuser); // default
409        assert!(state.createdb);
410        assert!(!state.createrole); // default
411        assert!(!state.inherit); // overridden
412        assert_eq!(state.connection_limit, 10);
413        assert_eq!(state.comment, Some("test role".to_string()));
414        assert_eq!(
415            state.password_valid_until,
416            Some("2025-12-31T00:00:00Z".to_string())
417        );
418    }
419
420    #[test]
421    fn changed_attributes_detects_differences() {
422        let current = RoleState::default();
423        let desired = RoleState {
424            login: true,
425            connection_limit: 5,
426            ..RoleState::default()
427        };
428        let changes = current.changed_attributes(&desired);
429        assert_eq!(changes.len(), 2);
430        assert!(changes.contains(&RoleAttribute::Login(true)));
431        assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
432    }
433
434    #[test]
435    fn changed_attributes_empty_when_equal() {
436        let state = RoleState::default();
437        assert!(state.changed_attributes(&state.clone()).is_empty());
438    }
439
440    #[test]
441    fn role_graph_from_expanded_manifest() {
442        let yaml = r#"
443default_owner: app_owner
444
445profiles:
446  editor:
447    grants:
448      - privileges: [USAGE]
449        object: { type: schema }
450      - privileges: [SELECT, INSERT]
451        object: { type: table, name: "*" }
452    default_privileges:
453      - privileges: [SELECT, INSERT]
454        on_type: table
455
456schemas:
457  - name: inventory
458    profiles: [editor]
459
460roles:
461  - name: analytics
462    login: true
463
464memberships:
465  - role: inventory-editor
466    members:
467      - name: "user@example.com"
468        inherit: true
469"#;
470        let manifest = parse_manifest(yaml).unwrap();
471        let expanded = expand_manifest(&manifest).unwrap();
472        let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
473
474        // Two roles: inventory-editor (from profile) + analytics (one-off)
475        assert_eq!(graph.roles.len(), 2);
476        assert!(graph.roles.contains_key("inventory-editor"));
477        assert!(graph.roles.contains_key("analytics"));
478
479        // Managed schema state includes the declared schema and resolved owner.
480        assert_eq!(graph.schemas.len(), 1);
481        assert_eq!(
482            graph.schemas["inventory"].owner.as_deref(),
483            Some("app_owner")
484        );
485
486        // inventory-editor is NOLOGIN, analytics is LOGIN
487        assert!(!graph.roles["inventory-editor"].login);
488        assert!(graph.roles["analytics"].login);
489
490        // Two grant targets: schema USAGE + table SELECT,INSERT
491        assert_eq!(graph.grants.len(), 2);
492
493        // One default privilege entry: SELECT,INSERT on tables for inventory-editor
494        assert_eq!(graph.default_privileges.len(), 1);
495        let dp_key = graph.default_privileges.keys().next().unwrap();
496        assert_eq!(dp_key.owner, "app_owner");
497        assert_eq!(dp_key.schema, "inventory");
498        assert_eq!(dp_key.on_type, ObjectType::Table);
499        assert_eq!(dp_key.grantee, "inventory-editor");
500        let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
501        assert!(dp_privs.contains(&Privilege::Select));
502        assert!(dp_privs.contains(&Privilege::Insert));
503
504        // One membership edge
505        assert_eq!(graph.memberships.len(), 1);
506        let edge = graph.memberships.iter().next().unwrap();
507        assert_eq!(edge.role, "inventory-editor");
508        assert_eq!(edge.member, "user@example.com");
509        assert!(edge.inherit);
510        assert!(!edge.admin);
511    }
512
513    #[test]
514    fn grant_privileges_merge_for_same_target() {
515        let yaml = r#"
516roles:
517  - name: testrole
518
519grants:
520  - role: testrole
521    privileges: [SELECT]
522    object: { type: table, schema: public, name: "*" }
523  - role: testrole
524    privileges: [INSERT, UPDATE]
525    object: { type: table, schema: public, name: "*" }
526"#;
527        let manifest = parse_manifest(yaml).unwrap();
528        let expanded = expand_manifest(&manifest).unwrap();
529        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
530
531        // Both grants target the same key, so privileges should merge
532        assert_eq!(graph.grants.len(), 1);
533        let grant_state = graph.grants.values().next().unwrap();
534        assert_eq!(grant_state.privileges.len(), 3);
535        assert!(grant_state.privileges.contains(&Privilege::Select));
536        assert!(grant_state.privileges.contains(&Privilege::Insert));
537        assert!(grant_state.privileges.contains(&Privilege::Update));
538    }
539}