1use std::collections::{BTreeMap, BTreeSet};
9
10use crate::manifest::{ExpandedManifest, Grant, ObjectType, Privilege, RoleDefinition};
11
12#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
18pub struct RoleState {
19 pub login: bool,
20 pub superuser: bool,
21 pub createdb: bool,
22 pub createrole: bool,
23 pub inherit: bool,
24 pub replication: bool,
25 pub bypassrls: bool,
26 pub connection_limit: i32,
27 pub comment: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
31 pub password_valid_until: Option<String>,
32}
33
34impl Default for RoleState {
35 fn default() -> Self {
36 Self {
37 login: false,
38 superuser: false,
39 createdb: false,
40 createrole: false,
41 inherit: true, replication: false,
43 bypassrls: false,
44 connection_limit: -1, comment: None,
46 password_valid_until: None,
47 }
48 }
49}
50
51impl RoleState {
52 pub fn from_definition(definition: &RoleDefinition) -> Self {
55 let defaults = Self::default();
56 Self {
57 login: definition.login.unwrap_or(defaults.login),
58 superuser: definition.superuser.unwrap_or(defaults.superuser),
59 createdb: definition.createdb.unwrap_or(defaults.createdb),
60 createrole: definition.createrole.unwrap_or(defaults.createrole),
61 inherit: definition.inherit.unwrap_or(defaults.inherit),
62 replication: definition.replication.unwrap_or(defaults.replication),
63 bypassrls: definition.bypassrls.unwrap_or(defaults.bypassrls),
64 connection_limit: definition
65 .connection_limit
66 .unwrap_or(defaults.connection_limit),
67 comment: definition.comment.clone(),
68 password_valid_until: definition.password_valid_until.clone(),
69 }
70 }
71
72 pub fn changed_attributes(&self, other: &RoleState) -> Vec<RoleAttribute> {
74 let mut changes = Vec::new();
75 if self.login != other.login {
76 changes.push(RoleAttribute::Login(other.login));
77 }
78 if self.superuser != other.superuser {
79 changes.push(RoleAttribute::Superuser(other.superuser));
80 }
81 if self.createdb != other.createdb {
82 changes.push(RoleAttribute::Createdb(other.createdb));
83 }
84 if self.createrole != other.createrole {
85 changes.push(RoleAttribute::Createrole(other.createrole));
86 }
87 if self.inherit != other.inherit {
88 changes.push(RoleAttribute::Inherit(other.inherit));
89 }
90 if self.replication != other.replication {
91 changes.push(RoleAttribute::Replication(other.replication));
92 }
93 if self.bypassrls != other.bypassrls {
94 changes.push(RoleAttribute::Bypassrls(other.bypassrls));
95 }
96 if self.connection_limit != other.connection_limit {
97 changes.push(RoleAttribute::ConnectionLimit(other.connection_limit));
98 }
99 if self.password_valid_until != other.password_valid_until {
100 changes.push(RoleAttribute::ValidUntil(
101 other.password_valid_until.clone(),
102 ));
103 }
104 changes
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
110pub enum RoleAttribute {
111 Login(bool),
112 Superuser(bool),
113 Createdb(bool),
114 Createrole(bool),
115 Inherit(bool),
116 Replication(bool),
117 Bypassrls(bool),
118 ConnectionLimit(i32),
119 ValidUntil(Option<String>),
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
131pub struct GrantKey {
132 pub role: String,
134 pub object_type: ObjectType,
136 pub schema: Option<String>,
138 pub name: Option<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
144pub struct GrantState {
145 pub privileges: BTreeSet<Privilege>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
154pub struct DefaultPrivKey {
155 pub owner: String,
157 pub schema: String,
159 pub on_type: ObjectType,
161 pub grantee: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
167pub struct DefaultPrivState {
168 pub privileges: BTreeSet<Privilege>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
177pub struct MembershipEdge {
178 pub role: String,
180 pub member: String,
182 pub inherit: bool,
184 pub admin: bool,
186}
187
188#[derive(Debug, Clone, Default)]
197pub struct RoleGraph {
198 pub roles: BTreeMap<String, RoleState>,
200 pub grants: BTreeMap<GrantKey, GrantState>,
202 pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
204 pub memberships: BTreeSet<MembershipEdge>,
206}
207
208impl RoleGraph {
209 pub fn from_expanded(
214 expanded: &ExpandedManifest,
215 default_owner: Option<&str>,
216 ) -> Result<Self, crate::manifest::ManifestError> {
217 let mut graph = Self::default();
218
219 for role_def in &expanded.roles {
221 let state = RoleState::from_definition(role_def);
222 graph.roles.insert(role_def.name.clone(), state);
223 }
224
225 for grant in &expanded.grants {
227 let key = grant_key_from_manifest(grant);
228 let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
229 privileges: BTreeSet::new(),
230 });
231 for privilege in &grant.privileges {
232 entry.privileges.insert(*privilege);
233 }
234 }
235
236 for default_priv in &expanded.default_privileges {
238 let owner = default_priv
239 .owner
240 .as_deref()
241 .or(default_owner)
242 .unwrap_or("postgres")
243 .to_string();
244
245 for grant in &default_priv.grant {
246 let grantee = grant.role.clone().ok_or_else(|| {
247 crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
248 schema: default_priv.schema.clone(),
249 }
250 })?;
251
252 let key = DefaultPrivKey {
253 owner: owner.clone(),
254 schema: default_priv.schema.clone(),
255 on_type: grant.on_type,
256 grantee,
257 };
258
259 let entry =
260 graph
261 .default_privileges
262 .entry(key)
263 .or_insert_with(|| DefaultPrivState {
264 privileges: BTreeSet::new(),
265 });
266 for privilege in &grant.privileges {
267 entry.privileges.insert(*privilege);
268 }
269 }
270 }
271
272 for membership in &expanded.memberships {
274 for member_spec in &membership.members {
275 graph.memberships.insert(MembershipEdge {
276 role: membership.role.clone(),
277 member: member_spec.name.clone(),
278 inherit: member_spec.inherit,
279 admin: member_spec.admin,
280 });
281 }
282 }
283
284 Ok(graph)
285 }
286}
287
288fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
293 GrantKey {
294 role: grant.role.clone(),
295 object_type: grant.on.object_type,
296 schema: grant.on.schema.clone(),
297 name: grant.on.name.clone(),
298 }
299}
300
301impl PartialOrd for ObjectType {
306 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
307 Some(self.cmp(other))
308 }
309}
310
311impl Ord for ObjectType {
312 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
313 self.to_string().cmp(&other.to_string())
314 }
315}
316
317impl PartialOrd for Privilege {
318 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
319 Some(self.cmp(other))
320 }
321}
322
323impl Ord for Privilege {
324 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
325 self.to_string().cmp(&other.to_string())
326 }
327}
328
329#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::manifest::{expand_manifest, parse_manifest};
337
338 #[test]
339 fn role_state_defaults_match_postgres() {
340 let state = RoleState::default();
341 assert!(!state.login);
342 assert!(!state.superuser);
343 assert!(!state.createdb);
344 assert!(!state.createrole);
345 assert!(state.inherit); assert!(!state.replication);
347 assert!(!state.bypassrls);
348 assert_eq!(state.connection_limit, -1);
349 }
350
351 #[test]
352 fn role_state_from_definition_applies_overrides() {
353 let definition = RoleDefinition {
354 name: "test".to_string(),
355 login: Some(true),
356 superuser: None,
357 createdb: Some(true),
358 createrole: None,
359 inherit: Some(false),
360 replication: None,
361 bypassrls: None,
362 connection_limit: Some(10),
363 comment: Some("test role".to_string()),
364 password: None,
365 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
366 };
367 let state = RoleState::from_definition(&definition);
368 assert!(state.login);
369 assert!(!state.superuser); assert!(state.createdb);
371 assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
374 assert_eq!(state.comment, Some("test role".to_string()));
375 assert_eq!(
376 state.password_valid_until,
377 Some("2025-12-31T00:00:00Z".to_string())
378 );
379 }
380
381 #[test]
382 fn changed_attributes_detects_differences() {
383 let current = RoleState::default();
384 let desired = RoleState {
385 login: true,
386 connection_limit: 5,
387 ..RoleState::default()
388 };
389 let changes = current.changed_attributes(&desired);
390 assert_eq!(changes.len(), 2);
391 assert!(changes.contains(&RoleAttribute::Login(true)));
392 assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
393 }
394
395 #[test]
396 fn changed_attributes_empty_when_equal() {
397 let state = RoleState::default();
398 assert!(state.changed_attributes(&state.clone()).is_empty());
399 }
400
401 #[test]
402 fn role_graph_from_expanded_manifest() {
403 let yaml = r#"
404default_owner: app_owner
405
406profiles:
407 editor:
408 grants:
409 - privileges: [USAGE]
410 on: { type: schema }
411 - privileges: [SELECT, INSERT]
412 on: { type: table, name: "*" }
413 default_privileges:
414 - privileges: [SELECT, INSERT]
415 on_type: table
416
417schemas:
418 - name: inventory
419 profiles: [editor]
420
421roles:
422 - name: analytics
423 login: true
424
425memberships:
426 - role: inventory-editor
427 members:
428 - name: "user@example.com"
429 inherit: true
430"#;
431 let manifest = parse_manifest(yaml).unwrap();
432 let expanded = expand_manifest(&manifest).unwrap();
433 let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
434
435 assert_eq!(graph.roles.len(), 2);
437 assert!(graph.roles.contains_key("inventory-editor"));
438 assert!(graph.roles.contains_key("analytics"));
439
440 assert!(!graph.roles["inventory-editor"].login);
442 assert!(graph.roles["analytics"].login);
443
444 assert_eq!(graph.grants.len(), 2);
446
447 assert_eq!(graph.default_privileges.len(), 1);
449 let dp_key = graph.default_privileges.keys().next().unwrap();
450 assert_eq!(dp_key.owner, "app_owner");
451 assert_eq!(dp_key.schema, "inventory");
452 assert_eq!(dp_key.on_type, ObjectType::Table);
453 assert_eq!(dp_key.grantee, "inventory-editor");
454 let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
455 assert!(dp_privs.contains(&Privilege::Select));
456 assert!(dp_privs.contains(&Privilege::Insert));
457
458 assert_eq!(graph.memberships.len(), 1);
460 let edge = graph.memberships.iter().next().unwrap();
461 assert_eq!(edge.role, "inventory-editor");
462 assert_eq!(edge.member, "user@example.com");
463 assert!(edge.inherit);
464 assert!(!edge.admin);
465 }
466
467 #[test]
468 fn grant_privileges_merge_for_same_target() {
469 let yaml = r#"
470roles:
471 - name: testrole
472
473grants:
474 - role: testrole
475 privileges: [SELECT]
476 on: { type: table, schema: public, name: "*" }
477 - role: testrole
478 privileges: [INSERT, UPDATE]
479 on: { type: table, schema: public, name: "*" }
480"#;
481 let manifest = parse_manifest(yaml).unwrap();
482 let expanded = expand_manifest(&manifest).unwrap();
483 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
484
485 assert_eq!(graph.grants.len(), 1);
487 let grant_state = graph.grants.values().next().unwrap();
488 assert_eq!(grant_state.privileges.len(), 3);
489 assert!(grant_state.privileges.contains(&Privilege::Select));
490 assert!(grant_state.privileges.contains(&Privilege::Insert));
491 assert!(grant_state.privileges.contains(&Privilege::Update));
492 }
493}