1use std::collections::{BTreeMap, HashMap};
8
9use crate::manifest::{
10 DefaultPrivilege, DefaultPrivilegeGrant, Grant, MemberSpec, Membership, ObjectTarget,
11 PolicyManifest, RoleDefinition,
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 }
71 })
72 .collect();
73
74 let grants: Vec<Grant> = graph
76 .grants
77 .iter()
78 .map(|(key, state)| Grant {
79 role: key.role.clone(),
80 privileges: state.privileges.iter().copied().collect(),
81 on: ObjectTarget {
82 object_type: key.object_type,
83 schema: key.schema.clone(),
84 name: key.name.clone(),
85 },
86 })
87 .collect();
88
89 let mut dp_groups: BTreeMap<(String, String), Vec<DefaultPrivilegeGrant>> = BTreeMap::new();
92 for (key, state) in &graph.default_privileges {
93 dp_groups
94 .entry((key.owner.clone(), key.schema.clone()))
95 .or_default()
96 .push(DefaultPrivilegeGrant {
97 role: Some(key.grantee.clone()),
98 privileges: state.privileges.iter().copied().collect(),
99 on_type: key.on_type,
100 });
101 }
102 let default_privileges: Vec<DefaultPrivilege> = dp_groups
103 .into_iter()
104 .map(|((owner, schema), grant)| DefaultPrivilege {
105 owner: Some(owner),
106 schema,
107 grant,
108 })
109 .collect();
110
111 let mut membership_map: BTreeMap<String, Vec<MemberSpec>> = BTreeMap::new();
114 for edge in &graph.memberships {
115 membership_map
116 .entry(edge.role.clone())
117 .or_default()
118 .push(MemberSpec {
119 name: edge.member.clone(),
120 inherit: edge.inherit,
121 admin: edge.admin,
122 });
123 }
124 let memberships: Vec<Membership> = membership_map
125 .into_iter()
126 .map(|(role, members)| Membership { role, members })
127 .collect();
128
129 PolicyManifest {
130 default_owner: None,
131 auth_providers: Vec::new(),
132 profiles: HashMap::new(),
133 schemas: Vec::new(),
134 roles,
135 grants,
136 default_privileges,
137 memberships,
138 retirements: Vec::new(),
139 }
140}
141
142#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::diff::diff;
150 use crate::manifest::{expand_manifest, parse_manifest};
151 use crate::model::RoleGraph;
152
153 #[test]
155 fn round_trip_export_import() {
156 let yaml = r#"
157default_owner: app_owner
158
159profiles:
160 editor:
161 grants:
162 - privileges: [USAGE]
163 on: { type: schema }
164 - privileges: [SELECT, INSERT, UPDATE, DELETE]
165 on: { type: table, name: "*" }
166 default_privileges:
167 - privileges: [SELECT, INSERT, UPDATE, DELETE]
168 on_type: table
169
170schemas:
171 - name: inventory
172 profiles: [editor]
173
174roles:
175 - name: analytics
176 login: true
177 comment: "Analytics role"
178
179memberships:
180 - role: inventory-editor
181 members:
182 - name: "user@example.com"
183 inherit: true
184"#;
185 let manifest = parse_manifest(yaml).unwrap();
186 let expanded = expand_manifest(&manifest).unwrap();
187 let original =
188 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
189
190 let exported_manifest = role_graph_to_manifest(&original);
192 let exported_expanded = expand_manifest(&exported_manifest).unwrap();
193 let reimported = RoleGraph::from_expanded(
194 &exported_expanded,
195 exported_manifest.default_owner.as_deref(),
196 )
197 .unwrap();
198
199 let changes = diff(&original, &reimported);
201 assert!(
202 changes.is_empty(),
203 "round-trip produced unexpected changes: {changes:?}"
204 );
205 }
206
207 #[test]
208 fn export_only_emits_non_default_attributes() {
209 let yaml = r#"
210roles:
211 - name: basic-role
212 - name: login-role
213 login: true
214 connection_limit: 5
215"#;
216 let manifest = parse_manifest(yaml).unwrap();
217 let expanded = expand_manifest(&manifest).unwrap();
218 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
219
220 let exported = role_graph_to_manifest(&graph);
221 let basic = exported
222 .roles
223 .iter()
224 .find(|r| r.name == "basic-role")
225 .unwrap();
226 assert!(basic.login.is_none());
227 assert!(basic.superuser.is_none());
228 assert!(basic.connection_limit.is_none());
229
230 let login = exported
231 .roles
232 .iter()
233 .find(|r| r.name == "login-role")
234 .unwrap();
235 assert_eq!(login.login, Some(true));
236 assert_eq!(login.connection_limit, Some(5));
237 }
238}