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