Skip to main content

pgroles_core/
export.rs

1//! Export a [`RoleGraph`] to a [`PolicyManifest`] for brownfield adoption.
2//!
3//! This is the reverse of `manifest::expand_manifest` → `RoleGraph::from_expanded`.
4//! It produces a flat manifest (no profiles) that faithfully represents the
5//! current database state. When applied back, it should produce zero diff.
6
7use std::collections::{BTreeMap, HashMap};
8
9use crate::manifest::{
10    DefaultPrivilege, DefaultPrivilegeGrant, Grant, MemberSpec, Membership, ObjectTarget,
11    PolicyManifest, RoleDefinition,
12};
13use crate::model::RoleGraph;
14
15/// Convert a [`RoleGraph`] into a flat [`PolicyManifest`].
16///
17/// The resulting manifest uses no profiles — all roles, grants, default
18/// privileges, and memberships are emitted as top-level entries. This makes
19/// the output straightforward and correct for round-tripping.
20pub fn role_graph_to_manifest(graph: &RoleGraph) -> PolicyManifest {
21    // --- Roles ---
22    let roles: Vec<RoleDefinition> = graph
23        .roles
24        .iter()
25        .map(|(name, state)| {
26            let defaults = crate::model::RoleState::default();
27            RoleDefinition {
28                name: name.clone(),
29                login: if state.login != defaults.login {
30                    Some(state.login)
31                } else {
32                    None
33                },
34                superuser: if state.superuser != defaults.superuser {
35                    Some(state.superuser)
36                } else {
37                    None
38                },
39                createdb: if state.createdb != defaults.createdb {
40                    Some(state.createdb)
41                } else {
42                    None
43                },
44                createrole: if state.createrole != defaults.createrole {
45                    Some(state.createrole)
46                } else {
47                    None
48                },
49                inherit: if state.inherit != defaults.inherit {
50                    Some(state.inherit)
51                } else {
52                    None
53                },
54                replication: if state.replication != defaults.replication {
55                    Some(state.replication)
56                } else {
57                    None
58                },
59                bypassrls: if state.bypassrls != defaults.bypassrls {
60                    Some(state.bypassrls)
61                } else {
62                    None
63                },
64                connection_limit: if state.connection_limit != defaults.connection_limit {
65                    Some(state.connection_limit)
66                } else {
67                    None
68                },
69                comment: state.comment.clone(),
70            }
71        })
72        .collect();
73
74    // --- Grants ---
75    let grants: Vec<Grant> = graph
76        .grants
77        .iter()
78        .map(|(key, state)| Grant {
79            role: key.role.clone(),
80            privileges: state.privileges.iter().copied().collect(),
81            on: ObjectTarget {
82                object_type: key.object_type,
83                schema: key.schema.clone(),
84                name: key.name.clone(),
85            },
86        })
87        .collect();
88
89    // --- Default privileges ---
90    // Group by (owner, schema) to produce compact default_privileges entries.
91    let mut dp_groups: BTreeMap<(String, String), Vec<DefaultPrivilegeGrant>> = BTreeMap::new();
92    for (key, state) in &graph.default_privileges {
93        dp_groups
94            .entry((key.owner.clone(), key.schema.clone()))
95            .or_default()
96            .push(DefaultPrivilegeGrant {
97                role: Some(key.grantee.clone()),
98                privileges: state.privileges.iter().copied().collect(),
99                on_type: key.on_type,
100            });
101    }
102    let default_privileges: Vec<DefaultPrivilege> = dp_groups
103        .into_iter()
104        .map(|((owner, schema), grant)| DefaultPrivilege {
105            owner: Some(owner),
106            schema,
107            grant,
108        })
109        .collect();
110
111    // --- Memberships ---
112    // Group by group role.
113    let mut membership_map: BTreeMap<String, Vec<MemberSpec>> = BTreeMap::new();
114    for edge in &graph.memberships {
115        membership_map
116            .entry(edge.role.clone())
117            .or_default()
118            .push(MemberSpec {
119                name: edge.member.clone(),
120                inherit: edge.inherit,
121                admin: edge.admin,
122            });
123    }
124    let memberships: Vec<Membership> = membership_map
125        .into_iter()
126        .map(|(role, members)| Membership { role, members })
127        .collect();
128
129    PolicyManifest {
130        default_owner: None,
131        auth_providers: Vec::new(),
132        profiles: HashMap::new(),
133        schemas: Vec::new(),
134        roles,
135        grants,
136        default_privileges,
137        memberships,
138        retirements: Vec::new(),
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Tests
144// ---------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::diff::diff;
150    use crate::manifest::{expand_manifest, parse_manifest};
151    use crate::model::RoleGraph;
152
153    /// Round-trip test: build a RoleGraph, export to manifest, re-import, diff should be empty.
154    #[test]
155    fn round_trip_export_import() {
156        let yaml = r#"
157default_owner: app_owner
158
159profiles:
160  editor:
161    grants:
162      - privileges: [USAGE]
163        on: { type: schema }
164      - privileges: [SELECT, INSERT, UPDATE, DELETE]
165        on: { type: table, name: "*" }
166    default_privileges:
167      - privileges: [SELECT, INSERT, UPDATE, DELETE]
168        on_type: table
169
170schemas:
171  - name: inventory
172    profiles: [editor]
173
174roles:
175  - name: analytics
176    login: true
177    comment: "Analytics role"
178
179memberships:
180  - role: inventory-editor
181    members:
182      - name: "user@example.com"
183        inherit: true
184"#;
185        let manifest = parse_manifest(yaml).unwrap();
186        let expanded = expand_manifest(&manifest).unwrap();
187        let original =
188            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
189
190        // Export and re-import
191        let exported_manifest = role_graph_to_manifest(&original);
192        let exported_expanded = expand_manifest(&exported_manifest).unwrap();
193        let reimported = RoleGraph::from_expanded(
194            &exported_expanded,
195            exported_manifest.default_owner.as_deref(),
196        )
197        .unwrap();
198
199        // Diff should be empty
200        let changes = diff(&original, &reimported);
201        assert!(
202            changes.is_empty(),
203            "round-trip produced unexpected changes: {changes:?}"
204        );
205    }
206
207    #[test]
208    fn export_only_emits_non_default_attributes() {
209        let yaml = r#"
210roles:
211  - name: basic-role
212  - name: login-role
213    login: true
214    connection_limit: 5
215"#;
216        let manifest = parse_manifest(yaml).unwrap();
217        let expanded = expand_manifest(&manifest).unwrap();
218        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
219
220        let exported = role_graph_to_manifest(&graph);
221        let basic = exported
222            .roles
223            .iter()
224            .find(|r| r.name == "basic-role")
225            .unwrap();
226        assert!(basic.login.is_none());
227        assert!(basic.superuser.is_none());
228        assert!(basic.connection_limit.is_none());
229
230        let login = exported
231            .roles
232            .iter()
233            .find(|r| r.name == "login-role")
234            .unwrap();
235        assert_eq!(login.login, Some(true));
236        assert_eq!(login.connection_limit, Some(5));
237    }
238}