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;
8
9use crate::manifest::{
10    DefaultPrivilege, DefaultPrivilegeGrant, Grant, MemberSpec, Membership, ObjectTarget,
11    PolicyManifest, RoleDefinition, SchemaBinding,
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                password: None, // Passwords are never exported (cannot be read from DB)
71                password_valid_until: state.password_valid_until.clone(),
72            }
73        })
74        .collect();
75
76    // --- Grants ---
77    let grants: Vec<Grant> = graph
78        .grants
79        .iter()
80        .map(|(key, state)| Grant {
81            role: key.role.clone(),
82            privileges: state.privileges.iter().copied().collect(),
83            object: ObjectTarget {
84                object_type: key.object_type,
85                schema: key.schema.clone(),
86                name: key.name.clone(),
87            },
88        })
89        .collect();
90
91    // --- Schemas ---
92    let schemas: Vec<SchemaBinding> = graph
93        .schemas
94        .iter()
95        .map(|(name, state)| SchemaBinding {
96            name: name.clone(),
97            profiles: Vec::new(),
98            role_pattern: "{schema}-{profile}".to_string(),
99            owner: state.owner.clone(),
100        })
101        .collect();
102
103    // --- Default privileges ---
104    // Group by (owner, schema) to produce compact default_privileges entries.
105    let mut dp_groups: BTreeMap<(String, String), Vec<DefaultPrivilegeGrant>> = BTreeMap::new();
106    for (key, state) in &graph.default_privileges {
107        dp_groups
108            .entry((key.owner.clone(), key.schema.clone()))
109            .or_default()
110            .push(DefaultPrivilegeGrant {
111                role: Some(key.grantee.clone()),
112                privileges: state.privileges.iter().copied().collect(),
113                on_type: key.on_type,
114            });
115    }
116    let default_privileges: Vec<DefaultPrivilege> = dp_groups
117        .into_iter()
118        .map(|((owner, schema), grant)| DefaultPrivilege {
119            owner: Some(owner),
120            schema,
121            grant,
122        })
123        .collect();
124
125    // --- Memberships ---
126    // Group by group role.
127    let mut membership_map: BTreeMap<String, Vec<MemberSpec>> = BTreeMap::new();
128    for edge in &graph.memberships {
129        membership_map
130            .entry(edge.role.clone())
131            .or_default()
132            .push(MemberSpec {
133                name: edge.member.clone(),
134                inherit: if edge.inherit { None } else { Some(false) },
135                admin: if edge.admin { Some(true) } else { None },
136            });
137    }
138    let memberships: Vec<Membership> = membership_map
139        .into_iter()
140        .map(|(role, members)| Membership { role, members })
141        .collect();
142
143    PolicyManifest {
144        default_owner: None,
145        auth_providers: Vec::new(),
146        profiles: BTreeMap::new(),
147        schemas,
148        roles,
149        grants,
150        default_privileges,
151        memberships,
152        retirements: Vec::new(),
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Tests
158// ---------------------------------------------------------------------------
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::diff::diff;
164    use crate::manifest::{expand_manifest, parse_manifest};
165    use crate::model::RoleGraph;
166
167    /// Round-trip test: build a RoleGraph, export to manifest, re-import, diff should be empty.
168    #[test]
169    fn round_trip_export_import() {
170        let yaml = r#"
171default_owner: app_owner
172
173profiles:
174  editor:
175    grants:
176      - privileges: [USAGE]
177        object: { type: schema }
178      - privileges: [SELECT, INSERT, UPDATE, DELETE]
179        object: { type: table, name: "*" }
180    default_privileges:
181      - privileges: [SELECT, INSERT, UPDATE, DELETE]
182        on_type: table
183
184schemas:
185  - name: inventory
186    owner: inventory_owner
187    profiles: [editor]
188
189roles:
190  - name: analytics
191    login: true
192    comment: "Analytics role"
193
194memberships:
195  - role: inventory-editor
196    members:
197      - name: "user@example.com"
198        inherit: true
199"#;
200        let manifest = parse_manifest(yaml).unwrap();
201        let expanded = expand_manifest(&manifest).unwrap();
202        let original =
203            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
204
205        // Export and re-import
206        let exported_manifest = role_graph_to_manifest(&original);
207        let exported_expanded = expand_manifest(&exported_manifest).unwrap();
208        let reimported = RoleGraph::from_expanded(
209            &exported_expanded,
210            exported_manifest.default_owner.as_deref(),
211        )
212        .unwrap();
213
214        // Diff should be empty
215        let changes = diff(&original, &reimported);
216        assert!(
217            changes.is_empty(),
218            "round-trip produced unexpected changes: {changes:?}"
219        );
220
221        assert_eq!(exported_manifest.schemas.len(), 1);
222        assert_eq!(exported_manifest.schemas[0].name, "inventory");
223    }
224
225    #[test]
226    fn export_only_emits_non_default_attributes() {
227        let yaml = r#"
228roles:
229  - name: basic-role
230  - name: login-role
231    login: true
232    connection_limit: 5
233"#;
234        let manifest = parse_manifest(yaml).unwrap();
235        let expanded = expand_manifest(&manifest).unwrap();
236        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
237
238        let exported = role_graph_to_manifest(&graph);
239        let basic = exported
240            .roles
241            .iter()
242            .find(|r| r.name == "basic-role")
243            .unwrap();
244        assert!(basic.login.is_none());
245        assert!(basic.superuser.is_none());
246        assert!(basic.connection_limit.is_none());
247
248        let login = exported
249            .roles
250            .iter()
251            .find(|r| r.name == "login-role")
252            .unwrap();
253        assert_eq!(login.login, Some(true));
254        assert_eq!(login.connection_limit, Some(5));
255    }
256
257    #[test]
258    fn export_includes_managed_schemas() {
259        let mut graph = RoleGraph::default();
260        graph.schemas.insert(
261            "cdc".to_string(),
262            crate::model::SchemaState {
263                owner: Some("cdc_owner".to_string()),
264                owner_privileges: crate::model::default_schema_owner_privileges("cdc_owner"),
265            },
266        );
267
268        let exported = role_graph_to_manifest(&graph);
269        assert_eq!(exported.schemas.len(), 1);
270        assert_eq!(exported.schemas[0].name, "cdc");
271        assert_eq!(exported.schemas[0].owner.as_deref(), Some("cdc_owner"));
272        assert!(exported.schemas[0].profiles.is_empty());
273    }
274
275    #[test]
276    fn exported_yaml_omits_null_fields() {
277        let yaml = r#"
278roles:
279  - name: basic-role
280  - name: login-role
281    login: true
282    connection_limit: 5
283"#;
284        let manifest = parse_manifest(yaml).unwrap();
285        let expanded = expand_manifest(&manifest).unwrap();
286        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
287
288        let exported = role_graph_to_manifest(&graph);
289        let serialized = serde_yaml::to_string(&exported).unwrap();
290
291        assert!(
292            !serialized.contains("null"),
293            "serialized YAML should not contain null fields, got:\n{serialized}"
294        );
295        // Non-default attributes should still be present
296        assert!(serialized.contains("login: true"), "got:\n{serialized}");
297        assert!(
298            serialized.contains("connection_limit: 5"),
299            "got:\n{serialized}"
300        );
301    }
302
303    #[test]
304    fn exported_yaml_uses_object_for_grant_targets() {
305        let yaml = r#"
306grants:
307  - role: analytics
308    privileges: [SELECT]
309    object: { type: table, schema: public, name: "*" }
310"#;
311        let manifest = parse_manifest(yaml).unwrap();
312        let expanded = expand_manifest(&manifest).unwrap();
313        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
314
315        let exported = role_graph_to_manifest(&graph);
316        let serialized = serde_yaml::to_string(&exported).unwrap();
317
318        assert!(serialized.contains("object:"), "got:\n{serialized}");
319        assert!(
320            !serialized.contains("\non:"),
321            "exported YAML should not emit legacy on key, got:\n{serialized}"
322        );
323    }
324
325    #[test]
326    fn export_omits_password_and_preserves_password_valid_until() {
327        let yaml = r#"
328roles:
329  - name: app-role
330    login: true
331    password_valid_until: "2026-12-31T00:00:00Z"
332"#;
333        let manifest = parse_manifest(yaml).unwrap();
334        let expanded = expand_manifest(&manifest).unwrap();
335        let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
336
337        let exported = role_graph_to_manifest(&graph);
338        let role = exported
339            .roles
340            .iter()
341            .find(|r| r.name == "app-role")
342            .unwrap();
343
344        assert!(
345            role.password.is_none(),
346            "passwords should never be exported"
347        );
348        assert_eq!(
349            role.password_valid_until.as_deref(),
350            Some("2026-12-31T00:00:00Z")
351        );
352
353        let serialized = serde_yaml::to_string(&exported).unwrap();
354        assert!(
355            !serialized.contains("password:"),
356            "exported YAML must not contain password fields, got:\n{serialized}"
357        );
358        assert!(
359            serialized.contains("password_valid_until: \"2026-12-31T00:00:00Z\"")
360                || serialized.contains("password_valid_until: '2026-12-31T00:00:00Z'")
361                || serialized.contains("password_valid_until: 2026-12-31T00:00:00Z"),
362            "exported YAML should preserve password_valid_until, got:\n{serialized}"
363        );
364    }
365}