use std::collections::{BTreeMap, BTreeSet};
use crate::diff::{Change, diff};
use crate::manifest::{
DefaultPrivilege, DefaultPrivilegeGrant, Grant, ObjectTarget, ObjectType, PolicyManifest,
Privilege, Profile, ProfileGrant, ProfileObjectTarget, RoleDefinition, SchemaBinding,
expand_manifest,
};
use crate::model::RoleGraph;
#[derive(Debug, Clone)]
pub struct SuggestOptions {
pub min_schemas: usize,
pub full_inventory: Option<Inventory>,
}
impl Default for SuggestOptions {
fn default() -> Self {
Self {
min_schemas: 2,
full_inventory: None,
}
}
}
#[derive(Debug, Clone)]
pub struct SuggestedProfile {
pub profile_name: String,
pub role_pattern: String,
pub schema_to_role: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum SkipReason {
MultiSchema { role: String, schemas: Vec<String> },
SchemaNotDeclared { role: String, schema: String },
OwnerMismatch { role: String, schema: String },
UniqueAttributes { role: String },
UnrepresentableGrant { role: String },
SoleSchema { role: String, schema: String },
NoUniformPattern { roles: Vec<String> },
SchemaPatternConflict {
schema: String,
winning_pattern: String,
dropped_roles: Vec<String>,
},
RoundTripFailure { reason: String },
IncompleteFullInventory { reason: String },
}
#[derive(Debug, Clone)]
pub struct SuggestReport {
pub manifest: PolicyManifest,
pub profiles: Vec<SuggestedProfile>,
pub skipped: Vec<SkipReason>,
pub round_trip_ok: bool,
}
pub fn suggest_profiles(input: &PolicyManifest, opts: &SuggestOptions) -> SuggestReport {
if !input.profiles.is_empty() {
return SuggestReport {
manifest: input.clone(),
profiles: vec![],
skipped: vec![],
round_trip_ok: true,
};
}
let mut skipped: Vec<SkipReason> = Vec::new();
let grant_inventory = build_inventory(input);
let collapse_inventory: Option<&Inventory> = match opts.full_inventory.as_ref() {
None => None,
Some(full) => match validate_full_inventory(&grant_inventory, full) {
Ok(()) => Some(full),
Err(reason) => {
skipped.push(SkipReason::IncompleteFullInventory { reason });
None
}
},
};
let mut role_grants: BTreeMap<String, Vec<Grant>> = BTreeMap::new();
for grant in &input.grants {
role_grants
.entry(grant.role.clone())
.or_default()
.push(grant.clone());
}
if let Some(inv) = collapse_inventory {
for grants in role_grants.values_mut() {
collapse_full_coverage_grants(grants, inv);
}
}
let mut role_dps: BTreeMap<String, Vec<(String, String, DefaultPrivilegeGrant)>> =
BTreeMap::new();
for dp in &input.default_privileges {
let owner = dp
.owner
.clone()
.or_else(|| input.default_owner.clone())
.unwrap_or_default();
for grant in &dp.grant {
if let Some(role) = &grant.role {
role_dps.entry(role.clone()).or_default().push((
owner.clone(),
dp.schema.clone(),
grant.clone(),
));
}
}
}
let schema_owner: BTreeMap<String, Option<String>> = input
.schemas
.iter()
.map(|s| {
(
s.name.clone(),
s.owner.clone().or_else(|| input.default_owner.clone()),
)
})
.collect();
struct Eligible {
role_name: String,
schema: String,
signature: RoleSignature,
login: Option<bool>,
inherit: Option<bool>,
}
let mut eligible: Vec<Eligible> = Vec::new();
let mut clustered_role_names: BTreeSet<String> = BTreeSet::new();
for role_def in &input.roles {
let role_name = &role_def.name;
let has_user_comment = role_def
.comment
.as_deref()
.is_some_and(|c| !is_auto_profile_comment(c));
if role_def.superuser.is_some()
|| role_def.createdb.is_some()
|| role_def.createrole.is_some()
|| role_def.replication.is_some()
|| role_def.bypassrls.is_some()
|| role_def.connection_limit.is_some()
|| role_def.password.is_some()
|| role_def.password_valid_until.is_some()
|| has_user_comment
{
skipped.push(SkipReason::UniqueAttributes {
role: role_name.clone(),
});
continue;
}
let mut schemas_seen: BTreeSet<String> = BTreeSet::new();
let mut has_unrepresentable_grant = false;
let role_grants_vec = role_grants.get(role_name).cloned().unwrap_or_default();
for g in &role_grants_vec {
match g.object.object_type {
ObjectType::Schema => match &g.object.name {
Some(name) => {
schemas_seen.insert(name.clone());
}
None => has_unrepresentable_grant = true,
},
ObjectType::Database => has_unrepresentable_grant = true,
_ => match &g.object.schema {
Some(s) => {
schemas_seen.insert(s.clone());
}
None => has_unrepresentable_grant = true,
},
}
}
if has_unrepresentable_grant {
skipped.push(SkipReason::UnrepresentableGrant {
role: role_name.clone(),
});
continue;
}
let role_dp_vec = role_dps.get(role_name).cloned().unwrap_or_default();
for (_, schema, _) in &role_dp_vec {
schemas_seen.insert(schema.clone());
}
if schemas_seen.is_empty() {
continue;
}
if schemas_seen.len() > 1 {
skipped.push(SkipReason::MultiSchema {
role: role_name.clone(),
schemas: schemas_seen.into_iter().collect(),
});
continue;
}
let schema = schemas_seen.into_iter().next().unwrap();
let Some(owner_for_schema) = schema_owner.get(&schema) else {
skipped.push(SkipReason::SchemaNotDeclared {
role: role_name.clone(),
schema,
});
continue;
};
let mut owner_mismatch = false;
for (owner, _, _) in &role_dp_vec {
if Some(owner.as_str()) != owner_for_schema.as_deref() {
owner_mismatch = true;
break;
}
}
if owner_mismatch {
skipped.push(SkipReason::OwnerMismatch {
role: role_name.clone(),
schema,
});
continue;
}
let signature = compute_signature(&role_grants_vec, &role_dp_vec, &schema);
eligible.push(Eligible {
role_name: role_name.clone(),
schema,
signature,
login: role_def.login,
inherit: role_def.inherit,
});
}
type ClusterKey = (RoleSignature, Option<bool>, Option<bool>);
let mut clusters: BTreeMap<ClusterKey, Vec<&Eligible>> = BTreeMap::new();
for el in &eligible {
clusters
.entry((el.signature.clone(), el.login, el.inherit))
.or_default()
.push(el);
}
let mut cluster_entries: Vec<_> = clusters.into_iter().collect();
cluster_entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
let pattern_priority = [
"{schema}-{profile}",
"{schema}_{profile}",
"{profile}-{schema}",
"{profile}_{schema}",
];
let mut schema_pattern: BTreeMap<String, String> = BTreeMap::new();
let mut schema_profiles: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut profiles_out: BTreeMap<String, Profile> = BTreeMap::new();
let mut suggested: Vec<SuggestedProfile> = Vec::new();
let mut taken_profile_names: BTreeSet<String> = BTreeSet::new();
for ((_signature, login, inherit), members) in cluster_entries {
let distinct_schemas: BTreeSet<&str> = members.iter().map(|m| m.schema.as_str()).collect();
if distinct_schemas.len() < opts.min_schemas {
for m in &members {
skipped.push(SkipReason::SoleSchema {
role: m.role_name.clone(),
schema: m.schema.clone(),
});
}
continue;
}
let mut seen_schemas: BTreeSet<&str> = BTreeSet::new();
let unique_members: Vec<&Eligible> = members
.iter()
.filter(|m| seen_schemas.insert(m.schema.as_str()))
.copied()
.collect();
let mut chosen: Option<(String, String)> = None;
let mut schema_conflict_blocking: Option<(String, String)> = None;
for pat in pattern_priority {
let viable_name: Option<String> = {
let mut names: BTreeSet<String> = BTreeSet::new();
let mut ok = true;
for m in &unique_members {
if let Some(prof) = match_pattern(pat, &m.role_name, &m.schema) {
names.insert(prof);
} else {
ok = false;
break;
}
}
if !ok || names.len() != 1 {
None
} else {
let n = names.into_iter().next().unwrap();
if !is_valid_identifier(&n)
|| taken_profile_names.contains(&n)
|| input.profiles.contains_key(&n)
{
None
} else {
Some(n)
}
}
};
let blocked_by_schema = unique_members.iter().find_map(|m| {
schema_pattern
.get(&m.schema)
.filter(|committed| *committed != pat)
.map(|committed| (m.schema.clone(), committed.clone()))
});
match (viable_name, blocked_by_schema) {
(Some(name), None) => {
chosen = Some((pat.to_string(), name));
break;
}
(Some(_), Some(conflict)) if schema_conflict_blocking.is_none() => {
schema_conflict_blocking = Some(conflict);
}
_ => {}
}
}
let Some((pattern, profile_name)) = chosen else {
if let Some((schema, winning_pattern)) = schema_conflict_blocking {
skipped.push(SkipReason::SchemaPatternConflict {
schema,
winning_pattern,
dropped_roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
});
} else {
skipped.push(SkipReason::NoUniformPattern {
roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
});
}
continue;
};
for m in &unique_members {
schema_pattern.insert(m.schema.clone(), pattern.clone());
schema_profiles
.entry(m.schema.clone())
.or_default()
.push(profile_name.clone());
clustered_role_names.insert(m.role_name.clone());
}
let representative = unique_members[0];
let rep_grants = role_grants
.get(&representative.role_name)
.cloned()
.unwrap_or_default();
let rep_dps = role_dps
.get(&representative.role_name)
.cloned()
.unwrap_or_default();
let profile = build_profile(
login,
inherit,
&rep_grants,
&rep_dps,
&representative.schema,
);
profiles_out.insert(profile_name.clone(), profile);
taken_profile_names.insert(profile_name.clone());
let schema_to_role: BTreeMap<String, String> = unique_members
.iter()
.map(|m| (m.schema.clone(), m.role_name.clone()))
.collect();
suggested.push(SuggestedProfile {
profile_name,
role_pattern: pattern,
schema_to_role,
});
}
let mut new_schemas: Vec<SchemaBinding> = input
.schemas
.iter()
.map(|s| {
let mut bound_profiles = schema_profiles.get(&s.name).cloned().unwrap_or_default();
bound_profiles.sort();
let pattern = schema_pattern
.get(&s.name)
.cloned()
.unwrap_or_else(|| s.role_pattern.clone());
SchemaBinding {
name: s.name.clone(),
profiles: bound_profiles,
role_pattern: pattern,
owner: s.owner.clone(),
}
})
.collect();
new_schemas.sort_by(|a, b| a.name.cmp(&b.name));
let new_roles: Vec<RoleDefinition> = input
.roles
.iter()
.filter(|r| !clustered_role_names.contains(&r.name))
.cloned()
.collect();
let new_grants: Vec<Grant> = input
.grants
.iter()
.filter(|g| !clustered_role_names.contains(&g.role))
.cloned()
.collect();
let new_default_privileges: Vec<DefaultPrivilege> = input
.default_privileges
.iter()
.filter_map(|dp| {
let kept: Vec<DefaultPrivilegeGrant> = dp
.grant
.iter()
.filter(|g| match &g.role {
Some(r) => !clustered_role_names.contains(r),
None => true,
})
.cloned()
.collect();
if kept.is_empty() {
None
} else {
Some(DefaultPrivilege {
owner: dp.owner.clone(),
schema: dp.schema.clone(),
grant: kept,
})
}
})
.collect();
let candidate = PolicyManifest {
default_owner: input.default_owner.clone(),
auth_providers: input.auth_providers.clone(),
profiles: profiles_out,
schemas: new_schemas,
roles: new_roles,
grants: new_grants,
default_privileges: new_default_privileges,
memberships: input.memberships.clone(),
retirements: input.retirements.clone(),
};
let round_trip_inventory = collapse_inventory.cloned().unwrap_or(grant_inventory);
let round_trip_ok = match check_round_trip(input, &candidate, &round_trip_inventory) {
Ok(()) => true,
Err(reason) => {
skipped.push(SkipReason::RoundTripFailure {
reason: reason.clone(),
});
false
}
};
let manifest = if round_trip_ok {
candidate
} else {
input.clone()
};
SuggestReport {
manifest,
profiles: if round_trip_ok { suggested } else { vec![] },
skipped,
round_trip_ok,
}
}
pub type Inventory = BTreeMap<(String, ObjectType), BTreeSet<String>>;
pub fn inventory_from_manifest_grants(m: &PolicyManifest) -> Inventory {
build_inventory(m)
}
#[deprecated(
note = "renamed to `inventory_from_manifest_grants` — must NOT be used as full_inventory"
)]
pub fn build_inventory_pub(m: &PolicyManifest) -> Inventory {
build_inventory(m)
}
pub fn expand_wildcard_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
expand_wildcards_in_place(grants, inventory)
}
fn validate_full_inventory(
grant_inventory: &Inventory,
full_inventory: &Inventory,
) -> Result<(), String> {
for (key, granted_names) in grant_inventory {
let Some(full_names) = full_inventory.get(key) else {
return Err(format!(
"full_inventory missing entry for (schema={}, type={:?}) — but {} object name(s) are referenced in input grants",
key.0,
key.1,
granted_names.len()
));
};
if let Some(missing) = granted_names.iter().find(|n| !full_names.contains(*n)) {
return Err(format!(
"full_inventory[(schema={}, type={:?})] does not contain {missing:?} but it appears in input grants",
key.0, key.1
));
}
}
Ok(())
}
fn build_inventory(m: &PolicyManifest) -> Inventory {
let mut inv: Inventory = BTreeMap::new();
for g in &m.grants {
match g.object.object_type {
ObjectType::Schema | ObjectType::Database => continue,
_ => {}
}
let Some(name) = g.object.name.as_ref() else {
continue;
};
if name == "*" {
continue;
}
let Some(schema) = g.object.schema.as_ref() else {
continue;
};
inv.entry((schema.clone(), g.object.object_type))
.or_default()
.insert(name.clone());
}
inv
}
fn collapse_full_coverage_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
let mut buckets: BTreeMap<(String, ObjectType), Vec<usize>> = BTreeMap::new();
let mut has_wildcard: BTreeSet<(String, ObjectType)> = BTreeSet::new();
for (i, g) in grants.iter().enumerate() {
match g.object.object_type {
ObjectType::Schema | ObjectType::Database => continue,
_ => {}
}
let Some(schema) = g.object.schema.as_ref() else {
continue;
};
let Some(name) = g.object.name.as_ref() else {
continue;
};
if name == "*" {
has_wildcard.insert((schema.clone(), g.object.object_type));
continue;
}
buckets
.entry((schema.clone(), g.object.object_type))
.or_default()
.push(i);
}
buckets.retain(|key, _| !has_wildcard.contains(key));
let mut to_remove: BTreeSet<usize> = BTreeSet::new();
let mut to_add: Vec<Grant> = Vec::new();
for ((schema, object_type), idxs) in buckets {
let first_privs = canonical_privs(&grants[idxs[0]].privileges);
let all_same = idxs
.iter()
.all(|&i| canonical_privs(&grants[i].privileges) == first_privs);
if !all_same {
continue;
}
let mut covered: BTreeSet<String> = BTreeSet::new();
for &i in &idxs {
if let Some(name) = grants[i].object.name.as_ref() {
covered.insert(name.clone());
}
}
let inv_names = inventory.get(&(schema.clone(), object_type));
let full_coverage = match inv_names {
Some(names) => &covered == names,
None => false,
};
if !full_coverage {
continue;
}
for &i in &idxs {
to_remove.insert(i);
}
let role = grants[idxs[0]].role.clone();
to_add.push(Grant {
role,
privileges: first_privs.into_iter().collect(),
object: ObjectTarget {
object_type,
schema: Some(schema),
name: Some("*".to_string()),
},
});
}
let mut remaining = Vec::with_capacity(grants.len() - to_remove.len() + to_add.len());
for (i, g) in grants.drain(..).enumerate() {
if !to_remove.contains(&i) {
remaining.push(g);
}
}
remaining.extend(to_add);
*grants = remaining;
}
fn canonical_privs(privs: &[Privilege]) -> Vec<Privilege> {
let mut out = privs.to_vec();
out.sort_by_key(|p| privilege_sort_key(*p));
out.dedup();
out
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct RoleSignature {
grants: Vec<SignatureGrant>,
defaults: Vec<SignatureDefault>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct SignatureGrant {
object_type: ObjectType,
name: Option<String>,
privileges: Vec<Privilege>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct SignatureDefault {
on_type: ObjectType,
privileges: Vec<Privilege>,
}
fn compute_signature(
grants: &[Grant],
dps: &[(String, String, DefaultPrivilegeGrant)],
schema: &str,
) -> RoleSignature {
let mut sig_grants: Vec<SignatureGrant> = grants
.iter()
.map(|g| {
let name = match g.object.object_type {
ObjectType::Schema => {
if g.object.name.as_deref() == Some(schema) {
None
} else {
g.object.name.clone()
}
}
_ => g.object.name.clone(),
};
let mut privs = g.privileges.clone();
privs.sort_by_key(|p| privilege_sort_key(*p));
privs.dedup();
SignatureGrant {
object_type: g.object.object_type,
name,
privileges: privs,
}
})
.collect();
sig_grants.sort();
sig_grants.dedup();
let mut sig_defaults: Vec<SignatureDefault> = dps
.iter()
.map(|(_, _, dpg)| {
let mut privs = dpg.privileges.clone();
privs.sort_by_key(|p| privilege_sort_key(*p));
privs.dedup();
SignatureDefault {
on_type: dpg.on_type,
privileges: privs,
}
})
.collect();
sig_defaults.sort();
sig_defaults.dedup();
RoleSignature {
grants: sig_grants,
defaults: sig_defaults,
}
}
fn privilege_sort_key(p: Privilege) -> u8 {
match p {
Privilege::Select => 0,
Privilege::Insert => 1,
Privilege::Update => 2,
Privilege::Delete => 3,
Privilege::Truncate => 4,
Privilege::References => 5,
Privilege::Trigger => 6,
Privilege::Execute => 7,
Privilege::Usage => 8,
Privilege::Create => 9,
Privilege::Connect => 10,
Privilege::Temporary => 11,
}
}
fn match_pattern(pattern: &str, role_name: &str, schema: &str) -> Option<String> {
match pattern {
"{schema}-{profile}" => role_name
.strip_prefix(schema)
.and_then(|r| r.strip_prefix('-'))
.filter(|p| !p.is_empty())
.map(|p| p.to_string()),
"{schema}_{profile}" => role_name
.strip_prefix(schema)
.and_then(|r| r.strip_prefix('_'))
.filter(|p| !p.is_empty())
.map(|p| p.to_string()),
"{profile}-{schema}" => role_name
.strip_suffix(schema)
.and_then(|r| r.strip_suffix('-'))
.filter(|p| !p.is_empty())
.map(|p| p.to_string()),
"{profile}_{schema}" => role_name
.strip_suffix(schema)
.and_then(|r| r.strip_suffix('_'))
.filter(|p| !p.is_empty())
.map(|p| p.to_string()),
_ => None,
}
}
fn is_auto_profile_comment(c: &str) -> bool {
c.starts_with("Generated from profile '") && c.contains("' for schema '") && c.ends_with('\'')
}
fn is_valid_identifier(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& !s.starts_with('-')
&& !s.starts_with('_')
}
fn build_profile(
login: Option<bool>,
inherit: Option<bool>,
grants: &[Grant],
dps: &[(String, String, DefaultPrivilegeGrant)],
#[cfg_attr(not(debug_assertions), allow(unused_variables))] schema: &str,
) -> Profile {
let mut profile_grants: Vec<ProfileGrant> = grants
.iter()
.map(|g| {
let object = match g.object.object_type {
ObjectType::Schema => ProfileObjectTarget {
object_type: ObjectType::Schema,
name: None,
},
_ => {
debug_assert_eq!(g.object.schema.as_deref(), Some(schema));
ProfileObjectTarget {
object_type: g.object.object_type,
name: g.object.name.clone(),
}
}
};
let mut privs = g.privileges.clone();
privs.sort_by_key(|p| privilege_sort_key(*p));
privs.dedup();
ProfileGrant {
privileges: privs,
object,
}
})
.collect();
profile_grants.sort_by(|a, b| {
let key_a = (a.object.object_type, a.object.name.clone());
let key_b = (b.object.object_type, b.object.name.clone());
key_a.cmp(&key_b)
});
let mut profile_defaults: Vec<DefaultPrivilegeGrant> = dps
.iter()
.map(|(_, _, dpg)| {
let mut privs = dpg.privileges.clone();
privs.sort_by_key(|p| privilege_sort_key(*p));
privs.dedup();
DefaultPrivilegeGrant {
role: None, privileges: privs,
on_type: dpg.on_type,
}
})
.collect();
profile_defaults.sort_by_key(|d| d.on_type);
Profile {
login,
inherit,
grants: profile_grants,
default_privileges: profile_defaults,
}
}
fn check_round_trip(
original: &PolicyManifest,
candidate: &PolicyManifest,
inventory: &Inventory,
) -> Result<(), String> {
let mut original_expanded =
expand_manifest(original).map_err(|e| format!("original expand: {e}"))?;
expand_wildcards_in_place(&mut original_expanded.grants, inventory);
let original_graph =
RoleGraph::from_expanded(&original_expanded, original.default_owner.as_deref())
.map_err(|e| format!("original graph: {e}"))?;
let mut candidate_expanded =
expand_manifest(candidate).map_err(|e| format!("candidate expand: {e}"))?;
expand_wildcards_in_place(&mut candidate_expanded.grants, inventory);
let candidate_graph =
RoleGraph::from_expanded(&candidate_expanded, candidate.default_owner.as_deref())
.map_err(|e| format!("candidate graph: {e}"))?;
let changes = diff(&original_graph, &candidate_graph);
let unacceptable: Vec<&Change> = changes
.iter()
.filter(|c| !matches!(c, Change::SetComment { .. }))
.collect();
if !unacceptable.is_empty() {
return Err(format!(
"{} structural change(s) after suggestion (sample: {:?})",
unacceptable.len(),
unacceptable.first()
));
}
Ok(())
}
fn expand_wildcards_in_place(grants: &mut Vec<Grant>, inventory: &Inventory) {
let mut out: Vec<Grant> = Vec::with_capacity(grants.len());
for g in grants.drain(..) {
let is_wildcard = matches!(
g.object.object_type,
ObjectType::Table
| ObjectType::View
| ObjectType::MaterializedView
| ObjectType::Sequence
| ObjectType::Function
| ObjectType::Type
) && g.object.name.as_deref() == Some("*");
if !is_wildcard {
out.push(g);
continue;
}
let Some(schema) = g.object.schema.as_ref() else {
out.push(g);
continue;
};
let key = (schema.clone(), g.object.object_type);
if let Some(names) = inventory.get(&key) {
for name in names {
out.push(Grant {
role: g.role.clone(),
privileges: g.privileges.clone(),
object: ObjectTarget {
object_type: g.object.object_type,
schema: g.object.schema.clone(),
name: Some(name.clone()),
},
});
}
} else {
out.push(g);
}
}
*grants = out;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::parse_manifest;
fn parse(yaml: &str) -> PolicyManifest {
parse_manifest(yaml).expect("parse")
}
#[test]
fn no_input_profiles_no_clusters_returns_unchanged() {
let m = parse(
r#"
roles:
- name: alice
login: true
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(report.round_trip_ok);
}
#[test]
fn input_with_existing_profiles_is_left_alone() {
let m = parse(
r#"
profiles:
reader:
grants:
- privileges: [USAGE]
object: { type: schema }
schemas:
- name: x
profiles: [reader]
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert_eq!(report.manifest.profiles.len(), 1);
}
#[test]
fn clusters_two_schemas_with_dash_pattern() {
let m = parse(
r#"
default_owner: app_owner
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
- name: analytics
owner: app_owner
roles:
- name: inventory-reader
- name: checkout-reader
- name: analytics-reader
grants:
- role: inventory-reader
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
- role: analytics-reader
privileges: [USAGE]
object: { type: schema, name: analytics }
- role: analytics-reader
privileges: [SELECT]
object: { type: table, schema: analytics, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
assert_eq!(report.profiles.len(), 1);
let p = &report.profiles[0];
assert_eq!(p.profile_name, "reader");
assert_eq!(p.role_pattern, "{schema}-{profile}");
assert_eq!(p.schema_to_role.len(), 3);
assert!(report.manifest.profiles.contains_key("reader"));
assert!(
report
.manifest
.roles
.iter()
.all(|r| !r.name.ends_with("-reader"))
);
for s in &report.manifest.schemas {
assert_eq!(s.profiles, vec!["reader"]);
assert_eq!(s.role_pattern, "{schema}-{profile}");
}
}
#[test]
fn clusters_with_underscore_pattern() {
let m = parse(
r#"
default_owner: app_owner
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
roles:
- name: inventory_app
login: true
- name: checkout_app
login: true
grants:
- role: inventory_app
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory_app
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: inventory, name: "*" }
- role: checkout_app
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout_app
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
let p = &report.profiles[0];
assert_eq!(p.profile_name, "app");
assert_eq!(p.role_pattern, "{schema}_{profile}");
let prof = report.manifest.profiles.get("app").unwrap();
assert_eq!(prof.login, Some(true));
}
#[test]
fn does_not_cluster_single_schema_role() {
let m = parse(
r#"
schemas:
- name: inventory
owner: app_owner
roles:
- name: inventory-reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(matches!(
report.skipped.first(),
Some(SkipReason::SoleSchema { .. })
));
}
#[test]
fn min_schemas_one_promotes_single_schema_role() {
let m = parse(
r#"
schemas:
- name: inventory
owner: app_owner
roles:
- name: inventory-reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
"#,
);
let report = suggest_profiles(
&m,
&SuggestOptions {
min_schemas: 1,
..Default::default()
},
);
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
}
#[test]
fn role_with_unique_attributes_stays_flat() {
let m = parse(
r#"
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
roles:
- name: inventory-reader
connection_limit: 5
- name: checkout-reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(report.skipped.iter().any(
|s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
));
}
#[test]
fn multi_schema_role_skipped() {
let m = parse(
r#"
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
roles:
- name: cross
grants:
- role: cross
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: cross
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "cross"))
);
}
#[test]
fn non_uniform_pattern_skipped() {
let m = parse(
r#"
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
roles:
- name: inventory-reader
- name: checkout_reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout_reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::NoUniformPattern { .. }))
);
}
#[test]
fn different_login_split_into_separate_clusters() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
- name: c
owner: o
- name: d
owner: o
roles:
- name: a-svc
login: true
- name: b-svc
login: true
- name: c-svc
- name: d-svc
grants:
- role: a-svc
privileges: [SELECT]
object: { type: table, schema: a, name: "*" }
- role: b-svc
privileges: [SELECT]
object: { type: table, schema: b, name: "*" }
- role: c-svc
privileges: [SELECT]
object: { type: table, schema: c, name: "*" }
- role: d-svc
privileges: [SELECT]
object: { type: table, schema: d, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
assert_eq!(report.profiles[0].profile_name, "svc");
let kept_role_names: BTreeSet<&str> = report
.manifest
.roles
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(kept_role_names.len(), 2);
}
#[test]
fn round_trip_zero_diff() {
let m = parse(
r#"
default_owner: app_owner
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
roles:
- name: inventory-rw
- name: checkout-rw
grants:
- role: inventory-rw
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: inventory, name: "*" }
- role: inventory-rw
privileges: [USAGE, SELECT]
object: { type: sequence, schema: inventory, name: "*" }
- role: checkout-rw
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: checkout, name: "*" }
- role: checkout-rw
privileges: [USAGE, SELECT]
object: { type: sequence, schema: checkout, name: "*" }
default_privileges:
- owner: app_owner
schema: inventory
grant:
- role: inventory-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
- owner: app_owner
schema: checkout
grant:
- role: checkout-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
let prof = report.manifest.profiles.get("rw").unwrap();
assert_eq!(prof.grants.len(), 3);
assert_eq!(prof.default_privileges.len(), 1);
let original_expanded = expand_manifest(&m).unwrap();
let original_graph =
RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
let new_expanded = expand_manifest(&report.manifest).unwrap();
let new_graph =
RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
.unwrap();
let changes = diff(&original_graph, &new_graph);
let bad: Vec<_> = changes
.iter()
.filter(|c| !matches!(c, Change::SetComment { .. }))
.collect();
assert!(bad.is_empty(), "unexpected diff: {bad:?}");
}
#[test]
fn schema_pattern_conflict_drops_smaller_cluster() {
let m = parse(
r#"
default_owner: o
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
- name: checkout-reader
- name: inventory_writer
- name: checkout_writer
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
- role: inventory_writer
privileges: [INSERT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout_writer
privileges: [INSERT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(
report.profiles.len(),
1,
"exactly one profile should win: {:?}",
report.profiles
);
let conflicts: Vec<_> = report
.skipped
.iter()
.filter_map(|s| match s {
SkipReason::SchemaPatternConflict {
schema,
winning_pattern,
dropped_roles,
} => Some((schema, winning_pattern, dropped_roles)),
_ => None,
})
.collect();
assert_eq!(
conflicts.len(),
1,
"expected one SchemaPatternConflict skip, got: {:?}",
report.skipped
);
let (_, winning, dropped) = conflicts[0];
assert!(
winning == "{schema}-{profile}" || winning == "{schema}_{profile}",
"unexpected winning_pattern: {winning}"
);
assert_eq!(dropped.len(), 2);
}
#[test]
fn match_pattern_basic() {
assert_eq!(
match_pattern("{schema}-{profile}", "inventory-reader", "inventory"),
Some("reader".into())
);
assert_eq!(
match_pattern("{schema}_{profile}", "inventory_app", "inventory"),
Some("app".into())
);
assert_eq!(
match_pattern("{profile}-{schema}", "ro-inventory", "inventory"),
Some("ro".into())
);
assert_eq!(
match_pattern("{profile}_{schema}", "ro_inventory", "inventory"),
Some("ro".into())
);
assert_eq!(
match_pattern("{schema}-{profile}", "checkout-reader", "inventory"),
None
);
assert_eq!(
match_pattern("{schema}-{profile}", "inventory-", "inventory"),
None
);
assert_eq!(
match_pattern("{schema}-{profile}", "inventoryreader", "inventory"),
None
);
}
#[test]
fn database_grants_excluded_from_clustering() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-svc
- name: b-svc
grants:
- role: a-svc
privileges: [CONNECT]
object: { type: database, name: mydb }
- role: a-svc
privileges: [SELECT]
object: { type: table, schema: a, name: "*" }
- role: b-svc
privileges: [SELECT]
object: { type: table, schema: b, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::UnrepresentableGrant { role } if role == "a-svc"))
);
}
#[test]
fn membership_targets_clustered_role_still_resolve_after_suggestion() {
let m = parse(
r#"
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
- name: checkout-reader
- name: alice
login: true
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
memberships:
- role: inventory-reader
members:
- name: alice
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.manifest.memberships.len(), 1);
assert_eq!(report.manifest.memberships[0].role, "inventory-reader");
let expanded = expand_manifest(&report.manifest).unwrap();
assert!(expanded.roles.iter().any(|r| r.name == "inventory-reader"));
assert!(expanded.roles.iter().any(|r| r.name == "checkout-reader"));
}
#[test]
fn wildcard_object_names_preserved_in_profile() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-rw
- name: b-rw
grants:
- role: a-rw
privileges: [SELECT, INSERT]
object: { type: table, schema: a, name: "*" }
- role: a-rw
privileges: [USAGE]
object: { type: sequence, schema: a, name: orders_id_seq }
- role: b-rw
privileges: [SELECT, INSERT]
object: { type: table, schema: b, name: "*" }
- role: b-rw
privileges: [USAGE]
object: { type: sequence, schema: b, name: orders_id_seq }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
let prof = report.manifest.profiles.get("rw").unwrap();
let seq_grant = prof
.grants
.iter()
.find(|g| g.object.object_type == ObjectType::Sequence)
.unwrap();
assert_eq!(seq_grant.object.name.as_deref(), Some("orders_id_seq"));
let inv = inventory_from_manifest_grants(&m);
let report = suggest_profiles(
&m,
&SuggestOptions {
full_inventory: Some(inv),
..Default::default()
},
);
assert!(report.round_trip_ok);
let prof = report.manifest.profiles.get("rw").unwrap();
let seq_grant = prof
.grants
.iter()
.find(|g| g.object.object_type == ObjectType::Sequence)
.unwrap();
assert_eq!(
seq_grant.object.name.as_deref(),
Some("*"),
"single-object full coverage should collapse to wildcard"
);
}
#[test]
fn collapse_clusters_roles_with_different_object_names() {
let m = parse(
r#"
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
- name: checkout-reader
grants:
- role: inventory-reader
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: products }
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: stock_levels }
- role: checkout-reader
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: orders }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: order_items }
"#,
);
let inv = inventory_from_manifest_grants(&m);
let report = suggest_profiles(
&m,
&SuggestOptions {
full_inventory: Some(inv),
..Default::default()
},
);
assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
assert_eq!(report.profiles.len(), 1);
let prof = report.manifest.profiles.get("reader").unwrap();
let table_grant = prof
.grants
.iter()
.find(|g| g.object.object_type == ObjectType::Table)
.unwrap();
assert_eq!(table_grant.object.name.as_deref(), Some("*"));
}
#[test]
fn no_full_inventory_prevents_clustering_across_different_names() {
let m = parse(
r#"
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
- name: checkout-reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: products }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: orders }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
}
#[test]
fn collapse_partial_coverage_preserves_per_name_grants() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-ro
- name: b-ro
grants:
- role: a-ro
privileges: [SELECT]
object: { type: table, schema: a, name: t1 }
# a-ro has no grant on a.t2 (which exists, evidenced by another role)
- role: filler
privileges: [SELECT]
object: { type: table, schema: a, name: t2 }
- role: b-ro
privileges: [SELECT]
object: { type: table, schema: b, name: only_one }
"#,
);
let inv = inventory_from_manifest_grants(&m);
let report = suggest_profiles(
&m,
&SuggestOptions {
full_inventory: Some(inv),
..Default::default()
},
);
assert!(report.profiles.is_empty());
}
#[test]
fn incomplete_full_inventory_disables_collapse_with_skip_reason() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-rw
- name: b-rw
grants:
- role: a-rw
privileges: [SELECT]
object: { type: table, schema: a, name: products }
- role: b-rw
privileges: [SELECT]
object: { type: table, schema: b, name: orders }
"#,
);
let mut bad: Inventory = BTreeMap::new();
bad.entry(("a".to_string(), ObjectType::Table)).or_default(); bad.entry(("b".to_string(), ObjectType::Table))
.or_default()
.insert("orders".to_string());
let report = suggest_profiles(
&m,
&SuggestOptions {
full_inventory: Some(bad),
..Default::default()
},
);
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::IncompleteFullInventory { .. })),
"expected IncompleteFullInventory skip; got: {:?}",
report.skipped
);
}
#[test]
fn full_inventory_with_ungranted_objects_blocks_unsafe_collapse() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-ro
- name: b-ro
grants:
- role: a-ro
privileges: [SELECT]
object: { type: table, schema: a, name: t1 }
- role: b-ro
privileges: [SELECT]
object: { type: table, schema: b, name: only_one }
"#,
);
let mut inv = inventory_from_manifest_grants(&m);
inv.entry(("a".to_string(), ObjectType::Table))
.or_default()
.insert("t2_ungranted".to_string());
let report = suggest_profiles(
&m,
&SuggestOptions {
full_inventory: Some(inv),
..Default::default()
},
);
assert!(report.profiles.is_empty());
}
#[test]
fn auto_generated_profile_comments_dont_block_resuggestion() {
let m = parse(
r#"
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
comment: "Generated from profile 'reader' for schema 'inventory'"
- name: checkout-reader
comment: "Generated from profile 'reader' for schema 'checkout'"
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
assert_eq!(report.profiles[0].profile_name, "reader");
}
#[test]
fn user_set_comments_still_block_clustering() {
let m = parse(
r#"
schemas:
- name: inventory
owner: o
- name: checkout
owner: o
roles:
- name: inventory-reader
comment: "Owned by data team — Q3 access only"
- name: checkout-reader
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(report.skipped.iter().any(
|s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
));
}
#[test]
fn is_auto_profile_comment_basic() {
assert!(is_auto_profile_comment(
"Generated from profile 'reader' for schema 'inventory'"
));
assert!(is_auto_profile_comment(
"Generated from profile 'app-rw' for schema 'app_v2'"
));
assert!(!is_auto_profile_comment("Random user note"));
assert!(!is_auto_profile_comment(
"Generated from profile 'reader' for schema 'inventory"
)); assert!(!is_auto_profile_comment("Generated from profile 'reader'")); }
#[test]
fn function_grants_with_signature_in_name_round_trip() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-rw
- name: b-rw
grants:
- role: a-rw
privileges: [EXECUTE]
object: { type: function, schema: a, name: "order_total(bigint)" }
- role: b-rw
privileges: [EXECUTE]
object: { type: function, schema: b, name: "order_total(bigint)" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
}
#[test]
fn default_privilege_owner_mismatch_excludes_role() {
let m = parse(
r#"
schemas:
- name: a
owner: app_owner
- name: b
owner: app_owner
roles:
- name: a-rw
- name: b-rw
grants:
- role: a-rw
privileges: [SELECT]
object: { type: table, schema: a, name: "*" }
- role: b-rw
privileges: [SELECT]
object: { type: table, schema: b, name: "*" }
default_privileges:
- owner: a_different_owner # mismatch — schema "a" is owned by app_owner
schema: a
grant:
- role: a-rw
privileges: [SELECT]
on_type: table
- owner: app_owner
schema: b
grant:
- role: b-rw
privileges: [SELECT]
on_type: table
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::OwnerMismatch { role, .. } if role == "a-rw"))
);
}
#[test]
fn role_with_zero_grants_is_left_flat() {
let m = parse(
r#"
schemas:
- name: a
owner: o
roles:
- name: lonely
login: true
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(report.round_trip_ok);
assert!(report.manifest.roles.iter().any(|r| r.name == "lonely"));
}
#[test]
fn schema_typed_grant_pointing_to_unrelated_schema_excludes_role() {
let m = parse(
r#"
schemas:
- name: a
owner: o
- name: b
owner: o
roles:
- name: a-rw
- name: b-rw
grants:
- role: a-rw
privileges: [USAGE]
object: { type: schema, name: a }
- role: a-rw
privileges: [USAGE]
object: { type: schema, name: b } # surprise: also touches b
- role: b-rw
privileges: [USAGE]
object: { type: schema, name: b }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(
report
.skipped
.iter()
.any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "a-rw"))
);
}
#[test]
fn determinism_same_input_same_output() {
let yaml = r#"
default_owner: app_owner
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
- name: analytics
owner: app_owner
roles:
- name: inventory-reader
- name: checkout-reader
- name: analytics-reader
- name: inventory-rw
- name: checkout-rw
- name: analytics-rw
grants:
- role: inventory-reader
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-reader
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
- role: analytics-reader
privileges: [SELECT]
object: { type: table, schema: analytics, name: "*" }
- role: inventory-rw
privileges: [SELECT, INSERT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-rw
privileges: [SELECT, INSERT]
object: { type: table, schema: checkout, name: "*" }
- role: analytics-rw
privileges: [SELECT, INSERT]
object: { type: table, schema: analytics, name: "*" }
"#;
let m1 = parse(yaml);
let m2 = parse(yaml);
let r1 = suggest_profiles(&m1, &SuggestOptions::default());
let r2 = suggest_profiles(&m2, &SuggestOptions::default());
assert_eq!(r1.profiles.len(), 2);
assert_eq!(r2.profiles.len(), 2);
assert_eq!(
serde_yaml::to_string(&r1.manifest).unwrap(),
serde_yaml::to_string(&r2.manifest).unwrap()
);
}
#[test]
fn realistic_scenario_full_round_trip() {
let yaml = r#"
default_owner: app_owner
schemas:
- name: inventory
owner: app_owner
- name: checkout
owner: app_owner
- name: analytics
owner: analytics_owner
roles:
- name: app_owner
- name: analytics_owner
- name: inventory-editor
- name: checkout-editor
- name: inventory-viewer
- name: checkout-viewer
- name: analytics-viewer
- name: data_analyst
grants:
- role: inventory-editor
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory-editor
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: inventory, name: "*" }
- role: inventory-editor
privileges: [USAGE, SELECT]
object: { type: sequence, schema: inventory, name: "*" }
- role: checkout-editor
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout-editor
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: checkout, name: "*" }
- role: checkout-editor
privileges: [USAGE, SELECT]
object: { type: sequence, schema: checkout, name: "*" }
- role: inventory-viewer
privileges: [USAGE]
object: { type: schema, name: inventory }
- role: inventory-viewer
privileges: [SELECT]
object: { type: table, schema: inventory, name: "*" }
- role: checkout-viewer
privileges: [USAGE]
object: { type: schema, name: checkout }
- role: checkout-viewer
privileges: [SELECT]
object: { type: table, schema: checkout, name: "*" }
- role: analytics-viewer
privileges: [USAGE]
object: { type: schema, name: analytics }
- role: analytics-viewer
privileges: [SELECT]
object: { type: table, schema: analytics, name: "*" }
default_privileges:
- owner: app_owner
schema: inventory
grant:
- role: inventory-editor
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
- owner: app_owner
schema: checkout
grant:
- role: checkout-editor
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
memberships:
- role: inventory-editor
members:
- name: data_analyst
- role: analytics-viewer
members:
- name: data_analyst
"#;
let m = parse(yaml);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
let names: BTreeSet<String> = report
.profiles
.iter()
.map(|p| p.profile_name.clone())
.collect();
assert!(names.contains("editor"), "got: {names:?}");
assert!(names.contains("viewer"), "got: {names:?}");
assert_eq!(report.manifest.memberships.len(), 2);
let expanded = expand_manifest(&report.manifest).unwrap();
let role_names: BTreeSet<String> = expanded.roles.iter().map(|r| r.name.clone()).collect();
for orig in [
"inventory-editor",
"checkout-editor",
"inventory-viewer",
"checkout-viewer",
"analytics-viewer",
"data_analyst",
"app_owner",
"analytics_owner",
] {
assert!(
role_names.contains(orig),
"missing role {orig} in re-expanded manifest"
);
}
let viewer = report
.profiles
.iter()
.find(|p| p.profile_name == "viewer")
.unwrap();
assert_eq!(viewer.schema_to_role.len(), 3);
let editor = report
.profiles
.iter()
.find(|p| p.profile_name == "editor")
.unwrap();
assert_eq!(editor.schema_to_role.len(), 2);
}
#[test]
fn round_trip_diff_engine_finds_no_structural_changes() {
let yaml = r#"
default_owner: o
schemas:
- name: s1
owner: o
- name: s2
owner: o
- name: s3
owner: o
roles:
- name: s1-rw
- name: s2-rw
- name: s3-rw
- name: s1-ro
- name: s2-ro
- name: s3-ro
- name: alice
login: true
grants:
- role: s1-rw
privileges: [USAGE]
object: { type: schema, name: s1 }
- role: s1-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: s1, name: "*" }
- role: s2-rw
privileges: [USAGE]
object: { type: schema, name: s2 }
- role: s2-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: s2, name: "*" }
- role: s3-rw
privileges: [USAGE]
object: { type: schema, name: s3 }
- role: s3-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, schema: s3, name: "*" }
- role: s1-ro
privileges: [USAGE]
object: { type: schema, name: s1 }
- role: s1-ro
privileges: [SELECT]
object: { type: table, schema: s1, name: "*" }
- role: s2-ro
privileges: [USAGE]
object: { type: schema, name: s2 }
- role: s2-ro
privileges: [SELECT]
object: { type: table, schema: s2, name: "*" }
- role: s3-ro
privileges: [USAGE]
object: { type: schema, name: s3 }
- role: s3-ro
privileges: [SELECT]
object: { type: table, schema: s3, name: "*" }
default_privileges:
- owner: o
schema: s1
grant:
- role: s1-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
- role: s1-ro
privileges: [SELECT]
on_type: table
- owner: o
schema: s2
grant:
- role: s2-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
- role: s2-ro
privileges: [SELECT]
on_type: table
- owner: o
schema: s3
grant:
- role: s3-rw
privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
- role: s3-ro
privileges: [SELECT]
on_type: table
memberships:
- role: s1-rw
members:
- name: alice
"#;
let m = parse(yaml);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
assert_eq!(report.profiles.len(), 2);
let original_expanded = expand_manifest(&m).unwrap();
let original_graph =
RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
let new_expanded = expand_manifest(&report.manifest).unwrap();
let new_graph =
RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
.unwrap();
let changes = diff(&original_graph, &new_graph);
let bad: Vec<_> = changes
.iter()
.filter(|c| !matches!(c, Change::SetComment { .. }))
.collect();
assert!(bad.is_empty(), "structural drift: {bad:?}");
}
#[test]
fn empty_manifest_is_idempotent() {
let m = parse("");
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.profiles.is_empty());
assert!(report.round_trip_ok);
}
#[test]
fn schema_with_special_chars_in_name() {
let m = parse(
r#"
schemas:
- name: app_v2
owner: o
- name: app_v3
owner: o
roles:
- name: app_v2-rw
- name: app_v3-rw
grants:
- role: app_v2-rw
privileges: [SELECT]
object: { type: table, schema: app_v2, name: "*" }
- role: app_v3-rw
privileges: [SELECT]
object: { type: table, schema: app_v3, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
assert_eq!(report.profiles[0].profile_name, "rw");
}
#[test]
fn schema_name_is_substring_of_role_name() {
let m = parse(
r#"
schemas:
- name: app
owner: o
- name: api
owner: o
roles:
- name: app-rw
- name: api-rw
grants:
- role: app-rw
privileges: [SELECT]
object: { type: table, schema: app, name: "*" }
- role: api-rw
privileges: [SELECT]
object: { type: table, schema: api, name: "*" }
"#,
);
let report = suggest_profiles(&m, &SuggestOptions::default());
assert!(report.round_trip_ok);
assert_eq!(report.profiles.len(), 1);
assert_eq!(report.profiles[0].profile_name, "rw");
}
#[test]
fn is_valid_identifier_basic() {
assert!(is_valid_identifier("reader"));
assert!(is_valid_identifier("read-only"));
assert!(is_valid_identifier("read_only"));
assert!(is_valid_identifier("rw2"));
assert!(!is_valid_identifier(""));
assert!(!is_valid_identifier("-reader"));
assert!(!is_valid_identifier("_reader"));
assert!(!is_valid_identifier("read.only"));
assert!(!is_valid_identifier("read only"));
}
}