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