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 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 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 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 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 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#[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 #[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 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 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 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}