1use std::path::Path;
8
9use anyhow::{Context, Result};
10
11use pgroles_core::diff::{self, Change};
12use pgroles_core::manifest::{self, ExpandedManifest, PolicyManifest, RoleRetirement};
13use pgroles_core::model::RoleGraph;
14use pgroles_core::sql;
15
16pub fn read_manifest_file(path: &Path) -> Result<String> {
22 std::fs::read_to_string(path)
23 .with_context(|| format!("failed to read manifest file: {}", path.display()))
24}
25
26pub fn parse(yaml: &str) -> Result<PolicyManifest> {
32 manifest::parse_manifest(yaml).map_err(|err| anyhow::anyhow!("{err}"))
33}
34
35pub fn parse_and_expand(yaml: &str) -> Result<ExpandedManifest> {
37 let policy_manifest = parse(yaml)?;
38 manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))
39}
40
41pub fn validate_manifest(yaml: &str) -> Result<ValidatedManifest> {
44 let policy_manifest = parse(yaml)?;
45 let expanded =
46 manifest::expand_manifest(&policy_manifest).map_err(|err| anyhow::anyhow!("{err}"))?;
47
48 let default_owner = policy_manifest.default_owner.as_deref();
49 let desired = RoleGraph::from_expanded(&expanded, default_owner)
50 .map_err(|err| anyhow::anyhow!("{err}"))?;
51
52 Ok(ValidatedManifest {
53 manifest: policy_manifest,
54 expanded,
55 desired,
56 })
57}
58
59pub struct ValidatedManifest {
61 pub manifest: PolicyManifest,
62 pub expanded: ExpandedManifest,
63 pub desired: RoleGraph,
64}
65
66pub fn compute_plan(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
72 diff::diff(current, desired)
73}
74
75pub fn planned_role_drops(changes: &[Change]) -> Vec<String> {
77 changes
78 .iter()
79 .filter_map(|change| match change {
80 Change::DropRole { name } => Some(name.clone()),
81 _ => None,
82 })
83 .collect()
84}
85
86pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
88 diff::apply_role_retirements(changes, retirements)
89}
90
91pub fn format_plan_sql(changes: &[Change]) -> String {
97 sql::render_all(changes)
98}
99
100pub fn format_plan_sql_with_context(changes: &[Change], ctx: &sql::SqlContext) -> String {
102 sql::render_all_with_context(changes, ctx)
103}
104
105pub fn format_plan_json(changes: &[Change]) -> Result<String> {
107 serde_json::to_string_pretty(changes).map_err(|err| anyhow::anyhow!("{err}"))
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
112pub struct PlanSummary {
113 pub roles_created: usize,
114 pub roles_altered: usize,
115 pub roles_dropped: usize,
116 pub comments_changed: usize,
117 pub sessions_terminated: usize,
118 pub ownerships_reassigned: usize,
119 pub owned_objects_dropped: usize,
120 pub grants: usize,
121 pub revokes: usize,
122 pub default_privileges_set: usize,
123 pub default_privileges_revoked: usize,
124 pub members_added: usize,
125 pub members_removed: usize,
126}
127
128impl PlanSummary {
129 pub fn from_changes(changes: &[Change]) -> Self {
131 let mut summary = Self::default();
132 for change in changes {
133 match change {
134 Change::CreateRole { .. } => summary.roles_created += 1,
135 Change::AlterRole { .. } => summary.roles_altered += 1,
136 Change::DropRole { .. } => summary.roles_dropped += 1,
137 Change::SetComment { .. } => summary.comments_changed += 1,
138 Change::TerminateSessions { .. } => summary.sessions_terminated += 1,
139 Change::ReassignOwned { .. } => summary.ownerships_reassigned += 1,
140 Change::DropOwned { .. } => summary.owned_objects_dropped += 1,
141 Change::Grant { .. } => summary.grants += 1,
142 Change::Revoke { .. } => summary.revokes += 1,
143 Change::SetDefaultPrivilege { .. } => summary.default_privileges_set += 1,
144 Change::RevokeDefaultPrivilege { .. } => summary.default_privileges_revoked += 1,
145 Change::AddMember { .. } => summary.members_added += 1,
146 Change::RemoveMember { .. } => summary.members_removed += 1,
147 }
148 }
149 summary
150 }
151
152 pub fn total(&self) -> usize {
154 self.roles_created
155 + self.roles_altered
156 + self.roles_dropped
157 + self.comments_changed
158 + self.sessions_terminated
159 + self.ownerships_reassigned
160 + self.owned_objects_dropped
161 + self.grants
162 + self.revokes
163 + self.default_privileges_set
164 + self.default_privileges_revoked
165 + self.members_added
166 + self.members_removed
167 }
168
169 pub fn is_empty(&self) -> bool {
171 self.total() == 0
172 }
173}
174
175impl std::fmt::Display for PlanSummary {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 if self.is_empty() {
178 return write!(f, "No changes needed. Database is in sync with manifest.");
179 }
180
181 writeln!(f, "Plan: {} change(s)", self.total())?;
182
183 let items: Vec<(&str, usize)> = vec![
184 ("role(s) to create", self.roles_created),
185 ("role(s) to alter", self.roles_altered),
186 ("role(s) to drop", self.roles_dropped),
187 ("comment(s) to change", self.comments_changed),
188 ("session termination step(s)", self.sessions_terminated),
189 ("ownership reassignment(s)", self.ownerships_reassigned),
190 ("DROP OWNED cleanup step(s)", self.owned_objects_dropped),
191 ("grant(s) to add", self.grants),
192 ("grant(s) to revoke", self.revokes),
193 ("default privilege(s) to set", self.default_privileges_set),
194 (
195 "default privilege(s) to revoke",
196 self.default_privileges_revoked,
197 ),
198 ("membership(s) to add", self.members_added),
199 ("membership(s) to remove", self.members_removed),
200 ];
201
202 for (label, count) in items {
203 if count > 0 {
204 writeln!(f, " {count} {label}")?;
205 }
206 }
207 Ok(())
208 }
209}
210
211pub fn format_validation_result(validated: &ValidatedManifest) -> String {
213 let mut output = String::new();
214 output.push_str("Manifest is valid.\n");
215 output.push_str(&format!(
216 " {} role(s) defined\n",
217 validated.expanded.roles.len()
218 ));
219 output.push_str(&format!(
220 " {} grant(s) defined\n",
221 validated.expanded.grants.len()
222 ));
223 output.push_str(&format!(
224 " {} default privilege(s) defined\n",
225 validated.expanded.default_privileges.len()
226 ));
227 output.push_str(&format!(
228 " {} membership(s) defined\n",
229 validated.expanded.memberships.len()
230 ));
231 output
232}
233
234pub fn format_role_graph_summary(graph: &RoleGraph) -> String {
240 let mut output = String::new();
241 output.push_str(&format!("Roles: {}\n", graph.roles.len()));
242 output.push_str(&format!("Grants: {}\n", graph.grants.len()));
243 output.push_str(&format!(
244 "Default privileges: {}\n",
245 graph.default_privileges.len()
246 ));
247 output.push_str(&format!("Memberships: {}\n", graph.memberships.len()));
248 output
249}
250
251#[cfg(test)]
256mod tests {
257 use super::*;
258
259 const MINIMAL_MANIFEST: &str = r#"
260default_owner: app_owner
261
262roles:
263 - name: analytics
264 login: true
265 comment: "Analytics read-only role"
266
267grants:
268 - role: analytics
269 privileges: [CONNECT]
270 on: { type: database, name: mydb }
271"#;
272
273 const PROFILE_MANIFEST: &str = r#"
274default_owner: app_owner
275
276profiles:
277 editor:
278 grants:
279 - privileges: [USAGE]
280 on: { type: schema }
281 - privileges: [SELECT, INSERT, UPDATE, DELETE]
282 on: { type: table, name: "*" }
283 default_privileges:
284 - privileges: [SELECT, INSERT, UPDATE, DELETE]
285 on_type: table
286 viewer:
287 grants:
288 - privileges: [USAGE]
289 on: { type: schema }
290 - privileges: [SELECT]
291 on: { type: table, name: "*" }
292 default_privileges:
293 - privileges: [SELECT]
294 on_type: table
295
296schemas:
297 - name: inventory
298 profiles: [editor, viewer]
299 - name: catalog
300 profiles: [viewer]
301
302roles:
303 - name: app-service
304 login: true
305
306grants:
307 - role: app-service
308 privileges: [CONNECT]
309 on: { type: database, name: mydb }
310
311memberships:
312 - role: inventory-editor
313 members:
314 - name: app-service
315"#;
316
317 const INVALID_YAML: &str = r#"
318this is: [not: valid yaml: [[
319"#;
320
321 const UNDEFINED_PROFILE: &str = r#"
322profiles:
323 editor:
324 grants: []
325
326schemas:
327 - name: myschema
328 profiles: [nonexistent]
329"#;
330
331 #[test]
336 fn parse_valid_manifest() {
337 let result = parse(MINIMAL_MANIFEST);
338 assert!(result.is_ok());
339 let manifest = result.unwrap();
340 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
341 assert_eq!(manifest.roles.len(), 1);
342 assert_eq!(manifest.roles[0].name, "analytics");
343 }
344
345 #[test]
346 fn parse_invalid_yaml() {
347 let result = parse(INVALID_YAML);
348 assert!(result.is_err());
349 let err_msg = result.unwrap_err().to_string();
350 assert!(err_msg.contains("YAML parse error"), "got: {err_msg}");
351 }
352
353 #[test]
358 fn expand_profile_manifest() {
359 let expanded = parse_and_expand(PROFILE_MANIFEST).unwrap();
360
361 assert_eq!(expanded.roles.len(), 4);
363
364 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
365 assert!(role_names.contains(&"inventory-editor"));
366 assert!(role_names.contains(&"inventory-viewer"));
367 assert!(role_names.contains(&"catalog-viewer"));
368 assert!(role_names.contains(&"app-service"));
369 }
370
371 #[test]
372 fn expand_undefined_profile_fails() {
373 let result = parse_and_expand(UNDEFINED_PROFILE);
374 assert!(result.is_err());
375 let err_msg = result.unwrap_err().to_string();
376 assert!(
377 err_msg.contains("nonexistent"),
378 "expected error about 'nonexistent' profile, got: {err_msg}"
379 );
380 }
381
382 #[test]
387 fn validate_builds_role_graph() {
388 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
389
390 assert_eq!(validated.desired.roles.len(), 4);
392 assert!(validated.desired.roles.contains_key("inventory-editor"));
393 assert!(validated.desired.roles.contains_key("app-service"));
394
395 assert!(!validated.desired.grants.is_empty());
397
398 assert!(!validated.desired.memberships.is_empty());
400 }
401
402 #[test]
407 fn plan_from_empty_creates_roles() {
408 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
409 let current = RoleGraph::default(); let changes = compute_plan(¤t, &validated.desired);
412 assert!(!changes.is_empty());
413
414 let summary = PlanSummary::from_changes(&changes);
415 assert_eq!(summary.roles_created, 4); assert!(summary.grants > 0);
417 assert!(!summary.is_empty());
418 }
419
420 #[test]
421 fn plan_no_changes_when_in_sync() {
422 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
423 let current = validated.desired.clone();
425
426 let changes = compute_plan(¤t, &validated.desired);
427 let summary = PlanSummary::from_changes(&changes);
428 assert!(summary.is_empty());
429 assert_eq!(summary.total(), 0);
430 }
431
432 #[test]
433 fn format_plan_sql_produces_sql() {
434 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
435 let current = RoleGraph::default();
436 let changes = compute_plan(¤t, &validated.desired);
437
438 let sql_output = format_plan_sql(&changes);
439 assert!(
440 sql_output.contains("CREATE ROLE"),
441 "expected CREATE ROLE in: {sql_output}"
442 );
443 assert!(
444 sql_output.contains("\"analytics\""),
445 "expected quoted role name in: {sql_output}"
446 );
447 }
448
449 #[test]
450 fn planned_role_drops_only_returns_drop_changes() {
451 let changes = vec![
452 Change::CreateRole {
453 name: "new-role".to_string(),
454 state: pgroles_core::model::RoleState::default(),
455 },
456 Change::DropRole {
457 name: "old-role".to_string(),
458 },
459 Change::DropRole {
460 name: "stale-role".to_string(),
461 },
462 ];
463
464 assert_eq!(
465 planned_role_drops(&changes),
466 vec!["old-role".to_string(), "stale-role".to_string()]
467 );
468 }
469
470 #[test]
471 fn apply_role_retirements_updates_plan_summary() {
472 let changes = apply_role_retirements(
473 vec![Change::DropRole {
474 name: "legacy-app".to_string(),
475 }],
476 &[pgroles_core::manifest::RoleRetirement {
477 role: "legacy-app".to_string(),
478 reassign_owned_to: Some("app-owner".to_string()),
479 drop_owned: true,
480 terminate_sessions: true,
481 }],
482 );
483
484 let summary = PlanSummary::from_changes(&changes);
485 assert_eq!(summary.roles_dropped, 1);
486 assert_eq!(summary.sessions_terminated, 1);
487 assert_eq!(summary.ownerships_reassigned, 1);
488 assert_eq!(summary.owned_objects_dropped, 1);
489 assert_eq!(summary.total(), 4);
490 }
491
492 #[test]
497 fn plan_summary_display_empty() {
498 let summary = PlanSummary::default();
499 let display = summary.to_string();
500 assert!(display.contains("No changes needed"));
501 }
502
503 #[test]
504 fn plan_summary_display_with_changes() {
505 let summary = PlanSummary {
506 roles_created: 2,
507 grants: 5,
508 members_added: 1,
509 ..Default::default()
510 };
511 let display = summary.to_string();
512 assert!(display.contains("8 change(s)"), "got: {display}");
513 assert!(display.contains("2 role(s) to create"), "got: {display}");
514 assert!(display.contains("5 grant(s) to add"), "got: {display}");
515 assert!(display.contains("1 membership(s) to add"), "got: {display}");
516 assert!(!display.contains("to drop"), "got: {display}");
518 assert!(!display.contains("to revoke"), "got: {display}");
519 }
520
521 #[test]
526 fn validation_result_shows_counts() {
527 let validated = validate_manifest(PROFILE_MANIFEST).unwrap();
528 let output = format_validation_result(&validated);
529 assert!(output.contains("Manifest is valid"), "got: {output}");
530 assert!(output.contains("4 role(s)"), "got: {output}");
531 }
532
533 #[test]
538 fn read_nonexistent_file_fails() {
539 let result = read_manifest_file(Path::new("/tmp/nonexistent-pgroles-test.yaml"));
540 assert!(result.is_err());
541 let err_msg = format!("{:#}", result.unwrap_err());
542 assert!(
543 err_msg.contains("failed to read manifest file"),
544 "got: {err_msg}"
545 );
546 }
547
548 #[test]
553 fn role_graph_summary_format() {
554 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
555 let summary = format_role_graph_summary(&validated.desired);
556 assert!(summary.contains("Roles: 1"), "got: {summary}");
557 }
558
559 #[test]
564 fn plan_json_produces_valid_json() {
565 let validated = validate_manifest(MINIMAL_MANIFEST).unwrap();
566 let current = RoleGraph::default();
567 let changes = compute_plan(¤t, &validated.desired);
568
569 let json_output = format_plan_json(&changes).unwrap();
570 let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
572 assert!(parsed.is_array());
573 let text = json_output.to_string();
575 assert!(text.contains("CreateRole"), "got: {text}");
576 assert!(text.contains("analytics"), "got: {text}");
577 }
578}