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::{Ensure, 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// Grantees and scopes
181// ---------------------------------------------------------------------------
182
183/// A privilege grantee: either an ordinary role or the PostgreSQL PUBLIC
184/// pseudo-role (ACL grantee OID 0).
185///
186/// PUBLIC is typed rather than spelled as a magic string so that SQL rendering
187/// can never quote it (`"PUBLIC"` would name a real role) and so a role
188/// literally named `PUBLIC` can never be confused with the pseudo-role.
189/// `Public` sorts before every role name, which keeps BTreeMap output
190/// deterministic.
191#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
192pub enum Grantee {
193    Public,
194    Role(String),
195}
196
197/// How the PUBLIC pseudo-role spells itself wherever a grantee is carried as a
198/// bare string.
199pub const PUBLIC_ROLE: &str = "PUBLIC";
200
201impl Grantee {
202    /// Parse a manifest grantee string. The exact-uppercase `PUBLIC` is the
203    /// pseudo-role; everything else is a role name.
204    pub fn parse(s: &str) -> Self {
205        if s == PUBLIC_ROLE {
206            Grantee::Public
207        } else {
208            Grantee::Role(s.to_string())
209        }
210    }
211
212    pub fn is_public(&self) -> bool {
213        matches!(self, Grantee::Public)
214    }
215
216    pub fn as_str(&self) -> &str {
217        match self {
218            Grantee::Public => PUBLIC_ROLE,
219            Grantee::Role(name) => name,
220        }
221    }
222}
223
224impl std::fmt::Display for Grantee {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.write_str(self.as_str())
227    }
228}
229
230impl From<&str> for Grantee {
231    fn from(s: &str) -> Self {
232        Grantee::parse(s)
233    }
234}
235
236// Serialized as a plain string so plan JSON keeps the shape it had when the
237// grantee was a `String`.
238impl serde::Serialize for Grantee {
239    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
240        serializer.serialize_str(self.as_str())
241    }
242}
243
244/// Where a default-privilege rule applies.
245///
246/// `Global` is the owner-wide layer (`pg_default_acl.defaclnamespace = 0`),
247/// which affects every schema in the database and renders without an
248/// `IN SCHEMA` clause. `Global` sorts before `Schema` so the global layer
249/// appears first in deterministic output.
250#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
251#[serde(tag = "type", rename_all = "lowercase")]
252pub enum DefaultPrivilegeScope {
253    Global,
254    Schema { schema: String },
255}
256
257impl DefaultPrivilegeScope {
258    pub fn schema(&self) -> Option<&str> {
259        match self {
260            DefaultPrivilegeScope::Global => None,
261            DefaultPrivilegeScope::Schema { schema } => Some(schema),
262        }
263    }
264}
265
266impl std::fmt::Display for DefaultPrivilegeScope {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        match self {
269            DefaultPrivilegeScope::Global => write!(f, "global scope"),
270            DefaultPrivilegeScope::Schema { schema } => write!(f, "schema \"{schema}\""),
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// Grants
277// ---------------------------------------------------------------------------
278
279/// Unique key identifying a grant target — (grantee, object_type, schema, name).
280///
281/// We use `Ord` so these can live in a `BTreeMap` for deterministic output.
282/// The field order also matters for the diff engine: absence assertions with a
283/// wildcard name range-scan all keys sharing the (role, object_type, schema)
284/// prefix, which needs `name` to be the last field.
285#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
286pub struct GrantKey {
287    /// The grantee receiving the privilege.
288    pub role: Grantee,
289    /// The kind of object.
290    pub object_type: ObjectType,
291    /// Schema name. `None` for schema-level and database-level grants.
292    pub schema: Option<String>,
293    /// Object name, `"*"` for all-objects wildcard, `None` for schema-level grants.
294    pub name: Option<String>,
295}
296
297/// The privilege set on a particular grant target.
298#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
299pub struct GrantState {
300    pub privileges: BTreeSet<Privilege>,
301}
302
303// ---------------------------------------------------------------------------
304// Default privileges
305// ---------------------------------------------------------------------------
306
307/// Unique key identifying a default privilege rule.
308#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
309pub struct DefaultPrivKey {
310    /// The owner role context (whose newly-created objects get these defaults).
311    pub owner: String,
312    /// Where the default applies: one schema, or owner-wide.
313    pub scope: DefaultPrivilegeScope,
314    /// The type of object affected.
315    pub on_type: ObjectType,
316    /// The grantee.
317    pub grantee: Grantee,
318}
319
320/// The privilege set for a default privilege rule.
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
322pub struct DefaultPrivState {
323    pub privileges: BTreeSet<Privilege>,
324}
325
326// ---------------------------------------------------------------------------
327// Memberships
328// ---------------------------------------------------------------------------
329
330/// A membership edge — "member belongs to role".
331#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
332pub struct MembershipEdge {
333    /// The group role.
334    pub role: String,
335    /// The member role (may be external, e.g. an email address).
336    pub member: String,
337    /// Whether the member inherits the role's privileges.
338    pub inherit: bool,
339    /// Whether the member can administer the role.
340    pub admin: bool,
341}
342
343// ---------------------------------------------------------------------------
344// RoleGraph — the top-level state container
345// ---------------------------------------------------------------------------
346
347/// Complete state of managed roles, grants, default privileges, and memberships.
348///
349/// Both the manifest expander and the database inspector produce this type.
350/// The diff engine compares two `RoleGraph` instances to compute changes.
351#[derive(Debug, Clone, Default)]
352pub struct RoleGraph {
353    /// Managed roles, keyed by role name.
354    pub roles: BTreeMap<String, RoleState>,
355    /// Managed schemas, keyed by schema name.
356    pub schemas: BTreeMap<String, SchemaState>,
357    /// Object privilege grants, keyed by grant target.
358    pub grants: BTreeMap<GrantKey, GrantState>,
359    /// Default privilege rules, keyed by (owner, scope, type, grantee).
360    pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
361    /// Membership edges.
362    pub memberships: BTreeSet<MembershipEdge>,
363    /// Privileges asserted absent per grant target (`ensure: absent`).
364    /// Only the desired graph populates this; inspection leaves it empty.
365    pub grant_absences: BTreeMap<GrantKey, BTreeSet<Privilege>>,
366    /// Privileges asserted absent per default-privilege rule.
367    /// Only the desired graph populates this; inspection leaves it empty.
368    pub default_privilege_absences: BTreeMap<DefaultPrivKey, BTreeSet<Privilege>>,
369}
370
371impl RoleGraph {
372    /// Build a `RoleGraph` from an `ExpandedManifest`.
373    ///
374    /// This converts the manifest's user-facing types into the normalized model
375    /// that the diff engine operates on.
376    pub fn from_expanded(
377        expanded: &ExpandedManifest,
378        default_owner: Option<&str>,
379    ) -> Result<Self, crate::manifest::ManifestError> {
380        let mut graph = Self::default();
381
382        // --- Roles ---
383        for role_def in &expanded.roles {
384            let state = RoleState::from_definition(role_def);
385            graph.roles.insert(role_def.name.clone(), state);
386        }
387
388        // --- Schemas ---
389        for schema in &expanded.schemas {
390            let owner = schema.owner.clone();
391            graph.schemas.insert(
392                schema.name.clone(),
393                SchemaState {
394                    owner_privileges: owner
395                        .as_deref()
396                        .map(default_schema_owner_privileges)
397                        .unwrap_or_default(),
398                    owner,
399                },
400            );
401        }
402
403        // --- Grants ---
404        for grant in &expanded.grants {
405            let key = grant_key_from_manifest(grant);
406            let privileges = match grant.ensure {
407                Ensure::Present => {
408                    &mut graph
409                        .grants
410                        .entry(key)
411                        .or_insert_with(|| GrantState {
412                            privileges: BTreeSet::new(),
413                        })
414                        .privileges
415                }
416                Ensure::Absent => graph.grant_absences.entry(key).or_default(),
417            };
418            for privilege in &grant.privileges {
419                privileges.insert(*privilege);
420            }
421        }
422
423        // --- Default privileges ---
424        for default_priv in &expanded.default_privileges {
425            let owner = default_priv
426                .owner
427                .as_deref()
428                .or(default_owner)
429                .unwrap_or("postgres")
430                .to_string();
431            let scope = default_priv.resolved_scope()?;
432
433            for grant in &default_priv.grant {
434                let grantee = grant.role.as_deref().map(Grantee::parse).ok_or_else(|| {
435                    crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
436                        scope: scope.to_string(),
437                    }
438                })?;
439
440                let key = DefaultPrivKey {
441                    owner: owner.clone(),
442                    scope: scope.clone(),
443                    on_type: grant.on_type,
444                    grantee,
445                };
446
447                let privileges = match grant.ensure {
448                    Ensure::Present => {
449                        &mut graph
450                            .default_privileges
451                            .entry(key)
452                            .or_insert_with(|| DefaultPrivState {
453                                privileges: BTreeSet::new(),
454                            })
455                            .privileges
456                    }
457                    Ensure::Absent => graph.default_privilege_absences.entry(key).or_default(),
458                };
459                for privilege in &grant.privileges {
460                    privileges.insert(*privilege);
461                }
462            }
463        }
464
465        // --- Memberships ---
466        for membership in &expanded.memberships {
467            for member_spec in &membership.members {
468                graph.memberships.insert(MembershipEdge {
469                    role: membership.role.clone(),
470                    member: member_spec.name.clone(),
471                    inherit: member_spec.inherit(),
472                    admin: member_spec.admin(),
473                });
474            }
475        }
476
477        Ok(graph)
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Helpers
483// ---------------------------------------------------------------------------
484
485fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
486    GrantKey {
487        role: Grantee::parse(&grant.role),
488        object_type: grant.object.object_type,
489        schema: grant.object.schema.clone(),
490        name: grant.object.name.clone(),
491    }
492}
493
494pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
495    [Privilege::Create, Privilege::Usage].into_iter().collect()
496}
497
498// ---------------------------------------------------------------------------
499// Implement Ord for ObjectType and Privilege so we can use them in BTreeSet/BTreeMap
500// ---------------------------------------------------------------------------
501
502impl PartialOrd for ObjectType {
503    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
504        Some(self.cmp(other))
505    }
506}
507
508impl Ord for ObjectType {
509    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
510        self.to_string().cmp(&other.to_string())
511    }
512}
513
514impl PartialOrd for Privilege {
515    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
516        Some(self.cmp(other))
517    }
518}
519
520impl Ord for Privilege {
521    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
522        self.to_string().cmp(&other.to_string())
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Tests
528// ---------------------------------------------------------------------------
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::manifest::{expand_manifest, parse_manifest};
534
535    #[test]
536    fn role_state_defaults_match_postgres() {
537        let state = RoleState::default();
538        assert!(!state.login);
539        assert!(!state.superuser);
540        assert!(!state.createdb);
541        assert!(!state.createrole);
542        assert!(state.inherit); // PG default is INHERIT
543        assert!(!state.replication);
544        assert!(!state.bypassrls);
545        assert_eq!(state.connection_limit, -1);
546    }
547
548    #[test]
549    fn role_state_from_definition_applies_overrides() {
550        let definition = RoleDefinition {
551            name: "test".to_string(),
552            external: false,
553            login: Some(true),
554            superuser: None,
555            createdb: Some(true),
556            createrole: None,
557            inherit: Some(false),
558            replication: None,
559            bypassrls: None,
560            connection_limit: Some(10),
561            comment: Some("test role".to_string()),
562            password: None,
563            password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
564            config: Default::default(),
565        };
566        let state = RoleState::from_definition(&definition);
567        assert!(state.login);
568        assert!(!state.superuser); // default
569        assert!(state.createdb);
570        assert!(!state.createrole); // default
571        assert!(!state.inherit); // overridden
572        assert_eq!(state.connection_limit, 10);
573        assert_eq!(state.comment, Some("test role".to_string()));
574        assert_eq!(
575            state.password_valid_until,
576            Some("2025-12-31T00:00:00Z".to_string())
577        );
578    }
579
580    #[test]
581    fn changed_attributes_detects_differences() {
582        let current = RoleState::default();
583        let desired = RoleState {
584            login: true,
585            connection_limit: 5,
586            ..RoleState::default()
587        };
588        let changes = current.changed_attributes(&desired);
589        assert_eq!(changes.len(), 2);
590        assert!(changes.contains(&RoleAttribute::Login(true)));
591        assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
592    }
593
594    #[test]
595    fn changed_attributes_empty_when_equal() {
596        let state = RoleState::default();
597        assert!(state.changed_attributes(&state.clone()).is_empty());
598    }
599
600    #[test]
601    fn changed_attributes_detects_config_set_change_and_reset() {
602        let current = RoleState {
603            config: [
604                ("role".to_string(), "combined".to_string()),
605                ("statement_timeout".to_string(), "30s".to_string()),
606            ]
607            .into_iter()
608            .collect(),
609            ..RoleState::default()
610        };
611        let desired = RoleState {
612            config: [
613                ("role".to_string(), "combined".to_string()),
614                ("search_path".to_string(), "app".to_string()),
615            ]
616            .into_iter()
617            .collect(),
618            ..RoleState::default()
619        };
620        let changes = current.changed_attributes(&desired);
621        assert_eq!(changes.len(), 2);
622        assert!(changes.contains(&RoleAttribute::SetConfig(
623            "search_path".to_string(),
624            "app".to_string()
625        )));
626        assert!(changes.contains(&RoleAttribute::ResetConfig("statement_timeout".to_string())));
627        // Unchanged `role` setting produces no change.
628        assert!(
629            !changes
630                .iter()
631                .any(|c| matches!(c, RoleAttribute::SetConfig(p, _) if p == "role"))
632        );
633    }
634
635    #[test]
636    fn profile_config_flows_through_to_generated_role_state() {
637        // The generated role's `config` comes entirely from `expand_manifest`
638        // substituting the profile's config — `RoleState::from_definition`
639        // and `RoleGraph::from_expanded` need no changes to support it, since
640        // generated roles are ordinary `RoleDefinition`s by the time they
641        // reach this layer. This test is the proof, not an assumption.
642        let yaml = r#"
643profiles:
644  editor:
645    login: true
646    config:
647      search_path: "{schema}"
648      statement_timeout: "30s"
649
650schemas:
651  - name: inventory
652    profiles: [editor]
653"#;
654        let manifest = parse_manifest(yaml).unwrap();
655        let expanded = expand_manifest(&manifest).unwrap();
656        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
657
658        let role = graph
659            .roles
660            .get("inventory-editor")
661            .expect("generated role should be present");
662        assert_eq!(
663            role.config.get("search_path").map(String::as_str),
664            Some("inventory")
665        );
666        assert_eq!(
667            role.config.get("statement_timeout").map(String::as_str),
668            Some("30s")
669        );
670    }
671
672    #[test]
673    fn from_definition_lowercases_config_parameter_names() {
674        let yaml = r#"
675roles:
676  - name: blue
677    login: true
678    config:
679      Role: combined
680      statement_timeout: "30000"
681"#;
682        let manifest = parse_manifest(yaml).unwrap();
683        let graph = RoleGraph::from_expanded(&expand_manifest(&manifest).unwrap(), None).unwrap();
684        let config = &graph.roles["blue"].config;
685        assert_eq!(config.get("role").map(String::as_str), Some("combined"));
686        assert_eq!(
687            config.get("statement_timeout").map(String::as_str),
688            Some("30000")
689        );
690    }
691
692    #[test]
693    fn role_graph_from_expanded_manifest() {
694        let yaml = r#"
695default_owner: app_owner
696
697profiles:
698  editor:
699    grants:
700      - privileges: [USAGE]
701        object: { type: schema }
702      - privileges: [SELECT, INSERT]
703        object: { type: table, name: "*" }
704    default_privileges:
705      - privileges: [SELECT, INSERT]
706        on_type: table
707
708schemas:
709  - name: inventory
710    profiles: [editor]
711
712roles:
713  - name: analytics
714    login: true
715
716memberships:
717  - role: inventory-editor
718    members:
719      - name: "user@example.com"
720        inherit: true
721"#;
722        let manifest = parse_manifest(yaml).unwrap();
723        let expanded = expand_manifest(&manifest).unwrap();
724        let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
725
726        // Two roles: inventory-editor (from profile) + analytics (one-off)
727        assert_eq!(graph.roles.len(), 2);
728        assert!(graph.roles.contains_key("inventory-editor"));
729        assert!(graph.roles.contains_key("analytics"));
730
731        // Managed schema state includes the declared schema and resolved owner.
732        assert_eq!(graph.schemas.len(), 1);
733        assert_eq!(
734            graph.schemas["inventory"].owner.as_deref(),
735            Some("app_owner")
736        );
737
738        // inventory-editor is NOLOGIN, analytics is LOGIN
739        assert!(!graph.roles["inventory-editor"].login);
740        assert!(graph.roles["analytics"].login);
741
742        // Two grant targets: schema USAGE + table SELECT,INSERT
743        assert_eq!(graph.grants.len(), 2);
744
745        // One default privilege entry: SELECT,INSERT on tables for inventory-editor
746        assert_eq!(graph.default_privileges.len(), 1);
747        let dp_key = graph.default_privileges.keys().next().unwrap();
748        assert_eq!(dp_key.owner, "app_owner");
749        assert_eq!(
750            dp_key.scope,
751            DefaultPrivilegeScope::Schema {
752                schema: "inventory".to_string()
753            }
754        );
755        assert_eq!(dp_key.on_type, ObjectType::Table);
756        assert_eq!(dp_key.grantee.as_str(), "inventory-editor");
757        let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
758        assert!(dp_privs.contains(&Privilege::Select));
759        assert!(dp_privs.contains(&Privilege::Insert));
760
761        // One membership edge
762        assert_eq!(graph.memberships.len(), 1);
763        let edge = graph.memberships.iter().next().unwrap();
764        assert_eq!(edge.role, "inventory-editor");
765        assert_eq!(edge.member, "user@example.com");
766        assert!(edge.inherit);
767        assert!(!edge.admin);
768    }
769
770    #[test]
771    fn grant_privileges_merge_for_same_target() {
772        let yaml = r#"
773roles:
774  - name: testrole
775
776grants:
777  - role: testrole
778    privileges: [SELECT]
779    object: { type: table, schema: public, name: "*" }
780  - role: testrole
781    privileges: [INSERT, UPDATE]
782    object: { type: table, schema: public, name: "*" }
783"#;
784        let manifest = parse_manifest(yaml).unwrap();
785        let expanded = expand_manifest(&manifest).unwrap();
786        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
787
788        // Both grants target the same key, so privileges should merge
789        assert_eq!(graph.grants.len(), 1);
790        let grant_state = graph.grants.values().next().unwrap();
791        assert_eq!(grant_state.privileges.len(), 3);
792        assert!(grant_state.privileges.contains(&Privilege::Select));
793        assert!(grant_state.privileges.contains(&Privilege::Insert));
794        assert!(grant_state.privileges.contains(&Privilege::Update));
795    }
796}