1use std::collections::BTreeMap;
8
9use crate::manifest::{
10 DefaultPrivilege, DefaultPrivilegeGrant, Grant, MemberSpec, Membership, ObjectTarget,
11 PolicyManifest, RoleDefinition, SchemaBinding,
12};
13use crate::model::RoleGraph;
14
15pub fn role_graph_to_manifest(graph: &RoleGraph) -> PolicyManifest {
21 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, password_valid_until: state.password_valid_until.clone(),
73 }
74 })
75 .collect();
76
77 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 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 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 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#[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 #[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 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 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 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}