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