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, serde::Serialize)]
129pub struct SchemaState {
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub owner: Option<String>,
133 #[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
138 pub owner_privileges: BTreeSet<Privilege>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
149pub struct GrantKey {
150 pub role: String,
152 pub object_type: ObjectType,
154 pub schema: Option<String>,
156 pub name: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162pub struct GrantState {
163 pub privileges: BTreeSet<Privilege>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
172pub struct DefaultPrivKey {
173 pub owner: String,
175 pub schema: String,
177 pub on_type: ObjectType,
179 pub grantee: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
185pub struct DefaultPrivState {
186 pub privileges: BTreeSet<Privilege>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
195pub struct MembershipEdge {
196 pub role: String,
198 pub member: String,
200 pub inherit: bool,
202 pub admin: bool,
204}
205
206#[derive(Debug, Clone, Default)]
215pub struct RoleGraph {
216 pub roles: BTreeMap<String, RoleState>,
218 pub schemas: BTreeMap<String, SchemaState>,
220 pub grants: BTreeMap<GrantKey, GrantState>,
222 pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
224 pub memberships: BTreeSet<MembershipEdge>,
226}
227
228impl RoleGraph {
229 pub fn from_expanded(
234 expanded: &ExpandedManifest,
235 default_owner: Option<&str>,
236 ) -> Result<Self, crate::manifest::ManifestError> {
237 let mut graph = Self::default();
238
239 for role_def in &expanded.roles {
241 let state = RoleState::from_definition(role_def);
242 graph.roles.insert(role_def.name.clone(), state);
243 }
244
245 for schema in &expanded.schemas {
247 let owner = schema.owner.clone();
248 graph.schemas.insert(
249 schema.name.clone(),
250 SchemaState {
251 owner_privileges: owner
252 .as_deref()
253 .map(default_schema_owner_privileges)
254 .unwrap_or_default(),
255 owner,
256 },
257 );
258 }
259
260 for grant in &expanded.grants {
262 let key = grant_key_from_manifest(grant);
263 let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
264 privileges: BTreeSet::new(),
265 });
266 for privilege in &grant.privileges {
267 entry.privileges.insert(*privilege);
268 }
269 }
270
271 for default_priv in &expanded.default_privileges {
273 let owner = default_priv
274 .owner
275 .as_deref()
276 .or(default_owner)
277 .unwrap_or("postgres")
278 .to_string();
279
280 for grant in &default_priv.grant {
281 let grantee = grant.role.clone().ok_or_else(|| {
282 crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
283 schema: default_priv.schema.clone(),
284 }
285 })?;
286
287 let key = DefaultPrivKey {
288 owner: owner.clone(),
289 schema: default_priv.schema.clone(),
290 on_type: grant.on_type,
291 grantee,
292 };
293
294 let entry =
295 graph
296 .default_privileges
297 .entry(key)
298 .or_insert_with(|| DefaultPrivState {
299 privileges: BTreeSet::new(),
300 });
301 for privilege in &grant.privileges {
302 entry.privileges.insert(*privilege);
303 }
304 }
305 }
306
307 for membership in &expanded.memberships {
309 for member_spec in &membership.members {
310 graph.memberships.insert(MembershipEdge {
311 role: membership.role.clone(),
312 member: member_spec.name.clone(),
313 inherit: member_spec.inherit(),
314 admin: member_spec.admin(),
315 });
316 }
317 }
318
319 Ok(graph)
320 }
321}
322
323fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
328 GrantKey {
329 role: grant.role.clone(),
330 object_type: grant.object.object_type,
331 schema: grant.object.schema.clone(),
332 name: grant.object.name.clone(),
333 }
334}
335
336pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
337 [Privilege::Create, Privilege::Usage].into_iter().collect()
338}
339
340impl PartialOrd for ObjectType {
345 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
346 Some(self.cmp(other))
347 }
348}
349
350impl Ord for ObjectType {
351 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
352 self.to_string().cmp(&other.to_string())
353 }
354}
355
356impl PartialOrd for Privilege {
357 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
358 Some(self.cmp(other))
359 }
360}
361
362impl Ord for Privilege {
363 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
364 self.to_string().cmp(&other.to_string())
365 }
366}
367
368#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::manifest::{expand_manifest, parse_manifest};
376
377 #[test]
378 fn role_state_defaults_match_postgres() {
379 let state = RoleState::default();
380 assert!(!state.login);
381 assert!(!state.superuser);
382 assert!(!state.createdb);
383 assert!(!state.createrole);
384 assert!(state.inherit); assert!(!state.replication);
386 assert!(!state.bypassrls);
387 assert_eq!(state.connection_limit, -1);
388 }
389
390 #[test]
391 fn role_state_from_definition_applies_overrides() {
392 let definition = RoleDefinition {
393 name: "test".to_string(),
394 login: Some(true),
395 superuser: None,
396 createdb: Some(true),
397 createrole: None,
398 inherit: Some(false),
399 replication: None,
400 bypassrls: None,
401 connection_limit: Some(10),
402 comment: Some("test role".to_string()),
403 password: None,
404 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
405 };
406 let state = RoleState::from_definition(&definition);
407 assert!(state.login);
408 assert!(!state.superuser); assert!(state.createdb);
410 assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
413 assert_eq!(state.comment, Some("test role".to_string()));
414 assert_eq!(
415 state.password_valid_until,
416 Some("2025-12-31T00:00:00Z".to_string())
417 );
418 }
419
420 #[test]
421 fn changed_attributes_detects_differences() {
422 let current = RoleState::default();
423 let desired = RoleState {
424 login: true,
425 connection_limit: 5,
426 ..RoleState::default()
427 };
428 let changes = current.changed_attributes(&desired);
429 assert_eq!(changes.len(), 2);
430 assert!(changes.contains(&RoleAttribute::Login(true)));
431 assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
432 }
433
434 #[test]
435 fn changed_attributes_empty_when_equal() {
436 let state = RoleState::default();
437 assert!(state.changed_attributes(&state.clone()).is_empty());
438 }
439
440 #[test]
441 fn role_graph_from_expanded_manifest() {
442 let yaml = r#"
443default_owner: app_owner
444
445profiles:
446 editor:
447 grants:
448 - privileges: [USAGE]
449 object: { type: schema }
450 - privileges: [SELECT, INSERT]
451 object: { type: table, name: "*" }
452 default_privileges:
453 - privileges: [SELECT, INSERT]
454 on_type: table
455
456schemas:
457 - name: inventory
458 profiles: [editor]
459
460roles:
461 - name: analytics
462 login: true
463
464memberships:
465 - role: inventory-editor
466 members:
467 - name: "user@example.com"
468 inherit: true
469"#;
470 let manifest = parse_manifest(yaml).unwrap();
471 let expanded = expand_manifest(&manifest).unwrap();
472 let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
473
474 assert_eq!(graph.roles.len(), 2);
476 assert!(graph.roles.contains_key("inventory-editor"));
477 assert!(graph.roles.contains_key("analytics"));
478
479 assert_eq!(graph.schemas.len(), 1);
481 assert_eq!(
482 graph.schemas["inventory"].owner.as_deref(),
483 Some("app_owner")
484 );
485
486 assert!(!graph.roles["inventory-editor"].login);
488 assert!(graph.roles["analytics"].login);
489
490 assert_eq!(graph.grants.len(), 2);
492
493 assert_eq!(graph.default_privileges.len(), 1);
495 let dp_key = graph.default_privileges.keys().next().unwrap();
496 assert_eq!(dp_key.owner, "app_owner");
497 assert_eq!(dp_key.schema, "inventory");
498 assert_eq!(dp_key.on_type, ObjectType::Table);
499 assert_eq!(dp_key.grantee, "inventory-editor");
500 let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
501 assert!(dp_privs.contains(&Privilege::Select));
502 assert!(dp_privs.contains(&Privilege::Insert));
503
504 assert_eq!(graph.memberships.len(), 1);
506 let edge = graph.memberships.iter().next().unwrap();
507 assert_eq!(edge.role, "inventory-editor");
508 assert_eq!(edge.member, "user@example.com");
509 assert!(edge.inherit);
510 assert!(!edge.admin);
511 }
512
513 #[test]
514 fn grant_privileges_merge_for_same_target() {
515 let yaml = r#"
516roles:
517 - name: testrole
518
519grants:
520 - role: testrole
521 privileges: [SELECT]
522 object: { type: table, schema: public, name: "*" }
523 - role: testrole
524 privileges: [INSERT, UPDATE]
525 object: { type: table, schema: public, name: "*" }
526"#;
527 let manifest = parse_manifest(yaml).unwrap();
528 let expanded = expand_manifest(&manifest).unwrap();
529 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
530
531 assert_eq!(graph.grants.len(), 1);
533 let grant_state = graph.grants.values().next().unwrap();
534 assert_eq!(grant_state.privileges.len(), 3);
535 assert!(grant_state.privileges.contains(&Privilege::Select));
536 assert!(grant_state.privileges.contains(&Privilege::Insert));
537 assert!(grant_state.privileges.contains(&Privilege::Update));
538 }
539}