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 external: false,
395 login: Some(true),
396 superuser: None,
397 createdb: Some(true),
398 createrole: None,
399 inherit: Some(false),
400 replication: None,
401 bypassrls: None,
402 connection_limit: Some(10),
403 comment: Some("test role".to_string()),
404 password: None,
405 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
406 };
407 let state = RoleState::from_definition(&definition);
408 assert!(state.login);
409 assert!(!state.superuser); assert!(state.createdb);
411 assert!(!state.createrole); assert!(!state.inherit); assert_eq!(state.connection_limit, 10);
414 assert_eq!(state.comment, Some("test role".to_string()));
415 assert_eq!(
416 state.password_valid_until,
417 Some("2025-12-31T00:00:00Z".to_string())
418 );
419 }
420
421 #[test]
422 fn changed_attributes_detects_differences() {
423 let current = RoleState::default();
424 let desired = RoleState {
425 login: true,
426 connection_limit: 5,
427 ..RoleState::default()
428 };
429 let changes = current.changed_attributes(&desired);
430 assert_eq!(changes.len(), 2);
431 assert!(changes.contains(&RoleAttribute::Login(true)));
432 assert!(changes.contains(&RoleAttribute::ConnectionLimit(5)));
433 }
434
435 #[test]
436 fn changed_attributes_empty_when_equal() {
437 let state = RoleState::default();
438 assert!(state.changed_attributes(&state.clone()).is_empty());
439 }
440
441 #[test]
442 fn role_graph_from_expanded_manifest() {
443 let yaml = r#"
444default_owner: app_owner
445
446profiles:
447 editor:
448 grants:
449 - privileges: [USAGE]
450 object: { type: schema }
451 - privileges: [SELECT, INSERT]
452 object: { type: table, name: "*" }
453 default_privileges:
454 - privileges: [SELECT, INSERT]
455 on_type: table
456
457schemas:
458 - name: inventory
459 profiles: [editor]
460
461roles:
462 - name: analytics
463 login: true
464
465memberships:
466 - role: inventory-editor
467 members:
468 - name: "user@example.com"
469 inherit: true
470"#;
471 let manifest = parse_manifest(yaml).unwrap();
472 let expanded = expand_manifest(&manifest).unwrap();
473 let graph = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
474
475 assert_eq!(graph.roles.len(), 2);
477 assert!(graph.roles.contains_key("inventory-editor"));
478 assert!(graph.roles.contains_key("analytics"));
479
480 assert_eq!(graph.schemas.len(), 1);
482 assert_eq!(
483 graph.schemas["inventory"].owner.as_deref(),
484 Some("app_owner")
485 );
486
487 assert!(!graph.roles["inventory-editor"].login);
489 assert!(graph.roles["analytics"].login);
490
491 assert_eq!(graph.grants.len(), 2);
493
494 assert_eq!(graph.default_privileges.len(), 1);
496 let dp_key = graph.default_privileges.keys().next().unwrap();
497 assert_eq!(dp_key.owner, "app_owner");
498 assert_eq!(dp_key.schema, "inventory");
499 assert_eq!(dp_key.on_type, ObjectType::Table);
500 assert_eq!(dp_key.grantee, "inventory-editor");
501 let dp_privs = &graph.default_privileges.values().next().unwrap().privileges;
502 assert!(dp_privs.contains(&Privilege::Select));
503 assert!(dp_privs.contains(&Privilege::Insert));
504
505 assert_eq!(graph.memberships.len(), 1);
507 let edge = graph.memberships.iter().next().unwrap();
508 assert_eq!(edge.role, "inventory-editor");
509 assert_eq!(edge.member, "user@example.com");
510 assert!(edge.inherit);
511 assert!(!edge.admin);
512 }
513
514 #[test]
515 fn grant_privileges_merge_for_same_target() {
516 let yaml = r#"
517roles:
518 - name: testrole
519
520grants:
521 - role: testrole
522 privileges: [SELECT]
523 object: { type: table, schema: public, name: "*" }
524 - role: testrole
525 privileges: [INSERT, UPDATE]
526 object: { type: table, schema: public, name: "*" }
527"#;
528 let manifest = parse_manifest(yaml).unwrap();
529 let expanded = expand_manifest(&manifest).unwrap();
530 let graph = RoleGraph::from_expanded(&expanded, None).unwrap();
531
532 assert_eq!(graph.grants.len(), 1);
534 let grant_state = graph.grants.values().next().unwrap();
535 assert_eq!(grant_state.privileges.len(), 3);
536 assert!(grant_state.privileges.contains(&Privilege::Select));
537 assert!(grant_state.privileges.contains(&Privilege::Insert));
538 assert!(grant_state.privileges.contains(&Privilege::Update));
539 }
540}