diff --git a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PermissionsTest.java b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PermissionsTest.java index b30749cd28..bca8c0c554 100644 --- a/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PermissionsTest.java +++ b/db-tests/src/test/java/gov/nasa/jpl/aerie/database/PermissionsTest.java @@ -22,6 +22,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class PermissionsTest { private static final File initSqlScriptFile = new File("../merlin-server/sql/merlin/init.sql"); + private static final String testRole = "testRole"; private enum FunctionPermissionKey { apply_preset, begin_merge, @@ -71,22 +72,12 @@ void beforeEach() throws SQLException { void afterEach() throws SQLException { helper.clearTable("uploaded_file"); helper.clearTable("mission_model"); + helper.clearTable("activity_type"); helper.clearTable("plan"); + helper.clearTable("plan_collaborators"); helper.clearTable("activity_directive"); - helper.clearTable("simulation_template"); helper.clearTable("simulation"); helper.clearTable("dataset"); - helper.clearTable("plan_dataset"); - helper.clearTable("simulation_dataset"); - helper.clearTable("plan_snapshot"); - helper.clearTable("plan_latest_snapshot"); - helper.clearTable("plan_snapshot_activities"); - helper.clearTable("plan_snapshot_parent"); - helper.clearTable("merge_request"); - helper.clearTable("merge_staging_area"); - helper.clearTable("conflicting_activities"); - helper.clearTable("anchor_validation_status"); - helper.clearTable("plan_collaborators"); } @BeforeAll @@ -99,6 +90,7 @@ void beforeAll() throws SQLException, IOException, InterruptedException { helper.startDatabase(); connection = helper.connection(); merlinHelper = new MerlinDatabaseTestHelper(connection); + insertUserRole(testRole); } @AfterAll @@ -161,6 +153,54 @@ private void checkMergePermissions( """.formatted(permission, planIdReceiving, planIdSupplying, username)); } } + + private void insertUserRole(String role) throws SQLException { + try(final var statement = connection.createStatement()){ + statement.execute( + //language=sql + """ + insert into metadata.user_roles(role, description) + values ('%s', 'A role created during DBTests'); + """.formatted(role)); + } + } + + private void updateUserRolePermissions( + String role, + String actionPermissionsJson, + String functionPermissionsJson) + throws SQLException { + try(final var statement = connection.createStatement()){ + if(actionPermissionsJson == null){ + statement.execute( + //language=sql + """ + update metadata.user_role_permission + set function_permissions = '%s'::jsonb + where role = '%s'; + """.formatted(functionPermissionsJson, role)); + } else if (functionPermissionsJson == null) { + statement.execute( + //language=sql + """ + update metadata.user_role_permission + set action_permissions = '%s'::jsonb + where role = '%s'; + """.formatted(actionPermissionsJson, role)); + } + else { + statement.execute( + //language=sql + """ + update metadata.user_role_permission + set action_permissions = '%s'::jsonb, + function_permissions = '%s'::jsonb + where role = '%s'; + """.formatted(actionPermissionsJson, functionPermissionsJson, role)); + } + + } + } //endregion @Test @@ -747,4 +787,237 @@ void testPlanOwnerCollaboratorTarget() throws SQLException { } } } + + @Nested + class UserRolePermissionsValidation{ + private final String invalidJsonTextErrCode = "22032"; + @Test + void emptyIsValid() { + // Action + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, "{}", null)); + // Function + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, null, "{}")); + // Both + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, "{}", "{}")); + } + + @Test + void nonsenseKeys() throws SQLException { + // Nonsense Values for Keys are caught + final String actionPermissions = "{\"fake_action_key\": \"NO_CHECK\"}"; + final String functionPermissions = "{\"fake_function_key\": \"NO_CHECK\"}"; + // Action + final String actionExceptionMsg = + """ + ERROR: invalid keys in supplied row + Detail: The following action keys are not valid: fake_action_key + Hint: + """.trim(); + final var actionException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, null)); + if( !actionException.getMessage().contains(actionExceptionMsg) + || !actionException.getSQLState().equals(invalidJsonTextErrCode)){ + throw actionException; + } + + // Function + final String fnExceptionMsg = + """ + ERROR: invalid keys in supplied row + Detail: The following function keys are not valid: fake_function_key + Hint: + """.trim(); + final var fnException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, null, functionPermissions)); + if(!fnException.getMessage().contains(fnExceptionMsg) + || !fnException.getSQLState().equals(invalidJsonTextErrCode)){ + throw fnException; + } + // Both + final String bothExceptionMsg = + """ + ERROR: invalid keys in supplied row + Detail: The following action keys are not valid: fake_action_key + The following function keys are not valid: fake_function_key + Hint: + """.trim(); + final var bothException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, functionPermissions)); + if(!bothException.getMessage().contains(bothExceptionMsg) + || !bothException.getSQLState().equals(invalidJsonTextErrCode)){ + throw bothException; + } + } + + @Test + void nonsensePermissions() throws SQLException { + // Nonsense Values for Permissions are caught + final String actionPermissions = "{\"simulate\": \"NONSENSE_PERMISSION\"}"; + final String functionPermissions = "{\"begin_merge\": \"NONSENSE_PERMISSION\"}"; + // Action + final String actionExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following action keys have invalid permissions: {simulate: NONSENSE_PERMISSION} + Hint: + """.trim(); + final var actionException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, null)); + if( !actionException.getMessage().contains(actionExceptionMsg) + || !actionException.getSQLState().equals(invalidJsonTextErrCode)){ + throw actionException; + } + + // Function + final String fnExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following function keys have invalid permissions: {begin_merge: NONSENSE_PERMISSION} + Hint: + """.trim(); + final var fnException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, null, functionPermissions)); + if(!fnException.getMessage().contains(fnExceptionMsg) + || !fnException.getSQLState().equals(invalidJsonTextErrCode)){ + throw fnException; + } + // Both + final String bothExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following action keys have invalid permissions: {simulate: NONSENSE_PERMISSION} + The following function keys have invalid permissions: {begin_merge: NONSENSE_PERMISSION} + Hint: + """.trim(); + final var bothException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, functionPermissions)); + if(!bothException.getMessage().contains(bothExceptionMsg) + || !bothException.getSQLState().equals(invalidJsonTextErrCode)){ + throw bothException; + } + } + + @Test + void invalidPermissions() throws SQLException { + // Improperly applied Plan Merge Permissions are caught + final String actionPermissions = "{\"simulate\": \"PLAN_OWNER_SOURCE\"}"; + final String functionPermissions = "{\"apply_preset\": \"PLAN_OWNER_TARGET\"}"; + // Action + final String actionExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following action keys may not take plan merge permissions: {simulate: PLAN_OWNER_SOURCE} + Hint: + """.trim(); + final var actionException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, null)); + if( !actionException.getMessage().contains(actionExceptionMsg) + || !actionException.getSQLState().equals(invalidJsonTextErrCode)){ + throw actionException; + } + + // Function + final String fnExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following function keys may not take plan merge permissions: {apply_preset: PLAN_OWNER_TARGET} + Hint: + """.trim(); + final var fnException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, null, functionPermissions)); + if(!fnException.getMessage().contains(fnExceptionMsg) + || !fnException.getSQLState().equals(invalidJsonTextErrCode)){ + throw fnException; + } + // Both + final String bothExceptionMsg = + """ + ERROR: invalid permissions in supplied row + Detail: The following action keys may not take plan merge permissions: {simulate: PLAN_OWNER_SOURCE} + The following function keys may not take plan merge permissions: {apply_preset: PLAN_OWNER_TARGET} + Hint: + """.trim(); + final var bothException = assertThrows(SQLException.class, + () -> updateUserRolePermissions(testRole, actionPermissions, functionPermissions)); + if(!bothException.getMessage().contains(bothExceptionMsg) + || !bothException.getSQLState().equals(invalidJsonTextErrCode)){ + throw bothException; + } + } + + @Test + void incompleteIsValid() { + // Updates Viewer Role Permissions, which is an incomplete set + final String actionPermissions = """ + { + "sequence_seq_json_bulk": "NO_CHECK", + "resource_samples": "NO_CHECK" + } + """; + final String functionPermissions = """ + { + "get_conflicting_activities": "NO_CHECK", + "get_non_conflicting_activities": "NO_CHECK", + "get_plan_history": "NO_CHECK" + } + """; + // Action + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, actionPermissions, null)); + // Function + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, null, functionPermissions)); + // Both + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, actionPermissions, functionPermissions)); + } + + @Test + void completeIsValid() { + // Uses User Role Permissions to check that a complete set is valid + // and that plan merge permissions on plan merge functions is valid + final String actionPermissions = """ + { + "check_constraints": "PLAN_OWNER_COLLABORATOR", + "create_expansion_rule": "NO_CHECK", + "create_expansion_set": "NO_CHECK", + "expand_all_activities": "NO_CHECK", + "insert_ext_dataset": "PLAN_OWNER", + "resource_samples": "NO_CHECK", + "schedule":"PLAN_OWNER_COLLABORATOR", + "sequence_seq_json_bulk": "NO_CHECK", + "simulate":"PLAN_OWNER_COLLABORATOR" + } + """; + final String functionPermissions = """ + { + "apply_preset": "PLAN_OWNER_COLLABORATOR", + "begin_merge": "PLAN_OWNER_TARGET", + "branch_plan": "NO_CHECK", + "cancel_merge": "PLAN_OWNER_TARGET", + "commit_merge": "PLAN_OWNER_TARGET", + "create_merge_rq": "PLAN_OWNER_SOURCE", + "create_snapshot": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_bulk": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_plan": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_plan_bulk": "PLAN_OWNER_COLLABORATOR", + "delete_activity_subtree": "PLAN_OWNER_COLLABORATOR", + "delete_activity_subtree_bulk": "PLAN_OWNER_COLLABORATOR", + "deny_merge": "PLAN_OWNER_TARGET", + "get_conflicting_activities": "NO_CHECK", + "get_non_conflicting_activities": "NO_CHECK", + "get_plan_history": "NO_CHECK", + "restore_activity_changelog": "PLAN_OWNER_COLLABORATOR", + "restore_snapshot": "PLAN_OWNER_COLLABORATOR", + "set_resolution": "PLAN_OWNER_TARGET", + "set_resolution_bulk": "PLAN_OWNER_TARGET", + "withdraw_merge_rq": "PLAN_OWNER_SOURCE" + } + """; + // Action + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, actionPermissions, null)); + // Function + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, null, functionPermissions)); + // Both + assertDoesNotThrow(() -> updateUserRolePermissions(testRole, actionPermissions, functionPermissions)); + } + } } diff --git a/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/down.sql b/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/down.sql new file mode 100644 index 0000000000..5a254686bc --- /dev/null +++ b/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/down.sql @@ -0,0 +1,5 @@ +drop trigger validate_permissions_trigger + on metadata.user_role_permission; +drop function metadata.validate_permissions_json(); + +call migrations.mark_migration_rolled_back('28'); diff --git a/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/up.sql b/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/up.sql new file mode 100644 index 0000000000..228c0eb3c5 --- /dev/null +++ b/deployment/hasura/migrations/AerieMerlin/28_validate_user_role_permissions/up.sql @@ -0,0 +1,145 @@ +create function metadata.validate_permissions_json() +returns trigger +language plpgsql as $$ + declare + error_msg text; + plan_merge_fns text[]; +begin + error_msg = ''; + + plan_merge_fns := '{ + "begin_merge", + "cancel_merge", + "commit_merge", + "create_merge_rq", + "deny_merge", + "get_conflicting_activities", + "get_non_conflicting_activities", + "set_resolution", + "set_resolution_bulk", + "withdraw_merge_rq" + }'; + + -- Do all the validation checks up front + -- Duplicate keys are not checked for, as as all but the last instance is removed + -- during conversion of JSON Text to JSONB (https://www.postgresql.org/docs/14/datatype-json.html) + create temp table _validate_functions_table as + select + jsonb_object_keys(new.function_permissions) as function_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) as function_permission, + jsonb_object_keys(new.function_permissions) = any(enum_range(null::metadata.function_permission_key)::text[]) as valid_function_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) = any(enum_range(null::metadata.permission)::text[]) as valid_function_permission, + jsonb_object_keys(new.function_permissions) = any(plan_merge_fns) as is_plan_merge_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) = any(enum_range('PLAN_OWNER_SOURCE'::metadata.permission, 'PLAN_OWNER_COLLABORATOR_TARGET'::metadata.permission)::text[]) as is_plan_merge_permission; + + create temp table _validate_actions_table as + select + jsonb_object_keys(new.action_permissions) as action_key, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) as action_permission, + jsonb_object_keys(new.action_permissions) = any(enum_range(null::metadata.action_permission_key)::text[]) as valid_action_key, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) = any(enum_range(null::metadata.permission)::text[]) as valid_action_permission, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) = any(enum_range('PLAN_OWNER_SOURCE'::metadata.permission, 'PLAN_OWNER_COLLABORATOR_TARGET'::metadata.permission)::text[]) as is_plan_merge_permission; + + + -- Get any invalid Action Keys + if exists(select from _validate_actions_table where not valid_action_key) + then + error_msg = 'The following action keys are not valid: ' + || (select string_agg(action_key, ', ') + from _validate_actions_table + where not valid_action_key) + ||e'\n'; + end if; + -- Get any invalid Function Keys + if exists(select from _validate_functions_table where not valid_function_key) + then + error_msg = error_msg + || 'The following function keys are not valid: ' + || (select string_agg(function_key, ', ') + from _validate_functions_table + where not valid_function_key); + end if; + + -- Raise if there were invalid Action/Function Keys + if error_msg != '' then + raise exception using + message = 'invalid keys in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for a list of valid keys.'; + end if; + + -- Get any values that aren't Action Permissions + if exists(select from _validate_actions_table where not valid_action_permission) + then + error_msg = 'The following action keys have invalid permissions: {' + || (select string_agg(action_key || ': ' || action_permission, ', ') + from _validate_actions_table + where not valid_action_permission) + ||e'}\n'; + end if; + + -- Get any values that aren't Function Permissions + if exists(select from _validate_functions_table where not valid_function_permission) + then + error_msg = error_msg + || 'The following function keys have invalid permissions: {' + || (select string_agg(function_key || ': ' || function_permission, ', ') + from _validate_functions_table + where not valid_function_permission) + || '}'; + end if; + + -- Raise if there were invalid Action/Function Permissions + if error_msg != '' then + raise exception using + message = 'invalid permissions in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for a list of valid Permissions.'; + end if; + + -- Check that no Actions have Plan Merge Permissions + if exists(select from _validate_actions_table where is_plan_merge_permission) + then + error_msg = 'The following action keys may not take plan merge permissions: {' + || (select string_agg(action_key || ': ' || action_permission, ', ') + from _validate_actions_table + where is_plan_merge_permission) + ||e'}\n'; + end if; + + -- Check that no non-Plan Merge Functions have Plan Merge Permissions + if exists(select from _validate_functions_table where is_plan_merge_permission and not is_plan_merge_key) + then + error_msg = error_msg + || 'The following function keys may not take plan merge permissions: {' + || (select string_agg(function_key || ': ' || function_permission, ', ') + from _validate_functions_table + where is_plan_merge_permission and not is_plan_merge_key) + || '}'; + end if; + + -- Raise if Plan Merge Permissions were improperly applied + if error_msg != '' then + raise exception using + message = 'invalid permissions in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for more information.'; + end if; + + -- Drop Temp Tables + drop table _validate_functions_table; + drop table _validate_actions_table; + + return new; +end +$$; + +create trigger validate_permissions_trigger + before insert or update on metadata.user_role_permission + for each row + execute function metadata.validate_permissions_json(); + +call migrations.mark_migration_applied('28'); diff --git a/merlin-server/sql/merlin/applied_migrations.sql b/merlin-server/sql/merlin/applied_migrations.sql index 63c2754eaa..b71dbc02ea 100644 --- a/merlin-server/sql/merlin/applied_migrations.sql +++ b/merlin-server/sql/merlin/applied_migrations.sql @@ -30,3 +30,4 @@ call migrations.mark_migration_applied('24'); call migrations.mark_migration_applied('25'); call migrations.mark_migration_applied('26'); call migrations.mark_migration_applied('27'); +call migrations.mark_migration_applied('28'); diff --git a/merlin-server/sql/merlin/default_user_roles.sql b/merlin-server/sql/merlin/default_user_roles.sql new file mode 100644 index 0000000000..213bd2fc90 --- /dev/null +++ b/merlin-server/sql/merlin/default_user_roles.sql @@ -0,0 +1,64 @@ +-- Default Roles: +insert into metadata.user_roles(role) values ('aerie_admin'), ('user'), ('viewer'); + +-- Permissions For Default Roles: +-- 'aerie_admin' permissions aren't specified since 'aerie_admin' is always considered to have "NO_CHECK" permissions +update metadata.user_role_permission +set action_permissions = '{}', + function_permissions = '{}' +where role = 'admin'; + +update metadata.user_role_permission +set action_permissions = '{ + "check_constraints": "PLAN_OWNER_COLLABORATOR", + "create_expansion_rule": "NO_CHECK", + "create_expansion_set": "NO_CHECK", + "expand_all_activities": "NO_CHECK", + "insert_ext_dataset": "PLAN_OWNER", + "resource_samples": "NO_CHECK", + "schedule":"PLAN_OWNER_COLLABORATOR", + "sequence_seq_json_bulk": "NO_CHECK", + "simulate":"PLAN_OWNER_COLLABORATOR" + }', + function_permissions = '{ + "apply_preset": "PLAN_OWNER_COLLABORATOR", + "begin_merge": "PLAN_OWNER_TARGET", + "branch_plan": "NO_CHECK", + "cancel_merge": "PLAN_OWNER_TARGET", + "commit_merge": "PLAN_OWNER_TARGET", + "create_merge_rq": "PLAN_OWNER_SOURCE", + "create_snapshot": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_bulk": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_plan": "PLAN_OWNER_COLLABORATOR", + "delete_activity_reanchor_plan_bulk": "PLAN_OWNER_COLLABORATOR", + "delete_activity_subtree": "PLAN_OWNER_COLLABORATOR", + "delete_activity_subtree_bulk": "PLAN_OWNER_COLLABORATOR", + "deny_merge": "PLAN_OWNER_TARGET", + "get_conflicting_activities": "NO_CHECK", + "get_non_conflicting_activities": "NO_CHECK", + "get_plan_history": "NO_CHECK", + "restore_activity_changelog": "PLAN_OWNER_COLLABORATOR", + "restore_snapshot": "PLAN_OWNER_COLLABORATOR", + "set_resolution": "PLAN_OWNER_TARGET", + "set_resolution_bulk": "PLAN_OWNER_TARGET", + "withdraw_merge_rq": "PLAN_OWNER_SOURCE" + }' +where role = 'user'; + +update metadata.user_role_permission +set action_permissions = '{ + "sequence_seq_json_bulk": "NO_CHECK", + "resource_samples": "NO_CHECK" + }', + function_permissions = '{ + "get_conflicting_activities": "NO_CHECK", + "get_non_conflicting_activities": "NO_CHECK", + "get_plan_history": "NO_CHECK" + }' +where role = 'viewer'; + +-- Default Users: +insert into metadata.users(username, default_role) + values ('Mission Model', 'viewer'), + ('Aerie Legacy', 'viewer'); diff --git a/merlin-server/sql/merlin/domain-types/permissions.sql b/merlin-server/sql/merlin/domain-types/permissions.sql index 630d89b2d5..9a6fd62d40 100644 --- a/merlin-server/sql/merlin/domain-types/permissions.sql +++ b/merlin-server/sql/merlin/domain-types/permissions.sql @@ -1,3 +1,5 @@ +-- User Role Permissions Validation assumes that the Plan Merge Permissions +-- are covered by the range [PLAN_OWNER_SOURCE - PLAN_OWNER_COLLABORATOR_TARGET] create type metadata.permission as enum ( 'NO_CHECK', diff --git a/merlin-server/sql/merlin/init.sql b/merlin-server/sql/merlin/init.sql index bd2a17fc57..be3c06a929 100644 --- a/merlin-server/sql/merlin/init.sql +++ b/merlin-server/sql/merlin/init.sql @@ -107,4 +107,7 @@ begin; \ir functions/hasura/plan_branching_functions.sql \ir functions/hasura/plan_merge_functions.sql + -- Preload Data + \ir default_user_roles.sql; + end; diff --git a/merlin-server/sql/merlin/tables/metadata/user_role_permission.sql b/merlin-server/sql/merlin/tables/metadata/user_role_permission.sql index 5f7e1f2083..46f66d78bc 100644 --- a/merlin-server/sql/merlin/tables/metadata/user_role_permission.sql +++ b/merlin-server/sql/merlin/tables/metadata/user_role_permission.sql @@ -18,54 +18,146 @@ comment on column metadata.user_role_permission.action_permissions is '' comment on column metadata.user_role_permission.function_permissions is '' 'The permissions the role has on Hasura Functions.'; --- Permissions For Default Roles: --- 'aerie_admin' permissions aren't specified since 'aerie_admin' is always considered to have "NO_CHECK" permissions -insert into metadata.user_role_permission(role, action_permissions, function_permissions) -values - ('aerie_admin', '{}', '{}'), - ('user', - '{ - "check_constraints": "PLAN_OWNER_COLLABORATOR", - "create_expansion_rule": "NO_CHECK", - "create_expansion_set": "NO_CHECK", - "expand_all_activities": "NO_CHECK", - "insert_ext_dataset": "PLAN_OWNER", - "resource_samples": "NO_CHECK", - "schedule":"PLAN_OWNER_COLLABORATOR", - "sequence_seq_json_bulk": "NO_CHECK", - "simulate":"PLAN_OWNER_COLLABORATOR" - }', - '{ - "apply_preset": "PLAN_OWNER_COLLABORATOR", - "begin_merge": "PLAN_OWNER_TARGET", - "branch_plan": "NO_CHECK", - "cancel_merge": "PLAN_OWNER_TARGET", - "commit_merge": "PLAN_OWNER_TARGET", - "create_merge_rq": "PLAN_OWNER_SOURCE", - "create_snapshot": "PLAN_OWNER_COLLABORATOR", - "delete_activity_reanchor": "PLAN_OWNER_COLLABORATOR", - "delete_activity_reanchor_bulk": "PLAN_OWNER_COLLABORATOR", - "delete_activity_reanchor_plan": "PLAN_OWNER_COLLABORATOR", - "delete_activity_reanchor_plan_bulk": "PLAN_OWNER_COLLABORATOR", - "delete_activity_subtree": "PLAN_OWNER_COLLABORATOR", - "delete_activity_subtree_bulk": "PLAN_OWNER_COLLABORATOR", - "deny_merge": "PLAN_OWNER_TARGET", - "get_conflicting_activities": "NO_CHECK", - "get_non_conflicting_activities": "NO_CHECK", - "get_plan_history": "NO_CHECK", - "restore_activity_changelog": "PLAN_OWNER_COLLABORATOR", - "restore_snapshot": "PLAN_OWNER_COLLABORATOR", - "set_resolution": "PLAN_OWNER_TARGET", - "set_resolution_bulk": "PLAN_OWNER_TARGET", - "withdraw_merge_rq": "PLAN_OWNER_SOURCE" - }' ), - ('viewer', - '{ - "sequence_seq_json_bulk": "NO_CHECK", - "resource_samples": "NO_CHECK" - }', - '{ - "get_conflicting_activities": "NO_CHECK", - "get_non_conflicting_activities": "NO_CHECK", - "get_plan_history": "NO_CHECK" - }'); +create function metadata.validate_permissions_json() +returns trigger +language plpgsql as $$ + declare + error_msg text; + plan_merge_fns text[]; +begin + error_msg = ''; + + plan_merge_fns := '{ + "begin_merge", + "cancel_merge", + "commit_merge", + "create_merge_rq", + "deny_merge", + "get_conflicting_activities", + "get_non_conflicting_activities", + "set_resolution", + "set_resolution_bulk", + "withdraw_merge_rq" + }'; + + -- Do all the validation checks up front + -- Duplicate keys are not checked for, as as all but the last instance is removed + -- during conversion of JSON Text to JSONB (https://www.postgresql.org/docs/14/datatype-json.html) + create temp table _validate_functions_table as + select + jsonb_object_keys(new.function_permissions) as function_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) as function_permission, + jsonb_object_keys(new.function_permissions) = any(enum_range(null::metadata.function_permission_key)::text[]) as valid_function_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) = any(enum_range(null::metadata.permission)::text[]) as valid_function_permission, + jsonb_object_keys(new.function_permissions) = any(plan_merge_fns) as is_plan_merge_key, + new.function_permissions ->> jsonb_object_keys(new.function_permissions) = any(enum_range('PLAN_OWNER_SOURCE'::metadata.permission, 'PLAN_OWNER_COLLABORATOR_TARGET'::metadata.permission)::text[]) as is_plan_merge_permission; + + create temp table _validate_actions_table as + select + jsonb_object_keys(new.action_permissions) as action_key, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) as action_permission, + jsonb_object_keys(new.action_permissions) = any(enum_range(null::metadata.action_permission_key)::text[]) as valid_action_key, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) = any(enum_range(null::metadata.permission)::text[]) as valid_action_permission, + new.action_permissions ->> jsonb_object_keys(new.action_permissions) = any(enum_range('PLAN_OWNER_SOURCE'::metadata.permission, 'PLAN_OWNER_COLLABORATOR_TARGET'::metadata.permission)::text[]) as is_plan_merge_permission; + + + -- Get any invalid Action Keys + if exists(select from _validate_actions_table where not valid_action_key) + then + error_msg = 'The following action keys are not valid: ' + || (select string_agg(action_key, ', ') + from _validate_actions_table + where not valid_action_key) + ||e'\n'; + end if; + -- Get any invalid Function Keys + if exists(select from _validate_functions_table where not valid_function_key) + then + error_msg = error_msg + || 'The following function keys are not valid: ' + || (select string_agg(function_key, ', ') + from _validate_functions_table + where not valid_function_key); + end if; + + -- Raise if there were invalid Action/Function Keys + if error_msg != '' then + raise exception using + message = 'invalid keys in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for a list of valid keys.'; + end if; + + -- Get any values that aren't Action Permissions + if exists(select from _validate_actions_table where not valid_action_permission) + then + error_msg = 'The following action keys have invalid permissions: {' + || (select string_agg(action_key || ': ' || action_permission, ', ') + from _validate_actions_table + where not valid_action_permission) + ||e'}\n'; + end if; + + -- Get any values that aren't Function Permissions + if exists(select from _validate_functions_table where not valid_function_permission) + then + error_msg = error_msg + || 'The following function keys have invalid permissions: {' + || (select string_agg(function_key || ': ' || function_permission, ', ') + from _validate_functions_table + where not valid_function_permission) + || '}'; + end if; + + -- Raise if there were invalid Action/Function Permissions + if error_msg != '' then + raise exception using + message = 'invalid permissions in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for a list of valid Permissions.'; + end if; + + -- Check that no Actions have Plan Merge Permissions + if exists(select from _validate_actions_table where is_plan_merge_permission) + then + error_msg = 'The following action keys may not take plan merge permissions: {' + || (select string_agg(action_key || ': ' || action_permission, ', ') + from _validate_actions_table + where is_plan_merge_permission) + ||e'}\n'; + end if; + + -- Check that no non-Plan Merge Functions have Plan Merge Permissions + if exists(select from _validate_functions_table where is_plan_merge_permission and not is_plan_merge_key) + then + error_msg = error_msg + || 'The following function keys may not take plan merge permissions: {' + || (select string_agg(function_key || ': ' || function_permission, ', ') + from _validate_functions_table + where is_plan_merge_permission and not is_plan_merge_key) + || '}'; + end if; + + -- Raise if Plan Merge Permissions were improperly applied + if error_msg != '' then + raise exception using + message = 'invalid permissions in supplied row', + detail = trim(both e'\n' from error_msg), + errcode = 'invalid_json_text', + hint = 'Visit https://nasa-ammos.github.io/aerie-docs/deployment/advanced-permissions/#action-and-function-permissions for more information.'; + end if; + + -- Drop Temp Tables + drop table _validate_functions_table; + drop table _validate_actions_table; + + return new; +end +$$; + +create trigger validate_permissions_trigger + before insert or update on metadata.user_role_permission + for each row + execute function metadata.validate_permissions_json(); diff --git a/merlin-server/sql/merlin/tables/metadata/user_roles.sql b/merlin-server/sql/merlin/tables/metadata/user_roles.sql index 1789c72bce..dc27702435 100644 --- a/merlin-server/sql/merlin/tables/metadata/user_roles.sql +++ b/merlin-server/sql/merlin/tables/metadata/user_roles.sql @@ -3,7 +3,6 @@ create table metadata.user_roles( role text primary key, description text null ); -insert into metadata.user_roles(role) values ('aerie_admin'), ('user'), ('viewer'); comment on table metadata.user_roles is e'' 'A list of all the allowed Hasura roles, with an optional description per role'; diff --git a/merlin-server/sql/merlin/tables/metadata/users.sql b/merlin-server/sql/merlin/tables/metadata/users.sql index 06e3dccd38..1ab737cc4d 100644 --- a/merlin-server/sql/merlin/tables/metadata/users.sql +++ b/merlin-server/sql/merlin/tables/metadata/users.sql @@ -4,11 +4,6 @@ create table metadata.users( on update cascade on delete restrict ); --- Insert the default roles into the table, then change the generated status to "Always" --- This can be changed back if we need to add more default users in the future -insert into metadata.users(username, default_role) - values ('Mission Model', 'viewer'), - ('Aerie Legacy', 'viewer'); comment on table metadata.users is e'' 'All users recognized by this deployment.';