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 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, password_valid_until: state.password_valid_until.clone(),
72 }
73 })
74 .collect();
75
76 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 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 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 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#[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 #[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 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 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 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}