Skip to main content

pgroles_core/
suggest.rs

1//! Profile suggestion: deterministically refactor a flat manifest into a
2//! manifest that uses reusable [`Profile`]s.
3//!
4//! ## Algorithm
5//!
6//! 1. Bucket grants and default privileges by role.
7//! 2. Classify each role:
8//!    - Eligible if it touches exactly one *declared* schema, has no role
9//!      attributes that profiles can't express (only `login` / `inherit` are
10//!      promotable), and every default privilege uses that schema's owner.
11//! 3. Compute a *schema-relative signature* for each eligible role — the
12//!    grants and default privileges with the schema replaced by a placeholder.
13//! 4. Cluster eligible roles by `(signature, login, inherit)`.
14//! 5. For each cluster of `>= min_schemas` schemas, pick a uniform role-name
15//!    pattern (`{schema}-{profile}` etc.) such that every member maps the
16//!    same way. Profile name is the shared portion.
17//! 6. Resolve per-schema role-pattern conflicts by giving the first cluster
18//!    (in deterministic iteration order) priority and dropping the rest.
19//! 7. Build a candidate manifest with the extracted profiles.
20//! 8. **Round-trip safety check**: re-expand the new manifest into a
21//!    [`RoleGraph`] and diff it against the original. The only acceptable
22//!    differences are auto-generated role comments (profile expansion
23//!    annotates each generated role). Any other delta means we'd silently
24//!    change semantics — fall back to the original manifest.
25//!
26//! The result is **deterministic**: the same input always produces the same
27//! output. No LLM, no heuristics that depend on iteration order of unstable
28//! collections.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use crate::diff::{Change, diff};
33use crate::manifest::{
34    DefaultPrivilege, DefaultPrivilegeGrant, Grant, ObjectTarget, ObjectType, PolicyManifest,
35    Privilege, Profile, ProfileGrant, ProfileObjectTarget, RoleDefinition, SchemaBinding,
36    expand_manifest,
37};
38use crate::model::RoleGraph;
39
40/// Knobs for the suggester. The defaults are conservative.
41#[derive(Debug, Clone)]
42pub struct SuggestOptions {
43    /// Minimum number of distinct schemas a candidate cluster must span before
44    /// it becomes a profile. Below this, the original flat roles are kept.
45    /// Default `2` — a profile with one schema is just an indirection.
46    pub min_schemas: usize,
47
48    /// Complete object inventory `(schema, object_type) → set of names`,
49    /// **as observed in the live database** (i.e. from
50    /// [`pgroles_inspect::fetch_object_inventory`]). When provided, the
51    /// suggester collapses per-name grants into wildcards (`name: "*"`) for
52    /// `(schema, object_type)` buckets where a role covers every object,
53    /// which is what makes profile clustering across schemas useful for
54    /// `pgroles generate` output (Postgres expands `GRANT … ON ALL TABLES`
55    /// into per-relation rows).
56    ///
57    /// **Why required**: a grant-derived inventory would treat ungranted
58    /// objects as nonexistent. A role granted on every *currently-granted*
59    /// table would collapse to `name: "*"`, and applying the suggested
60    /// manifest would silently grant on previously-ungranted tables —
61    /// broadening privileges beyond the original manifest's intent. With a
62    /// real introspected inventory we know what *exists* vs what's
63    /// *granted*, so the collapse is sound.
64    ///
65    /// `None` (default) disables wildcard collapse entirely. Roles only
66    /// cluster when their grants reference identical literal names.
67    pub full_inventory: Option<Inventory>,
68}
69
70impl Default for SuggestOptions {
71    fn default() -> Self {
72        Self {
73            min_schemas: 2,
74            full_inventory: None,
75        }
76    }
77}
78
79/// A single profile that the suggester extracted.
80#[derive(Debug, Clone)]
81pub struct SuggestedProfile {
82    pub profile_name: String,
83    pub role_pattern: String,
84    /// Schema → original role name now produced by `profile × schema` expansion.
85    pub schema_to_role: BTreeMap<String, String>,
86}
87
88/// Why a particular role wasn't promoted into a profile.
89#[derive(Debug, Clone)]
90pub enum SkipReason {
91    /// Role's grants/default-privs touch more than one schema.
92    MultiSchema { role: String, schemas: Vec<String> },
93    /// Role references a schema that isn't declared in `schemas:`.
94    SchemaNotDeclared { role: String, schema: String },
95    /// Role has a default privilege whose owner doesn't match the schema's owner.
96    OwnerMismatch { role: String, schema: String },
97    /// Role has role attributes (superuser, connection_limit, ...) that profiles
98    /// can't express.
99    UniqueAttributes { role: String },
100    /// Role has grants on cluster-wide objects (database, etc.) that profiles
101    /// can't express.
102    UnrepresentableGrant { role: String },
103    /// Cluster spans fewer than `min_schemas` schemas.
104    SoleSchema { role: String, schema: String },
105    /// Couldn't find a role-pattern that all cluster members agree on.
106    NoUniformPattern { roles: Vec<String> },
107    /// Two clusters wanted to bind to the same schema with different patterns.
108    SchemaPatternConflict {
109        schema: String,
110        winning_pattern: String,
111        dropped_roles: Vec<String>,
112    },
113    /// The candidate manifest didn't round-trip cleanly; we abandoned it.
114    RoundTripFailure { reason: String },
115    /// The provided `full_inventory` was missing object names that already
116    /// appear in the input's flat grants — a sure sign the inventory wasn't
117    /// sourced from a complete introspection. Wildcard collapse was
118    /// disabled for safety.
119    IncompleteFullInventory { reason: String },
120}
121
122/// What [`suggest_profiles`] returns: the new manifest, the profiles it built,
123/// and the reasons each non-promoted role stayed flat.
124#[derive(Debug, Clone)]
125pub struct SuggestReport {
126    pub manifest: PolicyManifest,
127    pub profiles: Vec<SuggestedProfile>,
128    pub skipped: Vec<SkipReason>,
129    /// `true` if the new manifest round-trips identically (modulo
130    /// auto-generated role comments) to the input.
131    pub round_trip_ok: bool,
132}
133
134/// Run the suggester. Always returns; if anything goes wrong the original
135/// manifest is returned unchanged with `round_trip_ok = false`.
136pub fn suggest_profiles(input: &PolicyManifest, opts: &SuggestOptions) -> SuggestReport {
137    // If the input already has profiles, the user has already curated this
138    // manifest. Don't overwrite their work.
139    if !input.profiles.is_empty() {
140        return SuggestReport {
141            manifest: input.clone(),
142            profiles: vec![],
143            skipped: vec![],
144            round_trip_ok: true,
145        };
146    }
147
148    let mut skipped: Vec<SkipReason> = Vec::new();
149
150    // --- Inventory ---------------------------------------------------------
151    //
152    // Two distinct inventories are involved:
153    //   * `full_inventory` (caller-provided, from live DB introspection):
154    //     authoritative list of every object that *exists*. Required to
155    //     safely collapse per-name grants into a wildcard, because we need
156    //     to know whether a role covers *every* object — not just every
157    //     object that happens to appear in some grant.
158    //   * `grant_inventory` (always built from the input's grants): the
159    //     domain over which wildcard grants in the candidate manifest must
160    //     be expanded for the round-trip diff. This is what guarantees the
161    //     candidate's wildcard expression matches the original's per-name
162    //     entries, regardless of whether collapse ran.
163    let grant_inventory = build_inventory(input);
164    // Defense-in-depth: if a caller hands us a `full_inventory` that's
165    // demonstrably incomplete (missing object names that already appear in
166    // the input's per-name grants), we can't trust it for collapse. Disable
167    // collapse and surface the issue. This catches accidental misuse like
168    // passing `inventory_from_manifest_grants(manifest)` as `full_inventory`.
169    let collapse_inventory: Option<&Inventory> = match opts.full_inventory.as_ref() {
170        None => None,
171        Some(full) => match validate_full_inventory(&grant_inventory, full) {
172            Ok(()) => Some(full),
173            Err(reason) => {
174                skipped.push(SkipReason::IncompleteFullInventory { reason });
175                None
176            }
177        },
178    };
179
180    // --- Bucket grants and default privileges by grantee role ---------------
181
182    let mut role_grants: BTreeMap<String, Vec<Grant>> = BTreeMap::new();
183    for grant in &input.grants {
184        role_grants
185            .entry(grant.role.clone())
186            .or_default()
187            .push(grant.clone());
188    }
189
190    // Collapse per-role per-name grants that fully cover their (schema,
191    // object_type) bucket — only when a real introspected inventory is
192    // available. Without it, "full coverage" can't be soundly determined.
193    if let Some(inv) = collapse_inventory {
194        for grants in role_grants.values_mut() {
195            collapse_full_coverage_grants(grants, inv);
196        }
197    }
198
199    // Each (role, schema) → (owner, Vec<DefaultPrivilegeGrant>)
200    // We keep the owner so we can compare it against the schema owner later.
201    let mut role_dps: BTreeMap<String, Vec<(String, String, DefaultPrivilegeGrant)>> =
202        BTreeMap::new();
203    for dp in &input.default_privileges {
204        let owner = dp
205            .owner
206            .clone()
207            .or_else(|| input.default_owner.clone())
208            .unwrap_or_default();
209        for grant in &dp.grant {
210            if let Some(role) = &grant.role {
211                role_dps.entry(role.clone()).or_default().push((
212                    owner.clone(),
213                    dp.schema.clone(),
214                    grant.clone(),
215                ));
216            }
217        }
218    }
219
220    // --- Index schemas ------------------------------------------------------
221
222    let schema_owner: BTreeMap<String, Option<String>> = input
223        .schemas
224        .iter()
225        .map(|s| {
226            (
227                s.name.clone(),
228                s.owner.clone().or_else(|| input.default_owner.clone()),
229            )
230        })
231        .collect();
232
233    // --- Classify each role -------------------------------------------------
234
235    /// Eligibility outcome for a single role.
236    struct Eligible {
237        role_name: String,
238        schema: String,
239        signature: RoleSignature,
240        login: Option<bool>,
241        inherit: Option<bool>,
242    }
243
244    let mut eligible: Vec<Eligible> = Vec::new();
245    let mut clustered_role_names: BTreeSet<String> = BTreeSet::new();
246
247    for role_def in &input.roles {
248        let role_name = &role_def.name;
249
250        // The suggester only promotes `login` and `inherit` into a profile.
251        // Any other explicitly-set attribute disqualifies the role.
252        //
253        // `config` is deliberately excluded even though profiles can express
254        // it: clustering would require comparing config maps modulo
255        // `{schema}`/`{profile}` substitution (a role's `search_path:
256        // inventory` and another's `search_path: checkout` are the "same"
257        // profile-relative value, but nothing else in a config map tells us
258        // which literal segments are schema-derived vs. genuinely distinct).
259        // Getting that wrong silently changes what gets applied, so a
260        // config-carrying role always stays flat — the caller can add
261        // `config` to a suggested profile by hand once it exists.
262        //
263        // Comments are treated as user-set documentation *unless* they match
264        // pgroles' own auto-generated annotation pattern (which `pgroles
265        // apply` writes when expanding a profile). Ignoring auto-comments
266        // makes `--suggest-profiles` idempotent across runs.
267        let has_user_comment = role_def
268            .comment
269            .as_deref()
270            .is_some_and(|c| !is_auto_profile_comment(c));
271        if role_def.superuser.is_some()
272            || role_def.createdb.is_some()
273            || role_def.createrole.is_some()
274            || role_def.replication.is_some()
275            || role_def.bypassrls.is_some()
276            || role_def.connection_limit.is_some()
277            || role_def.password.is_some()
278            || role_def.password_valid_until.is_some()
279            || !role_def.config.is_empty()
280            || has_user_comment
281        {
282            skipped.push(SkipReason::UniqueAttributes {
283                role: role_name.clone(),
284            });
285            continue;
286        }
287
288        // What schemas does this role touch (via grants and DPs)?
289        // Roles with grants that profiles can't express (e.g. database-level
290        // CONNECT) are excluded outright — even if the rest of their grants
291        // would cluster, we'd silently drop the unrepresentable ones.
292        let mut schemas_seen: BTreeSet<String> = BTreeSet::new();
293        let mut has_unrepresentable_grant = false;
294        let role_grants_vec = role_grants.get(role_name).cloned().unwrap_or_default();
295        for g in &role_grants_vec {
296            match g.object.object_type {
297                ObjectType::Schema => match &g.object.name {
298                    Some(name) => {
299                        schemas_seen.insert(name.clone());
300                    }
301                    None => has_unrepresentable_grant = true,
302                },
303                ObjectType::Database => has_unrepresentable_grant = true,
304                _ => match &g.object.schema {
305                    Some(s) => {
306                        schemas_seen.insert(s.clone());
307                    }
308                    None => has_unrepresentable_grant = true,
309                },
310            }
311        }
312        if has_unrepresentable_grant {
313            skipped.push(SkipReason::UnrepresentableGrant {
314                role: role_name.clone(),
315            });
316            continue;
317        }
318        let role_dp_vec = role_dps.get(role_name).cloned().unwrap_or_default();
319        for (_, schema, _) in &role_dp_vec {
320            schemas_seen.insert(schema.clone());
321        }
322
323        // No grants, no default privileges → can't promote, keep flat.
324        if schemas_seen.is_empty() {
325            continue;
326        }
327
328        if schemas_seen.len() > 1 {
329            skipped.push(SkipReason::MultiSchema {
330                role: role_name.clone(),
331                schemas: schemas_seen.into_iter().collect(),
332            });
333            continue;
334        }
335
336        let schema = schemas_seen.into_iter().next().unwrap();
337
338        // The schema must be declared in the manifest (otherwise we can't bind
339        // a profile to it).
340        let Some(owner_for_schema) = schema_owner.get(&schema) else {
341            skipped.push(SkipReason::SchemaNotDeclared {
342                role: role_name.clone(),
343                schema,
344            });
345            continue;
346        };
347
348        // Every default privilege owned-by must equal the schema's owner.
349        let mut owner_mismatch = false;
350        for (owner, _, _) in &role_dp_vec {
351            if Some(owner.as_str()) != owner_for_schema.as_deref() {
352                owner_mismatch = true;
353                break;
354            }
355        }
356        if owner_mismatch {
357            skipped.push(SkipReason::OwnerMismatch {
358                role: role_name.clone(),
359                schema,
360            });
361            continue;
362        }
363
364        let signature = compute_signature(&role_grants_vec, &role_dp_vec, &schema);
365
366        eligible.push(Eligible {
367            role_name: role_name.clone(),
368            schema,
369            signature,
370            login: role_def.login,
371            inherit: role_def.inherit,
372        });
373    }
374
375    // --- Cluster ------------------------------------------------------------
376
377    // Key = (signature, login, inherit). Value = Vec<member>.
378    type ClusterKey = (RoleSignature, Option<bool>, Option<bool>);
379    let mut clusters: BTreeMap<ClusterKey, Vec<&Eligible>> = BTreeMap::new();
380    for el in &eligible {
381        clusters
382            .entry((el.signature.clone(), el.login, el.inherit))
383            .or_default()
384            .push(el);
385    }
386
387    // --- Pattern resolution -------------------------------------------------
388
389    // Iterate clusters in size-descending order so that bigger clusters claim
390    // schema patterns first. Tie-break by signature for determinism.
391    let mut cluster_entries: Vec<_> = clusters.into_iter().collect();
392    cluster_entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
393
394    let pattern_priority = [
395        "{schema}-{profile}",
396        "{schema}_{profile}",
397        "{profile}-{schema}",
398        "{profile}_{schema}",
399    ];
400
401    // schema → committed pattern. Once a cluster lands, the pattern is sticky.
402    let mut schema_pattern: BTreeMap<String, String> = BTreeMap::new();
403    // schema → list of profile names already attached.
404    let mut schema_profiles: BTreeMap<String, Vec<String>> = BTreeMap::new();
405    // profile name → built Profile object.
406    let mut profiles_out: BTreeMap<String, Profile> = BTreeMap::new();
407    // profile name → sources (schema, original role name) for the report.
408    let mut suggested: Vec<SuggestedProfile> = Vec::new();
409    // Profile names already taken (avoid collisions).
410    let mut taken_profile_names: BTreeSet<String> = BTreeSet::new();
411
412    for ((_signature, login, inherit), members) in cluster_entries {
413        // Need at least `min_schemas` distinct schemas.
414        let distinct_schemas: BTreeSet<&str> = members.iter().map(|m| m.schema.as_str()).collect();
415        if distinct_schemas.len() < opts.min_schemas {
416            for m in &members {
417                skipped.push(SkipReason::SoleSchema {
418                    role: m.role_name.clone(),
419                    schema: m.schema.clone(),
420                });
421            }
422            continue;
423        }
424
425        // Sanity: each schema appears at most once in a cluster (otherwise the
426        // signature wouldn't match — distinct grants per role per schema).
427        // Defensive — drop the duplicates.
428        let mut seen_schemas: BTreeSet<&str> = BTreeSet::new();
429        let unique_members: Vec<&Eligible> = members
430            .iter()
431            .filter(|m| seen_schemas.insert(m.schema.as_str()))
432            .copied()
433            .collect();
434
435        // Find a (pattern, profile_name) that all members agree on AND that
436        // doesn't conflict with already-committed schema patterns.
437        //
438        // For diagnostics: when no viable pattern can be chosen, surface
439        // `SchemaPatternConflict` if some pattern *would* have succeeded
440        // except for an already-locked schema; otherwise the failure is a
441        // role-name disagreement / collision and we report `NoUniformPattern`.
442        let mut chosen: Option<(String, String)> = None;
443        // Records the schema/locked-pattern of the first pattern that was
444        // viable in every other respect but blocked by a schema lock.
445        let mut schema_conflict_blocking: Option<(String, String)> = None;
446        for pat in pattern_priority {
447            // Pattern viability ignoring schema lock: do role names match
448            // uniformly, is the resulting profile name a valid identifier,
449            // and is it free?
450            let viable_name: Option<String> = {
451                let mut names: BTreeSet<String> = BTreeSet::new();
452                let mut ok = true;
453                for m in &unique_members {
454                    if let Some(prof) = match_pattern(pat, &m.role_name, &m.schema) {
455                        names.insert(prof);
456                    } else {
457                        ok = false;
458                        break;
459                    }
460                }
461                if !ok || names.len() != 1 {
462                    None
463                } else {
464                    let n = names.into_iter().next().unwrap();
465                    if !is_valid_identifier(&n)
466                        || taken_profile_names.contains(&n)
467                        || input.profiles.contains_key(&n)
468                    {
469                        None
470                    } else {
471                        Some(n)
472                    }
473                }
474            };
475
476            // Is any of this cluster's schemas already locked to a different
477            // pattern?
478            let blocked_by_schema = unique_members.iter().find_map(|m| {
479                schema_pattern
480                    .get(&m.schema)
481                    .filter(|committed| *committed != pat)
482                    .map(|committed| (m.schema.clone(), committed.clone()))
483            });
484
485            match (viable_name, blocked_by_schema) {
486                (Some(name), None) => {
487                    chosen = Some((pat.to_string(), name));
488                    break;
489                }
490                (Some(_), Some(conflict)) if schema_conflict_blocking.is_none() => {
491                    schema_conflict_blocking = Some(conflict);
492                }
493                _ => {}
494            }
495        }
496
497        let Some((pattern, profile_name)) = chosen else {
498            if let Some((schema, winning_pattern)) = schema_conflict_blocking {
499                skipped.push(SkipReason::SchemaPatternConflict {
500                    schema,
501                    winning_pattern,
502                    dropped_roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
503                });
504            } else {
505                skipped.push(SkipReason::NoUniformPattern {
506                    roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
507                });
508            }
509            continue;
510        };
511
512        // Commit the pattern on every schema this cluster touches.
513        for m in &unique_members {
514            schema_pattern.insert(m.schema.clone(), pattern.clone());
515            schema_profiles
516                .entry(m.schema.clone())
517                .or_default()
518                .push(profile_name.clone());
519            clustered_role_names.insert(m.role_name.clone());
520        }
521
522        // Build the Profile from one representative member.
523        let representative = unique_members[0];
524        let rep_grants = role_grants
525            .get(&representative.role_name)
526            .cloned()
527            .unwrap_or_default();
528        let rep_dps = role_dps
529            .get(&representative.role_name)
530            .cloned()
531            .unwrap_or_default();
532
533        let profile = build_profile(
534            login,
535            inherit,
536            &rep_grants,
537            &rep_dps,
538            &representative.schema,
539        );
540
541        profiles_out.insert(profile_name.clone(), profile);
542        taken_profile_names.insert(profile_name.clone());
543
544        let schema_to_role: BTreeMap<String, String> = unique_members
545            .iter()
546            .map(|m| (m.schema.clone(), m.role_name.clone()))
547            .collect();
548        suggested.push(SuggestedProfile {
549            profile_name,
550            role_pattern: pattern,
551            schema_to_role,
552        });
553    }
554
555    // --- Build the candidate output manifest --------------------------------
556
557    let mut new_schemas: Vec<SchemaBinding> = input
558        .schemas
559        .iter()
560        .map(|s| {
561            let mut bound_profiles = schema_profiles.get(&s.name).cloned().unwrap_or_default();
562            bound_profiles.sort();
563            let pattern = schema_pattern
564                .get(&s.name)
565                .cloned()
566                .unwrap_or_else(|| s.role_pattern.clone());
567            SchemaBinding {
568                name: s.name.clone(),
569                profiles: bound_profiles,
570                role_pattern: pattern,
571                owner: s.owner.clone(),
572            }
573        })
574        .collect();
575    new_schemas.sort_by(|a, b| a.name.cmp(&b.name));
576
577    let new_roles: Vec<RoleDefinition> = input
578        .roles
579        .iter()
580        .filter(|r| !clustered_role_names.contains(&r.name))
581        .cloned()
582        .collect();
583
584    let new_grants: Vec<Grant> = input
585        .grants
586        .iter()
587        .filter(|g| !clustered_role_names.contains(&g.role))
588        .cloned()
589        .collect();
590
591    let new_default_privileges: Vec<DefaultPrivilege> = input
592        .default_privileges
593        .iter()
594        .filter_map(|dp| {
595            let kept: Vec<DefaultPrivilegeGrant> = dp
596                .grant
597                .iter()
598                .filter(|g| match &g.role {
599                    Some(r) => !clustered_role_names.contains(r),
600                    None => true,
601                })
602                .cloned()
603                .collect();
604            if kept.is_empty() {
605                None
606            } else {
607                Some(DefaultPrivilege {
608                    owner: dp.owner.clone(),
609                    schema: dp.schema.clone(),
610                    grant: kept,
611                })
612            }
613        })
614        .collect();
615
616    let candidate = PolicyManifest {
617        default_owner: input.default_owner.clone(),
618        auth_providers: input.auth_providers.clone(),
619        profiles: profiles_out,
620        schemas: new_schemas,
621        roles: new_roles,
622        grants: new_grants,
623        default_privileges: new_default_privileges,
624        memberships: input.memberships.clone(),
625        retirements: input.retirements.clone(),
626    };
627
628    // --- Round-trip safety check -------------------------------------------
629
630    // Round-trip wildcard expansion uses the most authoritative inventory
631    // available. With a full introspected inventory we expand against the
632    // *real* set of objects in each schema; otherwise we fall back to the
633    // grant-derived view (sufficient when collapse didn't run).
634    let round_trip_inventory = collapse_inventory.cloned().unwrap_or(grant_inventory);
635    let round_trip_ok = match check_round_trip(input, &candidate, &round_trip_inventory) {
636        Ok(()) => true,
637        Err(reason) => {
638            skipped.push(SkipReason::RoundTripFailure {
639                reason: reason.clone(),
640            });
641            false
642        }
643    };
644
645    let manifest = if round_trip_ok {
646        candidate
647    } else {
648        input.clone()
649    };
650
651    SuggestReport {
652        manifest,
653        profiles: if round_trip_ok { suggested } else { vec![] },
654        skipped,
655        round_trip_ok,
656    }
657}
658
659// ---------------------------------------------------------------------------
660// Object-name inventory (for full-coverage collapse and round-trip check)
661// ---------------------------------------------------------------------------
662
663/// `(schema, object_type) → set of object names` referenced in the manifest's
664/// flat grants. For schema-typed grants the schema is the grant's `name`
665/// field; the inventory stores no entries for `Schema` (those are 1:1).
666pub type Inventory = BTreeMap<(String, ObjectType), BTreeSet<String>>;
667
668/// Build a `(schema, object_type) → set of names` map from the flat grants
669/// in a manifest. Wildcards (`name: "*"`) and schema/database-typed grants
670/// are excluded.
671///
672/// **Do not pass this to [`SuggestOptions::full_inventory`].** A grant-only
673/// view treats ungranted objects as nonexistent, and would let
674/// `collapse_full_coverage_grants` silently broaden privileges. This
675/// function exists for the wildcard-aware round-trip comparison the
676/// suggester uses internally — re-exported so test code can perform the
677/// same comparison. Production callers should source `full_inventory` from
678/// [`pgroles_inspect::fetch_object_inventory`].
679pub fn inventory_from_manifest_grants(m: &PolicyManifest) -> Inventory {
680    build_inventory(m)
681}
682
683/// Deprecated alias for [`inventory_from_manifest_grants`].
684#[deprecated(
685    note = "renamed to `inventory_from_manifest_grants` — must NOT be used as full_inventory"
686)]
687pub fn build_inventory_pub(m: &PolicyManifest) -> Inventory {
688    build_inventory(m)
689}
690
691/// Replace each `name: "*"` table/sequence/function/etc. grant with one named
692/// grant per entry in `inventory[(schema, object_type)]`. Schema- and
693/// database-typed grants are passed through. Mutates `grants` in place.
694pub fn expand_wildcard_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
695    expand_wildcards_in_place(grants, inventory)
696}
697
698/// Verify that every object name appearing in `grant_inventory` (i.e. every
699/// per-name grant referenced in the flat manifest) is also present in
700/// `full_inventory`. If a granted object is missing from the supposedly
701/// "full" inventory, the inventory is provably incomplete — likely the
702/// caller passed a grant-derived view by mistake.
703fn validate_full_inventory(
704    grant_inventory: &Inventory,
705    full_inventory: &Inventory,
706) -> Result<(), String> {
707    for (key, granted_names) in grant_inventory {
708        let Some(full_names) = full_inventory.get(key) else {
709            return Err(format!(
710                "full_inventory missing entry for (schema={}, type={:?}) — but {} object name(s) are referenced in input grants",
711                key.0,
712                key.1,
713                granted_names.len()
714            ));
715        };
716        if let Some(missing) = granted_names.iter().find(|n| !full_names.contains(*n)) {
717            return Err(format!(
718                "full_inventory[(schema={}, type={:?})] does not contain {missing:?} but it appears in input grants",
719                key.0, key.1
720            ));
721        }
722    }
723    Ok(())
724}
725
726fn build_inventory(m: &PolicyManifest) -> Inventory {
727    let mut inv: Inventory = BTreeMap::new();
728    for g in &m.grants {
729        match g.object.object_type {
730            ObjectType::Schema | ObjectType::Database => continue,
731            _ => {}
732        }
733        let Some(name) = g.object.name.as_ref() else {
734            continue;
735        };
736        if name == "*" {
737            continue;
738        }
739        let Some(schema) = g.object.schema.as_ref() else {
740            continue;
741        };
742        inv.entry((schema.clone(), g.object.object_type))
743            .or_default()
744            .insert(name.clone());
745    }
746    inv
747}
748
749/// Replace per-name grants with a single wildcard grant when a role's grants
750/// fully cover every object of a given `(schema, object_type)` with identical
751/// privileges. Mutates `grants` in place.
752fn collapse_full_coverage_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
753    // Group grants by (schema, object_type). Skip schema-typed grants — they
754    // have a 1:1 mapping with the schema name and don't need collapsing.
755    // Track which (schema, type) buckets already have a wildcard grant —
756    // those cannot be collapsed (would produce two wildcards on the same
757    // GrantKey, which the model can't hold).
758    let mut buckets: BTreeMap<(String, ObjectType), Vec<usize>> = BTreeMap::new();
759    let mut has_wildcard: BTreeSet<(String, ObjectType)> = BTreeSet::new();
760    for (i, g) in grants.iter().enumerate() {
761        match g.object.object_type {
762            ObjectType::Schema | ObjectType::Database => continue,
763            _ => {}
764        }
765        let Some(schema) = g.object.schema.as_ref() else {
766            continue;
767        };
768        let Some(name) = g.object.name.as_ref() else {
769            continue;
770        };
771        if name == "*" {
772            has_wildcard.insert((schema.clone(), g.object.object_type));
773            continue;
774        }
775        buckets
776            .entry((schema.clone(), g.object.object_type))
777            .or_default()
778            .push(i);
779    }
780    buckets.retain(|key, _| !has_wildcard.contains(key));
781
782    let mut to_remove: BTreeSet<usize> = BTreeSet::new();
783    let mut to_add: Vec<Grant> = Vec::new();
784
785    for ((schema, object_type), idxs) in buckets {
786        // All entries must share the same privilege set.
787        let first_privs = canonical_privs(&grants[idxs[0]].privileges);
788        let all_same = idxs
789            .iter()
790            .all(|&i| canonical_privs(&grants[i].privileges) == first_privs);
791        if !all_same {
792            continue;
793        }
794        // Collected names must equal the inventory for that (schema, type).
795        let mut covered: BTreeSet<String> = BTreeSet::new();
796        for &i in &idxs {
797            if let Some(name) = grants[i].object.name.as_ref() {
798                covered.insert(name.clone());
799            }
800        }
801        let inv_names = inventory.get(&(schema.clone(), object_type));
802        let full_coverage = match inv_names {
803            Some(names) => &covered == names,
804            None => false,
805        };
806        if !full_coverage {
807            continue;
808        }
809        // Collapse: remove all per-name entries; add one wildcard.
810        for &i in &idxs {
811            to_remove.insert(i);
812        }
813        let role = grants[idxs[0]].role.clone();
814        to_add.push(Grant {
815            role,
816            privileges: first_privs.into_iter().collect(),
817            object: ObjectTarget {
818                object_type,
819                schema: Some(schema),
820                name: Some("*".to_string()),
821            },
822        });
823    }
824
825    // Apply removals (in reverse order) and additions.
826    let mut remaining = Vec::with_capacity(grants.len() - to_remove.len() + to_add.len());
827    for (i, g) in grants.drain(..).enumerate() {
828        if !to_remove.contains(&i) {
829            remaining.push(g);
830        }
831    }
832    remaining.extend(to_add);
833    *grants = remaining;
834}
835
836fn canonical_privs(privs: &[Privilege]) -> Vec<Privilege> {
837    let mut out = privs.to_vec();
838    out.sort_by_key(|p| privilege_sort_key(*p));
839    out.dedup();
840    out
841}
842
843// ---------------------------------------------------------------------------
844// Internals
845// ---------------------------------------------------------------------------
846
847/// Schema-relative signature: the set of grants and default privileges with
848/// the schema replaced by a placeholder. Stored as a sorted Vec so the type
849/// implements `Ord` for use as a `BTreeMap` key.
850#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
851struct RoleSignature {
852    grants: Vec<SignatureGrant>,
853    defaults: Vec<SignatureDefault>,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
857struct SignatureGrant {
858    object_type: ObjectType,
859    /// `None` for schema-typed grants, otherwise the object name (e.g. `"*"`).
860    name: Option<String>,
861    privileges: Vec<Privilege>,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
865struct SignatureDefault {
866    on_type: ObjectType,
867    privileges: Vec<Privilege>,
868}
869
870fn compute_signature(
871    grants: &[Grant],
872    dps: &[(String, String, DefaultPrivilegeGrant)],
873    schema: &str,
874) -> RoleSignature {
875    let mut sig_grants: Vec<SignatureGrant> = grants
876        .iter()
877        .map(|g| {
878            let name = match g.object.object_type {
879                // Schema-typed grants have schema as `name`. Drop it — the
880                // signature is schema-relative.
881                ObjectType::Schema => {
882                    if g.object.name.as_deref() == Some(schema) {
883                        None
884                    } else {
885                        // Pointed at a *different* schema — preserve the
886                        // literal name to keep the signature distinct.
887                        g.object.name.clone()
888                    }
889                }
890                _ => g.object.name.clone(),
891            };
892            let mut privs = g.privileges.clone();
893            privs.sort_by_key(|p| privilege_sort_key(*p));
894            privs.dedup();
895            SignatureGrant {
896                object_type: g.object.object_type,
897                name,
898                privileges: privs,
899            }
900        })
901        .collect();
902    sig_grants.sort();
903    sig_grants.dedup();
904
905    let mut sig_defaults: Vec<SignatureDefault> = dps
906        .iter()
907        .map(|(_, _, dpg)| {
908            let mut privs = dpg.privileges.clone();
909            privs.sort_by_key(|p| privilege_sort_key(*p));
910            privs.dedup();
911            SignatureDefault {
912                on_type: dpg.on_type,
913                privileges: privs,
914            }
915        })
916        .collect();
917    sig_defaults.sort();
918    sig_defaults.dedup();
919
920    RoleSignature {
921        grants: sig_grants,
922        defaults: sig_defaults,
923    }
924}
925
926fn privilege_sort_key(p: Privilege) -> u8 {
927    match p {
928        Privilege::Select => 0,
929        Privilege::Insert => 1,
930        Privilege::Update => 2,
931        Privilege::Delete => 3,
932        Privilege::Truncate => 4,
933        Privilege::References => 5,
934        Privilege::Trigger => 6,
935        Privilege::Execute => 7,
936        Privilege::Usage => 8,
937        Privilege::Create => 9,
938        Privilege::Connect => 10,
939        Privilege::Temporary => 11,
940    }
941}
942
943fn match_pattern(pattern: &str, role_name: &str, schema: &str) -> Option<String> {
944    match pattern {
945        "{schema}-{profile}" => role_name
946            .strip_prefix(schema)
947            .and_then(|r| r.strip_prefix('-'))
948            .filter(|p| !p.is_empty())
949            .map(|p| p.to_string()),
950        "{schema}_{profile}" => role_name
951            .strip_prefix(schema)
952            .and_then(|r| r.strip_prefix('_'))
953            .filter(|p| !p.is_empty())
954            .map(|p| p.to_string()),
955        "{profile}-{schema}" => role_name
956            .strip_suffix(schema)
957            .and_then(|r| r.strip_suffix('-'))
958            .filter(|p| !p.is_empty())
959            .map(|p| p.to_string()),
960        "{profile}_{schema}" => role_name
961            .strip_suffix(schema)
962            .and_then(|r| r.strip_suffix('_'))
963            .filter(|p| !p.is_empty())
964            .map(|p| p.to_string()),
965        _ => None,
966    }
967}
968
969/// Recognise the auto-generated role comment that `expand_manifest` writes
970/// when materializing a `profile × schema` role. Format:
971/// `"Generated from profile 'X' for schema 'Y'"`.
972fn is_auto_profile_comment(c: &str) -> bool {
973    c.starts_with("Generated from profile '") && c.contains("' for schema '") && c.ends_with('\'')
974}
975
976fn is_valid_identifier(s: &str) -> bool {
977    !s.is_empty()
978        && s.chars()
979            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
980        && !s.starts_with('-')
981        && !s.starts_with('_')
982}
983
984fn build_profile(
985    login: Option<bool>,
986    inherit: Option<bool>,
987    grants: &[Grant],
988    dps: &[(String, String, DefaultPrivilegeGrant)],
989    #[cfg_attr(not(debug_assertions), allow(unused_variables))] schema: &str,
990) -> Profile {
991    // Build profile grants in a deterministic order.
992    let mut profile_grants: Vec<ProfileGrant> = grants
993        .iter()
994        .map(|g| {
995            let object = match g.object.object_type {
996                ObjectType::Schema => ProfileObjectTarget {
997                    object_type: ObjectType::Schema,
998                    // Profile expansion ignores `name` for schema-typed grants
999                    // (it always uses the schema_binding name). Setting None
1000                    // keeps the YAML clean.
1001                    name: None,
1002                },
1003                _ => {
1004                    // The grant's schema must equal `schema` (otherwise the
1005                    // role wouldn't be eligible). Drop the schema; preserve
1006                    // `name` (e.g. `"*"` or a specific table name).
1007                    debug_assert_eq!(g.object.schema.as_deref(), Some(schema));
1008                    ProfileObjectTarget {
1009                        object_type: g.object.object_type,
1010                        name: g.object.name.clone(),
1011                    }
1012                }
1013            };
1014            let mut privs = g.privileges.clone();
1015            privs.sort_by_key(|p| privilege_sort_key(*p));
1016            privs.dedup();
1017            ProfileGrant {
1018                privileges: privs,
1019                object,
1020            }
1021        })
1022        .collect();
1023    profile_grants.sort_by(|a, b| {
1024        let key_a = (a.object.object_type, a.object.name.clone());
1025        let key_b = (b.object.object_type, b.object.name.clone());
1026        key_a.cmp(&key_b)
1027    });
1028
1029    let mut profile_defaults: Vec<DefaultPrivilegeGrant> = dps
1030        .iter()
1031        .map(|(_, _, dpg)| {
1032            let mut privs = dpg.privileges.clone();
1033            privs.sort_by_key(|p| privilege_sort_key(*p));
1034            privs.dedup();
1035            DefaultPrivilegeGrant {
1036                role: None, // expansion fills this in
1037                privileges: privs,
1038                on_type: dpg.on_type,
1039            }
1040        })
1041        .collect();
1042    profile_defaults.sort_by_key(|d| d.on_type);
1043
1044    Profile {
1045        login,
1046        inherit,
1047        grants: profile_grants,
1048        default_privileges: profile_defaults,
1049        // The suggester never clusters config-carrying roles into a profile
1050        // (see the `role_def.config` check in the eligibility loop above) —
1051        // profiles it builds never need to express `config`.
1052        config: BTreeMap::new(),
1053    }
1054}
1055
1056fn check_round_trip(
1057    original: &PolicyManifest,
1058    candidate: &PolicyManifest,
1059    inventory: &Inventory,
1060) -> Result<(), String> {
1061    let mut original_expanded =
1062        expand_manifest(original).map_err(|e| format!("original expand: {e}"))?;
1063    expand_wildcards_in_place(&mut original_expanded.grants, inventory);
1064    let original_graph =
1065        RoleGraph::from_expanded(&original_expanded, original.default_owner.as_deref())
1066            .map_err(|e| format!("original graph: {e}"))?;
1067
1068    let mut candidate_expanded =
1069        expand_manifest(candidate).map_err(|e| format!("candidate expand: {e}"))?;
1070    expand_wildcards_in_place(&mut candidate_expanded.grants, inventory);
1071    let candidate_graph =
1072        RoleGraph::from_expanded(&candidate_expanded, candidate.default_owner.as_deref())
1073            .map_err(|e| format!("candidate graph: {e}"))?;
1074
1075    let changes = diff(&original_graph, &candidate_graph);
1076    let unacceptable: Vec<&Change> = changes
1077        .iter()
1078        .filter(|c| !matches!(c, Change::SetComment { .. }))
1079        .collect();
1080    if !unacceptable.is_empty() {
1081        return Err(format!(
1082            "{} structural change(s) after suggestion (sample: {:?})",
1083            unacceptable.len(),
1084            unacceptable.first()
1085        ));
1086    }
1087    Ok(())
1088}
1089
1090/// Expand any `name: "*"` grant against the inventory: emit one named grant
1091/// per inventory entry. Schema and Database object_types are passed through.
1092fn expand_wildcards_in_place(grants: &mut Vec<Grant>, inventory: &Inventory) {
1093    let mut out: Vec<Grant> = Vec::with_capacity(grants.len());
1094    for g in grants.drain(..) {
1095        let is_wildcard = matches!(
1096            g.object.object_type,
1097            ObjectType::Table
1098                | ObjectType::View
1099                | ObjectType::MaterializedView
1100                | ObjectType::Sequence
1101                | ObjectType::Function
1102                | ObjectType::Type
1103        ) && g.object.name.as_deref() == Some("*");
1104        if !is_wildcard {
1105            out.push(g);
1106            continue;
1107        }
1108        let Some(schema) = g.object.schema.as_ref() else {
1109            out.push(g);
1110            continue;
1111        };
1112        let key = (schema.clone(), g.object.object_type);
1113        if let Some(names) = inventory.get(&key) {
1114            for name in names {
1115                out.push(Grant {
1116                    role: g.role.clone(),
1117                    privileges: g.privileges.clone(),
1118                    object: ObjectTarget {
1119                        object_type: g.object.object_type,
1120                        schema: g.object.schema.clone(),
1121                        name: Some(name.clone()),
1122                    },
1123                });
1124            }
1125        } else {
1126            // No objects of this type in the schema — wildcard is a no-op,
1127            // but we keep it so the model is preserved.
1128            out.push(g);
1129        }
1130    }
1131    *grants = out;
1132}
1133
1134// ---------------------------------------------------------------------------
1135// Tests
1136// ---------------------------------------------------------------------------
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use crate::manifest::parse_manifest;
1142
1143    fn parse(yaml: &str) -> PolicyManifest {
1144        parse_manifest(yaml).expect("parse")
1145    }
1146
1147    #[test]
1148    fn no_input_profiles_no_clusters_returns_unchanged() {
1149        let m = parse(
1150            r#"
1151roles:
1152  - name: alice
1153    login: true
1154"#,
1155        );
1156        let report = suggest_profiles(&m, &SuggestOptions::default());
1157        assert!(report.profiles.is_empty());
1158        assert!(report.round_trip_ok);
1159    }
1160
1161    #[test]
1162    fn input_with_existing_profiles_is_left_alone() {
1163        let m = parse(
1164            r#"
1165profiles:
1166  reader:
1167    grants:
1168      - privileges: [USAGE]
1169        object: { type: schema }
1170schemas:
1171  - name: x
1172    profiles: [reader]
1173"#,
1174        );
1175        let report = suggest_profiles(&m, &SuggestOptions::default());
1176        assert!(report.profiles.is_empty());
1177        assert_eq!(report.manifest.profiles.len(), 1);
1178    }
1179
1180    #[test]
1181    fn clusters_two_schemas_with_dash_pattern() {
1182        // Three schemas, three roles, all with identical schema-relative shape.
1183        let m = parse(
1184            r#"
1185default_owner: app_owner
1186schemas:
1187  - name: inventory
1188    owner: app_owner
1189  - name: checkout
1190    owner: app_owner
1191  - name: analytics
1192    owner: app_owner
1193
1194roles:
1195  - name: inventory-reader
1196  - name: checkout-reader
1197  - name: analytics-reader
1198
1199grants:
1200  - role: inventory-reader
1201    privileges: [USAGE]
1202    object: { type: schema, name: inventory }
1203  - role: inventory-reader
1204    privileges: [SELECT]
1205    object: { type: table, schema: inventory, name: "*" }
1206  - role: checkout-reader
1207    privileges: [USAGE]
1208    object: { type: schema, name: checkout }
1209  - role: checkout-reader
1210    privileges: [SELECT]
1211    object: { type: table, schema: checkout, name: "*" }
1212  - role: analytics-reader
1213    privileges: [USAGE]
1214    object: { type: schema, name: analytics }
1215  - role: analytics-reader
1216    privileges: [SELECT]
1217    object: { type: table, schema: analytics, name: "*" }
1218"#,
1219        );
1220        let report = suggest_profiles(&m, &SuggestOptions::default());
1221        assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
1222        assert_eq!(report.profiles.len(), 1);
1223        let p = &report.profiles[0];
1224        assert_eq!(p.profile_name, "reader");
1225        assert_eq!(p.role_pattern, "{schema}-{profile}");
1226        assert_eq!(p.schema_to_role.len(), 3);
1227        assert!(report.manifest.profiles.contains_key("reader"));
1228        // Roles section should no longer hold the clustered roles.
1229        assert!(
1230            report
1231                .manifest
1232                .roles
1233                .iter()
1234                .all(|r| !r.name.ends_with("-reader"))
1235        );
1236        // Schema bindings should reference the new profile.
1237        for s in &report.manifest.schemas {
1238            assert_eq!(s.profiles, vec!["reader"]);
1239            assert_eq!(s.role_pattern, "{schema}-{profile}");
1240        }
1241    }
1242
1243    #[test]
1244    fn clusters_with_underscore_pattern() {
1245        let m = parse(
1246            r#"
1247default_owner: app_owner
1248schemas:
1249  - name: inventory
1250    owner: app_owner
1251  - name: checkout
1252    owner: app_owner
1253roles:
1254  - name: inventory_app
1255    login: true
1256  - name: checkout_app
1257    login: true
1258grants:
1259  - role: inventory_app
1260    privileges: [USAGE]
1261    object: { type: schema, name: inventory }
1262  - role: inventory_app
1263    privileges: [SELECT, INSERT, UPDATE, DELETE]
1264    object: { type: table, schema: inventory, name: "*" }
1265  - role: checkout_app
1266    privileges: [USAGE]
1267    object: { type: schema, name: checkout }
1268  - role: checkout_app
1269    privileges: [SELECT, INSERT, UPDATE, DELETE]
1270    object: { type: table, schema: checkout, name: "*" }
1271"#,
1272        );
1273        let report = suggest_profiles(&m, &SuggestOptions::default());
1274        assert!(report.round_trip_ok);
1275        assert_eq!(report.profiles.len(), 1);
1276        let p = &report.profiles[0];
1277        assert_eq!(p.profile_name, "app");
1278        assert_eq!(p.role_pattern, "{schema}_{profile}");
1279        // Profile carries `login: true`.
1280        let prof = report.manifest.profiles.get("app").unwrap();
1281        assert_eq!(prof.login, Some(true));
1282    }
1283
1284    #[test]
1285    fn does_not_cluster_single_schema_role() {
1286        let m = parse(
1287            r#"
1288schemas:
1289  - name: inventory
1290    owner: app_owner
1291roles:
1292  - name: inventory-reader
1293grants:
1294  - role: inventory-reader
1295    privileges: [SELECT]
1296    object: { type: table, schema: inventory, name: "*" }
1297"#,
1298        );
1299        let report = suggest_profiles(&m, &SuggestOptions::default());
1300        assert!(report.profiles.is_empty());
1301        assert!(matches!(
1302            report.skipped.first(),
1303            Some(SkipReason::SoleSchema { .. })
1304        ));
1305    }
1306
1307    #[test]
1308    fn min_schemas_one_promotes_single_schema_role() {
1309        let m = parse(
1310            r#"
1311schemas:
1312  - name: inventory
1313    owner: app_owner
1314roles:
1315  - name: inventory-reader
1316grants:
1317  - role: inventory-reader
1318    privileges: [SELECT]
1319    object: { type: table, schema: inventory, name: "*" }
1320"#,
1321        );
1322        let report = suggest_profiles(
1323            &m,
1324            &SuggestOptions {
1325                min_schemas: 1,
1326                ..Default::default()
1327            },
1328        );
1329        assert!(report.round_trip_ok);
1330        assert_eq!(report.profiles.len(), 1);
1331    }
1332
1333    #[test]
1334    fn role_with_unique_attributes_stays_flat() {
1335        let m = parse(
1336            r#"
1337schemas:
1338  - name: inventory
1339    owner: app_owner
1340  - name: checkout
1341    owner: app_owner
1342roles:
1343  - name: inventory-reader
1344    connection_limit: 5
1345  - name: checkout-reader
1346grants:
1347  - role: inventory-reader
1348    privileges: [SELECT]
1349    object: { type: table, schema: inventory, name: "*" }
1350  - role: checkout-reader
1351    privileges: [SELECT]
1352    object: { type: table, schema: checkout, name: "*" }
1353"#,
1354        );
1355        let report = suggest_profiles(&m, &SuggestOptions::default());
1356        // Only one role qualifies → SoleSchema skip; no cluster formed.
1357        assert!(report.profiles.is_empty());
1358        assert!(report.skipped.iter().any(
1359            |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
1360        ));
1361    }
1362
1363    #[test]
1364    fn multi_schema_role_skipped() {
1365        let m = parse(
1366            r#"
1367schemas:
1368  - name: inventory
1369    owner: app_owner
1370  - name: checkout
1371    owner: app_owner
1372roles:
1373  - name: cross
1374grants:
1375  - role: cross
1376    privileges: [SELECT]
1377    object: { type: table, schema: inventory, name: "*" }
1378  - role: cross
1379    privileges: [SELECT]
1380    object: { type: table, schema: checkout, name: "*" }
1381"#,
1382        );
1383        let report = suggest_profiles(&m, &SuggestOptions::default());
1384        assert!(report.profiles.is_empty());
1385        assert!(
1386            report
1387                .skipped
1388                .iter()
1389                .any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "cross"))
1390        );
1391    }
1392
1393    #[test]
1394    fn non_uniform_pattern_skipped() {
1395        let m = parse(
1396            r#"
1397schemas:
1398  - name: inventory
1399    owner: app_owner
1400  - name: checkout
1401    owner: app_owner
1402roles:
1403  - name: inventory-reader
1404  - name: checkout_reader
1405grants:
1406  - role: inventory-reader
1407    privileges: [SELECT]
1408    object: { type: table, schema: inventory, name: "*" }
1409  - role: checkout_reader
1410    privileges: [SELECT]
1411    object: { type: table, schema: checkout, name: "*" }
1412"#,
1413        );
1414        // inventory-reader matches {schema}-{profile} → "reader"
1415        // checkout_reader matches {schema}_{profile} → "reader"
1416        // They have the SAME signature, but different patterns. Our resolver
1417        // picks the first pattern in priority order that all members agree on
1418        // — neither pattern works for both, so no cluster.
1419        let report = suggest_profiles(&m, &SuggestOptions::default());
1420        assert!(report.profiles.is_empty());
1421        assert!(
1422            report
1423                .skipped
1424                .iter()
1425                .any(|s| matches!(s, SkipReason::NoUniformPattern { .. }))
1426        );
1427    }
1428
1429    #[test]
1430    fn different_login_split_into_separate_clusters() {
1431        let m = parse(
1432            r#"
1433schemas:
1434  - name: a
1435    owner: o
1436  - name: b
1437    owner: o
1438  - name: c
1439    owner: o
1440  - name: d
1441    owner: o
1442roles:
1443  - name: a-svc
1444    login: true
1445  - name: b-svc
1446    login: true
1447  - name: c-svc
1448  - name: d-svc
1449grants:
1450  - role: a-svc
1451    privileges: [SELECT]
1452    object: { type: table, schema: a, name: "*" }
1453  - role: b-svc
1454    privileges: [SELECT]
1455    object: { type: table, schema: b, name: "*" }
1456  - role: c-svc
1457    privileges: [SELECT]
1458    object: { type: table, schema: c, name: "*" }
1459  - role: d-svc
1460    privileges: [SELECT]
1461    object: { type: table, schema: d, name: "*" }
1462"#,
1463        );
1464        let report = suggest_profiles(&m, &SuggestOptions::default());
1465        assert!(report.round_trip_ok);
1466        // Both clusters resolve to profile name "svc"; only one wins (the
1467        // larger one, or the lexicographically-first by signature in a tie).
1468        // The other cluster's roles are skipped as NoUniformPattern.
1469        assert_eq!(report.profiles.len(), 1);
1470        assert_eq!(report.profiles[0].profile_name, "svc");
1471        // The "losing" cluster's two roles must remain in the flat roles list.
1472        let kept_role_names: BTreeSet<&str> = report
1473            .manifest
1474            .roles
1475            .iter()
1476            .map(|r| r.name.as_str())
1477            .collect();
1478        assert_eq!(kept_role_names.len(), 2);
1479    }
1480
1481    #[test]
1482    fn round_trip_zero_diff() {
1483        // A representative manifest, including default privileges.
1484        let m = parse(
1485            r#"
1486default_owner: app_owner
1487schemas:
1488  - name: inventory
1489    owner: app_owner
1490  - name: checkout
1491    owner: app_owner
1492
1493roles:
1494  - name: inventory-rw
1495  - name: checkout-rw
1496
1497grants:
1498  - role: inventory-rw
1499    privileges: [USAGE]
1500    object: { type: schema, name: inventory }
1501  - role: inventory-rw
1502    privileges: [SELECT, INSERT, UPDATE, DELETE]
1503    object: { type: table, schema: inventory, name: "*" }
1504  - role: inventory-rw
1505    privileges: [USAGE, SELECT]
1506    object: { type: sequence, schema: inventory, name: "*" }
1507  - role: checkout-rw
1508    privileges: [USAGE]
1509    object: { type: schema, name: checkout }
1510  - role: checkout-rw
1511    privileges: [SELECT, INSERT, UPDATE, DELETE]
1512    object: { type: table, schema: checkout, name: "*" }
1513  - role: checkout-rw
1514    privileges: [USAGE, SELECT]
1515    object: { type: sequence, schema: checkout, name: "*" }
1516
1517default_privileges:
1518  - owner: app_owner
1519    schema: inventory
1520    grant:
1521      - role: inventory-rw
1522        privileges: [SELECT, INSERT, UPDATE, DELETE]
1523        on_type: table
1524  - owner: app_owner
1525    schema: checkout
1526    grant:
1527      - role: checkout-rw
1528        privileges: [SELECT, INSERT, UPDATE, DELETE]
1529        on_type: table
1530"#,
1531        );
1532
1533        let report = suggest_profiles(&m, &SuggestOptions::default());
1534        assert!(report.round_trip_ok);
1535        assert_eq!(report.profiles.len(), 1);
1536        let prof = report.manifest.profiles.get("rw").unwrap();
1537        assert_eq!(prof.grants.len(), 3);
1538        assert_eq!(prof.default_privileges.len(), 1);
1539
1540        // Compare the structural state (RoleGraph) of input vs suggested.
1541        let original_expanded = expand_manifest(&m).unwrap();
1542        let original_graph =
1543            RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
1544        let new_expanded = expand_manifest(&report.manifest).unwrap();
1545        let new_graph =
1546            RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
1547                .unwrap();
1548        let changes = diff(&original_graph, &new_graph);
1549        // Only role-comment changes (auto-generated by profile expansion) are
1550        // acceptable.
1551        let bad: Vec<_> = changes
1552            .iter()
1553            .filter(|c| !matches!(c, Change::SetComment { .. }))
1554            .collect();
1555        assert!(bad.is_empty(), "unexpected diff: {bad:?}");
1556    }
1557
1558    #[test]
1559    fn schema_pattern_conflict_drops_smaller_cluster() {
1560        // Two clusters compete for schema "inventory":
1561        //   "inventory-reader" + "checkout-reader" → wants "{schema}-{profile}"
1562        //   "inventory_app" + "stage_app" → wants "{schema}_{profile}"
1563        // Wait — these touch different schemas, so they don't actually conflict
1564        // unless the same schema appears in both. Construct a real conflict:
1565        // make role "inventory-reader" + "checkout-reader" (cluster A) and
1566        // "inventory_writer" + "checkout_writer" (cluster B). Both want to
1567        // bind to inventory and checkout, but with different patterns. Only
1568        // the first cluster (alphabetically: dash < underscore) wins.
1569        let m = parse(
1570            r#"
1571default_owner: o
1572schemas:
1573  - name: inventory
1574    owner: o
1575  - name: checkout
1576    owner: o
1577
1578roles:
1579  - name: inventory-reader
1580  - name: checkout-reader
1581  - name: inventory_writer
1582  - name: checkout_writer
1583
1584grants:
1585  - role: inventory-reader
1586    privileges: [SELECT]
1587    object: { type: table, schema: inventory, name: "*" }
1588  - role: checkout-reader
1589    privileges: [SELECT]
1590    object: { type: table, schema: checkout, name: "*" }
1591  - role: inventory_writer
1592    privileges: [INSERT]
1593    object: { type: table, schema: inventory, name: "*" }
1594  - role: checkout_writer
1595    privileges: [INSERT]
1596    object: { type: table, schema: checkout, name: "*" }
1597"#,
1598        );
1599
1600        let report = suggest_profiles(&m, &SuggestOptions::default());
1601        // Either "reader" wins outright (both schemas commit to dash pattern,
1602        // then the underscore cluster can't take its preferred pattern),
1603        // or vice-versa. Whichever one wins, the other should be left flat
1604        // and round-trip should still succeed.
1605        assert!(report.round_trip_ok);
1606        assert_eq!(
1607            report.profiles.len(),
1608            1,
1609            "exactly one profile should win: {:?}",
1610            report.profiles
1611        );
1612        // The losing cluster must surface a SchemaPatternConflict skip
1613        // pointing at the schema whose pattern was already locked.
1614        let conflicts: Vec<_> = report
1615            .skipped
1616            .iter()
1617            .filter_map(|s| match s {
1618                SkipReason::SchemaPatternConflict {
1619                    schema,
1620                    winning_pattern,
1621                    dropped_roles,
1622                } => Some((schema, winning_pattern, dropped_roles)),
1623                _ => None,
1624            })
1625            .collect();
1626        assert_eq!(
1627            conflicts.len(),
1628            1,
1629            "expected one SchemaPatternConflict skip, got: {:?}",
1630            report.skipped
1631        );
1632        let (_, winning, dropped) = conflicts[0];
1633        // Either pattern can win (depends on signature ordering); the
1634        // important thing is that the *other* one is reported as conflicting.
1635        assert!(
1636            winning == "{schema}-{profile}" || winning == "{schema}_{profile}",
1637            "unexpected winning_pattern: {winning}"
1638        );
1639        assert_eq!(dropped.len(), 2);
1640    }
1641
1642    #[test]
1643    fn match_pattern_basic() {
1644        assert_eq!(
1645            match_pattern("{schema}-{profile}", "inventory-reader", "inventory"),
1646            Some("reader".into())
1647        );
1648        assert_eq!(
1649            match_pattern("{schema}_{profile}", "inventory_app", "inventory"),
1650            Some("app".into())
1651        );
1652        assert_eq!(
1653            match_pattern("{profile}-{schema}", "ro-inventory", "inventory"),
1654            Some("ro".into())
1655        );
1656        assert_eq!(
1657            match_pattern("{profile}_{schema}", "ro_inventory", "inventory"),
1658            Some("ro".into())
1659        );
1660        // Schema not matched.
1661        assert_eq!(
1662            match_pattern("{schema}-{profile}", "checkout-reader", "inventory"),
1663            None
1664        );
1665        // Empty profile component.
1666        assert_eq!(
1667            match_pattern("{schema}-{profile}", "inventory-", "inventory"),
1668            None
1669        );
1670        // No separator.
1671        assert_eq!(
1672            match_pattern("{schema}-{profile}", "inventoryreader", "inventory"),
1673            None
1674        );
1675    }
1676
1677    #[test]
1678    fn database_grants_excluded_from_clustering() {
1679        // A role with a CONNECT-on-database grant has an unrepresentable
1680        // grant; profiles can't carry it. Even if its other grants are
1681        // schema-shaped and shared with another role, exclude it.
1682        let m = parse(
1683            r#"
1684schemas:
1685  - name: a
1686    owner: o
1687  - name: b
1688    owner: o
1689roles:
1690  - name: a-svc
1691  - name: b-svc
1692grants:
1693  - role: a-svc
1694    privileges: [CONNECT]
1695    object: { type: database, name: mydb }
1696  - role: a-svc
1697    privileges: [SELECT]
1698    object: { type: table, schema: a, name: "*" }
1699  - role: b-svc
1700    privileges: [SELECT]
1701    object: { type: table, schema: b, name: "*" }
1702"#,
1703        );
1704        let report = suggest_profiles(&m, &SuggestOptions::default());
1705        // a-svc is excluded, b-svc is single-schema → no cluster.
1706        assert!(report.profiles.is_empty());
1707        assert!(
1708            report
1709                .skipped
1710                .iter()
1711                .any(|s| matches!(s, SkipReason::UnrepresentableGrant { role } if role == "a-svc"))
1712        );
1713    }
1714
1715    #[test]
1716    fn membership_targets_clustered_role_still_resolve_after_suggestion() {
1717        // A membership targets `inventory-reader`; after clustering, the
1718        // expanded manifest must still produce a role with that exact name.
1719        let m = parse(
1720            r#"
1721schemas:
1722  - name: inventory
1723    owner: o
1724  - name: checkout
1725    owner: o
1726roles:
1727  - name: inventory-reader
1728  - name: checkout-reader
1729  - name: alice
1730    login: true
1731grants:
1732  - role: inventory-reader
1733    privileges: [SELECT]
1734    object: { type: table, schema: inventory, name: "*" }
1735  - role: checkout-reader
1736    privileges: [SELECT]
1737    object: { type: table, schema: checkout, name: "*" }
1738memberships:
1739  - role: inventory-reader
1740    members:
1741      - name: alice
1742"#,
1743        );
1744        let report = suggest_profiles(&m, &SuggestOptions::default());
1745        assert!(report.round_trip_ok);
1746        // The membership entry is preserved verbatim.
1747        assert_eq!(report.manifest.memberships.len(), 1);
1748        assert_eq!(report.manifest.memberships[0].role, "inventory-reader");
1749        // Re-expanding produces the role.
1750        let expanded = expand_manifest(&report.manifest).unwrap();
1751        assert!(expanded.roles.iter().any(|r| r.name == "inventory-reader"));
1752        assert!(expanded.roles.iter().any(|r| r.name == "checkout-reader"));
1753    }
1754
1755    #[test]
1756    fn wildcard_object_names_preserved_in_profile() {
1757        let m = parse(
1758            r#"
1759schemas:
1760  - name: a
1761    owner: o
1762  - name: b
1763    owner: o
1764roles:
1765  - name: a-rw
1766  - name: b-rw
1767grants:
1768  - role: a-rw
1769    privileges: [SELECT, INSERT]
1770    object: { type: table, schema: a, name: "*" }
1771  - role: a-rw
1772    privileges: [USAGE]
1773    object: { type: sequence, schema: a, name: orders_id_seq }
1774  - role: b-rw
1775    privileges: [SELECT, INSERT]
1776    object: { type: table, schema: b, name: "*" }
1777  - role: b-rw
1778    privileges: [USAGE]
1779    object: { type: sequence, schema: b, name: orders_id_seq }
1780"#,
1781        );
1782
1783        // Default options (no full_inventory) → no collapse → literal names
1784        // are preserved.
1785        let report = suggest_profiles(&m, &SuggestOptions::default());
1786        assert!(report.round_trip_ok);
1787        assert_eq!(report.profiles.len(), 1);
1788        let prof = report.manifest.profiles.get("rw").unwrap();
1789        let seq_grant = prof
1790            .grants
1791            .iter()
1792            .find(|g| g.object.object_type == ObjectType::Sequence)
1793            .unwrap();
1794        assert_eq!(seq_grant.object.name.as_deref(), Some("orders_id_seq"));
1795
1796        // With a full inventory provided, full-coverage names become
1797        // wildcards.
1798        let inv = inventory_from_manifest_grants(&m);
1799        let report = suggest_profiles(
1800            &m,
1801            &SuggestOptions {
1802                full_inventory: Some(inv),
1803                ..Default::default()
1804            },
1805        );
1806        assert!(report.round_trip_ok);
1807        let prof = report.manifest.profiles.get("rw").unwrap();
1808        let seq_grant = prof
1809            .grants
1810            .iter()
1811            .find(|g| g.object.object_type == ObjectType::Sequence)
1812            .unwrap();
1813        assert_eq!(
1814            seq_grant.object.name.as_deref(),
1815            Some("*"),
1816            "single-object full coverage should collapse to wildcard"
1817        );
1818    }
1819
1820    #[test]
1821    fn collapse_clusters_roles_with_different_object_names() {
1822        // The motivating real-world case: per-name grants from `pgroles
1823        // generate` (Postgres expands `GRANT … ON ALL TABLES` to per-relation
1824        // rows). After collapse, the two roles share a wildcard signature and
1825        // cluster.
1826        let m = parse(
1827            r#"
1828schemas:
1829  - name: inventory
1830    owner: o
1831  - name: checkout
1832    owner: o
1833roles:
1834  - name: inventory-reader
1835  - name: checkout-reader
1836grants:
1837  - role: inventory-reader
1838    privileges: [USAGE]
1839    object: { type: schema, name: inventory }
1840  - role: inventory-reader
1841    privileges: [SELECT]
1842    object: { type: table, schema: inventory, name: products }
1843  - role: inventory-reader
1844    privileges: [SELECT]
1845    object: { type: table, schema: inventory, name: stock_levels }
1846  - role: checkout-reader
1847    privileges: [USAGE]
1848    object: { type: schema, name: checkout }
1849  - role: checkout-reader
1850    privileges: [SELECT]
1851    object: { type: table, schema: checkout, name: orders }
1852  - role: checkout-reader
1853    privileges: [SELECT]
1854    object: { type: table, schema: checkout, name: order_items }
1855"#,
1856        );
1857        // With a full inventory provided, the per-name grants get collapsed
1858        // and the two roles cluster on a wildcard signature.
1859        let inv = inventory_from_manifest_grants(&m);
1860        let report = suggest_profiles(
1861            &m,
1862            &SuggestOptions {
1863                full_inventory: Some(inv),
1864                ..Default::default()
1865            },
1866        );
1867        assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
1868        assert_eq!(report.profiles.len(), 1);
1869        let prof = report.manifest.profiles.get("reader").unwrap();
1870        // Profile carries a wildcard table grant.
1871        let table_grant = prof
1872            .grants
1873            .iter()
1874            .find(|g| g.object.object_type == ObjectType::Table)
1875            .unwrap();
1876        assert_eq!(table_grant.object.name.as_deref(), Some("*"));
1877    }
1878
1879    #[test]
1880    fn no_full_inventory_prevents_clustering_across_different_names() {
1881        // Same input as `collapse_clusters_roles_with_different_object_names`
1882        // but without a full_inventory — should NOT cluster, since literal
1883        // names differ and we can't safely collapse without DB introspection.
1884        let m = parse(
1885            r#"
1886schemas:
1887  - name: inventory
1888    owner: o
1889  - name: checkout
1890    owner: o
1891roles:
1892  - name: inventory-reader
1893  - name: checkout-reader
1894grants:
1895  - role: inventory-reader
1896    privileges: [SELECT]
1897    object: { type: table, schema: inventory, name: products }
1898  - role: checkout-reader
1899    privileges: [SELECT]
1900    object: { type: table, schema: checkout, name: orders }
1901"#,
1902        );
1903        // Default (no full_inventory) → no collapse → different literal names
1904        // produce different signatures → no cluster.
1905        let report = suggest_profiles(&m, &SuggestOptions::default());
1906        assert!(report.profiles.is_empty());
1907    }
1908
1909    #[test]
1910    fn collapse_partial_coverage_preserves_per_name_grants() {
1911        // Two tables in schema `a`, but role `a-ro` only has SELECT on one of
1912        // them. Coverage isn't full → no collapse → no cluster with `b-ro`
1913        // (which has full coverage of its single-table schema).
1914        let m = parse(
1915            r#"
1916schemas:
1917  - name: a
1918    owner: o
1919  - name: b
1920    owner: o
1921roles:
1922  - name: a-ro
1923  - name: b-ro
1924grants:
1925  - role: a-ro
1926    privileges: [SELECT]
1927    object: { type: table, schema: a, name: t1 }
1928  # a-ro has no grant on a.t2 (which exists, evidenced by another role)
1929  - role: filler
1930    privileges: [SELECT]
1931    object: { type: table, schema: a, name: t2 }
1932  - role: b-ro
1933    privileges: [SELECT]
1934    object: { type: table, schema: b, name: only_one }
1935"#,
1936        );
1937        // Full inventory says schema `a` has {t1, t2}, schema `b` has
1938        // {only_one}. a-ro covers only t1 (partial) → no collapse for a-ro.
1939        // b-ro covers all of {only_one} (full) → collapses to wildcard.
1940        // Different signatures → no cluster.
1941        let inv = inventory_from_manifest_grants(&m);
1942        let report = suggest_profiles(
1943            &m,
1944            &SuggestOptions {
1945                full_inventory: Some(inv),
1946                ..Default::default()
1947            },
1948        );
1949        assert!(report.profiles.is_empty());
1950    }
1951
1952    #[test]
1953    fn incomplete_full_inventory_disables_collapse_with_skip_reason() {
1954        // Hand the suggester a `full_inventory` that's *missing* an object
1955        // that already appears in the manifest's flat grants. This is
1956        // exactly the failure mode of passing `inventory_from_manifest_grants`
1957        // (or any partial view) — the suggester must detect it and refuse
1958        // to collapse, surfacing an `IncompleteFullInventory` skip.
1959        let m = parse(
1960            r#"
1961schemas:
1962  - name: a
1963    owner: o
1964  - name: b
1965    owner: o
1966roles:
1967  - name: a-rw
1968  - name: b-rw
1969grants:
1970  - role: a-rw
1971    privileges: [SELECT]
1972    object: { type: table, schema: a, name: products }
1973  - role: b-rw
1974    privileges: [SELECT]
1975    object: { type: table, schema: b, name: orders }
1976"#,
1977        );
1978        // Provide an inventory that omits `products` — pretend the caller
1979        // missed it.
1980        let mut bad: Inventory = BTreeMap::new();
1981        bad.entry(("a".to_string(), ObjectType::Table)).or_default(); // empty set
1982        bad.entry(("b".to_string(), ObjectType::Table))
1983            .or_default()
1984            .insert("orders".to_string());
1985        let report = suggest_profiles(
1986            &m,
1987            &SuggestOptions {
1988                full_inventory: Some(bad),
1989                ..Default::default()
1990            },
1991        );
1992        // Collapse must have been disabled; literal names differ across
1993        // schemas → no cluster.
1994        assert!(report.profiles.is_empty());
1995        assert!(
1996            report
1997                .skipped
1998                .iter()
1999                .any(|s| matches!(s, SkipReason::IncompleteFullInventory { .. })),
2000            "expected IncompleteFullInventory skip; got: {:?}",
2001            report.skipped
2002        );
2003    }
2004
2005    #[test]
2006    fn full_inventory_with_ungranted_objects_blocks_unsafe_collapse() {
2007        // Schema `a` has 2 tables; role `a-ro` has SELECT on only one. With a
2008        // grant-derived view of the world we'd think coverage was full and
2009        // collapse to wildcard — which would silently grant on `t2` after
2010        // applying. With a real introspected inventory (containing both
2011        // tables), the suggester correctly sees partial coverage and refuses
2012        // to collapse.
2013        let m = parse(
2014            r#"
2015schemas:
2016  - name: a
2017    owner: o
2018  - name: b
2019    owner: o
2020roles:
2021  - name: a-ro
2022  - name: b-ro
2023grants:
2024  - role: a-ro
2025    privileges: [SELECT]
2026    object: { type: table, schema: a, name: t1 }
2027  - role: b-ro
2028    privileges: [SELECT]
2029    object: { type: table, schema: b, name: only_one }
2030"#,
2031        );
2032        // Inventory reports schema `a` actually has *two* tables.
2033        let mut inv = inventory_from_manifest_grants(&m);
2034        inv.entry(("a".to_string(), ObjectType::Table))
2035            .or_default()
2036            .insert("t2_ungranted".to_string());
2037        let report = suggest_profiles(
2038            &m,
2039            &SuggestOptions {
2040                full_inventory: Some(inv),
2041                ..Default::default()
2042            },
2043        );
2044        // a-ro has partial coverage now → no collapse → no cluster.
2045        assert!(report.profiles.is_empty());
2046    }
2047
2048    #[test]
2049    fn auto_generated_profile_comments_dont_block_resuggestion() {
2050        // When `pgroles apply` materializes a profile, it sets a comment on
2051        // each generated role. Re-running `--suggest-profiles` later must not
2052        // treat those auto-comments as user-set documentation that
2053        // disqualifies the role.
2054        let m = parse(
2055            r#"
2056schemas:
2057  - name: inventory
2058    owner: o
2059  - name: checkout
2060    owner: o
2061roles:
2062  - name: inventory-reader
2063    comment: "Generated from profile 'reader' for schema 'inventory'"
2064  - name: checkout-reader
2065    comment: "Generated from profile 'reader' for schema 'checkout'"
2066grants:
2067  - role: inventory-reader
2068    privileges: [SELECT]
2069    object: { type: table, schema: inventory, name: "*" }
2070  - role: checkout-reader
2071    privileges: [SELECT]
2072    object: { type: table, schema: checkout, name: "*" }
2073"#,
2074        );
2075        let report = suggest_profiles(&m, &SuggestOptions::default());
2076        assert!(report.round_trip_ok);
2077        assert_eq!(report.profiles.len(), 1);
2078        assert_eq!(report.profiles[0].profile_name, "reader");
2079    }
2080
2081    #[test]
2082    fn user_set_comments_still_block_clustering() {
2083        // A real user-set comment (not the auto-generated pattern) keeps the
2084        // role flat — profiles can't carry per-role comments.
2085        let m = parse(
2086            r#"
2087schemas:
2088  - name: inventory
2089    owner: o
2090  - name: checkout
2091    owner: o
2092roles:
2093  - name: inventory-reader
2094    comment: "Owned by data team — Q3 access only"
2095  - name: checkout-reader
2096grants:
2097  - role: inventory-reader
2098    privileges: [SELECT]
2099    object: { type: table, schema: inventory, name: "*" }
2100  - role: checkout-reader
2101    privileges: [SELECT]
2102    object: { type: table, schema: checkout, name: "*" }
2103"#,
2104        );
2105        let report = suggest_profiles(&m, &SuggestOptions::default());
2106        // inventory-reader excluded for user comment → checkout-reader is
2107        // now sole-schema → no cluster.
2108        assert!(report.profiles.is_empty());
2109        assert!(report.skipped.iter().any(
2110            |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
2111        ));
2112    }
2113
2114    #[test]
2115    fn is_auto_profile_comment_basic() {
2116        assert!(is_auto_profile_comment(
2117            "Generated from profile 'reader' for schema 'inventory'"
2118        ));
2119        assert!(is_auto_profile_comment(
2120            "Generated from profile 'app-rw' for schema 'app_v2'"
2121        ));
2122        assert!(!is_auto_profile_comment("Random user note"));
2123        assert!(!is_auto_profile_comment(
2124            "Generated from profile 'reader' for schema 'inventory"
2125        )); // missing trailing quote
2126        assert!(!is_auto_profile_comment("Generated from profile 'reader'")); // missing schema part
2127    }
2128
2129    #[test]
2130    fn function_grants_with_signature_in_name_round_trip() {
2131        // Functions are emitted by `pgroles generate` with their argument
2132        // signature in `name`, e.g. `order_total(_id bigint)`. Verify those
2133        // round-trip correctly through the suggester.
2134        let m = parse(
2135            r#"
2136schemas:
2137  - name: a
2138    owner: o
2139  - name: b
2140    owner: o
2141roles:
2142  - name: a-rw
2143  - name: b-rw
2144grants:
2145  - role: a-rw
2146    privileges: [EXECUTE]
2147    object: { type: function, schema: a, name: "order_total(bigint)" }
2148  - role: b-rw
2149    privileges: [EXECUTE]
2150    object: { type: function, schema: b, name: "order_total(bigint)" }
2151"#,
2152        );
2153        let report = suggest_profiles(&m, &SuggestOptions::default());
2154        assert!(report.round_trip_ok);
2155        assert_eq!(report.profiles.len(), 1);
2156    }
2157
2158    #[test]
2159    fn default_privilege_owner_mismatch_excludes_role() {
2160        let m = parse(
2161            r#"
2162schemas:
2163  - name: a
2164    owner: app_owner
2165  - name: b
2166    owner: app_owner
2167roles:
2168  - name: a-rw
2169  - name: b-rw
2170grants:
2171  - role: a-rw
2172    privileges: [SELECT]
2173    object: { type: table, schema: a, name: "*" }
2174  - role: b-rw
2175    privileges: [SELECT]
2176    object: { type: table, schema: b, name: "*" }
2177default_privileges:
2178  - owner: a_different_owner   # mismatch — schema "a" is owned by app_owner
2179    schema: a
2180    grant:
2181      - role: a-rw
2182        privileges: [SELECT]
2183        on_type: table
2184  - owner: app_owner
2185    schema: b
2186    grant:
2187      - role: b-rw
2188        privileges: [SELECT]
2189        on_type: table
2190"#,
2191        );
2192        let report = suggest_profiles(&m, &SuggestOptions::default());
2193        // a-rw is excluded for owner mismatch → b-rw is sole-schema.
2194        assert!(report.profiles.is_empty());
2195        assert!(
2196            report
2197                .skipped
2198                .iter()
2199                .any(|s| matches!(s, SkipReason::OwnerMismatch { role, .. } if role == "a-rw"))
2200        );
2201    }
2202
2203    #[test]
2204    fn role_with_zero_grants_is_left_flat() {
2205        let m = parse(
2206            r#"
2207schemas:
2208  - name: a
2209    owner: o
2210roles:
2211  - name: lonely
2212    login: true
2213"#,
2214        );
2215        let report = suggest_profiles(&m, &SuggestOptions::default());
2216        assert!(report.profiles.is_empty());
2217        assert!(report.round_trip_ok);
2218        assert!(report.manifest.roles.iter().any(|r| r.name == "lonely"));
2219    }
2220
2221    #[test]
2222    fn schema_typed_grant_pointing_to_unrelated_schema_excludes_role() {
2223        // Role `a-rw` mostly touches schema `a` but has a `USAGE on schema b`
2224        // grant — that's two schemas, so it's MultiSchema-skipped.
2225        let m = parse(
2226            r#"
2227schemas:
2228  - name: a
2229    owner: o
2230  - name: b
2231    owner: o
2232roles:
2233  - name: a-rw
2234  - name: b-rw
2235grants:
2236  - role: a-rw
2237    privileges: [USAGE]
2238    object: { type: schema, name: a }
2239  - role: a-rw
2240    privileges: [USAGE]
2241    object: { type: schema, name: b }   # surprise: also touches b
2242  - role: b-rw
2243    privileges: [USAGE]
2244    object: { type: schema, name: b }
2245"#,
2246        );
2247        let report = suggest_profiles(&m, &SuggestOptions::default());
2248        assert!(report.profiles.is_empty());
2249        assert!(
2250            report
2251                .skipped
2252                .iter()
2253                .any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "a-rw"))
2254        );
2255    }
2256
2257    #[test]
2258    fn determinism_same_input_same_output() {
2259        // Run the suggester twice; outputs must be byte-identical YAML.
2260        let yaml = r#"
2261default_owner: app_owner
2262schemas:
2263  - name: inventory
2264    owner: app_owner
2265  - name: checkout
2266    owner: app_owner
2267  - name: analytics
2268    owner: app_owner
2269roles:
2270  - name: inventory-reader
2271  - name: checkout-reader
2272  - name: analytics-reader
2273  - name: inventory-rw
2274  - name: checkout-rw
2275  - name: analytics-rw
2276grants:
2277  - role: inventory-reader
2278    privileges: [SELECT]
2279    object: { type: table, schema: inventory, name: "*" }
2280  - role: checkout-reader
2281    privileges: [SELECT]
2282    object: { type: table, schema: checkout, name: "*" }
2283  - role: analytics-reader
2284    privileges: [SELECT]
2285    object: { type: table, schema: analytics, name: "*" }
2286  - role: inventory-rw
2287    privileges: [SELECT, INSERT]
2288    object: { type: table, schema: inventory, name: "*" }
2289  - role: checkout-rw
2290    privileges: [SELECT, INSERT]
2291    object: { type: table, schema: checkout, name: "*" }
2292  - role: analytics-rw
2293    privileges: [SELECT, INSERT]
2294    object: { type: table, schema: analytics, name: "*" }
2295"#;
2296        let m1 = parse(yaml);
2297        let m2 = parse(yaml);
2298        let r1 = suggest_profiles(&m1, &SuggestOptions::default());
2299        let r2 = suggest_profiles(&m2, &SuggestOptions::default());
2300
2301        // PolicyManifest.profiles is a BTreeMap, so the entire manifest
2302        // serializes deterministically — compare YAML directly.
2303        assert_eq!(r1.profiles.len(), 2);
2304        assert_eq!(r2.profiles.len(), 2);
2305        assert_eq!(
2306            serde_yaml::to_string(&r1.manifest).unwrap(),
2307            serde_yaml::to_string(&r2.manifest).unwrap()
2308        );
2309    }
2310
2311    #[test]
2312    fn realistic_scenario_full_round_trip() {
2313        // The shape pgroles generate produces from a real DB: lots of granular
2314        // grants, default privileges, schemas, services, humans.
2315        let yaml = r#"
2316default_owner: app_owner
2317schemas:
2318  - name: inventory
2319    owner: app_owner
2320  - name: checkout
2321    owner: app_owner
2322  - name: analytics
2323    owner: analytics_owner
2324roles:
2325  - name: app_owner
2326  - name: analytics_owner
2327  - name: inventory-editor
2328  - name: checkout-editor
2329  - name: inventory-viewer
2330  - name: checkout-viewer
2331  - name: analytics-viewer
2332  - name: data_analyst
2333
2334grants:
2335  - role: inventory-editor
2336    privileges: [USAGE]
2337    object: { type: schema, name: inventory }
2338  - role: inventory-editor
2339    privileges: [SELECT, INSERT, UPDATE, DELETE]
2340    object: { type: table, schema: inventory, name: "*" }
2341  - role: inventory-editor
2342    privileges: [USAGE, SELECT]
2343    object: { type: sequence, schema: inventory, name: "*" }
2344
2345  - role: checkout-editor
2346    privileges: [USAGE]
2347    object: { type: schema, name: checkout }
2348  - role: checkout-editor
2349    privileges: [SELECT, INSERT, UPDATE, DELETE]
2350    object: { type: table, schema: checkout, name: "*" }
2351  - role: checkout-editor
2352    privileges: [USAGE, SELECT]
2353    object: { type: sequence, schema: checkout, name: "*" }
2354
2355  - role: inventory-viewer
2356    privileges: [USAGE]
2357    object: { type: schema, name: inventory }
2358  - role: inventory-viewer
2359    privileges: [SELECT]
2360    object: { type: table, schema: inventory, name: "*" }
2361
2362  - role: checkout-viewer
2363    privileges: [USAGE]
2364    object: { type: schema, name: checkout }
2365  - role: checkout-viewer
2366    privileges: [SELECT]
2367    object: { type: table, schema: checkout, name: "*" }
2368
2369  - role: analytics-viewer
2370    privileges: [USAGE]
2371    object: { type: schema, name: analytics }
2372  - role: analytics-viewer
2373    privileges: [SELECT]
2374    object: { type: table, schema: analytics, name: "*" }
2375
2376default_privileges:
2377  - owner: app_owner
2378    schema: inventory
2379    grant:
2380      - role: inventory-editor
2381        privileges: [SELECT, INSERT, UPDATE, DELETE]
2382        on_type: table
2383  - owner: app_owner
2384    schema: checkout
2385    grant:
2386      - role: checkout-editor
2387        privileges: [SELECT, INSERT, UPDATE, DELETE]
2388        on_type: table
2389
2390memberships:
2391  - role: inventory-editor
2392    members:
2393      - name: data_analyst
2394  - role: analytics-viewer
2395    members:
2396      - name: data_analyst
2397"#;
2398        let m = parse(yaml);
2399        let report = suggest_profiles(&m, &SuggestOptions::default());
2400        assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2401
2402        // Expect "editor" cluster (inventory + checkout) and "viewer" cluster
2403        // (inventory + checkout + analytics).
2404        let names: BTreeSet<String> = report
2405            .profiles
2406            .iter()
2407            .map(|p| p.profile_name.clone())
2408            .collect();
2409        assert!(names.contains("editor"), "got: {names:?}");
2410        assert!(names.contains("viewer"), "got: {names:?}");
2411
2412        // Memberships untouched.
2413        assert_eq!(report.manifest.memberships.len(), 2);
2414
2415        // Re-expand and verify the role set is preserved.
2416        let expanded = expand_manifest(&report.manifest).unwrap();
2417        let role_names: BTreeSet<String> = expanded.roles.iter().map(|r| r.name.clone()).collect();
2418        for orig in [
2419            "inventory-editor",
2420            "checkout-editor",
2421            "inventory-viewer",
2422            "checkout-viewer",
2423            "analytics-viewer",
2424            "data_analyst",
2425            "app_owner",
2426            "analytics_owner",
2427        ] {
2428            assert!(
2429                role_names.contains(orig),
2430                "missing role {orig} in re-expanded manifest"
2431            );
2432        }
2433
2434        // analytics-viewer cluster has 3 schemas. inventory/checkout-editor cluster has 2.
2435        let viewer = report
2436            .profiles
2437            .iter()
2438            .find(|p| p.profile_name == "viewer")
2439            .unwrap();
2440        assert_eq!(viewer.schema_to_role.len(), 3);
2441        let editor = report
2442            .profiles
2443            .iter()
2444            .find(|p| p.profile_name == "editor")
2445            .unwrap();
2446        assert_eq!(editor.schema_to_role.len(), 2);
2447    }
2448
2449    #[test]
2450    fn round_trip_diff_engine_finds_no_structural_changes() {
2451        // Hardest test: build a flat graph, suggest profiles, expand back into
2452        // a graph, and run the actual `diff` engine. Only `SetComment` deltas
2453        // are allowed (auto-generated annotations).
2454        let yaml = r#"
2455default_owner: o
2456schemas:
2457  - name: s1
2458    owner: o
2459  - name: s2
2460    owner: o
2461  - name: s3
2462    owner: o
2463roles:
2464  - name: s1-rw
2465  - name: s2-rw
2466  - name: s3-rw
2467  - name: s1-ro
2468  - name: s2-ro
2469  - name: s3-ro
2470  - name: alice
2471    login: true
2472grants:
2473  - role: s1-rw
2474    privileges: [USAGE]
2475    object: { type: schema, name: s1 }
2476  - role: s1-rw
2477    privileges: [SELECT, INSERT, UPDATE, DELETE]
2478    object: { type: table, schema: s1, name: "*" }
2479  - role: s2-rw
2480    privileges: [USAGE]
2481    object: { type: schema, name: s2 }
2482  - role: s2-rw
2483    privileges: [SELECT, INSERT, UPDATE, DELETE]
2484    object: { type: table, schema: s2, name: "*" }
2485  - role: s3-rw
2486    privileges: [USAGE]
2487    object: { type: schema, name: s3 }
2488  - role: s3-rw
2489    privileges: [SELECT, INSERT, UPDATE, DELETE]
2490    object: { type: table, schema: s3, name: "*" }
2491  - role: s1-ro
2492    privileges: [USAGE]
2493    object: { type: schema, name: s1 }
2494  - role: s1-ro
2495    privileges: [SELECT]
2496    object: { type: table, schema: s1, name: "*" }
2497  - role: s2-ro
2498    privileges: [USAGE]
2499    object: { type: schema, name: s2 }
2500  - role: s2-ro
2501    privileges: [SELECT]
2502    object: { type: table, schema: s2, name: "*" }
2503  - role: s3-ro
2504    privileges: [USAGE]
2505    object: { type: schema, name: s3 }
2506  - role: s3-ro
2507    privileges: [SELECT]
2508    object: { type: table, schema: s3, name: "*" }
2509default_privileges:
2510  - owner: o
2511    schema: s1
2512    grant:
2513      - role: s1-rw
2514        privileges: [SELECT, INSERT, UPDATE, DELETE]
2515        on_type: table
2516      - role: s1-ro
2517        privileges: [SELECT]
2518        on_type: table
2519  - owner: o
2520    schema: s2
2521    grant:
2522      - role: s2-rw
2523        privileges: [SELECT, INSERT, UPDATE, DELETE]
2524        on_type: table
2525      - role: s2-ro
2526        privileges: [SELECT]
2527        on_type: table
2528  - owner: o
2529    schema: s3
2530    grant:
2531      - role: s3-rw
2532        privileges: [SELECT, INSERT, UPDATE, DELETE]
2533        on_type: table
2534      - role: s3-ro
2535        privileges: [SELECT]
2536        on_type: table
2537memberships:
2538  - role: s1-rw
2539    members:
2540      - name: alice
2541"#;
2542        let m = parse(yaml);
2543        let report = suggest_profiles(&m, &SuggestOptions::default());
2544        assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2545        assert_eq!(report.profiles.len(), 2);
2546
2547        // Final: the actual diff engine should find no structural changes.
2548        let original_expanded = expand_manifest(&m).unwrap();
2549        let original_graph =
2550            RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
2551        let new_expanded = expand_manifest(&report.manifest).unwrap();
2552        let new_graph =
2553            RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
2554                .unwrap();
2555        let changes = diff(&original_graph, &new_graph);
2556        let bad: Vec<_> = changes
2557            .iter()
2558            .filter(|c| !matches!(c, Change::SetComment { .. }))
2559            .collect();
2560        assert!(bad.is_empty(), "structural drift: {bad:?}");
2561    }
2562
2563    #[test]
2564    fn empty_manifest_is_idempotent() {
2565        let m = parse("");
2566        let report = suggest_profiles(&m, &SuggestOptions::default());
2567        assert!(report.profiles.is_empty());
2568        assert!(report.round_trip_ok);
2569    }
2570
2571    #[test]
2572    fn schema_with_special_chars_in_name() {
2573        // Schema names can contain underscores, hyphens, digits.
2574        let m = parse(
2575            r#"
2576schemas:
2577  - name: app_v2
2578    owner: o
2579  - name: app_v3
2580    owner: o
2581roles:
2582  - name: app_v2-rw
2583  - name: app_v3-rw
2584grants:
2585  - role: app_v2-rw
2586    privileges: [SELECT]
2587    object: { type: table, schema: app_v2, name: "*" }
2588  - role: app_v3-rw
2589    privileges: [SELECT]
2590    object: { type: table, schema: app_v3, name: "*" }
2591"#,
2592        );
2593        let report = suggest_profiles(&m, &SuggestOptions::default());
2594        assert!(report.round_trip_ok);
2595        assert_eq!(report.profiles.len(), 1);
2596        assert_eq!(report.profiles[0].profile_name, "rw");
2597    }
2598
2599    #[test]
2600    fn schema_name_is_substring_of_role_name() {
2601        // Schema "app" is a substring of role "appraiser-app". The match_pattern
2602        // logic uses strip_prefix/strip_suffix, so a role name that starts with
2603        // the schema but with no separator (e.g. "appfoo") shouldn't match. Test
2604        // this and adjacent edge cases.
2605        let m = parse(
2606            r#"
2607schemas:
2608  - name: app
2609    owner: o
2610  - name: api
2611    owner: o
2612roles:
2613  - name: app-rw
2614  - name: api-rw
2615grants:
2616  - role: app-rw
2617    privileges: [SELECT]
2618    object: { type: table, schema: app, name: "*" }
2619  - role: api-rw
2620    privileges: [SELECT]
2621    object: { type: table, schema: api, name: "*" }
2622"#,
2623        );
2624        let report = suggest_profiles(&m, &SuggestOptions::default());
2625        assert!(report.round_trip_ok);
2626        assert_eq!(report.profiles.len(), 1);
2627        assert_eq!(report.profiles[0].profile_name, "rw");
2628    }
2629
2630    #[test]
2631    fn is_valid_identifier_basic() {
2632        assert!(is_valid_identifier("reader"));
2633        assert!(is_valid_identifier("read-only"));
2634        assert!(is_valid_identifier("read_only"));
2635        assert!(is_valid_identifier("rw2"));
2636        assert!(!is_valid_identifier(""));
2637        assert!(!is_valid_identifier("-reader"));
2638        assert!(!is_valid_identifier("_reader"));
2639        assert!(!is_valid_identifier("read.only"));
2640        assert!(!is_valid_identifier("read only"));
2641    }
2642
2643    #[test]
2644    fn config_carrying_role_stays_flat_and_keeps_its_config() {
2645        // Two roles with an otherwise-identical, cluster-eligible shape would
2646        // normally be promoted into a profile — but one of them carries
2647        // `config`, which profiles can express yet the suggester never
2648        // clusters on (see the comment in the eligibility loop). It must stay
2649        // flat, and — this is the property that matters — its `config` must
2650        // not be lost anywhere along the way: round-tripping through
2651        // `suggest` -> `expand` must show the exact same config on the
2652        // (still flat) generated role.
2653        let m = parse(
2654            r#"
2655schemas:
2656  - name: inventory
2657    owner: o
2658  - name: checkout
2659    owner: o
2660roles:
2661  - name: inventory-reader
2662    login: true
2663    config:
2664      search_path: inventory
2665      statement_timeout: "30s"
2666  - name: checkout-reader
2667    login: true
2668grants:
2669  - role: inventory-reader
2670    privileges: [SELECT]
2671    object: { type: table, schema: inventory, name: "*" }
2672  - role: checkout-reader
2673    privileges: [SELECT]
2674    object: { type: table, schema: checkout, name: "*" }
2675"#,
2676        );
2677
2678        let report = suggest_profiles(&m, &SuggestOptions::default());
2679        assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2680
2681        // No cluster forms: inventory-reader is disqualified by `config`, and
2682        // checkout-reader is then a sole-schema role with no partner.
2683        assert!(
2684            report.profiles.is_empty(),
2685            "expected no profiles, got: {:?}",
2686            report.profiles
2687        );
2688        assert!(report.skipped.iter().any(
2689            |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
2690        ));
2691
2692        // Both roles remain flat in the suggested manifest, config intact.
2693        let role = report
2694            .manifest
2695            .roles
2696            .iter()
2697            .find(|r| r.name == "inventory-reader")
2698            .expect("inventory-reader should remain a flat role");
2699        assert_eq!(role.config["search_path"].0, "inventory");
2700        assert_eq!(role.config["statement_timeout"].0, "30s");
2701
2702        // And the expanded RoleGraph — what actually gets applied — carries
2703        // the same config through, matching the original manifest's graph.
2704        let original_expanded = expand_manifest(&m).unwrap();
2705        let original_graph =
2706            RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
2707        let new_expanded = expand_manifest(&report.manifest).unwrap();
2708        let new_graph =
2709            RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
2710                .unwrap();
2711        assert_eq!(
2712            original_graph.roles["inventory-reader"].config,
2713            new_graph.roles["inventory-reader"].config
2714        );
2715        assert_eq!(
2716            new_graph.roles["inventory-reader"].config["search_path"],
2717            "inventory"
2718        );
2719    }
2720}