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