1use crate::auth::{Permission, RoleEntry, UserEntry};
72use crate::catalog::{Catalog, CatalogEntry, MaterializedViewEntry, TableState};
73use crate::error::{MongrelError, Result};
74use crate::procedure::{ProcedureEntry, StoredProcedure};
75use crate::schema::{ColumnDef, IndexDef, Schema, TypeId};
76use crate::security::{ColumnMask, MaskStrategy, RowPolicy, SecurityCatalog, SecurityExpr};
77use crate::trigger::{StoredTrigger, TriggerEntry, TriggerTarget};
78use serde::{Deserialize, Serialize};
79
80pub const CATALOG_COMMAND_FORMAT_VERSION: u16 = 1;
83
84pub const COMMAND_HISTORY_LIMIT: usize = 256;
88
89pub type ResourceGroupDef = crate::resource::ResourceGroup;
95
96pub use crate::jobs::{JobKind, JobState};
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct JobDefinition {
105 pub job_id: u64,
107 pub kind: JobKind,
108 pub state: JobState,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub target: Option<String>,
112 pub created_epoch: u64,
113 pub updated_epoch: u64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum CatalogCommand {
125 CreateTable {
129 name: String,
130 schema: Schema,
131 created_epoch: u64,
132 },
133 DropTable {
138 name: String,
139 at_epoch: u64,
140 },
141 RenameTable {
145 name: String,
146 new_name: String,
147 at_epoch: u64,
148 },
149 AlterColumn {
152 table: String,
153 column: ColumnDef,
154 },
155 AddColumn {
157 table: String,
158 column: ColumnDef,
159 },
160 DropColumn {
163 table: String,
164 column: String,
165 },
166 AddIndex {
169 table: String,
170 index: IndexDef,
171 },
172 RemoveIndex {
175 table: String,
176 name: String,
177 },
178 ReplaceIndex {
185 table: String,
186 expected_schema_sequence: u64,
187 expected_old_name: String,
188 new_definition: IndexDef,
189 },
190 CreateUser {
194 username: String,
195 password_hash: String,
196 is_admin: bool,
197 created_epoch: u64,
198 },
199 DropUser {
200 username: String,
201 },
202 AlterUserPassword {
203 username: String,
204 password_hash: String,
205 },
206 SetUserAdmin {
207 username: String,
208 is_admin: bool,
209 },
210 CreateRole {
211 name: String,
212 created_epoch: u64,
213 },
214 DropRole {
217 name: String,
218 },
219 GrantRole {
222 username: String,
223 role: String,
224 },
225 RevokeRole {
227 username: String,
228 role: String,
229 },
230 GrantPermission {
233 role: String,
234 permission: Permission,
235 },
236 RevokePermission {
238 role: String,
239 permission: Permission,
240 },
241 EnableRls {
243 table: String,
244 },
245 DisableRls {
246 table: String,
247 },
248 SetRowPolicy {
250 policy: RowPolicy,
251 },
252 DropRowPolicy {
253 table: String,
254 name: String,
255 },
256 SetColumnMask {
258 mask: ColumnMask,
259 },
260 DropColumnMask {
261 table: String,
262 name: String,
263 },
264 SetSecurityCatalog {
268 security: SecurityCatalog,
269 },
270 CreateTrigger {
272 trigger: StoredTrigger,
273 },
274 DropTrigger {
275 name: String,
276 },
277 CreateProcedure {
278 procedure: StoredProcedure,
279 },
280 DropProcedure {
281 name: String,
282 },
283 CreateMaterializedView {
287 definition: MaterializedViewEntry,
288 },
289 DropMaterializedView {
292 name: String,
293 },
294 RefreshMaterializedView {
297 name: String,
298 at_epoch: u64,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 checkpoint_event_id: Option<String>,
301 },
302 SetResourceGroup {
305 group: ResourceGroupDef,
306 },
307 RemoveResourceGroup {
308 name: String,
309 },
310 SubmitJob {
313 job: JobDefinition,
314 },
315 SetJobState {
318 job_id: u64,
319 state: JobState,
320 at_epoch: u64,
321 },
322 ReplaceTrigger {
329 trigger: StoredTrigger,
330 },
331 ReplaceProcedure {
335 procedure: StoredProcedure,
336 },
337 CreateUserWithScram {
339 username: String,
340 password_hash: String,
341 scram_sha_256: crate::security_hardening::ScramVerifier,
342 is_admin: bool,
343 created_epoch: u64,
344 },
345 AlterUserPasswordWithScram {
347 username: String,
348 password_hash: String,
349 scram_sha_256: crate::security_hardening::ScramVerifier,
350 },
351 CreateUserWithAuthVerifiers {
353 username: String,
354 password_hash: String,
355 scram_sha_256: crate::security_hardening::ScramVerifier,
356 mysql_caching_sha2: crate::auth::MysqlCachingSha2Verifier,
357 is_admin: bool,
358 created_epoch: u64,
359 },
360 AlterUserPasswordWithAuthVerifiers {
362 username: String,
363 password_hash: String,
364 scram_sha_256: crate::security_hardening::ScramVerifier,
365 mysql_caching_sha2: crate::auth::MysqlCachingSha2Verifier,
366 },
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct CatalogCommandRecord {
373 pub version: u16,
375 pub catalog_version: u64,
377 pub command: CatalogCommand,
379}
380
381impl CatalogCommandRecord {
382 pub fn next(catalog: &Catalog, command: CatalogCommand) -> Self {
384 Self {
385 version: CATALOG_COMMAND_FORMAT_VERSION,
386 catalog_version: catalog.catalog_version.saturating_add(1),
387 command,
388 }
389 }
390}
391
392#[derive(Debug, Clone)]
398pub enum CatalogDelta {
399 NoOp,
402 TableCreated {
403 entry: CatalogEntry,
404 },
405 TableDropped {
406 table_id: u64,
407 name: String,
408 at_epoch: u64,
409 },
410 TableRenamed {
411 table_id: u64,
412 old_name: String,
413 new_name: String,
414 at_epoch: u64,
415 },
416 SchemaReplaced {
418 table_id: u64,
419 schema: Schema,
420 },
421 UserUpserted(UserEntry),
424 UserRemoved {
425 username: String,
426 },
427 RoleUpserted(RoleEntry),
429 RoleRemoved {
430 name: String,
431 },
432 SecurityReplaced {
435 security: SecurityCatalog,
436 },
437 TriggerUpserted(TriggerEntry),
438 TriggerRemoved {
439 name: String,
440 },
441 ProcedureUpserted(ProcedureEntry),
442 ProcedureRemoved {
443 name: String,
444 },
445 MaterializedViewUpserted(MaterializedViewEntry),
446 MaterializedViewRemoved {
447 name: String,
448 },
449 ResourceGroupUpserted(ResourceGroupDef),
450 ResourceGroupRemoved {
451 name: String,
452 },
453 JobUpserted(JobDefinition),
454}
455
456impl CatalogDelta {
457 pub(crate) fn apply_to(&self, catalog: &mut Catalog) -> Result<()> {
460 match self {
461 CatalogDelta::NoOp => {}
462 CatalogDelta::TableCreated { entry } => {
463 catalog.next_table_id = entry.table_id.checked_add(1).ok_or_else(|| {
464 MongrelError::InvalidArgument("table id space exhausted".into())
465 })?;
466 catalog.tables.push(entry.clone());
467 }
468 CatalogDelta::TableDropped {
469 table_id,
470 name,
471 at_epoch,
472 } => {
473 let entry = catalog
474 .tables
475 .iter_mut()
476 .find(|table| table.table_id == *table_id)
477 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
478 entry.state = TableState::Dropped {
479 at_epoch: *at_epoch,
480 };
481 catalog.triggers.retain(|trigger| {
482 !matches!(
483 &trigger.trigger.target,
484 TriggerTarget::Table(target) if target == name
485 )
486 });
487 catalog
488 .materialized_views
489 .retain(|definition| definition.name != *name);
490 catalog.security.rls_tables.retain(|table| table != name);
491 catalog
492 .security
493 .policies
494 .retain(|policy| policy.table != *name);
495 catalog.security.masks.retain(|mask| mask.table != *name);
496 for role in &mut catalog.roles {
497 role.permissions
498 .retain(|permission| permission_table(permission) != Some(name.as_str()));
499 }
500 advance_security_version(catalog)?;
501 }
502 CatalogDelta::TableRenamed {
503 table_id,
504 old_name,
505 new_name,
506 at_epoch,
507 } => {
508 let entry = catalog
509 .tables
510 .iter_mut()
511 .find(|table| table.table_id == *table_id)
512 .ok_or_else(|| {
513 MongrelError::NotFound(format!("table {old_name:?} not found"))
514 })?;
515 entry.name = new_name.clone();
516 for trigger in &mut catalog.triggers {
517 if matches!(
518 &trigger.trigger.target,
519 TriggerTarget::Table(target) if target == old_name
520 ) {
521 trigger.trigger = trigger.trigger.retarget_table(new_name, *at_epoch)?;
522 }
523 }
524 if let Some(definition) = catalog
525 .materialized_views
526 .iter_mut()
527 .find(|definition| definition.name == *old_name)
528 {
529 definition.name = new_name.clone();
530 }
531 for table in &mut catalog.security.rls_tables {
535 if table == old_name {
536 *table = new_name.clone();
537 }
538 }
539 for policy in &mut catalog.security.policies {
540 if policy.table == *old_name {
541 policy.table = new_name.clone();
542 }
543 }
544 for mask in &mut catalog.security.masks {
545 if mask.table == *old_name {
546 mask.table = new_name.clone();
547 }
548 }
549 for role in &mut catalog.roles {
550 for permission in &mut role.permissions {
551 rename_permission_table(permission, old_name, new_name);
552 }
553 }
554 advance_security_version(catalog)?;
555 }
556 CatalogDelta::SchemaReplaced { table_id, schema } => {
557 let entry = catalog
558 .tables
559 .iter_mut()
560 .find(|table| table.table_id == *table_id)
561 .ok_or_else(|| {
562 MongrelError::NotFound(format!("table id {table_id} not found"))
563 })?;
564 entry.schema = schema.clone();
565 }
566 CatalogDelta::UserUpserted(entry) => {
567 match catalog
568 .users
569 .iter_mut()
570 .find(|user| user.username == entry.username)
571 {
572 Some(existing) => *existing = entry.clone(),
573 None => {
574 let next = entry.id.checked_add(1).ok_or_else(|| {
575 MongrelError::Full("user-id namespace exhausted".into())
576 })?;
577 catalog.users.push(entry.clone());
578 catalog.next_user_id = catalog.next_user_id.max(next);
579 }
580 }
581 advance_security_version(catalog)?;
582 }
583 CatalogDelta::UserRemoved { username } => {
584 catalog.users.retain(|user| user.username != *username);
585 advance_security_version(catalog)?;
586 }
587 CatalogDelta::RoleUpserted(entry) => {
588 match catalog
589 .roles
590 .iter_mut()
591 .find(|role| role.name == entry.name)
592 {
593 Some(existing) => *existing = entry.clone(),
594 None => catalog.roles.push(entry.clone()),
595 }
596 advance_security_version(catalog)?;
597 }
598 CatalogDelta::RoleRemoved { name } => {
599 catalog.roles.retain(|role| role.name != *name);
600 for user in &mut catalog.users {
601 user.roles.retain(|role| role != name);
602 }
603 advance_security_version(catalog)?;
604 }
605 CatalogDelta::SecurityReplaced { security } => {
606 catalog.security = security.clone();
607 advance_security_version(catalog)?;
608 }
609 CatalogDelta::TriggerUpserted(entry) => {
610 match catalog
611 .triggers
612 .iter_mut()
613 .find(|trigger| trigger.trigger.name == entry.trigger.name)
614 {
615 Some(existing) => *existing = entry.clone(),
616 None => catalog.triggers.push(entry.clone()),
617 }
618 }
619 CatalogDelta::TriggerRemoved { name } => {
620 catalog
621 .triggers
622 .retain(|trigger| trigger.trigger.name != *name);
623 }
624 CatalogDelta::ProcedureUpserted(entry) => {
625 match catalog
626 .procedures
627 .iter_mut()
628 .find(|procedure| procedure.procedure.name == entry.procedure.name)
629 {
630 Some(existing) => *existing = entry.clone(),
631 None => catalog.procedures.push(entry.clone()),
632 }
633 }
634 CatalogDelta::ProcedureRemoved { name } => {
635 catalog
636 .procedures
637 .retain(|procedure| procedure.procedure.name != *name);
638 }
639 CatalogDelta::MaterializedViewUpserted(definition) => {
640 match catalog
641 .materialized_views
642 .iter_mut()
643 .find(|existing| existing.name == definition.name)
644 {
645 Some(existing) => *existing = definition.clone(),
646 None => catalog.materialized_views.push(definition.clone()),
647 }
648 }
649 CatalogDelta::MaterializedViewRemoved { name } => {
650 catalog
651 .materialized_views
652 .retain(|definition| definition.name != *name);
653 }
654 CatalogDelta::ResourceGroupUpserted(group) => {
655 match catalog
656 .resource_groups
657 .iter_mut()
658 .find(|existing| existing.name == group.name)
659 {
660 Some(existing) => *existing = group.clone(),
661 None => catalog.resource_groups.push(group.clone()),
662 }
663 }
664 CatalogDelta::ResourceGroupRemoved { name } => {
665 catalog.resource_groups.retain(|group| group.name != *name);
666 }
667 CatalogDelta::JobUpserted(job) => {
668 match catalog
669 .job_definitions
670 .iter_mut()
671 .find(|existing| existing.job_id == job.job_id)
672 {
673 Some(existing) => *existing = job.clone(),
674 None => catalog.job_definitions.push(job.clone()),
675 }
676 }
677 }
678 Ok(())
679 }
680}
681
682pub fn required_permission(command: &CatalogCommand) -> Permission {
686 match command {
687 CatalogCommand::CreateTable { .. }
688 | CatalogCommand::DropTable { .. }
689 | CatalogCommand::RenameTable { .. }
690 | CatalogCommand::AlterColumn { .. }
691 | CatalogCommand::AddColumn { .. }
692 | CatalogCommand::DropColumn { .. }
693 | CatalogCommand::AddIndex { .. }
694 | CatalogCommand::RemoveIndex { .. }
695 | CatalogCommand::ReplaceIndex { .. }
696 | CatalogCommand::CreateTrigger { .. }
697 | CatalogCommand::DropTrigger { .. }
698 | CatalogCommand::CreateProcedure { .. }
699 | CatalogCommand::DropProcedure { .. }
700 | CatalogCommand::ReplaceTrigger { .. }
701 | CatalogCommand::ReplaceProcedure { .. }
702 | CatalogCommand::CreateMaterializedView { .. }
703 | CatalogCommand::DropMaterializedView { .. }
704 | CatalogCommand::RefreshMaterializedView { .. } => Permission::Ddl,
705 CatalogCommand::CreateUser { .. }
706 | CatalogCommand::CreateUserWithScram { .. }
707 | CatalogCommand::CreateUserWithAuthVerifiers { .. }
708 | CatalogCommand::DropUser { .. }
709 | CatalogCommand::AlterUserPassword { .. }
710 | CatalogCommand::AlterUserPasswordWithScram { .. }
711 | CatalogCommand::AlterUserPasswordWithAuthVerifiers { .. }
712 | CatalogCommand::SetUserAdmin { .. }
713 | CatalogCommand::CreateRole { .. }
714 | CatalogCommand::DropRole { .. }
715 | CatalogCommand::GrantRole { .. }
716 | CatalogCommand::RevokeRole { .. }
717 | CatalogCommand::GrantPermission { .. }
718 | CatalogCommand::RevokePermission { .. }
719 | CatalogCommand::EnableRls { .. }
720 | CatalogCommand::DisableRls { .. }
721 | CatalogCommand::SetRowPolicy { .. }
722 | CatalogCommand::DropRowPolicy { .. }
723 | CatalogCommand::SetColumnMask { .. }
724 | CatalogCommand::DropColumnMask { .. }
725 | CatalogCommand::SetSecurityCatalog { .. }
726 | CatalogCommand::SetResourceGroup { .. }
727 | CatalogCommand::RemoveResourceGroup { .. }
728 | CatalogCommand::SubmitJob { .. }
729 | CatalogCommand::SetJobState { .. } => Permission::Admin,
730 }
731}
732
733pub fn apply(catalog: &Catalog, command: &CatalogCommand) -> Result<CatalogDelta> {
737 match command {
738 CatalogCommand::CreateTable {
739 name,
740 schema,
741 created_epoch,
742 } => {
743 if name.is_empty() || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX) {
744 return Err(MongrelError::InvalidArgument(format!(
745 "invalid table name {name:?}"
746 )));
747 }
748 if catalog.live(name).is_some() || catalog.building_for(name).is_some() {
749 return Err(MongrelError::InvalidArgument(format!(
750 "table {name:?} already exists or is being built"
751 )));
752 }
753 let mut schema = schema.clone();
754 schema.validate_auto_increment()?;
758 schema.validate_defaults()?;
759 schema.validate_ai()?;
760 for index in &schema.indexes {
761 index.validate_options()?;
762 }
763 for constraint in &schema.constraints.checks {
764 constraint.expr.validate()?;
765 }
766 let table_id = catalog.next_table_id;
767 schema.schema_id = table_id;
768 Ok(CatalogDelta::TableCreated {
769 entry: CatalogEntry {
770 table_id,
771 name: name.clone(),
772 schema,
773 state: TableState::Live,
774 created_epoch: *created_epoch,
775 },
776 })
777 }
778 CatalogCommand::DropTable { name, at_epoch } => {
779 let entry = catalog
780 .live(name)
781 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
782 Ok(CatalogDelta::TableDropped {
783 table_id: entry.table_id,
784 name: name.clone(),
785 at_epoch: *at_epoch,
786 })
787 }
788 CatalogCommand::RenameTable {
789 name,
790 new_name,
791 at_epoch,
792 } => {
793 if name == new_name {
794 return Ok(CatalogDelta::NoOp);
795 }
796 if new_name.is_empty()
797 || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
798 || new_name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
799 {
800 return Err(MongrelError::InvalidArgument(
801 "invalid table rename identity".into(),
802 ));
803 }
804 let entry = catalog
805 .live(name)
806 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
807 if catalog.live(new_name).is_some() || catalog.building_for(new_name).is_some() {
808 return Err(MongrelError::InvalidArgument(format!(
809 "a table named {new_name:?} already exists"
810 )));
811 }
812 Ok(CatalogDelta::TableRenamed {
813 table_id: entry.table_id,
814 old_name: name.clone(),
815 new_name: new_name.clone(),
816 at_epoch: *at_epoch,
817 })
818 }
819 CatalogCommand::AlterColumn { table, column } => {
820 let entry = live_entry(catalog, table)?;
821 let mut schema = entry.schema.clone();
822 let position = schema
823 .columns
824 .iter()
825 .position(|existing| existing.id == column.id)
826 .ok_or_else(|| {
827 MongrelError::ColumnNotFound(format!("column id {} on {table}", column.id))
828 })?;
829 schema.columns[position] = column.clone();
830 schema.schema_id = schema
834 .schema_id
835 .checked_add(1)
836 .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
837 Ok(CatalogDelta::SchemaReplaced {
838 table_id: entry.table_id,
839 schema,
840 })
841 }
842 CatalogCommand::AddColumn { table, column } => {
843 let entry = live_entry(catalog, table)?;
844 if entry.schema.column(&column.name).is_some() {
845 return Err(MongrelError::InvalidArgument(format!(
846 "column {} already exists on {table}",
847 column.name
848 )));
849 }
850 if entry
851 .schema
852 .columns
853 .iter()
854 .any(|existing| existing.id == column.id)
855 {
856 return Err(MongrelError::InvalidArgument(format!(
857 "column id {} already used on {table}",
858 column.id
859 )));
860 }
861 let mut schema = entry.schema.clone();
862 schema.columns.push(column.clone());
863 schema.validate_auto_increment()?;
864 schema.validate_defaults()?;
865 schema.validate_ai()?;
866 Ok(CatalogDelta::SchemaReplaced {
867 table_id: entry.table_id,
868 schema,
869 })
870 }
871 CatalogCommand::DropColumn { table, column } => {
872 let entry = live_entry(catalog, table)?;
873 let schema = &entry.schema;
874 let target = schema.column(column).ok_or_else(|| {
875 MongrelError::ColumnNotFound(format!("column {column} on {table}"))
876 })?;
877 let dropped_id = target.id;
878 let mut schema = schema.clone();
879 schema.columns.retain(|existing| existing.id != dropped_id);
880 schema.indexes.retain(|index| index.column_id != dropped_id);
881 Ok(CatalogDelta::SchemaReplaced {
882 table_id: entry.table_id,
883 schema,
884 })
885 }
886 CatalogCommand::AddIndex { table, index } => {
887 let entry = live_entry(catalog, table)?;
888 let schema = &entry.schema;
889 if schema
890 .indexes
891 .iter()
892 .any(|existing| existing.name == index.name)
893 {
894 return Err(MongrelError::InvalidArgument(format!(
895 "index {} already exists on {table}",
896 index.name
897 )));
898 }
899 index.validate_options()?;
900 let mut schema = schema.clone();
901 schema.indexes.push(index.clone());
902 bump_schema_sequence(&mut schema)?;
903 schema.validate_ai()?;
905 Ok(CatalogDelta::SchemaReplaced {
906 table_id: entry.table_id,
907 schema,
908 })
909 }
910 CatalogCommand::RemoveIndex { table, name } => {
911 let entry = live_entry(catalog, table)?;
912 let schema = &entry.schema;
913 if !schema.indexes.iter().any(|index| index.name == *name) {
914 return Err(MongrelError::NotFound(format!(
915 "index {name} does not exist on {table}"
916 )));
917 }
918 let mut schema = schema.clone();
919 schema.indexes.retain(|index| index.name != *name);
920 bump_schema_sequence(&mut schema)?;
921 Ok(CatalogDelta::SchemaReplaced {
922 table_id: entry.table_id,
923 schema,
924 })
925 }
926 CatalogCommand::ReplaceIndex {
927 table,
928 expected_schema_sequence,
929 expected_old_name,
930 new_definition,
931 } => {
932 let entry = live_entry(catalog, table)?;
933 let schema = &entry.schema;
934 if schema.schema_id != *expected_schema_sequence {
935 return Err(MongrelError::Conflict(format!(
936 "index replace on {table}: expected schema sequence {expected_schema_sequence}, found {}",
937 schema.schema_id
938 )));
939 }
940 if !schema
941 .indexes
942 .iter()
943 .any(|index| index.name == *expected_old_name)
944 {
945 return Err(MongrelError::NotFound(format!(
946 "index {expected_old_name} does not exist on {table}"
947 )));
948 }
949 if new_definition.name != *expected_old_name
950 && schema
951 .indexes
952 .iter()
953 .any(|index| index.name == new_definition.name)
954 {
955 return Err(MongrelError::InvalidArgument(format!(
956 "index {} already exists on {table}",
957 new_definition.name
958 )));
959 }
960 new_definition.validate_options()?;
961 let mut schema = schema.clone();
962 let position = schema
963 .indexes
964 .iter()
965 .position(|index| index.name == *expected_old_name)
966 .expect("presence checked above");
967 schema.indexes[position] = new_definition.clone();
968 bump_schema_sequence(&mut schema)?;
969 schema.validate_ai()?;
970 Ok(CatalogDelta::SchemaReplaced {
971 table_id: entry.table_id,
972 schema,
973 })
974 }
975 CatalogCommand::CreateUser {
976 username,
977 password_hash,
978 is_admin,
979 created_epoch,
980 } => {
981 if username.is_empty() {
982 return Err(MongrelError::InvalidArgument(
983 "username must not be empty".into(),
984 ));
985 }
986 if catalog.users.iter().any(|user| user.username == *username) {
987 return Err(MongrelError::InvalidArgument(format!(
988 "user {username:?} already exists"
989 )));
990 }
991 let id = catalog.next_user_id.max(1);
992 Ok(CatalogDelta::UserUpserted(UserEntry {
993 id,
994 username: username.clone(),
995 password_hash: password_hash.clone(),
996 scram_sha_256: None,
997 mysql_caching_sha2: None,
998 roles: Vec::new(),
999 is_admin: *is_admin,
1000 created_epoch: *created_epoch,
1001 }))
1002 }
1003 CatalogCommand::DropUser { username } => {
1004 if !catalog.users.iter().any(|user| user.username == *username) {
1005 return Err(MongrelError::NotFound(format!(
1006 "user {username:?} not found"
1007 )));
1008 }
1009 Ok(CatalogDelta::UserRemoved {
1010 username: username.clone(),
1011 })
1012 }
1013 CatalogCommand::AlterUserPassword {
1014 username,
1015 password_hash,
1016 } => {
1017 let user = find_user(catalog, username)?;
1018 let mut user = user.clone();
1019 user.password_hash = password_hash.clone();
1020 user.scram_sha_256 = None;
1021 user.mysql_caching_sha2 = None;
1022 Ok(CatalogDelta::UserUpserted(user))
1023 }
1024 CatalogCommand::CreateUserWithScram {
1025 username,
1026 password_hash,
1027 scram_sha_256,
1028 is_admin,
1029 created_epoch,
1030 } => {
1031 if username.is_empty() {
1032 return Err(MongrelError::InvalidArgument(
1033 "username must not be empty".into(),
1034 ));
1035 }
1036 if catalog.users.iter().any(|user| user.username == *username) {
1037 return Err(MongrelError::InvalidArgument(format!(
1038 "user {username:?} already exists"
1039 )));
1040 }
1041 Ok(CatalogDelta::UserUpserted(UserEntry {
1042 id: catalog.next_user_id.max(1),
1043 username: username.clone(),
1044 password_hash: password_hash.clone(),
1045 scram_sha_256: Some(scram_sha_256.clone()),
1046 mysql_caching_sha2: None,
1047 roles: Vec::new(),
1048 is_admin: *is_admin,
1049 created_epoch: *created_epoch,
1050 }))
1051 }
1052 CatalogCommand::AlterUserPasswordWithScram {
1053 username,
1054 password_hash,
1055 scram_sha_256,
1056 } => {
1057 let mut user = find_user(catalog, username)?.clone();
1058 user.password_hash = password_hash.clone();
1059 user.scram_sha_256 = Some(scram_sha_256.clone());
1060 user.mysql_caching_sha2 = None;
1061 Ok(CatalogDelta::UserUpserted(user))
1062 }
1063 CatalogCommand::CreateUserWithAuthVerifiers {
1064 username,
1065 password_hash,
1066 scram_sha_256,
1067 mysql_caching_sha2,
1068 is_admin,
1069 created_epoch,
1070 } => {
1071 if username.is_empty() {
1072 return Err(MongrelError::InvalidArgument(
1073 "username must not be empty".into(),
1074 ));
1075 }
1076 if catalog.users.iter().any(|user| user.username == *username) {
1077 return Err(MongrelError::InvalidArgument(format!(
1078 "user {username:?} already exists"
1079 )));
1080 }
1081 Ok(CatalogDelta::UserUpserted(UserEntry {
1082 id: catalog.next_user_id.max(1),
1083 username: username.clone(),
1084 password_hash: password_hash.clone(),
1085 scram_sha_256: Some(scram_sha_256.clone()),
1086 mysql_caching_sha2: Some(mysql_caching_sha2.clone()),
1087 roles: Vec::new(),
1088 is_admin: *is_admin,
1089 created_epoch: *created_epoch,
1090 }))
1091 }
1092 CatalogCommand::AlterUserPasswordWithAuthVerifiers {
1093 username,
1094 password_hash,
1095 scram_sha_256,
1096 mysql_caching_sha2,
1097 } => {
1098 let mut user = find_user(catalog, username)?.clone();
1099 user.password_hash = password_hash.clone();
1100 user.scram_sha_256 = Some(scram_sha_256.clone());
1101 user.mysql_caching_sha2 = Some(mysql_caching_sha2.clone());
1102 Ok(CatalogDelta::UserUpserted(user))
1103 }
1104 CatalogCommand::SetUserAdmin { username, is_admin } => {
1105 let user = find_user(catalog, username)?;
1106 if user.is_admin == *is_admin {
1107 return Ok(CatalogDelta::NoOp);
1108 }
1109 let mut user = user.clone();
1110 user.is_admin = *is_admin;
1111 Ok(CatalogDelta::UserUpserted(user))
1112 }
1113 CatalogCommand::CreateRole {
1114 name,
1115 created_epoch,
1116 } => {
1117 if name.is_empty() {
1118 return Err(MongrelError::InvalidArgument(
1119 "role name must not be empty".into(),
1120 ));
1121 }
1122 if catalog.roles.iter().any(|role| role.name == *name) {
1123 return Err(MongrelError::InvalidArgument(format!(
1124 "role {name:?} already exists"
1125 )));
1126 }
1127 Ok(CatalogDelta::RoleUpserted(RoleEntry {
1128 name: name.clone(),
1129 permissions: Vec::new(),
1130 created_epoch: *created_epoch,
1131 }))
1132 }
1133 CatalogCommand::DropRole { name } => {
1134 if !catalog.roles.iter().any(|role| role.name == *name) {
1135 return Err(MongrelError::NotFound(format!("role {name:?} not found")));
1136 }
1137 Ok(CatalogDelta::RoleRemoved { name: name.clone() })
1138 }
1139 CatalogCommand::GrantRole { username, role } => {
1140 if !catalog.roles.iter().any(|entry| entry.name == *role) {
1141 return Err(MongrelError::NotFound(format!("role {role:?} not found")));
1142 }
1143 let user = find_user(catalog, username)?;
1144 if user.roles.iter().any(|held| held == role) {
1145 return Ok(CatalogDelta::NoOp);
1146 }
1147 let mut user = user.clone();
1148 user.roles.push(role.clone());
1149 Ok(CatalogDelta::UserUpserted(user))
1150 }
1151 CatalogCommand::RevokeRole { username, role } => {
1152 let user = find_user(catalog, username)?;
1153 if !user.roles.iter().any(|held| held == role) {
1154 return Ok(CatalogDelta::NoOp);
1155 }
1156 let mut user = user.clone();
1157 user.roles.retain(|held| held != role);
1158 Ok(CatalogDelta::UserUpserted(user))
1159 }
1160 CatalogCommand::GrantPermission { role, permission } => {
1161 let role = find_role(catalog, role)?;
1162 let mut resolved = role.clone();
1163 merge_permission(&mut resolved.permissions, permission.clone());
1164 if resolved.permissions == role.permissions {
1165 return Ok(CatalogDelta::NoOp);
1166 }
1167 Ok(CatalogDelta::RoleUpserted(resolved))
1168 }
1169 CatalogCommand::RevokePermission { role, permission } => {
1170 let role = find_role(catalog, role)?;
1171 let mut resolved = role.clone();
1172 revoke_permission_from(&mut resolved.permissions, permission);
1173 if resolved.permissions == role.permissions {
1174 return Ok(CatalogDelta::NoOp);
1175 }
1176 Ok(CatalogDelta::RoleUpserted(resolved))
1177 }
1178 CatalogCommand::EnableRls { table } => {
1179 live_entry(catalog, table)?;
1180 if catalog.security.rls_enabled(table) {
1181 return Ok(CatalogDelta::NoOp);
1182 }
1183 let mut security = catalog.security.clone();
1184 security.rls_tables.push(table.clone());
1185 Ok(CatalogDelta::SecurityReplaced { security })
1186 }
1187 CatalogCommand::DisableRls { table } => {
1188 if !catalog.security.rls_enabled(table) {
1189 return Ok(CatalogDelta::NoOp);
1190 }
1191 let mut security = catalog.security.clone();
1192 security.rls_tables.retain(|name| name != table);
1193 Ok(CatalogDelta::SecurityReplaced { security })
1194 }
1195 CatalogCommand::SetRowPolicy { policy } => {
1196 let entry = live_entry(catalog, &policy.table)?;
1197 if let Some(expression) = &policy.using {
1198 validate_policy_columns(expression, &entry.schema)?;
1199 }
1200 if let Some(expression) = &policy.with_check {
1201 validate_policy_columns(expression, &entry.schema)?;
1202 }
1203 let mut security = catalog.security.clone();
1204 match security
1205 .policies
1206 .iter_mut()
1207 .find(|existing| existing.table == policy.table && existing.name == policy.name)
1208 {
1209 Some(existing) => *existing = policy.clone(),
1210 None => security.policies.push(policy.clone()),
1211 }
1212 Ok(CatalogDelta::SecurityReplaced { security })
1213 }
1214 CatalogCommand::DropRowPolicy { table, name } => {
1215 if !catalog
1216 .security
1217 .policies
1218 .iter()
1219 .any(|policy| policy.table == *table && policy.name == *name)
1220 {
1221 return Err(MongrelError::NotFound(format!(
1222 "policy {name:?} on {table:?} not found"
1223 )));
1224 }
1225 let mut security = catalog.security.clone();
1226 security
1227 .policies
1228 .retain(|policy| !(policy.table == *table && policy.name == *name));
1229 Ok(CatalogDelta::SecurityReplaced { security })
1230 }
1231 CatalogCommand::SetColumnMask { mask } => {
1232 let entry = live_entry(catalog, &mask.table)?;
1233 let column = entry
1234 .schema
1235 .columns
1236 .iter()
1237 .find(|column| column.id == mask.column)
1238 .ok_or_else(|| {
1239 MongrelError::NotFound(format!(
1240 "mask column {} on {:?} not found",
1241 mask.column, mask.table
1242 ))
1243 })?;
1244 if matches!(
1245 mask.strategy,
1246 MaskStrategy::Redact { .. } | MaskStrategy::Sha256
1247 ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
1248 {
1249 return Err(MongrelError::InvalidArgument(format!(
1250 "mask {:?} requires a string/bytes column",
1251 mask.name
1252 )));
1253 }
1254 let mut security = catalog.security.clone();
1255 match security
1256 .masks
1257 .iter_mut()
1258 .find(|existing| existing.table == mask.table && existing.name == mask.name)
1259 {
1260 Some(existing) => *existing = mask.clone(),
1261 None => security.masks.push(mask.clone()),
1262 }
1263 Ok(CatalogDelta::SecurityReplaced { security })
1264 }
1265 CatalogCommand::DropColumnMask { table, name } => {
1266 if !catalog
1267 .security
1268 .masks
1269 .iter()
1270 .any(|mask| mask.table == *table && mask.name == *name)
1271 {
1272 return Err(MongrelError::NotFound(format!(
1273 "mask {name:?} on {table:?} not found"
1274 )));
1275 }
1276 let mut security = catalog.security.clone();
1277 security
1278 .masks
1279 .retain(|mask| !(mask.table == *table && mask.name == *name));
1280 Ok(CatalogDelta::SecurityReplaced { security })
1281 }
1282 CatalogCommand::SetSecurityCatalog { security } => {
1283 crate::database::validate_security_catalog(catalog, security)?;
1288 Ok(CatalogDelta::SecurityReplaced {
1289 security: security.clone(),
1290 })
1291 }
1292 CatalogCommand::CreateTrigger { trigger } => {
1293 trigger.validate()?;
1294 if let TriggerTarget::Table(target) = &trigger.target {
1295 if catalog.live(target).is_none() {
1296 return Err(MongrelError::InvalidArgument(format!(
1297 "trigger {:?} references unknown target table {target:?}",
1298 trigger.name
1299 )));
1300 }
1301 }
1302 if catalog
1303 .triggers
1304 .iter()
1305 .any(|entry| entry.trigger.name == trigger.name)
1306 {
1307 return Err(MongrelError::TriggerValidation(format!(
1308 "trigger {:?} already exists",
1309 trigger.name
1310 )));
1311 }
1312 Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1313 trigger.clone(),
1314 )))
1315 }
1316 CatalogCommand::DropTrigger { name } => {
1317 if !catalog
1318 .triggers
1319 .iter()
1320 .any(|entry| entry.trigger.name == *name)
1321 {
1322 return Err(MongrelError::NotFound(format!(
1323 "trigger {name:?} not found"
1324 )));
1325 }
1326 Ok(CatalogDelta::TriggerRemoved { name: name.clone() })
1327 }
1328 CatalogCommand::CreateProcedure { procedure } => {
1329 procedure.validate()?;
1330 for step in &procedure.body.steps {
1331 if let Some(table) = step.table() {
1332 if catalog.live(table).is_none() {
1333 return Err(MongrelError::InvalidArgument(format!(
1334 "procedure {:?} references unknown table {table:?}",
1335 procedure.name
1336 )));
1337 }
1338 }
1339 }
1340 if catalog
1341 .procedures
1342 .iter()
1343 .any(|entry| entry.procedure.name == procedure.name)
1344 {
1345 return Err(MongrelError::InvalidArgument(format!(
1346 "procedure {:?} already exists",
1347 procedure.name
1348 )));
1349 }
1350 Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1351 procedure.clone(),
1352 )))
1353 }
1354 CatalogCommand::DropProcedure { name } => {
1355 if !catalog
1356 .procedures
1357 .iter()
1358 .any(|entry| entry.procedure.name == *name)
1359 {
1360 return Err(MongrelError::NotFound(format!(
1361 "procedure {name:?} not found"
1362 )));
1363 }
1364 Ok(CatalogDelta::ProcedureRemoved { name: name.clone() })
1365 }
1366 CatalogCommand::ReplaceTrigger { trigger } => {
1367 trigger.validate()?;
1372 if let TriggerTarget::Table(target) = &trigger.target {
1373 if catalog.live(target).is_none() {
1374 return Err(MongrelError::InvalidArgument(format!(
1375 "trigger {:?} references unknown target table {target:?}",
1376 trigger.name
1377 )));
1378 }
1379 }
1380 Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1381 trigger.clone(),
1382 )))
1383 }
1384 CatalogCommand::ReplaceProcedure { procedure } => {
1385 procedure.validate()?;
1386 for step in &procedure.body.steps {
1387 if let Some(table) = step.table() {
1388 if catalog.live(table).is_none() {
1389 return Err(MongrelError::InvalidArgument(format!(
1390 "procedure {:?} references unknown table {table:?}",
1391 procedure.name
1392 )));
1393 }
1394 }
1395 }
1396 Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1397 procedure.clone(),
1398 )))
1399 }
1400 CatalogCommand::CreateMaterializedView { definition } => {
1401 if definition.name.is_empty() || definition.query.trim().is_empty() {
1402 return Err(MongrelError::InvalidArgument(
1403 "materialized view name and query must not be empty".into(),
1404 ));
1405 }
1406 if catalog.live(&definition.name).is_none() {
1407 return Err(MongrelError::NotFound(format!(
1408 "materialized view table {:?} not found",
1409 definition.name
1410 )));
1411 }
1412 Ok(CatalogDelta::MaterializedViewUpserted(definition.clone()))
1413 }
1414 CatalogCommand::DropMaterializedView { name } => {
1415 if !catalog
1416 .materialized_views
1417 .iter()
1418 .any(|definition| definition.name == *name)
1419 {
1420 return Err(MongrelError::NotFound(format!(
1421 "materialized view {name:?} not found"
1422 )));
1423 }
1424 Ok(CatalogDelta::MaterializedViewRemoved { name: name.clone() })
1425 }
1426 CatalogCommand::RefreshMaterializedView {
1427 name,
1428 at_epoch,
1429 checkpoint_event_id,
1430 } => {
1431 let definition = catalog
1432 .materialized_views
1433 .iter()
1434 .find(|definition| definition.name == *name)
1435 .ok_or_else(|| {
1436 MongrelError::NotFound(format!("materialized view {name:?} not found"))
1437 })?;
1438 let mut definition = definition.clone();
1439 definition.last_refresh_epoch = *at_epoch;
1440 if let Some(checkpoint) = checkpoint_event_id {
1441 let plan = definition.incremental.as_mut().ok_or_else(|| {
1442 MongrelError::InvalidArgument(format!(
1443 "materialized view {name:?} has no incremental plan"
1444 ))
1445 })?;
1446 plan.checkpoint_event_id = checkpoint.clone();
1447 }
1448 Ok(CatalogDelta::MaterializedViewUpserted(definition))
1449 }
1450 CatalogCommand::SetResourceGroup { group } => {
1451 if group.name.is_empty() {
1452 return Err(MongrelError::InvalidArgument(
1453 "resource group name must not be empty".into(),
1454 ));
1455 }
1456 Ok(CatalogDelta::ResourceGroupUpserted(group.clone()))
1457 }
1458 CatalogCommand::RemoveResourceGroup { name } => {
1459 if !catalog
1460 .resource_groups
1461 .iter()
1462 .any(|group| group.name == *name)
1463 {
1464 return Err(MongrelError::NotFound(format!(
1465 "resource group {name:?} not found"
1466 )));
1467 }
1468 Ok(CatalogDelta::ResourceGroupRemoved { name: name.clone() })
1469 }
1470 CatalogCommand::SubmitJob { job } => {
1471 if catalog
1472 .job_definitions
1473 .iter()
1474 .any(|existing| existing.job_id == job.job_id)
1475 {
1476 return Err(MongrelError::InvalidArgument(format!(
1477 "job id {} already exists",
1478 job.job_id
1479 )));
1480 }
1481 Ok(CatalogDelta::JobUpserted(job.clone()))
1482 }
1483 CatalogCommand::SetJobState {
1484 job_id,
1485 state,
1486 at_epoch,
1487 } => {
1488 let job = catalog
1489 .job_definitions
1490 .iter()
1491 .find(|job| job.job_id == *job_id)
1492 .ok_or_else(|| MongrelError::NotFound(format!("job id {job_id} not found")))?;
1493 if job.state.is_terminal() {
1494 return Err(MongrelError::InvalidArgument(format!(
1495 "job id {job_id} is in terminal state {:?}",
1496 job.state
1497 )));
1498 }
1499 let mut job = job.clone();
1500 job.state = *state;
1501 job.updated_epoch = *at_epoch;
1502 Ok(CatalogDelta::JobUpserted(job))
1503 }
1504 }
1505}
1506
1507pub fn encode_command(record: &CatalogCommandRecord) -> Result<Vec<u8>> {
1509 serde_json::to_vec(record)
1510 .map_err(|error| MongrelError::Other(format!("catalog command serialize: {error}")))
1511}
1512
1513pub fn decode_command(bytes: &[u8]) -> Result<CatalogCommandRecord> {
1516 let record: CatalogCommandRecord = serde_json::from_slice(bytes)
1517 .map_err(|error| MongrelError::Other(format!("catalog command deserialize: {error}")))?;
1518 if record.version != CATALOG_COMMAND_FORMAT_VERSION {
1519 return Err(MongrelError::UnsupportedStorageVersion {
1520 component: "catalog command",
1521 found: record.version,
1522 supported: CATALOG_COMMAND_FORMAT_VERSION,
1523 });
1524 }
1525 Ok(record)
1526}
1527
1528fn live_entry<'a>(catalog: &'a Catalog, table: &str) -> Result<&'a CatalogEntry> {
1529 catalog
1530 .live(table)
1531 .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))
1532}
1533
1534fn bump_schema_sequence(schema: &mut Schema) -> Result<()> {
1537 schema.schema_id = schema
1538 .schema_id
1539 .checked_add(1)
1540 .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
1541 Ok(())
1542}
1543
1544fn find_user<'a>(catalog: &'a Catalog, username: &str) -> Result<&'a UserEntry> {
1545 catalog
1546 .users
1547 .iter()
1548 .find(|user| user.username == username)
1549 .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))
1550}
1551
1552fn find_role<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a RoleEntry> {
1553 catalog
1554 .roles
1555 .iter()
1556 .find(|role| role.name == name)
1557 .ok_or_else(|| MongrelError::NotFound(format!("role {name:?} not found")))
1558}
1559
1560fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
1564 catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
1565 MongrelError::Conflict("security catalog version space is exhausted".into())
1566 })?;
1567 Ok(())
1568}
1569
1570fn permission_table(permission: &Permission) -> Option<&str> {
1573 match permission {
1574 Permission::Select { table }
1575 | Permission::Insert { table }
1576 | Permission::Update { table }
1577 | Permission::Delete { table }
1578 | Permission::SelectColumns { table, .. }
1579 | Permission::InsertColumns { table, .. }
1580 | Permission::UpdateColumns { table, .. } => Some(table),
1581 Permission::All | Permission::Ddl | Permission::Admin => None,
1582 }
1583}
1584
1585fn rename_permission_table(permission: &mut Permission, old: &str, new: &str) {
1588 let table = match permission {
1589 Permission::Select { table }
1590 | Permission::Insert { table }
1591 | Permission::Update { table }
1592 | Permission::Delete { table }
1593 | Permission::SelectColumns { table, .. }
1594 | Permission::InsertColumns { table, .. }
1595 | Permission::UpdateColumns { table, .. } => Some(table),
1596 Permission::All | Permission::Ddl | Permission::Admin => None,
1597 };
1598 if let Some(table) = table.filter(|table| table.as_str() == old) {
1599 *table = new.to_string();
1600 }
1601}
1602
1603fn merge_permission(permissions: &mut Vec<Permission>, permission: Permission) {
1606 let (kind, table, mut columns) = match permission {
1607 Permission::SelectColumns { table, columns } => (0, table, columns),
1608 Permission::InsertColumns { table, columns } => (1, table, columns),
1609 Permission::UpdateColumns { table, columns } => (2, table, columns),
1610 permission if !permissions.contains(&permission) => {
1611 permissions.push(permission);
1612 return;
1613 }
1614 _ => return,
1615 };
1616 for permission in permissions.iter_mut() {
1617 let existing = match permission {
1618 Permission::SelectColumns {
1619 table: existing_table,
1620 columns,
1621 } if kind == 0 && existing_table == &table => Some(columns),
1622 Permission::InsertColumns {
1623 table: existing_table,
1624 columns,
1625 } if kind == 1 && existing_table == &table => Some(columns),
1626 Permission::UpdateColumns {
1627 table: existing_table,
1628 columns,
1629 } if kind == 2 && existing_table == &table => Some(columns),
1630 _ => None,
1631 };
1632 if let Some(existing) = existing {
1633 existing.append(&mut columns);
1634 existing.sort();
1635 existing.dedup();
1636 return;
1637 }
1638 }
1639 columns.sort();
1640 columns.dedup();
1641 let permission = if kind == 0 {
1642 Permission::SelectColumns { table, columns }
1643 } else if kind == 1 {
1644 Permission::InsertColumns { table, columns }
1645 } else {
1646 Permission::UpdateColumns { table, columns }
1647 };
1648 permissions.push(permission);
1649}
1650
1651fn revoke_permission_from(permissions: &mut Vec<Permission>, revoked: &Permission) {
1654 let revoked_columns = match revoked {
1655 Permission::SelectColumns { table, columns } => Some((0, table, columns)),
1656 Permission::InsertColumns { table, columns } => Some((1, table, columns)),
1657 Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
1658 _ => None,
1659 };
1660 let Some((kind, table, columns)) = revoked_columns else {
1661 permissions.retain(|permission| permission != revoked);
1662 return;
1663 };
1664 for permission in permissions.iter_mut() {
1665 let current = match permission {
1666 Permission::SelectColumns {
1667 table: current_table,
1668 columns,
1669 } if kind == 0 && current_table == table => Some(columns),
1670 Permission::InsertColumns {
1671 table: current_table,
1672 columns,
1673 } if kind == 1 && current_table == table => Some(columns),
1674 Permission::UpdateColumns {
1675 table: current_table,
1676 columns,
1677 } if kind == 2 && current_table == table => Some(columns),
1678 _ => None,
1679 };
1680 if let Some(current) = current {
1681 current.retain(|column| !columns.contains(column));
1682 }
1683 }
1684 permissions.retain(|permission| match permission {
1685 Permission::SelectColumns { columns, .. }
1686 | Permission::InsertColumns { columns, .. }
1687 | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
1688 _ => true,
1689 });
1690}
1691
1692fn validate_policy_columns(expression: &SecurityExpr, schema: &Schema) -> Result<()> {
1696 match expression {
1697 SecurityExpr::True => Ok(()),
1698 SecurityExpr::ColumnEqCurrentUser { column }
1699 | SecurityExpr::ColumnEqValue { column, .. } => {
1700 if schema
1701 .columns
1702 .iter()
1703 .any(|candidate| candidate.id == *column)
1704 {
1705 Ok(())
1706 } else {
1707 Err(MongrelError::InvalidArgument(format!(
1708 "security expression references unknown column id {column}"
1709 )))
1710 }
1711 }
1712 SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
1713 validate_policy_columns(left, schema)?;
1714 validate_policy_columns(right, schema)
1715 }
1716 SecurityExpr::Not { expression } => validate_policy_columns(expression, schema),
1717 }
1718}
1719
1720#[cfg(test)]
1721mod tests {
1722 use super::*;
1723 use crate::schema::ColumnFlags;
1724
1725 fn test_schema() -> Schema {
1726 Schema {
1727 schema_id: 0,
1728 columns: vec![
1729 ColumnDef {
1730 id: 1,
1731 name: "id".into(),
1732 ty: TypeId::Int64,
1733 flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
1734 default_value: None,
1735 embedding_source: None,
1736 },
1737 ColumnDef {
1738 id: 2,
1739 name: "secret".into(),
1740 ty: TypeId::Bytes,
1741 flags: ColumnFlags::empty(),
1742 default_value: None,
1743 embedding_source: None,
1744 },
1745 ],
1746 indexes: vec![],
1747 colocation: vec![],
1748 constraints: Default::default(),
1749 clustered: false,
1750 }
1751 }
1752
1753 fn catalog_with_table() -> Catalog {
1754 let mut catalog = Catalog::empty();
1755 let record = CatalogCommandRecord::next(
1756 &catalog,
1757 CatalogCommand::CreateTable {
1758 name: "t".into(),
1759 schema: test_schema(),
1760 created_epoch: 1,
1761 },
1762 );
1763 catalog.apply_command(&record).unwrap();
1764 catalog
1765 }
1766
1767 #[test]
1768 fn create_table_allocates_id_and_stamps_schema() {
1769 let catalog = catalog_with_table();
1770 assert_eq!(catalog.catalog_version(), 1);
1771 let entry = catalog.live("t").unwrap();
1772 assert_eq!(entry.table_id, 0);
1773 assert_eq!(entry.schema.schema_id, 0);
1774 assert_eq!(catalog.next_table_id, 1);
1775 let mut catalog = catalog;
1777 let record = CatalogCommandRecord::next(
1778 &catalog,
1779 CatalogCommand::CreateTable {
1780 name: "u".into(),
1781 schema: test_schema(),
1782 created_epoch: 2,
1783 },
1784 );
1785 catalog.apply_command(&record).unwrap();
1786 assert_eq!(catalog.live("u").unwrap().table_id, 1);
1787 assert_eq!(catalog.live("u").unwrap().schema.schema_id, 1);
1788 }
1789
1790 #[test]
1791 fn duplicate_create_table_fails_closed() {
1792 let catalog = catalog_with_table();
1793 let delta = apply(
1794 &catalog,
1795 &CatalogCommand::CreateTable {
1796 name: "t".into(),
1797 schema: test_schema(),
1798 created_epoch: 9,
1799 },
1800 );
1801 assert!(matches!(delta, Err(MongrelError::InvalidArgument(_))));
1802 }
1803
1804 #[test]
1805 fn version_gap_and_replay_guard() {
1806 let mut catalog = catalog_with_table();
1807 let gap = CatalogCommandRecord {
1809 version: CATALOG_COMMAND_FORMAT_VERSION,
1810 catalog_version: 3,
1811 command: CatalogCommand::DisableRls { table: "t".into() },
1812 };
1813 assert!(matches!(
1814 catalog.apply_command(&gap),
1815 Err(MongrelError::Conflict(_))
1816 ));
1817 let recorded = catalog.commands_since(0)[0].clone();
1819 let delta = catalog.apply_command(&recorded).unwrap();
1820 assert!(matches!(delta, CatalogDelta::NoOp));
1821 assert_eq!(catalog.catalog_version(), 1);
1822 let conflicting = CatalogCommandRecord {
1824 version: CATALOG_COMMAND_FORMAT_VERSION,
1825 catalog_version: 1,
1826 command: CatalogCommand::CreateTable {
1827 name: "other".into(),
1828 schema: test_schema(),
1829 created_epoch: 5,
1830 },
1831 };
1832 assert!(matches!(
1833 catalog.apply_command(&conflicting),
1834 Err(MongrelError::Conflict(_))
1835 ));
1836 }
1837
1838 #[test]
1839 fn unsupported_encoding_version_fails_closed() {
1840 let mut catalog = catalog_with_table();
1841 let record = CatalogCommandRecord {
1842 version: CATALOG_COMMAND_FORMAT_VERSION + 1,
1843 catalog_version: 2,
1844 command: CatalogCommand::DisableRls { table: "t".into() },
1845 };
1846 assert!(matches!(
1847 catalog.apply_command(&record),
1848 Err(MongrelError::UnsupportedStorageVersion { .. })
1849 ));
1850 }
1851
1852 #[test]
1853 fn encode_decode_round_trip() {
1854 let catalog = catalog_with_table();
1855 let record = &catalog.commands_since(0)[0];
1856 let bytes = encode_command(record).unwrap();
1857 let decoded = decode_command(&bytes).unwrap();
1858 assert_eq!(decoded.catalog_version, record.catalog_version);
1859 assert_eq!(decoded.version, CATALOG_COMMAND_FORMAT_VERSION);
1860 assert_eq!(encode_command(&decoded).unwrap(), bytes);
1861 }
1862
1863 #[test]
1864 fn decode_rejects_unknown_version_and_variant() {
1865 let catalog = catalog_with_table();
1866 let bytes = encode_command(&catalog.commands_since(0)[0]).unwrap();
1867 let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1868 json["version"] = serde_json::json!(99);
1869 assert!(matches!(
1870 decode_command(serde_json::to_string(&json).unwrap().as_bytes()),
1871 Err(MongrelError::UnsupportedStorageVersion { .. })
1872 ));
1873 let unknown = br#"{"version":1,"catalog_version":1,"command":{"NotACommand":{}}}"#;
1874 assert!(decode_command(unknown).is_err());
1875 let unknown_field =
1876 br#"{"version":1,"catalog_version":1,"command":{"DisableRls":{"table":"t"}},"x":1}"#;
1877 assert!(decode_command(unknown_field).is_err());
1878 }
1879
1880 #[test]
1881 fn drop_table_cascades_security_and_roles() {
1882 let mut catalog = catalog_with_table();
1883 for command in [
1884 CatalogCommand::EnableRls { table: "t".into() },
1885 CatalogCommand::SetRowPolicy {
1886 policy: RowPolicy {
1887 name: "p".into(),
1888 table: "t".into(),
1889 command: crate::security::PolicyCommand::Select,
1890 subjects: vec![],
1891 permissive: true,
1892 using: Some(SecurityExpr::True),
1893 with_check: None,
1894 },
1895 },
1896 CatalogCommand::SetColumnMask {
1897 mask: ColumnMask {
1898 name: "m".into(),
1899 table: "t".into(),
1900 column: 2,
1901 strategy: MaskStrategy::Sha256,
1902 exempt_subjects: vec![],
1903 },
1904 },
1905 CatalogCommand::CreateRole {
1906 name: "r".into(),
1907 created_epoch: 1,
1908 },
1909 CatalogCommand::GrantPermission {
1910 role: "r".into(),
1911 permission: Permission::Select { table: "t".into() },
1912 },
1913 ] {
1914 let record = CatalogCommandRecord::next(&catalog, command);
1915 catalog.apply_command(&record).unwrap();
1916 }
1917 let security_before = catalog.security.clone();
1918 assert!(security_before.rls_enabled("t"));
1919 let record = CatalogCommandRecord::next(
1920 &catalog,
1921 CatalogCommand::DropTable {
1922 name: "t".into(),
1923 at_epoch: 7,
1924 },
1925 );
1926 catalog.apply_command(&record).unwrap();
1927 assert!(catalog.live("t").is_none());
1928 assert!(catalog.security.policies.is_empty());
1929 assert!(catalog.security.masks.is_empty());
1930 assert!(!catalog.security.rls_enabled("t"));
1931 assert!(catalog.roles[0].permissions.is_empty());
1932 assert!(matches!(
1933 catalog.tables[0].state,
1934 TableState::Dropped { at_epoch: 7 }
1935 ));
1936 }
1937
1938 #[test]
1939 fn grant_and_revoke_permission_merge_columns() {
1940 let mut catalog = catalog_with_table();
1941 let commands = [
1942 CatalogCommand::CreateRole {
1943 name: "r".into(),
1944 created_epoch: 1,
1945 },
1946 CatalogCommand::GrantPermission {
1947 role: "r".into(),
1948 permission: Permission::SelectColumns {
1949 table: "t".into(),
1950 columns: vec!["id".into()],
1951 },
1952 },
1953 CatalogCommand::GrantPermission {
1954 role: "r".into(),
1955 permission: Permission::SelectColumns {
1956 table: "t".into(),
1957 columns: vec!["secret".into(), "id".into()],
1958 },
1959 },
1960 ];
1961 for command in commands {
1962 let record = CatalogCommandRecord::next(&catalog, command);
1963 catalog.apply_command(&record).unwrap();
1964 }
1965 assert_eq!(
1966 catalog.roles[0].permissions,
1967 vec![Permission::SelectColumns {
1968 table: "t".into(),
1969 columns: vec!["id".into(), "secret".into()],
1970 }]
1971 );
1972 let record = CatalogCommandRecord::next(
1973 &catalog,
1974 CatalogCommand::RevokePermission {
1975 role: "r".into(),
1976 permission: Permission::SelectColumns {
1977 table: "t".into(),
1978 columns: vec!["id".into()],
1979 },
1980 },
1981 );
1982 catalog.apply_command(&record).unwrap();
1983 assert_eq!(
1984 catalog.roles[0].permissions,
1985 vec![Permission::SelectColumns {
1986 table: "t".into(),
1987 columns: vec!["secret".into()],
1988 }]
1989 );
1990 }
1991
1992 #[test]
1993 fn job_state_terminal_guard() {
1994 let mut catalog = Catalog::empty();
1995 let submit = CatalogCommandRecord::next(
1996 &catalog,
1997 CatalogCommand::SubmitJob {
1998 job: JobDefinition {
1999 job_id: 1,
2000 kind: JobKind::IndexBuild,
2001 state: JobState::Pending,
2002 target: Some("t".into()),
2003 created_epoch: 1,
2004 updated_epoch: 1,
2005 },
2006 },
2007 );
2008 catalog.apply_command(&submit).unwrap();
2009 let run = CatalogCommandRecord::next(
2010 &catalog,
2011 CatalogCommand::SetJobState {
2012 job_id: 1,
2013 state: JobState::Succeeded,
2014 at_epoch: 2,
2015 },
2016 );
2017 catalog.apply_command(&run).unwrap();
2018 let late = CatalogCommandRecord::next(
2019 &catalog,
2020 CatalogCommand::SetJobState {
2021 job_id: 1,
2022 state: JobState::Running,
2023 at_epoch: 3,
2024 },
2025 );
2026 assert!(matches!(
2027 catalog.apply_command(&late),
2028 Err(MongrelError::InvalidArgument(_))
2029 ));
2030 }
2031
2032 #[test]
2033 fn history_is_bounded() {
2034 let mut catalog = Catalog::empty();
2035 for index in 0..(COMMAND_HISTORY_LIMIT + 10) {
2036 let record = CatalogCommandRecord::next(
2037 &catalog,
2038 CatalogCommand::SubmitJob {
2039 job: JobDefinition {
2040 job_id: index as u64 + 1,
2041 kind: JobKind::SchemaValidation,
2042 state: JobState::Pending,
2043 target: None,
2044 created_epoch: 0,
2045 updated_epoch: 0,
2046 },
2047 },
2048 );
2049 catalog.apply_command(&record).unwrap();
2050 }
2051 assert_eq!(catalog.command_log.len(), COMMAND_HISTORY_LIMIT);
2052 let retained = catalog.commands_since(0);
2053 assert_eq!(retained.len(), COMMAND_HISTORY_LIMIT);
2054 let oldest = retained[0].catalog_version;
2056 assert_eq!(
2057 oldest,
2058 catalog.catalog_version() - COMMAND_HISTORY_LIMIT as u64 + 1
2059 );
2060 let compacted = CatalogCommandRecord {
2062 version: CATALOG_COMMAND_FORMAT_VERSION,
2063 catalog_version: 1,
2064 command: CatalogCommand::SubmitJob {
2065 job: JobDefinition {
2066 job_id: 999_999,
2067 kind: JobKind::KeyRotation,
2068 state: JobState::Pending,
2069 target: None,
2070 created_epoch: 0,
2071 updated_epoch: 0,
2072 },
2073 },
2074 };
2075 let delta = catalog.apply_command(&compacted).unwrap();
2076 assert!(matches!(delta, CatalogDelta::NoOp));
2077 assert!(catalog
2078 .job_definitions
2079 .iter()
2080 .all(|job| job.job_id != 999_999));
2081 }
2082
2083 #[test]
2084 fn required_permission_matches_legacy_gates() {
2085 assert_eq!(
2086 required_permission(&CatalogCommand::CreateTable {
2087 name: "t".into(),
2088 schema: test_schema(),
2089 created_epoch: 0,
2090 }),
2091 Permission::Ddl
2092 );
2093 assert_eq!(
2094 required_permission(&CatalogCommand::EnableRls { table: "t".into() }),
2095 Permission::Admin
2096 );
2097 assert_eq!(
2098 required_permission(&CatalogCommand::DropUser {
2099 username: "u".into()
2100 }),
2101 Permission::Admin
2102 );
2103 assert_eq!(
2104 required_permission(&CatalogCommand::DropTrigger { name: "t".into() }),
2105 Permission::Ddl
2106 );
2107 }
2108
2109 fn replacement_trigger(name: &str) -> StoredTrigger {
2110 StoredTrigger::new(
2111 name,
2112 crate::trigger::TriggerDefinition {
2113 target: TriggerTarget::Table("t".into()),
2114 timing: crate::trigger::TriggerTiming::After,
2115 event: crate::trigger::TriggerEvent::Insert,
2116 update_of: Vec::new(),
2117 target_columns: Vec::new(),
2118 when: None,
2119 program: crate::trigger::TriggerProgram { steps: Vec::new() },
2120 },
2121 0,
2122 )
2123 .unwrap()
2124 }
2125
2126 fn replacement_procedure(name: &str) -> StoredProcedure {
2127 StoredProcedure::new(
2128 name,
2129 crate::procedure::ProcedureMode::ReadOnly,
2130 Vec::new(),
2131 crate::procedure::ProcedureBody {
2132 steps: Vec::new(),
2133 return_value: crate::procedure::ProcedureValue::Literal(
2134 crate::memtable::Value::Null,
2135 ),
2136 },
2137 0,
2138 )
2139 .unwrap()
2140 }
2141
2142 #[test]
2143 fn alter_column_delta_bumps_schema_id_like_the_engine() {
2144 let mut catalog = catalog_with_table();
2145 let mut column = catalog.live("t").unwrap().schema.columns[1].clone();
2146 column.flags = column.flags.with(crate::schema::ColumnFlags::NULLABLE);
2147 let record = CatalogCommandRecord::next(
2148 &catalog,
2149 CatalogCommand::AlterColumn {
2150 table: "t".into(),
2151 column: column.clone(),
2152 },
2153 );
2154 let delta = catalog.apply_command(&record).unwrap();
2155 let CatalogDelta::SchemaReplaced { table_id, schema } = delta else {
2158 panic!("expected SchemaReplaced");
2159 };
2160 assert_eq!(table_id, 0);
2161 assert_eq!(schema.schema_id, 1);
2162 assert_eq!(schema.columns[1], column);
2163 assert_eq!(catalog.live("t").unwrap().schema.schema_id, 1);
2164 let mut unknown = column;
2166 unknown.id = 99;
2167 assert!(matches!(
2168 apply(
2169 &catalog,
2170 &CatalogCommand::AlterColumn {
2171 table: "t".into(),
2172 column: unknown,
2173 },
2174 ),
2175 Err(MongrelError::ColumnNotFound(_))
2176 ));
2177 }
2178
2179 #[test]
2180 fn replace_trigger_upserts_and_validates_references() {
2181 let mut catalog = catalog_with_table();
2182 let record = CatalogCommandRecord::next(
2184 &catalog,
2185 CatalogCommand::ReplaceTrigger {
2186 trigger: replacement_trigger("trg"),
2187 },
2188 );
2189 let delta = catalog.apply_command(&record).unwrap();
2190 assert!(matches!(delta, CatalogDelta::TriggerUpserted(_)));
2191 assert_eq!(catalog.triggers.len(), 1);
2192 let mut resolved = replacement_trigger("trg");
2195 resolved.version = 2;
2196 resolved.updated_epoch = 7;
2197 let record = CatalogCommandRecord::next(
2198 &catalog,
2199 CatalogCommand::ReplaceTrigger {
2200 trigger: resolved.clone(),
2201 },
2202 );
2203 catalog.apply_command(&record).unwrap();
2204 assert_eq!(catalog.triggers.len(), 1);
2205 assert_eq!(catalog.triggers[0].trigger.version, 2);
2206 assert_eq!(catalog.triggers[0].trigger.updated_epoch, 7);
2207 let mut dangling = replacement_trigger("trg2");
2209 dangling.target = TriggerTarget::Table("missing".into());
2210 assert!(matches!(
2211 apply(
2212 &catalog,
2213 &CatalogCommand::ReplaceTrigger { trigger: dangling }
2214 ),
2215 Err(MongrelError::InvalidArgument(_))
2216 ));
2217 assert_eq!(
2218 required_permission(&CatalogCommand::ReplaceTrigger {
2219 trigger: replacement_trigger("trg"),
2220 }),
2221 Permission::Ddl
2222 );
2223 }
2224
2225 #[test]
2226 fn replace_procedure_upserts_and_validates_references() {
2227 let mut catalog = catalog_with_table();
2228 let record = CatalogCommandRecord::next(
2229 &catalog,
2230 CatalogCommand::ReplaceProcedure {
2231 procedure: replacement_procedure("proc"),
2232 },
2233 );
2234 let delta = catalog.apply_command(&record).unwrap();
2235 assert!(matches!(delta, CatalogDelta::ProcedureUpserted(_)));
2236 assert_eq!(catalog.procedures.len(), 1);
2237 let mut resolved = replacement_procedure("proc");
2240 resolved.version = 3;
2241 resolved.updated_epoch = 9;
2242 let record = CatalogCommandRecord::next(
2243 &catalog,
2244 CatalogCommand::ReplaceProcedure {
2245 procedure: resolved.clone(),
2246 },
2247 );
2248 catalog.apply_command(&record).unwrap();
2249 assert_eq!(catalog.procedures.len(), 1);
2250 assert_eq!(catalog.procedures[0].procedure.version, 3);
2251 assert_eq!(catalog.procedures[0].procedure.updated_epoch, 9);
2252 assert_eq!(
2253 required_permission(&CatalogCommand::ReplaceProcedure {
2254 procedure: replacement_procedure("proc"),
2255 }),
2256 Permission::Ddl
2257 );
2258 }
2259
2260 #[test]
2261 fn serde_appended_variants_decode_older_records() {
2262 let legacy = br#"{"version":1,"catalog_version":1,"command":{"DropTrigger":{"name":"t"}}}"#;
2265 let record = decode_command(legacy).unwrap();
2266 assert!(matches!(
2267 record.command,
2268 CatalogCommand::DropTrigger { ref name } if name == "t"
2269 ));
2270 let record = CatalogCommandRecord {
2272 version: CATALOG_COMMAND_FORMAT_VERSION,
2273 catalog_version: 2,
2274 command: CatalogCommand::ReplaceTrigger {
2275 trigger: replacement_trigger("trg"),
2276 },
2277 };
2278 let bytes = encode_command(&record).unwrap();
2279 let decoded = decode_command(&bytes).unwrap();
2280 assert_eq!(encode_command(&decoded).unwrap(), bytes);
2281 }
2282}