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