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::{BTreeMap, BTreeSet};
12
13use crate::manifest::{ObjectType, Privilege, RoleDefinition, 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
208/// Remove role-lifecycle and granted-role membership changes for external roles.
209///
210/// External roles are still valid references for grants, schema ownership, and
211/// as members of managed roles. pgroles simply avoids taking ownership of the
212/// external role object itself or of memberships granted from that role.
213pub fn filter_external_role_changes(changes: Vec<Change>, roles: &[RoleDefinition]) -> Vec<Change> {
214    let external_roles: BTreeSet<&str> = roles
215        .iter()
216        .filter(|role| role.external)
217        .map(|role| role.name.as_str())
218        .collect();
219
220    if external_roles.is_empty() {
221        return changes;
222    }
223
224    changes
225        .into_iter()
226        .filter(|change| !is_external_role_change(change, &external_roles))
227        .collect()
228}
229
230fn is_external_role_change(change: &Change, external_roles: &BTreeSet<&str>) -> bool {
231    match change {
232        Change::CreateRole { name, .. }
233        | Change::AlterRole { name, .. }
234        | Change::SetComment { name, .. }
235        | Change::SetPassword { name, .. }
236        | Change::DropRole { name } => external_roles.contains(name.as_str()),
237        Change::AddMember { role, .. } | Change::RemoveMember { role, .. } => {
238            external_roles.contains(role.as_str())
239        }
240        Change::TerminateSessions { role }
241        | Change::DropOwned { role }
242        | Change::ReassignOwned {
243            from_role: role, ..
244        } => external_roles.contains(role.as_str()),
245        _ => false,
246    }
247}
248
249fn filter_additive_changes(changes: Vec<Change>) -> Vec<Change> {
250    let skipped_owner_transfers: BTreeSet<(String, String)> = changes
251        .iter()
252        .filter_map(|change| match change {
253            Change::AlterSchemaOwner { name, owner } => Some((name.clone(), owner.clone())),
254            _ => None,
255        })
256        .collect();
257
258    changes
259        .into_iter()
260        .filter(|change| match change {
261            Change::EnsureSchemaOwnerPrivileges { name, owner, .. } => {
262                !skipped_owner_transfers.contains(&(name.clone(), owner.clone()))
263            }
264            Change::SetDefaultPrivilege { schema, owner, .. } => {
265                !skipped_owner_transfers.contains(&(schema.clone(), owner.clone()))
266            }
267            Change::AlterRole { .. } | Change::SetComment { .. } => false,
268            _ => !is_destructive(change),
269        })
270        .collect()
271}
272
273/// Returns `true` for any change that removes access or drops a role.
274fn is_destructive(change: &Change) -> bool {
275    matches!(
276        change,
277        Change::AlterSchemaOwner { .. }
278            | Change::Revoke { .. }
279            | Change::RevokeDefaultPrivilege { .. }
280            | Change::RemoveMember { .. }
281            | Change::DropRole { .. }
282            | Change::DropOwned { .. }
283            | Change::ReassignOwned { .. }
284            | Change::TerminateSessions { .. }
285    )
286}
287
288/// Returns `true` for role drops and their associated retirement cleanup steps.
289fn is_role_drop_or_retirement(change: &Change) -> bool {
290    matches!(
291        change,
292        Change::DropRole { .. }
293            | Change::DropOwned { .. }
294            | Change::ReassignOwned { .. }
295            | Change::TerminateSessions { .. }
296    )
297}
298
299// ---------------------------------------------------------------------------
300// Diff function
301// ---------------------------------------------------------------------------
302
303/// Compute the list of changes needed to bring `current` to `desired`.
304///
305/// Changes are ordered so that dependencies are respected:
306/// creates before grants, revokes before drops, etc.
307pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
308    let mut creates = Vec::new();
309    let mut alters = Vec::new();
310    let mut schema_changes = Vec::new();
311    let mut schema_grants = Vec::new();
312    let mut grants = Vec::new();
313    let mut set_defaults = Vec::new();
314    let mut add_members = Vec::new();
315    let mut remove_members = Vec::new();
316    let mut revoke_defaults = Vec::new();
317    let mut revokes = Vec::new();
318    let mut drops = Vec::new();
319
320    // ----- Roles -----
321
322    // Roles in desired but not in current → CREATE
323    for (name, desired_state) in &desired.roles {
324        match current.roles.get(name) {
325            None => {
326                creates.push(Change::CreateRole {
327                    name: name.clone(),
328                    state: desired_state.clone(),
329                });
330            }
331            Some(current_state) => {
332                // Role exists — check for attribute changes
333                let attribute_changes = current_state.changed_attributes(desired_state);
334                if !attribute_changes.is_empty() {
335                    alters.push(Change::AlterRole {
336                        name: name.clone(),
337                        attributes: attribute_changes,
338                    });
339                }
340                // Check comment change
341                if current_state.comment != desired_state.comment {
342                    alters.push(Change::SetComment {
343                        name: name.clone(),
344                        comment: desired_state.comment.clone(),
345                    });
346                }
347            }
348        }
349    }
350
351    // Roles in current but not in desired → DROP
352    for name in current.roles.keys() {
353        if !desired.roles.contains_key(name) {
354            drops.push(Change::DropRole { name: name.clone() });
355        }
356    }
357
358    // ----- Schemas -----
359
360    diff_schemas(current, desired, &mut schema_changes, &mut schema_grants);
361
362    // ----- Grants -----
363
364    diff_grants(current, desired, &mut grants, &mut revokes);
365
366    // ----- Default privileges -----
367
368    diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
369
370    // ----- Memberships -----
371
372    diff_memberships(current, desired, &mut add_members, &mut remove_members);
373
374    // ----- Assemble in dependency order -----
375    let mut changes = Vec::new();
376    changes.extend(creates);
377    changes.extend(alters);
378    changes.extend(schema_changes);
379    changes.extend(schema_grants);
380    changes.extend(grants);
381    changes.extend(set_defaults);
382    changes.extend(remove_members);
383    changes.extend(add_members);
384    changes.extend(revoke_defaults);
385    changes.extend(revokes);
386    changes.extend(drops);
387    changes
388}
389
390fn diff_schemas(
391    current: &RoleGraph,
392    desired: &RoleGraph,
393    schema_out: &mut Vec<Change>,
394    grant_out: &mut Vec<Change>,
395) {
396    for (name, desired_state) in &desired.schemas {
397        match current.schemas.get(name) {
398            None => schema_out.push(Change::CreateSchema {
399                name: name.clone(),
400                owner: desired_state.owner.clone(),
401            }),
402            Some(current_state) => {
403                if current_state.owner != desired_state.owner
404                    && let Some(owner) = &desired_state.owner
405                {
406                    schema_out.push(Change::AlterSchemaOwner {
407                        name: name.clone(),
408                        owner: owner.clone(),
409                    });
410                }
411            }
412        }
413
414        let Some(owner) = desired_state.owner.as_deref() else {
415            continue;
416        };
417
418        if !current.schemas.contains_key(name) {
419            continue;
420        }
421
422        let expected_privileges = default_schema_owner_privileges(owner);
423        let current_privileges = current
424            .schemas
425            .get(name)
426            .map(|state| state.owner_privileges.clone())
427            .unwrap_or_default();
428        let missing_privileges: BTreeSet<Privilege> = expected_privileges
429            .difference(&current_privileges)
430            .copied()
431            .collect();
432
433        if !missing_privileges.is_empty() {
434            grant_out.push(Change::EnsureSchemaOwnerPrivileges {
435                name: name.clone(),
436                owner: owner.to_string(),
437                privileges: missing_privileges,
438            });
439        }
440    }
441}
442
443/// Augment a diff plan with explicit role-retirement actions.
444///
445/// Retirement steps are inserted immediately before the matching `DropRole`
446/// so the final plan remains dependency-safe:
447/// `TERMINATE SESSIONS` → `REASSIGN OWNED` → `DROP OWNED` → `DROP ROLE`.
448pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
449    if retirements.is_empty() {
450        return changes;
451    }
452
453    let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
454        .iter()
455        .map(|retirement| (retirement.role.as_str(), retirement))
456        .collect();
457
458    let mut planned = Vec::with_capacity(changes.len());
459    for change in changes {
460        if let Change::DropRole { name } = &change
461            && let Some(retirement) = retirement_by_role.get(name.as_str())
462        {
463            if retirement.terminate_sessions {
464                planned.push(Change::TerminateSessions { role: name.clone() });
465            }
466            if let Some(successor) = &retirement.reassign_owned_to {
467                planned.push(Change::ReassignOwned {
468                    from_role: name.clone(),
469                    to_role: successor.clone(),
470                });
471            }
472            if retirement.drop_owned {
473                planned.push(Change::DropOwned { role: name.clone() });
474            }
475        }
476        planned.push(change);
477    }
478
479    planned
480}
481
482// ---------------------------------------------------------------------------
483// Password injection
484// ---------------------------------------------------------------------------
485
486/// Resolve password sources from environment variables.
487///
488/// Returns a map of role name → resolved password for every managed role that
489/// declares a `password.from_env` source. External roles are reference-only and
490/// never participate in password management.
491pub fn resolve_passwords(
492    roles: &[crate::manifest::RoleDefinition],
493) -> Result<std::collections::BTreeMap<String, String>, PasswordResolutionError> {
494    let mut resolved = std::collections::BTreeMap::new();
495    for role in roles {
496        if role.external {
497            continue;
498        }
499        if let Some(source) = &role.password {
500            let value = std::env::var(&source.from_env).map_err(|_| {
501                PasswordResolutionError::MissingEnvVar {
502                    role: role.name.clone(),
503                    env_var: source.from_env.clone(),
504                }
505            })?;
506            if value.is_empty() {
507                return Err(PasswordResolutionError::EmptyPassword {
508                    role: role.name.clone(),
509                    env_var: source.from_env.clone(),
510                });
511            }
512            resolved.insert(role.name.clone(), value);
513        }
514    }
515    Ok(resolved)
516}
517
518/// Errors that can occur during password resolution.
519#[derive(Debug, thiserror::Error)]
520pub enum PasswordResolutionError {
521    #[error("environment variable \"{env_var}\" for role \"{role}\" password is not set")]
522    MissingEnvVar { role: String, env_var: String },
523
524    #[error("environment variable \"{env_var}\" for role \"{role}\" password is empty")]
525    EmptyPassword { role: String, env_var: String },
526}
527
528/// Inject `SetPassword` changes into a plan for roles that declare passwords.
529///
530/// For newly created roles, the `SetPassword` is inserted immediately after the
531/// `CreateRole`. For existing roles with a password source, a `SetPassword` is
532/// appended after all creates/alters (ensuring the role exists).
533///
534/// Cleartext passwords are converted to SCRAM-SHA-256 verifiers before being
535/// placed in `SetPassword` changes, so the cleartext never appears in generated
536/// SQL. PostgreSQL detects the `SCRAM-SHA-256$` prefix and stores the verifier
537/// directly.
538///
539/// This function should be called after `diff()` and `apply_role_retirements()`.
540pub fn inject_password_changes(
541    changes: Vec<Change>,
542    resolved_passwords: &std::collections::BTreeMap<String, String>,
543) -> Vec<Change> {
544    if resolved_passwords.is_empty() {
545        return changes;
546    }
547
548    // Track which roles have CreateRole in the plan (newly created roles).
549    let created_roles: std::collections::BTreeSet<String> = changes
550        .iter()
551        .filter_map(|c| match c {
552            Change::CreateRole { name, .. } => Some(name.clone()),
553            _ => None,
554        })
555        .collect();
556
557    let mut result = Vec::with_capacity(changes.len() + resolved_passwords.len());
558
559    // Insert SetPassword immediately after CreateRole for new roles.
560    for change in changes {
561        if let Change::CreateRole { ref name, .. } = change
562            && let Some(password) = resolved_passwords.get(name.as_str())
563        {
564            let role_name = name.clone();
565            let verifier =
566                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
567            result.push(change);
568            result.push(Change::SetPassword {
569                name: role_name,
570                password: verifier,
571            });
572            continue;
573        }
574        result.push(change);
575    }
576
577    // For existing roles (not newly created), append SetPassword after all creates/alters.
578    for (role_name, password) in resolved_passwords {
579        if !created_roles.contains(role_name) {
580            let verifier =
581                crate::scram::compute_verifier(password, crate::scram::DEFAULT_ITERATIONS);
582            result.push(Change::SetPassword {
583                name: role_name.clone(),
584                password: verifier,
585            });
586        }
587    }
588
589    result
590}
591
592// ---------------------------------------------------------------------------
593// Grant diffing
594// ---------------------------------------------------------------------------
595
596fn diff_grants(
597    current: &RoleGraph,
598    desired: &RoleGraph,
599    grants_out: &mut Vec<Change>,
600    revokes_out: &mut Vec<Change>,
601) {
602    // Index desired wildcard grants for shadow-revoke filtering below. A
603    // desired wildcard `(role, schema, type, "*")` declares "every object of
604    // this type in this schema gets these privileges", so for any per-name
605    // entry surviving in `current` for the same (role, schema, type), the
606    // wildcard's privileges are implicitly covered. Revoking those privileges
607    // per-name would just be undone by the wildcard GRANT in the same plan
608    // — and because GRANTs are applied before REVOKEs, the net effect is to
609    // strip privileges from exactly the objects the inspector knew about,
610    // leaving the recently-recreated objects with grants. The next reconcile
611    // observes the inverted set, and the controller flaps forever.
612    //
613    // The shadowing applies to BOTH branches that produce per-name REVOKEs:
614    //   - the matched-key branch (desired and current both have the per-name
615    //     entry, e.g. desired=`widgets:INSERT` plus wildcard `*:SELECT`,
616    //     current=`widgets:SELECT+INSERT` → without filtering, `to_remove`
617    //     for the matched key would be `{SELECT}` and apply would strip a
618    //     privilege the wildcard still declares).
619    //   - the absent-key branch (current has a per-name entry that desired
620    //     covers only via wildcard).
621    let desired_wildcards: BTreeMap<(&str, &Option<String>, ObjectType), &BTreeSet<Privilege>> =
622        desired
623            .grants
624            .iter()
625            .filter(|(k, _)| k.name.as_deref() == Some("*") && k.schema.is_some())
626            .map(|(k, v)| ((k.role.as_str(), &k.schema, k.object_type), &v.privileges))
627            .collect();
628
629    // Returns the subset of `candidate` not shadowed by a desired wildcard
630    // for the same (role, schema, type). The wildcard itself is never
631    // shadowed (it has name="*", not a specific object name).
632    let shadow_filter = |key: &GrantKey, candidate: BTreeSet<Privilege>| -> BTreeSet<Privilege> {
633        if key.name.as_deref() == Some("*") {
634            return candidate;
635        }
636        match desired_wildcards.get(&(key.role.as_str(), &key.schema, key.object_type)) {
637            Some(wildcard_privileges) => {
638                candidate.difference(wildcard_privileges).copied().collect()
639            }
640            None => candidate,
641        }
642    };
643
644    // Grants in desired but not in current → GRANT (full set)
645    // Grants in both → diff the privilege sets
646    for (key, desired_state) in &desired.grants {
647        match current.grants.get(key) {
648            None => {
649                // Entirely new grant target — grant the full set
650                grants_out.push(change_grant(key, &desired_state.privileges));
651            }
652            Some(current_state) => {
653                // Grant target exists — find privileges to add/remove
654                let to_add: BTreeSet<Privilege> = desired_state
655                    .privileges
656                    .difference(&current_state.privileges)
657                    .copied()
658                    .collect();
659                let to_remove: BTreeSet<Privilege> = current_state
660                    .privileges
661                    .difference(&desired_state.privileges)
662                    .copied()
663                    .collect();
664                let to_remove = shadow_filter(key, to_remove);
665
666                if !to_add.is_empty() {
667                    grants_out.push(change_grant(key, &to_add));
668                }
669                if !to_remove.is_empty() {
670                    revokes_out.push(change_revoke(key, &to_remove));
671                }
672            }
673        }
674    }
675
676    // Grant targets in current but not in desired → REVOKE the privileges
677    // that aren't shadowed by a desired wildcard for the same scope.
678    for (key, current_state) in &current.grants {
679        if desired.grants.contains_key(key) {
680            continue;
681        }
682
683        let to_revoke = shadow_filter(key, current_state.privileges.clone());
684        if !to_revoke.is_empty() {
685            revokes_out.push(change_revoke(key, &to_revoke));
686        }
687    }
688}
689
690fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
691    Change::Grant {
692        role: key.role.clone(),
693        privileges: privileges.clone(),
694        object_type: key.object_type,
695        schema: key.schema.clone(),
696        name: key.name.clone(),
697    }
698}
699
700fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
701    Change::Revoke {
702        role: key.role.clone(),
703        privileges: privileges.clone(),
704        object_type: key.object_type,
705        schema: key.schema.clone(),
706        name: key.name.clone(),
707    }
708}
709
710// ---------------------------------------------------------------------------
711// Default privilege diffing
712// ---------------------------------------------------------------------------
713
714fn diff_default_privileges(
715    current: &RoleGraph,
716    desired: &RoleGraph,
717    set_out: &mut Vec<Change>,
718    revoke_out: &mut Vec<Change>,
719) {
720    for (key, desired_state) in &desired.default_privileges {
721        match current.default_privileges.get(key) {
722            None => {
723                set_out.push(change_set_default(key, &desired_state.privileges));
724            }
725            Some(current_state) => {
726                let to_add: BTreeSet<Privilege> = desired_state
727                    .privileges
728                    .difference(&current_state.privileges)
729                    .copied()
730                    .collect();
731                let to_remove: BTreeSet<Privilege> = current_state
732                    .privileges
733                    .difference(&desired_state.privileges)
734                    .copied()
735                    .collect();
736
737                if !to_add.is_empty() {
738                    set_out.push(change_set_default(key, &to_add));
739                }
740                if !to_remove.is_empty() {
741                    revoke_out.push(change_revoke_default(key, &to_remove));
742                }
743            }
744        }
745    }
746
747    for (key, current_state) in &current.default_privileges {
748        if !desired.default_privileges.contains_key(key) {
749            revoke_out.push(change_revoke_default(key, &current_state.privileges));
750        }
751    }
752}
753
754fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
755    Change::SetDefaultPrivilege {
756        owner: key.owner.clone(),
757        schema: key.schema.clone(),
758        on_type: key.on_type,
759        grantee: key.grantee.clone(),
760        privileges: privileges.clone(),
761    }
762}
763
764fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
765    Change::RevokeDefaultPrivilege {
766        owner: key.owner.clone(),
767        schema: key.schema.clone(),
768        on_type: key.on_type,
769        grantee: key.grantee.clone(),
770        privileges: privileges.clone(),
771    }
772}
773
774// ---------------------------------------------------------------------------
775// Membership diffing
776// ---------------------------------------------------------------------------
777
778fn diff_memberships(
779    current: &RoleGraph,
780    desired: &RoleGraph,
781    add_out: &mut Vec<Change>,
782    remove_out: &mut Vec<Change>,
783) {
784    // We compare memberships by (role, member) as the key.
785    // If inherit/admin flags changed, we remove and re-add.
786
787    // Build lookup maps: (role, member) → MembershipEdge
788    let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
789        .memberships
790        .iter()
791        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
792        .collect();
793    let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
794        .memberships
795        .iter()
796        .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
797        .collect();
798
799    // Desired but not current → add
800    // Desired and current but different flags → remove + add
801    for (&(role, member), &desired_edge) in &desired_map {
802        match current_map.get(&(role, member)) {
803            None => {
804                add_out.push(Change::AddMember {
805                    role: desired_edge.role.clone(),
806                    member: desired_edge.member.clone(),
807                    inherit: desired_edge.inherit,
808                    admin: desired_edge.admin,
809                });
810            }
811            Some(current_edge) => {
812                if current_edge.inherit != desired_edge.inherit
813                    || current_edge.admin != desired_edge.admin
814                {
815                    // Flags changed — revoke and re-grant
816                    remove_out.push(Change::RemoveMember {
817                        role: current_edge.role.clone(),
818                        member: current_edge.member.clone(),
819                    });
820                    add_out.push(Change::AddMember {
821                        role: desired_edge.role.clone(),
822                        member: desired_edge.member.clone(),
823                        inherit: desired_edge.inherit,
824                        admin: desired_edge.admin,
825                    });
826                }
827            }
828        }
829    }
830
831    // Current but not desired → remove
832    for &(role, member) in current_map.keys() {
833        if !desired_map.contains_key(&(role, member)) {
834            remove_out.push(Change::RemoveMember {
835                role: role.to_string(),
836                member: member.to_string(),
837            });
838        }
839    }
840}
841
842// ---------------------------------------------------------------------------
843// Tests
844// ---------------------------------------------------------------------------
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::model::{
850        DefaultPrivState, GrantState, SchemaState, default_schema_owner_privileges,
851    };
852
853    /// Helper: build an empty graph.
854    fn empty_graph() -> RoleGraph {
855        RoleGraph::default()
856    }
857
858    fn managed_schema(owner: &str) -> SchemaState {
859        SchemaState {
860            owner: Some(owner.to_string()),
861            owner_privileges: default_schema_owner_privileges(owner),
862        }
863    }
864
865    fn role_definition(name: &str, external: bool) -> RoleDefinition {
866        RoleDefinition {
867            name: name.to_string(),
868            external,
869            login: None,
870            superuser: None,
871            createdb: None,
872            createrole: None,
873            inherit: None,
874            replication: None,
875            bypassrls: None,
876            connection_limit: None,
877            comment: None,
878            password: None,
879            password_valid_until: None,
880        }
881    }
882
883    #[test]
884    fn diff_empty_to_empty_is_empty() {
885        let changes = diff(&empty_graph(), &empty_graph());
886        assert!(changes.is_empty());
887    }
888
889    #[test]
890    fn diff_creates_new_roles() {
891        let current = empty_graph();
892        let mut desired = empty_graph();
893        desired
894            .roles
895            .insert("new-role".to_string(), RoleState::default());
896
897        let changes = diff(&current, &desired);
898        assert_eq!(changes.len(), 1);
899        assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
900    }
901
902    #[test]
903    fn diff_drops_removed_roles() {
904        let mut current = empty_graph();
905        current
906            .roles
907            .insert("old-role".to_string(), RoleState::default());
908        let desired = empty_graph();
909
910        let changes = diff(&current, &desired);
911        assert_eq!(changes.len(), 1);
912        assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
913    }
914
915    #[test]
916    fn diff_alters_changed_role_attributes() {
917        let mut current = empty_graph();
918        current
919            .roles
920            .insert("role1".to_string(), RoleState::default());
921
922        let mut desired = empty_graph();
923        desired.roles.insert(
924            "role1".to_string(),
925            RoleState {
926                login: true,
927                ..RoleState::default()
928            },
929        );
930
931        let changes = diff(&current, &desired);
932        assert_eq!(changes.len(), 1);
933        match &changes[0] {
934            Change::AlterRole { name, attributes } => {
935                assert_eq!(name, "role1");
936                assert!(attributes.contains(&RoleAttribute::Login(true)));
937            }
938            other => panic!("expected AlterRole, got: {other:?}"),
939        }
940    }
941
942    #[test]
943    fn external_role_filter_suppresses_lifecycle_and_granted_role_memberships() {
944        let external = "analytics-admin@example.com";
945        let mut current = empty_graph();
946        current.roles.insert(
947            external.to_string(),
948            RoleState {
949                login: true,
950                ..RoleState::default()
951            },
952        );
953        current.memberships.insert(MembershipEdge {
954            role: external.to_string(),
955            member: "cloudsqlsuperuser".to_string(),
956            inherit: true,
957            admin: false,
958        });
959
960        let mut desired = empty_graph();
961        desired
962            .roles
963            .insert(external.to_string(), RoleState::default());
964
965        let changes = diff(&current, &desired);
966        assert!(changes.iter().any(|change| {
967            matches!(
968                change,
969                Change::AlterRole { name, attributes }
970                    if name == external && attributes.contains(&RoleAttribute::Login(false))
971            )
972        }));
973        assert!(changes.iter().any(|change| {
974            matches!(
975                change,
976                Change::RemoveMember { role, member }
977                    if role == external && member == "cloudsqlsuperuser"
978            )
979        }));
980
981        let filtered = filter_external_role_changes(changes, &[role_definition(external, true)]);
982        assert!(filtered.is_empty());
983    }
984
985    #[test]
986    fn external_role_filter_keeps_external_role_as_managed_member() {
987        let external = "team@example.com";
988        let changes = vec![Change::RemoveMember {
989            role: "kv-editor".to_string(),
990            member: external.to_string(),
991        }];
992
993        let filtered =
994            filter_external_role_changes(changes.clone(), &[role_definition(external, true)]);
995        assert_eq!(filtered, changes);
996    }
997
998    #[test]
999    fn diff_creates_missing_schema() {
1000        let current = empty_graph();
1001        let mut desired = empty_graph();
1002        desired
1003            .schemas
1004            .insert("inventory".to_string(), managed_schema("inventory_owner"));
1005
1006        let changes = diff(&current, &desired);
1007        assert_eq!(changes.len(), 1);
1008        assert!(matches!(
1009            &changes[0],
1010            Change::CreateSchema { name, owner }
1011                if name == "inventory" && owner.as_deref() == Some("inventory_owner")
1012        ));
1013    }
1014
1015    #[test]
1016    fn diff_alters_schema_owner_when_different() {
1017        let mut current = empty_graph();
1018        current
1019            .schemas
1020            .insert("inventory".to_string(), managed_schema("old_owner"));
1021
1022        let mut desired = empty_graph();
1023        desired
1024            .schemas
1025            .insert("inventory".to_string(), managed_schema("new_owner"));
1026
1027        let changes = diff(&current, &desired);
1028        assert_eq!(changes.len(), 1);
1029        assert!(matches!(
1030            &changes[0],
1031            Change::AlterSchemaOwner { name, owner }
1032                if name == "inventory" && owner == "new_owner"
1033        ));
1034    }
1035
1036    #[test]
1037    fn diff_does_not_alter_schema_owner_when_unmanaged() {
1038        let mut current = empty_graph();
1039        current
1040            .schemas
1041            .insert("inventory".to_string(), managed_schema("old_owner"));
1042
1043        let mut desired = empty_graph();
1044        desired.schemas.insert(
1045            "inventory".to_string(),
1046            SchemaState {
1047                owner: None,
1048                owner_privileges: BTreeSet::new(),
1049            },
1050        );
1051
1052        let changes = diff(&current, &desired);
1053        assert!(changes.is_empty());
1054    }
1055
1056    #[test]
1057    fn diff_restores_missing_owner_schema_privileges() {
1058        let mut current = empty_graph();
1059        current.schemas.insert(
1060            "inventory".to_string(),
1061            SchemaState {
1062                owner: Some("inventory_owner".to_string()),
1063                owner_privileges: BTreeSet::from([Privilege::Usage]),
1064            },
1065        );
1066
1067        let mut desired = empty_graph();
1068        desired
1069            .schemas
1070            .insert("inventory".to_string(), managed_schema("inventory_owner"));
1071
1072        let changes = diff(&current, &desired);
1073        assert_eq!(changes.len(), 1);
1074        assert!(matches!(
1075            &changes[0],
1076            Change::EnsureSchemaOwnerPrivileges {
1077                name,
1078                owner,
1079                privileges,
1080            } if name == "inventory"
1081                && owner == "inventory_owner"
1082                && privileges == &BTreeSet::from([Privilege::Create])
1083        ));
1084    }
1085
1086    #[test]
1087    fn diff_restores_owner_schema_privileges_after_transfer() {
1088        let mut current = empty_graph();
1089        current.schemas.insert(
1090            "inventory".to_string(),
1091            SchemaState {
1092                owner: Some("old_owner".to_string()),
1093                owner_privileges: BTreeSet::from([Privilege::Usage]),
1094            },
1095        );
1096
1097        let mut desired = empty_graph();
1098        desired
1099            .schemas
1100            .insert("inventory".to_string(), managed_schema("new_owner"));
1101
1102        let changes = diff(&current, &desired);
1103        assert_eq!(changes.len(), 2);
1104        assert!(matches!(
1105            &changes[0],
1106            Change::AlterSchemaOwner { name, owner }
1107                if name == "inventory" && owner == "new_owner"
1108        ));
1109        assert!(matches!(
1110            &changes[1],
1111            Change::EnsureSchemaOwnerPrivileges {
1112                name,
1113                owner,
1114                privileges,
1115            } if name == "inventory"
1116                && owner == "new_owner"
1117                && privileges == &BTreeSet::from([Privilege::Create])
1118        ));
1119    }
1120
1121    #[test]
1122    fn diff_grants_new_privileges() {
1123        let current = empty_graph();
1124        let mut desired = empty_graph();
1125        let key = GrantKey {
1126            role: "r1".to_string(),
1127            object_type: ObjectType::Table,
1128            schema: Some("public".to_string()),
1129            name: Some("*".to_string()),
1130        };
1131        desired.grants.insert(
1132            key,
1133            GrantState {
1134                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1135            },
1136        );
1137
1138        let changes = diff(&current, &desired);
1139        assert_eq!(changes.len(), 1);
1140        match &changes[0] {
1141            Change::Grant {
1142                role, privileges, ..
1143            } => {
1144                assert_eq!(role, "r1");
1145                assert!(privileges.contains(&Privilege::Select));
1146                assert!(privileges.contains(&Privilege::Insert));
1147            }
1148            other => panic!("expected Grant, got: {other:?}"),
1149        }
1150    }
1151
1152    #[test]
1153    fn diff_revokes_removed_privileges() {
1154        let mut current = empty_graph();
1155        let key = GrantKey {
1156            role: "r1".to_string(),
1157            object_type: ObjectType::Table,
1158            schema: Some("public".to_string()),
1159            name: Some("*".to_string()),
1160        };
1161        current.grants.insert(
1162            key.clone(),
1163            GrantState {
1164                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1165            },
1166        );
1167
1168        let mut desired = empty_graph();
1169        desired.grants.insert(
1170            key,
1171            GrantState {
1172                privileges: BTreeSet::from([Privilege::Select]),
1173            },
1174        );
1175
1176        let changes = diff(&current, &desired);
1177        assert_eq!(changes.len(), 1);
1178        match &changes[0] {
1179            Change::Revoke {
1180                role, privileges, ..
1181            } => {
1182                assert_eq!(role, "r1");
1183                assert!(privileges.contains(&Privilege::Insert));
1184                assert!(!privileges.contains(&Privilege::Select));
1185            }
1186            other => panic!("expected Revoke, got: {other:?}"),
1187        }
1188    }
1189
1190    #[test]
1191    fn diff_revokes_entire_grant_target_when_absent_from_desired() {
1192        let mut current = empty_graph();
1193        let key = GrantKey {
1194            role: "r1".to_string(),
1195            object_type: ObjectType::Schema,
1196            schema: None,
1197            name: Some("myschema".to_string()),
1198        };
1199        current.grants.insert(
1200            key,
1201            GrantState {
1202                privileges: BTreeSet::from([Privilege::Usage]),
1203            },
1204        );
1205        let desired = empty_graph();
1206
1207        let changes = diff(&current, &desired);
1208        assert_eq!(changes.len(), 1);
1209        assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
1210    }
1211
1212    #[test]
1213    fn diff_adds_memberships() {
1214        let current = empty_graph();
1215        let mut desired = empty_graph();
1216        desired.memberships.insert(MembershipEdge {
1217            role: "editors".to_string(),
1218            member: "user@example.com".to_string(),
1219            inherit: true,
1220            admin: false,
1221        });
1222
1223        let changes = diff(&current, &desired);
1224        assert_eq!(changes.len(), 1);
1225        match &changes[0] {
1226            Change::AddMember {
1227                role,
1228                member,
1229                inherit,
1230                admin,
1231            } => {
1232                assert_eq!(role, "editors");
1233                assert_eq!(member, "user@example.com");
1234                assert!(*inherit);
1235                assert!(!admin);
1236            }
1237            other => panic!("expected AddMember, got: {other:?}"),
1238        }
1239    }
1240
1241    #[test]
1242    fn diff_removes_memberships() {
1243        let mut current = empty_graph();
1244        current.memberships.insert(MembershipEdge {
1245            role: "editors".to_string(),
1246            member: "old@example.com".to_string(),
1247            inherit: true,
1248            admin: false,
1249        });
1250        let desired = empty_graph();
1251
1252        let changes = diff(&current, &desired);
1253        assert_eq!(changes.len(), 1);
1254        assert!(
1255            matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
1256        );
1257    }
1258
1259    #[test]
1260    fn diff_re_grants_membership_when_flags_change() {
1261        let mut current = empty_graph();
1262        current.memberships.insert(MembershipEdge {
1263            role: "editors".to_string(),
1264            member: "user@example.com".to_string(),
1265            inherit: true,
1266            admin: false,
1267        });
1268
1269        let mut desired = empty_graph();
1270        desired.memberships.insert(MembershipEdge {
1271            role: "editors".to_string(),
1272            member: "user@example.com".to_string(),
1273            inherit: true,
1274            admin: true, // changed!
1275        });
1276
1277        let changes = diff(&current, &desired);
1278        // Should produce remove + add
1279        assert_eq!(changes.len(), 2);
1280        assert!(matches!(
1281            &changes[0],
1282            Change::RemoveMember { role, member }
1283                if role == "editors" && member == "user@example.com"
1284        ));
1285        assert!(matches!(
1286            &changes[1],
1287            Change::AddMember {
1288                role,
1289                member,
1290                admin: true,
1291                ..
1292            } if role == "editors" && member == "user@example.com"
1293        ));
1294    }
1295
1296    #[test]
1297    fn diff_default_privileges_add_and_revoke() {
1298        let mut current = empty_graph();
1299        let key = DefaultPrivKey {
1300            owner: "app_owner".to_string(),
1301            schema: "inventory".to_string(),
1302            on_type: ObjectType::Table,
1303            grantee: "inventory-editor".to_string(),
1304        };
1305        current.default_privileges.insert(
1306            key.clone(),
1307            DefaultPrivState {
1308                privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
1309            },
1310        );
1311
1312        let mut desired = empty_graph();
1313        desired.default_privileges.insert(
1314            key,
1315            DefaultPrivState {
1316                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1317            },
1318        );
1319
1320        let changes = diff(&current, &desired);
1321        // Should add INSERT and revoke DELETE
1322        assert_eq!(changes.len(), 2);
1323        assert!(changes.iter().any(|c| matches!(
1324            c,
1325            Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
1326        )));
1327        assert!(changes.iter().any(|c| matches!(
1328            c,
1329            Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
1330        )));
1331    }
1332
1333    #[test]
1334    fn diff_ordering_creates_before_drops() {
1335        let mut current = empty_graph();
1336        current
1337            .roles
1338            .insert("old-role".to_string(), RoleState::default());
1339
1340        let mut desired = empty_graph();
1341        desired
1342            .roles
1343            .insert("new-role".to_string(), RoleState::default());
1344
1345        let changes = diff(&current, &desired);
1346        assert_eq!(changes.len(), 2);
1347
1348        // Creates should come before drops
1349        let create_idx = changes
1350            .iter()
1351            .position(|c| matches!(c, Change::CreateRole { .. }))
1352            .unwrap();
1353        let schema_idx = changes
1354            .iter()
1355            .position(|c| matches!(c, Change::CreateSchema { .. }))
1356            .unwrap_or(create_idx);
1357        let drop_idx = changes
1358            .iter()
1359            .position(|c| matches!(c, Change::DropRole { .. }))
1360            .unwrap();
1361        assert!(create_idx <= schema_idx);
1362        assert!(schema_idx < drop_idx);
1363    }
1364
1365    #[test]
1366    fn diff_identical_graphs_produce_no_changes() {
1367        let mut graph = empty_graph();
1368        graph
1369            .roles
1370            .insert("role1".to_string(), RoleState::default());
1371        graph.grants.insert(
1372            GrantKey {
1373                role: "role1".to_string(),
1374                object_type: ObjectType::Table,
1375                schema: Some("public".to_string()),
1376                name: Some("*".to_string()),
1377            },
1378            GrantState {
1379                privileges: BTreeSet::from([Privilege::Select]),
1380            },
1381        );
1382        graph.memberships.insert(MembershipEdge {
1383            role: "role1".to_string(),
1384            member: "user@example.com".to_string(),
1385            inherit: true,
1386            admin: false,
1387        });
1388
1389        let changes = diff(&graph, &graph);
1390        assert!(
1391            changes.is_empty(),
1392            "identical graphs should produce no changes"
1393        );
1394    }
1395
1396    /// Integration test: round-trip from manifest → expand → model → diff
1397    #[test]
1398    fn manifest_to_diff_integration() {
1399        use crate::manifest::{expand_manifest, parse_manifest};
1400        use crate::model::RoleGraph;
1401
1402        let yaml = r#"
1403default_owner: app_owner
1404
1405profiles:
1406  editor:
1407    grants:
1408      - privileges: [USAGE]
1409        object: { type: schema }
1410      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1411        object: { type: table, name: "*" }
1412    default_privileges:
1413      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1414        on_type: table
1415
1416schemas:
1417  - name: inventory
1418    owner: inventory_owner
1419    profiles: [editor]
1420
1421memberships:
1422  - role: inventory-editor
1423    members:
1424      - name: "user@example.com"
1425"#;
1426        let manifest = parse_manifest(yaml).unwrap();
1427        let expanded = expand_manifest(&manifest).unwrap();
1428        let desired =
1429            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1430
1431        // Current state is empty — everything should be created
1432        let current = RoleGraph::default();
1433        let changes = diff(&current, &desired);
1434
1435        // Should have: 1 CreateRole, 1 CreateSchema, 2 Grants, 1 SetDefaultPrivilege, 1 AddMember
1436        let create_count = changes
1437            .iter()
1438            .filter(|c| matches!(c, Change::CreateRole { .. }))
1439            .count();
1440        let create_schema_count = changes
1441            .iter()
1442            .filter(|c| matches!(c, Change::CreateSchema { .. }))
1443            .count();
1444        let grant_count = changes
1445            .iter()
1446            .filter(|c| matches!(c, Change::Grant { .. }))
1447            .count();
1448        let dp_count = changes
1449            .iter()
1450            .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1451            .count();
1452        let member_count = changes
1453            .iter()
1454            .filter(|c| matches!(c, Change::AddMember { .. }))
1455            .count();
1456
1457        assert_eq!(create_count, 1);
1458        assert_eq!(create_schema_count, 1);
1459        assert_eq!(grant_count, 2); // schema USAGE + table *
1460        assert_eq!(dp_count, 1);
1461        assert_eq!(member_count, 1);
1462
1463        // Diffing desired against itself should produce no changes
1464        let no_changes = diff(&desired, &desired);
1465        assert!(no_changes.is_empty());
1466    }
1467
1468    // -----------------------------------------------------------------------
1469    // filter_changes — ReconciliationMode tests
1470    // -----------------------------------------------------------------------
1471
1472    /// Build a representative change list covering every Change variant.
1473    fn all_change_variants() -> Vec<Change> {
1474        vec![
1475            Change::CreateRole {
1476                name: "new-role".to_string(),
1477                state: RoleState::default(),
1478            },
1479            Change::CreateSchema {
1480                name: "inventory".to_string(),
1481                owner: Some("inventory_owner".to_string()),
1482            },
1483            Change::AlterSchemaOwner {
1484                name: "catalog".to_string(),
1485                owner: "catalog_owner".to_string(),
1486            },
1487            Change::EnsureSchemaOwnerPrivileges {
1488                name: "catalog".to_string(),
1489                owner: "catalog_owner".to_string(),
1490                privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1491            },
1492            Change::AlterRole {
1493                name: "altered-role".to_string(),
1494                attributes: vec![RoleAttribute::Login(true)],
1495            },
1496            Change::SetComment {
1497                name: "commented-role".to_string(),
1498                comment: Some("hello".to_string()),
1499            },
1500            Change::Grant {
1501                role: "r1".to_string(),
1502                privileges: BTreeSet::from([Privilege::Select]),
1503                object_type: ObjectType::Table,
1504                schema: Some("public".to_string()),
1505                name: Some("*".to_string()),
1506            },
1507            Change::Revoke {
1508                role: "r1".to_string(),
1509                privileges: BTreeSet::from([Privilege::Insert]),
1510                object_type: ObjectType::Table,
1511                schema: Some("public".to_string()),
1512                name: Some("*".to_string()),
1513            },
1514            Change::SetDefaultPrivilege {
1515                owner: "owner".to_string(),
1516                schema: "public".to_string(),
1517                on_type: ObjectType::Table,
1518                grantee: "r1".to_string(),
1519                privileges: BTreeSet::from([Privilege::Select]),
1520            },
1521            Change::RevokeDefaultPrivilege {
1522                owner: "owner".to_string(),
1523                schema: "public".to_string(),
1524                on_type: ObjectType::Table,
1525                grantee: "r1".to_string(),
1526                privileges: BTreeSet::from([Privilege::Delete]),
1527            },
1528            Change::AddMember {
1529                role: "editors".to_string(),
1530                member: "user@example.com".to_string(),
1531                inherit: true,
1532                admin: false,
1533            },
1534            Change::RemoveMember {
1535                role: "editors".to_string(),
1536                member: "old@example.com".to_string(),
1537            },
1538            Change::TerminateSessions {
1539                role: "retired-role".to_string(),
1540            },
1541            Change::ReassignOwned {
1542                from_role: "retired-role".to_string(),
1543                to_role: "successor".to_string(),
1544            },
1545            Change::DropOwned {
1546                role: "retired-role".to_string(),
1547            },
1548            Change::DropRole {
1549                name: "retired-role".to_string(),
1550            },
1551        ]
1552    }
1553
1554    #[test]
1555    fn filter_authoritative_keeps_all_changes() {
1556        let changes = all_change_variants();
1557        let original_len = changes.len();
1558        let filtered = filter_changes(changes, ReconciliationMode::Authoritative);
1559        assert_eq!(filtered.len(), original_len);
1560    }
1561
1562    #[test]
1563    fn filter_additive_keeps_only_constructive_changes() {
1564        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Additive);
1565
1566        // Should keep: CreateRole, CreateSchema, Grant, SetDefaultPrivilege, AddMember
1567        assert_eq!(filtered.len(), 5);
1568
1569        // Verify no destructive changes remain
1570        for change in &filtered {
1571            assert!(
1572                !matches!(
1573                    change,
1574                    Change::AlterSchemaOwner { .. }
1575                        | Change::EnsureSchemaOwnerPrivileges { .. }
1576                        | Change::AlterRole { .. }
1577                        | Change::SetComment { .. }
1578                        | Change::Revoke { .. }
1579                        | Change::RevokeDefaultPrivilege { .. }
1580                        | Change::RemoveMember { .. }
1581                        | Change::DropRole { .. }
1582                        | Change::DropOwned { .. }
1583                        | Change::ReassignOwned { .. }
1584                        | Change::TerminateSessions { .. }
1585                ),
1586                "additive mode should not contain destructive change: {change:?}"
1587            );
1588        }
1589
1590        // Verify constructive changes are present
1591        assert!(
1592            filtered
1593                .iter()
1594                .any(|c| matches!(c, Change::CreateRole { .. }))
1595        );
1596        assert!(
1597            filtered
1598                .iter()
1599                .any(|c| matches!(c, Change::CreateSchema { .. }))
1600        );
1601        assert!(
1602            filtered
1603                .iter()
1604                .all(|c| !matches!(c, Change::AlterRole { .. } | Change::SetComment { .. }))
1605        );
1606        assert!(filtered.iter().any(|c| matches!(c, Change::Grant { .. })));
1607        assert!(
1608            filtered
1609                .iter()
1610                .any(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
1611        );
1612        assert!(
1613            filtered
1614                .iter()
1615                .any(|c| matches!(c, Change::AddMember { .. }))
1616        );
1617    }
1618
1619    #[test]
1620    fn filter_additive_skips_owner_bound_follow_ups_when_transfer_is_skipped() {
1621        let changes = vec![
1622            Change::AlterSchemaOwner {
1623                name: "inventory".to_string(),
1624                owner: "new_owner".to_string(),
1625            },
1626            Change::EnsureSchemaOwnerPrivileges {
1627                name: "inventory".to_string(),
1628                owner: "new_owner".to_string(),
1629                privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
1630            },
1631            Change::SetDefaultPrivilege {
1632                owner: "new_owner".to_string(),
1633                schema: "inventory".to_string(),
1634                on_type: ObjectType::Table,
1635                grantee: "inventory-editor".to_string(),
1636                privileges: BTreeSet::from([Privilege::Select]),
1637            },
1638            Change::Grant {
1639                role: "inventory-editor".to_string(),
1640                privileges: BTreeSet::from([Privilege::Usage]),
1641                object_type: ObjectType::Schema,
1642                schema: None,
1643                name: Some("inventory".to_string()),
1644            },
1645        ];
1646
1647        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1648        assert_eq!(filtered.len(), 1);
1649        assert!(matches!(&filtered[0], Change::Grant { role, .. } if role == "inventory-editor"));
1650    }
1651
1652    #[test]
1653    fn filter_adopt_keeps_revokes_but_not_drops() {
1654        let filtered = filter_changes(all_change_variants(), ReconciliationMode::Adopt);
1655
1656        // Should keep everything except: DropRole, DropOwned, ReassignOwned, TerminateSessions
1657        assert_eq!(filtered.len(), 12);
1658
1659        // Verify no role-drop/retirement changes remain
1660        for change in &filtered {
1661            assert!(
1662                !matches!(
1663                    change,
1664                    Change::DropRole { .. }
1665                        | Change::DropOwned { .. }
1666                        | Change::ReassignOwned { .. }
1667                        | Change::TerminateSessions { .. }
1668                ),
1669                "adopt mode should not contain drop/retirement change: {change:?}"
1670            );
1671        }
1672
1673        // Verify revokes ARE still present (unlike additive)
1674        assert!(filtered.iter().any(|c| matches!(c, Change::Revoke { .. })));
1675        assert!(
1676            filtered
1677                .iter()
1678                .any(|c| matches!(c, Change::RevokeDefaultPrivilege { .. }))
1679        );
1680        assert!(
1681            filtered
1682                .iter()
1683                .any(|c| matches!(c, Change::RemoveMember { .. }))
1684        );
1685    }
1686
1687    #[test]
1688    fn filter_additive_with_empty_input() {
1689        let filtered = filter_changes(vec![], ReconciliationMode::Additive);
1690        assert!(filtered.is_empty());
1691    }
1692
1693    #[test]
1694    fn filter_additive_only_destructive_changes_yields_empty() {
1695        let changes = vec![
1696            Change::Revoke {
1697                role: "r1".to_string(),
1698                privileges: BTreeSet::from([Privilege::Select]),
1699                object_type: ObjectType::Table,
1700                schema: Some("public".to_string()),
1701                name: Some("*".to_string()),
1702            },
1703            Change::DropRole {
1704                name: "old-role".to_string(),
1705            },
1706        ];
1707        let filtered = filter_changes(changes, ReconciliationMode::Additive);
1708        assert!(filtered.is_empty());
1709    }
1710
1711    #[test]
1712    fn filter_adopt_preserves_ordering() {
1713        let changes = vec![
1714            Change::CreateRole {
1715                name: "new-role".to_string(),
1716                state: RoleState::default(),
1717            },
1718            Change::Grant {
1719                role: "new-role".to_string(),
1720                privileges: BTreeSet::from([Privilege::Select]),
1721                object_type: ObjectType::Table,
1722                schema: Some("public".to_string()),
1723                name: Some("*".to_string()),
1724            },
1725            Change::Revoke {
1726                role: "existing-role".to_string(),
1727                privileges: BTreeSet::from([Privilege::Insert]),
1728                object_type: ObjectType::Table,
1729                schema: Some("public".to_string()),
1730                name: Some("*".to_string()),
1731            },
1732            Change::DropRole {
1733                name: "old-role".to_string(),
1734            },
1735        ];
1736
1737        let filtered = filter_changes(changes, ReconciliationMode::Adopt);
1738        assert_eq!(filtered.len(), 3);
1739        assert!(matches!(&filtered[0], Change::CreateRole { name, .. } if name == "new-role"));
1740        assert!(matches!(&filtered[1], Change::Grant { .. }));
1741        assert!(matches!(&filtered[2], Change::Revoke { .. }));
1742    }
1743
1744    #[test]
1745    fn reconciliation_mode_display() {
1746        assert_eq!(
1747            ReconciliationMode::Authoritative.to_string(),
1748            "authoritative"
1749        );
1750        assert_eq!(ReconciliationMode::Additive.to_string(), "additive");
1751        assert_eq!(ReconciliationMode::Adopt.to_string(), "adopt");
1752    }
1753
1754    #[test]
1755    fn reconciliation_mode_default_is_authoritative() {
1756        assert_eq!(
1757            ReconciliationMode::default(),
1758            ReconciliationMode::Authoritative
1759        );
1760    }
1761
1762    // -----------------------------------------------------------------------
1763    // apply_role_retirements tests
1764    // -----------------------------------------------------------------------
1765
1766    #[test]
1767    fn apply_role_retirements_inserts_cleanup_before_drop() {
1768        let changes = vec![
1769            Change::Grant {
1770                role: "analytics".to_string(),
1771                privileges: BTreeSet::from([Privilege::Select]),
1772                object_type: ObjectType::Table,
1773                schema: Some("public".to_string()),
1774                name: Some("*".to_string()),
1775            },
1776            Change::DropRole {
1777                name: "old-app".to_string(),
1778            },
1779        ];
1780
1781        let planned = apply_role_retirements(
1782            changes,
1783            &[crate::manifest::RoleRetirement {
1784                role: "old-app".to_string(),
1785                reassign_owned_to: Some("successor".to_string()),
1786                drop_owned: true,
1787                terminate_sessions: true,
1788            }],
1789        );
1790
1791        assert!(matches!(planned[0], Change::Grant { .. }));
1792        assert!(matches!(
1793            planned[1],
1794            Change::TerminateSessions { ref role } if role == "old-app"
1795        ));
1796        assert!(matches!(
1797            planned[2],
1798            Change::ReassignOwned {
1799                ref from_role,
1800                ref to_role
1801            } if from_role == "old-app" && to_role == "successor"
1802        ));
1803        assert!(matches!(
1804            planned[3],
1805            Change::DropOwned { ref role } if role == "old-app"
1806        ));
1807        assert!(matches!(
1808            planned[4],
1809            Change::DropRole { ref name } if name == "old-app"
1810        ));
1811    }
1812
1813    #[test]
1814    fn inject_password_for_new_role() {
1815        let changes = vec![Change::CreateRole {
1816            name: "app-svc".to_string(),
1817            state: RoleState::default(),
1818        }];
1819
1820        let mut passwords = std::collections::BTreeMap::new();
1821        passwords.insert("app-svc".to_string(), "secret123".to_string());
1822
1823        let result = inject_password_changes(changes, &passwords);
1824        assert_eq!(result.len(), 2);
1825        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "app-svc"));
1826        assert!(
1827            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1828        );
1829    }
1830
1831    #[test]
1832    fn inject_password_for_existing_role() {
1833        // No CreateRole — role already exists. Only grants change.
1834        let changes = vec![Change::Grant {
1835            role: "app-svc".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        let mut passwords = std::collections::BTreeMap::new();
1843        passwords.insert("app-svc".to_string(), "secret123".to_string());
1844
1845        let result = inject_password_changes(changes, &passwords);
1846        assert_eq!(result.len(), 2);
1847        assert!(matches!(&result[0], Change::Grant { .. }));
1848        assert!(
1849            matches!(&result[1], Change::SetPassword { name, password } if name == "app-svc" && password.starts_with("SCRAM-SHA-256$"))
1850        );
1851    }
1852
1853    #[test]
1854    fn inject_password_empty_passwords_is_noop() {
1855        let changes = vec![Change::CreateRole {
1856            name: "app-svc".to_string(),
1857            state: RoleState::default(),
1858        }];
1859
1860        let passwords = std::collections::BTreeMap::new();
1861        let result = inject_password_changes(changes.clone(), &passwords);
1862        assert_eq!(result.len(), 1);
1863    }
1864
1865    #[test]
1866    fn resolve_passwords_missing_env_var() {
1867        let roles = vec![crate::manifest::RoleDefinition {
1868            name: "app-svc".to_string(),
1869            external: false,
1870            login: Some(true),
1871            password: Some(crate::manifest::PasswordSource {
1872                from_env: "PGROLES_TEST_MISSING_VAR_9a8b7c6d".to_string(),
1873            }),
1874            password_valid_until: None,
1875            superuser: None,
1876            createdb: None,
1877            createrole: None,
1878            inherit: None,
1879            replication: None,
1880            bypassrls: None,
1881            connection_limit: None,
1882            comment: None,
1883        }];
1884
1885        // Ensure the env var does not exist.
1886        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1887        unsafe { std::env::remove_var("PGROLES_TEST_MISSING_VAR_9a8b7c6d") };
1888
1889        let result = resolve_passwords(&roles);
1890        assert!(result.is_err());
1891        let err = result.unwrap_err();
1892        assert!(
1893            matches!(err, PasswordResolutionError::MissingEnvVar { ref role, ref env_var }
1894                if role == "app-svc" && env_var == "PGROLES_TEST_MISSING_VAR_9a8b7c6d"),
1895            "expected MissingEnvVar, got: {err:?}"
1896        );
1897    }
1898
1899    #[test]
1900    fn resolve_passwords_empty_env_var() {
1901        let roles = vec![crate::manifest::RoleDefinition {
1902            name: "app-svc".to_string(),
1903            external: false,
1904            login: Some(true),
1905            password: Some(crate::manifest::PasswordSource {
1906                from_env: "PGROLES_TEST_EMPTY_VAR_1a2b3c4d".to_string(),
1907            }),
1908            password_valid_until: None,
1909            superuser: None,
1910            createdb: None,
1911            createrole: None,
1912            inherit: None,
1913            replication: None,
1914            bypassrls: None,
1915            connection_limit: None,
1916            comment: None,
1917        }];
1918
1919        // Set the env var to an empty string.
1920        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1921        unsafe { std::env::set_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d", "") };
1922
1923        let result = resolve_passwords(&roles);
1924
1925        // Clean up.
1926        unsafe { std::env::remove_var("PGROLES_TEST_EMPTY_VAR_1a2b3c4d") };
1927
1928        assert!(result.is_err());
1929        let err = result.unwrap_err();
1930        assert!(
1931            matches!(err, PasswordResolutionError::EmptyPassword { ref role, ref env_var }
1932                if role == "app-svc" && env_var == "PGROLES_TEST_EMPTY_VAR_1a2b3c4d"),
1933            "expected EmptyPassword, got: {err:?}"
1934        );
1935    }
1936
1937    #[test]
1938    fn resolve_passwords_happy_path() {
1939        let roles = vec![crate::manifest::RoleDefinition {
1940            name: "app-svc".to_string(),
1941            external: false,
1942            login: Some(true),
1943            password: Some(crate::manifest::PasswordSource {
1944                from_env: "PGROLES_TEST_RESOLVE_VAR_5e6f7g8h".to_string(),
1945            }),
1946            password_valid_until: None,
1947            superuser: None,
1948            createdb: None,
1949            createrole: None,
1950            inherit: None,
1951            replication: None,
1952            bypassrls: None,
1953            connection_limit: None,
1954            comment: None,
1955        }];
1956
1957        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1958        unsafe { std::env::set_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h", "my_secret_pw") };
1959
1960        let result = resolve_passwords(&roles);
1961
1962        unsafe { std::env::remove_var("PGROLES_TEST_RESOLVE_VAR_5e6f7g8h") };
1963
1964        let resolved = result.expect("should succeed");
1965        assert_eq!(resolved.len(), 1);
1966        assert_eq!(resolved["app-svc"], "my_secret_pw");
1967    }
1968
1969    #[test]
1970    fn resolve_passwords_skips_external_roles() {
1971        let roles = vec![crate::manifest::RoleDefinition {
1972            name: "external-svc".to_string(),
1973            external: true,
1974            login: Some(true),
1975            password: Some(crate::manifest::PasswordSource {
1976                from_env: "PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h".to_string(),
1977            }),
1978            password_valid_until: None,
1979            superuser: None,
1980            createdb: None,
1981            createrole: None,
1982            inherit: None,
1983            replication: None,
1984            bypassrls: None,
1985            connection_limit: None,
1986            comment: None,
1987        }];
1988
1989        // SAFETY: test-only, unique var name avoids conflicts with parallel tests.
1990        unsafe { std::env::remove_var("PGROLES_TEST_EXTERNAL_MISSING_VAR_2b4d6f8h") };
1991
1992        let resolved = resolve_passwords(&roles).expect("external role passwords are ignored");
1993        assert!(resolved.is_empty());
1994    }
1995
1996    #[test]
1997    fn resolve_passwords_skips_roles_without_password() {
1998        let roles = vec![crate::manifest::RoleDefinition {
1999            name: "no-password".to_string(),
2000            external: false,
2001            login: Some(true),
2002            password: None,
2003            password_valid_until: None,
2004            superuser: None,
2005            createdb: None,
2006            createrole: None,
2007            inherit: None,
2008            replication: None,
2009            bypassrls: None,
2010            connection_limit: None,
2011            comment: None,
2012        }];
2013
2014        let result = resolve_passwords(&roles);
2015        let resolved = result.expect("should succeed");
2016        assert!(resolved.is_empty());
2017    }
2018
2019    #[test]
2020    fn inject_password_multiple_roles() {
2021        let changes = vec![
2022            Change::CreateRole {
2023                name: "role-a".to_string(),
2024                state: RoleState::default(),
2025            },
2026            Change::CreateRole {
2027                name: "role-b".to_string(),
2028                state: RoleState::default(),
2029            },
2030            Change::Grant {
2031                role: "role-c".to_string(),
2032                privileges: BTreeSet::from([crate::manifest::Privilege::Select]),
2033                object_type: crate::manifest::ObjectType::Table,
2034                schema: Some("public".to_string()),
2035                name: Some("*".to_string()),
2036            },
2037        ];
2038
2039        let mut passwords = std::collections::BTreeMap::new();
2040        passwords.insert("role-a".to_string(), "pw-a".to_string());
2041        passwords.insert("role-b".to_string(), "pw-b".to_string());
2042        passwords.insert("role-c".to_string(), "pw-c".to_string());
2043
2044        let result = inject_password_changes(changes, &passwords);
2045
2046        // role-a: CreateRole, SetPassword (inline)
2047        // role-b: CreateRole, SetPassword (inline)
2048        // role-c: Grant (existing role — SetPassword appended at end)
2049        assert_eq!(result.len(), 6, "expected 6 changes, got: {result:?}");
2050        assert!(matches!(&result[0], Change::CreateRole { name, .. } if name == "role-a"));
2051        assert!(matches!(&result[1], Change::SetPassword { name, .. } if name == "role-a"));
2052        assert!(matches!(&result[2], Change::CreateRole { name, .. } if name == "role-b"));
2053        assert!(matches!(&result[3], Change::SetPassword { name, .. } if name == "role-b"));
2054        assert!(matches!(&result[4], Change::Grant { .. }));
2055        assert!(matches!(&result[5], Change::SetPassword { name, .. } if name == "role-c"));
2056    }
2057
2058    #[test]
2059    fn diff_detects_valid_until_change() {
2060        let mut current = empty_graph();
2061        current.roles.insert(
2062            "r1".to_string(),
2063            RoleState {
2064                login: true,
2065                ..RoleState::default()
2066            },
2067        );
2068
2069        let mut desired = empty_graph();
2070        desired.roles.insert(
2071            "r1".to_string(),
2072            RoleState {
2073                login: true,
2074                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2075                ..RoleState::default()
2076            },
2077        );
2078
2079        let changes = diff(&current, &desired);
2080        assert_eq!(changes.len(), 1);
2081        match &changes[0] {
2082            Change::AlterRole { name, attributes } => {
2083                assert_eq!(name, "r1");
2084                assert!(attributes.contains(&RoleAttribute::ValidUntil(Some(
2085                    "2025-12-31T00:00:00Z".to_string()
2086                ))));
2087            }
2088            other => panic!("expected AlterRole, got: {other:?}"),
2089        }
2090    }
2091
2092    /// Reproduces a production reconcile flap: when the desired
2093    /// graph has a wildcard grant `(role, schema, type, "*")` and `current`
2094    /// has only per-name entries (because the inspector's wildcard collapse
2095    /// failed — typically because at least one inventory object lacks the
2096    /// privilege, e.g. a function that was DROPped+CREATEd between reconciles
2097    /// resetting its proacl to NULL), `diff()` must NOT emit per-name REVOKEs
2098    /// for objects covered by the desired wildcard. Otherwise apply order
2099    /// (GRANTs before REVOKEs) re-grants on ALL ROUTINES and then strips
2100    /// privileges from the previously-granted set, producing a permanent
2101    /// oscillation between two stable states.
2102    #[test]
2103    fn diff_does_not_revoke_per_name_grants_covered_by_desired_wildcard() {
2104        let role = "cdc-editor".to_string();
2105        let schema = "cdc".to_string();
2106        let object_type = ObjectType::Function;
2107
2108        // current: per-name EXECUTE grants for f1 and f3 only — f2 was
2109        // recreated externally (proacl=NULL) so the inspector did not produce
2110        // a row for it, the wildcard collapse failed, and per-name entries
2111        // remain in the graph.
2112        let mut current = empty_graph();
2113        for fn_name in ["f1()", "f3()"] {
2114            current.grants.insert(
2115                GrantKey {
2116                    role: role.clone(),
2117                    object_type,
2118                    schema: Some(schema.clone()),
2119                    name: Some(fn_name.to_string()),
2120                },
2121                GrantState {
2122                    privileges: BTreeSet::from([Privilege::Execute]),
2123                },
2124            );
2125        }
2126
2127        // desired: a single wildcard grant declaring EXECUTE on every function
2128        // in the schema.
2129        let mut desired = empty_graph();
2130        desired.grants.insert(
2131            GrantKey {
2132                role: role.clone(),
2133                object_type,
2134                schema: Some(schema.clone()),
2135                name: Some("*".to_string()),
2136            },
2137            GrantState {
2138                privileges: BTreeSet::from([Privilege::Execute]),
2139            },
2140        );
2141
2142        let changes = diff(&current, &desired);
2143
2144        let revokes: Vec<_> = changes
2145            .iter()
2146            .filter(|c| matches!(c, Change::Revoke { .. }))
2147            .collect();
2148        assert!(
2149            revokes.is_empty(),
2150            "must not revoke per-name grants covered by desired wildcard \
2151             (would cause apply-order flap); got: {revokes:#?}"
2152        );
2153
2154        let grants: Vec<_> = changes
2155            .iter()
2156            .filter(|c| matches!(c, Change::Grant { .. }))
2157            .collect();
2158        assert_eq!(
2159            grants.len(),
2160            1,
2161            "expected a single wildcard GRANT to materialise ACLs on all functions; got: {grants:#?}"
2162        );
2163        match grants[0] {
2164            Change::Grant {
2165                role: r,
2166                name,
2167                privileges,
2168                ..
2169            } => {
2170                assert_eq!(r, &role);
2171                assert_eq!(name.as_deref(), Some("*"));
2172                assert!(privileges.contains(&Privilege::Execute));
2173            }
2174            other => panic!("expected wildcard Grant, got: {other:?}"),
2175        }
2176    }
2177
2178    /// Companion to the absent-key flap test above: the matched-key branch
2179    /// of `diff_grants` (where current and desired share a per-name entry)
2180    /// must also subtract desired-wildcard privileges from the revoke set.
2181    /// Concrete shape: a manifest combines `table * SELECT` (wildcard) with
2182    /// `widgets INSERT` (per-object extra). If wildcard collapse fails and
2183    /// `current` carries `widgets {SELECT, INSERT}`, the matched-key diff
2184    /// computes `to_remove = {SELECT}` against desired `widgets {INSERT}`
2185    /// — but SELECT is still declared by the wildcard, so revoking it here
2186    /// produces the same apply-order hazard (GRANT * SELECT, then
2187    /// REVOKE widgets SELECT → widgets ends up with INSERT only, the
2188    /// wildcard is unsatisfied, the next reconcile inverts again).
2189    #[test]
2190    fn diff_does_not_revoke_extra_privileges_covered_by_desired_wildcard() {
2191        let role = "viewer".to_string();
2192        let schema = "myschema".to_string();
2193        let object_type = ObjectType::Table;
2194
2195        // current: widgets has the wildcard's SELECT plus the extra INSERT.
2196        // The wildcard's `(*)` key is absent from current (collapse failed).
2197        let mut current = empty_graph();
2198        current.grants.insert(
2199            GrantKey {
2200                role: role.clone(),
2201                object_type,
2202                schema: Some(schema.clone()),
2203                name: Some("widgets".to_string()),
2204            },
2205            GrantState {
2206                privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
2207            },
2208        );
2209
2210        // desired: wildcard SELECT plus per-object widgets INSERT.
2211        let mut desired = empty_graph();
2212        desired.grants.insert(
2213            GrantKey {
2214                role: role.clone(),
2215                object_type,
2216                schema: Some(schema.clone()),
2217                name: Some("*".to_string()),
2218            },
2219            GrantState {
2220                privileges: BTreeSet::from([Privilege::Select]),
2221            },
2222        );
2223        desired.grants.insert(
2224            GrantKey {
2225                role: role.clone(),
2226                object_type,
2227                schema: Some(schema.clone()),
2228                name: Some("widgets".to_string()),
2229            },
2230            GrantState {
2231                privileges: BTreeSet::from([Privilege::Insert]),
2232            },
2233        );
2234
2235        let changes = diff(&current, &desired);
2236
2237        let revokes: Vec<_> = changes
2238            .iter()
2239            .filter(|c| matches!(c, Change::Revoke { .. }))
2240            .collect();
2241        assert!(
2242            revokes.is_empty(),
2243            "must not revoke widgets SELECT — covered by desired wildcard; got: {revokes:#?}"
2244        );
2245
2246        // Should still emit the wildcard GRANT to materialise SELECT on
2247        // every table (the reason the wildcard is unsatisfied in current).
2248        let grants: Vec<_> = changes
2249            .iter()
2250            .filter(|c| matches!(c, Change::Grant { .. }))
2251            .collect();
2252        let has_wildcard_select_grant = grants.iter().any(|c| {
2253            matches!(
2254                c,
2255                Change::Grant {
2256                    name,
2257                    privileges,
2258                    ..
2259                } if name.as_deref() == Some("*")
2260                    && privileges.contains(&Privilege::Select)
2261            )
2262        });
2263        assert!(
2264            has_wildcard_select_grant,
2265            "expected wildcard GRANT for SELECT; got: {grants:#?}"
2266        );
2267    }
2268
2269    #[test]
2270    fn diff_detects_valid_until_removal() {
2271        let mut current = empty_graph();
2272        current.roles.insert(
2273            "r1".to_string(),
2274            RoleState {
2275                login: true,
2276                password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
2277                ..RoleState::default()
2278            },
2279        );
2280
2281        let mut desired = empty_graph();
2282        desired.roles.insert(
2283            "r1".to_string(),
2284            RoleState {
2285                login: true,
2286                ..RoleState::default()
2287            },
2288        );
2289
2290        let changes = diff(&current, &desired);
2291        assert_eq!(changes.len(), 1);
2292        match &changes[0] {
2293            Change::AlterRole { name, attributes } => {
2294                assert_eq!(name, "r1");
2295                assert!(attributes.contains(&RoleAttribute::ValidUntil(None)));
2296            }
2297            other => panic!("expected AlterRole, got: {other:?}"),
2298        }
2299    }
2300}