use std::path::Path;
use anyhow::{Context, Result};
use pgroles_core::diff::{self, Change};
use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
use pgroles_core::model::RoleGraph;
use pgroles_core::sql;
pub fn read_manifest_file(path: &Path) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read manifest file: {}", path.display()))
}
pub fn parse(yaml: &str) -> Result<PolicyManifest> {
manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
}
pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
let policy_manifest = parse(yaml)?;
manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
}
pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
let policy_manifest = parse(yaml)?;
if policy_manifest.roles.is_empty()
&& policy_manifest.schemas.is_empty()
&& policy_manifest.grants.is_empty()
&& policy_manifest.memberships.is_empty()
{
tracing::warn!(
"manifest defines no roles, schemas, grants, or memberships — is the file correct?"
);
}
let expanded =
manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
let default_owner = policy_manifest.default_owner.as_deref();
let desired = RoleGraph::from_expanded(&expanded, default_owner)
.map_err(|err| anyhow::anyhow!("{err}"))?;
Ok(ValidatedManifest {
manifest: policy_manifest,
expanded,
desired,
})
}
pub struct ValidatedManifest {
pub manifest: PolicyManifest,
pub expanded: ExpandedManifest,
pub desired: RoleGraph,
}
pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
diff::diff(current, desired)
}
pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
changes
.iter()
.filter_map(|change| match change {
Change::DropRole { name } => Some(name.clone()),
_ => None,
})
.collect()
}
pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
diff::apply_role_retirements(changes, retirements)
}
pub fn resolve_passwords(
expanded: &ExpandedManifest,
) -> Result<std::collections::BTreeMap<String, String>> {
diff::resolve_passwords(&expanded.roles).map_err(|err| anyhow::anyhow!("{err}"))
}
pub fn inject_password_changes(
changes: Vec<Change>,
resolved_passwords: &std::collections::BTreeMap<String, String>,
) -> Vec<Change> {
diff::inject_password_changes(changes, resolved_passwords)
}
pub fn format_plan_sql(changes: &[Change]) -> String {
sql::render_all(changes)
}
pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
sql::render_all_with_context(&redacted_changes(changes), ctx)
}
pub fn format_plan_json(changes: &[Change]) -> Result<String> {
serde_json::to_string_pretty(&redacted_changes(changes)).map_err(|err| anyhow::anyhow!("{err}"))
}
fn redacted_changes(changes: &[Change]) -> Vec<Change> {
changes
.iter()
.map(|change| match change {
Change::SetPassword { name, .. } => Change::SetPassword {
name: name.clone(),
password: "[REDACTED]".to_string(),
},
other => other.clone(),
})
.collect()
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct PlanSummary {
pub roles_created: usize,
pub roles_altered: usize,
pub roles_dropped: usize,
pub comments_changed: usize,
pub sessions_terminated: usize,
pub ownerships_reassigned: usize,
pub owned_objects_dropped: usize,
pub grants: usize,
pub revokes: usize,
pub default_privileges_set: usize,
pub default_privileges_revoked: usize,
pub members_added: usize,
pub members_removed: usize,
pub passwords_set: usize,
}
impl PlanSummary {
pub fn from_changes(changes: &[Change]) -> Self {
let mut summary = Self::default();
for change in changes {
match change {
Change::CreateRole { .. } => summary.roles_created += 1,
Change::AlterRole { .. } => summary.roles_altered += 1,
Change::DropRole { .. } => summary.roles_dropped += 1,
Change::SetComment { .. } => summary.comments_changed += 1,
Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
Change::Grant { .. } => summary.grants += 1,
Change::Revoke { .. } => summary.revokes += 1,
Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
Change::AddMember { .. } => summary.members_added += 1,
Change::RemoveMember { .. } => summary.members_removed += 1,
Change::SetPassword { .. } => summary.passwords_set += 1,
}
}
summary
}
pub fn total(&self) -> usize {
self.roles_created
+ self.roles_altered
+ self.roles_dropped
+ self.comments_changed
+ self.sessions_terminated
+ self.ownerships_reassigned
+ self.owned_objects_dropped
+ self.grants
+ self.revokes
+ self.default_privileges_set
+ self.default_privileges_revoked
+ self.members_added
+ self.members_removed
+ self.passwords_set
}
pub fn is_empty(&self) -> bool {
self.total() == 0
}
pub fn has_structural_changes(&self) -> bool {
self.total() - self.passwords_set > 0
}
pub fn format_plan(&self) -> String {
self.format_with_header("Plan")
}
pub fn format_applied(&self) -> String {
self.format_with_header("Applied")
}
fn format_with_header(&self, header: &str) -> String {
if self.is_empty() {
return "No changes needed. Database is in sync with manifest.".to_string();
}
let mut output = String::new();
output.push_str(&format!("{header}: {} change(s)\n", self.total()));
let items: Vec<(&str, usize)> = vec![
("role(s) to create", self.roles_created),
("role(s) to alter", self.roles_altered),
("role(s) to drop", self.roles_dropped),
("comment(s) to change", self.comments_changed),
("session termination step(s)", self.sessions_terminated),
("ownership reassignment(s)", self.ownerships_reassigned),
("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
("grant(s) to add", self.grants),
("grant(s) to revoke", self.revokes),
("default privilege(s) to set", self.default_privileges_set),
(
"default privilege(s) to revoke",
self.default_privileges_revoked,
),
("membership(s) to add", self.members_added),
("membership(s) to remove", self.members_removed),
("password(s) to set", self.passwords_set),
];
for (label, count) in items {
if count > 0 {
output.push_str(&format!(" {count} {label}\n"));
}
}
output
}
}
impl std::fmt::Display for PlanSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.format_plan())
}
}
pub fn format_validation_result(validated: &ValidatedManifest) -> String {
let mut output = String::new();
output.push_str("Manifest is valid.\n");
output.push_str(&format!(
" {} role(s) defined\n",
validated.expanded.roles.len()
));
output.push_str(&format!(
" {} grant(s) defined\n",
validated.expanded.grants.len()
));
output.push_str(&format!(
" {} default privilege(s) defined\n",
validated.expanded.default_privileges.len()
));
output.push_str(&format!(
" {} membership(s) defined\n",
validated.expanded.memberships.len()
));
output
}
pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
let mut output = String::new();
output.push_str(&format!("Roles: {}\n", graph.roles.len()));
for (name, state) in &graph.roles {
let login_marker = if state.login { "LOGIN" } else { "NOLOGIN" };
output.push_str(&format!(" {name} ({login_marker})\n"));
}
output.push_str(&format!("Grants: {}\n", graph.grants.len()));
output.push_str(&format!(
"Default privileges: {}\n",
graph.default_privileges.len()
));
output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
for edge in &graph.memberships {
output.push_str(&format!(" {} -> {}\n", edge.member, edge.role));
}
output
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_MANIFEST: &str = r#"
default_owner: app_owner
roles:
- name: analytics
login: true
comment: "Analytics read-only role"
grants:
- role: analytics
privileges: [CONNECT]
object: { type: database, name: mydb }
"#;
const PROFILE_MANIFEST: &str = r#"
default_owner: app_owner
profiles:
editor:
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT, INSERT, UPDATE, DELETE]
object: { type: table, name: "*" }
default_privileges:
- privileges: [SELECT, INSERT, UPDATE, DELETE]
on_type: table
viewer:
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT]
object: { type: table, name: "*" }
default_privileges:
- privileges: [SELECT]
on_type: table
schemas:
- name: inventory
profiles: [editor, viewer]
- name: catalog
profiles: [viewer]
roles:
- name: app-service
login: true
grants:
- role: app-service
privileges: [CONNECT]
object: { type: database, name: mydb }
memberships:
- role: inventory-editor
members:
- name: app-service
"#;
const INVALID_YAML: &str = r#"
this is: [not: valid yaml: [[
"#;
const UNDEFINED_PROFILE: &str = r#"
profiles:
editor:
grants: []
schemas:
- name: myschema
profiles: [nonexistent]
"#;
#[test]
fn parse_valid_manifest() {
let result = parse(MINIMAL_MANIFEST);
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
assert_eq!(manifest.roles.len(), 1);
assert_eq!(manifest.roles[0].name, "analytics");
}
#[test]
fn parse_invalid_yaml() {
let result = parse(INVALID_YAML);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
}
#[test]
fn expand_profile_manifest() {
let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
assert_eq!(expanded.roles.len(), 4);
let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
assert!(role_names.contains(&"inventory-editor"));
assert!(role_names.contains(&"inventory-viewer"));
assert!(role_names.contains(&"catalog-viewer"));
assert!(role_names.contains(&"app-service"));
}
#[test]
fn expand_undefined_profile_fails() {
let result = parse_and_expand(UNDEFINED_PROFILE);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("nonexistent"),
"expected error about 'nonexistent' profile, got: {err_msg}"
);
}
#[test]
fn validate_builds_role_graph() {
let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
assert_eq!(validated.desired.roles.len(), 4);
assert!(validated.desired.roles.contains_key("inventory-editor"));
assert!(validated.desired.roles.contains_key("app-service"));
assert!(!validated.desired.grants.is_empty());
assert!(!validated.desired.memberships.is_empty());
}
#[test]
fn plan_from_empty_creates_roles() {
let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
let current = RoleGraph::default();
let changes = compute_plan(¤t, &validated.desired);
assert!(!changes.is_empty());
let summary = PlanSummary::from_changes(&changes);
assert_eq!(summary.roles_created, 4); assert!(summary.grants > 0);
assert!(!summary.is_empty());
}
#[test]
fn plan_no_changes_when_in_sync() {
let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
let current = validated.desired.clone();
let changes = compute_plan(¤t, &validated.desired);
let summary = PlanSummary::from_changes(&changes);
assert!(summary.is_empty());
assert_eq!(summary.total(), 0);
}
#[test]
fn format_plan_sql_produces_sql() {
let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
let current = RoleGraph::default();
let changes = compute_plan(¤t, &validated.desired);
let sql_output = format_plan_sql(&changes);
assert!(
sql_output.contains("CREATE ROLE"),
"expected CREATE ROLE in: {sql_output}"
);
assert!(
sql_output.contains("\"analytics\""),
"expected quoted role name in: {sql_output}"
);
}
#[test]
fn planned_role_drops_only_returns_drop_changes() {
let changes = vec![
Change::CreateRole {
name: "new-role".to_string(),
state: pgroles_core::model::RoleState::default(),
},
Change::DropRole {
name: "old-role".to_string(),
},
Change::DropRole {
name: "stale-role".to_string(),
},
];
assert_eq!(
planned_role_drops(&changes),
vec!["old-role".to_string(), "stale-role".to_string()]
);
}
#[test]
fn apply_role_retirements_updates_plan_summary() {
let changes = apply_role_retirements(
vec![Change::DropRole {
name: "legacy-app".to_string(),
}],
&[pgroles_core::manifest::RoleRetirement {
role: "legacy-app".to_string(),
reassign_owned_to: Some("app-owner".to_string()),
drop_owned: true,
terminate_sessions: true,
}],
);
let summary = PlanSummary::from_changes(&changes);
assert_eq!(summary.roles_dropped, 1);
assert_eq!(summary.sessions_terminated, 1);
assert_eq!(summary.ownerships_reassigned, 1);
assert_eq!(summary.owned_objects_dropped, 1);
assert_eq!(summary.total(), 4);
}
#[test]
fn plan_summary_display_empty() {
let summary = PlanSummary::default();
let display = summary.to_string();
assert!(display.contains("No changes needed"));
}
#[test]
fn plan_summary_display_with_changes() {
let summary = PlanSummary {
roles_created: 2,
grants: 5,
members_added: 1,
..Default::default()
};
let display = summary.to_string();
assert!(display.contains("8 change(s)"), "got: {display}");
assert!(display.contains("2 role(s) to create"), "got: {display}");
assert!(display.contains("5 grant(s) to add"), "got: {display}");
assert!(display.contains("1 membership(s) to add"), "got: {display}");
assert!(!display.contains("to drop"), "got: {display}");
assert!(!display.contains("to revoke"), "got: {display}");
}
#[test]
fn validation_result_shows_counts() {
let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
let output = format_validation_result(&validated);
assert!(output.contains("Manifest is valid"), "got: {output}");
assert!(output.contains("4 role(s)"), "got: {output}");
}
#[test]
fn read_nonexistent_file_fails() {
let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
assert!(result.is_err());
let err_msg = format!("{:#}", result.unwrap_err());
assert!(
err_msg.contains("failed to read manifest file"),
"got: {err_msg}"
);
}
#[test]
fn role_graph_summary_format() {
let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
let summary = format_role_graph_summary(&validated.desired);
assert!(summary.contains("Roles: 1"), "got: {summary}");
assert!(summary.contains("analytics (LOGIN)"), "got: {summary}");
}
#[test]
fn has_structural_changes_true_for_non_password_changes() {
let summary = PlanSummary {
roles_created: 1,
grants: 2,
..Default::default()
};
assert!(summary.has_structural_changes());
}
#[test]
fn has_structural_changes_false_for_password_only() {
let summary = PlanSummary {
passwords_set: 3,
..Default::default()
};
assert!(
!summary.has_structural_changes(),
"password-only plan should NOT be considered structural drift"
);
}
#[test]
fn has_structural_changes_true_for_mixed() {
let summary = PlanSummary {
roles_created: 1,
passwords_set: 2,
..Default::default()
};
assert!(
summary.has_structural_changes(),
"mixed plan with structural + password changes IS structural drift"
);
}
#[test]
fn has_structural_changes_false_for_empty() {
let summary = PlanSummary::default();
assert!(!summary.has_structural_changes());
}
#[test]
fn plan_summary_displays_password_count() {
let summary = PlanSummary {
passwords_set: 2,
roles_created: 1,
..Default::default()
};
let display = summary.to_string();
assert!(display.contains("2 password(s) to set"), "got: {display}");
assert!(display.contains("3 change(s)"), "got: {display}");
}
#[test]
fn additive_mode_filters_revokes_from_plan() {
use pgroles_core::diff::{ReconciliationMode, filter_changes};
use pgroles_core::model::RoleState;
let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
let mut current = validated.desired.clone();
current
.roles
.insert("stale-role".to_string(), RoleState::default());
let changes = compute_plan(¤t, &validated.desired);
assert!(changes.iter().any(|c| matches!(
c,
pgroles_core::diff::Change::DropRole { name } if name == "stale-role"
)));
let filtered = filter_changes(changes, ReconciliationMode::Additive);
assert!(
!filtered
.iter()
.any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
"additive mode should filter out DropRole"
);
}
#[test]
fn adopt_mode_filters_drops_but_keeps_revokes() {
use pgroles_core::diff::{ReconciliationMode, filter_changes};
use pgroles_core::manifest::{ObjectType, Privilege};
use pgroles_core::model::{GrantKey, GrantState, RoleState};
use std::collections::BTreeSet;
let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
let mut current = validated.desired.clone();
current
.roles
.insert("stale-role".to_string(), RoleState::default());
current.grants.insert(
GrantKey {
role: "analytics".to_string(),
object_type: ObjectType::Table,
schema: Some("public".to_string()),
name: Some("*".to_string()),
},
GrantState {
privileges: BTreeSet::from([Privilege::Select]),
},
);
let changes = compute_plan(¤t, &validated.desired);
let filtered = filter_changes(changes, ReconciliationMode::Adopt);
assert!(
!filtered
.iter()
.any(|c| matches!(c, pgroles_core::diff::Change::DropRole { .. })),
"adopt mode should filter out DropRole"
);
assert!(
filtered
.iter()
.any(|c| matches!(c, pgroles_core::diff::Change::Revoke { .. })),
"adopt mode should keep Revoke changes"
);
}
#[test]
fn plan_json_produces_valid_json() {
let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
let current = RoleGraph::default();
let changes = compute_plan(¤t, &validated.desired);
let json_output = format_plan_json(&changes).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
assert!(parsed.is_array());
let text = json_output.to_string();
assert!(text.contains("CreateRole"), "got: {text}");
assert!(text.contains("analytics"), "got: {text}");
}
#[test]
fn format_plan_json_redacts_passwords() {
let changes = vec![Change::SetPassword {
name: "app-svc".to_string(),
password: "super-secret".to_string(),
}];
let json = format_plan_json(&changes).expect("json formatting should succeed");
assert!(json.contains("[REDACTED]"), "got: {json}");
assert!(!json.contains("super-secret"), "got: {json}");
}
#[test]
fn format_plan_sql_redacts_passwords() {
let changes = vec![Change::SetPassword {
name: "app-svc".to_string(),
password: "super-secret".to_string(),
}];
let sql = format_plan_sql_with_context(&changes, &sql::SqlContext::default());
assert!(sql.contains("[REDACTED]"), "got: {sql}");
assert!(!sql.contains("super-secret"), "got: {sql}");
}
#[test]
fn format_applied_uses_applied_header() {
let summary = PlanSummary {
roles_created: 1,
grants: 2,
..Default::default()
};
let display = summary.format_applied();
assert!(
display.starts_with("Applied: 3 change(s)\n"),
"got: {display}"
);
assert!(display.contains("1 role(s) to create"), "got: {display}");
assert!(display.contains("2 grant(s) to add"), "got: {display}");
assert!(!display.contains("Plan:"), "got: {display}");
}
}