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