1use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Write as _;
8
9use serde::{Deserialize, Serialize};
10
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{DefaultPrivKey, GrantKey, RoleGraph};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct VisualGraph {
21 pub meta: VisualMeta,
22 pub nodes: Vec<VisualNode>,
23 pub edges: Vec<VisualEdge>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisualMeta {
29 pub source: VisualSource,
30 pub role_count: usize,
31 pub grant_count: usize,
32 pub default_privilege_count: usize,
33 pub membership_count: usize,
34 pub collapsed: bool,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum VisualSource {
41 Desired,
42 Current,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct VisualNode {
48 pub id: String,
49 pub label: String,
50 pub kind: NodeKind,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub managed: Option<bool>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub login: Option<bool>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub privileges: Vec<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub comment: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum NodeKind {
65 Role,
66 ExternalPrincipal,
67 GrantTarget,
68 DefaultPrivilegeTarget,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct VisualEdge {
74 pub source: String,
75 pub target: String,
76 pub kind: EdgeKind,
77 pub label: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum EdgeKind {
84 Membership,
85 Grant,
86 DefaultPrivilege,
87}
88
89pub fn build_visual_graph(graph: &RoleGraph, source: VisualSource) -> VisualGraph {
98 let mut nodes: Vec<VisualNode> = Vec::new();
99 let mut edges: Vec<VisualEdge> = Vec::new();
100 let mut node_ids: BTreeSet<String> = BTreeSet::new();
101
102 let managed_role_names: BTreeSet<&str> = graph.roles.keys().map(|name| name.as_str()).collect();
104
105 for (name, state) in &graph.roles {
107 let node_id = format!("role:{name}");
108 nodes.push(VisualNode {
109 id: node_id.clone(),
110 label: name.clone(),
111 kind: NodeKind::Role,
112 managed: Some(true),
113 login: Some(state.login),
114 privileges: Vec::new(),
115 comment: state.comment.clone(),
116 });
117 node_ids.insert(node_id);
118 }
119
120 for edge in &graph.memberships {
122 if !managed_role_names.contains(edge.member.as_str()) {
123 let node_id = format!("external:{}", edge.member);
124 if node_ids.insert(node_id.clone()) {
125 nodes.push(VisualNode {
126 id: node_id,
127 label: edge.member.clone(),
128 kind: NodeKind::ExternalPrincipal,
129 managed: Some(false),
130 login: None,
131 privileges: Vec::new(),
132 comment: None,
133 });
134 }
135 }
136 }
137
138 for edge in &graph.memberships {
140 let source_id = if managed_role_names.contains(edge.member.as_str()) {
141 format!("role:{}", edge.member)
142 } else {
143 format!("external:{}", edge.member)
144 };
145 let target_id = format!("role:{}", edge.role);
146
147 let label = membership_label(edge.inherit, edge.admin);
148 edges.push(VisualEdge {
149 source: source_id,
150 target: target_id,
151 kind: EdgeKind::Membership,
152 label,
153 });
154 }
155
156 let collapsed_grants = collapse_grants(&graph.grants);
159 for (collapsed_key, privileges) in &collapsed_grants {
160 let node_id = collapsed_key.node_id();
161 if node_ids.insert(node_id.clone()) {
162 nodes.push(VisualNode {
163 id: node_id.clone(),
164 label: collapsed_key.label(),
165 kind: NodeKind::GrantTarget,
166 managed: None,
167 login: None,
168 privileges: privileges.iter().map(|p| p.to_string()).collect(),
169 comment: None,
170 });
171 } else {
172 if let Some(existing) = nodes.iter_mut().find(|n| n.id == node_id) {
174 for priv_str in privileges.iter().map(|p| p.to_string()) {
175 if !existing.privileges.contains(&priv_str) {
176 existing.privileges.push(priv_str);
177 }
178 }
179 existing.privileges.sort();
180 }
181 }
182
183 let privilege_label = privileges
184 .iter()
185 .map(|p| p.to_string())
186 .collect::<Vec<_>>()
187 .join(",");
188 edges.push(VisualEdge {
189 source: format!("role:{}", collapsed_key.role),
190 target: node_id,
191 kind: EdgeKind::Grant,
192 label: privilege_label,
193 });
194 }
195
196 for (key, state) in &graph.default_privileges {
198 let node_id = default_priv_node_id(key);
199 let node_label = format!("defaults: {} -> {}.{}s", key.owner, key.schema, key.on_type);
200
201 if node_ids.insert(node_id.clone()) {
202 nodes.push(VisualNode {
203 id: node_id.clone(),
204 label: node_label,
205 kind: NodeKind::DefaultPrivilegeTarget,
206 managed: None,
207 login: None,
208 privileges: state.privileges.iter().map(|p| p.to_string()).collect(),
209 comment: None,
210 });
211 }
212
213 let privilege_label = state
214 .privileges
215 .iter()
216 .map(|p| p.to_string())
217 .collect::<Vec<_>>()
218 .join(",");
219 edges.push(VisualEdge {
220 source: node_id,
221 target: format!("role:{}", key.grantee),
222 kind: EdgeKind::DefaultPrivilege,
223 label: privilege_label,
224 });
225 }
226
227 nodes.sort_by(|a, b| a.id.cmp(&b.id));
229 edges.sort_by(|a, b| (&a.source, &a.target, &a.kind).cmp(&(&b.source, &b.target, &b.kind)));
230
231 VisualGraph {
232 meta: VisualMeta {
233 source,
234 role_count: graph.roles.len(),
235 grant_count: graph.grants.len(),
236 default_privilege_count: graph.default_privileges.len(),
237 membership_count: graph.memberships.len(),
238 collapsed: true,
239 },
240 nodes,
241 edges,
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
251struct CollapsedGrantKey {
252 role: String,
253 object_type: ObjectType,
254 scope: String,
257}
258
259impl CollapsedGrantKey {
260 fn node_id(&self) -> String {
261 match self.object_type {
262 ObjectType::Schema => format!("grant:schema:{}:{}", self.scope, self.scope),
263 ObjectType::Database => format!("grant:database:{}:{}", self.scope, self.scope),
264 _ => format!("grant:{}:{}:*", self.object_type, self.scope),
265 }
266 }
267
268 fn label(&self) -> String {
269 match self.object_type {
270 ObjectType::Schema => format!("{}.schema", self.scope),
271 ObjectType::Database => format!("{}.database", self.scope),
272 _ => format!("{}.{}s[*]", self.scope, self.object_type),
273 }
274 }
275}
276
277fn collapse_grants(
278 grants: &BTreeMap<GrantKey, crate::model::GrantState>,
279) -> BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> {
280 let mut collapsed: BTreeMap<CollapsedGrantKey, BTreeSet<Privilege>> = BTreeMap::new();
281
282 for (key, state) in grants {
283 let scope = match key.object_type {
284 ObjectType::Schema => key
286 .name
287 .as_deref()
288 .or(key.schema.as_deref())
289 .unwrap_or("public")
290 .to_string(),
291 ObjectType::Database => key.name.as_deref().unwrap_or("db").to_string(),
293 _ => key.schema.as_deref().unwrap_or("public").to_string(),
295 };
296
297 let collapsed_key = CollapsedGrantKey {
298 role: key.role.clone(),
299 object_type: key.object_type,
300 scope,
301 };
302
303 collapsed
304 .entry(collapsed_key)
305 .or_default()
306 .extend(&state.privileges);
307 }
308
309 collapsed
310}
311
312fn default_priv_node_id(key: &DefaultPrivKey) -> String {
313 format!(
314 "default:{}:{}:{}:{}",
315 key.owner, key.schema, key.on_type, key.grantee
316 )
317}
318
319fn membership_label(inherit: bool, admin: bool) -> String {
320 let mut parts = vec!["member"];
321 if !inherit {
322 parts.push("NOINHERIT");
323 }
324 if admin {
325 parts.push("ADMIN");
326 }
327 parts.join(", ")
328}
329
330pub fn render_json(graph: &VisualGraph) -> String {
336 serde_json::to_string_pretty(graph).expect("VisualGraph serialization should not fail")
337}
338
339pub fn render_dot(graph: &VisualGraph) -> String {
341 let mut out = String::new();
342 writeln!(out, "digraph roles {{").unwrap();
343 writeln!(out, " rankdir=LR;").unwrap();
344 writeln!(out, " node [fontname=\"sans-serif\" fontsize=10];").unwrap();
345 writeln!(out, " edge [fontname=\"sans-serif\" fontsize=9];").unwrap();
346 writeln!(out).unwrap();
347
348 for node in &graph.nodes {
349 let dot_id = dot_escape_id(&node.id);
350 let label = dot_escape_label(&node.label);
351 let shape = match node.kind {
352 NodeKind::Role => {
353 if node.login == Some(true) {
354 "box"
355 } else {
356 "ellipse"
357 }
358 }
359 NodeKind::ExternalPrincipal => "hexagon",
360 NodeKind::GrantTarget => "note",
361 NodeKind::DefaultPrivilegeTarget => "component",
362 };
363 let style = match node.kind {
364 NodeKind::Role => "filled",
365 NodeKind::ExternalPrincipal => "dashed,filled",
366 NodeKind::GrantTarget => "filled",
367 NodeKind::DefaultPrivilegeTarget => "filled",
368 };
369 let fillcolor = match node.kind {
370 NodeKind::Role => {
371 if node.login == Some(true) {
372 "#e0f2fe" } else {
374 "#f0fdf4" }
376 }
377 NodeKind::ExternalPrincipal => "#fef3c7", NodeKind::GrantTarget => "#f5f5f4", NodeKind::DefaultPrivilegeTarget => "#f0fdfa", };
381 writeln!(
382 out,
383 " {dot_id} [label=\"{label}\" shape={shape} style=\"{style}\" fillcolor=\"{fillcolor}\"];",
384 )
385 .unwrap();
386 }
387
388 writeln!(out).unwrap();
389
390 for edge in &graph.edges {
391 let source = dot_escape_id(&edge.source);
392 let target = dot_escape_id(&edge.target);
393 let label = dot_escape_label(&edge.label);
394 let style = match edge.kind {
395 EdgeKind::Membership => "solid",
396 EdgeKind::Grant => "solid",
397 EdgeKind::DefaultPrivilege => "dashed",
398 };
399 let color = match edge.kind {
400 EdgeKind::Membership => "#1e3a5f",
401 EdgeKind::Grant => "#374151",
402 EdgeKind::DefaultPrivilege => "#0d9488",
403 };
404 writeln!(
405 out,
406 " {source} -> {target} [label=\"{label}\" style={style} color=\"{color}\" fontcolor=\"{color}\"];",
407 )
408 .unwrap();
409 }
410
411 writeln!(out, "}}").unwrap();
412 out
413}
414
415pub fn render_mermaid(graph: &VisualGraph) -> String {
417 let mut out = String::new();
418 writeln!(out, "graph LR").unwrap();
419
420 for node in &graph.nodes {
421 let mermaid_id = mermaid_escape_id(&node.id);
422 let label = mermaid_escape_label(&node.label);
423 let shape = match node.kind {
424 NodeKind::Role => {
425 if node.login == Some(true) {
426 format!("[{label}]")
427 } else {
428 format!("([{label}])")
429 }
430 }
431 NodeKind::ExternalPrincipal => format!("{{{{{label}}}}}"),
432 NodeKind::GrantTarget => format!("[/{label}/]"),
433 NodeKind::DefaultPrivilegeTarget => format!("[\\{label}\\]"),
434 };
435 writeln!(out, " {mermaid_id}{shape}").unwrap();
436 }
437
438 for edge in &graph.edges {
439 let source = mermaid_escape_id(&edge.source);
440 let target = mermaid_escape_id(&edge.target);
441 let label = mermaid_escape_label(&edge.label);
442 let arrow = match edge.kind {
443 EdgeKind::Membership => "-->",
444 EdgeKind::Grant => "-->",
445 EdgeKind::DefaultPrivilege => "-.->",
446 };
447 if label.is_empty() {
448 writeln!(out, " {source} {arrow} {target}").unwrap();
449 } else {
450 writeln!(out, " {source} {arrow}|{label}| {target}").unwrap();
451 }
452 }
453
454 out
455}
456
457pub fn render_tree(graph: &VisualGraph) -> String {
459 let mut out = String::new();
460
461 let mut membership_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
463 let mut grant_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
464 let mut default_priv_edges: BTreeMap<&str, Vec<&VisualEdge>> = BTreeMap::new();
465 let node_map: BTreeMap<&str, &VisualNode> =
466 graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
467
468 for edge in &graph.edges {
469 match edge.kind {
470 EdgeKind::Membership => {
471 membership_edges
472 .entry(edge.target.as_str())
473 .or_default()
474 .push(edge);
475 }
476 EdgeKind::Grant => {
477 grant_edges
478 .entry(edge.source.as_str())
479 .or_default()
480 .push(edge);
481 }
482 EdgeKind::DefaultPrivilege => {
483 default_priv_edges
484 .entry(edge.target.as_str())
485 .or_default()
486 .push(edge);
487 }
488 }
489 }
490
491 let role_nodes: Vec<&VisualNode> = graph
493 .nodes
494 .iter()
495 .filter(|n| n.kind == NodeKind::Role)
496 .collect();
497
498 for (role_idx, role_node) in role_nodes.iter().enumerate() {
499 let is_last_role = role_idx == role_nodes.len() - 1;
500 let role_connector = if is_last_role { "\u{2514}" } else { "\u{251c}" };
501 let role_tag = if role_node.login == Some(true) {
502 " [LOGIN]"
503 } else {
504 ""
505 };
506 writeln!(
507 out,
508 "{role_connector}\u{2500}\u{2500} {}{role_tag}",
509 role_node.label
510 )
511 .unwrap();
512
513 let child_prefix = if is_last_role { " " } else { "\u{2502} " };
514
515 let members = membership_edges.get(role_node.id.as_str());
517 let grants = grant_edges.get(role_node.id.as_str());
518 let default_privs = default_priv_edges.get(role_node.id.as_str());
519
520 let section_count = members.is_some() as usize
521 + grants.is_some() as usize
522 + default_privs.is_some() as usize;
523 let mut section_idx = 0;
524
525 if let Some(member_list) = members {
526 let is_last_section = section_idx == section_count - 1;
527 render_tree_section(
528 &mut out,
529 child_prefix,
530 is_last_section,
531 "Members",
532 member_list,
533 &node_map,
534 |edge, node_map| {
535 let label = node_map
536 .get(edge.source.as_str())
537 .map(|n| n.label.as_str())
538 .unwrap_or(&edge.source);
539 let flags = if edge.label != "member" {
540 format!(" ({0})", edge.label)
541 } else {
542 String::new()
543 };
544 format!("{label}{flags}")
545 },
546 );
547 section_idx += 1;
548 }
549
550 if let Some(grant_list) = grants {
551 let is_last_section = section_idx == section_count - 1;
552 render_tree_section(
553 &mut out,
554 child_prefix,
555 is_last_section,
556 "Grants",
557 grant_list,
558 &node_map,
559 |edge, node_map| {
560 let label = node_map
561 .get(edge.target.as_str())
562 .map(|n| n.label.as_str())
563 .unwrap_or(&edge.target);
564 format!("{label}: {}", edge.label)
565 },
566 );
567 section_idx += 1;
568 }
569
570 if let Some(dp_list) = default_privs {
571 let is_last_section = section_idx == section_count - 1;
572 render_tree_section(
573 &mut out,
574 child_prefix,
575 is_last_section,
576 "Default Privileges",
577 dp_list,
578 &node_map,
579 |edge, node_map| {
580 let label = node_map
581 .get(edge.source.as_str())
582 .map(|n| n.label.as_str())
583 .unwrap_or(&edge.source);
584 format!("{label}: {}", edge.label)
585 },
586 );
587 let _ = section_idx;
588 }
589 }
590
591 let external_nodes: Vec<&VisualNode> = graph
593 .nodes
594 .iter()
595 .filter(|n| n.kind == NodeKind::ExternalPrincipal)
596 .collect();
597
598 if !external_nodes.is_empty() {
599 writeln!(out).unwrap();
600 writeln!(out, "External principals:").unwrap();
601 for (idx, node) in external_nodes.iter().enumerate() {
602 let is_last = idx == external_nodes.len() - 1;
603 let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
604 writeln!(out, "{connector}\u{2500}\u{2500} {}", node.label).unwrap();
605 }
606 }
607
608 out
609}
610
611fn render_tree_section(
614 out: &mut String,
615 child_prefix: &str,
616 is_last_section: bool,
617 section_name: &str,
618 edges: &[&VisualEdge],
619 node_map: &BTreeMap<&str, &VisualNode>,
620 format_item: impl Fn(&VisualEdge, &BTreeMap<&str, &VisualNode>) -> String,
621) {
622 let section_connector = if is_last_section {
623 "\u{2514}"
624 } else {
625 "\u{251c}"
626 };
627 let item_prefix = if is_last_section {
628 format!("{child_prefix} ")
629 } else {
630 format!("{child_prefix}\u{2502} ")
631 };
632 writeln!(
633 out,
634 "{child_prefix}{section_connector}\u{2500}\u{2500} {section_name}"
635 )
636 .unwrap();
637 for (idx, edge) in edges.iter().enumerate() {
638 let is_last = idx == edges.len() - 1;
639 let connector = if is_last { "\u{2514}" } else { "\u{251c}" };
640 let item_text = format_item(edge, node_map);
641 writeln!(out, "{item_prefix}{connector}\u{2500}\u{2500} {item_text}").unwrap();
642 }
643}
644
645fn dot_escape_id(id: &str) -> String {
650 format!("\"{}\"", id.replace('\\', "\\\\").replace('"', "\\\""))
651}
652
653fn dot_escape_label(label: &str) -> String {
654 label
655 .replace('\\', "\\\\")
656 .replace('"', "\\\"")
657 .replace('\n', "\\n")
658}
659
660fn mermaid_escape_id(id: &str) -> String {
661 let mut out = String::with_capacity(id.len());
665 for ch in id.chars() {
666 match ch {
667 ':' => out.push_str("__"),
668 '.' => out.push_str("_d_"),
669 '@' => out.push_str("_at_"),
670 '*' => out.push_str("_star_"),
671 ' ' => out.push_str("_sp_"),
672 '/' => out.push_str("_sl_"),
673 '\\' => out.push_str("_bs_"),
674 c if c.is_alphanumeric() || c == '-' || c == '_' => out.push(c),
675 _ => {
676 out.push_str(&format!("_x{:02x}_", ch as u32));
677 }
678 }
679 }
680 out
681}
682
683fn mermaid_escape_label(label: &str) -> String {
684 label.replace('"', "#quot;").replace(['[', ']'], "")
686}
687
688#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::manifest::{expand_manifest, parse_manifest};
696 use crate::model::RoleGraph;
697
698 fn build_test_graph() -> RoleGraph {
699 let yaml = r#"
700default_owner: app_owner
701
702profiles:
703 editor:
704 grants:
705 - privileges: [USAGE]
706 object: { type: schema }
707 - privileges: [SELECT, INSERT, UPDATE, DELETE]
708 object: { type: table, name: "*" }
709 default_privileges:
710 - privileges: [SELECT, INSERT, UPDATE, DELETE]
711 on_type: table
712
713schemas:
714 - name: orders
715 profiles: [editor]
716
717roles:
718 - name: analytics
719 login: true
720 comment: "Read-only analytics"
721
722grants:
723 - role: analytics
724 privileges: [CONNECT]
725 object: { type: database, name: mydb }
726
727memberships:
728 - role: orders-editor
729 members:
730 - name: "team@example.com"
731 - name: analytics
732"#;
733 let manifest = parse_manifest(yaml).unwrap();
734 let expanded = expand_manifest(&manifest).unwrap();
735 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap()
736 }
737
738 #[test]
739 fn visual_graph_has_correct_node_count() {
740 let graph = build_test_graph();
741 let visual = build_visual_graph(&graph, VisualSource::Desired);
742
743 let role_nodes: Vec<_> = visual
745 .nodes
746 .iter()
747 .filter(|n| n.kind == NodeKind::Role)
748 .collect();
749 assert_eq!(role_nodes.len(), 2);
750
751 let external_nodes: Vec<_> = visual
753 .nodes
754 .iter()
755 .filter(|n| n.kind == NodeKind::ExternalPrincipal)
756 .collect();
757 assert_eq!(external_nodes.len(), 1);
758 assert_eq!(external_nodes[0].label, "team@example.com");
759 }
760
761 #[test]
762 fn visual_graph_login_flag_correct() {
763 let graph = build_test_graph();
764 let visual = build_visual_graph(&graph, VisualSource::Desired);
765
766 let analytics = visual
767 .nodes
768 .iter()
769 .find(|n| n.label == "analytics")
770 .unwrap();
771 assert_eq!(analytics.login, Some(true));
772
773 let editor = visual
774 .nodes
775 .iter()
776 .find(|n| n.label == "orders-editor")
777 .unwrap();
778 assert_eq!(editor.login, Some(false));
779 }
780
781 #[test]
782 fn visual_graph_has_membership_edges() {
783 let graph = build_test_graph();
784 let visual = build_visual_graph(&graph, VisualSource::Desired);
785
786 let membership_edges: Vec<_> = visual
787 .edges
788 .iter()
789 .filter(|e| e.kind == EdgeKind::Membership)
790 .collect();
791 assert_eq!(membership_edges.len(), 2);
792 }
793
794 #[test]
795 fn visual_graph_collapses_grants() {
796 let graph = build_test_graph();
797 let visual = build_visual_graph(&graph, VisualSource::Desired);
798
799 let grant_targets: Vec<_> = visual
800 .nodes
801 .iter()
802 .filter(|n| n.kind == NodeKind::GrantTarget)
803 .collect();
804
805 assert_eq!(grant_targets.len(), 3, "grant targets: {grant_targets:?}");
807 }
808
809 #[test]
810 fn visual_graph_has_default_privilege_nodes() {
811 let graph = build_test_graph();
812 let visual = build_visual_graph(&graph, VisualSource::Desired);
813
814 let dp_nodes: Vec<_> = visual
815 .nodes
816 .iter()
817 .filter(|n| n.kind == NodeKind::DefaultPrivilegeTarget)
818 .collect();
819 assert_eq!(dp_nodes.len(), 1);
820 assert!(dp_nodes[0].label.contains("app_owner"));
821 assert!(dp_nodes[0].label.contains("orders"));
822 }
823
824 #[test]
825 fn visual_graph_nodes_are_sorted() {
826 let graph = build_test_graph();
827 let visual = build_visual_graph(&graph, VisualSource::Desired);
828
829 let ids: Vec<&str> = visual.nodes.iter().map(|n| n.id.as_str()).collect();
830 let mut sorted_ids = ids.clone();
831 sorted_ids.sort();
832 assert_eq!(ids, sorted_ids, "nodes should be sorted by ID");
833 }
834
835 #[test]
836 fn json_roundtrips() {
837 let graph = build_test_graph();
838 let visual = build_visual_graph(&graph, VisualSource::Desired);
839 let json = render_json(&visual);
840 let deserialized: VisualGraph = serde_json::from_str(&json).unwrap();
841 assert_eq!(deserialized.nodes.len(), visual.nodes.len());
842 assert_eq!(deserialized.edges.len(), visual.edges.len());
843 }
844
845 #[test]
846 fn dot_output_is_valid() {
847 let graph = build_test_graph();
848 let visual = build_visual_graph(&graph, VisualSource::Desired);
849 let dot = render_dot(&visual);
850
851 assert!(dot.starts_with("digraph roles {"));
852 assert!(dot.contains("orders-editor"));
853 assert!(dot.contains("analytics"));
854 assert!(dot.contains("team@example.com"));
855 assert!(dot.ends_with("}\n"));
856 }
857
858 #[test]
859 fn mermaid_output_is_valid() {
860 let graph = build_test_graph();
861 let visual = build_visual_graph(&graph, VisualSource::Desired);
862 let mermaid = render_mermaid(&visual);
863
864 assert!(mermaid.starts_with("graph LR\n"));
865 assert!(mermaid.contains("orders-editor"));
866 assert!(mermaid.contains("analytics"));
867 }
868
869 #[test]
870 fn tree_output_shows_roles() {
871 let graph = build_test_graph();
872 let visual = build_visual_graph(&graph, VisualSource::Desired);
873 let tree = render_tree(&visual);
874
875 assert!(
876 tree.contains("analytics"),
877 "tree should contain analytics role"
878 );
879 assert!(
880 tree.contains("orders-editor"),
881 "tree should contain orders-editor role"
882 );
883 assert!(tree.contains("[LOGIN]"), "tree should show LOGIN tag");
884 assert!(
885 tree.contains("team@example.com"),
886 "tree should show external member"
887 );
888 }
889
890 #[test]
891 fn membership_label_defaults_to_member() {
892 assert_eq!(membership_label(true, false), "member");
893 }
894
895 #[test]
896 fn membership_label_noinherit() {
897 assert_eq!(membership_label(false, false), "member, NOINHERIT");
898 }
899
900 #[test]
901 fn membership_label_admin() {
902 assert_eq!(membership_label(true, true), "member, ADMIN");
903 }
904
905 #[test]
906 fn membership_label_both_flags() {
907 assert_eq!(membership_label(false, true), "member, NOINHERIT, ADMIN");
908 }
909
910 #[test]
911 fn empty_graph_produces_empty_visual() {
912 let graph = RoleGraph::default();
913 let visual = build_visual_graph(&graph, VisualSource::Current);
914 assert!(visual.nodes.is_empty());
915 assert!(visual.edges.is_empty());
916 assert_eq!(visual.meta.role_count, 0);
917 }
918
919 #[test]
920 fn grant_node_privileges_merge_across_roles() {
921 let yaml = r#"
923roles:
924 - name: role-a
925 - name: role-b
926
927grants:
928 - role: role-a
929 privileges: [SELECT]
930 object: { type: table, schema: app, name: "*" }
931 - role: role-b
932 privileges: [SELECT, INSERT, UPDATE]
933 object: { type: table, schema: app, name: "*" }
934"#;
935 let manifest = parse_manifest(yaml).unwrap();
936 let expanded = expand_manifest(&manifest).unwrap();
937 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
938 let visual = build_visual_graph(&graph, VisualSource::Desired);
939
940 let grant_nodes: Vec<_> = visual
941 .nodes
942 .iter()
943 .filter(|n| n.kind == NodeKind::GrantTarget && n.label.contains("tables"))
944 .collect();
945
946 assert_eq!(grant_nodes.len(), 1, "grant nodes: {grant_nodes:?}");
948
949 let privs = &grant_nodes[0].privileges;
951 assert!(
952 privs.contains(&"INSERT".to_string()),
953 "missing INSERT in {privs:?}"
954 );
955 assert!(
956 privs.contains(&"SELECT".to_string()),
957 "missing SELECT in {privs:?}"
958 );
959 assert!(
960 privs.contains(&"UPDATE".to_string()),
961 "missing UPDATE in {privs:?}"
962 );
963 }
964
965 #[test]
966 fn mermaid_ids_do_not_collide_for_similar_names() {
967 let id_at = mermaid_escape_id("role:alice@example.com");
968 let id_dot = mermaid_escape_id("role:alice.example.com");
969 let id_under = mermaid_escape_id("role:alice_example_com");
970 assert_ne!(id_at, id_dot, "@ and . should produce different IDs");
971 assert_ne!(id_at, id_under, "@ and _ should produce different IDs");
972 assert_ne!(id_dot, id_under, ". and _ should produce different IDs");
973 }
974}