1use std::collections::BTreeSet;
8use std::fmt::Write;
9
10use crate::diff::Change;
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{RoleAttribute, RoleState};
13
14pub fn quote_ident(identifier: &str) -> String {
27 format!("\"{}\"", identifier.replace('"', "\"\""))
28}
29
30pub fn render(change: &Change) -> String {
36 render_statements(change).join("\n")
37}
38
39pub fn render_statements(change: &Change) -> Vec<String> {
41 match change {
42 Change::CreateRole { name, state } => render_create_role(name, state),
43 Change::AlterRole { name, attributes } => render_alter_role(name, attributes),
44 Change::SetComment { name, comment } => render_set_comment(name, comment),
45 Change::Grant {
46 role,
47 privileges,
48 object_type,
49 schema,
50 name,
51 } => render_grant(
52 role,
53 privileges,
54 *object_type,
55 schema.as_deref(),
56 name.as_deref(),
57 ),
58 Change::Revoke {
59 role,
60 privileges,
61 object_type,
62 schema,
63 name,
64 } => render_revoke(
65 role,
66 privileges,
67 *object_type,
68 schema.as_deref(),
69 name.as_deref(),
70 ),
71 Change::SetDefaultPrivilege {
72 owner,
73 schema,
74 on_type,
75 grantee,
76 privileges,
77 } => render_set_default_privilege(owner, schema, *on_type, grantee, privileges),
78 Change::RevokeDefaultPrivilege {
79 owner,
80 schema,
81 on_type,
82 grantee,
83 privileges,
84 } => render_revoke_default_privilege(owner, schema, *on_type, grantee, privileges),
85 Change::AddMember {
86 role,
87 member,
88 inherit,
89 admin,
90 } => render_add_member(role, member, *inherit, *admin),
91 Change::RemoveMember { role, member } => render_remove_member(role, member),
92 Change::ReassignOwned { from_role, to_role } => render_reassign_owned(from_role, to_role),
93 Change::DropOwned { role } => render_drop_owned(role),
94 Change::DropRole { name } => vec![format!("DROP ROLE {};", quote_ident(name))],
95 }
96}
97
98pub fn render_all(changes: &[Change]) -> String {
100 changes
101 .iter()
102 .flat_map(render_statements)
103 .collect::<Vec<_>>()
104 .join("\n")
105}
106
107fn render_create_role(name: &str, state: &RoleState) -> Vec<String> {
112 let mut sql = format!("CREATE ROLE {}", quote_ident(name));
113 let mut options = vec![
114 bool_option("LOGIN", "NOLOGIN", state.login),
115 bool_option("SUPERUSER", "NOSUPERUSER", state.superuser),
116 bool_option("CREATEDB", "NOCREATEDB", state.createdb),
117 bool_option("CREATEROLE", "NOCREATEROLE", state.createrole),
118 bool_option("INHERIT", "NOINHERIT", state.inherit),
119 bool_option("REPLICATION", "NOREPLICATION", state.replication),
120 bool_option("BYPASSRLS", "NOBYPASSRLS", state.bypassrls),
121 ];
122
123 if state.connection_limit != -1 {
124 options.push(format!("CONNECTION LIMIT {}", state.connection_limit));
125 }
126
127 let _ = write!(sql, " {}", options.join(" "));
128 sql.push(';');
129
130 let mut statements = vec![sql];
131 if let Some(comment) = &state.comment {
132 statements.push(format!(
133 "COMMENT ON ROLE {} IS {};",
134 quote_ident(name),
135 quote_literal(comment)
136 ));
137 }
138
139 statements
140}
141
142fn bool_option(positive: &str, negative: &str, value: bool) -> String {
143 if value {
144 positive.to_string()
145 } else {
146 negative.to_string()
147 }
148}
149
150fn render_alter_role(name: &str, attributes: &[RoleAttribute]) -> Vec<String> {
155 let mut options = Vec::new();
156 for attr in attributes {
157 match attr {
158 RoleAttribute::Login(v) => options.push(bool_option("LOGIN", "NOLOGIN", *v)),
159 RoleAttribute::Superuser(v) => {
160 options.push(bool_option("SUPERUSER", "NOSUPERUSER", *v));
161 }
162 RoleAttribute::Createdb(v) => {
163 options.push(bool_option("CREATEDB", "NOCREATEDB", *v));
164 }
165 RoleAttribute::Createrole(v) => {
166 options.push(bool_option("CREATEROLE", "NOCREATEROLE", *v));
167 }
168 RoleAttribute::Inherit(v) => options.push(bool_option("INHERIT", "NOINHERIT", *v)),
169 RoleAttribute::Replication(v) => {
170 options.push(bool_option("REPLICATION", "NOREPLICATION", *v));
171 }
172 RoleAttribute::Bypassrls(v) => {
173 options.push(bool_option("BYPASSRLS", "NOBYPASSRLS", *v));
174 }
175 RoleAttribute::ConnectionLimit(v) => {
176 options.push(format!("CONNECTION LIMIT {v}"));
177 }
178 }
179 }
180 vec![format!(
181 "ALTER ROLE {} {};",
182 quote_ident(name),
183 options.join(" ")
184 )]
185}
186
187fn render_set_comment(name: &str, comment: &Option<String>) -> Vec<String> {
192 vec![match comment {
193 Some(text) => format!(
194 "COMMENT ON ROLE {} IS {};",
195 quote_ident(name),
196 quote_literal(text)
197 ),
198 None => format!("COMMENT ON ROLE {} IS NULL;", quote_ident(name)),
199 }]
200}
201
202fn render_grant(
207 role: &str,
208 privileges: &BTreeSet<Privilege>,
209 object_type: ObjectType,
210 schema: Option<&str>,
211 name: Option<&str>,
212) -> Vec<String> {
213 let privilege_list = format_privileges(privileges);
214 let target = format_object_target(object_type, schema, name);
215 vec![format!(
216 "GRANT {} ON {} TO {};",
217 privilege_list,
218 target,
219 quote_ident(role)
220 )]
221}
222
223fn render_revoke(
224 role: &str,
225 privileges: &BTreeSet<Privilege>,
226 object_type: ObjectType,
227 schema: Option<&str>,
228 name: Option<&str>,
229) -> Vec<String> {
230 let privilege_list = format_privileges(privileges);
231 let target = format_object_target(object_type, schema, name);
232 vec![format!(
233 "REVOKE {} ON {} FROM {};",
234 privilege_list,
235 target,
236 quote_ident(role)
237 )]
238}
239
240fn format_object_target(
247 object_type: ObjectType,
248 schema: Option<&str>,
249 name: Option<&str>,
250) -> String {
251 let type_keyword = sql_object_type_keyword(object_type);
252
253 match object_type {
254 ObjectType::Schema => {
255 let schema_name = name.unwrap_or("public");
257 format!("{type_keyword} {}", quote_ident(schema_name))
258 }
259 ObjectType::Database => {
260 let db_name = name.unwrap_or("postgres");
261 format!("{type_keyword} {}", quote_ident(db_name))
262 }
263 ObjectType::Function => match name {
264 Some("*") => {
265 let schema_name = schema.unwrap_or("public");
266 format!("ALL FUNCTIONS IN SCHEMA {}", quote_ident(schema_name))
267 }
268 Some(function_name) => format_function_target(schema, function_name),
269 None => {
270 let schema_name = schema.unwrap_or("public");
271 format!("{type_keyword} {}", quote_ident(schema_name))
272 }
273 },
274 _ => {
275 match name {
276 Some("*") => {
277 let plural = sql_object_type_plural(object_type);
279 let schema_name = schema.unwrap_or("public");
280 format!("ALL {plural} IN SCHEMA {}", quote_ident(schema_name))
281 }
282 Some(obj_name) => {
283 let schema_name = schema.unwrap_or("public");
285 format!(
286 "{type_keyword} {}.{}",
287 quote_ident(schema_name),
288 quote_ident(obj_name)
289 )
290 }
291 None => {
292 let schema_name = schema.unwrap_or("public");
294 format!("{type_keyword} {}", quote_ident(schema_name))
295 }
296 }
297 }
298 }
299}
300
301fn format_function_target(schema: Option<&str>, function_name: &str) -> String {
302 let schema_name = schema.unwrap_or("public");
303
304 match function_name.rfind('(') {
305 Some(paren_idx) if function_name.ends_with(')') => {
306 let base_name = &function_name[..paren_idx];
307 let args = &function_name[paren_idx..];
308 format!(
309 "FUNCTION {}.{}{}",
310 quote_ident(schema_name),
311 quote_ident(base_name),
312 args
313 )
314 }
315 _ => format!(
316 "FUNCTION {}.{}",
317 quote_ident(schema_name),
318 quote_ident(function_name)
319 ),
320 }
321}
322
323fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
325 match object_type {
326 ObjectType::Table => "TABLE",
327 ObjectType::View => "TABLE", ObjectType::MaterializedView => "TABLE", ObjectType::Sequence => "SEQUENCE",
330 ObjectType::Function => "FUNCTION",
331 ObjectType::Schema => "SCHEMA",
332 ObjectType::Database => "DATABASE",
333 ObjectType::Type => "TYPE",
334 }
335}
336
337fn sql_object_type_plural(object_type: ObjectType) -> &'static str {
339 match object_type {
340 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView => "TABLES",
341 ObjectType::Sequence => "SEQUENCES",
342 ObjectType::Function => "FUNCTIONS",
343 ObjectType::Type => "TABLES",
347 ObjectType::Schema | ObjectType::Database => "TABLES",
349 }
350}
351
352fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
354 privileges
355 .iter()
356 .map(|p| p.to_string())
357 .collect::<Vec<_>>()
358 .join(", ")
359}
360
361fn render_set_default_privilege(
366 owner: &str,
367 schema: &str,
368 on_type: ObjectType,
369 grantee: &str,
370 privileges: &BTreeSet<Privilege>,
371) -> Vec<String> {
372 let privilege_list = format_privileges(privileges);
373 let type_plural = sql_object_type_plural(on_type);
374 vec![format!(
375 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {};",
376 quote_ident(owner),
377 quote_ident(schema),
378 privilege_list,
379 type_plural,
380 quote_ident(grantee)
381 )]
382}
383
384fn render_revoke_default_privilege(
385 owner: &str,
386 schema: &str,
387 on_type: ObjectType,
388 grantee: &str,
389 privileges: &BTreeSet<Privilege>,
390) -> Vec<String> {
391 let privilege_list = format_privileges(privileges);
392 let type_plural = sql_object_type_plural(on_type);
393 vec![format!(
394 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {};",
395 quote_ident(owner),
396 quote_ident(schema),
397 privilege_list,
398 type_plural,
399 quote_ident(grantee)
400 )]
401}
402
403fn render_add_member(role: &str, member: &str, inherit: bool, admin: bool) -> Vec<String> {
408 let mut sql = format!("GRANT {} TO {}", quote_ident(role), quote_ident(member));
409
410 let mut options = Vec::new();
412 if inherit {
413 options.push("INHERIT TRUE");
414 } else {
415 options.push("INHERIT FALSE");
416 }
417 if admin {
418 options.push("ADMIN TRUE");
419 }
420
421 if !options.is_empty() {
422 let _ = write!(sql, " WITH {}", options.join(", "));
423 }
424
425 sql.push(';');
426 vec![sql]
427}
428
429fn render_remove_member(role: &str, member: &str) -> Vec<String> {
430 vec![format!(
431 "REVOKE {} FROM {};",
432 quote_ident(role),
433 quote_ident(member)
434 )]
435}
436
437fn render_reassign_owned(from_role: &str, to_role: &str) -> Vec<String> {
438 vec![format!(
439 "REASSIGN OWNED BY {} TO {};",
440 quote_ident(from_role),
441 quote_ident(to_role)
442 )]
443}
444
445fn render_drop_owned(role: &str) -> Vec<String> {
446 vec![format!("DROP OWNED BY {};", quote_ident(role))]
447}
448
449fn quote_literal(value: &str) -> String {
455 format!("'{}'", value.replace('\'', "''"))
456}
457
458#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn quote_ident_simple() {
468 assert_eq!(quote_ident("simple"), "\"simple\"");
469 }
470
471 #[test]
472 fn quote_ident_with_hyphen() {
473 assert_eq!(quote_ident("inventory-editor"), "\"inventory-editor\"");
474 }
475
476 #[test]
477 fn quote_ident_with_email() {
478 assert_eq!(quote_ident("user@example.com"), "\"user@example.com\"");
479 }
480
481 #[test]
482 fn quote_ident_with_embedded_quotes() {
483 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
484 }
485
486 #[test]
487 fn quote_literal_simple() {
488 assert_eq!(quote_literal("hello"), "'hello'");
489 }
490
491 #[test]
492 fn quote_literal_with_embedded_quotes() {
493 assert_eq!(quote_literal("it's"), "'it''s'");
494 }
495
496 #[test]
497 fn render_create_role_basic() {
498 let change = Change::CreateRole {
499 name: "inventory-editor".to_string(),
500 state: RoleState::default(),
501 };
502 let sql = render(&change);
503 assert!(sql.starts_with("CREATE ROLE \"inventory-editor\""));
504 assert!(sql.contains("NOLOGIN"));
505 assert!(sql.contains("NOSUPERUSER"));
506 assert!(sql.contains("INHERIT")); assert!(sql.ends_with(';'));
508 }
509
510 #[test]
511 fn render_create_role_with_login_and_comment() {
512 let change = Change::CreateRole {
513 name: "analytics".to_string(),
514 state: RoleState {
515 login: true,
516 comment: Some("Analytics readonly role".to_string()),
517 ..RoleState::default()
518 },
519 };
520 let sql = render(&change);
521 assert!(sql.contains("LOGIN"));
522 assert!(sql.contains("COMMENT ON ROLE \"analytics\" IS 'Analytics readonly role';"));
523 }
524
525 #[test]
526 fn render_alter_role() {
527 let change = Change::AlterRole {
528 name: "r1".to_string(),
529 attributes: vec![RoleAttribute::Login(true), RoleAttribute::Createdb(true)],
530 };
531 let sql = render(&change);
532 assert_eq!(sql, "ALTER ROLE \"r1\" LOGIN CREATEDB;");
533 }
534
535 #[test]
536 fn render_drop_role() {
537 let change = Change::DropRole {
538 name: "old-role".to_string(),
539 };
540 assert_eq!(render(&change), "DROP ROLE \"old-role\";");
541 }
542
543 #[test]
544 fn render_grant_schema_usage() {
545 let change = Change::Grant {
546 role: "inventory-editor".to_string(),
547 privileges: BTreeSet::from([Privilege::Usage]),
548 object_type: ObjectType::Schema,
549 schema: None,
550 name: Some("inventory".to_string()),
551 };
552 let sql = render(&change);
553 assert_eq!(
554 sql,
555 "GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\";"
556 );
557 }
558
559 #[test]
560 fn render_grant_all_tables() {
561 let change = Change::Grant {
562 role: "inventory-editor".to_string(),
563 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
564 object_type: ObjectType::Table,
565 schema: Some("inventory".to_string()),
566 name: Some("*".to_string()),
567 };
568 let sql = render(&change);
569 assert_eq!(
570 sql,
571 "GRANT INSERT, SELECT ON ALL TABLES IN SCHEMA \"inventory\" TO \"inventory-editor\";"
572 );
573 }
574
575 #[test]
576 fn render_grant_specific_table() {
577 let change = Change::Grant {
578 role: "r1".to_string(),
579 privileges: BTreeSet::from([Privilege::Select]),
580 object_type: ObjectType::Table,
581 schema: Some("public".to_string()),
582 name: Some("users".to_string()),
583 };
584 let sql = render(&change);
585 assert_eq!(sql, "GRANT SELECT ON TABLE \"public\".\"users\" TO \"r1\";");
586 }
587
588 #[test]
589 fn render_grant_specific_function() {
590 let change = Change::Grant {
591 role: "r1".to_string(),
592 privileges: BTreeSet::from([Privilege::Execute]),
593 object_type: ObjectType::Function,
594 schema: Some("public".to_string()),
595 name: Some("refresh_users(integer, text)".to_string()),
596 };
597 let sql = render(&change);
598 assert_eq!(
599 sql,
600 "GRANT EXECUTE ON FUNCTION \"public\".\"refresh_users\"(integer, text) TO \"r1\";"
601 );
602 }
603
604 #[test]
605 fn render_revoke_all_sequences() {
606 let change = Change::Revoke {
607 role: "inventory-editor".to_string(),
608 privileges: BTreeSet::from([Privilege::Usage, Privilege::Select]),
609 object_type: ObjectType::Sequence,
610 schema: Some("inventory".to_string()),
611 name: Some("*".to_string()),
612 };
613 let sql = render(&change);
614 assert_eq!(
615 sql,
616 "REVOKE SELECT, USAGE ON ALL SEQUENCES IN SCHEMA \"inventory\" FROM \"inventory-editor\";"
617 );
618 }
619
620 #[test]
621 fn render_set_default_privilege() {
622 let change = Change::SetDefaultPrivilege {
623 owner: "app_owner".to_string(),
624 schema: "inventory".to_string(),
625 on_type: ObjectType::Table,
626 grantee: "inventory-editor".to_string(),
627 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
628 };
629 let sql = render(&change);
630 assert_eq!(
631 sql,
632 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" GRANT INSERT, SELECT ON TABLES TO \"inventory-editor\";"
633 );
634 }
635
636 #[test]
637 fn render_revoke_default_privilege() {
638 let change = Change::RevokeDefaultPrivilege {
639 owner: "app_owner".to_string(),
640 schema: "inventory".to_string(),
641 on_type: ObjectType::Function,
642 grantee: "inventory-editor".to_string(),
643 privileges: BTreeSet::from([Privilege::Execute]),
644 };
645 let sql = render(&change);
646 assert_eq!(
647 sql,
648 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" REVOKE EXECUTE ON FUNCTIONS FROM \"inventory-editor\";"
649 );
650 }
651
652 #[test]
653 fn render_add_member_basic() {
654 let change = Change::AddMember {
655 role: "inventory-editor".to_string(),
656 member: "user@example.com".to_string(),
657 inherit: true,
658 admin: false,
659 };
660 let sql = render(&change);
661 assert_eq!(
662 sql,
663 "GRANT \"inventory-editor\" TO \"user@example.com\" WITH INHERIT TRUE;"
664 );
665 }
666
667 #[test]
668 fn render_add_member_with_admin() {
669 let change = Change::AddMember {
670 role: "inventory-editor".to_string(),
671 member: "admin@example.com".to_string(),
672 inherit: true,
673 admin: true,
674 };
675 let sql = render(&change);
676 assert_eq!(
677 sql,
678 "GRANT \"inventory-editor\" TO \"admin@example.com\" WITH INHERIT TRUE, ADMIN TRUE;"
679 );
680 }
681
682 #[test]
683 fn render_add_member_no_inherit() {
684 let change = Change::AddMember {
685 role: "inventory-editor".to_string(),
686 member: "noinherit@example.com".to_string(),
687 inherit: false,
688 admin: false,
689 };
690 let sql = render(&change);
691 assert_eq!(
692 sql,
693 "GRANT \"inventory-editor\" TO \"noinherit@example.com\" WITH INHERIT FALSE;"
694 );
695 }
696
697 #[test]
698 fn render_remove_member() {
699 let change = Change::RemoveMember {
700 role: "inventory-editor".to_string(),
701 member: "user@example.com".to_string(),
702 };
703 let sql = render(&change);
704 assert_eq!(
705 sql,
706 "REVOKE \"inventory-editor\" FROM \"user@example.com\";"
707 );
708 }
709
710 #[test]
711 fn render_reassign_owned() {
712 let change = Change::ReassignOwned {
713 from_role: "legacy-owner".to_string(),
714 to_role: "app-owner".to_string(),
715 };
716 assert_eq!(
717 render(&change),
718 "REASSIGN OWNED BY \"legacy-owner\" TO \"app-owner\";"
719 );
720 }
721
722 #[test]
723 fn render_drop_owned() {
724 let change = Change::DropOwned {
725 role: "legacy-owner".to_string(),
726 };
727 assert_eq!(render(&change), "DROP OWNED BY \"legacy-owner\";");
728 }
729
730 #[test]
731 fn render_set_comment_some() {
732 let change = Change::SetComment {
733 name: "r1".to_string(),
734 comment: Some("A test role".to_string()),
735 };
736 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS 'A test role';");
737 }
738
739 #[test]
740 fn render_set_comment_none() {
741 let change = Change::SetComment {
742 name: "r1".to_string(),
743 comment: None,
744 };
745 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS NULL;");
746 }
747
748 #[test]
750 fn full_pipeline_manifest_to_sql() {
751 use crate::diff::diff;
752 use crate::manifest::{expand_manifest, parse_manifest};
753 use crate::model::RoleGraph;
754
755 let yaml = r#"
756default_owner: app_owner
757
758profiles:
759 editor:
760 grants:
761 - privileges: [USAGE]
762 on: { type: schema }
763 - privileges: [SELECT, INSERT, UPDATE, DELETE]
764 on: { type: table, name: "*" }
765 default_privileges:
766 - privileges: [SELECT, INSERT, UPDATE, DELETE]
767 on_type: table
768
769schemas:
770 - name: inventory
771 profiles: [editor]
772
773memberships:
774 - role: inventory-editor
775 members:
776 - name: "user@example.com"
777"#;
778 let manifest = parse_manifest(yaml).unwrap();
779 let expanded = expand_manifest(&manifest).unwrap();
780 let desired =
781 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
782 let current = RoleGraph::default();
783
784 let changes = diff(¤t, &desired);
785 let sql = render_all(&changes);
786
787 assert!(sql.contains("CREATE ROLE \"inventory-editor\""));
789 assert!(sql.contains("GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\""));
790 assert!(sql.contains("ALL TABLES IN SCHEMA \"inventory\""));
791 assert!(sql.contains("ALTER DEFAULT PRIVILEGES"));
792 assert!(sql.contains("GRANT \"inventory-editor\" TO \"user@example.com\""));
793
794 #[cfg(test)]
796 {
797 eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
798 }
799 }
800}