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 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
37 pub config: BTreeMap<String, String>,
38}
39
40impl Default for RoleState {
41 fn default() -> Self {
42 Self {
43 login: false,
44 superuser: false,
45 createdb: false,
46 createrole: false,
47 inherit: true, replication: false,
49 bypassrls: false,
50 connection_limit: -1, comment: None,
52 password_valid_until: None,
53 config: BTreeMap::new(),
54 }
55 }
56}
57
58impl RoleState {
59 pub fn from_definition(definition: &RoleDefinition) -> Self {
62 let defaults = Self::default();
63 Self {
64 login: definition.login.unwrap_or(defaults.login),
65 superuser: definition.superuser.unwrap_or(defaults.superuser),
66 createdb: definition.createdb.unwrap_or(defaults.createdb),
67 createrole: definition.createrole.unwrap_or(defaults.createrole),
68 inherit: definition.inherit.unwrap_or(defaults.inherit),
69 replication: definition.replication.unwrap_or(defaults.replication),
70 bypassrls: definition.bypassrls.unwrap_or(defaults.bypassrls),
71 connection_limit: definition
72 .connection_limit
73 .unwrap_or(defaults.connection_limit),
74 comment: definition.comment.clone(),
75 password_valid_until: definition.password_valid_until.clone(),
76 config: definition
81 .config
82 .iter()
83 .map(|(name, value)| {
84 let name = name.to_ascii_lowercase();
85 let value = if crate::guc::is_list_quote_parameter(&name) {
86 crate::guc::canonicalize_list_guc_value(&value.0)
87 } else {
88 value.0.clone()
89 };
90 (name, value)
91 })
92 .collect(),
93 }
94 }
95
96 pub fn changed_attributes(&self, other: &RoleState) -> Vec<RoleAttribute> {
98 let mut changes = Vec::new();
99 if self.login != other.login {
100 changes.push(RoleAttribute::Login(other.login));
101 }
102 if self.superuser != other.superuser {
103 changes.push(RoleAttribute::Superuser(other.superuser));
104 }
105 if self.createdb != other.createdb {
106 changes.push(RoleAttribute::Createdb(other.createdb));
107 }
108 if self.createrole != other.createrole {
109 changes.push(RoleAttribute::Createrole(other.createrole));
110 }
111 if self.inherit != other.inherit {
112 changes.push(RoleAttribute::Inherit(other.inherit));
113 }
114 if self.replication != other.replication {
115 changes.push(RoleAttribute::Replication(other.replication));
116 }
117 if self.bypassrls != other.bypassrls {
118 changes.push(RoleAttribute::Bypassrls(other.bypassrls));
119 }
120 if self.connection_limit != other.connection_limit {
121 changes.push(RoleAttribute::ConnectionLimit(other.connection_limit));
122 }
123 if self.password_valid_until != other.password_valid_until {
124 changes.push(RoleAttribute::ValidUntil(
125 other.password_valid_until.clone(),
126 ));
127 }
128 for (parameter, value) in &other.config {
129 if self.config.get(parameter) != Some(value) {
130 changes.push(RoleAttribute::SetConfig(parameter.clone(), value.clone()));
131 }
132 }
133 for parameter in self.config.keys() {
134 if !other.config.contains_key(parameter) {
135 changes.push(RoleAttribute::ResetConfig(parameter.clone()));
136 }
137 }
138 changes
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
144pub enum RoleAttribute {
145 Login(bool),
146 Superuser(bool),
147 Createdb(bool),
148 Createrole(bool),
149 Inherit(bool),
150 Replication(bool),
151 Bypassrls(bool),
152 ConnectionLimit(i32),
153 ValidUntil(Option<String>),
155 SetConfig(String, String),
157 ResetConfig(String),
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
167pub struct SchemaState {
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub owner: Option<String>,
171 #[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
176 pub owner_privileges: BTreeSet<Privilege>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
187pub struct GrantKey {
188 pub role: String,
190 pub object_type: ObjectType,
192 pub schema: Option<String>,
194 pub name: Option<String>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
200pub struct GrantState {
201 pub privileges: BTreeSet<Privilege>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
210pub struct DefaultPrivKey {
211 pub owner: String,
213 pub schema: String,
215 pub on_type: ObjectType,
217 pub grantee: String,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
223pub struct DefaultPrivState {
224 pub privileges: BTreeSet<Privilege>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
233pub struct MembershipEdge {
234 pub role: String,
236 pub member: String,
238 pub inherit: bool,
240 pub admin: bool,
242}
243
244#[derive(Debug, Clone, Default)]
253pub struct RoleGraph {
254 pub roles: BTreeMap<String, RoleState>,
256 pub schemas: BTreeMap<String, SchemaState>,
258 pub grants: BTreeMap<GrantKey, GrantState>,
260 pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
262 pub memberships: BTreeSet<MembershipEdge>,
264}
265
266impl RoleGraph {
267 pub fn from_expanded(
272 expanded: &ExpandedManifest,
273 default_owner: Option<&str>,
274 ) -> Result<Self, crate::manifest::ManifestError> {
275 let mut graph = Self::default();
276
277 for role_def in &expanded.roles {
279 let state = RoleState::from_definition(role_def);
280 graph.roles.insert(role_def.name.clone(), state);
281 }
282
283 for schema in &expanded.schemas {
285 let owner = schema.owner.clone();
286 graph.schemas.insert(
287 schema.name.clone(),
288 SchemaState {
289 owner_privileges: owner
290 .as_deref()
291 .map(default_schema_owner_privileges)
292 .unwrap_or_default(),
293 owner,
294 },
295 );
296 }
297
298 for grant in &expanded.grants {
300 let key = grant_key_from_manifest(grant);
301 let entry = graph.grants.entry(key).or_insert_with(|| GrantState {
302 privileges: BTreeSet::new(),
303 });
304 for privilege in &grant.privileges {
305 entry.privileges.insert(*privilege);
306 }
307 }
308
309 for default_priv in &expanded.default_privileges {
311 let owner = default_priv
312 .owner
313 .as_deref()
314 .or(default_owner)
315 .unwrap_or("postgres")
316 .to_string();
317
318 for grant in &default_priv.grant {
319 let grantee = grant.role.clone().ok_or_else(|| {
320 crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
321 schema: default_priv.schema.clone(),
322 }
323 })?;
324
325 let key = DefaultPrivKey {
326 owner: owner.clone(),
327 schema: default_priv.schema.clone(),
328 on_type: grant.on_type,
329 grantee,
330 };
331
332 let entry =
333 graph
334 .default_privileges
335 .entry(key)
336 .or_insert_with(|| DefaultPrivState {
337 privileges: BTreeSet::new(),
338 });
339 for privilege in &grant.privileges {
340 entry.privileges.insert(*privilege);
341 }
342 }
343 }
344
345 for membership in &expanded.memberships {
347 for member_spec in &membership.members {
348 graph.memberships.insert(MembershipEdge {
349 role: membership.role.clone(),
350 member: member_spec.name.clone(),
351 inherit: member_spec.inherit(),
352 admin: member_spec.admin(),
353 });
354 }
355 }
356
357 Ok(graph)
358 }
359}
360
361fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
366 GrantKey {
367 role: grant.role.clone(),
368 object_type: grant.object.object_type,
369 schema: grant.object.schema.clone(),
370 name: grant.object.name.clone(),
371 }
372}
373
374pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
375 [Privilege::Create, Privilege::Usage].into_iter().collect()
376}
377
378impl PartialOrd for ObjectType {
383 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
384 Some(self.cmp(other))
385 }
386}
387
388impl Ord for ObjectType {
389 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
390 self.to_string().cmp(&other.to_string())
391 }
392}
393
394impl PartialOrd for Privilege {
395 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
396 Some(self.cmp(other))
397 }
398}
399
400impl Ord for Privilege {
401 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
402 self.to_string().cmp(&other.to_string())
403 }
404}
405
406#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::manifest::{expand_manifest, parse_manifest};
414
415 #[test]
416 fn role_state_defaults_match_postgres() {
417 let state = RoleState::default();
418 assert!(!state.login);
419 assert!(!state.superuser);
420 assert!(!state.createdb);
421 assert!(!state.createrole);
422 assert!(state.inherit); assert!(!state.replication);
424 assert!(!state.bypassrls);
425 assert_eq!(state.connection_limit, -1);
426 }
427
428 #[test]
429 fn role_state_from_definition_applies_overrides() {
430 let definition = RoleDefinition {
431 name: "test".to_string(),
432 external: false,
433 login: Some(true),
434 superuser: None,
435 createdb: Some(true),
436 createrole: None,
437 inherit: Some(false),
438 replication: None,
439 bypassrls: None,
440 connection_limit: Some(10),
441 comment: Some("test role".to_string()),
442 password: None,
443 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
444 config: Default::default(),
445 };
446 let state = RoleState::from_definition(&definition);
447 assert!(state.login);
448 assert!(!state.superuser); assert!(state.createdb);
450 assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
453 assert_eq!(state.comment, Some("test role".to_string()));
454 assert_eq!(
455 state.password_valid_until,
456 Some("2025-12-31T00:00:00Z".to_string())
457 );
458 }
459
460 #[test]
461 fn changed_attributes_detects_differences() {
462 let current = RoleState::default();
463 let desired = RoleState {
464 login: true,
465 connection_limit: 5,
466 ..RoleState::default()
467 };
468 let changes = current.changed_attributes(&desired);
469 assert_eq!(changes.len(), 2);
470 assert!(changes.contains(&RoleAttribute::Login(true)));
471 assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
472 }
473
474 #[test]
475 fn changed_attributes_empty_when_equal() {
476 let state = RoleState::default();
477 assert!(state.changed_attributes(&state.clone()).is_empty());
478 }
479
480 #[test]
481 fn changed_attributes_detects_config_set_change_and_reset() {
482 let current = RoleState {
483 config: [
484 ("role".to_string(), "combined".to_string()),
485 ("statement_timeout".to_string(), "30s".to_string()),
486 ]
487 .into_iter()
488 .collect(),
489 ..RoleState::default()
490 };
491 let desired = RoleState {
492 config: [
493 ("role".to_string(), "combined".to_string()),
494 ("search_path".to_string(), "app".to_string()),
495 ]
496 .into_iter()
497 .collect(),
498 ..RoleState::default()
499 };
500 let changes = current.changed_attributes(&desired);
501 assert_eq!(changes.len(), 2);
502 assert!(changes.contains(&RoleAttribute::SetConfig(
503 "search_path".to_string(),
504 "app".to_string()
505 )));
506 assert!(changes.contains(&RoleAttribute::ResetConfig("statement_timeout".to_string())));
507 assert!(
509 !changes
510 .iter()
511 .any(|c| matches!(c, RoleAttribute::SetConfig(p, _) if p == "role"))
512 );
513 }
514
515 #[test]
516 fn profile_config_flows_through_to_generated_role_state() {
517 let yaml = r#"
523profiles:
524 editor:
525 login: true
526 config:
527 search_path: "{schema}"
528 statement_timeout: "30s"
529
530schemas:
531 - name: inventory
532 profiles: [editor]
533"#;
534 let manifest = parse_manifest(yaml).unwrap();
535 let expanded = expand_manifest(&manifest).unwrap();
536 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
537
538 let role = graph
539 .roles
540 .get("inventory-editor")
541 .expect("generated role should be present");
542 assert_eq!(
543 role.config.get("search_path").map(String::as_str),
544 Some("inventory")
545 );
546 assert_eq!(
547 role.config.get("statement_timeout").map(String::as_str),
548 Some("30s")
549 );
550 }
551
552 #[test]
553 fn from_definition_lowercases_config_parameter_names() {
554 let yaml = r#"
555roles:
556 - name: blue
557 login: true
558 config:
559 Role: combined
560 statement_timeout: "30000"
561"#;
562 let manifest = parse_manifest(yaml).unwrap();
563 let graph = RoleGraph::from_expanded(&expand_manifest(&manifest).unwrap(), None).unwrap();
564 let config = &graph.roles["blue"].config;
565 assert_eq!(config.get("role").map(String::as_str), Some("combined"));
566 assert_eq!(
567 config.get("statement_timeout").map(String::as_str),
568 Some("30000")
569 );
570 }
571
572 #[test]
573 fn role_graph_from_expanded_manifest() {
574 let yaml = r#"
575default_owner: app_owner
576
577profiles:
578 editor:
579 grants:
580 - privileges: [USAGE]
581 object: { type: schema }
582 - privileges: [SELECT, INSERT]
583 object: { type: table, name: "*" }
584 default_privileges:
585 - privileges: [SELECT, INSERT]
586 on_type: table
587
588schemas:
589 - name: inventory
590 profiles: [editor]
591
592roles:
593 - name: analytics
594 login: true
595
596memberships:
597 - role: inventory-editor
598 members:
599 - name: "user@example.com"
600 inherit: true
601"#;
602 let manifest = parse_manifest(yaml).unwrap();
603 let expanded = expand_manifest(&manifest).unwrap();
604 let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
605
606 assert_eq!(graph.roles.len(), 2);
608 assert!(graph.roles.contains_key("inventory-editor"));
609 assert!(graph.roles.contains_key("analytics"));
610
611 assert_eq!(graph.schemas.len(), 1);
613 assert_eq!(
614 graph.schemas["inventory"].owner.as_deref(),
615 Some("app_owner")
616 );
617
618 assert!(!graph.roles["inventory-editor"].login);
620 assert!(graph.roles["analytics"].login);
621
622 assert_eq!(graph.grants.len(), 2);
624
625 assert_eq!(graph.default_privileges.len(), 1);
627 let dp_key = graph.default_privileges.keys().next().unwrap();
628 assert_eq!(dp_key.owner, "app_owner");
629 assert_eq!(dp_key.schema, "inventory");
630 assert_eq!(dp_key.on_type, ObjectType::Table);
631 assert_eq!(dp_key.grantee, "inventory-editor");
632 let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
633 assert!(dp_privs.contains(&Privilege::Select));
634 assert!(dp_privs.contains(&Privilege::Insert));
635
636 assert_eq!(graph.memberships.len(), 1);
638 let edge = graph.memberships.iter().next().unwrap();
639 assert_eq!(edge.role, "inventory-editor");
640 assert_eq!(edge.member, "user@example.com");
641 assert!(edge.inherit);
642 assert!(!edge.admin);
643 }
644
645 #[test]
646 fn grant_privileges_merge_for_same_target() {
647 let yaml = r#"
648roles:
649 - name: testrole
650
651grants:
652 - role: testrole
653 privileges: [SELECT]
654 object: { type: table, schema: public, name: "*" }
655 - role: testrole
656 privileges: [INSERT, UPDATE]
657 object: { type: table, schema: public, name: "*" }
658"#;
659 let manifest = parse_manifest(yaml).unwrap();
660 let expanded = expand_manifest(&manifest).unwrap();
661 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
662
663 assert_eq!(graph.grants.len(), 1);
665 let grant_state = graph.grants.values().next().unwrap();
666 assert_eq!(grant_state.privileges.len(), 3);
667 assert!(grant_state.privileges.contains(&Privilege::Select));
668 assert!(grant_state.privileges.contains(&Privilege::Insert));
669 assert!(grant_state.privileges.contains(&Privilege::Update));
670 }
671}