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