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