Skip to main content

pgroles_core/
diff.rs

1//! Convergent diff engine.
2//!
3//! Compares two [`RoleGraph`] instances (current vs desired) and produces an
4//! ordered list of [`Change`] operations needed to bring the database from
5//! its current state to the desired state.
6//!
7//! The model is convergent: anything present in the current state but absent
8//! from the desired state is revoked/dropped. This is the Terraform-style
9//! "manifest is the entire truth" approach.
10
11use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{
15    DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState,
16    default_schema_owner_privileges,
17};
18
19// ---------------------------------------------------------------------------
20// Change enum
21// ---------------------------------------------------------------------------
22
23/// A single change to be applied to the database.
24///
25/// Changes are produced in dependency order by [`diff`]:
26/// 1. Create roles (before granting anything to them)
27/// 2. Alter roles (attribute changes)
28/// 3. Grant privileges
29/// 4. Set default privileges
30/// 5. Remove memberships
31/// 6. Add memberships
32/// 7. Revoke default privileges
33/// 8. Revoke privileges
34/// 9. Drop roles (after revoking everything from them)
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
36pub enum Change {
37    /// Create a new role with the given attributes.
38    CreateRole { name: String, state: RoleState },
39
40    /// Create a schema, optionally assigning an owner up front.
41    CreateSchema { name: String, owner: Option<String> },
42
43    /// Change an existing schema's owner.
44    AlterSchemaOwner { name: String, owner: String },
45
46    /// Restore the schema owner's ordinary CREATE/USAGE privileges.
47    EnsureSchemaOwnerPrivileges {
48        name: String,
49        owner: String,
50        privileges: BTreeSet<Privilege>,
51    },
52
53    /// Alter an existing role's attributes.
54    AlterRole {
55        name: String,
56        attributes: Vec<RoleAttribute>,
57    },
58
59    /// Update a role's comment (via COMMENT ON ROLE).
60    SetComment {
61        name: String,
62        comment: Option<String>,
63    },
64
65    /// Grant privileges on an object to a role.
66    Grant {
67        role: String,
68        privileges: BTreeSet<Privilege>,
69        object_type: ObjectType,
70        schema: Option<String>,
71        name: Option<String>,
72    },
73
74    /// Revoke privileges on an object from a role.
75    Revoke {
76        role: String,
77        privileges: BTreeSet<Privilege>,
78        object_type: ObjectType,
79        schema: Option<String>,
80        name: Option<String>,
81    },
82
83    /// Set default privileges (ALTER DEFAULT PRIVILEGES ... GRANT ...).
84    SetDefaultPrivilege {
85        owner: String,
86        schema: String,
87        on_type: ObjectType,
88        grantee: String,
89        privileges: BTreeSet<Privilege>,
90    },
91
92    /// Revoke default privileges (ALTER DEFAULT PRIVILEGES ... REVOKE ...).
93    RevokeDefaultPrivilege {
94        owner: String,
95        schema: String,
96        on_type: ObjectType,
97        grantee: String,
98        privileges: BTreeSet<Privilege>,
99    },
100
101    /// Grant membership (GRANT role TO member).
102    AddMember {
103        role: String,
104        member: String,
105        inherit: bool,
106        admin: bool,
107    },
108
109    /// Revoke membership (REVOKE role FROM member).
110    RemoveMember { role: String, member: String },
111
112    /// Reassign owned objects to a successor role before drop.
113    ReassignOwned { from_role: String, to_role: String },
114
115    /// Drop owned objects and revoke remaining privileges before drop.
116    DropOwned { role: String },
117
118    /// Terminate other active sessions before dropping a role.
119    TerminateSessions { role: String },
120
121    /// Set a role's password using a SCRAM-SHA-256 verifier.
122    ///
123    /// The `password` field contains a pre-computed SCRAM-SHA-256 verifier
124    /// string (not cleartext). PostgreSQL detects the `SCRAM-SHA-256$` prefix
125    /// and stores it directly without re-hashing.
126    ///
127    /// This change is injected by [`inject_password_changes`] after the core
128    /// diff engine runs. The diff engine itself does not handle passwords
129    /// because they cannot be read back from the database for comparison.
130    SetPassword { name: String, password: String },
131
132    /// Drop a role.
133    DropRole { name: String },
134}
135
136// ---------------------------------------------------------------------------
137// Reconciliation modes
138// ---------------------------------------------------------------------------
139
140/// Controls how aggressively pgroles converges the database to the manifest.
141///
142/// The diff engine always computes the full set of changes. The reconciliation
143/// mode acts as a **post-filter** on the resulting `Vec<Change>`, stripping
144/// out changes that the operator does not want applied.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
146pub enum ReconciliationMode {
147    /// Full convergence — the manifest is the entire truth.
148    ///
149    /// All changes (creates, alters, grants, revokes, drops) are applied.
150    /// Anything present in the database but absent from the manifest is
151    /// revoked or dropped.
152    #[default]
153    Authoritative,
154
155    /// Only grant, never revoke — safe for incremental adoption.
156    ///
157    /// Additive mode filters out all destructive changes:
158    /// - `Revoke` / `RevokeDefaultPrivilege`
159    /// - `RemoveMember`
160    /// - `DropRole` and its retirement steps (`TerminateSessions`,
161    ///   `ReassignOwned`, `DropOwned`)
162    ///
163    /// Use this when onboarding pgroles into an existing environment where
164    /// you want to guarantee that no existing access is removed.
165    Additive,
166
167    /// Manage declared resources fully, but never drop undeclared roles.
168    ///
169    /// Adopt mode is identical to authoritative **except** that it filters out
170    /// `DropRole` and associated retirement steps (`TerminateSessions`,
171    /// `ReassignOwned`, `DropOwned`). Revokes within the managed scope are
172    /// still applied.
173    ///
174    /// Use this for brownfield onboarding where you want full privilege
175    /// convergence for declared roles but don't want pgroles to drop roles
176    /// it doesn't know about.
177    Adopt,
178}
179
180impl std::fmt::Display for ReconciliationMode {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            ReconciliationMode::Authoritative => write!(f, "authoritative"),
184            ReconciliationMode::Additive => write!(f, "additive"),
185            ReconciliationMode::Adopt => write!(f, "adopt"),
186        }
187    }
188}
189
190/// Filter a list of changes according to the reconciliation mode.
191///
192/// - **Authoritative**: returns all changes unmodified.
193/// - **Additive**: strips revokes, membership removals, owner transfers,
194///   role rewrites, role drops, and retirement cleanup steps.
195/// - **Adopt**: strips role drops and retirement cleanup steps, but keeps
196///   revokes and membership removals.
197pub fn filter_changes(changes: Vec<Change>, mode: ReconciliationMode) -> Vec<Change> {
198    match mode {
199        ReconciliationMode::Authoritative => changes,
200        ReconciliationMode::Additive => filter_additive_changes(changes),
201        ReconciliationMode::Adopt => changes
202            .into_iter()
203            .filter(|change| !is_role_drop_or_retirement(change))
204            .collect(),
205    }
206}
207
208fn filter_additive_changes(changes: Vec<Change>) -> Vec<Change> {
209    let skipped_owner_transfers: BTreeSet<(String, String)> = changes
210        .iter()
211        .filter_map(|change| match change {
212            Change::AlterSchemaOwner { name, owner } => Some((name.clone(), owner.clone())),
213            _ => None,
214        })
215        .collect();
216
217    changes
218        .into_iter()
219        .filter(|change| match change {
220            Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
221                !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
222            }
223            Change::SetDefaultPrivilege { schema, owner, .. } => {
224                !skipped_owner_transfers.contains(&(schema.clone(), owner.clone()))
225            }
226            Change::AlterRole { .. } | Change::SetComment { .. } => false,
227            _ => !is_destructive(change),
228        })
229        .collect()
230}
231
232/// Returns `true` for any change that removes access or drops a role.
233fn is_destructive(change: &Change) -> bool {
234    matches!(
235        change,
236        Change::AlterSchemaOwner { .. }
237            | Change::Revoke { .. }
238            | Change::RevokeDefaultPrivilege { .. }
239            | Change::RemoveMember { .. }
240            | Change::DropRole { .. }
241            | Change::DropOwned { .. }
242            | Change::ReassignOwned { .. }
243            | Change::TerminateSessions { .. }
244    )
245}
246
247/// Returns `true` for role drops and their associated retirement cleanup steps.
248fn is_role_drop_or_retirement(change: &Change) -> bool {
249    matches!(
250        change,
251        Change::DropRole { .. }
252            | Change::DropOwned { .. }
253            | Change::ReassignOwned { .. }
254            | Change::TerminateSessions { .. }
255    )
256}
257
258// ---------------------------------------------------------------------------
259// Diff function
260// ---------------------------------------------------------------------------
261
262/// Compute the list of changes needed to bring `current` to `desired`.
263///
264/// Changes are ordered so that dependencies are respected:
265/// creates before grants, revokes before drops, etc.
266pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
267    let mut creates = Vec::new();
268    let mut alters = Vec::new();
269    let mut schema_changes = Vec::new();
270    let mut schema_grants = Vec::new();
271    let mut grants = Vec::new();
272    let mut set_defaults = Vec::new();
273    let mut add_members = Vec::new();
274    let mut remove_members = Vec::new();
275    let mut revoke_defaults = Vec::new();
276    let mut revokes = Vec::new();
277    let mut drops = Vec::new();
278
279    // ----- Roles -----
280
281    // Roles in desired but not in current → CREATE
282    for (name, desired_state) in &desired.roles {
283        match current.roles.get(name) {
284            None => {
285                creates.push(Change::CreateRole {
286                    name: name.clone(),
287                    state: desired_state.clone(),
288                });
289            }
290            Some(current_state) => {
291                // Role exists — check for attribute changes
292                let attribute_changes = current_state.changed_attributes(desired_state);
293                if !attribute_changes.is_empty() {
294                    alters.push(Change::AlterRole {
295                        name: name.clone(),
296                        attributes: attribute_changes,
297                    });
298                }
299                // Check comment change
300                if current_state.comment != desired_state.comment {
301                    alters.push(Change::SetComment {
302                        name: name.clone(),
303                        comment: desired_state.comment.clone(),
304                    });
305                }
306            }
307        }
308    }
309
310    // Roles in current but not in desired → DROP
311    for name in current.roles.keys() {
312        if !desired.roles.contains_key(name) {
313            drops.push(Change::DropRole { name: name.clone() });
314        }
315    }
316
317    // ----- Schemas -----
318
319    diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
320
321    // ----- Grants -----
322
323    diff_grants(current, desired, &mut grants, &mut revokes);
324
325    // ----- Default privileges -----
326
327    diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
328
329    // ----- Memberships -----
330
331    diff_memberships(current, desired, &mut add_members, &mut remove_members);
332
333    // ----- Assemble in dependency order -----
334    let mut changes = Vec::new();
335    changes.extend(creates);
336    changes.extend(alters);
337    changes.extend(schema_changes);
338    changes.extend(schema_grants);
339    changes.extend(grants);
340    changes.extend(set_defaults);
341    changes.extend(remove_members);
342    changes.extend(add_members);
343    changes.extend(revoke_defaults);
344    changes.extend(revokes);
345    changes.extend(drops);
346    changes
347}
348
349fn diff_schemas(
350    current: &RoleGraph,
351    desired: &RoleGraph,
352    schema_out: &mut Vec<Change>,
353    grant_out: &mut Vec<Change>,
354) {
355    for (name, desired_state) in &desired.schemas {
356        match current.schemas.get(name) {
357            None => schema_out.push(Change::CreateSchema {
358                name: name.clone(),
359                owner: desired_state.owner.clone(),
360            }),
361            Some(current_state) => {
362                if current_state.owner != desired_state.owner
363                    && let Some(owner) = &desired_state.owner
364                {
365                    schema_out.push(Change::AlterSchemaOwner {
366                        name: name.clone(),
367                        owner: owner.clone(),
368                    });
369                }
370            }
371        }
372
373        let Some(owner) = desired_state.owner.as_deref() else {
374            continue;
375        };
376
377        if !current.schemas.contains_key(name) {
378            continue;
379        }
380
381        let expected_privileges = default_schema_owner_privileges(owner);
382        let current_privileges = current
383            .schemas
384            .get(name)
385            .map(|state| state.owner_privileges.clone())
386            .unwrap_or_default();
387        let missing_privileges: BTreeSet<Privilege> = expected_privileges
388            .difference(&current_privileges)
389            .copied()
390            .collect();
391
392        if !missing_privileges.is_empty() {
393            grant_out.push(Change::EnsureSchemaOwnerPrivileges {
394                name: name.clone(),
395                owner: owner.to_string(),
396                privileges: missing_privileges,
397            });
398        }
399    }
400}
401
402/// Augment a diff plan with explicit role-retirement actions.
403///
404/// Retirement steps are inserted immediately before the matching `DropRole`
405/// so the final plan remains dependency-safe:
406/// `TERMINATE SESSIONS` → `REASSIGN OWNED` → `DROP OWNED` → `DROP ROLE`.
407pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
408    if retirements.is_empty() {
409        return changes;
410    }
411
412    let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
413        .iter()
414        .map(|retirement| (retirement.role.as_str(), retirement))
415        .collect();
416
417    let mut planned = Vec::with_capacity(changes.len());
418    for change in changes {
419        if let Change::DropRole { name } = &change
420            && let Some(retirement) = retirement_by_role.get(name.as_str())
421        {
422            if retirement.terminate_sessions {
423                planned.push(Change::TerminateSessions { role: name.clone() });
424            }
425            if let Some(successor) = &retirement.reassign_owned_to {
426                planned.push(Change::ReassignOwned {
427                    from_role: name.clone(),
428                    to_role: successor.clone(),
429                });
430            }
431            if retirement.drop_owned {
432                planned.push(Change::DropOwned { role: name.clone() });
433            }
434        }
435        planned.push(change);
436    }
437
438    planned
439}
440
441// ---------------------------------------------------------------------------
442// Password injection
443// ---------------------------------------------------------------------------
444
445/// Resolve password sources from environment variables.
446///
447/// Returns a map of role name → resolved password for every role that declares
448/// a `password.from_env` source. Returns an error if a referenced environment
449/// variable is not set.
450pub fn resolve_passwords(
451    roles: &[crate::manifest::RoleDefinition],
452) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
453    let mut resolved = std::collections::BTreeMap::new();
454    for role in roles {
455        if let Some(source) = &role.password {
456            let value = std::env::var(&source.from_env).map_err(|_| {
457                PasswordResolutionError::MissingEnvVar {
458                    role: role.name.clone(),
459                    env_var: source.from_env.clone(),
460                }
461            })?;
462            if value.is_empty() {
463                return Err(PasswordResolutionError::EmptyPassword {
464                    role: role.name.clone(),
465                    env_var: source.from_env.clone(),
466                });
467            }
468            resolved.insert(role.name.clone(), value);
469        }
470    }
471    Ok(resolved)
472}
473
474/// Errors that can occur during password resolution.
475#[derive(Debug, thiserror::Error)]
476pub enum PasswordResolutionError {
477    #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
478    MissingEnvVar { role: String, env_var: String },
479
480    #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
481    EmptyPassword { role: String, env_var: String },
482}
483
484/// Inject `SetPassword` changes into a plan for roles that declare passwords.
485///
486/// For newly created roles, the `SetPassword` is inserted immediately after the
487/// `CreateRole`. For existing roles with a password source, a `SetPassword` is
488/// appended after all creates/alters (ensuring the role exists).
489///
490/// Cleartext passwords are converted to SCRAM-SHA-256 verifiers before being
491/// placed in `SetPassword` changes, so the cleartext never appears in generated
492/// SQL. PostgreSQL detects the `SCRAM-SHA-256$` prefix and stores the verifier
493/// directly.
494///
495/// This function should be called after `diff()` and `apply_role_retirements()`.
496pub fn inject_password_changes(
497    changes: Vec<Change>,
498    resolved_passwords: &std::collections::BTreeMap<String, String>,
499) -> Vec<Change> {
500    if resolved_passwords.is_empty() {
501        return changes;
502    }
503
504    // Track which roles have CreateRole in the plan (newly created roles).
505    let created_roles: std::collections::BTreeSet<String> = changes
506        .iter()
507        .filter_map(|c| match c {
508            Change::CreateRole { name, .. } => Some(name.clone()),
509            _ => None,
510        })
511        .collect();
512
513    let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
514
515    // Insert SetPassword immediately after CreateRole for new roles.
516    for change in changes {
517        if let Change::CreateRole { ref name, .. } = change
518            && let Some(password) = resolved_passwords.get(name.as_str())
519        {
520            let role_name = name.clone();
521            let verifier =
522                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
523            result.push(change);
524            result.push(Change::SetPassword {
525                name: role_name,
526                password: verifier,
527            });
528            continue;
529        }
530        result.push(change);
531    }
532
533    // For existing roles (not newly created), append SetPassword after all creates/alters.
534    for (role_name, password) in resolved_passwords {
535        if !created_roles.contains(role_name) {
536            let verifier =
537                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
538            result.push(Change::SetPassword {
539                name: role_name.clone(),
540                password: verifier,
541            });
542        }
543    }
544
545    result
546}
547
548// ---------------------------------------------------------------------------
549// Grant diffing
550// ---------------------------------------------------------------------------
551
552fn diff_grants(
553    current: &RoleGraph,
554    desired: &RoleGraph,
555    grants_out: &mut Vec<Change>,
556    revokes_out: &mut Vec<Change>,
557) {
558    // Grants in desired but not in current → GRANT (full set)
559    // Grants in both → diff the privilege sets
560    for (key, desired_state) in &desired.grants {
561        match current.grants.get(key) {
562            None => {
563                // Entirely new grant target — grant the full set
564                grants_out.push(change_grant(key, &desired_state.privileges));
565            }
566            Some(current_state) => {
567                // Grant target exists — find privileges to add/remove
568                let to_add: BTreeSet<Privilege> = desired_state
569                    .privileges
570                    .difference(&current_state.privileges)
571                    .copied()
572                    .collect();
573                let to_remove: BTreeSet<Privilege> = current_state
574                    .privileges
575                    .difference(&desired_state.privileges)
576                    .copied()
577                    .collect();
578
579                if !to_add.is_empty() {
580                    grants_out.push(change_grant(key, &to_add));
581                }
582                if !to_remove.is_empty() {
583                    revokes_out.push(change_revoke(key, &to_remove));
584                }
585            }
586        }
587    }
588
589    // Grant targets in current but not in desired → REVOKE all
590    for (key, current_state) in &current.grants {
591        if !desired.grants.contains_key(key) {
592            revokes_out.push(change_revoke(key, &current_state.privileges));
593        }
594    }
595}
596
597fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
598    Change::Grant {
599        role: key.role.clone(),
600        privileges: privileges.clone(),
601        object_type: key.object_type,
602        schema: key.schema.clone(),
603        name: key.name.clone(),
604    }
605}
606
607fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
608    Change::Revoke {
609        role: key.role.clone(),
610        privileges: privileges.clone(),
611        object_type: key.object_type,
612        schema: key.schema.clone(),
613        name: key.name.clone(),
614    }
615}
616
617// ---------------------------------------------------------------------------
618// Default privilege diffing
619// ---------------------------------------------------------------------------
620
621fn diff_default_privileges(
622    current: &RoleGraph,
623    desired: &RoleGraph,
624    set_out: &mut Vec<Change>,
625    revoke_out: &mut Vec<Change>,
626) {
627    for (key, desired_state) in &desired.default_privileges {
628        match current.default_privileges.get(key) {
629            None => {
630                set_out.push(change_set_default(key, &desired_state.privileges));
631            }
632            Some(current_state) => {
633                let to_add: BTreeSet<Privilege> = desired_state
634                    .privileges
635                    .difference(&current_state.privileges)
636                    .copied()
637                    .collect();
638                let to_remove: BTreeSet<Privilege> = current_state
639                    .privileges
640                    .difference(&desired_state.privileges)
641                    .copied()
642                    .collect();
643
644                if !to_add.is_empty() {
645                    set_out.push(change_set_default(key, &to_add));
646                }
647                if !to_remove.is_empty() {
648                    revoke_out.push(change_revoke_default(key, &to_remove));
649                }
650            }
651        }
652    }
653
654    for (key, current_state) in &current.default_privileges {
655        if !desired.default_privileges.contains_key(key) {
656            revoke_out.push(change_revoke_default(key, &current_state.privileges));
657        }
658    }
659}
660
661fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
662    Change::SetDefaultPrivilege {
663        owner: key.owner.clone(),
664        schema: key.schema.clone(),
665        on_type: key.on_type,
666        grantee: key.grantee.clone(),
667        privileges: privileges.clone(),
668    }
669}
670
671fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
672    Change::RevokeDefaultPrivilege {
673        owner: key.owner.clone(),
674        schema: key.schema.clone(),
675        on_type: key.on_type,
676        grantee: key.grantee.clone(),
677        privileges: privileges.clone(),
678    }
679}
680
681// ---------------------------------------------------------------------------
682// Membership diffing
683// ---------------------------------------------------------------------------
684
685fn diff_memberships(
686    current: &RoleGraph,
687    desired: &RoleGraph,
688    add_out: &mut Vec<Change>,
689    remove_out: &mut Vec<Change>,
690) {
691    // We compare memberships by (role, member) as the key.
692    // If inherit/admin flags changed, we remove and re-add.
693
694    // Build lookup maps: (role, member) → MembershipEdge
695    let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
696        .memberships
697        .iter()
698        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
699        .collect();
700    let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
701        .memberships
702        .iter()
703        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
704        .collect();
705
706    // Desired but not current → add
707    // Desired and current but different flags → remove + add
708    for (&(role, member), &desired_edge) in &desired_map {
709        match current_map.get(&(role, member)) {
710            None => {
711                add_out.push(Change::AddMember {
712                    role: desired_edge.role.clone(),
713                    member: desired_edge.member.clone(),
714                    inherit: desired_edge.inherit,
715                    admin: desired_edge.admin,
716                });
717            }
718            Some(current_edge) => {
719                if current_edge.inherit != desired_edge.inherit
720                    || current_edge.admin != desired_edge.admin
721                {
722                    // Flags changed — revoke and re-grant
723                    remove_out.push(Change::RemoveMember {
724                        role: current_edge.role.clone(),
725                        member: current_edge.member.clone(),
726                    });
727                    add_out.push(Change::AddMember {
728                        role: desired_edge.role.clone(),
729                        member: desired_edge.member.clone(),
730                        inherit: desired_edge.inherit,
731                        admin: desired_edge.admin,
732                    });
733                }
734            }
735        }
736    }
737
738    // Current but not desired → remove
739    for &(role, member) in current_map.keys() {
740        if !desired_map.contains_key(&(role, member)) {
741            remove_out.push(Change::RemoveMember {
742                role: role.to_string(),
743                member: member.to_string(),
744            });
745        }
746    }
747}
748
749// ---------------------------------------------------------------------------
750// Tests
751// ---------------------------------------------------------------------------
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::model::{
757        DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
758    };
759
760    /// Helper: build an empty graph.
761    fn empty_graph() -> RoleGraph {
762        RoleGraph::default()
763    }
764
765    fn managed_schema(owner: &str) -> SchemaState {
766        SchemaState {
767            owner: Some(owner.to_string()),
768            owner_privileges: default_schema_owner_privileges(owner),
769        }
770    }
771
772    #[test]
773    fn diff_empty_to_empty_is_empty() {
774        let changes = diff(&empty_graph(), &empty_graph());
775        assert!(changes.is_empty());
776    }
777
778    #[test]
779    fn diff_creates_new_roles() {
780        let current = empty_graph();
781        let mut desired = empty_graph();
782        desired
783            .roles
784            .insert("new-role".to_string(), RoleState::default());
785
786        let changes = diff(&current, &desired);
787        assert_eq!(changes.len(), 1);
788        assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
789    }
790
791    #[test]
792    fn diff_drops_removed_roles() {
793        let mut current = empty_graph();
794        current
795            .roles
796            .insert("old-role".to_string(), RoleState::default());
797        let desired = empty_graph();
798
799        let changes = diff(&current, &desired);
800        assert_eq!(changes.len(), 1);
801        assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
802    }
803
804    #[test]
805    fn diff_alters_changed_role_attributes() {
806        let mut current = empty_graph();
807        current
808            .roles
809            .insert("role1".to_string(), RoleState::default());
810
811        let mut desired = empty_graph();
812        desired.roles.insert(
813            "role1".to_string(),
814            RoleState {
815                login: true,
816                ..RoleState::default()
817            },
818        );
819
820        let changes = diff(&current, &desired);
821        assert_eq!(changes.len(), 1);
822        match &changes[0] {
823            Change::AlterRole { name, attributes } => {
824                assert_eq!(name, "role1");
825                assert!(attributes.contains(&RoleAttribute::Login(true)));
826            }
827            other => panic!("expected AlterRole, got: {other:?}"),
828        }
829    }
830
831    #[test]
832    fn diff_creates_missing_schema() {
833        let current = empty_graph();
834        let mut desired = empty_graph();
835        desired
836            .schemas
837            .insert("inventory".to_string(), managed_schema("inventory_owner"));
838
839        let changes = diff(&current, &desired);
840        assert_eq!(changes.len(), 1);
841        assert!(matches!(
842            &changes[0],
843            Change::CreateSchema { name, owner }
844                if name == "inventory" && owner.as_deref() == Some("inventory_owner")
845        ));
846    }
847
848    #[test]
849    fn diff_alters_schema_owner_when_different() {
850        let mut current = empty_graph();
851        current
852            .schemas
853            .insert("inventory".to_string(), managed_schema("old_owner"));
854
855        let mut desired = empty_graph();
856        desired
857            .schemas
858            .insert("inventory".to_string(), managed_schema("new_owner"));
859
860        let changes = diff(&current, &desired);
861        assert_eq!(changes.len(), 1);
862        assert!(matches!(
863            &changes[0],
864            Change::AlterSchemaOwner { name, owner }
865                if name == "inventory" && owner == "new_owner"
866        ));
867    }
868
869    #[test]
870    fn diff_does_not_alter_schema_owner_when_unmanaged() {
871        let mut current = empty_graph();
872        current
873            .schemas
874            .insert("inventory".to_string(), managed_schema("old_owner"));
875
876        let mut desired = empty_graph();
877        desired.schemas.insert(
878            "inventory".to_string(),
879            SchemaState {
880                owner: None,
881                owner_privileges: BTreeSet::new(),
882            },
883        );
884
885        let changes = diff(&current, &desired);
886        assert!(changes.is_empty());
887    }
888
889    #[test]
890    fn diff_restores_missing_owner_schema_privileges() {
891        let mut current = empty_graph();
892        current.schemas.insert(
893            "inventory".to_string(),
894            SchemaState {
895                owner: Some("inventory_owner".to_string()),
896                owner_privileges: BTreeSet::from([Privilege::Usage]),
897            },
898        );
899
900        let mut desired = empty_graph();
901        desired
902            .schemas
903            .insert("inventory".to_string(), managed_schema("inventory_owner"));
904
905        let changes = diff(&current, &desired);
906        assert_eq!(changes.len(), 1);
907        assert!(matches!(
908            &changes[0],
909            Change::EnsureSchemaOwnerPrivileges {
910                name,
911                owner,
912                privileges,
913            } if name == "inventory"
914                && owner == "inventory_owner"
915                && privileges == &BTreeSet::from([Privilege::Create])
916        ));
917    }
918
919    #[test]
920    fn diff_restores_owner_schema_privileges_after_transfer() {
921        let mut current = empty_graph();
922        current.schemas.insert(
923            "inventory".to_string(),
924            SchemaState {
925                owner: Some("old_owner".to_string()),
926                owner_privileges: BTreeSet::from([Privilege::Usage]),
927            },
928        );
929
930        let mut desired = empty_graph();
931        desired
932            .schemas
933            .insert("inventory".to_string(), managed_schema("new_owner"));
934
935        let changes = diff(&current, &desired);
936        assert_eq!(changes.len(), 2);
937        assert!(matches!(
938            &changes[0],
939            Change::AlterSchemaOwner { name, owner }
940                if name == "inventory" && owner == "new_owner"
941        ));
942        assert!(matches!(
943            &changes[1],
944            Change::EnsureSchemaOwnerPrivileges {
945                name,
946                owner,
947                privileges,
948            } if name == "inventory"
949                && owner == "new_owner"
950                && privileges == &BTreeSet::from([Privilege::Create])
951        ));
952    }
953
954    #[test]
955    fn diff_grants_new_privileges() {
956        let current = empty_graph();
957        let mut desired = empty_graph();
958        let key = GrantKey {
959            role: "r1".to_string(),
960            object_type: ObjectType::Table,
961            schema: Some("public".to_string()),
962            name: Some("*".to_string()),
963        };
964        desired.grants.insert(
965            key,
966            GrantState {
967                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
968            },
969        );
970
971        let changes = diff(&current, &desired);
972        assert_eq!(changes.len(), 1);
973        match &changes[0] {
974            Change::Grant {
975                role, privileges, ..
976            } => {
977                assert_eq!(role, "r1");
978                assert!(privileges.contains(&Privilege::Select));
979                assert!(privileges.contains(&Privilege::Insert));
980            }
981            other => panic!("expected Grant, got: {other:?}"),
982        }
983    }
984
985    #[test]
986    fn diff_revokes_removed_privileges() {
987        let mut current = empty_graph();
988        let key = GrantKey {
989            role: "r1".to_string(),
990            object_type: ObjectType::Table,
991            schema: Some("public".to_string()),
992            name: Some("*".to_string()),
993        };
994        current.grants.insert(
995            key.clone(),
996            GrantState {
997                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
998            },
999        );
1000
1001        let mut desired = empty_graph();
1002        desired.grants.insert(
1003            key,
1004            GrantState {
1005                privileges: BTreeSet::from([Privilege::Select]),
1006            },
1007        );
1008
1009        let changes = diff(&current, &desired);
1010        assert_eq!(changes.len(), 1);
1011        match &changes[0] {
1012            Change::Revoke {
1013                role, privileges, ..
1014            } => {
1015                assert_eq!(role, "r1");
1016                assert!(privileges.contains(&Privilege::Insert));
1017                assert!(!privileges.contains(&Privilege::Select));
1018            }
1019            other => panic!("expected Revoke, got: {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1025        let mut current = empty_graph();
1026        let key = GrantKey {
1027            role: "r1".to_string(),
1028            object_type: ObjectType::Schema,
1029            schema: None,
1030            name: Some("myschema".to_string()),
1031        };
1032        current.grants.insert(
1033            key,
1034            GrantState {
1035                privileges: BTreeSet::from([Privilege::Usage]),
1036            },
1037        );
1038        let desired = empty_graph();
1039
1040        let changes = diff(&current, &desired);
1041        assert_eq!(changes.len(), 1);
1042        assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1043    }
1044
1045    #[test]
1046    fn diff_adds_memberships() {
1047        let current = empty_graph();
1048        let mut desired = empty_graph();
1049        desired.memberships.insert(MembershipEdge {
1050            role: "editors".to_string(),
1051            member: "user@example.com".to_string(),
1052            inherit: true,
1053            admin: false,
1054        });
1055
1056        let changes = diff(&current, &desired);
1057        assert_eq!(changes.len(), 1);
1058        match &changes[0] {
1059            Change::AddMember {
1060                role,
1061                member,
1062                inherit,
1063                admin,
1064            } => {
1065                assert_eq!(role, "editors");
1066                assert_eq!(member, "user@example.com");
1067                assert!(*inherit);
1068                assert!(!admin);
1069            }
1070            other => panic!("expected AddMember, got: {other:?}"),
1071        }
1072    }
1073
1074    #[test]
1075    fn diff_removes_memberships() {
1076        let mut current = empty_graph();
1077        current.memberships.insert(MembershipEdge {
1078            role: "editors".to_string(),
1079            member: "old@example.com".to_string(),
1080            inherit: true,
1081            admin: false,
1082        });
1083        let desired = empty_graph();
1084
1085        let changes = diff(&current, &desired);
1086        assert_eq!(changes.len(), 1);
1087        assert!(
1088            matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1089        );
1090    }
1091
1092    #[test]
1093    fn diff_re_grants_membership_when_flags_change() {
1094        let mut current = empty_graph();
1095        current.memberships.insert(MembershipEdge {
1096            role: "editors".to_string(),
1097            member: "user@example.com".to_string(),
1098            inherit: true,
1099            admin: false,
1100        });
1101
1102        let mut desired = empty_graph();
1103        desired.memberships.insert(MembershipEdge {
1104            role: "editors".to_string(),
1105            member: "user@example.com".to_string(),
1106            inherit: true,
1107            admin: true, // changed!
1108        });
1109
1110        let changes = diff(&current, &desired);
1111        // Should produce remove + add
1112        assert_eq!(changes.len(), 2);
1113        assert!(matches!(
1114            &changes[0],
1115            Change::RemoveMember { role, member }
1116                if role == "editors" && member == "user@example.com"
1117        ));
1118        assert!(matches!(
1119            &changes[1],
1120            Change::AddMember {
1121                role,
1122                member,
1123                admin: true,
1124                ..
1125            } if role == "editors" && member == "user@example.com"
1126        ));
1127    }
1128
1129    #[test]
1130    fn diff_default_privileges_add_and_revoke() {
1131        let mut current = empty_graph();
1132        let key = DefaultPrivKey {
1133            owner: "app_owner".to_string(),
1134            schema: "inventory".to_string(),
1135            on_type: ObjectType::Table,
1136            grantee: "inventory-editor".to_string(),
1137        };
1138        current.default_privileges.insert(
1139            key.clone(),
1140            DefaultPrivState {
1141                privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1142            },
1143        );
1144
1145        let mut desired = empty_graph();
1146        desired.default_privileges.insert(
1147            key,
1148            DefaultPrivState {
1149                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1150            },
1151        );
1152
1153        let changes = diff(&current, &desired);
1154        // Should add INSERT and revoke DELETE
1155        assert_eq!(changes.len(), 2);
1156        assert!(changes.iter().any(|c| matches!(
1157            c,
1158            Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1159        )));
1160        assert!(changes.iter().any(|c| matches!(
1161            c,
1162            Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1163        )));
1164    }
1165
1166    #[test]
1167    fn diff_ordering_creates_before_drops() {
1168        let mut current = empty_graph();
1169        current
1170            .roles
1171            .insert("old-role".to_string(), RoleState::default());
1172
1173        let mut desired = empty_graph();
1174        desired
1175            .roles
1176            .insert("new-role".to_string(), RoleState::default());
1177
1178        let changes = diff(&current, &desired);
1179        assert_eq!(changes.len(), 2);
1180
1181        // Creates should come before drops
1182        let create_idx = changes
1183            .iter()
1184            .position(|c| matches!(c, Change::CreateRole { .. }))
1185            .unwrap();
1186        let schema_idx = changes
1187            .iter()
1188            .position(|c| matches!(c, Change::CreateSchema { .. }))
1189            .unwrap_or(create_idx);
1190        let drop_idx = changes
1191            .iter()
1192            .position(|c| matches!(c, Change::DropRole { .. }))
1193            .unwrap();
1194        assert!(create_idx <= schema_idx);
1195        assert!(schema_idx < drop_idx);
1196    }
1197
1198    #[test]
1199    fn diff_identical_graphs_produce_no_changes() {
1200        let mut graph = empty_graph();
1201        graph
1202            .roles
1203            .insert("role1".to_string(), RoleState::default());
1204        graph.grants.insert(
1205            GrantKey {
1206                role: "role1".to_string(),
1207                object_type: ObjectType::Table,
1208                schema: Some("public".to_string()),
1209                name: Some("*".to_string()),
1210            },
1211            GrantState {
1212                privileges: BTreeSet::from([Privilege::Select]),
1213            },
1214        );
1215        graph.memberships.insert(MembershipEdge {
1216            role: "role1".to_string(),
1217            member: "user@example.com".to_string(),
1218            inherit: true,
1219            admin: false,
1220        });
1221
1222        let changes = diff(&graph, &graph);
1223        assert!(
1224            changes.is_empty(),
1225            "identical graphs should produce no changes"
1226        );
1227    }
1228
1229    /// Integration test: round-trip from manifest → expand → model → diff
1230    #[test]
1231    fn manifest_to_diff_integration() {
1232        use crate::manifest::{expand_manifest, parse_manifest};
1233        use crate::model::RoleGraph;
1234
1235        let yaml = r#"
1236default_owner: app_owner
1237
1238profiles:
1239  editor:
1240    grants:
1241      - privileges: [USAGE]
1242        object: { type: schema }
1243      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1244        object: { type: table, name: "*" }
1245    default_privileges:
1246      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1247        on_type: table
1248
1249schemas:
1250  - name: inventory
1251    owner: inventory_owner
1252    profiles: [editor]
1253
1254memberships:
1255  - role: inventory-editor
1256    members:
1257      - name: "user@example.com"
1258"#;
1259        let manifest = parse_manifest(yaml).unwrap();
1260        let expanded = expand_manifest(&manifest).unwrap();
1261        let desired =
1262            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1263
1264        // Current state is empty — everything should be created
1265        let current = RoleGraph::default();
1266        let changes = diff(&current, &desired);
1267
1268        // Should have: 1 CreateRole, 1 CreateSchema, 2 Grants, 1 SetDefaultPrivilege, 1 AddMember
1269        let create_count = changes
1270            .iter()
1271            .filter(|c| matches!(c, Change::CreateRole { .. }))
1272            .count();
1273        let create_schema_count = changes
1274            .iter()
1275            .filter(|c| matches!(c, Change::CreateSchema { .. }))
1276            .count();
1277        let grant_count = changes
1278            .iter()
1279            .filter(|c| matches!(c, Change::Grant { .. }))
1280            .count();
1281        let dp_count = changes
1282            .iter()
1283            .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1284            .count();
1285        let member_count = changes
1286            .iter()
1287            .filter(|c| matches!(c, Change::AddMember { .. }))
1288            .count();
1289
1290        assert_eq!(create_count, 1);
1291        assert_eq!(create_schema_count, 1);
1292        assert_eq!(grant_count, 2); // schema USAGE + table *
1293        assert_eq!(dp_count, 1);
1294        assert_eq!(member_count, 1);
1295
1296        // Diffing desired against itself should produce no changes
1297        let no_changes = diff(&desired, &desired);
1298        assert!(no_changes.is_empty());
1299    }
1300
1301    // -----------------------------------------------------------------------
1302    // filter_changes — ReconciliationMode tests
1303    // -----------------------------------------------------------------------
1304
1305    /// Build a representative change list covering every Change variant.
1306    fn all_change_variants() -> Vec<Change> {
1307        vec![
1308            Change::CreateRole {
1309                name: "new-role".to_string(),
1310                state: RoleState::default(),
1311            },
1312            Change::CreateSchema {
1313                name: "inventory".to_string(),
1314                owner: Some("inventory_owner".to_string()),
1315            },
1316            Change::AlterSchemaOwner {
1317                name: "catalog".to_string(),
1318                owner: "catalog_owner".to_string(),
1319            },
1320            Change::EnsureSchemaOwnerPrivileges {
1321                name: "catalog".to_string(),
1322                owner: "catalog_owner".to_string(),
1323                privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1324            },
1325            Change::AlterRole {
1326                name: "altered-role".to_string(),
1327                attributes: vec![RoleAttribute::Login(true)],
1328            },
1329            Change::SetComment {
1330                name: "commented-role".to_string(),
1331                comment: Some("hello".to_string()),
1332            },
1333            Change::Grant {
1334                role: "r1".to_string(),
1335                privileges: BTreeSet::from([Privilege::Select]),
1336                object_type: ObjectType::Table,
1337                schema: Some("public".to_string()),
1338                name: Some("*".to_string()),
1339            },
1340            Change::Revoke {
1341                role: "r1".to_string(),
1342                privileges: BTreeSet::from([Privilege::Insert]),
1343                object_type: ObjectType::Table,
1344                schema: Some("public".to_string()),
1345                name: Some("*".to_string()),
1346            },
1347            Change::SetDefaultPrivilege {
1348                owner: "owner".to_string(),
1349                schema: "public".to_string(),
1350                on_type: ObjectType::Table,
1351                grantee: "r1".to_string(),
1352                privileges: BTreeSet::from([Privilege::Select]),
1353            },
1354            Change::RevokeDefaultPrivilege {
1355                owner: "owner".to_string(),
1356                schema: "public".to_string(),
1357                on_type: ObjectType::Table,
1358                grantee: "r1".to_string(),
1359                privileges: BTreeSet::from([Privilege::Delete]),
1360            },
1361            Change::AddMember {
1362                role: "editors".to_string(),
1363                member: "user@example.com".to_string(),
1364                inherit: true,
1365                admin: false,
1366            },
1367            Change::RemoveMember {
1368                role: "editors".to_string(),
1369                member: "old@example.com".to_string(),
1370            },
1371            Change::TerminateSessions {
1372                role: "retired-role".to_string(),
1373            },
1374            Change::ReassignOwned {
1375                from_role: "retired-role".to_string(),
1376                to_role: "successor".to_string(),
1377            },
1378            Change::DropOwned {
1379                role: "retired-role".to_string(),
1380            },
1381            Change::DropRole {
1382                name: "retired-role".to_string(),
1383            },
1384        ]
1385    }
1386
1387    #[test]
1388    fn filter_authoritative_keeps_all_changes() {
1389        let changes = all_change_variants();
1390        let original_len = changes.len();
1391        let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1392        assert_eq!(filtered.len(), original_len);
1393    }
1394
1395    #[test]
1396    fn filter_additive_keeps_only_constructive_changes() {
1397        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1398
1399        // Should keep: CreateRole, CreateSchema, Grant, SetDefaultPrivilege, AddMember
1400        assert_eq!(filtered.len(), 5);
1401
1402        // Verify no destructive changes remain
1403        for change in &filtered {
1404            assert!(
1405                !matches!(
1406                    change,
1407                    Change::AlterSchemaOwner { .. }
1408                        | Change::EnsureSchemaOwnerPrivileges { .. }
1409                        | Change::AlterRole { .. }
1410                        | Change::SetComment { .. }
1411                        | Change::Revoke { .. }
1412                        | Change::RevokeDefaultPrivilege { .. }
1413                        | Change::RemoveMember { .. }
1414                        | Change::DropRole { .. }
1415                        | Change::DropOwned { .. }
1416                        | Change::ReassignOwned { .. }
1417                        | Change::TerminateSessions { .. }
1418                ),
1419                "additive mode should not contain destructive change: {change:?}"
1420            );
1421        }
1422
1423        // Verify constructive changes are present
1424        assert!(
1425            filtered
1426                .iter()
1427                .any(|c| matches!(c, Change::CreateRole { .. }))
1428        );
1429        assert!(
1430            filtered
1431                .iter()
1432                .any(|c| matches!(c, Change::CreateSchema { .. }))
1433        );
1434        assert!(
1435            filtered
1436                .iter()
1437                .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1438        );
1439        assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1440        assert!(
1441            filtered
1442                .iter()
1443                .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1444        );
1445        assert!(
1446            filtered
1447                .iter()
1448                .any(|c| matches!(c, Change::AddMember { .. }))
1449        );
1450    }
1451
1452    #[test]
1453    fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1454        let changes = vec![
1455            Change::AlterSchemaOwner {
1456                name: "inventory".to_string(),
1457                owner: "new_owner".to_string(),
1458            },
1459            Change::EnsureSchemaOwnerPrivileges {
1460                name: "inventory".to_string(),
1461                owner: "new_owner".to_string(),
1462                privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1463            },
1464            Change::SetDefaultPrivilege {
1465                owner: "new_owner".to_string(),
1466                schema: "inventory".to_string(),
1467                on_type: ObjectType::Table,
1468                grantee: "inventory-editor".to_string(),
1469                privileges: BTreeSet::from([Privilege::Select]),
1470            },
1471            Change::Grant {
1472                role: "inventory-editor".to_string(),
1473                privileges: BTreeSet::from([Privilege::Usage]),
1474                object_type: ObjectType::Schema,
1475                schema: None,
1476                name: Some("inventory".to_string()),
1477            },
1478        ];
1479
1480        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1481        assert_eq!(filtered.len(), 1);
1482        assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1483    }
1484
1485    #[test]
1486    fn filter_adopt_keeps_revokes_but_not_drops() {
1487        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1488
1489        // Should keep everything except: DropRole, DropOwned, ReassignOwned, TerminateSessions
1490        assert_eq!(filtered.len(), 12);
1491
1492        // Verify no role-drop/retirement changes remain
1493        for change in &filtered {
1494            assert!(
1495                !matches!(
1496                    change,
1497                    Change::DropRole { .. }
1498                        | Change::DropOwned { .. }
1499                        | Change::ReassignOwned { .. }
1500                        | Change::TerminateSessions { .. }
1501                ),
1502                "adopt mode should not contain drop/retirement change: {change:?}"
1503            );
1504        }
1505
1506        // Verify revokes ARE still present (unlike additive)
1507        assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1508        assert!(
1509            filtered
1510                .iter()
1511                .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1512        );
1513        assert!(
1514            filtered
1515                .iter()
1516                .any(|c| matches!(c, Change::RemoveMember { .. }))
1517        );
1518    }
1519
1520    #[test]
1521    fn filter_additive_with_empty_input() {
1522        let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1523        assert!(filtered.is_empty());
1524    }
1525
1526    #[test]
1527    fn filter_additive_only_destructive_changes_yields_empty() {
1528        let changes = vec![
1529            Change::Revoke {
1530                role: "r1".to_string(),
1531                privileges: BTreeSet::from([Privilege::Select]),
1532                object_type: ObjectType::Table,
1533                schema: Some("public".to_string()),
1534                name: Some("*".to_string()),
1535            },
1536            Change::DropRole {
1537                name: "old-role".to_string(),
1538            },
1539        ];
1540        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1541        assert!(filtered.is_empty());
1542    }
1543
1544    #[test]
1545    fn filter_adopt_preserves_ordering() {
1546        let changes = vec![
1547            Change::CreateRole {
1548                name: "new-role".to_string(),
1549                state: RoleState::default(),
1550            },
1551            Change::Grant {
1552                role: "new-role".to_string(),
1553                privileges: BTreeSet::from([Privilege::Select]),
1554                object_type: ObjectType::Table,
1555                schema: Some("public".to_string()),
1556                name: Some("*".to_string()),
1557            },
1558            Change::Revoke {
1559                role: "existing-role".to_string(),
1560                privileges: BTreeSet::from([Privilege::Insert]),
1561                object_type: ObjectType::Table,
1562                schema: Some("public".to_string()),
1563                name: Some("*".to_string()),
1564            },
1565            Change::DropRole {
1566                name: "old-role".to_string(),
1567            },
1568        ];
1569
1570        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1571        assert_eq!(filtered.len(), 3);
1572        assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1573        assert!(matches!(&filtered[1], Change::Grant { .. }));
1574        assert!(matches!(&filtered[2], Change::Revoke { .. }));
1575    }
1576
1577    #[test]
1578    fn reconciliation_mode_display() {
1579        assert_eq!(
1580            ReconciliationMode::Authoritative.to_string(),
1581            "authoritative"
1582        );
1583        assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1584        assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1585    }
1586
1587    #[test]
1588    fn reconciliation_mode_default_is_authoritative() {
1589        assert_eq!(
1590            ReconciliationMode::default(),
1591            ReconciliationMode::Authoritative
1592        );
1593    }
1594
1595    // -----------------------------------------------------------------------
1596    // apply_role_retirements tests
1597    // -----------------------------------------------------------------------
1598
1599    #[test]
1600    fn apply_role_retirements_inserts_cleanup_before_drop() {
1601        let changes = vec![
1602            Change::Grant {
1603                role: "analytics".to_string(),
1604                privileges: BTreeSet::from([Privilege::Select]),
1605                object_type: ObjectType::Table,
1606                schema: Some("public".to_string()),
1607                name: Some("*".to_string()),
1608            },
1609            Change::DropRole {
1610                name: "old-app".to_string(),
1611            },
1612        ];
1613
1614        let planned = apply_role_retirements(
1615            changes,
1616            &[crate::manifest::RoleRetirement {
1617                role: "old-app".to_string(),
1618                reassign_owned_to: Some("successor".to_string()),
1619                drop_owned: true,
1620                terminate_sessions: true,
1621            }],
1622        );
1623
1624        assert!(matches!(planned[0], Change::Grant { .. }));
1625        assert!(matches!(
1626            planned[1],
1627            Change::TerminateSessions { ref role } if role == "old-app"
1628        ));
1629        assert!(matches!(
1630            planned[2],
1631            Change::ReassignOwned {
1632                ref from_role,
1633                ref to_role
1634            } if from_role == "old-app" && to_role == "successor"
1635        ));
1636        assert!(matches!(
1637            planned[3],
1638            Change::DropOwned { ref role } if role == "old-app"
1639        ));
1640        assert!(matches!(
1641            planned[4],
1642            Change::DropRole { ref name } if name == "old-app"
1643        ));
1644    }
1645
1646    #[test]
1647    fn inject_password_for_new_role() {
1648        let changes = vec![Change::CreateRole {
1649            name: "app-svc".to_string(),
1650            state: RoleState::default(),
1651        }];
1652
1653        let mut passwords = std::collections::BTreeMap::new();
1654        passwords.insert("app-svc".to_string(), "secret123".to_string());
1655
1656        let result = inject_password_changes(changes, &passwords);
1657        assert_eq!(result.len(), 2);
1658        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1659        assert!(
1660            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1661        );
1662    }
1663
1664    #[test]
1665    fn inject_password_for_existing_role() {
1666        // No CreateRole — role already exists. Only grants change.
1667        let changes = vec![Change::Grant {
1668            role: "app-svc".to_string(),
1669            privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1670            object_type: crate::manifest::ObjectType::Table,
1671            schema: Some("public".to_string()),
1672            name: Some("*".to_string()),
1673        }];
1674
1675        let mut passwords = std::collections::BTreeMap::new();
1676        passwords.insert("app-svc".to_string(), "secret123".to_string());
1677
1678        let result = inject_password_changes(changes, &passwords);
1679        assert_eq!(result.len(), 2);
1680        assert!(matches!(&result[0], Change::Grant { .. }));
1681        assert!(
1682            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1683        );
1684    }
1685
1686    #[test]
1687    fn inject_password_empty_passwords_is_noop() {
1688        let changes = vec![Change::CreateRole {
1689            name: "app-svc".to_string(),
1690            state: RoleState::default(),
1691        }];
1692
1693        let passwords = std::collections::BTreeMap::new();
1694        let result = inject_password_changes(changes.clone(), &passwords);
1695        assert_eq!(result.len(), 1);
1696    }
1697
1698    #[test]
1699    fn resolve_passwords_missing_env_var() {
1700        let roles = vec![crate::manifest::RoleDefinition {
1701            name: "app-svc".to_string(),
1702            login: Some(true),
1703            password: Some(crate::manifest::PasswordSource {
1704                from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1705            }),
1706            password_valid_until: None,
1707            superuser: None,
1708            createdb: None,
1709            createrole: None,
1710            inherit: None,
1711            replication: None,
1712            bypassrls: None,
1713            connection_limit: None,
1714            comment: None,
1715        }];
1716
1717        // Ensure the env var does not exist.
1718        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1719        unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1720
1721        let result = resolve_passwords(&roles);
1722        assert!(result.is_err());
1723        let err = result.unwrap_err();
1724        assert!(
1725            matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1726                if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1727            "expected MissingEnvVar, got: {err:?}"
1728        );
1729    }
1730
1731    #[test]
1732    fn resolve_passwords_empty_env_var() {
1733        let roles = vec![crate::manifest::RoleDefinition {
1734            name: "app-svc".to_string(),
1735            login: Some(true),
1736            password: Some(crate::manifest::PasswordSource {
1737                from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1738            }),
1739            password_valid_until: None,
1740            superuser: None,
1741            createdb: None,
1742            createrole: None,
1743            inherit: None,
1744            replication: None,
1745            bypassrls: None,
1746            connection_limit: None,
1747            comment: None,
1748        }];
1749
1750        // Set the env var to an empty string.
1751        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1752        unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1753
1754        let result = resolve_passwords(&roles);
1755
1756        // Clean up.
1757        unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1758
1759        assert!(result.is_err());
1760        let err = result.unwrap_err();
1761        assert!(
1762            matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1763                if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1764            "expected EmptyPassword, got: {err:?}"
1765        );
1766    }
1767
1768    #[test]
1769    fn resolve_passwords_happy_path() {
1770        let roles = vec![crate::manifest::RoleDefinition {
1771            name: "app-svc".to_string(),
1772            login: Some(true),
1773            password: Some(crate::manifest::PasswordSource {
1774                from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1775            }),
1776            password_valid_until: None,
1777            superuser: None,
1778            createdb: None,
1779            createrole: None,
1780            inherit: None,
1781            replication: None,
1782            bypassrls: None,
1783            connection_limit: None,
1784            comment: None,
1785        }];
1786
1787        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1788        unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1789
1790        let result = resolve_passwords(&roles);
1791
1792        unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1793
1794        let resolved = result.expect("should succeed");
1795        assert_eq!(resolved.len(), 1);
1796        assert_eq!(resolved["app-svc"], "my_secret_pw");
1797    }
1798
1799    #[test]
1800    fn resolve_passwords_skips_roles_without_password() {
1801        let roles = vec![crate::manifest::RoleDefinition {
1802            name: "no-password".to_string(),
1803            login: Some(true),
1804            password: None,
1805            password_valid_until: None,
1806            superuser: None,
1807            createdb: None,
1808            createrole: None,
1809            inherit: None,
1810            replication: None,
1811            bypassrls: None,
1812            connection_limit: None,
1813            comment: None,
1814        }];
1815
1816        let result = resolve_passwords(&roles);
1817        let resolved = result.expect("should succeed");
1818        assert!(resolved.is_empty());
1819    }
1820
1821    #[test]
1822    fn inject_password_multiple_roles() {
1823        let changes = vec![
1824            Change::CreateRole {
1825                name: "role-a".to_string(),
1826                state: RoleState::default(),
1827            },
1828            Change::CreateRole {
1829                name: "role-b".to_string(),
1830                state: RoleState::default(),
1831            },
1832            Change::Grant {
1833                role: "role-c".to_string(),
1834                privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
1835                object_type: crate::manifest::ObjectType::Table,
1836                schema: Some("public".to_string()),
1837                name: Some("*".to_string()),
1838            },
1839        ];
1840
1841        let mut passwords = std::collections::BTreeMap::new();
1842        passwords.insert("role-a".to_string(), "pw-a".to_string());
1843        passwords.insert("role-b".to_string(), "pw-b".to_string());
1844        passwords.insert("role-c".to_string(), "pw-c".to_string());
1845
1846        let result = inject_password_changes(changes, &passwords);
1847
1848        // role-a: CreateRole, SetPassword (inline)
1849        // role-b: CreateRole, SetPassword (inline)
1850        // role-c: Grant (existing role — SetPassword appended at end)
1851        assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
1852        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
1853        assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
1854        assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
1855        assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
1856        assert!(matches!(&result[4], Change::Grant { .. }));
1857        assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
1858    }
1859
1860    #[test]
1861    fn diff_detects_valid_until_change() {
1862        let mut current = empty_graph();
1863        current.roles.insert(
1864            "r1".to_string(),
1865            RoleState {
1866                login: true,
1867                ..RoleState::default()
1868            },
1869        );
1870
1871        let mut desired = empty_graph();
1872        desired.roles.insert(
1873            "r1".to_string(),
1874            RoleState {
1875                login: true,
1876                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1877                ..RoleState::default()
1878            },
1879        );
1880
1881        let changes = diff(&current, &desired);
1882        assert_eq!(changes.len(), 1);
1883        match &changes[0] {
1884            Change::AlterRole { name, attributes } => {
1885                assert_eq!(name, "r1");
1886                assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
1887                    "2025-12-31T00:00:00Z".to_string()
1888                ))));
1889            }
1890            other => panic!("expected AlterRole, got: {other:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn diff_detects_valid_until_removal() {
1896        let mut current = empty_graph();
1897        current.roles.insert(
1898            "r1".to_string(),
1899            RoleState {
1900                login: true,
1901                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1902                ..RoleState::default()
1903            },
1904        );
1905
1906        let mut desired = empty_graph();
1907        desired.roles.insert(
1908            "r1".to_string(),
1909            RoleState {
1910                login: true,
1911                ..RoleState::default()
1912            },
1913        );
1914
1915        let changes = diff(&current, &desired);
1916        assert_eq!(changes.len(), 1);
1917        match &changes[0] {
1918            Change::AlterRole { name, attributes } => {
1919                assert_eq!(name, "r1");
1920                assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
1921            }
1922            other => panic!("expected AlterRole, got: {other:?}"),
1923        }
1924    }
1925}