1use std::collections::{BTreeMap, BTreeSet};
9
10use crate::manifest::{Ensure, 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)]
192pub enum Grantee {
193 Public,
194 Role(String),
195}
196
197pub const PUBLIC_ROLE: &str = "PUBLIC";
200
201impl Grantee {
202 pub fn parse(s: &str) -> Self {
205 if s == PUBLIC_ROLE {
206 Grantee::Public
207 } else {
208 Grantee::Role(s.to_string())
209 }
210 }
211
212 pub fn is_public(&self) -> bool {
213 matches!(self, Grantee::Public)
214 }
215
216 pub fn as_str(&self) -> &str {
217 match self {
218 Grantee::Public => PUBLIC_ROLE,
219 Grantee::Role(name) => name,
220 }
221 }
222}
223
224impl std::fmt::Display for Grantee {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 f.write_str(self.as_str())
227 }
228}
229
230impl From<&str> for Grantee {
231 fn from(s: &str) -> Self {
232 Grantee::parse(s)
233 }
234}
235
236impl serde::Serialize for Grantee {
239 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
240 serializer.serialize_str(self.as_str())
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
251#[serde(tag = "type", rename_all = "lowercase")]
252pub enum DefaultPrivilegeScope {
253 Global,
254 Schema { schema: String },
255}
256
257impl DefaultPrivilegeScope {
258 pub fn schema(&self) -> Option<&str> {
259 match self {
260 DefaultPrivilegeScope::Global => None,
261 DefaultPrivilegeScope::Schema { schema } => Some(schema),
262 }
263 }
264}
265
266impl std::fmt::Display for DefaultPrivilegeScope {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 match self {
269 DefaultPrivilegeScope::Global => write!(f, "global scope"),
270 DefaultPrivilegeScope::Schema { schema } => write!(f, "schema \"{schema}\""),
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
286pub struct GrantKey {
287 pub role: Grantee,
289 pub object_type: ObjectType,
291 pub schema: Option<String>,
293 pub name: Option<String>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
299pub struct GrantState {
300 pub privileges: BTreeSet<Privilege>,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
309pub struct DefaultPrivKey {
310 pub owner: String,
312 pub scope: DefaultPrivilegeScope,
314 pub on_type: ObjectType,
316 pub grantee: Grantee,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
322pub struct DefaultPrivState {
323 pub privileges: BTreeSet<Privilege>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
332pub struct MembershipEdge {
333 pub role: String,
335 pub member: String,
337 pub inherit: bool,
339 pub admin: bool,
341}
342
343#[derive(Debug, Clone, Default)]
352pub struct RoleGraph {
353 pub roles: BTreeMap<String, RoleState>,
355 pub schemas: BTreeMap<String, SchemaState>,
357 pub grants: BTreeMap<GrantKey, GrantState>,
359 pub default_privileges: BTreeMap<DefaultPrivKey, DefaultPrivState>,
361 pub memberships: BTreeSet<MembershipEdge>,
363 pub grant_absences: BTreeMap<GrantKey, BTreeSet<Privilege>>,
366 pub default_privilege_absences: BTreeMap<DefaultPrivKey, BTreeSet<Privilege>>,
369}
370
371impl RoleGraph {
372 pub fn from_expanded(
377 expanded: &ExpandedManifest,
378 default_owner: Option<&str>,
379 ) -> Result<Self, crate::manifest::ManifestError> {
380 let mut graph = Self::default();
381
382 for role_def in &expanded.roles {
384 let state = RoleState::from_definition(role_def);
385 graph.roles.insert(role_def.name.clone(), state);
386 }
387
388 for schema in &expanded.schemas {
390 let owner = schema.owner.clone();
391 graph.schemas.insert(
392 schema.name.clone(),
393 SchemaState {
394 owner_privileges: owner
395 .as_deref()
396 .map(default_schema_owner_privileges)
397 .unwrap_or_default(),
398 owner,
399 },
400 );
401 }
402
403 for grant in &expanded.grants {
405 let key = grant_key_from_manifest(grant);
406 let privileges = match grant.ensure {
407 Ensure::Present => {
408 &mut graph
409 .grants
410 .entry(key)
411 .or_insert_with(|| GrantState {
412 privileges: BTreeSet::new(),
413 })
414 .privileges
415 }
416 Ensure::Absent => graph.grant_absences.entry(key).or_default(),
417 };
418 for privilege in &grant.privileges {
419 privileges.insert(*privilege);
420 }
421 }
422
423 for default_priv in &expanded.default_privileges {
425 let owner = default_priv
426 .owner
427 .as_deref()
428 .or(default_owner)
429 .unwrap_or("postgres")
430 .to_string();
431 let scope = default_priv.resolved_scope()?;
432
433 for grant in &default_priv.grant {
434 let grantee = grant.role.as_deref().map(Grantee::parse).ok_or_else(|| {
435 crate::manifest::ManifestError::MissingDefaultPrivilegeRole {
436 scope: scope.to_string(),
437 }
438 })?;
439
440 let key = DefaultPrivKey {
441 owner: owner.clone(),
442 scope: scope.clone(),
443 on_type: grant.on_type,
444 grantee,
445 };
446
447 let privileges = match grant.ensure {
448 Ensure::Present => {
449 &mut graph
450 .default_privileges
451 .entry(key)
452 .or_insert_with(|| DefaultPrivState {
453 privileges: BTreeSet::new(),
454 })
455 .privileges
456 }
457 Ensure::Absent => graph.default_privilege_absences.entry(key).or_default(),
458 };
459 for privilege in &grant.privileges {
460 privileges.insert(*privilege);
461 }
462 }
463 }
464
465 for membership in &expanded.memberships {
467 for member_spec in &membership.members {
468 graph.memberships.insert(MembershipEdge {
469 role: membership.role.clone(),
470 member: member_spec.name.clone(),
471 inherit: member_spec.inherit(),
472 admin: member_spec.admin(),
473 });
474 }
475 }
476
477 Ok(graph)
478 }
479}
480
481fn grant_key_from_manifest(grant: &Grant) -> GrantKey {
486 GrantKey {
487 role: Grantee::parse(&grant.role),
488 object_type: grant.object.object_type,
489 schema: grant.object.schema.clone(),
490 name: grant.object.name.clone(),
491 }
492}
493
494pub fn default_schema_owner_privileges(_owner: &str) -> BTreeSet<Privilege> {
495 [Privilege::Create, Privilege::Usage].into_iter().collect()
496}
497
498impl PartialOrd for ObjectType {
503 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
504 Some(self.cmp(other))
505 }
506}
507
508impl Ord for ObjectType {
509 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
510 self.to_string().cmp(&other.to_string())
511 }
512}
513
514impl PartialOrd for Privilege {
515 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
516 Some(self.cmp(other))
517 }
518}
519
520impl Ord for Privilege {
521 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
522 self.to_string().cmp(&other.to_string())
523 }
524}
525
526#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::manifest::{expand_manifest, parse_manifest};
534
535 #[test]
536 fn role_state_defaults_match_postgres() {
537 let state = RoleState::default();
538 assert!(!state.login);
539 assert!(!state.superuser);
540 assert!(!state.createdb);
541 assert!(!state.createrole);
542 assert!(state.inherit); assert!(!state.replication);
544 assert!(!state.bypassrls);
545 assert_eq!(state.connection_limit, -1);
546 }
547
548 #[test]
549 fn role_state_from_definition_applies_overrides() {
550 let definition = RoleDefinition {
551 name: "test".to_string(),
552 external: false,
553 login: Some(true),
554 superuser: None,
555 createdb: Some(true),
556 createrole: None,
557 inherit: Some(false),
558 replication: None,
559 bypassrls: None,
560 connection_limit: Some(10),
561 comment: Some("test role".to_string()),
562 password: None,
563 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
564 config: Default::default(),
565 };
566 let state = RoleState::from_definition(&definition);
567 assert!(state.login);
568 assert!(!state.superuser); assert!(state.createdb);
570 assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
573 assert_eq!(state.comment, Some("test role".to_string()));
574 assert_eq!(
575 state.password_valid_until,
576 Some("2025-12-31T00:00:00Z".to_string())
577 );
578 }
579
580 #[test]
581 fn changed_attributes_detects_differences() {
582 let current = RoleState::default();
583 let desired = RoleState {
584 login: true,
585 connection_limit: 5,
586 ..RoleState::default()
587 };
588 let changes = current.changed_attributes(&desired);
589 assert_eq!(changes.len(), 2);
590 assert!(changes.contains(&RoleAttribute::Login(true)));
591 assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
592 }
593
594 #[test]
595 fn changed_attributes_empty_when_equal() {
596 let state = RoleState::default();
597 assert!(state.changed_attributes(&state.clone()).is_empty());
598 }
599
600 #[test]
601 fn changed_attributes_detects_config_set_change_and_reset() {
602 let current = RoleState {
603 config: [
604 ("role".to_string(), "combined".to_string()),
605 ("statement_timeout".to_string(), "30s".to_string()),
606 ]
607 .into_iter()
608 .collect(),
609 ..RoleState::default()
610 };
611 let desired = RoleState {
612 config: [
613 ("role".to_string(), "combined".to_string()),
614 ("search_path".to_string(), "app".to_string()),
615 ]
616 .into_iter()
617 .collect(),
618 ..RoleState::default()
619 };
620 let changes = current.changed_attributes(&desired);
621 assert_eq!(changes.len(), 2);
622 assert!(changes.contains(&RoleAttribute::SetConfig(
623 "search_path".to_string(),
624 "app".to_string()
625 )));
626 assert!(changes.contains(&RoleAttribute::ResetConfig("statement_timeout".to_string())));
627 assert!(
629 !changes
630 .iter()
631 .any(|c| matches!(c, RoleAttribute::SetConfig(p, _) if p == "role"))
632 );
633 }
634
635 #[test]
636 fn profile_config_flows_through_to_generated_role_state() {
637 let yaml = r#"
643profiles:
644 editor:
645 login: true
646 config:
647 search_path: "{schema}"
648 statement_timeout: "30s"
649
650schemas:
651 - name: inventory
652 profiles: [editor]
653"#;
654 let manifest = parse_manifest(yaml).unwrap();
655 let expanded = expand_manifest(&manifest).unwrap();
656 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
657
658 let role = graph
659 .roles
660 .get("inventory-editor")
661 .expect("generated role should be present");
662 assert_eq!(
663 role.config.get("search_path").map(String::as_str),
664 Some("inventory")
665 );
666 assert_eq!(
667 role.config.get("statement_timeout").map(String::as_str),
668 Some("30s")
669 );
670 }
671
672 #[test]
673 fn from_definition_lowercases_config_parameter_names() {
674 let yaml = r#"
675roles:
676 - name: blue
677 login: true
678 config:
679 Role: combined
680 statement_timeout: "30000"
681"#;
682 let manifest = parse_manifest(yaml).unwrap();
683 let graph = RoleGraph::from_expanded(&expand_manifest(&manifest).unwrap(), None).unwrap();
684 let config = &graph.roles["blue"].config;
685 assert_eq!(config.get("role").map(String::as_str), Some("combined"));
686 assert_eq!(
687 config.get("statement_timeout").map(String::as_str),
688 Some("30000")
689 );
690 }
691
692 #[test]
693 fn role_graph_from_expanded_manifest() {
694 let yaml = r#"
695default_owner: app_owner
696
697profiles:
698 editor:
699 grants:
700 - privileges: [USAGE]
701 object: { type: schema }
702 - privileges: [SELECT, INSERT]
703 object: { type: table, name: "*" }
704 default_privileges:
705 - privileges: [SELECT, INSERT]
706 on_type: table
707
708schemas:
709 - name: inventory
710 profiles: [editor]
711
712roles:
713 - name: analytics
714 login: true
715
716memberships:
717 - role: inventory-editor
718 members:
719 - name: "user@example.com"
720 inherit: true
721"#;
722 let manifest = parse_manifest(yaml).unwrap();
723 let expanded = expand_manifest(&manifest).unwrap();
724 let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
725
726 assert_eq!(graph.roles.len(), 2);
728 assert!(graph.roles.contains_key("inventory-editor"));
729 assert!(graph.roles.contains_key("analytics"));
730
731 assert_eq!(graph.schemas.len(), 1);
733 assert_eq!(
734 graph.schemas["inventory"].owner.as_deref(),
735 Some("app_owner")
736 );
737
738 assert!(!graph.roles["inventory-editor"].login);
740 assert!(graph.roles["analytics"].login);
741
742 assert_eq!(graph.grants.len(), 2);
744
745 assert_eq!(graph.default_privileges.len(), 1);
747 let dp_key = graph.default_privileges.keys().next().unwrap();
748 assert_eq!(dp_key.owner, "app_owner");
749 assert_eq!(
750 dp_key.scope,
751 DefaultPrivilegeScope::Schema {
752 schema: "inventory".to_string()
753 }
754 );
755 assert_eq!(dp_key.on_type, ObjectType::Table);
756 assert_eq!(dp_key.grantee.as_str(), "inventory-editor");
757 let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
758 assert!(dp_privs.contains(&Privilege::Select));
759 assert!(dp_privs.contains(&Privilege::Insert));
760
761 assert_eq!(graph.memberships.len(), 1);
763 let edge = graph.memberships.iter().next().unwrap();
764 assert_eq!(edge.role, "inventory-editor");
765 assert_eq!(edge.member, "user@example.com");
766 assert!(edge.inherit);
767 assert!(!edge.admin);
768 }
769
770 #[test]
771 fn grant_privileges_merge_for_same_target() {
772 let yaml = r#"
773roles:
774 - name: testrole
775
776grants:
777 - role: testrole
778 privileges: [SELECT]
779 object: { type: table, schema: public, name: "*" }
780 - role: testrole
781 privileges: [INSERT, UPDATE]
782 object: { type: table, schema: public, name: "*" }
783"#;
784 let manifest = parse_manifest(yaml).unwrap();
785 let expanded = expand_manifest(&manifest).unwrap();
786 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
787
788 assert_eq!(graph.grants.len(), 1);
790 let grant_state = graph.grants.values().next().unwrap();
791 assert_eq!(grant_state.privileges.len(), 3);
792 assert!(grant_state.privileges.contains(&Privilege::Select));
793 assert!(grant_state.privileges.contains(&Privilege::Insert));
794 assert!(grant_state.privileges.contains(&Privilege::Update));
795 }
796}