Skip to main content

taudit_core/
map.rs

1use crate::graph::{AuthorityGraph, EdgeKind, NodeKind};
2
3/// A row in the authority map: one step and its authority grants.
4#[derive(Debug)]
5pub struct MapRow {
6    pub step_name: String,
7    pub trust_zone: String,
8    /// Index into the `authorities` Vec — true if this step has access.
9    pub access: Vec<bool>,
10}
11
12/// Authority map: which steps have access to which secrets/identities.
13#[derive(Debug)]
14pub struct AuthorityMap {
15    /// Column headers: authority source names (secrets + identities).
16    pub authorities: Vec<String>,
17    /// One row per step.
18    pub rows: Vec<MapRow>,
19}
20
21/// Build the authority map from a graph.
22pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
23    // Collect authority sources (secrets + identities) in stable order
24    let authorities: Vec<_> = graph
25        .authority_sources()
26        .map(|n| (n.id, n.name.clone()))
27        .collect();
28
29    let authority_names: Vec<String> = authorities.iter().map(|(_, name)| name.clone()).collect();
30
31    // Build rows for each step
32    let mut rows = Vec::new();
33    for step in graph.nodes_of_kind(NodeKind::Step) {
34        let mut access = vec![false; authorities.len()];
35
36        for edge in graph.edges_from(step.id) {
37            if edge.kind != EdgeKind::HasAccessTo {
38                continue;
39            }
40            // Find which authority column this maps to
41            if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
42                access[idx] = true;
43            }
44        }
45
46        rows.push(MapRow {
47            step_name: step.name.clone(),
48            trust_zone: format!("{:?}", step.trust_zone),
49            access,
50        });
51    }
52
53    AuthorityMap {
54        authorities: authority_names,
55        rows,
56    }
57}
58
59/// Render the authority map as a formatted table string.
60pub fn render_map(map: &AuthorityMap) -> String {
61    if map.rows.is_empty() && map.authorities.is_empty() {
62        return "No steps or authority sources found.\n".to_string();
63    }
64
65    // Calculate column widths
66    let step_width = map
67        .rows
68        .iter()
69        .map(|r| r.step_name.len())
70        .max()
71        .unwrap_or(4)
72        .max(4);
73
74    let zone_width = map
75        .rows
76        .iter()
77        .map(|r| r.trust_zone.len())
78        .max()
79        .unwrap_or(4)
80        .max(4);
81
82    let auth_widths: Vec<usize> = map.authorities.iter().map(|a| a.len().max(3)).collect();
83
84    let mut out = String::new();
85
86    // Header
87    out.push_str(&format!(
88        "{:<step_w$}  {:<zone_w$}",
89        "Step",
90        "Zone",
91        step_w = step_width,
92        zone_w = zone_width
93    ));
94    for (i, auth) in map.authorities.iter().enumerate() {
95        out.push_str(&format!("  {:^w$}", auth, w = auth_widths[i]));
96    }
97    out.push('\n');
98
99    // Separator
100    out.push_str(&"-".repeat(step_width));
101    out.push_str("  ");
102    out.push_str(&"-".repeat(zone_width));
103    for w in &auth_widths {
104        out.push_str("  ");
105        out.push_str(&"-".repeat(*w));
106    }
107    out.push('\n');
108
109    // Rows
110    for row in &map.rows {
111        out.push_str(&format!(
112            "{:<step_w$}  {:<zone_w$}",
113            row.step_name,
114            row.trust_zone,
115            step_w = step_width,
116            zone_w = zone_width
117        ));
118        for (i, &has) in row.access.iter().enumerate() {
119            let marker = if has { "X" } else { "." };
120            out.push_str(&format!("  {:^w$}", marker, w = auth_widths[i]));
121        }
122        out.push('\n');
123    }
124
125    out
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::graph::*;
132
133    fn source(file: &str) -> PipelineSource {
134        PipelineSource {
135            file: file.into(),
136            repo: None,
137            git_ref: None,
138        }
139    }
140
141    #[test]
142    fn map_shows_step_access() {
143        let mut g = AuthorityGraph::new(source("ci.yml"));
144        let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
145        let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
146        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
147        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
148
149        g.add_edge(build, secret, EdgeKind::HasAccessTo);
150        g.add_edge(build, token, EdgeKind::HasAccessTo);
151        g.add_edge(deploy, token, EdgeKind::HasAccessTo);
152
153        let map = authority_map(&g);
154        assert_eq!(map.authorities.len(), 2);
155        assert_eq!(map.rows.len(), 2);
156
157        // build has access to both
158        let build_row = &map.rows[0];
159        assert!(build_row.access[0]); // API_KEY
160        assert!(build_row.access[1]); // GITHUB_TOKEN
161
162        // deploy has access to token only
163        let deploy_row = &map.rows[1];
164        assert!(!deploy_row.access[0]); // no API_KEY
165        assert!(deploy_row.access[1]); // GITHUB_TOKEN
166    }
167
168    #[test]
169    fn map_renders_table() {
170        let mut g = AuthorityGraph::new(source("ci.yml"));
171        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
172        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
173        g.add_edge(step, secret, EdgeKind::HasAccessTo);
174
175        let map = authority_map(&g);
176        let table = render_map(&map);
177        assert!(table.contains("build"));
178        assert!(table.contains("KEY"));
179        assert!(table.contains("X"));
180    }
181}