From a344b3225beaf0e23fe067d8af983aaac55119e1 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 15 May 2025 11:33:08 +0200 Subject: [PATCH] [MM-61756] Attribute Based Access Control - Phase 1 (#30785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attribute Based Access Control - Base * MM-63662 * MM-63919 * MM-63954 * MM-63955 * MM-63425 * MM-63426 * MM-63458 * MM-63459 * MM-63603 * MM-63845 * MM-64146 * MM-64199 * MM-64201 * MM-64233 * MM-64247 * MM-64268 --------- Co-authored-by: Harshil Sharma Co-authored-by: Pablo Andrés Vélez Vidal Co-authored-by: abhijit-singh Co-authored-by: Harrison Healey --- api/Makefile | 1 + api/v4/source/access_control.yaml | 524 +++++++++++++++ api/v4/source/definitions.yaml | 177 ++++- .../compliance/compliance_export_ui_spec.js | 20 +- .../system_console/compliance/helpers.js | 4 +- .../tests/support/ui/compliance_export.js | 2 +- server/Makefile | 2 +- server/channels/api4/access_control.go | 515 +++++++++++++++ server/channels/api4/access_control_local.go | 27 + server/channels/api4/access_control_test.go | 613 ++++++++++++++++++ server/channels/api4/api.go | 11 + server/channels/api4/channel.go | 69 +- server/channels/app/access_control.go | 293 +++++++++ server/channels/app/access_control_test.go | 455 +++++++++++++ server/channels/app/channel.go | 109 +++- server/channels/app/channels.go | 18 + server/channels/app/enterprise.go | 12 + server/channels/app/job.go | 6 + server/channels/app/platform/enterprise.go | 6 +- server/channels/app/platform/service.go | 4 +- server/channels/app/server.go | 5 + server/channels/app/user.go | 20 + server/channels/db/migrations/migrations.list | 4 + .../000136_create_attribute_view.down.sql | 1 + .../mysql/000136_create_attribute_view.up.sql | 11 + .../000136_create_attribute_view.down.sql | 1 + .../000136_create_attribute_view.up.sql | 37 ++ .../channels/store/retrylayer/retrylayer.go | 137 +++- .../store/retrylayer/retrylayer_test.go | 1 + .../sqlstore/access_control_policy_store.go | 284 ++++++-- .../store/sqlstore/attributes_store.go | 253 ++++++++ .../store/sqlstore/attributes_store_test.go | 14 + .../channels/store/sqlstore/channel_store.go | 89 ++- server/channels/store/sqlstore/store.go | 6 + server/channels/store/store.go | 63 +- .../storetest/access_control_policy_store.go | 41 +- .../store/storetest/attributes_store.go | 282 ++++++++ .../mocks/AccessControlPolicyStore.go | 69 +- .../store/storetest/mocks/AttributesStore.go | 145 +++++ .../channels/store/storetest/mocks/Store.go | 20 + server/channels/store/storetest/store.go | 5 + .../channels/store/timerlayer/timerlayer.go | 107 ++- server/channels/web/params.go | 164 ++--- server/einterfaces/access_control.go | 12 + server/einterfaces/jobs/access_control.go | 13 + server/einterfaces/metrics.go | 2 +- .../mocks/AccessControlServiceInterface.go | 402 ++++++++++++ .../mocks/AccessControlSyncJobInterface.go | 71 ++ server/einterfaces/mocks/MetricsInterface.go | 10 +- .../PolicyAdministrationPointInterface.go | 372 +++++++++++ .../mocks/PolicyDecisionPointInterface.go | 12 +- server/einterfaces/pap.go | 42 ++ server/einterfaces/pdp.go | 2 +- server/enterprise/external_imports.go | 2 + server/enterprise/metrics/metrics.go | 49 +- server/go.mod | 8 +- server/go.sum | 20 +- server/i18n/en.json | 116 ++++ .../platform/services/telemetry/telemetry.go | 6 + server/public/model/access_policy.go | 114 +++- server/public/model/access_request.go | 31 +- server/public/model/cel.go | 30 + server/public/model/channel.go | 44 +- server/public/model/channel_search.go | 33 +- server/public/model/client4.go | 200 ++++++ server/public/model/feature_flags.go | 3 + server/public/model/job.go | 1 + webapp/channels/package.json | 2 + .../access_control/__mocks__/monaco-editor.ts | 17 + .../__snapshots__/policies.test.tsx.snap | 274 ++++++++ .../editors/cel_editor/editor.scss | 257 ++++++++ .../editors/cel_editor/editor.tsx | 363 +++++++++++ .../editors/cel_editor/language_provider.tsx | 275 ++++++++ .../access_control/editors/shared.scss | 55 ++ .../access_control/editors/shared.tsx | 78 +++ .../table_editor/attribute_selector_menu.tsx | 104 +++ .../table_editor/operator_selector_menu.tsx | 239 +++++++ .../editors/table_editor/selector_menus.scss | 46 ++ .../editors/table_editor/table_editor.scss | 174 +++++ .../editors/table_editor/table_editor.tsx | 317 +++++++++ .../editors/table_editor/table_row.tsx | 8 + .../editors/table_editor/values_editor.scss | 78 +++ .../editors/table_editor/values_editor.tsx | 122 ++++ .../admin_console/access_control/index.ts | 19 + .../jobs/access_control_sync_job_table.scss | 77 +++ .../jobs/access_control_sync_job_table.tsx | 115 ++++ .../access_control/jobs/index.ts | 19 + .../modals/cel_help/cel_help_modal.scss | 39 ++ .../modals/cel_help/cel_help_modal.tsx | 85 +++ .../confirmation/confirmation_modal.scss | 70 ++ .../confirmation/confirmation_modal.tsx | 106 +++ .../modals/job_details/job_details_modal.scss | 103 +++ .../modals/job_details/job_details_modal.tsx | 238 +++++++ .../searchable_sync_job_channel_list.tsx | 313 +++++++++ .../policy_selection_modal.tsx | 56 ++ .../modals/policy_test/test_modal.scss | 4 + .../modals/policy_test/test_modal.tsx | 120 ++++ .../modals/user_sync/synced_user_list.tsx | 107 +++ .../modals/user_sync/user_sync_modal.scss | 56 ++ .../modals/user_sync/user_sync_modal.tsx | 107 +++ .../access_control/policies.scss | 166 +++++ .../access_control/policies.test.tsx | 106 +++ .../admin_console/access_control/policies.tsx | 359 ++++++++++ .../policy_details.test.tsx.snap | 346 ++++++++++ .../__snapshots__/channel_list.test.tsx.snap | 347 ++++++++++ .../channel_list/channel_list.scss | 47 ++ .../channel_list/channel_list.test.tsx | 100 +++ .../channel_list/channel_list.tsx | 388 +++++++++++ .../policy_details/channel_list/index.ts | 83 +++ .../access_control/policy_details/index.ts | 52 ++ .../policy_details/policy_details.scss | 28 + .../policy_details/policy_details.test.tsx | 167 +++++ .../policy_details/policy_details.tsx | 585 +++++++++++++++++ .../admin_console/admin_definition.tsx | 115 ++++ .../__snapshots__/admin_sidebar.test.tsx.snap | 35 + .../admin_sidebar/admin_sidebar.test.tsx | 2 + .../attribute_based_access_control.tsx | 37 ++ .../components/admin_console/jobs/table.scss | 15 + .../admin_console/jobs/table.test.tsx | 2 +- .../components/admin_console/jobs/table.tsx | 149 ++++- .../admin_console/schema_admin_settings.scss | 5 + .../admin_console/schema_admin_settings.tsx | 24 +- .../channel_details.test.tsx.snap | 18 + .../__snapshots__/channel_modes.test.tsx.snap | 6 + .../channel_access_control_policy.scss | 80 +++ .../details/channel_access_control_policy.tsx | 148 +++++ .../channel/details/channel_details.test.tsx | 15 + .../channel/details/channel_details.tsx | 150 ++++- .../channel/details/channel_modes.test.tsx | 4 + .../channel/details/channel_modes.tsx | 78 ++- .../channel/details/index.ts | 13 +- .../__snapshots__/channel_list.test.tsx.snap | 168 +++-- .../channel/list/channel_list.tsx | 39 +- .../src/components/admin_console/types.ts | 3 + .../title_and_button_card_header.tsx | 33 +- .../channel_selector_modal.tsx | 13 +- .../searchable_user_list_container.tsx | 2 + .../src/components/widgets/tag/beta_tag.tsx | 7 +- .../src/components/widgets/tag/tag.tsx | 2 +- webapp/channels/src/i18n/en.json | 101 +++ .../src/action_types/admin.ts | 9 + .../src/actions/access_control.ts | 152 +++++ .../src/actions/channels.test.ts | 3 - .../mattermost-redux/src/actions/channels.ts | 8 +- .../src/reducers/entities/admin.ts | 65 ++ .../src/selectors/entities/access_control.ts | 63 ++ .../src/store/initial_state.ts | 2 + webapp/channels/src/utils/constants.tsx | 2 + webapp/channels/webpack.config.js | 35 + webapp/package-lock.json | 130 ++-- webapp/platform/client/src/client4.ts | 144 +++- webapp/platform/types/src/access_control.ts | 75 +++ webapp/platform/types/src/admin.ts | 5 +- webapp/platform/types/src/channels.ts | 4 + webapp/platform/types/src/config.ts | 1 + webapp/platform/types/src/jobs.ts | 2 +- 156 files changed, 14382 insertions(+), 621 deletions(-) create mode 100644 api/v4/source/access_control.yaml create mode 100644 server/channels/api4/access_control.go create mode 100644 server/channels/api4/access_control_local.go create mode 100644 server/channels/api4/access_control_test.go create mode 100644 server/channels/app/access_control.go create mode 100644 server/channels/app/access_control_test.go create mode 100644 server/channels/db/migrations/mysql/000136_create_attribute_view.down.sql create mode 100644 server/channels/db/migrations/mysql/000136_create_attribute_view.up.sql create mode 100644 server/channels/db/migrations/postgres/000136_create_attribute_view.down.sql create mode 100644 server/channels/db/migrations/postgres/000136_create_attribute_view.up.sql create mode 100644 server/channels/store/sqlstore/attributes_store.go create mode 100644 server/channels/store/sqlstore/attributes_store_test.go create mode 100644 server/channels/store/storetest/attributes_store.go create mode 100644 server/channels/store/storetest/mocks/AttributesStore.go create mode 100644 server/einterfaces/access_control.go create mode 100644 server/einterfaces/jobs/access_control.go create mode 100644 server/einterfaces/mocks/AccessControlServiceInterface.go create mode 100644 server/einterfaces/mocks/AccessControlSyncJobInterface.go create mode 100644 server/einterfaces/mocks/PolicyAdministrationPointInterface.go create mode 100644 server/einterfaces/pap.go create mode 100644 server/public/model/cel.go create mode 100644 webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts create mode 100644 webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/cel_editor/language_provider.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/shared.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/shared.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/index.ts create mode 100644 webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/jobs/index.ts create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/policy_selection/policy_selection_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/user_sync/synced_user_list.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policies.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/policies.test.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policies.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/__snapshots__/channel_list.test.tsx.snap create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.test.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/index.ts create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/index.ts create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.scss create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.test.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx create mode 100644 webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx create mode 100644 webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.scss create mode 100644 webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/access_control.ts create mode 100644 webapp/platform/types/src/access_control.ts diff --git a/api/Makefile b/api/Makefile index 3ca9684710..64c1a18691 100644 --- a/api/Makefile +++ b/api/Makefile @@ -60,6 +60,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/scheduled_post.yaml >> $(V4_YAML) @cat $(V4_SRC)/custom_profile_attributes.yaml >> $(V4_YAML) @cat $(V4_SRC)/audit_logging.yaml >> $(V4_YAML) + @cat $(V4_SRC)/access_control.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/access_control.yaml b/api/v4/source/access_control.yaml new file mode 100644 index 0000000000..901b264e70 --- /dev/null +++ b/api/v4/source/access_control.yaml @@ -0,0 +1,524 @@ + /api/v4/access_control_policies: + put: + tags: + - access control + summary: Create an access control policy + description: | + Creates a new access control policy. + ##### Permissions + Must have the `manage_system` permission. + operationId: CreateAccessControlPolicy + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPolicy" + responses: + "200": + description: Access control policy created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPolicy" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/access_control_policies/cel/check: + post: + tags: + - access control + summary: Check an access control policy expression + description: | + Checks the syntax and validity of an access control policy expression. + ##### Permissions + Must have the `manage_system` permission. + operationId: CheckAccessControlPolicyExpression + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expression: + type: string + description: The expression to check. + responses: + "200": + description: Expression check result. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ExpressionError" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/access_control_policies/cel/test: + post: + tags: + - access control + summary: Test an access control policy expression + description: | + Tests an access control policy expression against users to see who would be affected. + ##### Permissions + Must have the `manage_system` permission. + operationId: TestAccessControlPolicyExpression + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/QueryExpressionParams" + responses: + "200": + description: Expression test result. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPolicyTestResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/access_control_policies/search: + post: + tags: + - access control + summary: Search access control policies + description: | + Searches for access control policies based on given criteria. + ##### Permissions + Must have the `manage_system` permission. + operationId: SearchAccessControlPolicies + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPolicySearch" + responses: + "200": + description: Search results for access control policies. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPoliciesWithCount" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/access_control_policies/cel/autocomplete/fields: + get: + tags: + - access control + summary: Get autocomplete fields for access control policies + description: | + Provides a list of fields that can be used for autocompletion when creating/editing access control policy expressions. + ##### Permissions + Must have the `manage_system` permission. + operationId: GetAccessControlPolicyAutocompleteFields + parameters: + - name: after + in: query + description: The field ID to start after for pagination. + required: false + schema: + type: string + - name: limit + in: query + description: The maximum number of fields to return. + required: true + schema: + type: integer + default: 60 + responses: + "200": + description: Autocomplete fields retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlFieldsAutocompleteResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}": + get: + tags: + - access control + summary: Get an access control policy + description: | + Gets a specific access control policy by its ID. + ##### Permissions + Must have the `manage_system` permission. + operationId: GetAccessControlPolicy + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + responses: + "200": + description: Access control policy retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlPolicy" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + delete: + tags: + - access control + summary: Delete an access control policy + description: | + Deletes an access control policy by its ID. + ##### Permissions + Must have the `manage_system` permission. + operationId: DeleteAccessControlPolicy + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + responses: + "200": + description: Access control policy deleted successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}/activate": + get: + tags: + - access control + summary: Activate or deactivate an access control policy + description: | + Updates the active status of an access control policy. + ##### Permissions + Must have the `manage_system` permission. + operationId: UpdateAccessControlPolicyActiveStatus + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + - name: active + in: query + description: Set to "true" to activate, "false" to deactivate. + required: true + schema: + type: boolean + responses: + "200": + description: Policy active status updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}/assign": + post: + tags: + - access control + summary: Assign an access control policy to channels + description: | + Assigns an access control policy to a list of channels. + ##### Permissions + Must have the `manage_system` permission. + operationId: AssignAccessControlPolicyToChannels + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + channel_ids: + type: array + items: + type: string + description: The IDs of the channels to assign the policy to. + responses: + "200": + description: Policy assigned to channels successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}/unassign": + delete: + tags: + - access control + summary: Unassign an access control policy from channels + description: | + Unassigns an access control policy from a list of channels. + ##### Permissions + Must have the `manage_system` permission. + operationId: UnassignAccessControlPolicyFromChannels + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + channel_ids: + type: array + items: + type: string + description: The IDs of the channels to unassign the policy from. + responses: + "200": + description: Policy unassigned from channels successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}/resources/channels": + get: + tags: + - access control + summary: Get channels for an access control policy + description: | + Retrieves a paginated list of channels to which a specific access control policy is applied. + ##### Permissions + Must have the `manage_system` permission. + operationId: GetChannelsForAccessControlPolicy + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + - name: after + in: query + description: The channel ID to start after for pagination. + required: false + schema: + type: string + - name: limit + in: query + description: The maximum number of channels to return. + required: true + schema: + type: integer + default: 60 + responses: + "200": + description: Channels retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelsWithCount" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/access_control_policies/{policy_id}/resources/channels/search": + post: + tags: + - access control + summary: Search channels for an access control policy + description: | + Searches for channels associated with a specific access control policy based on search criteria. + ##### Permissions + Must have the `manage_system` permission. + operationId: SearchChannelsForAccessControlPolicy + parameters: + - name: policy_id + in: path + description: The ID of the access control policy. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelSearch" + responses: + "200": + description: Channel search results retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelsWithCount" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + "/api/v4/channels/{channel_id}/access_control/attributes": + get: + tags: + - access control + - channels + summary: Get access control attributes for a channel + description: | + Retrieves the effective access control policy attributes for a specific channel. + This can be used to understand what attributes are currently being applied to the channel by the access control system. + ##### Permissions + Must have `read_channel` permission for the specified channel. + operationId: GetChannelAccessControlAttributes + parameters: + - name: channel_id + in: path + description: The ID of the channel. + required: true + schema: + type: string + responses: + "200": + description: Access control attributes retrieved successfully. + content: + application/json: + schema: + type: object # Placeholder - define more specifically if the structure is known + additionalProperties: true + description: A map of attribute names to their values as applied to the channel. + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/access_control_policies/cel/visual_ast: + post: + tags: + - access control + summary: Get the visual AST for a CEL expression + description: | + Retrieves the visual AST for a CEL expression. + ##### Permissions + Must have the `manage_system` permission. + operationId: GetCELVisualAST + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CELExpression" + responses: + "200": + description: Visual AST retrieved successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/VisualExpression" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 3280ffa55e..a50aa21fe1 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -174,8 +174,6 @@ components: type: string total_member_count: type: integer - active_member_count: - type: integer TeamExists: type: object properties: @@ -3935,6 +3933,181 @@ components: description: Explains the error behind why a scheduled post could not have been sent metadata: $ref: "#/components/schemas/PostMetadata" + AccessControlFieldsAutocompleteResponse: + type: object + properties: + fields: + type: array + items: + type: object + properties: + name: + type: string + description: The name of the field. + description: + type: string + description: A description of the field. + AccessControlPoliciesWithCount: + type: object + properties: + policies: + type: array + items: + $ref: "#/components/schemas/AccessControlPolicy" + total_count: + type: integer + description: The total number of policies. + AccessControlPolicy: + type: object + properties: + id: + type: string + description: The unique identifier of the policy. + name: + type: string + description: The unique name for the policy. + display_name: + type: string + description: The human-readable name for the policy. + description: + type: string + description: A description of the policy. + expression: + type: string + description: The CEL expression defining the policy rules. + is_active: + type: boolean + description: Whether the policy is currently active and enforced. + create_at: + type: integer + format: int64 + description: The time in milliseconds the policy was created. + update_at: + type: integer + format: int64 + description: The time in milliseconds the policy was last updated. + delete_at: + type: integer + format: int64 + description: The time in milliseconds the policy was deleted. + AccessControlPolicySearch: + type: object + properties: + term: + type: string + description: The search term to match against policy names or display names. + is_active: + type: boolean + description: Filter policies by active status. + page: + type: integer + description: The page number to return. + per_page: + type: integer + description: The number of policies to return per page. + # Add other potential search/filter fields like sort_by, sort_direction + AccessControlPolicyTestResponse: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + description: A list of users affected by the policy expression. + total_count: + type: integer + description: The total number of users affected. + ChannelSearch: # Added based on dataretention.yaml and access_control.go usage + type: object + properties: + term: + type: string + description: The string to search in the channel name, display name, and purpose. + team_ids: + type: array + items: + type: string + description: Filters results to channels belonging to the given team ids. + public: + type: boolean + description: Filters results to only return Public / Open channels. + private: + type: boolean + description: Filters results to only return Private channels. + deleted: + type: boolean + description: Filters results to only return deleted / archived channels. + include_deleted: + type: boolean + description: Whether to include deleted channels in the search results. + # Add other potential search fields like not_associated_to_group, exclude_default_channels etc. + ChannelsWithCount: # Added based on access_control.go usage + type: object + properties: + channels: + $ref: "#/components/schemas/ChannelListWithTeamData" # Referencing existing type used in similar contexts + total_count: + type: integer + description: The total number of channels. + ExpressionError: + type: object + properties: + message: + type: string + description: The error message. + field: + type: string + description: The field related to the error, if applicable. + line: + type: integer + description: The line number where the error occurred in the expression. + column: + type: integer + description: The column number where the error occurred in the expression. + QueryExpressionParams: + type: object + properties: + expression: + type: string + description: The policy expression to test. + term: + type: string + description: A search term to filter users against whom the expression is tested. + limit: + type: integer + description: The maximum number of users to return. + after: + type: string + description: The ID of the user to start the test after (for pagination). + CELExpression: + type: object + properties: + expression: + type: string + description: The CEL expression to visualize. + VisualExpression: + type: object + properties: + conditions: + type: array + items: + $ref: "#/components/schemas/Condition" + description: The visual AST for the CEL expression + Condition: + type: object + properties: + attribute: + type: string + description: The attribute name. + operator: + type: string + description: The operator of a single condition. + value: + type: string + description: The value. + value_type: + type: string + description: The value type. externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/compliance_export_ui_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/compliance_export_ui_spec.js index 7d5f565dc2..ffc576e071 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/compliance_export_ui_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/compliance_export_ui_spec.js @@ -108,19 +108,19 @@ describe('Compliance Export', () => { // * Verify table header cy.get('@firstheader').within(() => { - cy.get('th:eq(1)').should('have.text', 'Status'); - cy.get('th:eq(2)').should('have.text', 'Files'); - cy.get('th:eq(3)').should('have.text', 'Finish Time'); - cy.get('th:eq(4)').should('have.text', 'Run Time'); - cy.get('th:eq(5)').should('have.text', 'Details'); + cy.get('th:eq(0)').should('have.text', 'Status'); + cy.get('th:eq(1)').should('have.text', 'Finish Time'); + cy.get('th:eq(2)').should('have.text', 'Run Time'); + cy.get('th:eq(3)').should('have.text', 'Files'); + cy.get('th:eq(4)').should('have.text', 'Details'); }); // * Verify first row (last run job) data cy.get('@firstRow').within(() => { - cy.get('td:eq(1)').should('have.text', 'Success'); - cy.get('td:eq(2)').should('have.text', 'Download'); - cy.get('td:eq(4)').contains('seconds'); - cy.get('td:eq(5)').should('have.text', '1 messages exported.'); + cy.get('td:eq(0)').should('have.text', 'Success'); + cy.get('td:eq(2)').contains('seconds'); + cy.get('td:eq(3)').should('have.text', 'Download'); + cy.get('td:eq(4)').should('have.text', '1 messages exported.'); }); }); @@ -166,6 +166,6 @@ describe('Compliance Export', () => { cy.get('.job-table__table').find('tbody > tr').eq(0).as('firstRow'); // * Canceled text should be shown in the first row of the table - cy.get('@firstRow').find('td:eq(1)').should('have.text', 'Canceled'); + cy.get('@firstRow').find('td:eq(0)').should('have.text', 'Canceled'); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/helpers.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/helpers.js index 5823e73de5..345833d20a 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/compliance/helpers.js @@ -41,7 +41,7 @@ export function verifyActianceXMLFile(targetFolder, type, match) { export function verifyExportedMessagesCount(expectedNumber) { // * Verifying number of exported messages - cy.get('@firstRow').find('td:eq(5)').should('have.text', `${expectedNumber} messages exported.`); + cy.get('@firstRow').find('td:eq(4)').should('have.text', `${expectedNumber} messages exported.`); } export function editLastPost(message) { @@ -161,7 +161,7 @@ export function runDataRetentionAndVerifyPostDeleted(testTeam, testChannel, post // # Waiting for Data Retention process to finish cy.get('.job-table__table').find('tbody > tr').eq(0).as('firstRow'); cy.get('@firstRow').within(() => { - cy.get('td:eq(1)', {timeout: TIMEOUTS.FOUR_MIN}).should('have.text', 'Success'); + cy.get('td:eq(0)', {timeout: TIMEOUTS.FOUR_MIN}).should('have.text', 'Success'); }); // * Verifying if post has been deleted diff --git a/e2e-tests/cypress/tests/support/ui/compliance_export.js b/e2e-tests/cypress/tests/support/ui/compliance_export.js index f566dc4257..75a9a5f74f 100644 --- a/e2e-tests/cypress/tests/support/ui/compliance_export.js +++ b/e2e-tests/cypress/tests/support/ui/compliance_export.js @@ -40,7 +40,7 @@ Cypress.Commands.add('uiExportCompliance', () => { // # Wait until export is finished cy.waitUntil(() => { - return cy.get('@firstRow').find('td:eq(1)').then((el) => { + return cy.get('@firstRow').find('td:eq(0)').then((el) => { return el[0].innerText.trim() === 'Success'; }); }, diff --git a/server/Makefile b/server/Makefile index 044beca54a..115c30c6a6 100644 --- a/server/Makefile +++ b/server/Makefile @@ -855,7 +855,7 @@ test-migration: # db_migrations differ due to a typo in the 92. migration name # for now we exclude plugins such as playbooks and focalboard # we also exlude systems table temporarily due to adding some keys while running the initial migration - bin/dbcmp --source "${MYSQL_DSN}" --target "${POSTGRES_DSN}" --exclude="db_migrations","ir_","focalboard","systems" + bin/dbcmp --source "${MYSQL_DSN}" --target "${POSTGRES_DSN}" --exclude="db_migrations","ir_","focalboard","systems","attributeview" test-local-filestore: # Run tests for local filestore $(GO) test ./platform/shared/filestore -run '^TestLocalFileBackend' -v diff --git a/server/channels/api4/access_control.go b/server/channels/api4/access_control.go new file mode 100644 index 0000000000..e3697e11d7 --- /dev/null +++ b/server/channels/api4/access_control.go @@ -0,0 +1,515 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/audit" +) + +func (api *API) InitAccessControlPolicy() { + if !api.srv.Config().FeatureFlags.AttributeBasedAccessControl { + return + } + api.BaseRoutes.AccessControlPolicies.Handle("", api.APISessionRequired(createAccessControlPolicy)).Methods(http.MethodPut) + api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APISessionRequired(searchAccessControlPolicies)).Methods(http.MethodPost) + + api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APISessionRequired(checkExpression)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APISessionRequired(testExpression)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APISessionRequired(getFieldsAutocomplete)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APISessionRequired(convertToVisualAST)).Methods(http.MethodPost) + + api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(getAccessControlPolicy)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(deleteAccessControlPolicy)).Methods(http.MethodDelete) + api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APISessionRequired(updateActiveStatus)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APISessionRequired(assignAccessPolicy)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APISessionRequired(unassignAccessPolicy)).Methods(http.MethodDelete) + api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APISessionRequired(getChannelsForAccessControlPolicy)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APISessionRequired(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost) +} + +func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + var policy model.AccessControlPolicy + if jsonErr := json.NewDecoder(r.Body).Decode(&policy); jsonErr != nil { + c.SetInvalidParamWithErr("policy", jsonErr) + return + } + + auditRec := c.MakeAuditRecord("createAccessControlPolicy", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameterAuditable(auditRec, "requested", &policy) + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + np, appErr := c.App.CreateOrUpdateAccessControlPolicy(c.AppContext, &policy) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventObjectType("access_control_policy") + auditRec.AddEventResultState(np) + + js, err := json.Marshal(np) + if err != nil { + c.Err = model.NewAppError("createAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func getAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + policyID := c.Params.PolicyId + + policy, appErr := c.App.GetAccessControlPolicy(c.AppContext, policyID) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(policy) + if err != nil { + c.Err = model.NewAppError("getAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + policyID := c.Params.PolicyId + + auditRec := c.MakeAuditRecord("deleteAccessControlPolicy", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", policyID) + + appErr := c.App.DeleteAccessControlPolicy(c.AppContext, policyID) + if appErr != nil { + c.Err = appErr + return + } + auditRec.Success() +} + +func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) { + // request type reserved for future expansion + // for now, we only support the expression check + checkExpressionRequest := struct { + Expression string `json:"expression"` + }{} + if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil { + c.SetInvalidParamWithErr("user", jsonErr) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + errs, appErr := c.App.CheckExpression(c.AppContext, checkExpressionRequest.Expression) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(errs) + if err != nil { + c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func testExpression(c *Context, w http.ResponseWriter, r *http.Request) { + var checkExpressionRequest model.QueryExpressionParams + if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil { + c.SetInvalidParamWithErr("user", jsonErr) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + users, count, appErr := c.App.TestExpression(c.AppContext, checkExpressionRequest.Expression, model.SubjectSearchOptions{ + Term: checkExpressionRequest.Term, + Limit: checkExpressionRequest.Limit, + Cursor: model.SubjectCursor{ + TargetID: checkExpressionRequest.After, + }, + }) + if appErr != nil { + c.Err = appErr + return + } + + resp := model.AccessControlPolicyTestResponse{ + Users: users, + Total: count, + } + + js, err := json.Marshal(resp) + if err != nil { + c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + var props *model.AccessControlPolicySearch + err := json.NewDecoder(r.Body).Decode(&props) + if err != nil || props == nil { + c.SetInvalidParamWithErr("access_control_policy_search", err) + return + } + + policies, total, appErr := c.App.SearchAccessControlPolicies(c.AppContext, *props) + if appErr != nil { + c.Err = appErr + return + } + + result := model.AccessControlPoliciesWithCount{ + Policies: policies, + Total: total, + } + + js, err := json.Marshal(result) + if err != nil { + c.Err = model.NewAppError("searchAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + + policyID := c.Params.PolicyId + + auditRec := c.MakeAuditRecord("updateActiveStatus", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", policyID) + + active := r.URL.Query().Get("active") + if active != "true" && active != "false" { + c.SetInvalidParam("active") + return + } + activeBool, err := strconv.ParseBool(active) + if err != nil { + c.SetInvalidParamWithErr("active", err) + return + } + audit.AddEventParameter(auditRec, "active", activeBool) + + appErr := c.App.UpdateAccessControlPolicyActive(c.AppContext, policyID, activeBool) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() +} + +func assignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + policyID := c.Params.PolicyId + + var assignments struct { + ChannelIds []string `json:"channel_ids"` + } + + err := json.NewDecoder(r.Body).Decode(&assignments) + if err != nil { + c.SetInvalidParamWithErr("assignments", err) + return + } + + auditRec := c.MakeAuditRecord("assignAccessPolicy", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", policyID) + audit.AddEventParameter(auditRec, "channel_ids", assignments.ChannelIds) + + if len(assignments.ChannelIds) != 0 { + _, appErr := c.App.AssignAccessControlPolicyToChannels(c.AppContext, policyID, assignments.ChannelIds) + if appErr != nil { + c.Err = appErr + return + } + } + + auditRec.Success() +} + +func unassignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + policyID := c.Params.PolicyId + + var assignments struct { + ChannelIds []string `json:"channel_ids"` + } + + auditRec := c.MakeAuditRecord("unassignAccessPolicy", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", policyID) + audit.AddEventParameter(auditRec, "channel_ids", assignments.ChannelIds) + + err := json.NewDecoder(r.Body).Decode(&assignments) + if err != nil { + c.SetInvalidParamWithErr("assignments", err) + return + } + + if len(assignments.ChannelIds) != 0 { + appErr := c.App.UnAssignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds) + if appErr != nil { + c.Err = appErr + return + } + } + + auditRec.Success() +} + +func getChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + policyID := c.Params.PolicyId + + afterID := r.URL.Query().Get("after") + if afterID != "" && !model.IsValidId(afterID) { + c.SetInvalidParam("after") + return + } + + limitStr := r.URL.Query().Get("limit") + limit, err := strconv.Atoi(limitStr) + if err != nil { + c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.access_control_policy.get_channels.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + channels, total, appErr := c.App.GetChannelsForPolicy(c.AppContext, policyID, model.AccessControlPolicyCursor{ + ID: afterID, + }, limit) + if appErr != nil { + c.Err = appErr + return + } + + data := model.ChannelsWithCount{Channels: channels, TotalCount: total} + + js, err := json.Marshal(data) + if err != nil { + c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func searchChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequirePolicyId() + if c.Err != nil { + return + } + + var props *model.ChannelSearch + err := json.NewDecoder(r.Body).Decode(&props) + if err != nil || props == nil { + c.SetInvalidParamWithErr("channel_search", err) + return + } + + policyID := c.Params.PolicyId + + c.RequirePolicyId() + + opts := model.ChannelSearchOpts{ + Deleted: props.Deleted, + IncludeDeleted: props.IncludeDeleted, + Private: true, + ExcludeGroupConstrained: true, + TeamIds: props.TeamIds, + ParentAccessControlPolicyId: policyID, + } + + channels, total, appErr := c.App.SearchAllChannels(c.AppContext, props.Term, opts) + if appErr != nil { + c.Err = appErr + return + } + + data := model.ChannelsWithCount{Channels: channels, TotalCount: total} + + channelsJSON, jsonErr := json.Marshal(data) + if jsonErr != nil { + c.Err = model.NewAppError("searchChannelsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + return + } + + if _, err := w.Write(channelsJSON); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + after := r.URL.Query().Get("after") + if after != "" && !model.IsValidId(after) { + c.SetInvalidParam("after") + return + } else if after == "" { + after = strings.Repeat("0", 26) + } + + limitStr := r.URL.Query().Get("limit") + limit, err := strconv.Atoi(limitStr) + if err != nil { + c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + if limit <= 0 || limit > 100 { + c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest) + return + } + + ac, appErr := c.App.GetAccessControlFieldsAutocomplete(c.AppContext, after, limit) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(ac) + if err != nil { + c.Err = model.NewAppError("getExpressionAutocomplete", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + var cel struct { + Expression string `json:"expression"` + } + if jsonErr := json.NewDecoder(r.Body).Decode(&cel); jsonErr != nil { + c.SetInvalidParamWithErr("user", jsonErr) + return + } + visualAST, appErr := c.App.ExpressionToVisualAST(c.AppContext, cel.Expression) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(visualAST) + if err != nil { + c.Err = model.NewAppError("convertToVisualAST", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if _, err := w.Write(b); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/api4/access_control_local.go b/server/channels/api4/access_control_local.go new file mode 100644 index 0000000000..3c2ebdb0e4 --- /dev/null +++ b/server/channels/api4/access_control_local.go @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import "net/http" + +func (api *API) InitAccessControlPolicyLocal() { + if !api.srv.Config().FeatureFlags.AttributeBasedAccessControl { + return + } + api.BaseRoutes.AccessControlPolicies.Handle("", api.APILocal(createAccessControlPolicy)).Methods(http.MethodPut) + api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APILocal(searchAccessControlPolicies)).Methods(http.MethodPost) + + api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APILocal(checkExpression)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APILocal(testExpression)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APILocal(getFieldsAutocomplete)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APILocal(convertToVisualAST)).Methods(http.MethodPost) + + api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(getAccessControlPolicy)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(deleteAccessControlPolicy)).Methods(http.MethodDelete) + api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APILocal(updateActiveStatus)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APILocal(assignAccessPolicy)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APILocal(unassignAccessPolicy)).Methods(http.MethodDelete) + api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APILocal(getChannelsForAccessControlPolicy)).Methods(http.MethodGet) + api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APILocal(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost) +} diff --git a/server/channels/api4/access_control_test.go b/server/channels/api4/access_control_test.go new file mode 100644 index 0000000000..1727ec39d3 --- /dev/null +++ b/server/channels/api4/access_control_test.go @@ -0,0 +1,613 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" + "github.com/stretchr/testify/require" +) + +func TestCreateAccessControlPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeChannel, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("CreateAccessControlPolicy without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), samplePolicy) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("CreateAccessControlPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + // Create and set up the mock + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), samplePolicy) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + // Set up a test license with Data Retention enabled + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + // Create and set up the mock + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + // Set up mock expectations + mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(samplePolicy, nil).Times(1) + + // Set the mock on the app + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := client.CreateAccessControlPolicy(context.Background(), samplePolicy) + require.NoError(t, err) + CheckOKStatus(t, resp) + }, "CreateAccessControlPolicy with system admin") +} + +func TestGetAccessControlPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeChannel, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("GetAccessControlPolicy without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.GetAccessControlPolicy(context.Background(), samplePolicy.ID) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("GetAccessControlPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + // Create and set up the mock + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.GetAccessControlPolicy(context.Background(), samplePolicy.ID) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + // Create and set up the mock + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := client.GetAccessControlPolicy(context.Background(), samplePolicy.ID) + require.NoError(t, err) + CheckOKStatus(t, resp) + }, "GetAccessControlPolicy with system admin") +} + +func TestDeleteAccessControlPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicyID := model.NewId() + + t.Run("DeleteAccessControlPolicy without license", func(t *testing.T) { + resp, err := th.SystemAdminClient.DeleteAccessControlPolicy(context.Background(), samplePolicyID) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("DeleteAccessControlPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := th.Client.DeleteAccessControlPolicy(context.Background(), samplePolicyID) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := client.DeleteAccessControlPolicy(context.Background(), samplePolicyID) + require.NoError(t, err) + CheckOKStatus(t, resp) + }) +} + +func TestCheckExpression(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + t.Run("CheckExpression without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.CheckExpression(context.Background(), "true") + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("CheckExpression with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.CheckExpression(context.Background(), "true") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("CheckExpression", mock.AnythingOfType("*request.Context"), "true").Return([]model.CELExpressionError{}, nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + errors, resp, err := client.CheckExpression(context.Background(), "true") + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Empty(t, errors, "expected no errors") + }, "CheckExpression with system admin") + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("CheckExpression", mock.AnythingOfType("*request.Context"), "true").Return([]model.CELExpressionError{ + { + Line: 1, + Column: 1, + Message: "Syntax error", + }, + }, nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + errors, resp, err := client.CheckExpression(context.Background(), "true") + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, errors, "expected errors") + }, "CheckExpression with system admin errors returned") +} + +func TestTestExpression(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + t.Run("TestExpression without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.TestExpression(context.Background(), model.QueryExpressionParams{}) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("TestExpression with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.TestExpression(context.Background(), model.QueryExpressionParams{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "true", model.SubjectSearchOptions{}).Return([]*model.User{}, int64(0), nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + usersResp, resp, err := client.TestExpression(context.Background(), model.QueryExpressionParams{ + Expression: "true", + }) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Empty(t, usersResp.Users, "expected no users") + require.Equal(t, int64(0), usersResp.Total, "expected count 0 users") + }, "TestExpression with system admin") +} + +func TestSearchAccessControlPolicies(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + t.Run("SearchAccessControlPolicies without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{}) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("SearchAccessControlPolicies with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("SearchPolicies", mock.AnythingOfType("*request.Context"), model.AccessControlPolicySearch{ + Term: "engineering", + }).Return([]*model.AccessControlPolicy{}, int64(0), nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + policiesResp, resp, err := client.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{ + Term: "engineering", + }) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Empty(t, policiesResp.Policies, "expected no policies") + require.Equal(t, int64(0), policiesResp.Total, "expected count 0 policies") + }, "SearchAccessControlPolicies with system admin") +} + +func TestAssignAccessPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("AssignAccessPolicy without license", func(t *testing.T) { + resp, err := th.SystemAdminClient.AssignAccessControlPolicies(context.Background(), model.NewId(), []string{model.NewId()}) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("AssignAccessPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := th.Client.AssignAccessControlPolicies(context.Background(), model.NewId(), []string{model.NewId()}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + resourceID := model.NewId() + + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + child, appErr := samplePolicy.Inherit(resourceID, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErr) + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1) + mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(child, nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := client.AssignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{resourceID}) + require.NoError(t, err) + CheckOKStatus(t, resp) + }, "AssignAccessPolicy with system admin") +} + +func TestUnassignAccessPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("UnassignAccessPolicy without license", func(t *testing.T) { + resp, err := th.SystemAdminClient.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{model.NewId()}) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("UnassignAccessPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := th.Client.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{model.NewId()}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + resourceID := model.NewId() + + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + child, appErr := samplePolicy.Inherit(resourceID, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErr) + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1) + mockAccessControlService.On("SearchPolicies", mock.AnythingOfType("*request.Context"), model.AccessControlPolicySearch{ + Type: model.AccessControlPolicyTypeChannel, + ParentID: samplePolicy.ID, + }).Return([]*model.AccessControlPolicy{child}, nil).Times(1) + mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), child.ID).Return(nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + resp, err := client.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{child.ID}) + require.NoError(t, err) + CheckOKStatus(t, resp) + }, "UnassignAccessPolicy with system admin") +} + +func TestGetChannelsForAccessControlPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("GetChannelsForAccessControlPolicy without license", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) + }) + + t.Run("GetChannelsForAccessControlPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + channelsResp, resp, err := client.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Empty(t, channelsResp.Channels, "expected no channels") + require.Equal(t, int64(0), channelsResp.TotalCount, "expected count 0 channels") + }, "GetChannelsForAccessControlPolicy with system admin") +} + +func TestSearchChannelsForAccessControlPolicy(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true") + th := Setup(t) + t.Cleanup(func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL") + }) + + samplePolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Version: model.AccessControlPolicyVersionV0_1, + Revision: 1, + Rules: []model.AccessControlPolicyRule{ + { + Expression: "user.attributes.team == 'engineering'", + Actions: []string{"*"}, + }, + }, + } + + t.Run("SearchChannelsForAccessControlPolicy with regular user", func(t *testing.T) { + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "SetLicense should return true") + + mockAccessControlService := &mocks.AccessControlServiceInterface{} + th.App.Srv().Channels().AccessControl = mockAccessControlService + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true) + }) + + _, resp, err := th.Client.SearchChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, model.ChannelSearch{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) +} diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index be834904f6..3879522ed9 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -158,6 +158,9 @@ type Routes struct { CustomProfileAttributesValues *mux.Router // 'api/v4/custom_profile_attributes/values' AuditLogs *mux.Router // 'api/v4/audit_logs' + + AccessControlPolicies *mux.Router // 'api/v4/access_control_policies' + AccessControlPolicy *mux.Router // 'api/v4/access_control_policies/{policy_id:[A-Za-z0-9]+}' } type API struct { @@ -302,6 +305,9 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.AuditLogs = api.BaseRoutes.APIRoot.PathPrefix("/audit_logs").Subrouter() + api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter() + api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -354,6 +360,7 @@ func Init(srv *app.Server) (*API, error) { api.InitScheduledPost() api.InitCustomProfileAttributes() api.InitAuditLogging() + api.InitAccessControlPolicy() // If we allow testing then listen for manual testing URL hits if *srv.Config().ServiceSettings.EnableTesting { @@ -441,6 +448,9 @@ func InitLocal(srv *app.Server) *API { api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter() + api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter() + api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter() + api.InitUserLocal() api.InitTeamLocal() api.InitChannelLocal() @@ -462,6 +472,7 @@ func InitLocal(srv *app.Server) *API { api.InitJobLocal() api.InitSamlLocal() api.InitCustomProfileAttributesLocal() + api.InitAccessControlPolicyLocal() srv.LocalRouter.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 4f1c940ab5..76dfe8a4f4 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -64,6 +64,7 @@ func (api *API) InitChannel() { api.BaseRoutes.Channel.Handle("/member_counts_by_group", api.APISessionRequired(channelMemberCountsByGroup)).Methods(http.MethodGet) api.BaseRoutes.Channel.Handle("/common_teams", api.APISessionRequired(getGroupMessageMembersCommonTeams)).Methods(http.MethodGet) api.BaseRoutes.Channel.Handle("/convert_to_channel", api.APISessionRequired(convertGroupMessageToChannel)).Methods(http.MethodPost) + api.BaseRoutes.Channel.Handle("/access_control/attributes", api.APISessionRequired(getChannelAccessControlAttributes)).Methods(http.MethodGet) api.BaseRoutes.ChannelForUser.Handle("/unread", api.APISessionRequired(getChannelUnread)).Methods(http.MethodGet) @@ -823,11 +824,18 @@ func getAllChannels(c *Context, w http.ResponseWriter, r *http.Request) { return } + if c.Params.ExcludeAccessControlPolicyEnforced && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + opts := model.ChannelSearchOpts{ - NotAssociatedToGroup: c.Params.NotAssociatedToGroup, - ExcludeDefaultChannels: c.Params.ExcludeDefaultChannels, - IncludeDeleted: c.Params.IncludeDeleted, - ExcludePolicyConstrained: c.Params.ExcludePolicyConstrained, + NotAssociatedToGroup: c.Params.NotAssociatedToGroup, + ExcludeDefaultChannels: c.Params.ExcludeDefaultChannels, + IncludeDeleted: c.Params.IncludeDeleted, + ExcludePolicyConstrained: c.Params.ExcludePolicyConstrained, + AccessControlPolicyEnforced: c.Params.AccessControlPolicyEnforced, + ExcludeAccessControlPolicyEnforced: c.Params.ExcludeAccessControlPolicyEnforced, } if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) { opts.IncludePolicyID = true @@ -1309,20 +1317,23 @@ func searchAllChannels(c *Context, w http.ResponseWriter, r *http.Request) { includeDeleted, _ := strconv.ParseBool(r.URL.Query().Get("include_deleted")) includeDeleted = includeDeleted || props.IncludeDeleted opts := model.ChannelSearchOpts{ - NotAssociatedToGroup: props.NotAssociatedToGroup, - ExcludeDefaultChannels: props.ExcludeDefaultChannels, - TeamIds: props.TeamIds, - GroupConstrained: props.GroupConstrained, - ExcludeGroupConstrained: props.ExcludeGroupConstrained, - ExcludePolicyConstrained: props.ExcludePolicyConstrained, - IncludeSearchById: props.IncludeSearchById, - ExcludeRemote: props.ExcludeRemote, - Public: props.Public, - Private: props.Private, - IncludeDeleted: includeDeleted, - Deleted: props.Deleted, - Page: props.Page, - PerPage: props.PerPage, + NotAssociatedToGroup: props.NotAssociatedToGroup, + ExcludeDefaultChannels: props.ExcludeDefaultChannels, + TeamIds: props.TeamIds, + GroupConstrained: props.GroupConstrained, + ExcludeGroupConstrained: props.ExcludeGroupConstrained, + ExcludePolicyConstrained: props.ExcludePolicyConstrained, + IncludeSearchById: props.IncludeSearchById, + ExcludeRemote: props.ExcludeRemote, + Public: props.Public, + Private: props.Private, + IncludeDeleted: includeDeleted, + Deleted: props.Deleted, + Page: props.Page, + PerPage: props.PerPage, + AccessControlPolicyEnforced: props.AccessControlPolicyEnforced, + ExcludeAccessControlPolicyEnforced: props.ExcludeAccessControlPolicyEnforced, + ParentAccessControlPolicyId: props.ParentAccessControlPolicyId, } if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) { opts.IncludePolicyID = true @@ -2478,3 +2489,25 @@ func canEditChannelBanner(c *Context, originalChannel *model.Channel) { c.Err = model.NewAppError("patchChannel", "api.channel.update_channel.banner_info.channel_type.not_allowed", nil, "", http.StatusBadRequest) } } + +func getChannelAccessControlAttributes(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireChannelId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + attributes, err := c.App.GetAccessControlPolicyAttributes(c.AppContext, c.Params.ChannelId, "*") + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(attributes); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go new file mode 100644 index 0000000000..3b6f376e54 --- /dev/null +++ b/server/channels/app/access_control.go @@ -0,0 +1,293 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +func (a *App) GetChannelsForPolicy(rctx request.CTX, policyID string, cursor model.AccessControlPolicyCursor, limit int) ([]*model.ChannelWithTeamData, int64, *model.AppError) { + policy, appErr := a.GetAccessControlPolicy(rctx, policyID) + if appErr != nil { + return nil, 0, appErr + } + + switch policy.Type { + case model.AccessControlPolicyTypeParent: + policies, total, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ + Type: model.AccessControlPolicyTypeChannel, + ParentID: policyID, + Cursor: cursor, + Limit: limit, + }) + if err != nil { + return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError) + } + channelIDs := make([]string, 0, len(policies)) + + for _, p := range policies { + channelIDs = append(channelIDs, p.ID) + } + + chs, err := a.Srv().Store().Channel().GetChannelsWithTeamDataByIds(channelIDs, true) + if err != nil { + return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return chs, total, nil + case model.AccessControlPolicyTypeChannel: + chs, err := a.Srv().Store().Channel().GetChannelsWithTeamDataByIds([]string{policyID}, true) + if err != nil { + return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + total := int64(len(chs)) + return chs, total, nil + default: + return nil, 0, model.NewAppError("GetChannelsForPolicy", "app.pap.get_all_access_control_policies.app_error", nil, "Invalid policy type", http.StatusBadRequest) + } +} + +func (a *App) GetAccessControlPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + policy, appErr := acs.GetPolicy(rctx, id) + if appErr != nil { + return nil, appErr + } + + return policy, nil +} + +func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("CreateAccessControlPolicy", "app.pap.create_access_control_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + if policy.ID == "" { + policy.ID = model.NewId() + } + + var appErr *model.AppError + policy, appErr = acs.SavePolicy(rctx, policy) + if appErr != nil { + return nil, appErr + } + + return policy, nil +} + +func (a *App) DeleteAccessControlPolicy(rctx request.CTX, id string) *model.AppError { + acs := a.Srv().ch.AccessControl + if acs == nil { + return model.NewAppError("DeleteAccessControlPolicy", "app.pap.delete_access_control_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + appErr := acs.DeletePolicy(rctx, id) + if appErr != nil { + return appErr + } + + return nil +} + +func (a *App) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("CheckExpression", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + errs, appErr := acs.CheckExpression(rctx, expression) + if appErr != nil { + return nil, model.NewAppError("CheckExpression", "app.pap.check_expression.app_error", nil, appErr.Error(), http.StatusInternalServerError) + } + + return errs, nil +} + +func (a *App) TestExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, 0, model.NewAppError("TestExpression", "app.pap.check_expression.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + res, count, err := acs.QueryUsersForExpression(rctx, expression, opts) + if err != nil { + return nil, 0, model.NewAppError("TestExpression", "app.pap.check_expression.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return res, count, nil +} + +func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID string, channelIDs []string) ([]*model.AccessControlPolicy, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + policy, appErr := a.GetAccessControlPolicy(rctx, parentID) + if appErr != nil { + return nil, appErr + } + + if policy.Type != model.AccessControlPolicyTypeParent { + return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Policy is not of type parent", http.StatusBadRequest) + } + + channels, err := a.GetChannels(rctx, channelIDs) + if err != nil { + return nil, appErr + } + + policies := make([]*model.AccessControlPolicy, 0, len(channelIDs)) + for _, channel := range channels { + if channel.Type != model.ChannelTypePrivate || channel.IsGroupConstrained() { + return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Channel is not of type private", http.StatusBadRequest) + } + + if channel.IsShared() { + return nil, model.NewAppError("AssignAccessControlPolicyToChannels", "app.pap.assign_access_control_policy_to_channels.app_error", nil, "Channel is shared", http.StatusBadRequest) + } + + newPolicy, appErr := policy.Inherit(channel.Id, model.AccessControlPolicyTypeChannel) + if appErr != nil { + return nil, appErr + } + + newPolicy, appErr = acs.SavePolicy(rctx, newPolicy) + if appErr != nil { + return nil, appErr + } + policies = append(policies, newPolicy) + } + + return policies, nil +} + +func (a *App) UnAssignPoliciesFromChannels(rctx request.CTX, policyID string, channelIDs []string) *model.AppError { + acs := a.Srv().ch.AccessControl + if acs == nil { + return model.NewAppError("UnAssignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + cps, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ + Type: model.AccessControlPolicyTypeChannel, + ParentID: policyID, + }) + if err != nil { + return model.NewAppError("UnAssignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + childPolicies := make(map[string]bool) + for _, p := range cps { + childPolicies[p.ID] = true + } + + for _, channelID := range channelIDs { + if _, ok := childPolicies[channelID]; !ok { + mlog.Warn("Policy is not assigned to the parent policy", mlog.String("channel_id", channelID), mlog.String("parent_policy_id", policyID)) + continue + } + + appErr := acs.DeletePolicy(rctx, channelID) + if appErr != nil { + return appErr + } + } + + return nil +} + +func (a *App) SearchAccessControlPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, 0, model.NewAppError("SearchAccessControlPolicies", "app.pap.search_access_control_policies.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + policies, total, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, opts) + if err != nil { + return nil, 0, model.NewAppError("SearchAccessControlPolicies", "app.pap.search_access_control_policies.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for i, policy := range policies { + if policy.Type != model.AccessControlPolicyTypeParent { + continue + } + + normlizedPolicy, appErr := acs.NormalizePolicy(rctx, policy) + if appErr != nil { + mlog.Error("Failed to normalize policy", mlog.String("policy_id", policy.ID), mlog.Err(appErr)) + continue + } + policies[i] = normlizedPolicy + } + + return policies, total, nil +} + +func (a *App) GetAccessControlPolicyAttributes(rctx request.CTX, channelID string, action string) (map[string][]string, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("GetChannelAccessControlAttributes", "app.pap.get_channel_access_control_attributes.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + attributes, appErr := acs.GetPolicyRuleAttributes(rctx, channelID, action) + if appErr != nil { + return nil, appErr + } + + return attributes, nil +} + +func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, after string, limit int) ([]*model.PropertyField, *model.AppError) { + cpaGroupID, err := a.CpaGroupID() + if err != nil { + return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + fields, err := a.Srv().Store().PropertyField().SearchPropertyFields(model.PropertyFieldSearchOpts{ + GroupID: cpaGroupID, + Cursor: model.PropertyFieldSearchCursor{ + PropertyFieldID: after, + CreateAt: 1, + }, + PerPage: limit, + }) + if err != nil { + return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return fields, nil +} + +func (a *App) UpdateAccessControlPolicyActive(rctx request.CTX, policyID string, active bool) *model.AppError { + _, err := a.Srv().Store().AccessControlPolicy().SetActiveStatus(rctx, policyID, active) + if err != nil { + return model.NewAppError("UpdateAccessControlPolicyActive", "app.pap.update_access_control_policy_active.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (a *App) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("ExpressionToVisualAST", "app.pap.expression_to_visual_ast.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + visualAST, appErr := acs.ExpressionToVisualAST(rctx, expression) + if appErr != nil { + return nil, appErr + } + + return visualAST, nil +} diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go new file mode 100644 index 0000000000..f3100849a3 --- /dev/null +++ b/server/channels/app/access_control_test.go @@ -0,0 +1,455 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" +) + +func TestGetChannelsForPolicy(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + rctx := request.TestContext(t) + policyID := "policyID" + cursor := model.AccessControlPolicyCursor{} + limit := 10 + + t.Run("Feature not enabled", func(t *testing.T) { + th.App.Srv().ch.AccessControl = nil + + channels, total, err := th.App.GetChannelsForPolicy(rctx, policyID, cursor, limit) + require.NotNil(t, err) + assert.Nil(t, channels) + assert.Equal(t, int64(0), total) + }) + + t.Run("Invalid policy type", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", mock.AnythingOfType("*request.Context"), policyID).Return(&model.AccessControlPolicy{Type: "invalid"}, nil) + + channels, total, err := th.App.GetChannelsForPolicy(rctx, policyID, cursor, limit) + require.NotNil(t, err) + require.Nil(t, channels) + require.Equal(t, int64(0), total) + }) + + t.Run("Valid policy type - no channels", func(t *testing.T) { + pID := model.NewId() + parentPolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeParent, + ID: pID, + Name: "parentPolicy", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"*"}, + Expression: "user.attributes.program == \"non-existent-program\"", + }, + }, + } + + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, pID).Return(parentPolicy, nil) + + channels, total, err := th.App.GetChannelsForPolicy(rctx, pID, cursor, limit) + require.Nil(t, err) + require.NotNil(t, channels) + require.Equal(t, int64(0), total) + }) + + t.Run("Valid policy type - with channels", func(t *testing.T) { + pID := model.NewId() + parentPolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeParent, + ID: pID, + Name: "parentPolicy", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"*"}, + Expression: "user.attributes.program == \"non-existent-program\"", + }, + }, + } + + ch := th.CreatePrivateChannel(rctx, th.BasicTeam) + + childPolicy, appErr := parentPolicy.Inherit(ch.Id, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErr) + + var err error + childPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, childPolicy) + require.NoError(t, err) + require.NotNil(t, childPolicy) + + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, pID).Return(parentPolicy, nil) + + channels, total, appErr := th.App.GetChannelsForPolicy(rctx, pID, cursor, limit) + require.Nil(t, appErr) + require.NotNil(t, channels) + require.Equal(t, int64(1), total) + assert.Equal(t, ch.Id, channels[0].Id) + + mockAccessControl.On("GetPolicy", rctx, ch.Id).Return(childPolicy, nil) + channels, total, appErr = th.App.GetChannelsForPolicy(rctx, ch.Id, cursor, limit) + require.Nil(t, appErr) + require.NotNil(t, channels) + require.Equal(t, int64(1), total) + assert.Equal(t, ch.Id, channels[0].Id) + }) +} + +func TestSearchAccessControlPolicies(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + rctx := request.TestContext(t) + + t.Run("Feature not enabled", func(t *testing.T) { + policies, total, err := th.App.SearchAccessControlPolicies(rctx, model.AccessControlPolicySearch{}) + require.NotNil(t, err) + require.Empty(t, policies) + require.Equal(t, int64(0), total) + }) + + t.Run("Empty search result", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + + policies, total, err := th.App.SearchAccessControlPolicies(rctx, model.AccessControlPolicySearch{}) + require.Nil(t, err) + require.Empty(t, policies) + require.Equal(t, int64(0), total) + }) + + t.Run("Single search result", func(t *testing.T) { + pID := model.NewId() + parentPolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeParent, + ID: pID, + Name: "parentPolicy", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"*"}, + Expression: "user.attributes.program == \"non-existent-program\"", + }, + }, + } + + var err error + parentPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, parentPolicy) + require.NoError(t, err) + require.NotNil(t, parentPolicy) + defer func() { + dErr := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, parentPolicy.ID) + require.NoError(t, dErr) + }() + + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("NormalizePolicy", rctx, parentPolicy).Return(parentPolicy, nil) + + t.Run("With no term", func(t *testing.T) { + policies, total, err := th.App.SearchAccessControlPolicies(rctx, model.AccessControlPolicySearch{}) + require.Nil(t, err) + require.NotNil(t, policies) + require.Equal(t, int64(1), total) + require.Equal(t, parentPolicy.ID, policies[0].ID) + }) + + t.Run("With term", func(t *testing.T) { + policies, total, err := th.App.SearchAccessControlPolicies(rctx, model.AccessControlPolicySearch{ + Term: "parent", + }) + require.Nil(t, err) + require.NotNil(t, policies) + require.Equal(t, int64(1), total) + require.Equal(t, parentPolicy.ID, policies[0].ID) + }) + + t.Run("With term and no results", func(t *testing.T) { + policies, total, err := th.App.SearchAccessControlPolicies(rctx, model.AccessControlPolicySearch{ + Term: "something else", + }) + require.Nil(t, err) + require.Empty(t, policies) + require.Equal(t, int64(0), total) + }) + }) +} + +func TestAssignAccessControlPolicyToChannels(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + rctx := request.TestContext(t) + parentID := model.NewId() + + parentPolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeParent, + ID: parentID, + Name: "parentPolicy", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"*"}, + Expression: "user.attributes.program == \"non-existent-program\"", + }, + }, + } + var err error + parentPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, parentPolicy) + require.NoError(t, err) + require.NotNil(t, parentPolicy) + t.Cleanup(func() { + dErr := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, parentPolicy.ID) + require.NoError(t, dErr) + }) + + t.Run("Feature not enabled", func(t *testing.T) { + th.App.Srv().ch.AccessControl = nil + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{}) + require.NotNil(t, err) + assert.Nil(t, policies) + assert.Equal(t, "app.pap.assign_access_control_policy_to_channels.app_error", err.Id) + }) + + t.Run("Error saving policy", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(parentPolicy, nil) + mockAccessControl.On("SavePolicy", rctx, mock.Anything).Return(nil, model.NewAppError("SavePolicy", "error", nil, "save error", http.StatusInternalServerError)) + + ch := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + appErr := th.App.PermanentDeleteChannel(rctx, ch) + require.Nil(t, appErr) + }) + + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{ch.Id}) + require.NotNil(t, err) + require.Empty(t, policies) + }) + + t.Run("Parent policy not found", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(nil, model.NewAppError("GetPolicy", "error", nil, "not found", http.StatusNotFound)) + + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{}) + require.NotNil(t, err) + assert.Nil(t, policies) + }) + + t.Run("Policy is not of type parent", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeChannel}, nil) + + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{}) + require.NotNil(t, err) + assert.Nil(t, policies) + assert.Equal(t, "app.pap.assign_access_control_policy_to_channels.app_error", err.Id) + }) + + t.Run("Channel is not private", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeParent}, nil) + // Create a public channel + publicChannel := th.CreateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + appErr := th.App.PermanentDeleteChannel(rctx, publicChannel) + require.Nil(t, appErr) + }) + + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{publicChannel.Id}) + require.NotNil(t, err) + assert.Nil(t, policies) + assert.Contains(t, err.Error(), "Channel is not of type private") + }) + + t.Run("Channel is shared", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeParent}, nil) + + privateChannel := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + appErr := th.App.PermanentDeleteChannel(rctx, privateChannel) + require.Nil(t, appErr) + }) + privateChannel.Shared = model.NewPointer(true) + _, err := th.App.Srv().Store().Channel().Update(rctx, privateChannel) + require.NoError(t, err) + + policies, appErr := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{privateChannel.Id}) + require.NotNil(t, appErr) + assert.Nil(t, policies) + assert.Contains(t, appErr.Error(), "Channel is shared") + }) + + t.Run("Successful assignment", func(t *testing.T) { + ch1 := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + appErr := th.App.PermanentDeleteChannel(rctx, ch1) + require.Nil(t, appErr) + }) + ch2 := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + appErr := th.App.PermanentDeleteChannel(rctx, ch2) + require.Nil(t, appErr) + }) + + childP1, appErr := parentPolicy.Inherit(ch1.Id, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErr) + childP2, appErr := parentPolicy.Inherit(ch2.Id, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErr) + + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("GetPolicy", rctx, parentID).Return(parentPolicy, nil) + mockAccessControl.On("SavePolicy", rctx, mock.MatchedBy(func(p *model.AccessControlPolicy) bool { return p.ID == ch1.Id })).Return(childP1, nil) + mockAccessControl.On("SavePolicy", rctx, mock.MatchedBy(func(p *model.AccessControlPolicy) bool { return p.ID == ch2.Id })).Return(childP2, nil) + + policies, err := th.App.AssignAccessControlPolicyToChannels(rctx, parentID, []string{ch1.Id, ch2.Id}) + require.Nil(t, err) + require.NotNil(t, policies) + require.Len(t, policies, 2) + assert.ElementsMatch(t, []string{ch1.Id, ch2.Id}, []string{policies[0].ID, policies[1].ID}) + mockAccessControl.AssertCalled(t, "SavePolicy", rctx, mock.AnythingOfType("*model.AccessControlPolicy")) + }) +} + +func TestUnAssignPoliciesFromChannels(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + rctx := request.TestContext(t) + + parentPolicy := &model.AccessControlPolicy{ + ID: model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Name: "parent-for-unassign-tests", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{"*"}, Expression: "true"}, + }, + } + var err error + parentPolicy, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, parentPolicy) + require.NoError(t, err) + require.NotNil(t, parentPolicy) + t.Cleanup(func() { + sErr := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, parentPolicy.ID) + require.NoError(t, sErr) + }) + + ch1 := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + sErr := th.App.PermanentDeleteChannel(rctx, ch1) + require.Nil(t, sErr) + }) + ch2 := th.CreatePrivateChannel(rctx, th.BasicTeam) + t.Cleanup(func() { + sErr := th.App.PermanentDeleteChannel(rctx, ch2) + require.Nil(t, sErr) + }) + + childPolicy1, appErrInherit1 := parentPolicy.Inherit(ch1.Id, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErrInherit1) + childPolicy1, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, childPolicy1) + require.NoError(t, err) + require.NotNil(t, childPolicy1) + t.Cleanup(func() { + sErr := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, childPolicy1.ID) + require.NoError(t, sErr) + }) + + childPolicy2, appErrInherit2 := parentPolicy.Inherit(ch2.Id, model.AccessControlPolicyTypeChannel) + require.Nil(t, appErrInherit2) + childPolicy2, err = th.App.Srv().Store().AccessControlPolicy().Save(rctx, childPolicy2) + require.NoError(t, err) + require.NotNil(t, childPolicy2) + t.Cleanup(func() { + sErr := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, childPolicy2.ID) + require.NoError(t, sErr) + }) + + t.Run("Feature not enabled", func(t *testing.T) { + th.App.Srv().ch.AccessControl = nil + appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) + require.NotNil(t, appErr) + assert.Equal(t, "app.pap.unassign_access_control_policy_from_channels.app_error", appErr.Id) + }) + + t.Run("Error deleting policy from AccessControlService", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + + expectedErr := model.NewAppError("DeletePolicy", "mock.delete.error", nil, "failed to delete from acs", http.StatusInternalServerError) + mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(expectedErr).Once() + mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Maybe() + + appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) + require.NotNil(t, appErr) + assert.Equal(t, expectedErr.Id, appErr.Id) + assert.Equal(t, expectedErr.Message, appErr.Message) + + mockAccessControl.AssertCalled(t, "DeletePolicy", rctx, ch1.Id) + mockAccessControl.AssertNotCalled(t, "DeletePolicy", rctx, ch2.Id) + + p1, storeErr := th.App.Srv().Store().AccessControlPolicy().Get(rctx, ch1.Id) + assert.NoError(t, storeErr) + assert.NotNil(t, p1) + p2, storeErr := th.App.Srv().Store().AccessControlPolicy().Get(rctx, ch2.Id) + assert.NoError(t, storeErr) + assert.NotNil(t, p2) + }) + + t.Run("Channel not actually a child policy", func(t *testing.T) { + ch3 := th.CreatePrivateChannel(rctx, th.BasicTeam) // Not a child of parentPolicy + t.Cleanup(func() { _ = th.App.PermanentDeleteChannel(rctx, ch3) }) + + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + + mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once() + mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once() + + appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id, ch3.Id}) + require.Nil(t, appErr) + }) + + t.Run("Successful unassignment", func(t *testing.T) { + mockAccessControl := &mocks.AccessControlServiceInterface{} + th.App.Srv().ch.AccessControl = mockAccessControl + + mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once() + mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once() + + appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) + require.Nil(t, appErr) + }) +} diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 7593080cc1..11cc77802d 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -635,6 +635,14 @@ func (a *App) GetGroupChannel(c request.CTX, userIDs []string) (*model.Channel, // UpdateChannel updates a given channel by its Id. It also publishes the CHANNEL_UPDATED event. func (a *App) UpdateChannel(c request.CTX, channel *model.Channel) (*model.Channel, *model.AppError) { + ok, appErr := a.ChannelAccessControlled(c, channel.Id) + if appErr != nil { + return nil, appErr + } + if ok && channel.Type != model.ChannelTypePrivate { + return nil, model.NewAppError("UpdateChannel", "api.channel.update_channel.not_allowed.app_error", nil, "", http.StatusForbidden) + } + _, err := a.Srv().Store().Channel().Update(c, channel) if err != nil { var appErr *model.AppError @@ -1576,6 +1584,40 @@ func (a *App) addUserToChannel(c request.CTX, user *model.User, channel *model.C newMember.SchemeAdmin = userShouldBeAdmin } + if channel.Type == model.ChannelTypePrivate { + if ok, appErr := a.ChannelAccessControlled(c, channel.Id); ok { + if acs := a.Srv().Channels().AccessControl; acs != nil { + groupID, err := a.CpaGroupID() + if err != nil { + return nil, model.NewAppError("AddUserToChannel", "api.channel.add_user.to.channel.failed.app_error", nil, + fmt.Sprintf("failed to get group: %v, user_id: %s, channel_id: %s", err, user.Id, channel.Id), http.StatusInternalServerError) + } + + s, err := a.Srv().Store().Attributes().GetSubject(c, user.Id, groupID) + if err != nil { + return nil, model.NewAppError("AddUserToChannel", "api.channel.add_user.to.channel.failed.app_error", nil, + fmt.Sprintf("failed to get subject: %v, user_id: %s, channel_id: %s", err, user.Id, channel.Id), http.StatusNotFound) + } + + decision, evalErr := acs.AccessEvaluation(c, model.AccessRequest{ + Subject: *s, + Resource: model.Resource{ + Type: model.AccessControlPolicyTypeChannel, + ID: channel.Id, + }, + Action: "join_channel", + }) + if evalErr != nil { + return nil, evalErr + } else if !decision.Decision { + return nil, model.NewAppError("AddUserToChannel", "api.channel.add_user.to.channel.rejected", nil, "", http.StatusForbidden) + } + } + } else if appErr != nil { + c.Logger().Error("Error checking access control policy for channel", mlog.Err(appErr)) + } + } + newMember, nErr = a.Srv().Store().Channel().SaveMember(c, newMember) if nErr != nil { return nil, model.NewAppError("AddUserToChannel", "api.channel.add_user.to.channel.failed.app_error", nil, @@ -1989,13 +2031,15 @@ func (a *App) GetAllChannels(c request.CTX, page, perPage int, opts model.Channe opts.ExcludeChannelNames = a.DefaultChannelNames(c) } storeOpts := store.ChannelSearchOpts{ - NotAssociatedToGroup: opts.NotAssociatedToGroup, - IncludeDeleted: opts.IncludeDeleted, - ExcludeChannelNames: opts.ExcludeChannelNames, - GroupConstrained: opts.GroupConstrained, - ExcludeGroupConstrained: opts.ExcludeGroupConstrained, - ExcludePolicyConstrained: opts.ExcludePolicyConstrained, - IncludePolicyID: opts.IncludePolicyID, + NotAssociatedToGroup: opts.NotAssociatedToGroup, + IncludeDeleted: opts.IncludeDeleted, + ExcludeChannelNames: opts.ExcludeChannelNames, + GroupConstrained: opts.GroupConstrained, + ExcludeGroupConstrained: opts.ExcludeGroupConstrained, + ExcludePolicyConstrained: opts.ExcludePolicyConstrained, + IncludePolicyID: opts.IncludePolicyID, + AccessControlPolicyEnforced: opts.AccessControlPolicyEnforced, + ExcludeAccessControlPolicyEnforced: opts.ExcludeAccessControlPolicyEnforced, } channels, err := a.Srv().Store().Channel().GetAllChannels(page*perPage, perPage, storeOpts) if err != nil { @@ -2962,22 +3006,25 @@ func (a *App) SearchAllChannels(c request.CTX, term string, opts model.ChannelSe opts.ExcludeChannelNames = a.DefaultChannelNames(c) } storeOpts := store.ChannelSearchOpts{ - ExcludeChannelNames: opts.ExcludeChannelNames, - NotAssociatedToGroup: opts.NotAssociatedToGroup, - IncludeDeleted: opts.IncludeDeleted, - Deleted: opts.Deleted, - TeamIds: opts.TeamIds, - GroupConstrained: opts.GroupConstrained, - ExcludeGroupConstrained: opts.ExcludeGroupConstrained, - PolicyID: opts.PolicyID, - IncludePolicyID: opts.IncludePolicyID, - IncludeSearchByID: opts.IncludeSearchById, - ExcludeRemote: opts.ExcludeRemote, - ExcludePolicyConstrained: opts.ExcludePolicyConstrained, - Public: opts.Public, - Private: opts.Private, - Page: opts.Page, - PerPage: opts.PerPage, + ExcludeChannelNames: opts.ExcludeChannelNames, + NotAssociatedToGroup: opts.NotAssociatedToGroup, + IncludeDeleted: opts.IncludeDeleted, + Deleted: opts.Deleted, + TeamIds: opts.TeamIds, + GroupConstrained: opts.GroupConstrained, + ExcludeGroupConstrained: opts.ExcludeGroupConstrained, + PolicyID: opts.PolicyID, + IncludePolicyID: opts.IncludePolicyID, + IncludeSearchByID: opts.IncludeSearchById, + ExcludeRemote: opts.ExcludeRemote, + ExcludePolicyConstrained: opts.ExcludePolicyConstrained, + Public: opts.Public, + Private: opts.Private, + Page: opts.Page, + PerPage: opts.PerPage, + AccessControlPolicyEnforced: opts.AccessControlPolicyEnforced, + ExcludeAccessControlPolicyEnforced: opts.ExcludeAccessControlPolicyEnforced, + ParentAccessControlPolicyId: opts.ParentAccessControlPolicyId, } term = strings.TrimSpace(term) @@ -3815,3 +3862,19 @@ func (s *Server) getDirectChannel(c request.CTX, userID, otherUserID string) (*m return channel, nil } + +func (a *App) ChannelAccessControlled(c request.CTX, channelID string) (bool, *model.AppError) { + if l := a.License(); !model.MinimumEnterpriseAdvancedLicense(l) || !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl { + return false, nil + } + + _, err := a.Srv().Store().AccessControlPolicy().Get(c, channelID) + var nfErr *store.ErrNotFound + if err != nil && !errors.As(err, &nfErr) { + return false, model.NewAppError("ChannelIsAccessControlled", "app.channel.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } else if errors.As(err, &nfErr) { + return false, nil + } + + return true, nil +} diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index 953de1747e..3a28c8d5c8 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -64,6 +64,7 @@ type Channels struct { Saml einterfaces.SamlInterface Notification einterfaces.NotificationInterface Ldap einterfaces.LdapInterface + AccessControl einterfaces.AccessControlServiceInterface // These are used to prevent concurrent upload requests // for a given upload session which could cause inconsistencies @@ -132,6 +133,23 @@ func NewChannels(s *Server) (*Channels, error) { } }) } + if accessControlServiceInterface != nil { + app := New(ServerConnector(ch)) + ch.AccessControl = accessControlServiceInterface(app) + + appErr := ch.AccessControl.Init(request.EmptyContext(s.Log())) + if appErr != nil { + s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr)) + } + + app.AddLicenseListener(func(newCfg, old *model.License) { + if ch.AccessControl != nil { + if appErr := ch.AccessControl.Init(request.EmptyContext(s.Log())); appErr != nil { + s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr)) + } + } + }) + } var imgErr error decoderConcurrency := int(*ch.cfgSvc.Config().FileSettings.MaxImageDecoderConcurrency) diff --git a/server/channels/app/enterprise.go b/server/channels/app/enterprise.go index deb4c92613..db4e70fc37 100644 --- a/server/channels/app/enterprise.go +++ b/server/channels/app/enterprise.go @@ -98,6 +98,18 @@ func RegisterIPFilteringInterface(f func(*App) einterfaces.IPFilteringInterface) ipFilteringInterface = f } +var accessControlServiceInterface func(*App) einterfaces.AccessControlServiceInterface + +func RegisterAccessControlServiceInterface(f func(*App) einterfaces.AccessControlServiceInterface) { + accessControlServiceInterface = f +} + +var jobsAccessControlSyncJobInterface func(*Server) ejobs.AccessControlSyncJobInterface + +func RegisterJobsAccessControlSyncJobInterface(f func(*Server) ejobs.AccessControlSyncJobInterface) { + jobsAccessControlSyncJobInterface = f +} + func (s *Server) initEnterprise() { if cloudInterface != nil { s.Cloud = cloudInterface(s) diff --git a/server/channels/app/job.go b/server/channels/app/job.go index d917b0f447..e855fc6efd 100644 --- a/server/channels/app/job.go +++ b/server/channels/app/job.go @@ -108,6 +108,8 @@ func (a *App) SessionHasPermissionToCreateJob(session model.Session, job *model. model.JobTypeCloud, model.JobTypeExtractContent: return a.SessionHasPermissionTo(session, model.PermissionManageJobs), model.PermissionManageJobs + case model.JobTypeAccessControlSync: + return a.SessionHasPermissionTo(session, model.PermissionManageSystem), model.PermissionManageSystem } return false, nil @@ -142,6 +144,8 @@ func (a *App) SessionHasPermissionToManageJob(session model.Session, job *model. model.JobTypeCloud, model.JobTypeExtractContent: permission = model.PermissionManageJobs + case model.JobTypeAccessControlSync: + permission = model.PermissionManageSystem } if permission == nil { @@ -178,6 +182,8 @@ func (a *App) SessionHasPermissionToReadJob(session model.Session, jobType strin model.JobTypeMobileSessionMetadata, model.JobTypeExtractContent: return a.SessionHasPermissionTo(session, model.PermissionReadJobs), model.PermissionReadJobs + case model.JobTypeAccessControlSync: + return a.SessionHasPermissionTo(session, model.PermissionManageSystem), model.PermissionManageSystem } return false, nil diff --git a/server/channels/app/platform/enterprise.go b/server/channels/app/platform/enterprise.go index ee158b8f58..b601d908c9 100644 --- a/server/channels/app/platform/enterprise.go +++ b/server/channels/app/platform/enterprise.go @@ -38,8 +38,8 @@ func RegisterMetricsInterface(f func(*PlatformService, string, string) einterfac metricsInterfaceFn = f } -var pdpInterface func(*PlatformService) einterfaces.PolicyDecisionPointInterface +var accessControlServiceInterface func(*PlatformService) einterfaces.AccessControlServiceInterface -func RegisterPdpInterface(f func(*PlatformService) einterfaces.PolicyDecisionPointInterface) { - pdpInterface = f +func RegisterAccessControlServiceInterface(f func(*PlatformService) einterfaces.AccessControlServiceInterface) { + accessControlServiceInterface = f } diff --git a/server/channels/app/platform/service.go b/server/channels/app/platform/service.go index 5130f1c641..93b3a8806d 100644 --- a/server/channels/app/platform/service.go +++ b/server/channels/app/platform/service.go @@ -477,8 +477,8 @@ func (ps *PlatformService) initEnterprise() { ps.licenseManager = licenseInterface(ps) } - if pdpInterface != nil { - ps.pdpService = pdpInterface(ps) + if accessControlServiceInterface != nil { + ps.pdpService = accessControlServiceInterface(ps) } } diff --git a/server/channels/app/server.go b/server/channels/app/server.go index e676c50e40..c65c8f0133 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -1499,6 +1499,11 @@ func (s *Server) initJobs() { s.Jobs.RegisterJobType(model.JobTypeLdapSync, builder.MakeWorker(), builder.MakeScheduler()) } + if jobsAccessControlSyncJobInterface != nil { + builder := jobsAccessControlSyncJobInterface(s) + s.Jobs.RegisterJobType(model.JobTypeAccessControlSync, builder.MakeWorker(), builder.MakeScheduler()) + } + s.Jobs.RegisterJobType( model.JobTypeBlevePostIndexing, indexer.MakeWorker(s.Jobs, s.platform.SearchEngine.BleveEngine.(*bleveengine.BleveEngine)), diff --git a/server/channels/app/user.go b/server/channels/app/user.go index f66b22de84..b78985b054 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -2090,6 +2090,26 @@ func (a *App) SearchUsersInChannel(channelID string, term string, options *model func (a *App) SearchUsersNotInChannel(teamID string, channelID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { term = strings.TrimSpace(term) + + ctx := request.EmptyContext(a.Log()) + if ok, err := a.ChannelAccessControlled(ctx, channelID); err != nil { + return nil, err + } else if ok { + acs := a.Srv().Channels().AccessControl + if acs != nil { + users, _, appErr := acs.QueryUsersForResource(ctx, channelID, "*", model.SubjectSearchOptions{ + Term: term, + TeamID: teamID, + Limit: options.Limit, + }) + if appErr != nil { + return nil, appErr + } + + return users, nil + } + } + users, err := a.Srv().Store().User().SearchNotInChannel(teamID, channelID, term, options) if err != nil { return nil, model.NewAppError("SearchUsersNotInChannel", "app.user.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 85bf564cdf..04a6cc41a6 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -267,6 +267,8 @@ channels/db/migrations/mysql/000134_create_access_control_policies.down.sql channels/db/migrations/mysql/000134_create_access_control_policies.up.sql channels/db/migrations/mysql/000135_sidebarchannels_categoryid.down.sql channels/db/migrations/mysql/000135_sidebarchannels_categoryid.up.sql +channels/db/migrations/mysql/000136_create_attribute_view.down.sql +channels/db/migrations/mysql/000136_create_attribute_view.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -535,3 +537,5 @@ channels/db/migrations/postgres/000134_create_access_control_policies.down.sql channels/db/migrations/postgres/000134_create_access_control_policies.up.sql channels/db/migrations/postgres/000135_sidebarchannels_categoryid.down.sql channels/db/migrations/postgres/000135_sidebarchannels_categoryid.up.sql +channels/db/migrations/postgres/000136_create_attribute_view.down.sql +channels/db/migrations/postgres/000136_create_attribute_view.up.sql diff --git a/server/channels/db/migrations/mysql/000136_create_attribute_view.down.sql b/server/channels/db/migrations/mysql/000136_create_attribute_view.down.sql new file mode 100644 index 0000000000..65bde78cc2 --- /dev/null +++ b/server/channels/db/migrations/mysql/000136_create_attribute_view.down.sql @@ -0,0 +1 @@ +DROP VIEW IF EXISTS AttributeView; diff --git a/server/channels/db/migrations/mysql/000136_create_attribute_view.up.sql b/server/channels/db/migrations/mysql/000136_create_attribute_view.up.sql new file mode 100644 index 0000000000..17d1fd774d --- /dev/null +++ b/server/channels/db/migrations/mysql/000136_create_attribute_view.up.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE VIEW AttributeView AS + SELECT + pv.GroupID, + pv.TargetID, + pv.TargetType, + JSON_OBJECTAGG(pf.Name, pv.Value) + AS Attributes + FROM PropertyValues pv + LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID + GROUP BY GroupID, TargetID, TargetType; + \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000136_create_attribute_view.down.sql b/server/channels/db/migrations/postgres/000136_create_attribute_view.down.sql new file mode 100644 index 0000000000..ce537c5b70 --- /dev/null +++ b/server/channels/db/migrations/postgres/000136_create_attribute_view.down.sql @@ -0,0 +1 @@ +DROP MATERIALIZED VIEW IF EXISTS AttributeView; diff --git a/server/channels/db/migrations/postgres/000136_create_attribute_view.up.sql b/server/channels/db/migrations/postgres/000136_create_attribute_view.up.sql new file mode 100644 index 0000000000..06a4ba2954 --- /dev/null +++ b/server/channels/db/migrations/postgres/000136_create_attribute_view.up.sql @@ -0,0 +1,37 @@ +CREATE OR REPLACE PROCEDURE create_attribute_view() +LANGUAGE plpgsql +AS $$ +BEGIN + EXECUTE ' + CREATE MATERIALIZED VIEW IF NOT EXISTS AttributeView AS + SELECT + pv.GroupID, + pv.TargetID, + pv.TargetType, + jsonb_object_agg( + pf.Name, + CASE + WHEN pf.Type = ''select'' THEN ( + SELECT to_jsonb(options.name) + FROM jsonb_to_recordset(pf.Attrs->''options'') AS options(id text, name text) + WHERE options.id = pv.Value #>> ''{}'' + LIMIT 1 + ) + WHEN pf.Type = ''multiselect'' THEN ( + SELECT jsonb_agg(option_names.name) + FROM jsonb_array_elements_text(pv.Value) AS option_id + JOIN jsonb_to_recordset(pf.Attrs->''options'') AS option_names(id text, name text) + ON option_id = option_names.id + ) + ELSE pv.Value + END + ) AS Attributes FROM PropertyValues pv + LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID + WHERE pv.DeleteAt = 0 OR pv.DeleteAt IS NULL + GROUP BY pv.GroupID, pv.TargetID, pv.TargetType + '; +END; +$$; + +call create_attribute_view(); +DROP PROCEDURE create_attribute_view(); diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 0f2208f081..7241564c9a 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -24,6 +24,7 @@ const mySQLDeadlockCode = uint16(1213) type RetryLayer struct { store.Store AccessControlPolicyStore store.AccessControlPolicyStore + AttributesStore store.AttributesStore AuditStore store.AuditStore BotStore store.BotStore ChannelStore store.ChannelStore @@ -79,6 +80,10 @@ func (s *RetryLayer) AccessControlPolicy() store.AccessControlPolicyStore { return s.AccessControlPolicyStore } +func (s *RetryLayer) Attributes() store.AttributesStore { + return s.AttributesStore +} + func (s *RetryLayer) Audit() store.AuditStore { return s.AuditStore } @@ -280,6 +285,11 @@ type RetryLayerAccessControlPolicyStore struct { Root *RetryLayer } +type RetryLayerAttributesStore struct { + store.AttributesStore + Root *RetryLayer +} + type RetryLayerAuditStore struct { store.AuditStore Root *RetryLayer @@ -583,27 +593,6 @@ func (s *RetryLayerAccessControlPolicyStore) Get(c request.CTX, id string) (*mod } -func (s *RetryLayerAccessControlPolicyStore) GetAll(rctxc request.CTX, opts store.GetPolicyOptions) ([]*model.AccessControlPolicy, error) { - - tries := 0 - for { - result, err := s.AccessControlPolicyStore.GetAll(rctxc, opts) - if err == nil { - return result, nil - } - if !isRepeatableError(err) { - return result, err - } - tries++ - if tries >= 3 { - err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") - return result, err - } - timepkg.Sleep(100 * timepkg.Millisecond) - } - -} - func (s *RetryLayerAccessControlPolicyStore) Save(c request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, error) { tries := 0 @@ -625,6 +614,27 @@ func (s *RetryLayerAccessControlPolicyStore) Save(c request.CTX, policy *model.A } +func (s *RetryLayerAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) { + + tries := 0 + for { + result, resultVar1, err := s.AccessControlPolicyStore.SearchPolicies(rctx, opts) + if err == nil { + return result, resultVar1, nil + } + if !isRepeatableError(err) { + return result, resultVar1, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerAccessControlPolicyStore) SetActiveStatus(c request.CTX, id string, active bool) (*model.AccessControlPolicy, error) { tries := 0 @@ -646,6 +656,90 @@ func (s *RetryLayerAccessControlPolicyStore) SetActiveStatus(c request.CTX, id s } +func (s *RetryLayerAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) { + + tries := 0 + for { + result, err := s.AttributesStore.GetChannelMembersToRemove(rctx, channelID, opts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) { + + tries := 0 + for { + result, err := s.AttributesStore.GetSubject(rctx, ID, groupID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerAttributesStore) RefreshAttributes() error { + + tries := 0 + for { + err := s.AttributesStore.RefreshAttributes() + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) { + + tries := 0 + for { + result, resultVar1, err := s.AttributesStore.SearchUsers(rctx, opts) + if err == nil { + return result, resultVar1, nil + } + if !isRepeatableError(err) { + return result, resultVar1, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerAuditStore) Get(userID string, offset int, limit int) (model.Audits, error) { tries := 0 @@ -16513,6 +16607,7 @@ func New(childStore store.Store) *RetryLayer { } newStore.AccessControlPolicyStore = &RetryLayerAccessControlPolicyStore{AccessControlPolicyStore: childStore.AccessControlPolicy(), Root: &newStore} + newStore.AttributesStore = &RetryLayerAttributesStore{AttributesStore: childStore.Attributes(), Root: &newStore} newStore.AuditStore = &RetryLayerAuditStore{AuditStore: childStore.Audit(), Root: &newStore} newStore.BotStore = &RetryLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &RetryLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index f89777623c..84cb02c721 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -67,6 +67,7 @@ func genStore() *mocks.Store { mock.On("PropertyGroup").Return(&mocks.PropertyGroupStore{}) mock.On("PropertyValue").Return(&mocks.PropertyValueStore{}) mock.On("AccessControlPolicy").Return(&mocks.AccessControlPolicyStore{}) + mock.On("Attributes").Return(&mocks.AttributesStore{}) return mock } diff --git a/server/channels/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go index c07a82052f..8602260d7d 100644 --- a/server/channels/store/sqlstore/access_control_policy_store.go +++ b/server/channels/store/sqlstore/access_control_policy_store.go @@ -4,6 +4,7 @@ package sqlstore import ( + "bytes" "database/sql" "encoding/json" "fmt" @@ -17,6 +18,8 @@ import ( sq "github.com/mattermost/squirrel" ) +const MaxPerPage = 1000 + // Usually rules are how we define the policy, hence the versioning. For v0.1, we also // have the imports field which is used to link with the parent policy. type accessControlPolicyV0_1 struct { @@ -152,7 +155,7 @@ func newSqlAccessControlPolicyStore(sqlStore *SqlStore, metrics einterfaces.Metr return s } -func preSaveAccessControlPolicy(policy, existingPolicy *model.AccessControlPolicy) { +func preSaveAccessControlPolicy(policy *storeAccessControlPolicy, existingPolicy *model.AccessControlPolicy) { // since policies are immutable, we need to create a new revision // also if it's going to be saved, eventually it will be the new one // we overwrite createAt to make sure it gets the correct timestamp before saving @@ -181,38 +184,6 @@ func (s *SqlAccessControlPolicyStore) Save(rctx request.CTX, policy *model.Acces return nil, errors.Wrapf(err, "failed to fetch policy with id=%s", policy.ID) } - if existingPolicy != nil { - // move existing policy to history - tmp, err2 := fromModel(existingPolicy) - if err2 != nil { - return nil, errors.Wrapf(err2, "failed to parse policy with id=%s", policy.ID) - } - - data := tmp.Data - props := tmp.Props - if s.IsBinaryParamEnabled() { - data = AppendBinaryFlag(data) - props = AppendBinaryFlag(props) - } - - query := s.getQueryBuilder(). - Insert("AccessControlPolicyHistory"). - Columns(accessControlPolicyHistorySliceColumns()...). - Values(tmp.ID, tmp.Name, tmp.Type, tmp.CreateAt, tmp.Revision, tmp.Version, data, props) - - _, err = tx.ExecBuilder(query) - if err != nil { - return nil, errors.Wrapf(err, "failed to save policy with id=%s to history", policy.ID) - } - - err = s.deleteT(rctx, tx, existingPolicy.ID) - if err != nil { - return nil, errors.Wrapf(err, "failed to delete policy with id=%s", policy.ID) - } - } - - preSaveAccessControlPolicy(policy, existingPolicy) - storePolicy, err := fromModel(policy) if err != nil { return nil, errors.Wrapf(err, "failed to parse policy with Id=%s", policy.ID) @@ -225,6 +196,57 @@ func (s *SqlAccessControlPolicyStore) Save(rctx request.CTX, policy *model.Acces props = AppendBinaryFlag(props) } + if existingPolicy != nil { + if existingPolicy.Type != policy.Type { + return nil, errors.New("cannot change type of existing policy") + } + + // move existing policy to history + tmp, err2 := fromModel(existingPolicy) + if err2 != nil { + return nil, errors.Wrapf(err2, "failed to parse policy with id=%s", policy.ID) + } + + // Check if the policy has actually changed + // We compare data, name, and version fields, and ensure type hasn't changed + if bytes.Equal(storePolicy.Data, tmp.Data) && + storePolicy.Name == tmp.Name && + storePolicy.Version == tmp.Version { + return existingPolicy, nil + } + + existingData := tmp.Data + existingProps := tmp.Props + if s.IsBinaryParamEnabled() { + existingData = AppendBinaryFlag(existingData) + existingProps = AppendBinaryFlag(existingProps) + } + + query := s.getQueryBuilder(). + Insert("AccessControlPolicyHistory"). + Columns(accessControlPolicyHistorySliceColumns()...). + Values(tmp.ID, tmp.Name, tmp.Type, tmp.CreateAt, tmp.Revision, tmp.Version, existingData, existingProps) + + _, err = tx.ExecBuilder(query) + if err != nil { + return nil, errors.Wrapf(err, "failed to save policy with id=%s to history", policy.ID) + } + + err = s.deleteT(rctx, tx, existingPolicy.ID) + if err != nil { + return nil, errors.Wrapf(err, "failed to delete policy with id=%s", policy.ID) + } + } else { + // if there is no existing policy, also check the history table + // to make sure we are not overwriting an existing policy + existingPolicy, err = s.getHistoryT(rctx, tx, policy.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, errors.Wrapf(err, "failed to fetch policy with id=%s", policy.ID) + } + } + + preSaveAccessControlPolicy(storePolicy, existingPolicy) + query := s.getQueryBuilder(). Insert("AccessControlPolicies"). Columns(accessControlPolicySliceColumns()...). @@ -329,11 +351,29 @@ func (s *SqlAccessControlPolicyStore) SetActiveStatus(rctx request.CTX, id strin if err != nil { return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id) } - _, err = tx.Query(query, args...) + _, err = tx.Exec(query, args...) if err != nil { return nil, errors.Wrapf(err, "failed to update policy with id=%s", id) } + if existingPolicy.Type == model.AccessControlPolicyTypeParent { + // if the policy is a parent, we need to update the child policies + var expr sq.Sqlizer + if s.DriverName() == model.DatabaseDriverPostgres { + expr = sq.Expr("Data->'imports' @> ?::jsonb", fmt.Sprintf("%q", id)) + } else { + expr = sq.Expr("JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?)", fmt.Sprintf("%q", id)) + } + query, args, err = s.getQueryBuilder().Update("AccessControlPolicies").Set("Active", active).Where(expr).ToSql() + if err != nil { + return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id) + } + _, err = tx.Exec(query, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to update child policies with id=%s", id) + } + } + if err = tx.Commit(); err != nil { return nil, errors.Wrap(err, "commit_transaction") } @@ -345,7 +385,7 @@ func (s *SqlAccessControlPolicyStore) Get(_ request.CTX, id string) (*model.Acce p := storeAccessControlPolicy{} query := s.selectQueryBuilder.Where(sq.Eq{"ID": id}) - err := s.GetReplica().GetBuilder(&p, query) + err := s.GetMaster().GetBuilder(&p, query) if err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("AccessControlPolicy", id) @@ -388,7 +428,35 @@ func (s *SqlAccessControlPolicyStore) getT(_ request.CTX, tx *sqlxTxWrapper, id return policy, nil } -func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts store.GetPolicyOptions) ([]*model.AccessControlPolicy, error) { +func (s *SqlAccessControlPolicyStore) getHistoryT(_ request.CTX, tx *sqlxTxWrapper, id string) (*model.AccessControlPolicy, error) { + query := s.getQueryBuilder(). + Select(accessControlPolicyHistorySliceColumns()...). + From("AccessControlPolicyHistory"). + Where( + sq.Eq{"ID": id}, + ).OrderBy("Revision DESC"). + Limit(1) + + sql, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id) + } + + var storePolicy storeAccessControlPolicy + err = tx.Get(&storePolicy, sql, args...) + if err != nil { + return nil, err + } + + policy, err := storePolicy.toModel() + if err != nil { + return nil, errors.Wrapf(err, "failed to parse policy with id=%s", id) + } + + return policy, nil +} + +func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts model.GetAccessControlPolicyOptions) ([]*model.AccessControlPolicy, model.AccessControlPolicyCursor, error) { p := []storeAccessControlPolicy{} query := s.selectQueryBuilder @@ -404,18 +472,156 @@ func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts store.GetPolicy query = query.Where(sq.Eq{"Type": opts.Type}) } + cursor := opts.Cursor + + if !cursor.IsEmpty() { + query = query.Where(sq.Or{ + sq.Gt{"Id": cursor.ID}, + }) + } + + limit := uint64(opts.Limit) + if limit < 1 { + limit = 10 + } else if limit > MaxPerPage { + limit = MaxPerPage + } + + query = query.Limit(limit) + err := s.GetReplica().SelectBuilder(&p, query) if err != nil { - return nil, errors.Wrapf(err, "failed to find policies with opts={\"parentID\"=%q, \"resourceType\"=%q", opts.ParentID, opts.Type) + return nil, cursor, errors.Wrapf(err, "failed to find policies with opts={\"parentID\"=%q, \"resourceType\"=%q", opts.ParentID, opts.Type) } policies := make([]*model.AccessControlPolicy, len(p)) for i := range p { policies[i], err = p[i].toModel() if err != nil { - return nil, errors.Wrapf(err, "failed to parse policy with id=%s", p[i].ID) + return nil, cursor, errors.Wrapf(err, "failed to parse policy with id=%s", p[i].ID) } } - return policies, nil + if len(policies) != 0 { + cursor.ID = policies[len(policies)-1].ID + } + + return policies, cursor, nil +} + +func (s *SqlAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) { + type wrapper struct { + storeAccessControlPolicy + ChildIDs json.RawMessage + } + + p := []wrapper{} + var query sq.SelectBuilder + if opts.IncludeChildren && opts.ParentID == "" { + columns := accessControlPolicySliceColumns("p") + if s.DriverName() == model.DatabaseDriverPostgres { + childIDs := `COALESCE((SELECT JSON_AGG(c.ID) + FROM AccessControlPolicies c + WHERE c.Type != 'parent' + AND c.Data->'imports' @> JSONB_BUILD_ARRAY(p.ID)), '[]'::json) AS ChildIDs` + columns = append(columns, childIDs) + } else { + childIDs := `COALESCE((SELECT JSON_ARRAYAGG(c.ID) + FROM AccessControlPolicies c + WHERE c.Type != 'parent' + AND JSON_SEARCH(c.Data->'$.imports', 'one', p.ID) IS NOT NULL), JSON_ARRAY()) AS ChildIDs` + columns = append(columns, childIDs) + } + query = s.getQueryBuilder().Select(columns...).From("AccessControlPolicies p") + } else { + query = s.selectQueryBuilder + } + + count := s.getQueryBuilder().Select("COUNT(*)").From("AccessControlPolicies") + + if opts.Term != "" { + condition := sq.Like{"Name": fmt.Sprintf("%%%s%%", opts.Term)} + query = query.Where(condition) + count = count.Where(condition) + } + + if opts.Type != "" { + condition := sq.Eq{"Type": opts.Type} + query = query.Where(condition) + count = count.Where(condition) + } + + if opts.ParentID != "" { + if s.DriverName() == model.DatabaseDriverPostgres { + condition := sq.Expr("Data->'imports' @> ?", fmt.Sprintf("%q", opts.ParentID)) + query = query.Where(condition) + count = count.Where(condition) + } else { + condition := sq.Expr("JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?)", fmt.Sprintf("%q", opts.ParentID)) + query = query.Where(condition) + count = count.Where(condition) + } + } + + if opts.Active { + query = query.Where(sq.Eq{"Active": true}) + count = count.Where(sq.Eq{"Active": true}) + } + + cursor := opts.Cursor + + if !cursor.IsEmpty() { + query = query.Where(sq.Gt{"Id": cursor.ID}) + } + + limit := uint64(opts.Limit) + if limit < 1 { + limit = 10 + } else if limit > MaxPerPage { + limit = MaxPerPage + } + + query = query.Limit(limit) + + err := s.GetReplica().SelectBuilder(&p, query) + if err != nil { + return nil, 0, errors.Wrapf(err, "failed to find policies with opts={\"name\"=%q, \"resourceType\"=%q", opts.Term, opts.Type) + } + + policies := make([]*model.AccessControlPolicy, len(p)) + for i := range p { + m, err2 := p[i].toModel() + if err2 != nil { + return nil, 0, errors.Wrapf(err2, "failed to parse policy with id=%s", p[i].ID) + } + + // Props field is not guaranteed to be persisted correctly, and it shouldn't be. + // This is a field that we want to include metadata, some values may be stored but + // not all of them. For example for the childs, we don't want to update it whenever a + // child policy changes. + if opts.IncludeChildren && opts.ParentID == "" { + if m.Props == nil { + m.Props = make(map[string]any) + } + // Unmarshal the JSON array into a slice of strings + var childIDs []string + if err = json.Unmarshal(p[i].ChildIDs, &childIDs); err != nil { + return nil, 0, errors.Wrapf(err, "failed to unmarshal child IDs for policy with id=%s", p[i].ID) + } + m.Props["child_ids"] = childIDs + } + policies[i] = m + } + + var total int64 + err = s.GetReplica().GetBuilder(&total, count) + if err != nil { + return nil, 0, errors.Wrapf(err, "failed to count policies with opts={\"name\"=%q, \"resourceType\"=%q", opts.Term, opts.Type) + } + + if len(policies) != 0 { + cursor.ID = policies[len(policies)-1].ID + } + + return policies, total, nil } diff --git a/server/channels/store/sqlstore/attributes_store.go b/server/channels/store/sqlstore/attributes_store.go new file mode 100644 index 0000000000..f4deb802ed --- /dev/null +++ b/server/channels/store/sqlstore/attributes_store.go @@ -0,0 +1,253 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/mattermost/mattermost/server/v8/einterfaces" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlAttributesStore struct { + *SqlStore + metrics einterfaces.MetricsInterface + + selectQueryBuilder sq.SelectBuilder +} + +func attributesSliceColumns(prefix ...string) []string { + var p string + if len(prefix) == 1 { + p = prefix[0] + "." + } else if len(prefix) > 1 { + panic("cannot accept multiple prefixes") + } + + return []string{ + p + "TargetID as ID", + p + "TargetType as Type", + p + "Attributes", + } +} + +func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.AttributesStore { + s := &SqlAttributesStore{ + SqlStore: sqlStore, + metrics: metrics, + } + + s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...).From("AttributeView") + + return s +} + +func (s *SqlAttributesStore) RefreshAttributes() error { + if s.DriverName() == model.DatabaseDriverPostgres { + if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil { + return errors.Wrap(err, "error refreshing materialized view AttributeView") + } + } + + return nil +} + +func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error) { + query := s.selectQueryBuilder.Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}}) + + q, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "failed to build query for subject") + } + + row := s.GetReplica().QueryRowxContext(rctx.Context(), q, args...) + if err := row.Err(); err != nil { + return nil, errors.Wrap(err, "failed to get subject") + } + + var subject model.Subject + var properties []byte + + if err := row.Scan(&subject.ID, &subject.Type, &properties); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Attributes", ID) + } + return nil, errors.Wrap(err, "failed to scan subject row") + } + + if err := json.Unmarshal(properties, &subject.Attributes); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal attributes") + } + + return &subject, nil +} + +func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) { + query := s.getQueryBuilder(). + Select(getUsersColumns()...).From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID"). + OrderBy("Users.Id ASC") + + count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID") + + if opts.Query != "" { + query = query.Where(sq.Expr(opts.Query, opts.Args...)) + count = count.Where(sq.Expr(opts.Query, opts.Args...)) + } + + argCount := len(opts.Args) + + if opts.Limit > 0 { + query = query.Limit(uint64(opts.Limit)) + } else if opts.Limit > MaxPerPage { + query = query.Limit(uint64(MaxPerPage)) + } + + if !opts.AllowInactive { + query = query.Where("Users.DeleteAt = 0") + count = count.Where("Users.DeleteAt = 0") + } + + if opts.TeamID != "" { + argCount++ + if s.DriverName() == model.DatabaseDriverMysql { + query = query.Where("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = ? AND DeleteAt = 0)", opts.TeamID) + count = count.Where("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = ? AND DeleteAt = 0)", opts.TeamID) + } else { + query = query.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID)) + count = count.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID)) + } + } + + if opts.ExcludeChannelMembers != "" { + argCount++ + if s.DriverName() == model.DatabaseDriverMysql { + query = query.Where(sq.Expr("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = ?)", opts.ExcludeChannelMembers)) + } else { + query = query.Where(sq.Expr(fmt.Sprintf("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = $%d)", argCount), opts.ExcludeChannelMembers)) + } + } + + if opts.Cursor.TargetID != "" { + argCount++ + if s.DriverName() == model.DatabaseDriverMysql { + query = query.Where(sq.Expr("TargetID > ?", opts.Cursor.TargetID)) + } else { + query = query.Where(sq.Expr(fmt.Sprintf("TargetID > $%d", argCount), opts.Cursor.TargetID)) + } + } + + searchFields := make([]string, 0, len(UserSearchTypeNames)) + for _, field := range UserSearchTypeNames { + searchFields = append(searchFields, strings.Join([]string{"Users", field}, ".")) + } + + if term := opts.Term; strings.TrimSpace(term) != "" { + _, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount) + _, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount) + } + + q, args, err := query.ToSql() + if err != nil { + return nil, 0, errors.Wrap(err, "failed to build query for subjects") + } + + users := []*model.User{} + if err = s.GetReplica().Select(&users, q, args...); err != nil { + return nil, 0, errors.Wrapf(err, "failed to find Users with term=%s and searchType=%v", opts.Term, searchFields) + } + + for _, u := range users { + u.Sanitize(map[string]bool{}) + } + + var total int64 + + if !opts.IgnoreCount { + err = s.GetReplica().GetBuilder(&total, count) + if err != nil { + return nil, 0, errors.Wrapf(err, "failed to count Users with term=%s and searchType=%v", opts.Term, searchFields) + } + } + + return users, total, nil +} + +func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) { + query := s.getQueryBuilder(). + Select(channelMemberSliceColumns()...).From("ChannelMembers").LeftJoin("AttributeView ON ChannelMembers.UserId = AttributeView.TargetID"). + OrderBy("ChannelMembers.UserId ASC") + + if opts.Query != "" { + query = query.Where(sq.Expr(fmt.Sprintf("(NOT (%s) OR AttributeView.TargetID IS NULL)", opts.Query), opts.Args...)) + } + + argCount := len(opts.Args) + + if s.DriverName() == model.DatabaseDriverMysql { + query = query.Where(sq.Eq{"ChannelMembers.ChannelId": channelID}) + } else { + argCount++ + query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.ChannelId = $%d", argCount), channelID)) + } + + if opts.Limit > 0 { + query = query.Limit(uint64(opts.Limit)) + } else if opts.Limit > MaxPerPage { + query = query.Limit(uint64(MaxPerPage)) + } + + if opts.Cursor.TargetID != "" { + argCount++ + if s.DriverName() == model.DatabaseDriverMysql { + query = query.Where(sq.Expr("ChannelMembers.UserId > ?", opts.Cursor.TargetID)) + } else { + query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.UserId > $%d", argCount), opts.Cursor.TargetID)) + } + } + + q, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "failed to build query for subjects") + } + + members := []*model.ChannelMember{} + if err := s.GetReplica().Select(&members, q, args...); err != nil { + return nil, errors.Wrapf(err, "failed to find channel members with for channel id=%s", channelID) + } + + return members, nil +} + +func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, isPostgreSQL bool, prevArgs int) (int, sq.SelectBuilder) { + for _, term := range terms { + searchFields := []string{} + termArgs := []any{} + for _, field := range fields { + if isPostgreSQL { + prevArgs++ + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs)) + } else { + searchFields = append(searchFields, fmt.Sprintf("%s LIKE ? escape '*' ", field)) + } + termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@"))) + } + if isPostgreSQL { + prevArgs++ + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs)) + } else { + searchFields = append(searchFields, "Id = ?") + } + termArgs = append(termArgs, strings.TrimLeft(term, "@")) + query = query.Where(fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")), termArgs...) + } + + return prevArgs, query +} diff --git a/server/channels/store/sqlstore/attributes_store_test.go b/server/channels/store/sqlstore/attributes_store_test.go new file mode 100644 index 0000000000..058bd03ab4 --- /dev/null +++ b/server/channels/store/sqlstore/attributes_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" +) + +func TestAttributesStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestAttributesStore) +} diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 01f9ef5909..98fb61a23f 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -108,7 +108,7 @@ func channelMemberSliceColumns() []string { // channelSliceColumns returns fields of the channel as a string slice. // Optionally, you can add a prefix (accepts only 1 value) to the fields. -func channelSliceColumns(prefix ...string) []string { +func channelSliceColumns(isSelect bool, prefix ...string) []string { var p string if len(prefix) == 1 { p = prefix[0] + "." @@ -116,7 +116,7 @@ func channelSliceColumns(prefix ...string) []string { panic("cannot accept multiple prefixes") } - return []string{ + columns := []string{ p + "Id", p + "CreateAt", p + "UpdateAt", @@ -138,6 +138,16 @@ func channelSliceColumns(prefix ...string) []string { p + "LastRootPostAt", p + "BannerInfo", } + + if isSelect { + if p == "" { + p = "Channels." + } + + columns = append(columns, fmt.Sprintf("EXISTS (SELECT 1 FROM AccessControlPolicies acp WHERE acp.ID = %sId) AS PolicyEnforced", p)) + } + + return columns } func channelToSlice(channel *model.Channel) []any { @@ -493,7 +503,7 @@ func newSqlChannelStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface metrics: metrics, } - s.tableSelectQuery = s.getQueryBuilder().Select(channelSliceColumns()...).From("Channels") + s.tableSelectQuery = s.getQueryBuilder().Select(channelSliceColumns(true)...).From("Channels") s.sidebarCategorySelectQuery = s.getQueryBuilder(). Select("SidebarCategories.Id", "SidebarCategories.UserId", "SidebarCategories.TeamId", "SidebarCategories.SortOrder", "SidebarCategories.Sorting", "SidebarCategories.Type", "SidebarCategories.DisplayName", "SidebarCategories.Muted", "SidebarCategories.Collapsed"). @@ -731,7 +741,7 @@ func (s SqlChannelStore) saveChannelT(transaction *sqlxTxWrapper, channel *model insert := s.getQueryBuilder(). Insert("Channels"). - Columns(channelSliceColumns()...). + Columns(channelSliceColumns(false)...). Values(channelToSlice(channel)...) if s.DriverName() == model.DatabaseDriverMysql { insert = insert.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE Id=Id")) @@ -908,7 +918,7 @@ func (s SqlChannelStore) Get(id string, allowFromCache bool) (*model.Channel, er //nolint:unparam func (s SqlChannelStore) GetMany(ids []string, allowFromCache bool) (model.ChannelList, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Eq{"Id": ids}) sql, args, err := query.ToSql() @@ -1070,7 +1080,7 @@ func (s SqlChannelStore) PermanentDeleteMembersByChannel(rctx request.CTX, chann func (s SqlChannelStore) GetChannels(teamId string, userId string, opts *model.ChannelSearchOpts) (model.ChannelList, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("ch")...). + Select(channelSliceColumns(true, "ch")...). From("Channels ch, ChannelMembers cm"). Where( sq.And{ @@ -1125,7 +1135,7 @@ func (s SqlChannelStore) GetChannels(teamId string, userId string, opts *model.C func (s SqlChannelStore) GetChannelsByUser(userId string, includeDeleted bool, lastDeleteAt, pageSize int, fromChannelID string) (model.ChannelList, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels, ChannelMembers"). Where( sq.And{ @@ -1233,7 +1243,7 @@ func (s SqlChannelStore) getAllChannelsQuery(opts store.ChannelSearchOpts, forCo Select("count(c.Id)") } else { selectQuery = s.getQueryBuilder(). - Select(channelSliceColumns("c")...). + Select(channelSliceColumns(true, "c")...). Columns( "Teams.DisplayName AS TeamDisplayName", "Teams.Name AS TeamName", @@ -1280,6 +1290,11 @@ func (s SqlChannelStore) getAllChannelsQuery(opts store.ChannelSearchOpts, forCo if opts.ExcludePolicyConstrained { query = query.Where("RetentionPoliciesChannels.ChannelId IS NULL") } + if opts.ExcludeAccessControlPolicyEnforced { + query = query.Where("c.Id NOT IN (SELECT ID From AccessControlPolicies WHERE Type = ?)", model.AccessControlPolicyTypeChannel) + } else if opts.AccessControlPolicyEnforced { + query = query.InnerJoin("AccessControlPolicies acp ON c.Id = acp.ID") + } return query } @@ -1296,7 +1311,7 @@ func (s SqlChannelStore) GetMoreChannels(teamId string, userId string, offset in }) query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels c ON (c.Id = Channels.Id)"). Where(sq.Eq{ @@ -1321,7 +1336,7 @@ func (s SqlChannelStore) GetPrivateChannelsForTeam(teamId string, offset int, li channels := model.ChannelList{} builder := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Eq{"Type": model.ChannelTypePrivate, "TeamId": teamId, "DeleteAt": 0}). OrderBy("DisplayName"). @@ -1342,7 +1357,7 @@ func (s SqlChannelStore) GetPrivateChannelsForTeam(teamId string, offset int, li func (s SqlChannelStore) GetPublicChannelsForTeam(teamId string, offset int, limit int) (model.ChannelList, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels pc ON (pc.Id = Channels.Id)"). Where(sq.Eq{ @@ -1386,7 +1401,7 @@ func (s SqlChannelStore) GetPublicChannelsByIdsForTeam(teamId string, channelIds var data model.ChannelList builder := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels pc ON (pc.Id = Channels.Id)"). Where(sq.And{ @@ -1481,7 +1496,7 @@ func (s SqlChannelStore) getByNames(teamId string, names []string, allowFromCach } builder := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(cond) @@ -1516,7 +1531,7 @@ func (s SqlChannelStore) GetByName(teamId string, name string, allowFromCache bo func (s SqlChannelStore) getByName(teamId string, name string, includeDeleted bool, allowFromCache bool) (*model.Channel, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Eq{"Name": name}). Where(sq.Or{ @@ -1567,7 +1582,7 @@ func (s SqlChannelStore) GetDeleted(teamId string, offset int, limit int, userId channels := model.ChannelList{} builder := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Or{ sq.Eq{"TeamId": teamId}, @@ -2883,7 +2898,7 @@ func (s SqlChannelStore) GetAll(teamId string) ([]*model.Channel, error) { func (s SqlChannelStore) GetChannelsByIds(channelIds []string, includeDeleted bool) ([]*model.Channel, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns()...). + Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Eq{"Id": channelIds}). OrderBy("Name") @@ -2907,7 +2922,7 @@ func (s SqlChannelStore) GetChannelsByIds(channelIds []string, includeDeleted bo func (s SqlChannelStore) GetChannelsWithTeamDataByIds(channelIDs []string, includeDeleted bool) ([]*model.ChannelWithTeamData, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("c")...). + Select(channelSliceColumns(true, "c")...). Columns( "COALESCE(t.DisplayName, '') As TeamDisplayName", "COALESCE(t.Name, '') AS TeamName", @@ -2937,7 +2952,7 @@ func (s SqlChannelStore) GetChannelsWithTeamDataByIds(channelIDs []string, inclu func (s SqlChannelStore) GetForPost(postId string) (*model.Channel, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("Posts ON Channels.Id = Posts.ChannelId"). Where(sq.Eq{ @@ -3110,7 +3125,7 @@ func (s SqlChannelStore) GetTeamMembersForChannel(channelID string) ([]string, e func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, includeDeleted, isGuest bool) (model.ChannelListWithTeamData, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("c")...). + Select(channelSliceColumns(true, "c")...). Columns( "t.DisplayName AS TeamDisplayName", "t.Name AS TeamName", @@ -3167,7 +3182,7 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc } func (s SqlChannelStore) AutocompleteInTeam(rctx request.CTX, teamID, userID, term string, includeDeleted, isGuest bool) (model.ChannelList, error) { - query := s.getQueryBuilder().Select(channelSliceColumns()...). + query := s.getQueryBuilder().Select(channelSliceColumns(true, "c")...). From("Channels c"). Where(sq.Eq{"c.TeamId": teamID}). OrderBy("c.DisplayName"). @@ -3203,7 +3218,7 @@ func (s SqlChannelStore) AutocompleteInTeam(rctx request.CTX, teamID, userID, te func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID string, term string, includeDeleted bool) (model.ChannelList, error) { // shared query - query := s.getSubQueryBuilder().Select(channelSliceColumns("C")...). + query := s.getSubQueryBuilder().Select(channelSliceColumns(true, "C")...). From("Channels AS C"). Join("ChannelMembers AS CM ON CM.ChannelId = C.Id"). Limit(50). @@ -3294,7 +3309,7 @@ func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID strin func (s SqlChannelStore) autocompleteInTeamForSearchDirectMessages(userID string, term string) ([]*model.Channel, error) { // create the main query query := s.getQueryBuilder(). - Select(channelSliceColumns("C")...). + Select(channelSliceColumns(true, "C")...). Columns("OtherUsers.Username AS DisplayName"). From("Channels AS C"). Join("ChannelMembers AS CM ON CM.ChannelId = C.Id"). @@ -3339,7 +3354,7 @@ func (s SqlChannelStore) autocompleteInTeamForSearchDirectMessages(userID string } func (s SqlChannelStore) SearchInTeam(teamId string, term string, includeDeleted bool) (model.ChannelList, error) { - query := s.getQueryBuilder().Select(channelSliceColumns("Channels")...). + query := s.getQueryBuilder().Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels c ON (c.Id = Channels.Id)"). Where(sq.Eq{"c.TeamId": teamId}). @@ -3361,7 +3376,7 @@ func (s SqlChannelStore) SearchInTeam(teamId string, term string, includeDeleted } func (s SqlChannelStore) SearchArchivedInTeam(teamId string, term string, userId string) (model.ChannelList, error) { - queryBase := s.getQueryBuilder().Select(channelSliceColumns("Channels")...). + queryBase := s.getQueryBuilder().Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("Channels c ON (c.Id = Channels.Id)"). Where(sq.And{ @@ -3405,7 +3420,7 @@ func (s SqlChannelStore) SearchArchivedInTeam(teamId string, term string, userId } func (s SqlChannelStore) SearchForUserInTeam(userId string, teamId string, term string, includeDeleted bool) (model.ChannelList, error) { - query := s.getQueryBuilder().Select(channelSliceColumns("Channels")...). + query := s.getQueryBuilder().Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels c ON (c.Id = Channels.Id)"). Join("ChannelMembers cm ON (c.Id = cm.ChannelId)"). @@ -3441,7 +3456,7 @@ func (s SqlChannelStore) channelSearchQuery(opts *store.ChannelSearchOpts) sq.Se selectQuery = s.getQueryBuilder().Select("count(*)") } else { selectQuery = s.getQueryBuilder(). - Select(channelSliceColumns("c")...) + Select(channelSliceColumns(true, "c")...) if opts.IncludeTeamInfo { selectQuery = selectQuery.Columns( "t.DisplayName AS TeamDisplayName", @@ -3557,6 +3572,18 @@ func (s SqlChannelStore) channelSearchQuery(opts *store.ChannelSearchOpts) sq.Se }) } + if opts.ExcludeAccessControlPolicyEnforced { + query = query.Where("c.Id NOT IN (SELECT ID From AccessControlPolicies WHERE Type = ?)", model.AccessControlPolicyTypeChannel) + } else if opts.ParentAccessControlPolicyId != "" { + if s.DriverName() == model.DatabaseDriverPostgres { + query = query.Where(sq.Expr("c.Id IN (SELECT ID From AccessControlPolicies WHERE Type = ? AND Data->'imports' @> ?)", model.AccessControlPolicyTypeChannel, fmt.Sprintf("%q", opts.ParentAccessControlPolicyId))) + } else { + query = query.Where(sq.Expr("c.Id IN (SELECT ID From AccessControlPolicies WHERE Type = ? AND JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?))", model.AccessControlPolicyTypeChannel, fmt.Sprintf("%q", opts.ParentAccessControlPolicyId))) + } + } else if opts.AccessControlPolicyEnforced { + query = query.InnerJoin("AccessControlPolicies acp ON acp.ID = c.Id") + } + return query } @@ -3601,7 +3628,7 @@ func (s SqlChannelStore) SearchMore(userId string, teamId string, term string) ( "c.DeleteAt": 0, }) - query := s.getQueryBuilder().Select(channelSliceColumns("Channels")...). + query := s.getQueryBuilder().Select(channelSliceColumns(true, "Channels")...). From("Channels"). Join("PublicChannels c ON (c.Id=Channels.Id)"). Where(sq.And{ @@ -3808,7 +3835,7 @@ func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string, isPostgre Having(having). Limit(model.ChannelSearchDefaultLimit) - return s.getQueryBuilder().Select(channelSliceColumns()...). + return s.getQueryBuilder().Select(channelSliceColumns(true)...). From("Channels"). Where(sq.Expr("Id IN (?)", subq)) } @@ -3820,7 +3847,7 @@ func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string, isPostgre having = append(having, sq.Expr(baseLikeTerm, "%"+term+"%")) } - cc := s.getSubQueryBuilder().Select(channelSliceColumns("c")...). + cc := s.getSubQueryBuilder().Select(channelSliceColumns(true, "c")...). From("Channels c"). Join("ChannelMembers cm ON c.Id=cm.ChannelId"). Join("Users u on u.Id = cm.UserId"). @@ -4154,7 +4181,7 @@ func (s SqlChannelStore) ClearAllCustomRoleAssignments() (err error) { func (s SqlChannelStore) GetAllChannelsForExportAfter(limit int, afterId string) ([]*model.ChannelForExport, error) { query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). Columns( "Teams.Name as TeamName", "Schemes.Name as SchemeName", @@ -4222,7 +4249,7 @@ func (s SqlChannelStore) GetChannelMembersForExport(userId string, teamId string func (s SqlChannelStore) GetAllDirectChannelsForExportAfter(limit int, afterId string, includeArchivedChannels bool) ([]*model.DirectChannelForExport, error) { directChannelsForExport := []*model.DirectChannelForExport{} query := s.getQueryBuilder(). - Select(channelSliceColumns("Channels")...). + Select(channelSliceColumns(true, "Channels")...). From("Channels"). Where(sq.And{ sq.Gt{"Channels.Id": afterId}, diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index ac176a111c..8c49510ecf 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -119,6 +119,7 @@ type SqlStoreStores struct { propertyField store.PropertyFieldStore propertyValue store.PropertyValueStore accessControlPolicy store.AccessControlPolicyStore + Attributes store.AttributesStore } type SqlStore struct { @@ -265,6 +266,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.propertyField = newPropertyFieldStore(store) store.stores.propertyValue = newPropertyValueStore(store) store.stores.accessControlPolicy = newSqlAccessControlPolicyStore(store, metrics) + store.stores.Attributes = newSqlAttributesStore(store, metrics) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1085,6 +1087,10 @@ func (ss *SqlStore) AccessControlPolicy() store.AccessControlPolicyStore { return ss.stores.accessControlPolicy } +func (ss *SqlStore) Attributes() store.AttributesStore { + return ss.stores.Attributes +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/server/channels/store/store.go b/server/channels/store/store.go index a51778e03b..775713ec3d 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -96,6 +96,7 @@ type Store interface { PropertyField() PropertyFieldStore PropertyValue() PropertyValueStore AccessControlPolicy() AccessControlPolicyStore + Attributes() AttributesStore } type RetentionPolicyStore interface { @@ -1116,7 +1117,14 @@ type AccessControlPolicyStore interface { Delete(c request.CTX, id string) error SetActiveStatus(c request.CTX, id string, active bool) (*model.AccessControlPolicy, error) Get(c request.CTX, id string) (*model.AccessControlPolicy, error) - GetAll(rctxc request.CTX, opts GetPolicyOptions) ([]*model.AccessControlPolicy, error) + SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) +} + +type AttributesStore interface { + RefreshAttributes() error + GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error) + SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) + GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) } // ChannelSearchOpts contains options for searching channels. @@ -1129,27 +1137,30 @@ type AccessControlPolicyStore interface { // Page page requested, if results are paginated. // PerPage number of results per page, if paginated. type ChannelSearchOpts struct { - Term string - NotAssociatedToGroup string - IncludeDeleted bool - Deleted bool - ExcludeChannelNames []string - TeamIds []string - GroupConstrained bool - ExcludeGroupConstrained bool - PolicyID string - ExcludePolicyConstrained bool - IncludePolicyID bool - IncludeTeamInfo bool - IncludeSearchByID bool - ExcludeRemote bool - CountOnly bool - Public bool - Private bool - Page *int - PerPage *int - LastDeleteAt int - LastUpdateAt int + Term string + NotAssociatedToGroup string + IncludeDeleted bool + Deleted bool + ExcludeChannelNames []string + TeamIds []string + GroupConstrained bool + ExcludeGroupConstrained bool + PolicyID string + ExcludePolicyConstrained bool + IncludePolicyID bool + IncludeTeamInfo bool + IncludeSearchByID bool + ExcludeRemote bool + CountOnly bool + Public bool + Private bool + Page *int + PerPage *int + LastDeleteAt int + LastUpdateAt int + AccessControlPolicyEnforced bool + ExcludeAccessControlPolicyEnforced bool + ParentAccessControlPolicyId string } func (c *ChannelSearchOpts) IsPaginated() bool { @@ -1211,11 +1222,3 @@ type ThreadMembershipImportData struct { // UnreadMentions is the number of unread mentions to set the UnreadMentions field to. UnreadMentions int64 } - -// GetPolicyOptions contains options for filtering policy records. -type GetPolicyOptions struct { - // ParentID will filter policy records where they inherit parent with PolicyID. - ParentID string - // Type will filter policy records where they are associated with the Type. - Type string -} diff --git a/server/channels/store/storetest/access_control_policy_store.go b/server/channels/store/storetest/access_control_policy_store.go index 3116c71d37..609874cb28 100644 --- a/server/channels/store/storetest/access_control_policy_store.go +++ b/server/channels/store/storetest/access_control_policy_store.go @@ -290,24 +290,51 @@ func testAccessControlPolicyStoreGetAll(t *testing.T, rctx request.CTX, ss store require.NoError(t, err) }) + id3 := "zzz" + model.NewId()[3:] // ensure the order of the ID + parentPolicy2 := &model.AccessControlPolicy{ + ID: id3, + Name: "Name", + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"action"}, + Expression: "user.properties.program == \"engineering\"", + }, + }, + } + t.Cleanup(func() { + err = ss.AccessControlPolicy().Delete(rctx, id) + require.NoError(t, err) + }) + + _, err = ss.AccessControlPolicy().Save(rctx, parentPolicy2) + require.NoError(t, err) + require.NotNil(t, parentPolicy) + resourcePolicy, err = ss.AccessControlPolicy().Save(rctx, resourcePolicy) require.NoError(t, err) require.NotNil(t, resourcePolicy) t.Run("GetAll", func(t *testing.T) { - policies, err := ss.AccessControlPolicy().GetAll(rctx, store.GetPolicyOptions{}) + policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{}) require.NoError(t, err) require.NotNil(t, policies) - require.Len(t, policies, 2) + require.Len(t, policies, 3) }) t.Run("GetAll by type", func(t *testing.T) { - policies, err := ss.AccessControlPolicy().GetAll(rctx, store.GetPolicyOptions{Type: model.AccessControlPolicyTypeParent}) + policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{Type: model.AccessControlPolicyTypeParent, IncludeChildren: true}) require.NoError(t, err) require.NotNil(t, policies) - require.Len(t, policies, 1) + require.Len(t, policies, 2) require.Equal(t, parentPolicy.ID, policies[0].ID) + require.Equal(t, map[string]any{"child_ids": []string{resourcePolicy.ID}}, policies[0].Props) + require.Equal(t, map[string]any{"child_ids": []string{}}, policies[1].Props) - policies, err = ss.AccessControlPolicy().GetAll(rctx, store.GetPolicyOptions{Type: model.AccessControlPolicyTypeChannel}) + policies, _, err = ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{Type: model.AccessControlPolicyTypeChannel}) require.NoError(t, err) require.NotNil(t, policies) require.Len(t, policies, 1) @@ -315,13 +342,13 @@ func testAccessControlPolicyStoreGetAll(t *testing.T, rctx request.CTX, ss store }) t.Run("GetAll by parent", func(t *testing.T) { - policies, err := ss.AccessControlPolicy().GetAll(rctx, store.GetPolicyOptions{ParentID: parentPolicy.ID}) + policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ParentID: parentPolicy.ID}) require.NoError(t, err) require.NotNil(t, policies) require.Len(t, policies, 1) require.Equal(t, resourcePolicy.ID, policies[0].ID) - policies, err = ss.AccessControlPolicy().GetAll(rctx, store.GetPolicyOptions{ParentID: model.NewId()}) + policies, _, err = ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ParentID: model.NewId()}) require.NoError(t, err) require.NotNil(t, policies) require.Len(t, policies, 0) diff --git a/server/channels/store/storetest/attributes_store.go b/server/channels/store/storetest/attributes_store.go new file mode 100644 index 0000000000..8595c84cb4 --- /dev/null +++ b/server/channels/store/storetest/attributes_store.go @@ -0,0 +1,282 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/require" +) + +const ( + testPropertyGroupName = "test_property_group" + testPropertyA = "test_property_a" + testPropertyB = "test_property_b" + testPropertyValueA1 = "value_a1" + testPropertyValueA2 = "value_a2" + testPropertyValueB1 = "value_b1" +) + +var ( + testTeamID = model.NewId() +) + +func TestAttributesStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("RefreshAndGet", func(t *testing.T) { testAttributesStoreRefresh(t, rctx, ss) }) + t.Run("SearchUsers", func(t *testing.T) { testAttributesStoreSearchUsers(t, rctx, ss, s) }) +} + +func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.User, string, func()) { + maxUsersPerTeam := 50 + + u1 := model.User{ + Email: MakeEmail(), + Username: model.NewUsername(), + } + + _, err := ss.User().Save(rctx, &u1) + require.NoError(t, err, "couldn't save user") + + _, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: testTeamID, UserId: u1.Id}, maxUsersPerTeam) + require.NoError(t, nErr) + + u2 := model.User{ + Email: MakeEmail(), + Username: model.NewUsername(), + } + _, err = ss.User().Save(rctx, &u2) + require.NoError(t, err, "couldn't save user") + + _, nErr = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: testTeamID, UserId: u2.Id}, maxUsersPerTeam) + require.NoError(t, nErr) + + // user3 does not have any attributes + u3 := model.User{ + Email: MakeEmail(), + Username: model.NewUsername(), + } + + _, err = ss.User().Save(rctx, &u3) + require.NoError(t, err, "couldn't save user") + + // user3 does not have any attributes + u4 := model.User{ + Email: MakeEmail(), + Username: model.NewUsername(), + } + + _, err = ss.User().Save(rctx, &u4) + require.NoError(t, err, "couldn't save user") + + group, err := ss.PropertyGroup().Register(testPropertyGroupName) + require.NoError(t, err) + require.NotZero(t, group.ID) + require.Equal(t, testPropertyGroupName, group.Name) + groupID := group.ID + + fieldA, err := ss.PropertyField().Create(&model.PropertyField{ + GroupID: groupID, + Name: testPropertyA, + Type: model.PropertyFieldTypeText, + }) + require.NoError(t, err) + fieldB, err := ss.PropertyField().Create(&model.PropertyField{ + GroupID: groupID, + Name: testPropertyB, + Type: model.PropertyFieldTypeText, + }) + require.NoError(t, err) + + vala1, err := json.Marshal(testPropertyValueA1) + require.NoError(t, err) + vala2, err := json.Marshal(testPropertyValueA2) + require.NoError(t, err) + valab1, err := json.Marshal(testPropertyValueB1) + require.NoError(t, err) + + pva1, err := ss.PropertyValue().Create(&model.PropertyValue{ + TargetID: u1.Id, + TargetType: "user", + GroupID: groupID, + FieldID: fieldA.ID, + Value: vala1, + }) + require.NoError(t, err) + + pvb1, err := ss.PropertyValue().Create(&model.PropertyValue{ + TargetID: u1.Id, + TargetType: "user", + GroupID: groupID, + FieldID: fieldB.ID, + Value: valab1, + }) + require.NoError(t, err) + + pva2, err := ss.PropertyValue().Create(&model.PropertyValue{ + TargetID: u2.Id, + TargetType: "user", + GroupID: groupID, + FieldID: fieldA.ID, + Value: vala2, + }) + require.NoError(t, err) + + pva3, err := ss.PropertyValue().Create(&model.PropertyValue{ + TargetID: u3.Id, + TargetType: "user", + GroupID: groupID, + FieldID: fieldA.ID, + Value: vala1, + }) + require.NoError(t, err) + + return []*model.User{&u1, &u2, &u3}, groupID, func() { + for _, pv := range []*model.PropertyValue{pva1, pvb1, pva2, pva3} { + dErr := ss.PropertyValue().Delete(groupID, pv.ID) + require.NoError(t, dErr, "couldn't delete property value") + } + for _, field := range []*model.PropertyField{fieldA, fieldB} { + dErr := ss.PropertyField().Delete(groupID, field.ID) + require.NoError(t, dErr, "couldn't delete property field") + } + for _, u := range []*model.User{&u1, &u2, &u3, &u4} { + dErr := ss.User().PermanentDelete(rctx, u.Id) + require.NoError(t, dErr, "couldn't delete user") + } + } +} + +func testAttributesStoreRefresh(t *testing.T, rctx request.CTX, ss store.Store) { + users, groupID, cleanup := createTestUsers(t, rctx, ss) + t.Cleanup(cleanup) + + t.Run("Refresh attributes", func(t *testing.T) { + err := ss.Attributes().RefreshAttributes() + require.NoError(t, err, "couldn't refresh attributes") + + // Check if the attributes are set correctly + for _, user := range users { + subject, err := ss.Attributes().GetSubject(rctx, user.Id, groupID) + require.NoError(t, err, "couldn't get subject") + + require.Equal(t, user.Id, subject.ID) + require.Equal(t, "user", subject.Type) + } + }) + + t.Run("Get non-existing subject", func(t *testing.T) { + subject, err := ss.Attributes().GetSubject(rctx, "non-existing-id", groupID) + require.Error(t, err, "expected error when getting non-existing subject") + require.IsType(t, &store.ErrNotFound{}, err, "expected not found error") + require.Nil(t, subject, "expected nil subject for non-existing ID") + }) +} + +func testAttributesStoreSearchUsers(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + users, _, cleanup := createTestUsers(t, rctx, ss) + t.Cleanup(cleanup) + require.Len(t, users, 3, "expected 3 users") + + err := ss.Attributes().RefreshAttributes() + require.NoError(t, err, "couldn't refresh attributes") + + t.Run("Search users without query", func(t *testing.T) { + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{}) + require.NoError(t, err, "couldn't search users") + require.Len(t, subjects, 4, "expected 4 users") + require.Equal(t, int64(4), count, "expected count 4 users") + }) + + t.Run("Search users without query, limit by team", func(t *testing.T) { + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{ + TeamID: testTeamID, + }) + require.NoError(t, err, "couldn't search users") + require.Len(t, subjects, 2, "expected 2 users") + require.Equal(t, int64(2), count, "expected count 2 users") + }) + + t.Run("Search users with a random value query", func(t *testing.T) { + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{ + Query: "Attributes ->> '$." + testPropertyA + "' = ?", + Args: []any{"random_value"}, + }) + require.NoError(t, err, "couldn't search users") + require.Empty(t, subjects, "expected no users with the query") + require.Equal(t, int64(0), count, "expected count 0 users") + }) + + t.Run("Search users with a valid value query", func(t *testing.T) { + var query string + if s.DriverName() == model.DatabaseDriverMysql { + query = "Attributes ->> '$." + testPropertyB + "' = ?" + } else { + query = "Attributes ->> '" + testPropertyB + "' = $1::text" + } + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{ + Query: query, + Args: []any{testPropertyValueB1}, + }) + require.NoError(t, err, "couldn't search users") + require.Len(t, subjects, 1, "expected 1 user with the query") + require.Equal(t, subjects[0].Id, users[0].Id, "expected user ID to match") + require.Equal(t, int64(1), count, "expected count 1 user") + }) + + t.Run("Search users with a valid value query and limit", func(t *testing.T) { + var query string + if s.DriverName() == model.DatabaseDriverMysql { + query = "Attributes ->> '$." + testPropertyA + "' = ?" + } else { + query = "Attributes ->> '" + testPropertyA + "' = $1::text" + } + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{ + Query: query, + Args: []any{testPropertyValueA1}, + Limit: 1, + }) + require.NoError(t, err, "couldn't search users") + require.Len(t, subjects, 1, "expected 1 user with the query") + if users[0].Id < users[2].Id { + require.Equal(t, subjects[0].Id, users[0].Id, "expected user ID to match") + } else { + require.Equal(t, subjects[0].Id, users[2].Id, "expected user ID to match") + } + require.Equal(t, int64(2), count, "expected count 1 user") + }) + + t.Run("Search users with pagination", func(t *testing.T) { + var query string + if s.DriverName() == model.DatabaseDriverMysql { + query = "Attributes ->> '$." + testPropertyA + "' = ?" + } else { + query = "Attributes ->> '" + testPropertyA + "' = $1::text" + } + + cursor := strings.Repeat("0", 26) + for i := 0; i < 5; i++ { + subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{ + Query: query, + Args: []any{testPropertyValueA1}, + Limit: 1, + Cursor: model.SubjectCursor{ + TargetID: cursor, + }, + }) + if len(subjects) == 0 { + break + } + cursor = subjects[0].Id + + require.NoError(t, err, "couldn't search users") + require.Len(t, subjects, 1, "expected 1 user with the query") + require.Equal(t, int64(2), count, "expected count 2 user with the query") + } + }) +} diff --git a/server/channels/store/storetest/mocks/AccessControlPolicyStore.go b/server/channels/store/storetest/mocks/AccessControlPolicyStore.go index 9413180636..16b37dfc3e 100644 --- a/server/channels/store/storetest/mocks/AccessControlPolicyStore.go +++ b/server/channels/store/storetest/mocks/AccessControlPolicyStore.go @@ -8,8 +8,6 @@ import ( model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" - - store "github.com/mattermost/mattermost/server/v8/channels/store" ) // AccessControlPolicyStore is an autogenerated mock type for the AccessControlPolicyStore type @@ -65,36 +63,6 @@ func (_m *AccessControlPolicyStore) Get(c request.CTX, id string) (*model.Access return r0, r1 } -// GetAll provides a mock function with given fields: rctxc, opts -func (_m *AccessControlPolicyStore) GetAll(rctxc request.CTX, opts store.GetPolicyOptions) ([]*model.AccessControlPolicy, error) { - ret := _m.Called(rctxc, opts) - - if len(ret) == 0 { - panic("no return value specified for GetAll") - } - - var r0 []*model.AccessControlPolicy - var r1 error - if rf, ok := ret.Get(0).(func(request.CTX, store.GetPolicyOptions) ([]*model.AccessControlPolicy, error)); ok { - return rf(rctxc, opts) - } - if rf, ok := ret.Get(0).(func(request.CTX, store.GetPolicyOptions) []*model.AccessControlPolicy); ok { - r0 = rf(rctxc, opts) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*model.AccessControlPolicy) - } - } - - if rf, ok := ret.Get(1).(func(request.CTX, store.GetPolicyOptions) error); ok { - r1 = rf(rctxc, opts) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // Save provides a mock function with given fields: c, policy func (_m *AccessControlPolicyStore) Save(c request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, error) { ret := _m.Called(c, policy) @@ -125,6 +93,43 @@ func (_m *AccessControlPolicyStore) Save(c request.CTX, policy *model.AccessCont return r0, r1 } +// SearchPolicies provides a mock function with given fields: rctx, opts +func (_m *AccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) { + ret := _m.Called(rctx, opts) + + if len(ret) == 0 { + panic("no return value specified for SearchPolicies") + } + + var r0 []*model.AccessControlPolicy + var r1 int64 + var r2 error + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error)); ok { + return rf(rctx, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessControlPolicySearch) []*model.AccessControlPolicy); ok { + r0 = rf(rctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.AccessControlPolicySearch) int64); ok { + r1 = rf(rctx, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, model.AccessControlPolicySearch) error); ok { + r2 = rf(rctx, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + // SetActiveStatus provides a mock function with given fields: c, id, active func (_m *AccessControlPolicyStore) SetActiveStatus(c request.CTX, id string, active bool) (*model.AccessControlPolicy, error) { ret := _m.Called(c, id, active) diff --git a/server/channels/store/storetest/mocks/AttributesStore.go b/server/channels/store/storetest/mocks/AttributesStore.go new file mode 100644 index 0000000000..9062204267 --- /dev/null +++ b/server/channels/store/storetest/mocks/AttributesStore.go @@ -0,0 +1,145 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// AttributesStore is an autogenerated mock type for the AttributesStore type +type AttributesStore struct { + mock.Mock +} + +// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID, opts +func (_m *AttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) { + ret := _m.Called(rctx, channelID, opts) + + if len(ret) == 0 { + panic("no return value specified for GetChannelMembersToRemove") + } + + var r0 []*model.ChannelMember + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.ChannelMember, error)); ok { + return rf(rctx, channelID, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.ChannelMember); ok { + r0 = rf(rctx, channelID, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelMember) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) error); ok { + r1 = rf(rctx, channelID, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSubject provides a mock function with given fields: rctx, ID, groupID +func (_m *AttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) { + ret := _m.Called(rctx, ID, groupID) + + if len(ret) == 0 { + panic("no return value specified for GetSubject") + } + + var r0 *model.Subject + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.Subject, error)); ok { + return rf(rctx, ID, groupID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Subject); ok { + r0 = rf(rctx, ID, groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Subject) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok { + r1 = rf(rctx, ID, groupID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RefreshAttributes provides a mock function with given fields: +func (_m *AttributesStore) RefreshAttributes() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for RefreshAttributes") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SearchUsers provides a mock function with given fields: rctx, opts +func (_m *AttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) { + ret := _m.Called(rctx, opts) + + if len(ret) == 0 { + panic("no return value specified for SearchUsers") + } + + var r0 []*model.User + var r1 int64 + var r2 error + if rf, ok := ret.Get(0).(func(request.CTX, model.SubjectSearchOptions) ([]*model.User, int64, error)); ok { + return rf(rctx, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.SubjectSearchOptions) []*model.User); ok { + r0 = rf(rctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.SubjectSearchOptions) int64); ok { + r1 = rf(rctx, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, model.SubjectSearchOptions) error); ok { + r2 = rf(rctx, opts) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewAttributesStore creates a new instance of AttributesStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAttributesStore(t interface { + mock.TestingT + Cleanup(func()) +}) *AttributesStore { + mock := &AttributesStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index 0b27378b29..85d571bda7 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -44,6 +44,26 @@ func (_m *Store) AccessControlPolicy() store.AccessControlPolicyStore { return r0 } +// Attributes provides a mock function with given fields: +func (_m *Store) Attributes() store.AttributesStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Attributes") + } + + var r0 store.AttributesStore + if rf, ok := ret.Get(0).(func() store.AttributesStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.AttributesStore) + } + } + + return r0 +} + // Audit provides a mock function with given fields: func (_m *Store) Audit() store.AuditStore { ret := _m.Called() diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 7e2f9757bb..7a4935bbe3 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -70,6 +70,7 @@ type Store struct { PropertyFieldStore mocks.PropertyFieldStore PropertyValueStore mocks.PropertyValueStore AccessControlPolicyStore mocks.AccessControlPolicyStore + AttributesStore mocks.AttributesStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -158,6 +159,9 @@ func (s *Store) ReplicaLagTime() error { return nil } func (s *Store) AccessControlPolicy() store.AccessControlPolicyStore { return &s.AccessControlPolicyStore } +func (s *Store) Attributes() store.AttributesStore { + return &s.AttributesStore +} func (s *Store) AssertExpectations(t mock.TestingT) bool { return mock.AssertExpectationsForObjects(t, @@ -202,5 +206,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ChannelBookmarkStore, &s.ScheduledPostStore, &s.AccessControlPolicyStore, + &s.AttributesStore, ) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index b26fdda458..7367116ff4 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -20,6 +20,7 @@ type TimerLayer struct { store.Store Metrics einterfaces.MetricsInterface AccessControlPolicyStore store.AccessControlPolicyStore + AttributesStore store.AttributesStore AuditStore store.AuditStore BotStore store.BotStore ChannelStore store.ChannelStore @@ -75,6 +76,10 @@ func (s *TimerLayer) AccessControlPolicy() store.AccessControlPolicyStore { return s.AccessControlPolicyStore } +func (s *TimerLayer) Attributes() store.AttributesStore { + return s.AttributesStore +} + func (s *TimerLayer) Audit() store.AuditStore { return s.AuditStore } @@ -276,6 +281,11 @@ type TimerLayerAccessControlPolicyStore struct { Root *TimerLayer } +type TimerLayerAttributesStore struct { + store.AttributesStore + Root *TimerLayer +} + type TimerLayerAuditStore struct { store.AuditStore Root *TimerLayer @@ -553,22 +563,6 @@ func (s *TimerLayerAccessControlPolicyStore) Get(c request.CTX, id string) (*mod return result, err } -func (s *TimerLayerAccessControlPolicyStore) GetAll(rctxc request.CTX, opts store.GetPolicyOptions) ([]*model.AccessControlPolicy, error) { - start := time.Now() - - result, err := s.AccessControlPolicyStore.GetAll(rctxc, opts) - - elapsed := float64(time.Since(start)) / float64(time.Second) - if s.Root.Metrics != nil { - success := "false" - if err == nil { - success = "true" - } - s.Root.Metrics.ObserveStoreMethodDuration("AccessControlPolicyStore.GetAll", success, elapsed) - } - return result, err -} - func (s *TimerLayerAccessControlPolicyStore) Save(c request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, error) { start := time.Now() @@ -585,6 +579,22 @@ func (s *TimerLayerAccessControlPolicyStore) Save(c request.CTX, policy *model.A return result, err } +func (s *TimerLayerAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) { + start := time.Now() + + result, resultVar1, err := s.AccessControlPolicyStore.SearchPolicies(rctx, opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AccessControlPolicyStore.SearchPolicies", success, elapsed) + } + return result, resultVar1, err +} + func (s *TimerLayerAccessControlPolicyStore) SetActiveStatus(c request.CTX, id string, active bool) (*model.AccessControlPolicy, error) { start := time.Now() @@ -601,6 +611,70 @@ func (s *TimerLayerAccessControlPolicyStore) SetActiveStatus(c request.CTX, id s return result, err } +func (s *TimerLayerAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) { + start := time.Now() + + result, err := s.AttributesStore.GetChannelMembersToRemove(rctx, channelID, opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AttributesStore.GetChannelMembersToRemove", success, elapsed) + } + return result, err +} + +func (s *TimerLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) { + start := time.Now() + + result, err := s.AttributesStore.GetSubject(rctx, ID, groupID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AttributesStore.GetSubject", success, elapsed) + } + return result, err +} + +func (s *TimerLayerAttributesStore) RefreshAttributes() error { + start := time.Now() + + err := s.AttributesStore.RefreshAttributes() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AttributesStore.RefreshAttributes", success, elapsed) + } + return err +} + +func (s *TimerLayerAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) { + start := time.Now() + + result, resultVar1, err := s.AttributesStore.SearchUsers(rctx, opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AttributesStore.SearchUsers", success, elapsed) + } + return result, resultVar1, err +} + func (s *TimerLayerAuditStore) Get(userID string, offset int, limit int) (model.Audits, error) { start := time.Now() @@ -13021,6 +13095,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay } newStore.AccessControlPolicyStore = &TimerLayerAccessControlPolicyStore{AccessControlPolicyStore: childStore.AccessControlPolicy(), Root: &newStore} + newStore.AttributesStore = &TimerLayerAttributesStore{AttributesStore: childStore.Attributes(), Root: &newStore} newStore.AuditStore = &TimerLayerAuditStore{AuditStore: childStore.Audit(), Root: &newStore} newStore.BotStore = &TimerLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &TimerLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} diff --git a/server/channels/web/params.go b/server/channels/web/params.go index fe5466b8af..b1c69d3d26 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -25,86 +25,88 @@ const ( ) type Params struct { - UserId string - TeamId string - InviteId string - TokenId string - ThreadId string - Timestamp int64 - TimeRange string - ChannelId string - PostId string - PolicyId string - FileId string - Filename string - UploadId string - PluginId string - CommandId string - HookId string - ReportId string - EmojiId string - AppId string - Email string - Username string - TeamName string - ChannelName string - PreferenceName string - EmojiName string - Category string - Service string - JobId string - JobType string - ActionId string - RoleId string - RoleName string - SchemeId string - Scope string - GroupId string - Page int - PerPage int - LogsPerPage int - Permanent bool - RemoteId string - SyncableId string - SyncableType model.GroupSyncableType - BotUserId string - Q string - IsLinked *bool - IsConfigured *bool - NotAssociatedToTeam string - NotAssociatedToChannel string - Paginate *bool - IncludeMemberCount bool - IncludeMemberIDs bool - NotAssociatedToGroup string - ExcludeDefaultChannels bool - LimitAfter int - LimitBefore int - GroupIDs string - IncludeTotalCount bool - IncludeDeleted bool - FilterAllowReference bool - FilterArchived bool - FilterParentTeamPermitted bool - CategoryId string - ExportName string - ExcludePolicyConstrained bool - GroupSource model.GroupSource - FilterHasMember string - IncludeChannelMemberCount string - OutgoingOAuthConnectionID string - ExcludeOffline bool - InChannel string - NotInChannel string - Topic string - CreatorId string - OnlyConfirmed bool - OnlyPlugins bool - IncludeUnconfirmed bool - ExcludeConfirmed bool - ExcludePlugins bool - ExcludeHome bool - ExcludeRemote bool + UserId string + TeamId string + InviteId string + TokenId string + ThreadId string + Timestamp int64 + TimeRange string + ChannelId string + PostId string + PolicyId string + FileId string + Filename string + UploadId string + PluginId string + CommandId string + HookId string + ReportId string + EmojiId string + AppId string + Email string + Username string + TeamName string + ChannelName string + PreferenceName string + EmojiName string + Category string + Service string + JobId string + JobType string + ActionId string + RoleId string + RoleName string + SchemeId string + Scope string + GroupId string + Page int + PerPage int + LogsPerPage int + Permanent bool + RemoteId string + SyncableId string + SyncableType model.GroupSyncableType + BotUserId string + Q string + IsLinked *bool + IsConfigured *bool + NotAssociatedToTeam string + NotAssociatedToChannel string + Paginate *bool + IncludeMemberCount bool + IncludeMemberIDs bool + NotAssociatedToGroup string + ExcludeDefaultChannels bool + LimitAfter int + LimitBefore int + GroupIDs string + IncludeTotalCount bool + IncludeDeleted bool + FilterAllowReference bool + FilterArchived bool + FilterParentTeamPermitted bool + CategoryId string + ExportName string + ExcludePolicyConstrained bool + GroupSource model.GroupSource + FilterHasMember string + IncludeChannelMemberCount string + OutgoingOAuthConnectionID string + ExcludeOffline bool + InChannel string + NotInChannel string + Topic string + CreatorId string + OnlyConfirmed bool + OnlyPlugins bool + IncludeUnconfirmed bool + ExcludeConfirmed bool + ExcludePlugins bool + ExcludeHome bool + ExcludeRemote bool + AccessControlPolicyEnforced bool + ExcludeAccessControlPolicyEnforced bool //Bookmarks ChannelBookmarkId string @@ -277,6 +279,8 @@ func ParamsFromRequest(r *http.Request) *Params { params.IncludeDeleted, _ = strconv.ParseBool(query.Get("include_deleted")) params.ExportName = props["export_name"] params.ExcludePolicyConstrained, _ = strconv.ParseBool(query.Get("exclude_policy_constrained")) + params.AccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("access_control_policy_enforced")) + params.ExcludeAccessControlPolicyEnforced, _ = strconv.ParseBool(query.Get("exclude_access_control_policy_enforced")) if val := query.Get("group_source"); val != "" { switch val { diff --git a/server/einterfaces/access_control.go b/server/einterfaces/access_control.go new file mode 100644 index 0000000000..0909b2839b --- /dev/null +++ b/server/einterfaces/access_control.go @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +// AccessControlServiceInterface is the interface that provides access control +// services. It combines the PolicyAdministrationPointInterface and +// PolicyDecisionPointInterface interfaces to provide a complete access control solution. +type AccessControlServiceInterface interface { + PolicyAdministrationPointInterface + PolicyDecisionPointInterface +} diff --git a/server/einterfaces/jobs/access_control.go b/server/einterfaces/jobs/access_control.go new file mode 100644 index 0000000000..580ad78766 --- /dev/null +++ b/server/einterfaces/jobs/access_control.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package jobs + +import ( + "github.com/mattermost/mattermost/server/public/model" +) + +type AccessControlSyncJobInterface interface { + MakeWorker() model.Worker + MakeScheduler() Scheduler +} diff --git a/server/einterfaces/metrics.go b/server/einterfaces/metrics.go index 0824681c28..1a0714a5a9 100644 --- a/server/einterfaces/metrics.go +++ b/server/einterfaces/metrics.go @@ -135,7 +135,7 @@ type MetricsInterface interface { ObserveDesktopCpuUsage(platform, version, process string, usage float64) ObserveDesktopMemoryUsage(platform, version, process string, usage float64) - ObserveAccessControlEngineInitDuration(value float64) + ObserveAccessControlSearchQueryDuration(value float64) ObserveAccessControlExpressionCompileDuration(value float64) ObserveAccessControlEvaluateDuration(value float64) IncrementAccessControlCacheInvalidation() diff --git a/server/einterfaces/mocks/AccessControlServiceInterface.go b/server/einterfaces/mocks/AccessControlServiceInterface.go new file mode 100644 index 0000000000..3dae00991f --- /dev/null +++ b/server/einterfaces/mocks/AccessControlServiceInterface.go @@ -0,0 +1,402 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// AccessControlServiceInterface is an autogenerated mock type for the AccessControlServiceInterface type +type AccessControlServiceInterface struct { + mock.Mock +} + +// AccessEvaluation provides a mock function with given fields: rctx, accessRequest +func (_m *AccessControlServiceInterface) AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (model.AccessDecision, *model.AppError) { + ret := _m.Called(rctx, accessRequest) + + if len(ret) == 0 { + panic("no return value specified for AccessEvaluation") + } + + var r0 model.AccessDecision + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) (model.AccessDecision, *model.AppError)); ok { + return rf(rctx, accessRequest) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) model.AccessDecision); ok { + r0 = rf(rctx, accessRequest) + } else { + r0 = ret.Get(0).(model.AccessDecision) + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.AccessRequest) *model.AppError); ok { + r1 = rf(rctx, accessRequest) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// CheckExpression provides a mock function with given fields: rctx, expression +func (_m *AccessControlServiceInterface) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) { + ret := _m.Called(rctx, expression) + + if len(ret) == 0 { + panic("no return value specified for CheckExpression") + } + + var r0 []model.CELExpressionError + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) ([]model.CELExpressionError, *model.AppError)); ok { + return rf(rctx, expression) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) []model.CELExpressionError); ok { + r0 = rf(rctx, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.CELExpressionError) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// DeletePolicy provides a mock function with given fields: rctx, id +func (_m *AccessControlServiceInterface) DeletePolicy(rctx request.CTX, id string) *model.AppError { + ret := _m.Called(rctx, id) + + if len(ret) == 0 { + panic("no return value specified for DeletePolicy") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// ExpressionToVisualAST provides a mock function with given fields: rctx, expression +func (_m *AccessControlServiceInterface) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) { + ret := _m.Called(rctx, expression) + + if len(ret) == 0 { + panic("no return value specified for ExpressionToVisualAST") + } + + var r0 *model.VisualExpression + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.VisualExpression, *model.AppError)); ok { + return rf(rctx, expression) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.VisualExpression); ok { + r0 = rf(rctx, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.VisualExpression) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID +func (_m *AccessControlServiceInterface) GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) { + ret := _m.Called(rctx, channelID) + + if len(ret) == 0 { + panic("no return value specified for GetChannelMembersToRemove") + } + + var r0 []*model.ChannelMember + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.ChannelMember, *model.AppError)); ok { + return rf(rctx, channelID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.ChannelMember); ok { + r0 = rf(rctx, channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelMember) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, channelID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetPolicy provides a mock function with given fields: rctx, id +func (_m *AccessControlServiceInterface) GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetPolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, id) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AccessControlPolicy); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetPolicyRuleAttributes provides a mock function with given fields: rctx, policyID, action +func (_m *AccessControlServiceInterface) GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) { + ret := _m.Called(rctx, policyID, action) + + if len(ret) == 0 { + panic("no return value specified for GetPolicyRuleAttributes") + } + + var r0 map[string][]string + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, string) (map[string][]string, *model.AppError)); ok { + return rf(rctx, policyID, action) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string) map[string][]string); ok { + r0 = rf(rctx, policyID, action) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string][]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok { + r1 = rf(rctx, policyID, action) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// Init provides a mock function with given fields: rctx +func (_m *AccessControlServiceInterface) Init(rctx request.CTX) *model.AppError { + ret := _m.Called(rctx) + + if len(ret) == 0 { + panic("no return value specified for Init") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok { + r0 = rf(rctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// NormalizePolicy provides a mock function with given fields: rctx, policy +func (_m *AccessControlServiceInterface) NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, policy) + + if len(ret) == 0 { + panic("no return value specified for NormalizePolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, policy) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok { + r0 = rf(rctx, policy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok { + r1 = rf(rctx, policy) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// QueryUsersForExpression provides a mock function with given fields: rctx, expression, opts +func (_m *AccessControlServiceInterface) QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { + ret := _m.Called(rctx, expression, opts) + + if len(ret) == 0 { + panic("no return value specified for QueryUsersForExpression") + } + + var r0 []*model.User + var r1 int64 + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok { + return rf(rctx, expression, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.User); ok { + r0 = rf(rctx, expression, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) int64); ok { + r1 = rf(rctx, expression, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, model.SubjectSearchOptions) *model.AppError); ok { + r2 = rf(rctx, expression, opts) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) + } + } + + return r0, r1, r2 +} + +// QueryUsersForResource provides a mock function with given fields: rctx, resourceID, action, opts +func (_m *AccessControlServiceInterface) QueryUsersForResource(rctx request.CTX, resourceID string, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { + ret := _m.Called(rctx, resourceID, action, opts) + + if len(ret) == 0 { + panic("no return value specified for QueryUsersForResource") + } + + var r0 []*model.User + var r1 int64 + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok { + return rf(rctx, resourceID, action, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) []*model.User); ok { + r0 = rf(rctx, resourceID, action, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.SubjectSearchOptions) int64); ok { + r1 = rf(rctx, resourceID, action, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.SubjectSearchOptions) *model.AppError); ok { + r2 = rf(rctx, resourceID, action, opts) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) + } + } + + return r0, r1, r2 +} + +// SavePolicy provides a mock function with given fields: rctx, policy +func (_m *AccessControlServiceInterface) SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, policy) + + if len(ret) == 0 { + panic("no return value specified for SavePolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, policy) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok { + r0 = rf(rctx, policy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok { + r1 = rf(rctx, policy) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// NewAccessControlServiceInterface creates a new instance of AccessControlServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAccessControlServiceInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *AccessControlServiceInterface { + mock := &AccessControlServiceInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/AccessControlSyncJobInterface.go b/server/einterfaces/mocks/AccessControlSyncJobInterface.go new file mode 100644 index 0000000000..c19ab469bd --- /dev/null +++ b/server/einterfaces/mocks/AccessControlSyncJobInterface.go @@ -0,0 +1,71 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" + mock "github.com/stretchr/testify/mock" + + model "github.com/mattermost/mattermost/server/public/model" +) + +// AccessControlSyncJobInterface is an autogenerated mock type for the AccessControlSyncJobInterface type +type AccessControlSyncJobInterface struct { + mock.Mock +} + +// MakeScheduler provides a mock function with given fields: +func (_m *AccessControlSyncJobInterface) MakeScheduler() jobs.Scheduler { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MakeScheduler") + } + + var r0 jobs.Scheduler + if rf, ok := ret.Get(0).(func() jobs.Scheduler); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(jobs.Scheduler) + } + } + + return r0 +} + +// MakeWorker provides a mock function with given fields: +func (_m *AccessControlSyncJobInterface) MakeWorker() model.Worker { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MakeWorker") + } + + var r0 model.Worker + if rf, ok := ret.Get(0).(func() model.Worker); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(model.Worker) + } + } + + return r0 +} + +// NewAccessControlSyncJobInterface creates a new instance of AccessControlSyncJobInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAccessControlSyncJobInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *AccessControlSyncJobInterface { + mock := &AccessControlSyncJobInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/MetricsInterface.go b/server/einterfaces/mocks/MetricsInterface.go index 5187025fba..1571ee9933 100644 --- a/server/einterfaces/mocks/MetricsInterface.go +++ b/server/einterfaces/mocks/MetricsInterface.go @@ -308,11 +308,6 @@ func (_m *MetricsInterface) ObserveAPIEndpointDuration(endpoint string, method s _m.Called(endpoint, method, statusCode, originClient, pageLoadContext, elapsed) } -// ObserveAccessControlEngineInitDuration provides a mock function with given fields: value -func (_m *MetricsInterface) ObserveAccessControlEngineInitDuration(value float64) { - _m.Called(value) -} - // ObserveAccessControlEvaluateDuration provides a mock function with given fields: value func (_m *MetricsInterface) ObserveAccessControlEvaluateDuration(value float64) { _m.Called(value) @@ -323,6 +318,11 @@ func (_m *MetricsInterface) ObserveAccessControlExpressionCompileDuration(value _m.Called(value) } +// ObserveAccessControlSearchQueryDuration provides a mock function with given fields: value +func (_m *MetricsInterface) ObserveAccessControlSearchQueryDuration(value float64) { + _m.Called(value) +} + // ObserveClientChannelSwitchDuration provides a mock function with given fields: platform, agent, fresh, userID, elapsed func (_m *MetricsInterface) ObserveClientChannelSwitchDuration(platform string, agent string, fresh string, userID string, elapsed float64) { _m.Called(platform, agent, fresh, userID, elapsed) diff --git a/server/einterfaces/mocks/PolicyAdministrationPointInterface.go b/server/einterfaces/mocks/PolicyAdministrationPointInterface.go new file mode 100644 index 0000000000..53228e5216 --- /dev/null +++ b/server/einterfaces/mocks/PolicyAdministrationPointInterface.go @@ -0,0 +1,372 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// PolicyAdministrationPointInterface is an autogenerated mock type for the PolicyAdministrationPointInterface type +type PolicyAdministrationPointInterface struct { + mock.Mock +} + +// CheckExpression provides a mock function with given fields: rctx, expression +func (_m *PolicyAdministrationPointInterface) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) { + ret := _m.Called(rctx, expression) + + if len(ret) == 0 { + panic("no return value specified for CheckExpression") + } + + var r0 []model.CELExpressionError + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) ([]model.CELExpressionError, *model.AppError)); ok { + return rf(rctx, expression) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) []model.CELExpressionError); ok { + r0 = rf(rctx, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.CELExpressionError) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// DeletePolicy provides a mock function with given fields: rctx, id +func (_m *PolicyAdministrationPointInterface) DeletePolicy(rctx request.CTX, id string) *model.AppError { + ret := _m.Called(rctx, id) + + if len(ret) == 0 { + panic("no return value specified for DeletePolicy") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// ExpressionToVisualAST provides a mock function with given fields: rctx, expression +func (_m *PolicyAdministrationPointInterface) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) { + ret := _m.Called(rctx, expression) + + if len(ret) == 0 { + panic("no return value specified for ExpressionToVisualAST") + } + + var r0 *model.VisualExpression + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.VisualExpression, *model.AppError)); ok { + return rf(rctx, expression) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.VisualExpression); ok { + r0 = rf(rctx, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.VisualExpression) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID +func (_m *PolicyAdministrationPointInterface) GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) { + ret := _m.Called(rctx, channelID) + + if len(ret) == 0 { + panic("no return value specified for GetChannelMembersToRemove") + } + + var r0 []*model.ChannelMember + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.ChannelMember, *model.AppError)); ok { + return rf(rctx, channelID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.ChannelMember); ok { + r0 = rf(rctx, channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelMember) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, channelID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetPolicy provides a mock function with given fields: rctx, id +func (_m *PolicyAdministrationPointInterface) GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetPolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, id) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AccessControlPolicy); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetPolicyRuleAttributes provides a mock function with given fields: rctx, policyID, action +func (_m *PolicyAdministrationPointInterface) GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) { + ret := _m.Called(rctx, policyID, action) + + if len(ret) == 0 { + panic("no return value specified for GetPolicyRuleAttributes") + } + + var r0 map[string][]string + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, string) (map[string][]string, *model.AppError)); ok { + return rf(rctx, policyID, action) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string) map[string][]string); ok { + r0 = rf(rctx, policyID, action) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string][]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok { + r1 = rf(rctx, policyID, action) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// Init provides a mock function with given fields: rctx +func (_m *PolicyAdministrationPointInterface) Init(rctx request.CTX) *model.AppError { + ret := _m.Called(rctx) + + if len(ret) == 0 { + panic("no return value specified for Init") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok { + r0 = rf(rctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// NormalizePolicy provides a mock function with given fields: rctx, policy +func (_m *PolicyAdministrationPointInterface) NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, policy) + + if len(ret) == 0 { + panic("no return value specified for NormalizePolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, policy) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok { + r0 = rf(rctx, policy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok { + r1 = rf(rctx, policy) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// QueryUsersForExpression provides a mock function with given fields: rctx, expression, opts +func (_m *PolicyAdministrationPointInterface) QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { + ret := _m.Called(rctx, expression, opts) + + if len(ret) == 0 { + panic("no return value specified for QueryUsersForExpression") + } + + var r0 []*model.User + var r1 int64 + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok { + return rf(rctx, expression, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.User); ok { + r0 = rf(rctx, expression, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) int64); ok { + r1 = rf(rctx, expression, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, model.SubjectSearchOptions) *model.AppError); ok { + r2 = rf(rctx, expression, opts) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) + } + } + + return r0, r1, r2 +} + +// QueryUsersForResource provides a mock function with given fields: rctx, resourceID, action, opts +func (_m *PolicyAdministrationPointInterface) QueryUsersForResource(rctx request.CTX, resourceID string, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { + ret := _m.Called(rctx, resourceID, action, opts) + + if len(ret) == 0 { + panic("no return value specified for QueryUsersForResource") + } + + var r0 []*model.User + var r1 int64 + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok { + return rf(rctx, resourceID, action, opts) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) []*model.User); ok { + r0 = rf(rctx, resourceID, action, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.SubjectSearchOptions) int64); ok { + r1 = rf(rctx, resourceID, action, opts) + } else { + r1 = ret.Get(1).(int64) + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.SubjectSearchOptions) *model.AppError); ok { + r2 = rf(rctx, resourceID, action, opts) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) + } + } + + return r0, r1, r2 +} + +// SavePolicy provides a mock function with given fields: rctx, policy +func (_m *PolicyAdministrationPointInterface) SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(rctx, policy) + + if len(ret) == 0 { + panic("no return value specified for SavePolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(rctx, policy) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok { + r0 = rf(rctx, policy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok { + r1 = rf(rctx, policy) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// NewPolicyAdministrationPointInterface creates a new instance of PolicyAdministrationPointInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPolicyAdministrationPointInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *PolicyAdministrationPointInterface { + mock := &PolicyAdministrationPointInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/PolicyDecisionPointInterface.go b/server/einterfaces/mocks/PolicyDecisionPointInterface.go index b2b6cb483e..a78e26a07d 100644 --- a/server/einterfaces/mocks/PolicyDecisionPointInterface.go +++ b/server/einterfaces/mocks/PolicyDecisionPointInterface.go @@ -16,24 +16,22 @@ type PolicyDecisionPointInterface struct { } // AccessEvaluation provides a mock function with given fields: rctx, accessRequest -func (_m *PolicyDecisionPointInterface) AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (*model.AccessDecision, *model.AppError) { +func (_m *PolicyDecisionPointInterface) AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (model.AccessDecision, *model.AppError) { ret := _m.Called(rctx, accessRequest) if len(ret) == 0 { panic("no return value specified for AccessEvaluation") } - var r0 *model.AccessDecision + var r0 model.AccessDecision var r1 *model.AppError - if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) (*model.AccessDecision, *model.AppError)); ok { + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) (model.AccessDecision, *model.AppError)); ok { return rf(rctx, accessRequest) } - if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) *model.AccessDecision); ok { + if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) model.AccessDecision); ok { r0 = rf(rctx, accessRequest) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.AccessDecision) - } + r0 = ret.Get(0).(model.AccessDecision) } if rf, ok := ret.Get(1).(func(request.CTX, model.AccessRequest) *model.AppError); ok { diff --git a/server/einterfaces/pap.go b/server/einterfaces/pap.go new file mode 100644 index 0000000000..13aed48547 --- /dev/null +++ b/server/einterfaces/pap.go @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +// PolicyAdministrationPointInterface is the service that manages access control policies. +// It is responsible for creating, updating, and deleting policies. +// Also, it provides methods to check the validity of expressions and to retrieve policies. +type PolicyAdministrationPointInterface interface { + // Init initializes the policy administration point and intiates the CEL engine. + // It is an idempotent operation, meaning that it can be called multiple times. + Init(rctx request.CTX) *model.AppError + // GetPolicyRuleAttributes retrieves the attributes of the given policy. + // It returns a map of attribute names to their values for given action. + GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) + // CheckExpression checks the validity of the given expression using the CEL engine. + // It returns a list of CELExpressionError if the expression is invalid. + // If the expression is valid, it returns an empty list. + CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) + // ExpressionToVisualAST converts the given expression to a visual AST. + ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) + // NormalizePolicy normalizes the given policy by restoring ids back to names. + NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) + // QueryUsersForExpression evaluates the given expression using the CEL engine. + // It returns a list of users that match the expression. + QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) + // QueryUsersForResource evaluates finds the users match to the resource. + QueryUsersForResource(rctx request.CTX, resourceID, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) + // GetChannelMembersToRemove retrieves the channel members that need to be removed from the given channel. + GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) + // SavePolicy saves the given access control policy. + SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) + // GetPolicy retrieves the access control policy with the given ID. + GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) + // DeletePolicy deletes the access control policy with the given ID. + DeletePolicy(rctx request.CTX, id string) *model.AppError +} diff --git a/server/einterfaces/pdp.go b/server/einterfaces/pdp.go index ea526b1d29..4b448dd8b8 100644 --- a/server/einterfaces/pdp.go +++ b/server/einterfaces/pdp.go @@ -12,5 +12,5 @@ import ( // using the OpenID Auth API spec. It determines whether a subject can perform // an action on a resource based on the resource policy. type PolicyDecisionPointInterface interface { - AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (*model.AccessDecision, *model.AppError) + AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (model.AccessDecision, *model.AppError) } diff --git a/server/enterprise/external_imports.go b/server/enterprise/external_imports.go index c6272585c3..a3127fb058 100644 --- a/server/enterprise/external_imports.go +++ b/server/enterprise/external_imports.go @@ -34,4 +34,6 @@ import ( _ "github.com/mattermost/enterprise/ip_filtering" // Needed to ensure the init() method in the EE gets run _ "github.com/mattermost/enterprise/outgoing_oauth_connections" + // Needed to ensure the init() method in the EE gets run + _ "github.com/mattermost/enterprise/access_control" ) diff --git a/server/enterprise/metrics/metrics.go b/server/enterprise/metrics/metrics.go index 9edf959ae8..aa00f27f9f 100644 --- a/server/enterprise/metrics/metrics.go +++ b/server/enterprise/metrics/metrics.go @@ -236,9 +236,9 @@ type MetricsInterfaceImpl struct { DesktopClientCPUUsage *prometheus.HistogramVec DesktopClientMemoryUsage *prometheus.HistogramVec - AccessControlEngineInitDuration prometheus.Histogram AccessControlExpressionCompileDuration prometheus.Histogram AccessControlEvaluateDuration prometheus.Histogram + AccessControlSearchQueryDuration prometheus.Histogram AccessControlCacheInvalidation prometheus.Counter } @@ -1541,34 +1541,31 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf ) m.Registry.MustRegister(m.DesktopClientMemoryUsage) - m.AccessControlEngineInitDuration = prometheus.NewHistogram( - prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystemAccessControl, - Name: "engine_init_duration_seconds", - Help: "Duration of the time taken to initialize the access control engine (seconds)", - ConstLabels: additionalLabels, - }) - m.Registry.MustRegister(m.AccessControlEngineInitDuration) + m.AccessControlSearchQueryDuration = prometheus.NewHistogram( + withLabels(prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemAccessControl, + Name: "search_query_duration_seconds", + Help: "Duration of the time taken to query users against an expression (seconds)", + })) + m.Registry.MustRegister(m.AccessControlSearchQueryDuration) m.AccessControlEvaluateDuration = prometheus.NewHistogram( - prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystemAccessControl, - Name: "evaluate_duration_seconds", - Help: "Duration of the time taken to evaluate the access control engine (seconds)", - ConstLabels: additionalLabels, - }) + withLabels(prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemAccessControl, + Name: "evaluate_duration_seconds", + Help: "Duration of the time taken to evaluate the access control engine (seconds)", + })) m.Registry.MustRegister(m.AccessControlEvaluateDuration) m.AccessControlExpressionCompileDuration = prometheus.NewHistogram( - prometheus.HistogramOpts{ - Namespace: MetricsNamespace, - Subsystem: MetricsSubsystemAccessControl, - Name: "expression_compile_duration_seconds", - Help: "Duration of the time taken to compile the access control engine expression (seconds)", - ConstLabels: additionalLabels, - }) + withLabels(prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemAccessControl, + Name: "expression_compile_duration_seconds", + Help: "Duration of the time taken to compile the access control engine expression (seconds)", + })) m.Registry.MustRegister(m.AccessControlExpressionCompileDuration) m.AccessControlCacheInvalidation = prometheus.NewCounter( @@ -2177,8 +2174,8 @@ func (mi *MetricsInterfaceImpl) ObserveMobileClientSessionMetadata(version, plat mi.MobileClientSessionMetadataGauge.With(prometheus.Labels{"version": version, "platform": platform, "notifications_disabled": notificationDisabled}).Set(value) } -func (mi *MetricsInterfaceImpl) ObserveAccessControlEngineInitDuration(value float64) { - mi.AccessControlEngineInitDuration.Observe(value) +func (mi *MetricsInterfaceImpl) ObserveAccessControlSearchQueryDuration(value float64) { + mi.AccessControlSearchQueryDuration.Observe(value) } func (mi *MetricsInterfaceImpl) ObserveAccessControlExpressionCompileDuration(value float64) { diff --git a/server/go.mod b/server/go.mod index 2a6b8368da..a4b14bfce5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,6 +4,8 @@ go 1.23.0 toolchain go1.23.7 +//replace github.com/mattermost/mattermost/server/public => /Users/ibrahim/go/src/github.com/mattermost/mattermost-server/server/public + require ( code.sajari.com/docconv/v2 v2.0.0-pre.4 github.com/Masterminds/semver/v3 v3.3.1 @@ -224,9 +226,9 @@ require ( golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/tools v0.29.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect - google.golang.org/grpc v1.70.0 // indirect - google.golang.org/protobuf v1.36.4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/server/go.sum b/server/go.sum index 313d2a3e11..db2e8d0da9 100644 --- a/server/go.sum +++ b/server/go.sum @@ -670,10 +670,10 @@ go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -860,14 +860,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -875,8 +875,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= diff --git a/server/i18n/en.json b/server/i18n/en.json index e9c2d7992f..c93a0ab738 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -47,6 +47,14 @@ "id": "September", "translation": "September" }, + { + "id": "api.access_control_policy.get_channels.limit.app_error", + "translation": "Get channels limit is not valid." + }, + { + "id": "api.access_control_policy.get_fields.limit.app_error", + "translation": "Get fields limit is not valid." + }, { "id": "api.acknowledgement.delete.archived_channel.app_error", "translation": "You cannot remove an acknowledgment in an archived channel." @@ -239,6 +247,10 @@ "id": "api.channel.add_user.to.channel.failed.deleted.app_error", "translation": "Failed to add user to channel because they have been removed from the team." }, + { + "id": "api.channel.add_user.to.channel.rejected", + "translation": "User does not have required attributes to join the channel." + }, { "id": "api.channel.add_user_to_channel.type.app_error", "translation": "Can not add user to this channel type." @@ -531,6 +543,10 @@ "id": "api.channel.update_channel.deleted.app_error", "translation": "The channel has been archived or deleted." }, + { + "id": "api.channel.update_channel.not_allowed.app_error", + "translation": "Policy enforced channels cannot be updated." + }, { "id": "api.channel.update_channel.tried.app_error", "translation": "Tried to perform an invalid update of the default channel {{.Channel}}." @@ -4646,6 +4662,10 @@ "id": "app.channel.delete.app_error", "translation": "Unable to delete the channel." }, + { + "id": "app.channel.get.app_error", + "translation": "Could not get channel." + }, { "id": "app.channel.get.existing.app_error", "translation": "Unable to find the existing channel {{.channel_id}}." @@ -6206,6 +6226,94 @@ "id": "app.oauth.update_app.updating.app_error", "translation": "We encountered an error updating the app." }, + { + "id": "app.pap.assign_access_control_policy_to_channels.app_error", + "translation": "Unable to assign access control policy to channels." + }, + { + "id": "app.pap.check_expression.app_error", + "translation": "Could not check expression." + }, + { + "id": "app.pap.create_access_control_policy.app_error", + "translation": "Could not create access control policy." + }, + { + "id": "app.pap.delete_access_control_policy.app_error", + "translation": "Could not delete access control policy." + }, + { + "id": "app.pap.delete_policy.app_error", + "translation": "Unable to delete access control policy." + }, + { + "id": "app.pap.expression_to_visual_ast.app_error", + "translation": "Could not genereate visual AST from expression." + }, + { + "id": "app.pap.get_access_control_auto_complete.app_error", + "translation": "Could not get access control auto complete." + }, + { + "id": "app.pap.get_all_access_control_policies.app_error", + "translation": "Could not get access control policies." + }, + { + "id": "app.pap.get_channel_access_control_attributes.app_error", + "translation": "Could not get attributes for channel." + }, + { + "id": "app.pap.get_channel_members_to_remove.app_error", + "translation": "Could not get channel members to remove." + }, + { + "id": "app.pap.get_policy.app_error", + "translation": "Unable to retrieve the access control policy." + }, + { + "id": "app.pap.get_policy_attributes.app_error", + "translation": "Could not get attributes for policy." + }, + { + "id": "app.pap.init.app_error", + "translation": "Unable to initialize access control service." + }, + { + "id": "app.pap.is_ready.app_error", + "translation": "Access control service is not ready." + }, + { + "id": "app.pap.missing_attribute.app_error", + "translation": "An attribute is missing from the expression." + }, + { + "id": "app.pap.normalize_policy.app_error", + "translation": "Could not normalize policy expression." + }, + { + "id": "app.pap.query_expression.app_error", + "translation": "Could not query for expression." + }, + { + "id": "app.pap.save_policy.app_error", + "translation": "Unable to save access control policy." + }, + { + "id": "app.pap.search_access_control_policies.app_error", + "translation": "Could not search access control policies." + }, + { + "id": "app.pap.unassign_access_control_policy_from_channels.app_error", + "translation": "Could not unassign access control policy from channels." + }, + { + "id": "app.pap.update_access_control_policy_active.app_error", + "translation": "Could not change active status of access control policy." + }, + { + "id": "app.pdp.access_evaluation.app_error", + "translation": "Failed evaluate access control policy." + }, { "id": "app.plugin.cluster.save_config.app_error", "translation": "The plugin configuration in your config.json file must be updated manually when using ReadOnlyConfig with clustering enabled." @@ -7772,6 +7880,10 @@ "id": "common.parse_error_int64", "translation": "Failed to parse the value:{{.Value}} to int64" }, + { + "id": "ent.access_control.sync_job.app_error", + "translation": "Failed to run access control sync job." + }, { "id": "ent.account_migration.get_all_failed", "translation": "Unable to get users." @@ -8552,6 +8664,10 @@ "id": "model.access.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.access_policy.inherit.version.app_error", + "translation": "Could not inherit access control policy." + }, { "id": "model.access_policy.is_valid.id.app_error", "translation": "Invalid policy id." diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index f73675b60b..4cff121255 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -78,6 +78,7 @@ const ( TrackConfigExport = "config_export" TrackConfigWrangler = "config_wrangler" TrackConfigConnectedWorkspaces = "config_connected_workspaces" + TrackConfigAccessControl = "config_access_control" TrackFeatureFlags = "config_feature_flags" TrackPermissionsGeneral = "permissions_general" TrackPermissionsSystemScheme = "permissions_system_scheme" @@ -973,6 +974,11 @@ func (ts *TelemetryService) trackConfig() { "max_posts_per_sync": *cfg.ConnectedWorkspacesSettings.MaxPostsPerSync, } + configs[TrackConfigAccessControl] = map[string]any{ + "enable_attribute_based_access_control": *cfg.AccessControlSettings.EnableAttributeBasedAccessControl, + "enable_channel_scope_access_control": *cfg.AccessControlSettings.EnableChannelScopeAccessControl, + } + // Convert feature flags to map[string]any for sending flags := cfg.FeatureFlags.ToMap() interfaceFlags := make(map[string]any) diff --git a/server/public/model/access_policy.go b/server/public/model/access_policy.go index 77eea09971..69b5d050f0 100644 --- a/server/public/model/access_policy.go +++ b/server/public/model/access_policy.go @@ -4,8 +4,10 @@ package model import ( + "fmt" "slices" + "github.com/pkg/errors" "golang.org/x/mod/semver" ) @@ -18,13 +20,41 @@ const ( AccessControlPolicyVersionV0_1 = "v0.1" ) -// ParentPolicy is a augmented version of AccessPolicy to be used in -// system console and API responses. -type ParentPolicy struct { - ID string `json:"id"` - Name string `json:"name"` - Attributes map[string]string `json:"attributes"` - Children []*AccessControlPolicy `json:"children"` +// AccessControlAttribute represents a user attribute with its name and possible values +type AccessControlAttribute struct { + Attribute PropertyField `json:"attribute"` + Values []string `json:"values"` +} + +type AccessControlPolicyTestResponse struct { + Users []*User `json:"users"` + Total int64 `json:"total"` +} + +type GetAccessControlPolicyOptions struct { + Type string `json:"type"` + ParentID string `json:"parent_id"` + Cursor AccessControlPolicyCursor `json:"cursor"` + Limit int `json:"limit"` +} + +type AccessControlPolicySearch struct { + Term string `json:"term"` + Type string `json:"type"` + ParentID string `json:"parent_id"` + Cursor AccessControlPolicyCursor `json:"cursor"` + Limit int `json:"limit"` + IncludeChildren bool `json:"include_children"` + Active bool `json:"active"` +} + +type AccessControlPolicyCursor struct { + ID string `json:"id"` +} + +type AccessControlPoliciesWithCount struct { + Policies []*AccessControlPolicy `json:"policies"` + Total int64 `json:"total"` } type AccessControlPolicy struct { @@ -48,6 +78,16 @@ type AccessControlPolicyRule struct { Expression string `json:"expression"` } +type CELExpressionError struct { + Line int `json:"line"` + Column int `json:"column"` + Message string `json:"message"` +} + +type AccessControlQueryResult struct { + MatchedSubjectIDs []string `json:"matched_subject_ids"` +} + func (p *AccessControlPolicy) IsValid() *AppError { switch p.Version { case AccessControlPolicyVersionV0_1: @@ -103,3 +143,63 @@ func (p *AccessControlPolicy) accessPolicyVersionV0_1() *AppError { return nil } + +func (p *AccessControlPolicy) Inherit(resourceID, resourceType string) (*AccessControlPolicy, *AppError) { + rules := make([]AccessControlPolicyRule, len(p.Rules)) + + switch p.Version { + case AccessControlPolicyVersionV0_1: + for i, rule := range p.Rules { + actions := make([]string, len(rule.Actions)) + copy(actions, rule.Actions) + rules[i] = AccessControlPolicyRule{ + Actions: actions, + Expression: fmt.Sprintf("policies.id_%s", p.ID), + } + } + default: + return nil, NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400) + } + + child := &AccessControlPolicy{ + ID: resourceID, + Type: resourceType, + Active: p.Active, + CreateAt: GetMillis(), + Version: p.Version, + Imports: []string{p.ID}, + Rules: rules, + + Props: map[string]any{}, + } + + if appErr := child.IsValid(); appErr != nil { + return nil, appErr + } + + return child, nil +} + +func (c *AccessControlPolicyCursor) IsEmpty() bool { + return c.ID == "" +} + +func (c *AccessControlPolicyCursor) IsValid() error { + if c.IsEmpty() { + return nil + } + + if !IsValidId(c.ID) { + return errors.New("cursor id is invalid") + } + + return nil +} + +func (p *AccessControlPolicy) Auditable() map[string]any { + return map[string]any{ + "id": p.ID, + "type": p.Type, + "revision": p.Revision, + } +} diff --git a/server/public/model/access_request.go b/server/public/model/access_request.go index 9165e87752..1bb2ca9f72 100644 --- a/server/public/model/access_request.go +++ b/server/public/model/access_request.go @@ -11,10 +11,30 @@ type Subject struct { ID string `json:"id"` // Type specifies the type of the Subject, eg. user, bot, etc. Type string `json:"type"` - // Properties are the key-value pairs assicuated with the subject. + // Attributes are the key-value pairs assicuated with the subject. // An attribute may be single-valued or multi-valued and can be a primitive type // (string, boolean, number) or a complex type like a JSON object or array. - Properties map[string]any `json:"properties"` + Attributes map[string]any `json:"attributes"` +} + +type SubjectSearchOptions struct { + Term string `json:"term"` + TeamID string `json:"team_id"` + // Query and Args should be generated within the Access Control Service + // and passed here wrt database driver + Query string `json:"query"` + Args []any `json:"args"` + Limit int `json:"limit"` + Cursor SubjectCursor `json:"cursor"` + AllowInactive bool `json:"allow_inactive"` + IgnoreCount bool `json:"ignore_count"` + // ExcludeChannelMembers is used to exclude members from the search results + // specifically used when syncing channel members + ExcludeChannelMembers string `json:"exclude_members"` +} + +type SubjectCursor struct { + TargetID string `json:"target_id"` } // Resource is the target of an access request. @@ -41,3 +61,10 @@ type AccessDecision struct { Decision bool `json:"decision"` Context map[string]any `json:"context,omitempty"` } + +type QueryExpressionParams struct { + Expression string `json:"expression"` + Term string `json:"term"` + Limit int `json:"limit"` + After string `json:"after"` +} diff --git a/server/public/model/cel.go b/server/public/model/cel.go new file mode 100644 index 0000000000..566d0bfd0c --- /dev/null +++ b/server/public/model/cel.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// ValueType indicates whether a value is a literal or another attribute. +type ValueType int + +const ( + LiteralValue ValueType = iota + AttrValue +) + +// Condition represents a single logical condition (e.g., user.attributes.Team == "Engineering"). +type Condition struct { + // Left-hand side attribute selector (e.g., "user.attributes.Team"). + Attribute string `json:"attribute"` + // The comparison operator. + Operator string `json:"operator"` + // Right-hand side value(s). Can be a single value or a slice for 'in'. + Value any `json:"value"` + // Type of the Value (LiteralValue or AttributeValue). Needed for comparisons like user.attr1 == user.attr2. + ValueType ValueType `json:"value_type"` +} + +// VisualExpression represents a series of conditions combined with logical AND. +type VisualExpression struct { + // Conditions is a list of individual conditions that will be ANDed together. + Conditions []Condition `json:"conditions"` +} diff --git a/server/public/model/channel.go b/server/public/model/channel.go index 087f52c4ab..f3cb78702c 100644 --- a/server/public/model/channel.go +++ b/server/public/model/channel.go @@ -99,6 +99,7 @@ type Channel struct { PolicyID *string `json:"policy_id"` LastRootPostAt int64 `json:"last_root_post_at"` BannerInfo *ChannelBannerInfo `json:"banner_info"` + PolicyEnforced bool `json:"policy_enforced"` } func (o *Channel) Auditable() map[string]any { @@ -119,6 +120,7 @@ func (o *Channel) Auditable() map[string]any { "total_msg_count_root": o.TotalMsgCountRoot, "type": o.Type, "update_at": o.UpdateAt, + "policy_enforced": o.PolicyEnforced, } } @@ -209,26 +211,30 @@ type ChannelModeratedRolesPatch struct { // Paginate whether to paginate the results. // Page page requested, if results are paginated. // PerPage number of results per page, if paginated. +// ExcludeAccessPolicyEnforced will exclude channels that are enforced by an access policy. type ChannelSearchOpts struct { - NotAssociatedToGroup string - ExcludeDefaultChannels bool - IncludeDeleted bool // If true, deleted channels will be included in the results. - Deleted bool - ExcludeChannelNames []string - TeamIds []string - GroupConstrained bool - ExcludeGroupConstrained bool - PolicyID string - ExcludePolicyConstrained bool - IncludePolicyID bool - IncludeSearchById bool - ExcludeRemote bool - Public bool - Private bool - Page *int - PerPage *int - LastDeleteAt int // When combined with IncludeDeleted, only channels deleted after this time will be returned. - LastUpdateAt int + NotAssociatedToGroup string + ExcludeDefaultChannels bool + IncludeDeleted bool // If true, deleted channels will be included in the results. + Deleted bool + ExcludeChannelNames []string + TeamIds []string + GroupConstrained bool + ExcludeGroupConstrained bool + PolicyID string + ExcludePolicyConstrained bool + IncludePolicyID bool + IncludeSearchById bool + ExcludeRemote bool + Public bool + Private bool + Page *int + PerPage *int + LastDeleteAt int // When combined with IncludeDeleted, only channels deleted after this time will be returned. + LastUpdateAt int + AccessControlPolicyEnforced bool + ExcludeAccessControlPolicyEnforced bool + ParentAccessControlPolicyId string } type ChannelMemberCountByGroup struct { diff --git a/server/public/model/channel_search.go b/server/public/model/channel_search.go index 6e41f622dd..a3ed027314 100644 --- a/server/public/model/channel_search.go +++ b/server/public/model/channel_search.go @@ -6,19 +6,22 @@ package model const ChannelSearchDefaultLimit = 50 type ChannelSearch struct { - Term string `json:"term"` - ExcludeDefaultChannels bool `json:"exclude_default_channels"` - NotAssociatedToGroup string `json:"not_associated_to_group"` - TeamIds []string `json:"team_ids"` - GroupConstrained bool `json:"group_constrained"` - ExcludeGroupConstrained bool `json:"exclude_group_constrained"` - ExcludePolicyConstrained bool `json:"exclude_policy_constrained"` - Public bool `json:"public"` - Private bool `json:"private"` - IncludeDeleted bool `json:"include_deleted"` - IncludeSearchById bool `json:"include_search_by_id"` - ExcludeRemote bool `json:"exclude_remote"` - Deleted bool `json:"deleted"` - Page *int `json:"page,omitempty"` - PerPage *int `json:"per_page,omitempty"` + Term string `json:"term"` + ExcludeDefaultChannels bool `json:"exclude_default_channels"` + NotAssociatedToGroup string `json:"not_associated_to_group"` + TeamIds []string `json:"team_ids"` + GroupConstrained bool `json:"group_constrained"` + ExcludeGroupConstrained bool `json:"exclude_group_constrained"` + ExcludePolicyConstrained bool `json:"exclude_policy_constrained"` + Public bool `json:"public"` + Private bool `json:"private"` + IncludeDeleted bool `json:"include_deleted"` + IncludeSearchById bool `json:"include_search_by_id"` + ExcludeRemote bool `json:"exclude_remote"` + Deleted bool `json:"deleted"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"per_page,omitempty"` + AccessControlPolicyEnforced bool `json:"access_control_policy_enforced"` + ExcludeAccessControlPolicyEnforced bool `json:"exclude_access_control_policy_enforced"` + ParentAccessControlPolicyId string `json:"parent_access_control_policy_id"` } diff --git a/server/public/model/client4.go b/server/public/model/client4.go index d222edf553..8787475967 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -622,6 +622,18 @@ func (c *Client4) customProfileAttributeValuesRoute() string { return fmt.Sprintf("%s/values", c.customProfileAttributesRoute()) } +func (c *Client4) accessControlPoliciesRoute() string { + return "/access_control_policies" +} + +func (c *Client4) celRoute() string { + return "/access_control_policies/cel" +} + +func (c *Client4) accessControlPolicyRoute(policyID string) string { + return fmt.Sprintf(c.accessControlPoliciesRoute()+"/%v", policyID) +} + func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) { r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "") if err != nil { @@ -9564,3 +9576,191 @@ func (c *Client4) PatchCPAValues(ctx context.Context, values map[string]json.Raw return patchedValues, BuildResponse(r), nil } + +// Access Control Policies Section + +// CreateAccessControlPolicy creates a new access control policy. +func (c *Client4) CreateAccessControlPolicy(ctx context.Context, policy *AccessControlPolicy) (*AccessControlPolicy, *Response, error) { + b, err := json.Marshal(policy) + if err != nil { + return nil, nil, NewAppError("CreateAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPutBytes(ctx, c.accessControlPoliciesRoute(), b) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var p AccessControlPolicy + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + return nil, nil, NewAppError("CreateAccessControlPolicy", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &p, BuildResponse(r), nil +} + +func (c *Client4) GetAccessControlPolicy(ctx context.Context, id string) (*AccessControlPolicy, *Response, error) { + r, err := c.DoAPIGet(ctx, c.accessControlPolicyRoute(id), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var policy AccessControlPolicy + if err := json.NewDecoder(r.Body).Decode(&policy); err != nil { + return nil, nil, NewAppError("GetAccessControlPolicy", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &policy, BuildResponse(r), nil +} + +func (c *Client4) DeleteAccessControlPolicy(ctx context.Context, id string) (*Response, error) { + r, err := c.DoAPIDelete(ctx, c.accessControlPolicyRoute(id)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + +func (c *Client4) CheckExpression(ctx context.Context, expression string) ([]CELExpressionError, *Response, error) { + checkExpressionRequest := struct { + Expression string `json:"expression"` + }{ + Expression: expression, + } + b, err := json.Marshal(checkExpressionRequest) + if err != nil { + return nil, nil, NewAppError("CheckExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.celRoute()+"/check", b) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var errors []CELExpressionError + if err := json.NewDecoder(r.Body).Decode(&errors); err != nil { + return nil, nil, NewAppError("CheckExpression", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return errors, BuildResponse(r), nil +} + +func (c *Client4) TestExpression(ctx context.Context, params QueryExpressionParams) (*AccessControlPolicyTestResponse, *Response, error) { + b, err := json.Marshal(params) + if err != nil { + return nil, nil, NewAppError("TestExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.celRoute()+"/test", b) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var testResponse AccessControlPolicyTestResponse + if err := json.NewDecoder(r.Body).Decode(&testResponse); err != nil { + return nil, nil, NewAppError("TestExpression", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &testResponse, BuildResponse(r), nil +} + +func (c *Client4) SearchAccessControlPolicies(ctx context.Context, options AccessControlPolicySearch) (*AccessControlPoliciesWithCount, *Response, error) { + b, err := json.Marshal(options) + if err != nil { + return nil, nil, NewAppError("SearchAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.accessControlPoliciesRoute()+"/search", b) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var policies AccessControlPoliciesWithCount + if err := json.NewDecoder(r.Body).Decode(&policies); err != nil { + return nil, nil, NewAppError("SearchAccessControlPolicies", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &policies, BuildResponse(r), nil +} + +func (c *Client4) AssignAccessControlPolicies(ctx context.Context, policyID string, resourceIDs []string) (*Response, error) { + var assignments struct { + ChannelIds []string `json:"channel_ids"` + } + assignments.ChannelIds = resourceIDs + + b, err := json.Marshal(assignments) + if err != nil { + return nil, NewAppError("AssignAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.accessControlPolicyRoute(policyID)+"/assign", b) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + +func (c *Client4) UnassignAccessControlPolicies(ctx context.Context, policyID string, resourceIDs []string) (*Response, error) { + var unassignments struct { + ChannelIds []string `json:"channel_ids"` + } + unassignments.ChannelIds = resourceIDs + + b, err := json.Marshal(unassignments) + if err != nil { + return nil, NewAppError("UnassignAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIDeleteBytes(ctx, c.accessControlPolicyRoute(policyID)+"/unassign", b) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + +func (c *Client4) GetChannelsForAccessControlPolicy(ctx context.Context, policyID string, after string, limit int) (*ChannelsWithCount, *Response, error) { + r, err := c.DoAPIGet(ctx, c.accessControlPolicyRoute(policyID)+"/resources/channels?after="+after+"&limit="+strconv.Itoa(limit), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var channels ChannelsWithCount + if err := json.NewDecoder(r.Body).Decode(&channels); err != nil { + return nil, nil, NewAppError("GetChannelsForAccessControlPolicy", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &channels, BuildResponse(r), nil +} + +func (c *Client4) SearchChannelsForAccessControlPolicy(ctx context.Context, policyID string, options ChannelSearch) (*ChannelsWithCount, *Response, error) { + b, err := json.Marshal(options) + if err != nil { + return nil, nil, NewAppError("SearchChannelsForAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.accessControlPolicyRoute(policyID)+"/resources/channels/search", b) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var channels ChannelsWithCount + if err := json.NewDecoder(r.Body).Decode(&channels); err != nil { + return nil, nil, NewAppError("SearchChannelsForAccessControlPolicy", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &channels, BuildResponse(r), nil +} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index b0043b18a1..e57ccbb34c 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -57,6 +57,8 @@ type FeatureFlags struct { ExperimentalAuditSettingsSystemConsoleUI bool CustomProfileAttributes bool + + AttributeBasedAccessControl bool } func (f *FeatureFlags) SetDefaults() { @@ -81,6 +83,7 @@ func (f *FeatureFlags) SetDefaults() { f.NotificationMonitoring = true f.ExperimentalAuditSettingsSystemConsoleUI = false f.CustomProfileAttributes = false + f.AttributeBasedAccessControl = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/model/job.go b/server/public/model/job.go index f3df27b514..fd106d9c78 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -44,6 +44,7 @@ const ( JobTypeExportUsersToCSV = "export_users_to_csv" JobTypeDeleteDmsPreferencesMigration = "delete_dms_preferences_migration" JobTypeMobileSessionMetadata = "mobile_session_metadata" + JobTypeAccessControlSync = "access_control_sync" JobStatusPending = "pending" JobStatusInProgress = "in_progress" diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 2f8ac100d8..07934532e9 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -54,6 +54,8 @@ "marked": "github:mattermost/marked#3b13ba8ddf725327ddf0298361d6d304a021f2d1", "memoize-one": "6.0.0", "moment-timezone": "0.5.38", + "monaco-editor": "0.52.2", + "monaco-editor-webpack-plugin": "7.1.0", "p-queue": "7.3.0", "pdfjs-dist": "4.4.168", "process": "0.11.10", diff --git a/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts b/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts new file mode 100644 index 0000000000..d6a969c7b5 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {jest} from '@jest/globals'; + +const monacoMock = { + editor: { + create: jest.fn(), + defineTheme: jest.fn(), + setTheme: jest.fn(), + }, + languages: { + registerCompletionItemProvider: jest.fn(), + }, +}; + +export default monacoMock; diff --git a/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap new file mode 100644 index 0000000000..2e67d56d42 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/admin_console/access_control/PolicyList should match snapshot with no policies 1`] = ` +
+
+
+

+ +

+

+ +

+
+ +
+ , + "width": 5, + }, + Object { + "field": "resources", + "name": , + "textAlign": "center", + "width": 4, + }, + Object { + "className": "actions-column", + "field": "actions", + "name": , + "width": 1, + }, + ] + } + endCount={0} + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={Array []} + rowsContainerStyles={ + Object { + "minHeight": "0px", + } + } + startCount={1} + term="" + total={0} + /> +
+`; + +exports[`components/admin_console/access_control/PolicyList should match snapshot with policies 1`] = ` +
+
+
+

+ +

+

+ +

+
+ +
+ , + "width": 5, + }, + Object { + "field": "resources", + "name": , + "textAlign": "center", + "width": 4, + }, + Object { + "className": "actions-column", + "field": "actions", + "name": , + "width": 1, + }, + ] + } + endCount={0} + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={Array []} + rowsContainerStyles={ + Object { + "minHeight": "0px", + } + } + startCount={1} + term="" + total={0} + /> +
+`; + +exports[`components/admin_console/access_control/PolicyList should match snapshot with search error 1`] = ` +
+
+
+

+ +

+

+ +

+
+ +
+ , + "width": 5, + }, + Object { + "field": "resources", + "name": , + "textAlign": "center", + "width": 4, + }, + Object { + "className": "actions-column", + "field": "actions", + "name": , + "width": 1, + }, + ] + } + endCount={0} + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={Array []} + rowsContainerStyles={ + Object { + "minHeight": "0px", + } + } + startCount={1} + term="" + total={0} + /> +
+`; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss new file mode 100644 index 0000000000..f90ee84f51 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss @@ -0,0 +1,257 @@ +.cel-editor { + margin-bottom: 24px; + + &__container { + position: relative; + display: flex; + overflow: auto; + height: auto; + flex-direction: column; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + margin-bottom: 8px; + background: var(--center-channel-bg); + overflow-y: auto; + + .policy-editor-placeholder { + position: absolute; + z-index: 10; + top: 13px; + left: 30px; + color: rgba(0,0,0, 0.40); + font-family: monospace; + font-size: 12px; + pointer-events: none; + user-select: none; + white-space: pre-wrap; + } + } + + &__cursor-position { + position: absolute; + right: 8px; + border-radius: 4px; + color: var(--button-color); + font-family: monospace; + font-size: 12px; + pointer-events: none; + } + + &__input { + overflow: auto; + width: 100%; + height: auto; + min-height: 120px; + max-height: 600px; + //overflow-y: auto; + flex-grow: 0; + padding: 12px; + border: 1px solid var(--button-bg); + border-radius: 4px 4px 0 0; + font-family: monospace; + resize: vertical; + transition: border-color 0.15s ease; + + &:focus { + outline: none; + } + + &::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.56); + } + + .policyEditor { + outline: none; + } + + .cel-editor__container[data-status-color='var(--error-text)'] & { + border-color: var(--error-text); + } + + .cel-editor__container[data-status-color='var(--online-indicator)'] & { + border-color: var(--online-indicator); + } + + .cel-editor__container[data-status-color='var(--button-bg)'] & { + border-color: var(--button-bg); + } + } + + &__footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-top: 8px; + + .help-text-container { + flex: 1; + padding-right: 16px; + color: var(--center-channel-color-72); + font-size: 12px; + } + } + + &__actions { + display: flex; + align-items: center; + } + + + + &__loading { + display: flex; + align-items: center; + + i { + margin-right: 8px; + } + } + + &__status-bar { + display: flex; + min-height: 24px; + align-items: center; + justify-content: space-between; + padding: 0 8px; + border-radius: 0 0 4px 4px; + color: var(--button-color); + font-family: monospace; + font-size: 12px; + transition: background-color 0.15s ease; + + &:not([data-validation-state="validated"]):not([data-validation-state="error"]):not([data-validation-state="validating"]) { + cursor: pointer; + + &:hover { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--button-bg); + } + + &:active { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--button-bg); + } + } + } + + &__error-message { + display: flex; + align-items: center; + + .icon { + margin-right: 4px; + font-size: 14px; + } + } + + &__error { + display: flex; + align-items: center; + color: var(--button-color); + } + + &__status-message { + display: flex; + align-items: center; + + .icon { + margin-right: 4px; + font-size: 14px; + + &.icon-refresh { + cursor: pointer; + &:hover { + opacity: 0.8; + } + } + } + } + + &__inline-validate-btn { + padding: 0; + border: none; + background: transparent; + color: var(--button-color); + cursor: pointer; + font-size: 12px; + font-weight: 600; + + &:hover { + text-decoration: underline; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .cel-editor__loading { + display: flex; + align-items: center; + + i { + margin-right: 4px; + font-size: 12px; + } + } + } + + &__validate-btn { + display: none; + } +} + +.cel-test-results-modal { + .modal-body { + max-height: 70vh; + overflow-y: auto; + } + + .cel-test-attributes { + padding: 10px; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + margin-bottom: 20px; + + .cel-attribute-tag { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + margin: 0 4px 4px 0; + background: rgba(var(--center-channel-color-rgb), 0.08); + font-size: 12px; + } + } + + .cel-subjects-list { + .cel-subject-item { + padding: 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; + margin-bottom: 12px; + + .cel-subject-header { + margin-bottom: 8px; + font-weight: 600; + } + + .cel-subject-attributes { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + + .cel-subject-attribute { + .cel-attribute-key { + margin-right: 4px; + font-weight: 500; + } + + .cel-attribute-value { + color: rgba(var(--center-channel-color-rgb), 0.72); + } + } + } + } + } +} + +.Card__body.expanded:has(.cel-editor) { + height: max-content !important; +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx new file mode 100644 index 0000000000..0d8b49ff66 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx @@ -0,0 +1,363 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as monaco from 'monaco-editor'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import type {AccessControlTestResult} from '@mattermost/types/access_control'; + +import {searchUsersForExpression} from 'mattermost-redux/actions/access_control'; +import {Client4} from 'mattermost-redux/client'; + +import {MonacoLanguageProvider} from './language_provider'; + +import CELHelpModal from '../../modals/cel_help/cel_help_modal'; +import TestResultsModal from '../../modals/policy_test/test_modal'; +import {TestButton, HelpText} from '../shared'; + +import './editor.scss'; + +export const POLICY_LANGUAGE = 'expressionLanguage'; +const VALIDATE_POLICY_SYNTAX_COMMAND_ID = 'policyEditorValidateSyntaxCommand'; + +const MONACO_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { + extraEditorClassName: 'policyEditor', + language: POLICY_LANGUAGE, + automaticLayout: true, + minimap: {enabled: false}, + lineNumbers: 'off', + scrollBeyondLastLine: false, + wordWrap: 'on', + renderLineHighlight: 'none', + lineNumbersMinChars: 1, + occurrencesHighlight: 'off', + stickyScroll: {enabled: false}, + autoClosingBrackets: 'never', + autoClosingQuotes: 'never', + autoIndent: 'keep', + autoSurround: 'never', + codeLens: false, + folding: false, + fontFamily: 'monospace', + hideCursorInOverviewRuler: true, + fontSize: 12, + guides: {indentation: false}, + links: true, + matchBrackets: 'never', + multiCursorLimit: 1, + overviewRulerBorder: false, + quickSuggestions: false, + renderControlCharacters: false, + scrollbar: { + horizontal: 'hidden', + useShadows: false, + }, + selectionHighlight: false, + showFoldingControls: 'never', + suggestOnTriggerCharacters: true, + unicodeHighlight: { + ambiguousCharacters: false, + invisibleCharacters: false, + }, + unusualLineTerminators: 'auto', + wordWrapColumn: 400, + wrappingIndent: 'none', + wrappingStrategy: 'advanced', + contextmenu: false, +}; + +interface CELEditorProps { + value: string; + onChange: (value: string) => void; + onValidate?: (isValid: boolean) => void; + placeholder?: string; + className?: string; + userAttributes: Array<{ + attribute: string; + values: string[]; + }>; +} + +// TODO: this is just a sample schema for the editor, we need to get the actual schema from the server + +function CELEditor({ + value, + onChange, + onValidate, + placeholder = 'user.attributes. == ', + className = '', + userAttributes, +}: CELEditorProps): JSX.Element { + const [editorState, setEditorState] = useState({ + expression: value, + isValidating: false, + isValid: true, + cursorPosition: {line: 1, column: 1}, + validationErrors: [] as string[], + statusBarColor: 'var(--button-bg)', + showTestResults: false, + testResults: null as AccessControlTestResult | null, + }); + + const schemas = { + user: ['attributes'], + 'user.attributes': userAttributes.map((attr) => attr.attribute), + }; + + const editorRef = useRef(null); + const monacoRef = useRef(null); + const [showHelpModal, setShowHelpModal] = useState(false); + + useEffect(() => { + setEditorState((prev) => ({...prev, expression: value})); + }, [value]); + + useEffect(() => { + if (monacoRef.current && monacoRef.current.getValue() !== editorState.expression) { + monacoRef.current.setValue(editorState.expression); + } + }, [editorState.expression]); + + const handleChange = useCallback((newValue: string) => { + setEditorState((prev) => ({ + ...prev, + expression: newValue, + statusBarColor: 'var(--button-bg)', + validationErrors: [], + })); + onChange(newValue); + }, [onChange]); + + const validateSyntax = useCallback(async () => { + setEditorState((prev) => ({...prev, isValidating: true})); + + try { + const errors = await Client4.checkAccessControlExpression(editorState.expression); + const isValid = errors.length === 0; + setEditorState((prev) => ({ + ...prev, + isValid, + validationErrors: errors.map((error) => `${error.message} @L${error.line}:${error.column + 1}`), + statusBarColor: isValid ? 'var(--online-indicator)' : 'var(--error-text)', + isValidating: false, + })); + onValidate?.(isValid); + } catch (error) { + setEditorState((prev) => ({ + ...prev, + isValid: false, + validationErrors: [error.detailed_error || 'Unknown error'], + statusBarColor: 'var(--error-text)', + isValidating: false, + })); + onValidate?.(false); + } + }, [editorState.expression, onValidate]); + + // initialize monaco editor + useEffect(() => { + if (!editorRef.current || monacoRef.current) { + return () => {}; + } + + monacoRef.current = monaco.editor.create(editorRef.current, MONACO_EDITOR_OPTIONS); + + // Set the initial value from the expression state + monacoRef.current.setValue(editorState.expression); + + monacoRef.current.getModel()?.onDidChangeContent(() => { + const newValue = monacoRef.current?.getValue() || ''; + handleChange(newValue); + }); + + monacoRef.current.onDidChangeCursorPosition((e) => { + setEditorState((prev) => ({ + ...prev, + cursorPosition: {line: e.position.lineNumber, column: e.position.column}, + })); + }); + + // To disable monaco's default behavior of opening the find and replace widget + monaco.editor.addKeybindingRule({ + keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, + command: null, + }); + + monaco.editor.addCommand({ + id: VALIDATE_POLICY_SYNTAX_COMMAND_ID, + run: validateSyntax, + }); + + monaco.editor.addKeybindingRule({ + keybinding: monaco.KeyMod.Alt | monaco.KeyCode.Enter, + command: VALIDATE_POLICY_SYNTAX_COMMAND_ID, + }); + + return () => { + if (monacoRef.current) { + monacoRef.current.dispose(); + monacoRef.current = null; + } + }; + }, []); + + return ( +
+ + +
+ {!editorState.expression && ( +
+ {placeholder} +
+ )} + +
+
{ + if (!editorState.isValidating && editorState.validationErrors.length === 0 && + !(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) { + validateSyntax(); + } + }} + role='button' + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (!editorState.isValidating && editorState.validationErrors.length === 0 && + !(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) { + validateSyntax(); + } + } + }} + data-validation-state={ + (() => { + if (editorState.isValidating) { + return 'validating'; + } + + if (editorState.validationErrors.length > 0) { + return 'error'; + } + + if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') { + return 'validated'; + } + + return 'unvalidated'; + })() + } + > +
+ {(() => { + if (editorState.validationErrors.length > 0) { + return ( + + + {editorState.validationErrors[0]} + + ); + } + + if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') { + return ( + + + {'Valid'} + + ); + } + + return ( + + ); + })()} +
+
+ +
+
+
+
+
+
+ == `. Use `&&` / `||` (and/or) for multiple conditions. Group conditions with `()`.'} + onLearnMoreClick={() => setShowHelpModal(true)} + /> +
+
+ setEditorState((prev) => ({...prev, showTestResults: true}))} + disabled={!editorState.isValid || editorState.isValidating} + /> +
+ {editorState.showTestResults && ( + setEditorState((prev) => ({...prev, showTestResults: false}))} + actions={{ + openModal: () => {}, + searchUsers: (term: string, after: string, limit: number) => { + return searchUsersForExpression(editorState.expression, term, after, limit); + }, + }} + /> + )} + {showHelpModal && ( + setShowHelpModal(false)} + /> + )} +
+ ); +} + +export default CELEditor; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/language_provider.tsx b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/language_provider.tsx new file mode 100644 index 0000000000..dfbda4d9dc --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/language_provider.tsx @@ -0,0 +1,275 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as monaco from 'monaco-editor'; +import {useEffect} from 'react'; + +const POLICY_LANGUAGE_NAME = 'expressionLanguage'; + +// Enhanced schema interface to support different types of values +interface SchemaValue { + [key: string]: string[] | boolean | SchemaValue; +} + +interface SchemaMap { + [schemaName: string]: string[] | SchemaValue | boolean; +} + +interface MonacoLanguageProviderProps { + schemas: SchemaMap; +} + +export function MonacoLanguageProvider({schemas}: MonacoLanguageProviderProps) { + useEffect(() => { + // Register our custom expression language + if ( + !monaco.languages. + getLanguages(). + some((lang) => lang.id === POLICY_LANGUAGE_NAME) + ) { + monaco.languages.register({id: POLICY_LANGUAGE_NAME}); + + // Define language tokenizer + monaco.languages.setMonarchTokensProvider(POLICY_LANGUAGE_NAME, { + tokenizer: { + root: [ + + // Comments + [/\/\/.*$/, 'comment'], + + // Object and property paths + [/[a-zA-Z][\w$]*(?=\.)/, 'variable'], + [/\./, 'delimiter'], + [/[a-zA-Z][\w$]*/, 'property'], + + // Operators + [/&&|\|\||==|!=/, 'operator'], + + // Whitespace + [/[ \t\r\n]+/, 'white'], + + // Parentheses + [/[()]/, '@brackets'], + + // String literals + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/"/, {token: 'string.quote', bracket: '@open', next: '@string'}], + [/'([^'\\]|\\.)*$/, 'string.invalid'], + [ + /'/, + {token: 'string.quote', bracket: '@open', next: '@string2'}, + ], + + // Numbers + [/\d+/, 'number'], + ], + string: [ + [/[^\\"]+/, 'string'], + [/"/, {token: 'string.quote', bracket: '@close', next: '@pop'}], + ], + string2: [ + [/[^'\\]+/, 'string'], + [/'/, {token: 'string.quote', bracket: '@close', next: '@pop'}], + ], + }, + }); + } + + // Get properties from a schema path + const getPropertiesFromPath = (path: string): string[] => { + const schemaItem = schemas[path]; + + if (!schemaItem) { + return []; + } + + if (Array.isArray(schemaItem)) { + return schemaItem; + } else if (typeof schemaItem === 'object') { + return Object.keys(schemaItem); + } + + return []; + }; + + // Get allowed values for a property or path + const getValuesForPath = (fullPath: string): string[] | null => { + // Check if the path exists directly in schemas + const directValue = schemas[fullPath]; + + if (Array.isArray(directValue)) { + return directValue; + } + + // Otherwise, try to parse it as parent.property + const pathParts = fullPath.split('.'); + + if (pathParts.length >= 2) { + const property = pathParts.pop(); + if (!property) { + return null; + } + const parentPath = pathParts.join('.'); + + const schemaItem = schemas[parentPath]; + + if (!schemaItem || Array.isArray(schemaItem) || typeof schemaItem === 'boolean') { + return null; + } + + const propValue = schemaItem[property]; + + if (Array.isArray(propValue)) { + return propValue; + } else if (propValue === true) { + return null; // Property exists but no predefined values + } + } + + return null; + }; + + // Create a completion item provider for our language + const disposable = monaco.languages.registerCompletionItemProvider( + 'expressionLanguage', + { + triggerCharacters: ['.', ' ', '"', "'", '='], + provideCompletionItems: (model, position) => { + const lineNumber = position.lineNumber; + const column = position.column; + const lineContent = model.getLineContent(lineNumber); + const textBeforePosition = lineContent.substring(0, column - 1); + + // Check if we're after an operator that expects a value + // Pattern: path followed by an operator that expects a value + const valueOperatorPattern = + /(\w+(?:\.\w+)*)\s+(==|!=|>|<|>=|<=)\s+["']?(\w*)$/; + const valueMatch = textBeforePosition.match(valueOperatorPattern); + + if (valueMatch) { + const [, fullPath, , currentValue] = valueMatch; + + // Get values for this full path + const allowedValues = getValuesForPath(fullPath); + + if (allowedValues && allowedValues.length > 0) { + // Create range that includes the characters already typed + const wordStartColumn = column - currentValue.length; + + return { + suggestions: allowedValues. + filter((val) => + val. + toString(). + toLowerCase(). + startsWith(currentValue.toLowerCase()), + ). + map((val) => ({ + label: val.toString(), + kind: monaco.languages.CompletionItemKind.Value, + insertText: `"${val}"`, + range: { + startLineNumber: lineNumber, + startColumn: wordStartColumn, + endLineNumber: lineNumber, + endColumn: column, + }, + })), + }; + } + } + + // Check if we should suggest operators + // Pattern: an entity (word possibly with dots) followed by space + const operatorPattern = /(\w+(?:\.\w+)*)\s+$/; + const operatorMatch = textBeforePosition.match(operatorPattern); + + if (operatorMatch) { + // We have an entity followed by space - suggest operators + const operators = ['&&', '||', '==', '!=', 'in']; + + return { + suggestions: operators.map((op) => ({ + label: op, + kind: monaco.languages.CompletionItemKind.Operator, + insertText: op + ' ', + range: { + startLineNumber: lineNumber, + startColumn: column, + endLineNumber: lineNumber, + endColumn: column, + }, + })), + }; + } + + // Check for dot completion (property access) + const dotMatch = textBeforePosition.match(/(\w+)(?:\.(\w+))*\.$/); + if (dotMatch) { + const fullPath = dotMatch[0].slice(0, -1); // Remove trailing dot + + // Get properties for this path + const properties = getPropertiesFromPath(fullPath); + + if (properties.length > 0) { + return { + suggestions: properties.map((field) => ({ + label: field, + kind: monaco.languages.CompletionItemKind.Field, + insertText: field, + range: { + startLineNumber: lineNumber, + startColumn: column, + endLineNumber: lineNumber, + endColumn: column, + }, + })), + }; + } + } + + // When not after a dot or space, suggest root objects + const wordMatch = textBeforePosition.match( + /(?:^|\s+|[&|=!<>()]|\()(\w*)$/, + ); + if (wordMatch) { + const word = wordMatch[1] || ''; + const wordStartColumn = column - word.length; + + // Filter schemas that are root objects (don't contain dots) + const rootSchemas = Object.keys(schemas).filter( + (key) => !key.includes('.'), + ); + + const suggestions = rootSchemas. + filter((schema) => + schema.toLowerCase().startsWith(word.toLowerCase()), + ). + map((schema) => ({ + label: schema, + kind: monaco.languages.CompletionItemKind.Class, + insertText: schema, + range: { + startLineNumber: lineNumber, + startColumn: wordStartColumn, + endLineNumber: lineNumber, + endColumn: column, + }, + })); + + return {suggestions}; + } + + return {suggestions: []}; + }, + }, + ); + + // Cleanup function + return () => { + disposable.dispose(); + }; + }, [schemas]); + + return null; // This component doesn't render anything +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.scss b/webapp/channels/src/components/admin_console/access_control/editors/shared.scss new file mode 100644 index 0000000000..c73535caf8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.scss @@ -0,0 +1,55 @@ +.editor__test-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 16px; + border: 1px solid var(--button-bg); + border-radius: 4px; + background: transparent; + color: var(--button-bg); + cursor: pointer; + font-weight: 600; + + i { + margin-right: 8px; + } + + &:hover { + background-color: rgba(var(--button-bg-rgb), 0.08); + } + + &:disabled { + border: 1px solid rgba(var(--center-channel-color-rgb), 0.32); + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: not-allowed; + opacity: 0.4; + } +} + +.editor__add-row-button { + display: flex; + align-items: center; + padding: 10px 16px; + border: 1px solid var(--button-bg); + border-radius: 4px; + background-color: transparent; + color: var(--button-bg); + cursor: pointer; + font-weight: 600; + + i { + margin-right: 8px; + font-size: 14px; + } + + &:hover { + background-color: rgba(var(--button-bg-rgb), 0.08); + } + + &:disabled { + border: 1px solid rgba(var(--center-channel-color-rgb), 0.32); + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: not-allowed; + opacity: 0.4; + } +} \ No newline at end of file diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx new file mode 100644 index 0000000000..9afcc12d58 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import './shared.scss'; +import Markdown from 'components/markdown'; + +interface TestButtonProps { + onClick: () => void; + disabled: boolean; +} + +interface AddAttributeButtonProps { + onClick: () => void; + disabled: boolean; +} + +interface HelpTextProps { + message: string; + onLearnMoreClick?: () => void; +} + +export function TestButton({onClick, disabled}: TestButtonProps): JSX.Element { + return ( + + ); +} + +export function AddAttributeButton({onClick, disabled}: AddAttributeButtonProps): JSX.Element { + return ( + + ); +} + +export function HelpText({message, onLearnMoreClick}: HelpTextProps): JSX.Element { + return ( +
+ + {onLearnMoreClick && ( + + + + )} +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx new file mode 100644 index 0000000000..8c82188fe0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {CheckIcon, MenuVariantIcon} from '@mattermost/compass-icons/components'; +import type IconProps from '@mattermost/compass-icons/components/props'; + +import * as Menu from 'components/menu'; + +import './selector_menus.scss'; + +interface AttributeOption { + attribute: string; + values: string[]; +} + +interface AttributeSelectorProps { + currentAttribute: string; + availableAttributes: AttributeOption[]; + disabled: boolean; + onChange: (attribute: string) => void; +} + +const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled, onChange}: AttributeSelectorProps) => { + const {formatMessage} = useIntl(); + const [filter, setFilter] = useState(''); + + const onFilterChange = (e: React.ChangeEvent) => { + setFilter(e.target.value); + }; + + const options = useMemo(() => { + return availableAttributes.filter((attr) => { + return attr.attribute.toLowerCase().includes(filter.toLowerCase()); + }); + }, [availableAttributes, filter]); + + const handleAttributeChange = (attribute: string) => { + onChange(attribute); + setFilter(''); + }; + + // TODO: We can use different icons for different attributes types + const AttributeIcon = (props: IconProps) => ; + + return ( + + + {currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})} + + ), + dataTestId: 'attributeSelectorMenuButton', + disabled, + }} + menu={{ + id: 'attribute-selector-menu', + 'aria-label': 'Select attribute', + className: 'select-attribute-mui-menu', + }} + > + {[ + , + ]} + {options.map((option) => { + const {attribute} = option; + return ( + handleAttributeChange(attribute)} + labels={{attribute}} + leadingElement={} + trailingElements={attribute === currentAttribute && ( + + )} + /> + ); + })} + + ); +}; + +export default AttributeSelectorMenu; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx new file mode 100644 index 0000000000..8c58386efb --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx @@ -0,0 +1,239 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ComponentType} from 'react'; +import React, {useMemo, useState} from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {defineMessage, FormattedMessage, useIntl} from 'react-intl'; + +import {CheckIcon} from '@mattermost/compass-icons/components'; +import type IconProps from '@mattermost/compass-icons/components/props'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import * as Menu from 'components/menu'; + +import './selector_menus.scss'; + +const AlphaEIcon: React.FC = ({size, color, ...rest}: IconProps): JSX.Element => ( + + + +); + +const EqualIcon: React.FC = ({size, color, ...rest}: IconProps): JSX.Element => ( + + + +); + +const FunctionIcon: React.FC = ({size, color, ...rest}: IconProps): JSX.Element => ( + + + +); + +const NotEqualIcon: React.FC = ({size, color, ...rest}: IconProps): JSX.Element => ( + + + +); + +interface OperatorSelectorProps { + currentOperator: string; + disabled: boolean; + onChange: (operator: string) => void; +} + +const OperatorSelectorMenu = ({currentOperator, disabled, onChange}: OperatorSelectorProps) => { + const handleOperatorChange = (descriptor: OperatorDescriptor) => { + onChange(descriptor.operatorValue); + setFilter(''); + }; + + const currentOperatorDescriptor = useMemo(() => { + return getOperatorDescriptor(currentOperator); + }, [currentOperator]); + + const CurrentOperatorIcon = currentOperatorDescriptor.icon; + const {formatMessage} = useIntl(); + const [filter, setFilter] = useState(''); + + const onFilterChange = (e: React.ChangeEvent) => { + setFilter(e.target.value); + }; + + const filteredOperators = useMemo(() => { + return Object.values(OPERATOR_DESCRIPTORS).filter((desc) => { + const label = formatMessage(desc.label); + return label.toLowerCase().includes(filter.toLowerCase()); + }); + }, [filter, formatMessage]); + + return ( + + + + + ), + dataTestId: 'operatorSelectorMenuButton', + disabled, + }} + menu={{ + id: 'operator-selector-menu', + 'aria-label': 'Select operator', + className: 'select-operator-mui-menu', + }} + > + + {filteredOperators.map((descriptor) => { + const {id, icon: Icon, label} = descriptor; + + return ( + handleOperatorChange(descriptor)} + labels={} + leadingElement={} + trailingElements={id === currentOperatorDescriptor.id && ( + + )} + /> + ); + })} + + ); +}; + +export default OperatorSelectorMenu; + +const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => { + for (const descriptor of Object.values(OPERATOR_DESCRIPTORS)) { + if (descriptor.operatorValue === operatorValue) { + return descriptor; + } + } + + return OPERATOR_DESCRIPTORS.is; +}; + +type OperatorID = 'is' | 'is_not' | 'in' | 'starts_with' | 'ends_with' | 'contains'; + +type OperatorDescriptor = { + id: OperatorID; + operatorValue: string; + icon: ComponentType; + label: MessageDescriptor; +}; + +const OPERATOR_DESCRIPTORS: IDMappedObjects = { + is: { + id: 'is', + operatorValue: 'is', + icon: EqualIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.is', + defaultMessage: 'is', + }), + }, + is_not: { + id: 'is_not', + operatorValue: 'is not', + icon: NotEqualIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.is_not', + defaultMessage: 'is not', + }), + }, + in: { + id: 'in', + operatorValue: 'in', + icon: AlphaEIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.in', + defaultMessage: 'in', + }), + }, + starts_with: { + id: 'starts_with', + operatorValue: 'starts with', + icon: FunctionIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.starts_with', + defaultMessage: 'starts with', + }), + }, + ends_with: { + id: 'ends_with', + operatorValue: 'ends with', + icon: FunctionIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.ends_with', + defaultMessage: 'ends with', + }), + }, + contains: { + id: 'contains', + operatorValue: 'contains', + icon: FunctionIcon, + label: defineMessage({ + id: 'admin.access_control.table_editor.operator.contains', + defaultMessage: 'contains', + }), + }, +}; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss new file mode 100644 index 0000000000..3415cb12d3 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss @@ -0,0 +1,46 @@ +.field-selector-menu-button { + width: 100%; + height: 40px; + justify-content: start; + border-color: transparent; + border-radius: 0; + box-shadow: none; + font-weight: normal; + + &:hover, + &:focus { + border-color: transparent; + box-shadow: none; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.04) + } + + &:focus, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &.disabled { + cursor: not-allowed; + opacity: 0.6; + } + + svg { + margin-right: 8px; + } +} + +.select-attribute-mui-menu, +.select-operator-mui-menu { + margin-top: 0; + + .MenuItem { + height: 40px; + + svg { + margin-right: 8px; + } + } +} \ No newline at end of file diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss new file mode 100644 index 0000000000..8d20c85e77 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss @@ -0,0 +1,174 @@ +.table-editor { + position: relative; + margin-bottom: 24px; + + &__table { + overflow: hidden; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + } + + &__header { + display: flex; + padding: 12px 16px; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + background: rgba(var(--center-channel-color-rgb), 0.04); + } + + &__column-header { + flex: 1; + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + padding-inline: 10px; + + &:nth-child(1) { + flex: 1; + } + + &:nth-child(2) { + flex: 0.8; + } + + &:nth-child(3) { + flex: 2.3; + } + } + + &__column-header-actions { + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + } + + &__row { + display: flex; + align-items: center; + padding: 0; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + } + + &__cell { + flex: 1; + + &:nth-child(1) { + flex: 1; + } + + &:nth-child(2) { + flex: 0.8; + } + + &:nth-child(3) { + flex: 2; + } + } + + &__cell-actions { + width: 40px; + text-align: right; + } + + &__attribute-select, + &__operator-select { + width: 100%; + } + + &__select { + width: 100%; + padding: 10px 16px; + border: none; + border-radius: 4px; + appearance: none; + background-color: transparent; + color: var(--center-channel-color); + font-size: 14px; + + &:hover { + background-color: rgba(var(--button-bg-rgb), 0.08); + cursor: pointer; + } + + &:focus { + background-color: rgba(var(--button-bg-rgb), 0.08); + outline: none; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + + &__row-remove { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: pointer; + + &:hover { + color: var(--error-text); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + + &__actions-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + + .editor__help-text { + margin-right: 32px; + + p { + margin-bottom: 0; + } + } + } + + &__blank-state { + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + + span { + color: var(--center-channel-color-64); + } + } + + &__add-button-container { + display: flex; + align-items: center; + padding: 8px; + } +} + +.editor__help-text { + color: var(--center-channel-color-72); + font-size: 12px; + + p { + margin-bottom: 0; + } + + a { + display: inline-block; + margin-top: 8px; + color: var(--link-color); + + &:hover { + text-decoration: underline; + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx new file mode 100644 index 0000000000..642f07c0c0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx @@ -0,0 +1,317 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {searchUsersForExpression} from 'mattermost-redux/actions/access_control'; +import {Client4} from 'mattermost-redux/client'; + +import AttributeSelectorMenu from './attribute_selector_menu'; +import OperatorSelectorMenu from './operator_selector_menu'; +import type {TableRow} from './table_row'; +import ValuesEditor from './values_editor'; + +import CELHelpModal from '../../modals/cel_help/cel_help_modal'; +import TestResultsModal from '../../modals/policy_test/test_modal'; +import {AddAttributeButton, TestButton, HelpText} from '../shared'; + +import './table_editor.scss'; + +interface TableEditorProps { + value: string; + onChange: (value: string) => void; + onValidate?: (isValid: boolean) => void; + disabled?: boolean; + userAttributes: Array<{ + attribute: string; + values: string[]; + }>; +} + +// Parse CEL expression into table rows +const parseExpression = async (expr: string): Promise => { + const tableRows: TableRow[] = []; + + if (!expr) { + return tableRows; + } + + const rawVisualAST = await Client4.expressionToVisualFormat(expr); + for (const node of rawVisualAST.conditions) { + let attr; + + if (node.attribute.startsWith('user.attributes.')) { + attr = node.attribute.slice(16); // wow, there is no trim-prefix + } else { + throw new Error(`Unknown attribute: ${node.attribute}`); + } + + let op; + + switch (node.operator) { + case '==': + op = 'is'; + break; + case 'in': + op = 'in'; + break; + case '!=': + op = 'is not'; + break; + case 'startsWith': + op = 'starts with'; + break; + case 'endsWith': + op = 'ends with'; + break; + case 'contains': + op = 'contains'; + break; + default: + throw new Error(`Unknown operator: ${node.operator}`); + } + + let values; + if (Array.isArray(node.value)) { + values = node.value; + } else { + values = [node.value]; + } + + tableRows.push({ + attribute: attr, + operator: op, + values, + }); + } + + return tableRows; +}; + +function TableEditor({ + value, + onChange, + onValidate, + disabled = false, + userAttributes, +}: TableEditorProps): JSX.Element { + const {formatMessage} = useIntl(); + const [rows, setRows] = useState([]); + const [showTestResults, setShowTestResults] = useState(false); + const [showHelpModal, setShowHelpModal] = useState(false); + + // Update rows when value changes externally + useEffect(() => { + parseExpression(value).then((rows) => { + setRows(rows); + }); + }, [value]); + + // Update the CEL expression when table changes + const updateExpression = (newRows: TableRow[]) => { + const validRows = newRows.filter((row) => row.attribute && row.values.length > 0); + const expr = validRows.map((row) => { + if (row.operator === 'is') { + return `user.attributes.${row.attribute} == "${row.values[0]}"`; + } + + if (row.operator === 'is not') { + return `user.attributes.${row.attribute} != "${row.values[0]}"`; + } + + if (row.operator === 'starts with') { + return `user.attributes.${row.attribute}.startsWith("${row.values[0]}")`; + } + + if (row.operator === 'ends with') { + return `user.attributes.${row.attribute}.endsWith("${row.values[0]}")`; + } + + if (row.operator === 'contains') { + return `user.attributes.${row.attribute}.contains("${row.values[0]}")`; + } + + const valuesStr = row.values.map((val) => `"${val}"`).join(', '); + return `user.attributes.${row.attribute} in [${valuesStr}]`; + }).join(' && '); + + onChange(expr); + if (onValidate) { + onValidate(true); + } + }; + + const addRow = () => { + // Find first available attribute + const availableAttrs = getAvailableAttributes(); + if (availableAttrs.length === 0) { + return; + } + + const newRows = [...rows, { + attribute: availableAttrs[0].attribute, + operator: 'is', + values: [], + }]; + + setRows(newRows); + updateExpression(newRows); + }; + + const removeRow = (index: number) => { + const newRows = rows.filter((_, i) => i !== index); + setRows(newRows); + updateExpression(newRows); + }; + + const updateRowAttribute = (index: number, attribute: string) => { + const newRows = [...rows]; + newRows[index].attribute = attribute; + setRows(newRows); + updateExpression(newRows); + }; + + const updateRowOperator = (index: number, operator: string) => { + const newRows = [...rows]; + newRows[index].operator = operator; + + if ((operator !== 'in') && newRows[index].values.length > 1) { + newRows[index].values = newRows[index].values.length > 0 ? [newRows[index].values[0]] : []; + } + + setRows(newRows); + updateExpression(newRows); + }; + + const updateRowValues = (index: number, values: string[]) => { + const newRows = [...rows]; + newRows[index].values = values; + setRows(newRows); + updateExpression(newRows); + }; + + // Get available attributes (excluding ones already used) + const getAvailableAttributes = () => { + const usedAttributes = new Set(rows.map((row) => row.attribute)); + return userAttributes.filter((attr) => !usedAttributes.has(attr.attribute)); + }; + + return ( +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ {rows.length === 0 ? ( +
+ + {formatMessage({ + id: 'admin.access_control.table_editor.blank_state', + defaultMessage: 'Select a user attribute and values to create a rule', + })} + +
+ ) : ( + rows.map((row, index) => ( +
+
+ updateRowAttribute(index, attribute)} + /> +
+
+ updateRowOperator(index, operator)} + /> +
+
+ updateRowValues(index, values)} + /> +
+
+ +
+
+ )) + )} +
+
+ +
+
+ +
+ + setShowTestResults(true)} + disabled={disabled || !value} + /> +
+ + {showTestResults && ( + setShowTestResults(false)} + actions={{ + openModal: () => {}, + searchUsers: (term: string, after: string, limit: number) => { + return searchUsersForExpression(value, term, after, limit); + }, + }} + /> + )} + {showHelpModal && ( + setShowHelpModal(false)} + /> + )} +
+ ); +} + +export default TableEditor; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx new file mode 100644 index 0000000000..e8e68c188e --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export interface TableRow { + attribute: string; + operator: string; + values: string[]; +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss new file mode 100644 index 0000000000..6fbd6b9755 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss @@ -0,0 +1,78 @@ +.values-editor { + position: relative; + width: 100%; + + .select__multi-value { + display: flex; + height: 24px; + align-items: center; + padding: 0; + border: none; + border-radius: 4px; + margin: 2px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + } + + .select__multi-value__label { + padding: 0 8px; + color: var(--center-channel-color); + font-size: 12px; + line-height: 16px; + } + + .select__multi-value__remove { + display: flex; + height: 100%; + align-items: center; + padding: 0 4px; + border-radius: 0 4px 4px 0; + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: pointer; + + &:hover { + background-color: rgba(var(--center-channel-color-rgb), 0.16); + color: var(--center-channel-color); + } + } + + .select__control { + min-height: 40px; + border: none; + border-radius: 0; + overflow-y: auto; + + &--is-focused { + border: none; + box-shadow: none; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.06); + cursor: text; + } + } + + &__simple-input { + width: 100%; + height: 40px; + padding: 0 16px; + border: none; + background: transparent; + color: var(--center-channel-color); + font-size: 14px; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.06); + } + + &:focus { + background: rgba(var(--center-channel-color-rgb), 0.06); + outline: none; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx new file mode 100644 index 0000000000..f3fbdf7fb4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import CreatableSelect from 'react-select/creatable'; + +import Constants from 'utils/constants'; + +import './values_editor.scss'; +import type {TableRow} from './table_row'; + +export type ValuesEditorProps = { + row: TableRow; + disabled: boolean; + updateValues: (values: string[]) => void; +} + +function ValuesEditor({row, disabled, updateValues}: ValuesEditorProps) { + const {formatMessage} = useIntl(); + const isMulti = row.operator === 'in'; + const [inputValue, setInputValue] = useState(''); + const [isEditing, setIsEditing] = useState(false); + + // Format options for react-select + const value = useMemo(() => { + return row.values.map((val) => ({ + label: val, + value: val, + })); + }, [row.values]); + + // Handle input submission for single value + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + + // Only update if there's actual text - don't set empty values + if (inputValue.trim()) { + updateValues([inputValue.trim()]); + } + setInputValue(''); + setIsEditing(false); + } + }; + + // For single value mode, use a simple input field + if (!isMulti) { + const displayValue = row.values.length > 0 ? row.values[0] : ''; + + return ( +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + setIsEditing(true); + if (displayValue) { + setInputValue(displayValue); + } + }} + onBlur={() => { + // Only update if there's actual text - don't set empty values + if (inputValue.trim()) { + updateValues([inputValue.trim()]); + } + setInputValue(''); + setIsEditing(false); + }} + placeholder={formatMessage({id: 'admin.access_control.table_editor.value.placeholder', defaultMessage: 'Add value...'})} + disabled={disabled} + maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH} + /> +
+ ); + } + + // For multi-value mode, continue using CreatableSelect + const customComponents = { + DropdownIndicator: () => null, + IndicatorsContainer: () => null, + }; + + const handleChange = (newValue: any) => { + if (!newValue) { + updateValues([]); + } else if (Array.isArray(newValue)) { + updateValues(newValue.map((option) => option.value)); + } + }; + + return ( +
+ { + const val = inputValue.trim(); + if (!val) { + return; + } + + if (!row.values.includes(val)) { + updateValues([...row.values, val]); + } + }} + placeholder={formatMessage({id: 'admin.access_control.table_editor.values.placeholder', defaultMessage: 'Add values...'})} + classNamePrefix='select' + menuPortalTarget={document.body} + /> +
+ ); +} + +export default ValuesEditor; diff --git a/webapp/channels/src/components/admin_console/access_control/index.ts b/webapp/channels/src/components/admin_console/access_control/index.ts new file mode 100644 index 0000000000..0849e4ee1b --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import type {Dispatch} from 'redux'; + +import {searchAccessControlPolicies, deleteAccessControlPolicy} from 'mattermost-redux/actions/access_control'; + +import PolicyList from './policies'; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + actions: bindActionCreators({ + searchPolicies: searchAccessControlPolicies, + deletePolicy: deleteAccessControlPolicy, + }, dispatch), +}); + +export default connect(null, mapDispatchToProps)(PolicyList); diff --git a/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.scss b/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.scss new file mode 100644 index 0000000000..2422ac7339 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.scss @@ -0,0 +1,77 @@ +.AccessControlSyncJobTable { + overflow: auto; + + .policy-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + &-text { + h1 { + margin-bottom: 8px; + font-size: 20px; + font-weight: 600; + } + + p { + margin: 0; + color: rgba(63, 67, 80, 0.72); + } + } + + .btn-primary { + display: flex; + align-items: center; + padding: 10px 16px; + gap: 8px; + + .icon { + font-size: 18px; + } + } + } + + .job-table__access-control { + .job-table__table { + max-height: 400px; + padding: 0px; + border: none; + } + + .table > thead > tr > th, + .table > tbody > tr > th, + .table > tfoot > tr > th, + .table > thead > tr > td, + .table > tbody > tr > td, + .table > tfoot > tr > td { + border-top: none; + padding-block: 10px; + } + + .table > thead > tr > th { + border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.16); + font-size: 1em; + font-weight: 600; + } + + .cancel-button-field { + width: 30px; + } + + .table-row { + min-height: 40px; + cursor: pointer; + } + } + + .DataGrid_footer { + border-bottom: 0; + color: rgba(var(--sys-center-channel-color-rgb), 0.56); + } + + .actions-column { + justify-content: flex-end !important; + text-align: right; + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.tsx b/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.tsx new file mode 100644 index 0000000000..7775610e02 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/jobs/access_control_sync_job_table.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect} from 'react'; + +import type {JobType, JobTypeBase, Job} from '@mattermost/types/jobs'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import JobsTable from 'components/admin_console/jobs'; + +import {JobTypes} from 'utils/constants'; + +import JobDetailsModal from '../modals/job_details/job_details_modal'; + +import './access_control_sync_job_table.scss'; + +type Props = { + actions: { + createJob: (job: JobTypeBase) => Promise; + getJobsByType: (jobType: JobType) => void; + }; +}; + +export default function AccessControlSyncJobTable(props: Props): JSX.Element { + const [selectedJob, setSelectedJob] = useState(null); + const [showModal, setShowModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + // Load jobs when component mounts + props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC); + + // Set up polling interval + const interval = setInterval(() => { + props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC); + }, 15000); + + return () => { + clearInterval(interval); + }; + }, [props.actions]); + + const handleCreateJob = async (e?: React.SyntheticEvent) => { + e?.preventDefault(); + + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + + const job = { + type: JobTypes.ACCESS_CONTROL_SYNC, + }; + + try { + await props.actions.createJob(job); + + // Immediately fetch updated job list + props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC); + } finally { + // Reset submitting state after a short delay to prevent rapid re-clicks + setTimeout(() => { + setIsSubmitting(false); + }, 1000); + } + }; + + const handleRowClick = (job: Job) => { + setSelectedJob(job); + setShowModal(true); + }; + + const handleModalClose = () => { + setShowModal(false); + setSelectedJob(null); + }; + + return ( +
+
+
+

{'Access Control Sync Jobs'}

+

{'Synchronize access control policies with system resources and permissions.'}

+
+ +
+ } + onRowClick={handleRowClick} + /> + {showModal && selectedJob && ( + + )} +
+ ); +} + diff --git a/webapp/channels/src/components/admin_console/access_control/jobs/index.ts b/webapp/channels/src/components/admin_console/access_control/jobs/index.ts new file mode 100644 index 0000000000..26e12063bb --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/jobs/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import type {Dispatch} from 'redux'; + +import {createJob, getJobsByType} from 'mattermost-redux/actions/jobs'; + +import AccessControlSyncJobTable from './access_control_sync_job_table'; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + actions: bindActionCreators({ + createJob, + getJobsByType, + }, dispatch), +}); + +export default connect(null, mapDispatchToProps)(AccessControlSyncJobTable); diff --git a/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.scss new file mode 100644 index 0000000000..4cea81feb9 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.scss @@ -0,0 +1,39 @@ + +// Content layout and styling +.cel-help-modal__content-container { + display: flex; + flex-direction: column; + .cel-help-modal__content { + padding: 32px; + padding-top: 0; + } + + // Important notes section + .cel-help-additional-info-modal__content { + display: flex; + flex-direction: column; + padding: 20px; + background-color: rgba(var(--button-bg-rgb), 0.08); + + // Header with icon + .cel-help-additional-info-modal__header { + display: flex; + align-items: center; + margin-bottom: 16px; + + i { + margin-right: 8px; + color: var(--button-bg); + font-size: 24px; + } + + .cel-help-additional-info-modal__title { + font-size: 16px; + font-weight: 600; + } + } + } +} + + + diff --git a/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.tsx new file mode 100644 index 0000000000..96f2150290 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/cel_help/cel_help_modal.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; + +import ExternalLink from 'components/external_link'; +import Markdown from 'components/markdown'; + +import './cel_help_modal.scss'; + +type Props = { + onExited: () => void; + onHide?: () => void; +}; + +const CELHelpModal: React.FC = ({onExited, onHide}: Props) => { + return ( + + )} + modalSubheaderText={( + + )} + compassDesign={true} + bodyPadding={false} + modalLocation='top' + > +
+
+ +
+
+
+ + + + +
+
+ ` are forbidden due to incorrect string comparison.\n- Only `user.attributes` are supported; any other variables are not supported yet.'} + /> + ( + + {msg} + + ), + }} + /> +
+
+
+
+ ); +}; + +export default CELHelpModal; + diff --git a/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.scss new file mode 100644 index 0000000000..6e9685a122 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.scss @@ -0,0 +1,70 @@ +.PolicyConfirmationModal { + .modal-body { + .enforce-toggle { + margin-top: 20px; + + .enforce-checkbox-label { + display: flex; + align-items: center; + color: #3d3c40; + cursor: pointer; + + input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; + } + } + } + + .confirmation { + margin-top: 20px; + color: #3d3c40; + font-weight: 600; + } + } + + .modal-footer { + .btn-cancel { + padding: 10px 16px; + border: none; + border-radius: 4px; + margin-right: 10px; + background: #f2f4f8; + color: #3d3c40; + font-size: 14px; + font-weight: 600; + + &:hover { + background: #e8eaed; + } + } + + .btn-apply { + padding: 10px 16px; + border: none; + border-radius: 4px; + background: #C74A4A; + color: white; + font-size: 14px; + font-weight: 600; + + &:hover { + background: #b73535; + } + } + + .btn-save { + padding: 10px 16px; + border: none; + border-radius: 4px; + background: #166de0; + color: white; + font-size: 14px; + font-weight: 600; + + &:hover { + background: #0f5fc0; + } + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.tsx new file mode 100644 index 0000000000..dbb402bae2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/confirmation/confirmation_modal.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import './confirmation_modal.scss'; +import GenericModal from '@mattermost/components/src/generic_modal/generic_modal'; + +type Props = { + active: boolean; + onExited: () => void; + onConfirm: (apply: boolean) => void; + channelsAffected: number; +} + +export default function PolicyConfirmationModal({active, onExited, onConfirm, channelsAffected}: Props) { + const {formatMessage} = useIntl(); + const [enforceImmediately, setEnforceImmediately] = useState(true); + + return ( + + } + modalSubheaderText={ + + } + footerContent={ +
+ + +
+ } + > + +
+ {active ? ( + formatMessage({ + id: 'admin.access_control.policy.save_policy_confirmation_body', + defaultMessage: 'Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.', + }) + ) : ( + formatMessage({ + id: 'admin.access_control.policy.save_policy_confirmation_body.inactive', + defaultMessage: 'Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.', + }) + )} +
+ +
+ +
+ +
+ {enforceImmediately ? + formatMessage({ + id: 'admin.access_control.policy.channels_affected', + defaultMessage: 'Are you sure you want to save and apply the access control policy?', + }) : + formatMessage({ + id: 'admin.access_control.policy.save_only', + defaultMessage: 'Are you sure you want to save this access control policy?', + }) + } +
+
+ ); +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.scss new file mode 100644 index 0000000000..bae6a12f62 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.scss @@ -0,0 +1,103 @@ +#job-details-modal { + .modal-header{ + padding: 16px 64px 4px 32px; + } + + .filtered-user-list { + height: 440px; + cursor: pointer; + } + + .modal-header-with-status { + display: flex; + align-items: center; + font-size: 26px; + font-weight: 600; + gap: 10px; + } + + // Status indicator + .status-indicator { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + + &.status-success { background-color: var(--online-indicator); } + &.status-error { background-color: var(--error-text); } + &.status-in-progress { background-color: var(--away-indicator); } + &.status-pending { background-color: var(--offline-indicator); } + } + + .filter-row--full { + position: relative; + padding: 0 32px; + + .input-clear { + top: 16px; + right: 14px; + } + + #searchIcon { + position: absolute; + z-index: 2; + top: 16px; + left: 42px; + color: rgba(var(--center-channel-color-rgb), 0.64); + pointer-events: none; + } + + #searchChannelsTextbox { + height: 48px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + box-shadow: none; + font-size: 16px; + padding-inline: 40px; + + &::placeholder { + color: var(--center-channel-color); + } + + &:focus { + border: 2px solid var(--button-bg); + } + } + } + + .sync-job-channel-count-label { + display: flex; + margin: 10px 16px; + color: var(--center-channel-color-64); + font-size: 12px; + } + + .changes-cell { + text-align: center; + + .changes-summary { + .added { + color: var(--online-indicator); + font-weight: 600; + } + .removed { + color: var(--error-text); + font-weight: 600; + } + } + } + + .more-modal__list .more-modal__details{ + padding-left: 0; + } + + .error-status-content { + display: flex; + height: 100%; + flex-direction: column; + padding: 0px 32px 32px; + + &__title { + color: var(--error-text); + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.tsx new file mode 100644 index 0000000000..90b494460b --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/job_details/job_details_modal.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import {GenericModal} from '@mattermost/components'; +import type {Channel} from '@mattermost/types/channels'; +import type {Job} from '@mattermost/types/jobs'; +import type {Team} from '@mattermost/types/teams'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import * as ChannelActions from 'mattermost-redux/actions/channels'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getTeam} from 'mattermost-redux/selectors/entities/teams'; + +import CodeBlock from 'components/code_block/code_block'; + +import type {GlobalState} from 'types/store'; + +import SearchableSyncJobChannelList from './searchable_sync_job_channel_list'; +import type {SyncResults} from './searchable_sync_job_channel_list'; + +import UserListModal, {type ChannelMembersSyncResults} from '../user_sync/user_sync_modal'; + +import './job_details_modal.scss'; + +// Component to display job status +type StatusIndicatorProps = { + status: string; +}; + +const StatusIndicator = ({status}: StatusIndicatorProps): JSX.Element => { + let statusClass = 'status-indicator'; + + if (status === 'success') { + statusClass += ' status-success'; + } else if (status === 'error' || status === 'canceled') { + statusClass += ' status-error'; + } else if (status === 'in_progress') { + statusClass += ' status-in-progress'; + } else { + statusClass += ' status-pending'; + } + + return ( +
+
+
+ ); +}; + +type Props = { + job: Job ; + onExited: () => void; +}; + +export default function JobDetailsModal({job, onExited}: Props): JSX.Element { + const dispatch = useDispatch(); + const [selectedChannel, setSelectedChannel] = useState(null); + const [selectedChannelName, setSelectedChannelName] = useState(''); + const [selectedChannelResults, setSelectedChannelResults] = useState(null); + const [channelLookup, setChannelLookup] = useState>({}); + const [teamLookup, setTeamLookup] = useState>({}); + const [syncResults, setSyncResults] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [allChannelsForList, setAllChannelsForList] = useState([]); + + const pageSize = 10; + + // Get state for lookups + const state = useSelector((state: GlobalState) => state); + + // Parse sync results initially + useEffect(() => { + if (job?.data?.sync_results) { + const parsedResults = JSON.parse(job.data.sync_results); + setSyncResults(parsedResults); + + // Collect all channel IDs and user IDs for lookup + const channelIds: string[] = []; + + // Use a safer type cast for Object.entries + Object.entries(parsedResults).forEach((entry) => { + const channelId = entry[0]; + + channelIds.push(channelId); + }); + + // Fetch channel and user data if we have IDs + if (channelIds.length > 0) { + // Fetch each channel individually + channelIds.forEach((id) => { + dispatch(ChannelActions.getChannel(id)); + }); + } + } + }, [job?.data?.sync_results, dispatch]); + + // Build channel lookup from state and prepare allChannelsForList + useEffect(() => { + if (syncResults) { + const channels: IDMappedObjects = {}; + const teams: IDMappedObjects = {}; + const channelsForList: Channel[] = []; + + Object.keys(syncResults).forEach((channelId) => { + const channel = getChannel(state, channelId); + if (channel) { + channels[channelId] = channel; + channelsForList.push(channel); + if (!teams[channel.team_id]) { + const team = getTeam(state, channel.team_id); + if (team) { + teams[team.id] = team; + } + } + } + }); + + setTeamLookup(teams); + setChannelLookup(channels); + setAllChannelsForList(channelsForList); + } + }, [syncResults, state]); + + const handleViewDetails = (channelId: string, channelName: string, results: ChannelMembersSyncResults) => { + setSelectedChannel(channelId); + setSelectedChannelName(channelName); + setSelectedChannelResults(results); + }; + + const handleCloseUserListModal = () => { + setSelectedChannel(null); + setSelectedChannelName(''); + setSelectedChannelResults(null); + }; + + // Filter and search channels for SearchableSyncJobChannelList + const getFilteredChannels = () => { + let channels = allChannelsForList; + + if (searchTerm) { + channels = channels.filter((channel) => + channel.display_name.toLowerCase().includes(searchTerm.toLowerCase()) || + channel.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (channelLookup[channel.id] && teamLookup[channelLookup[channel.id].team_id]?.name.toLowerCase().includes(searchTerm.toLowerCase())), + ); + } + + // Add filtering by type (Public, Private, Archived) if needed based on currentFilter + // For now, it shows all channels from syncResults + return channels; + }; + + const filteredChannels = getFilteredChannels(); + + const noResultsText = ( + + + + ); + + return ( + + + +
+ } + modalSubheaderText={ +
+ +
+ } + show={true} + bodyPadding={false} + > + {job.status === 'error' ? ( +
+
+ +
+ +
+ ) : ( + job.type.includes('access_control_sync') && syncResults && ( + {}} + isSearch={Boolean(searchTerm)} + search={setSearchTerm} + onViewDetails={handleViewDetails} + noResultsText={noResultsText} + syncResults={syncResults} + /> + ) + )} + + {selectedChannel && selectedChannelResults && ( + + )} + + ); +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx b/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx new file mode 100644 index 0000000000..2904113768 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/job_details/searchable_sync_job_channel_list.tsx @@ -0,0 +1,313 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useRef, useEffect} from 'react'; +import {FormattedMessage, defineMessages, injectIntl, type WrappedComponentProps} from 'react-intl'; + +import {ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components'; +import type {Channel} from '@mattermost/types/channels'; +import type {Team} from '@mattermost/types/teams'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils'; + +import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg'; +import LoadingScreen from 'components/loading_screen'; +import QuickInput from 'components/quick_input'; + +import {isArchivedChannel} from 'utils/channel_utils'; +import Constants from 'utils/constants'; +import {isKeyPressed} from 'utils/keyboard'; + +import type {ChannelMembersSyncResults} from '../user_sync/user_sync_modal'; + +export type SyncResults = { + [channelId: string]: ChannelMembersSyncResults; +}; + +interface Props extends WrappedComponentProps { + channels: Channel[]; + teams: IDMappedObjects; + channelsPerPage: number; + nextPage: (page: number) => void; + isSearch: boolean; + search: (term: string) => void; + onViewDetails?: (channelId: string, channelName: string, results: ChannelMembersSyncResults) => void; + noResultsText: JSX.Element; + loading?: boolean; + syncResults: SyncResults; +} + +const SearchableSyncJobChannelList = (props: Props) => { + const [page, setPage] = useState(0); + const [nextDisabled, setNextDisabled] = useState(false); + const [channelSearchValue, setChannelSearchValue] = useState(''); + const [isSearch, setIsSearch] = useState(props.isSearch); + + const channelListScroll = useRef(null); + + // Handle getDerivedStateFromProps + useEffect(() => { + setIsSearch(props.isSearch); + if (props.isSearch && !isSearch) { + setPage(0); + } + }, [props.isSearch, isSearch]); + + // Handle componentDidMount and componentWillUnmount + useEffect(() => { + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, []); + + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isEnterKeyPressed = isKeyPressed(e, Constants.KeyCodes.ENTER); + if (isEnterKeyPressed && (e.shiftKey || e.ctrlKey || e.altKey)) { + return; + } + if (isEnterKeyPressed && target?.classList.contains('more-modal__row')) { + target.click(); + } + }; + + const handleRowClick = (channel: Channel) => { + if (props.onViewDetails && props.syncResults[channel.id]) { + props.onViewDetails(channel.id, channel.display_name, props.syncResults[channel.id]); + } + }; + + const createChannelRow = (channel: Channel) => { + const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase(); + let channelTypeIcon; + + if (isArchivedChannel(channel)) { + channelTypeIcon = ; + } else if (isPrivateChannel(channel)) { + channelTypeIcon = ; + } else { + channelTypeIcon = ; + } + + const team = props.teams[channel.team_id]; + + const channelMoreInfoContainer = ( +
+ {`${team.display_name}`} +
+ ); + + const channelSyncData = props.syncResults[channel.id]; + const syncChangesDisplay = channelSyncData ? ( +
+ + + {'+' + (channelSyncData.MembersAdded?.length || 0)} + + {' / '} + + {'-' + (channelSyncData.MembersRemoved?.length || 0)} + + +
+ ) : null; + + return ( +
handleRowClick(channel)} + tabIndex={0} + > +
+
+ {channelTypeIcon} + {channel.display_name} +
+ {team && channelMoreInfoContainer} +
+
+ {syncChangesDisplay} +
+
+ ); + }; + + const nextPage = (e: React.MouseEvent) => { + e.preventDefault(); + setPage(page + 1); + setNextDisabled(true); + props.nextPage(page + 1); + channelListScroll.current?.scrollTo({top: 0}); + }; + + const previousPage = (e: React.MouseEvent) => { + e.preventDefault(); + setPage(page - 1); + channelListScroll.current?.scrollTo({top: 0}); + }; + + const handleChange = (e?: React.FormEvent) => { + if (e?.currentTarget) { + setChannelSearchValue(e.currentTarget.value); + props.search(e.currentTarget.value); + } + }; + + const handleClear = () => { + setChannelSearchValue(''); + props.search(''); + }; + + const getEmptyStateMessage = () => { + return ( + + ); + }; + + const channels = props.channels; + let listContent; + let nextButton; + let previousButton; + + if (props.loading && channels.length === 0) { + listContent = ; + } else if (channels.length === 0) { + listContent = ( +
0 ? props.intl.formatMessage(messages.noMore, {text: channelSearchValue}) : props.intl.formatMessage({id: 'widgets.channels_input.empty', defaultMessage: 'No channels found'}) + } + > + +

+ {getEmptyStateMessage()} +

+ {props.noResultsText} +
+ ); + } else { + const pageStart = page * props.channelsPerPage; + const pageEnd = pageStart + props.channelsPerPage; + const channelsToDisplay = props.channels.slice(pageStart, pageEnd); + listContent = channelsToDisplay.map(createChannelRow); + + if (channelsToDisplay.length >= props.channelsPerPage && pageEnd < props.channels.length) { + nextButton = ( + + ); + } + + if (page > 0) { + previousButton = ( + + ); + } + } + + const input = ( +
+ + +
+ ); + + let channelCountLabel; + if (channels.length === 0) { + channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'}); + } else if (channels.length === 1) { + channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_one', defaultMessage: '1 Result'}); + } else if (channels.length > 1) { + channelCountLabel = props.intl.formatMessage(messages.channelCount, {count: channels.length}); + } else { + channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'}); + } + + return ( +
+ {input} +
+ + {channelCountLabel} + +
+
+
+ {listContent} +
+
+
+ {previousButton} + {nextButton} +
+
+ ); +}; + +const messages = defineMessages({ + channelCount: { + id: 'more_channels.count', + defaultMessage: '{count} Results', + }, + noMore: { + id: 'more_channels.noMore', + defaultMessage: 'No results for {text}', + }, +}); + +export default injectIntl(SearchableSyncJobChannelList); diff --git a/webapp/channels/src/components/admin_console/access_control/modals/policy_selection/policy_selection_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/policy_selection/policy_selection_modal.tsx new file mode 100644 index 0000000000..abec289c36 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/policy_selection/policy_selection_modal.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; +import type {AccessControlPolicy} from '@mattermost/types/access_control'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import PolicyList from 'components/admin_console/access_control/policies'; + +type Props = { + show: boolean; + onHide: () => void; + onPolicySelected: (policy: AccessControlPolicy) => void; + actions: { + searchPolicies: (term: string, type: string, after: string, limit: number) => Promise; + }; +}; + +export default function PolicySelectionModal(props: Props): JSX.Element { + const {show, onHide, onPolicySelected, actions} = props; + + return ( + + )} + modalSubheaderText={( + + )} + > + Promise.resolve({data: {}}), + }} + /> + + ); +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.scss new file mode 100644 index 0000000000..c1d79a8758 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.scss @@ -0,0 +1,4 @@ +#testResultsModalLabel { + padding: 32px 0; + text-align: center; +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.tsx new file mode 100644 index 0000000000..af72b31873 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/policy_test/test_modal.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState, useCallback} from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {AccessControlTestResult} from '@mattermost/types/access_control'; +import type {UserProfile} from '@mattermost/types/users'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container'; + +import type {ModalData} from 'types/actions'; +import type {ActionFuncAsync} from 'types/store'; + +import './test_modal.scss'; + +const USERS_TO_FETCH = 50; +const USERS_PER_PAGE = 10; + +type Props = { + onExited: () => void; + actions: { + searchUsers: (term: string, after: string, limit: number) => ActionFuncAsync; + openModal?:

(modalData: ModalData

) => void; + }; +} + +function TestResultsModal({ + onExited, + actions, +}: Props): JSX.Element { + const dispatch = useDispatch(); + const [term, setTerm] = useState(''); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [cursorHistory, setCursorHistory] = useState([]); // Stores the 'after' cursor for page 1, page 2, etc. + + const fetchUsers = useCallback(async (searchTerm: string, cursor: string, reset: boolean = false) => { + setLoading(true); + const result: ActionResult = await dispatch(actions.searchUsers(searchTerm, cursor, USERS_TO_FETCH)); + if (result?.data) { + const newUsers = result.data.users; + if (reset) { + setUsers(newUsers); + } else { + setUsers((prevUsers) => [...prevUsers, ...newUsers]); + } + setTotal(result.data.total); + } else { + setUsers([]); + setTotal(0); + } + setLoading(false); + }, [dispatch, actions]); + + useEffect(() => { + fetchUsers(term, ''); + }, []); + + const handleSearch = (newTerm: string) => { + setCursorHistory([]); + setTerm(newTerm); + fetchUsers(newTerm, '', true); + }; + + const handleNextPage = (page: number) => { + if (loading || !users.length) { + return; + } + if (page * USERS_PER_PAGE < USERS_TO_FETCH) { + return; + } + const cursorForNextPage = users[users.length - 1].id; + setCursorHistory([...cursorHistory, cursorForNextPage]); + fetchUsers(term, cursorForNextPage); + }; + + return ( + + + + + + + + + + + ); +} + +export default TestResultsModal; diff --git a/webapp/channels/src/components/admin_console/access_control/modals/user_sync/synced_user_list.tsx b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/synced_user_list.tsx new file mode 100644 index 0000000000..724599d919 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/synced_user_list.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {UserProfile} from '@mattermost/types/users'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import {UserGroupsSVG} from 'components/common/svg_images_components/user_groups_svg'; +import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container'; + +import type {ActionFuncAsync} from 'types/store'; + +type SyncedUserListProps = { + userIds: string[]; + noResultsMessageId: string; + noResultsDefaultMessage: string; + actions: { + getProfilesByIds: (userIds: string[]) => ActionFuncAsync; + }; +}; + +const USERS_PER_PAGE = 10; + +// TODO: this component should be improved: +// - make pagination work +// - improve search + +export const SyncedUserList = ({userIds, noResultsMessageId, noResultsDefaultMessage, actions}: SyncedUserListProps): JSX.Element => { + const dispatch = useDispatch(); + const [users, setUsers] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + + const totalUsers = userIds.length; + + const fetchUsers = useCallback(async (page: number) => { + const startIndex = page * USERS_PER_PAGE; + const endIndex = startIndex + USERS_PER_PAGE; + const idsToFetch = userIds.slice(startIndex, endIndex); + + await dispatch(actions.getProfilesByIds(idsToFetch)).then((result: ActionResult) => { + if (result?.data) { + setUsers([...result.data]); + } else { + setUsers([]); + } + }); + }, [userIds]); + + useEffect(() => { + fetchUsers(currentPage); + }, [currentPage]); + + const handleSearch = (searchTerm: string) => { + if (searchTerm === '') { + fetchUsers(0); + } else { + setUsers(users.filter((user) => { + return user.username.toLowerCase().includes(searchTerm.toLowerCase()) || + user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) || + user.nickname.toLowerCase().includes(searchTerm.toLowerCase()); + })); + } + }; + + if (userIds.length === 0) { + return ( +

+ + +

+ +

+
+ ); + } + + return ( + { + setCurrentPage(currentPage + 1); + }} + previousPage={() => { + setCurrentPage(currentPage - 1); + }} + search={handleSearch} + actionUserProps={{}} + /> + ); +}; + +export default SyncedUserList; diff --git a/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.scss new file mode 100644 index 0000000000..426f546a85 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.scss @@ -0,0 +1,56 @@ +#user-list-modal-dialog { + .modal-header { + padding-bottom: 0px; + } + + .tabs { + display: flex; + padding: 0 32px; + border-bottom: 1px solid var(--center-channel-color-16); + margin-bottom: 12px; + } + + .tab-button { + padding: 10px 15px; + border: none; + margin-bottom: -1px; + background: none; + color: rgba(var(--center-channel-color-rgb), 0.64); + cursor: pointer; + font-weight: 600; + + &.active { + border-bottom: 2px solid var(--button-bg); + color: var(--button-bg); + } + + &:hover:not(.active) { + color: var(--center-channel-color); + } + } + + .tab-content { + .filtered-user-list { + height: 440px; + overflow-y: auto; + } + + .no-user-message { + display: flex; + height: 440px; + flex-direction: column; + align-items: center; + justify-content: center; + color: rgba(var(--center-channel-color-rgb), 0.64); + text-align: center; + + .empty-state-svg { + margin-bottom: 20px; + } + + .primary-message { + padding-bottom: 24px; + } + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.tsx new file mode 100644 index 0000000000..9ce62ea77f --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/user_sync/user_sync_modal.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; + +import {getProfilesByIds} from 'mattermost-redux/actions/users'; + +import {SyncedUserList} from './synced_user_list'; + +import './user_sync_modal.scss'; + +// Types for sync results +export type ChannelMembersSyncResults = { + MembersAdded: string[]; + MembersRemoved: string[]; +}; + +// Modal for showing detailed user lists +type UserListModalProps = { + channelId: string; + channelName: string; + syncResults: ChannelMembersSyncResults; + onClose: () => void; +}; + +export const UserListModal = ({channelId, channelName, syncResults, onClose}: UserListModalProps): JSX.Element => { + const [activeTab, setActiveTab] = useState<'added' | 'removed'>('added'); + + const handleTabChange = (tab: 'added' | 'removed') => { + setActiveTab(tab); + }; + + const displayName = channelName || channelId; + + return ( + + } + modalSubheaderText={`${displayName} - (${channelId})`} + > +
+ + +
+
+ {activeTab === 'added' && ( + + )} + {activeTab === 'removed' && ( + + )} +
+
+ ); +}; + +export default UserListModal; diff --git a/webapp/channels/src/components/admin_console/access_control/policies.scss b/webapp/channels/src/components/admin_console/access_control/policies.scss new file mode 100644 index 0000000000..b899ef0abc --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policies.scss @@ -0,0 +1,166 @@ +.PolicyTable { + overflow: auto; + + .DataGrid { + padding: 0; + background-color: rgba(var(--sys-white-rgb), 0.04); + + &_search { + height: 4em; + border-bottom: none; + } + + &_searchBar { + margin-left: 0 !important; + } + + &_header { + padding-left: 12px; + font-size: 1em; + font-weight: 600; + + .DataGrid_cell { + display: flex; + + &[data-field="name"] { + justify-content: flex-start; + } + + &[data-field="resources"] { + display: flex; + align-items: left; + justify-content: flex-start; + } + } + } + + .DataGrid_rows { + .DataGrid_row { + display: flex; + min-height: 40px; + padding-left: 12px !important; + border-left: none; + background-color: rgba(var(--sys-white-rgb), 0.04); + cursor: pointer; + + &:hover { + border-left: none; + background-color: rgba(0, 0, 0, 0.05); + } + + .policy-name { + width: 100%; + padding: 10px 0; + } + + .policy-resources { + width: 100%; + padding: 10px 0; + text-align: left; + } + + .policy-actions { + display: flex; + width: 100%; + justify-content: flex-end; + padding-right: 8px; + } + + .policy-menu-button { + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + .icon { + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 18px; + } + } + } + } + + &_footer { + border-bottom: 0; + } + } + + .policy-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + &-text { + h1 { + margin-bottom: 8px; + font-size: 20px; + font-weight: 600; + } + + p { + margin: 0; + color: rgba(63, 67, 80, 0.72); + } + } + + .btn-primary { + display: flex; + align-items: center; + padding: 10px 16px; + gap: 8px; + + .icon { + font-size: 18px; + } + } + } + + .action-wrapper { + display: flex; + justify-content: flex-end; + padding: 0 16px; + + .icon-button { + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(63, 67, 80, 0.72); + cursor: pointer; + + &:hover { + background-color: rgba(63, 67, 80, 0.08); + color: rgba(63, 67, 80, 0.8); + } + + .icon { + font-size: 18px; + } + } + } +} + +.actions-column { + justify-content: flex-end !important; + text-align: right; +} diff --git a/webapp/channels/src/components/admin_console/access_control/policies.test.tsx b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx new file mode 100644 index 0000000000..3fe59e0c5c --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; +import {act} from 'react-dom/test-utils'; + +import type {AccessControlPolicy} from '@mattermost/types/access_control'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import type {Column} from 'components/admin_console/data_grid/data_grid'; + +import PolicyList from './policies'; + +const mockHistoryPushInternal = jest.fn(); +jest.mock('utils/browser_history', () => ({ + getHistory: () => ({ + push: mockHistoryPushInternal, + }), +})); + +describe('components/admin_console/access_control/PolicyList', () => { + const mockSearchPolicies = jest.fn(); + const mockDeletePolicy = jest.fn(); + const defaultProps = { + actions: { + searchPolicies: mockSearchPolicies, + deletePolicy: mockDeletePolicy, + }, + }; + + beforeEach(() => { + mockSearchPolicies.mockReset(); + mockDeletePolicy.mockReset(); + mockHistoryPushInternal.mockReset(); + }); + + test('should match snapshot with no policies', async () => { + mockSearchPolicies.mockResolvedValue({data: {policies: [], total: 0}} as ActionResult); + const wrapper = shallow(); + await act(async () => { + await Promise.resolve(); + }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with policies', async () => { + mockSearchPolicies.mockResolvedValue({ + data: { + policies: [ + {id: 'policy1', name: 'Policy 1'} as AccessControlPolicy, + {id: 'policy2', name: 'Policy 2'} as AccessControlPolicy, + ], + total: 2, + }, + } as ActionResult); + const wrapper = shallow(); + await act(async () => { + await Promise.resolve(); + }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with search error', async () => { + mockSearchPolicies.mockRejectedValue(new Error('Search failed')); + const wrapper = shallow(); + await act(async () => { + await Promise.resolve(); + }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should not call previousPage if no history', async () => { + mockSearchPolicies.mockResolvedValueOnce({data: {policies: [], total: 0}} as ActionResult); + const wrapper = shallow(); + await act(async () => { + await Promise.resolve(); + }); + wrapper.update(); + + mockSearchPolicies.mockClear(); // Clear calls from mount + + await act(async () => { + (wrapper.find('DataGrid').props() as any).previousPage(); + }); + wrapper.update(); + + expect(mockSearchPolicies).not.toHaveBeenCalled(); + expect(wrapper.find('DataGrid').prop('page')).toBe(0); + }); + + test('should get columns correctly', () => { + const wrapper = shallow(); + + // Columns are determined synchronously + const columns = wrapper.find('DataGrid').prop('columns') as Column[]; + expect(columns).toHaveLength(3); + expect(columns[0].field).toBe('name'); + expect(columns[1].field).toBe('resources'); + expect(columns[2].field).toBe('actions'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/policies.tsx b/webapp/channels/src/components/admin_console/access_control/policies.tsx new file mode 100644 index 0000000000..521d3cb5b8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policies.tsx @@ -0,0 +1,359 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {AccessControlPolicy} from '@mattermost/types/access_control'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import type {Row, Column} from 'components/admin_console/data_grid/data_grid'; +import DataGrid from 'components/admin_console/data_grid/data_grid'; +import * as Menu from 'components/menu'; + +import {getHistory} from 'utils/browser_history'; + +import './policies.scss'; + +type Props = { + onPolicySelected?: (policy: AccessControlPolicy) => void; + simpleMode?: boolean; + actions: { + searchPolicies: (term: string, type: string, after: string, limit: number) => Promise; + deletePolicy: (id: string) => Promise; + }; +}; + +const PAGE_SIZE = 10; + +export default function PolicyList(props: Props): JSX.Element { + const [policies, setPolicies] = useState([]); + const [page, setPage] = useState(0); + const [after, setAfter] = useState(''); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [searchErrored, setSearchErrored] = useState(false); + const [cursorHistory, setCursorHistory] = useState([]); + const [total, setTotal] = useState(0); + const intl = useIntl(); + + const history = useMemo(() => getHistory(), []); + + useEffect(() => { + fetchPolicies(); + }, []); + + const fetchPolicies = async (term = '', afterParam = '', resetPage = false) => { + setLoading(true); + + try { + const action = await props.actions.searchPolicies(term, 'parent', afterParam, PAGE_SIZE + 1); + const data = action.data.policies || []; + const newTotal = action.data.total || 0; + + // Check if we have more data than the page size, indicating there's a next page + const hasNextPage = data.length > PAGE_SIZE; + + // If we have more data than needed, remove the extra item (which is used to check for next page) + const newPolicies = hasNextPage ? data.slice(0, PAGE_SIZE) : data; + + // Get the ID of the last policy for the next cursor + const lastPolicyId = newPolicies.length > 0 ? newPolicies[newPolicies.length - 1].id : ''; + + if (resetPage) { + setPolicies(newPolicies); + setLoading(false); + setAfter(lastPolicyId); + setTotal(newTotal); + setPage(0); + setCursorHistory([]); + } else { + setPolicies(newPolicies); + setLoading(false); + setAfter(lastPolicyId); + setTotal(newTotal); + } + } catch (error) { + setLoading(false); + setSearchErrored(true); + } + }; + + const onSearch = async (term: string) => { + if (term.length === 0) { + setPage(0); + setAfter(''); + setLoading(false); + setSearchErrored(false); + setSearch(''); + fetchPolicies(); + return; + } + + setLoading(true); + setSearch(term); + await fetchPolicies(term, '', true); + }; + + const nextPage = async () => { + // Save current cursor to history for "previous" navigation + const newCursorHistory = [...cursorHistory, after]; + + setLoading(true); + setPage(page + 1); + setCursorHistory(newCursorHistory); + + await fetchPolicies(search, after); + }; + + const previousPage = async () => { + if (cursorHistory.length === 0) { + return; + } + + // Remove the current cursor from history + const newCursorHistory = [...cursorHistory]; + newCursorHistory.pop(); + + // Get the previous cursor + const previousCursor = newCursorHistory.length > 0 ? newCursorHistory[newCursorHistory.length - 1] : ''; + + setLoading(true); + setPage(page - 1); + setCursorHistory(newCursorHistory); + + await fetchPolicies(search, previousCursor); + }; + + const getResources = (policy: AccessControlPolicy) => { + const childIds = policy.props?.child_ids as string[]; + if (!childIds || childIds.length === 0) { + return ( + + ); + } + + return ( + + ); + }; + + const handleDelete = async (policyId: string) => { + await props.actions.deletePolicy(policyId); + fetchPolicies(search); + }; + + const getRows = (): Row[] => { + return policies.map((policy: AccessControlPolicy) => { + const descriptionId = `customDescription-${policy.id}`; + const appliedToId = `customAppliedTo-${policy.id}`; + return { + cells: { + name: ( +
+ {policy.name} +
+ ), + resources: ( +
+ {getResources(policy)} +
+ ), + actions: ( +
+ {!props.simpleMode && ( + + ), + }} + menu={{ + id: `policy-menu-dropdown-${policy.id}`, + 'aria-label': intl.formatMessage({ + id: 'admin.access_control.policies.menu.aria_label', + defaultMessage: 'Policy actions menu', + }), + }} + > + { + history.push(`/admin_console/user_management/attribute_based_access_control/edit_policy/${policy.id}`); + }} + leadingElement={} + labels={ + + } + /> + handleDelete(policy.id)} + leadingElement={} + labels={ + + } + isDestructive={true} + disabled={Boolean(policy.props?.child_ids?.length)} + /> + + )} +
+ ), + }, + onClick: () => { + if (props.onPolicySelected) { + props.onPolicySelected(policy); + } else { + history.push(`/admin_console/user_management/attribute_based_access_control/edit_policy/${policy.id}`); + } + }, + }; + }); + }; + + const getColumns = (): Column[] => { + return [ + { + name: ( + + ), + field: 'name', + width: 5, + }, + { + name: ( + + ), + field: 'resources', + textAlign: 'center', + width: 4, + }, + { + name: ( + + ), + field: 'actions', + className: 'actions-column', + width: 1, + }, + ]; + }; + + const getPaginationProps = () => { + const startCount = (page * PAGE_SIZE) + 1; + const endCount = (startCount + policies.length) - 1; + + return { + startCount, + endCount, + total, + }; + }; + + const rows: Row[] = getRows(); + const columns: Column[] = getColumns(); + const {startCount, endCount} = getPaginationProps(); + + let placeholderEmpty: JSX.Element = ( + + ); + + if (searchErrored) { + placeholderEmpty = ( + + ); + } + + const rowsContainerStyles = { + minHeight: `${rows.length * 40}px`, + }; + + return ( +
+ {!props.simpleMode && ( +
+
+

+ +

+

+ +

+
+ +
+ )} + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap new file mode 100644 index 0000000000..c6171d4ad5 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap @@ -0,0 +1,346 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/admin_console/access_control/policy_details/PolicyDetails should match snapshot with existing policy 1`] = ` +
+ +
+ + +
+
+
+
+
+ + } + labelClassName="col-sm-4 vertically-centered-label" + onChange={[Function]} + placeholder="Add a unique policy name" + value="" + /> + + } + id="admin.access_control.policy.edit_policy.autoSyncMembership" + label={ +
+ +
+ } + onChange={[Function]} + setByEnv={false} + value={false} + /> +
+ + + + } + isDisabled={false} + onClick={[Function]} + subtitle="Select user attributes and values as rules to restrict channel membership." + title="Attribute based access rules" + /> + + + + + + + + + } + onClick={[Function]} + subtitle={ + + } + title={ + + } + /> + + + + + + + + + } + isDisabled={false} + onClick={[Function]} + subtitle={ + + } + title={ + + } + /> + + +
+
+
+ + } + disabled={true} + onClick={[Function]} + /> + + + +
+
+`; + +exports[`components/admin_console/access_control/policy_details/PolicyDetails should match snapshot with new policy 1`] = ` +
+ +
+ + +
+
+
+
+
+ + } + labelClassName="col-sm-4 vertically-centered-label" + onChange={[Function]} + placeholder="Add a unique policy name" + value="" + /> + + } + id="admin.access_control.policy.edit_policy.autoSyncMembership" + label={ +
+ +
+ } + onChange={[Function]} + setByEnv={false} + value={false} + /> +
+ + + + } + isDisabled={false} + onClick={[Function]} + subtitle="Select user attributes and values as rules to restrict channel membership." + title="Attribute based access rules" + /> + + + + + + + + + } + onClick={[Function]} + subtitle={ + + } + title={ + + } + /> + + + + + +
+
+
+ + } + disabled={true} + onClick={[Function]} + /> + + + +
+
+`; diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/__snapshots__/channel_list.test.tsx.snap new file mode 100644 index 0000000000..22b1111536 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/__snapshots__/channel_list.test.tsx.snap @@ -0,0 +1,347 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/admin_console/access_control/channel_list should match snapshot with channels 1`] = ` +
+ , + }, + Object { + "field": "team", + "fixed": true, + "name": , + }, + Object { + "field": "remove", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={2} + filterProps={ + Object { + "keys": Array [ + "teams", + ], + "onFilter": [Function], + "options": Object { + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={true} + nextPage={[Function]} + onSearch={[Function]} + page={0} + previousPage={[Function]} + rows={Array []} + startCount={1} + term="" + total={2} + /> +
+`; + +exports[`components/admin_console/access_control/channel_list should match snapshot with channels to add 1`] = ` +
+ , + }, + Object { + "field": "team", + "fixed": true, + "name": , + }, + Object { + "field": "remove", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={3} + filterProps={ + Object { + "keys": Array [ + "teams", + ], + "onFilter": [Function], + "options": Object { + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={true} + nextPage={[Function]} + onSearch={[Function]} + page={0} + previousPage={[Function]} + rows={ + Array [ + Object { + "cells": Object { + "id": "channel3", + "name":
+ +
+ + Channel 3 + +
+
, + "remove": + + , + "team": "Team 1", + }, + }, + ] + } + startCount={1} + term="" + total={3} + /> +
+`; + +exports[`components/admin_console/access_control/channel_list should match snapshot with channels to remove 1`] = ` +
+ , + }, + Object { + "field": "team", + "fixed": true, + "name": , + }, + Object { + "field": "remove", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={2} + filterProps={ + Object { + "keys": Array [ + "teams", + ], + "onFilter": [Function], + "options": Object { + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={true} + nextPage={[Function]} + onSearch={[Function]} + page={0} + previousPage={[Function]} + rows={Array []} + startCount={1} + term="" + total={2} + /> +
+`; + +exports[`components/admin_console/access_control/channel_list should match snapshot with no channels 1`] = ` +
+ , + }, + Object { + "field": "team", + "fixed": true, + "name": , + }, + Object { + "field": "remove", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={0} + filterProps={ + Object { + "keys": Array [ + "teams", + ], + "onFilter": [Function], + "options": Object { + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={false} + nextPage={[Function]} + onSearch={[Function]} + page={0} + previousPage={[Function]} + rows={Array []} + startCount={1} + term="" + total={0} + /> +
+`; diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.scss b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.scss new file mode 100644 index 0000000000..c3a86f749f --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.scss @@ -0,0 +1,47 @@ +.AccessControlPolicyChannelsList { + .Filter_content.Filter__show { + width: 320px; + } + + .FilterList { + width: auto; + } + + .ChannelList__nameColumn { + display: inline-flex; + align-items: center; + justify-content: center; + + span.channel-icon { + margin: 3px 8px 0 0; + } + } + + .DataGrid { + padding: 0; + background-color: rgba(var(--sys-white-rgb), 0.04); + + &_search { + border-bottom: none; + } + + &_searchBar { + margin-left: 0; + } + + .DataGrid_rows { + .DataGrid_row { + display: flex; + min-height: 40px; + border-left: none; + background-color: rgba(var(--sys-white-rgb), 0.04); + cursor: pointer; + + &:hover { + border-left: none; + background-color: rgba(0, 0, 0, 0.05); + } + } + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.test.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.test.tsx new file mode 100644 index 0000000000..8848fa742a --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.test.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import type {ChannelWithTeamData} from '@mattermost/types/channels'; + +import ChannelList from './channel_list'; + +describe('components/admin_console/access_control/channel_list', () => { + const mockSearchChannels = jest.fn(); + const mockSetChannelListSearch = jest.fn(); + const mockSetChannelListFilters = jest.fn(); + const mockOnRemoveCallback = jest.fn(); + const mockOnUndoRemoveCallback = jest.fn(); + const mockOnAddCallback = jest.fn(); + + const defaultProps = { + channels: [], + totalCount: 2, + searchTerm: '', + filters: {}, + policyId: 'policy1', + onRemoveCallback: mockOnRemoveCallback, + onUndoRemoveCallback: mockOnUndoRemoveCallback, + onAddCallback: mockOnAddCallback, + channelsToRemove: {}, + channelsToAdd: {}, + actions: { + searchChannels: jest.fn().mockResolvedValue({ + data: { + channels: [ + {id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData, + {id: 'channel2', name: 'channel2', display_name: 'Channel 2', team_display_name: 'Team 2', type: 'P'} as ChannelWithTeamData, + ], + }, + }), + setChannelListSearch: mockSetChannelListSearch, + setChannelListFilters: mockSetChannelListFilters, + }, + }; + + beforeEach(() => { + mockSearchChannels.mockReset(); + mockSetChannelListSearch.mockReset(); + mockSetChannelListFilters.mockReset(); + mockOnRemoveCallback.mockReset(); + mockOnUndoRemoveCallback.mockReset(); + mockOnAddCallback.mockReset(); + }); + + test('should match snapshot with no channels', () => { + const props = { + ...defaultProps, + channels: [], + totalCount: 0, + policyId: '', + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with channels', () => { + const props = { + ...defaultProps, + totalCount: 2, + policyId: 'policy1', + actions: { + ...defaultProps.actions, + }, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with channels to remove', () => { + const props = { + ...defaultProps, + totalCount: 2, + policyId: 'policy1', + channelsToRemove: { + channel1: {id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData, + }, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with channels to add', () => { + const props = { + ...defaultProps, + channelsToAdd: { + channel3: {id: 'channel3', name: 'channel3', display_name: 'Channel 3', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData, + }, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx new file mode 100644 index 0000000000..a42bda1f8d --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx @@ -0,0 +1,388 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import debounce from 'lodash/debounce'; +import isEqual from 'lodash/isEqual'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import DataGrid from 'components/admin_console/data_grid/data_grid'; +import type {Column, Row} from 'components/admin_console/data_grid/data_grid'; +import type {FilterOptions} from 'components/admin_console/filter/filter'; +import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown'; +import ArchiveIcon from 'components/widgets/icons/archive_icon'; +import GlobeIcon from 'components/widgets/icons/globe_icon'; +import LockIcon from 'components/widgets/icons/lock_icon'; + +import {isArchivedChannel} from 'utils/channel_utils'; +import {Constants} from 'utils/constants'; + +import './channel_list.scss'; + +type Props = { + channels: ChannelWithTeamData[]; + totalCount: number; + searchTerm: string; + filters: ChannelSearchOpts; + policyId?: string; + onRemoveCallback: (channel: ChannelWithTeamData) => void; + onUndoRemoveCallback: (channel: ChannelWithTeamData) => void; + channelsToRemove: Record; + channelsToAdd: Record; + actions: { + searchChannels: (id: string, term: string, opts: ChannelSearchOpts) => Promise; + setChannelListSearch: (term: string) => void; + setChannelListFilters: (filters: ChannelSearchOpts) => void; + }; +} + +type State = { + loading: boolean; + page: number; + after: string; + cursorHistory: string[]; +} + +const PAGE_SIZE = 10; + +export default class ChannelList extends React.PureComponent { + private mounted = false; + private searchDebounced; + + public constructor(props: Props) { + super(props); + this.state = { + after: '', + loading: false, + page: 0, + cursorHistory: [], + }; + + this.searchDebounced = debounce( + async () => { + const {policyId, searchTerm, filters, actions} = this.props; + if (policyId) { + await actions.searchChannels(policyId, searchTerm, filters); + } + this.setState({loading: false}); + }, + Constants.SEARCH_TIMEOUT_MILLISECONDS, + ); + } + + componentDidMount = () => { + this.mounted = true; + this.loadPage(0, PAGE_SIZE + 1); + }; + + componentWillUnmount = () => { + this.searchDebounced.cancel(); + this.mounted = false; + }; + + public async componentDidUpdate(prevProps: Props) { + const {policyId, searchTerm, filters} = this.props; + const filtersModified = !isEqual(prevProps.filters, filters); + const searchTermModified = prevProps.searchTerm !== searchTerm; + + if (searchTermModified || filtersModified) { + this.setState({loading: true}); + + if (searchTerm === '') { + if (filtersModified && policyId) { + await prevProps.actions.searchChannels(policyId, searchTerm, filters); + } else { + // Reset pagination state when clearing search + this.setState({ + after: '', + page: 0, + cursorHistory: [], + }); + await this.loadPage(0, PAGE_SIZE + 1); + } + this.setState({loading: false}); + return; + } + + this.searchDebounced(); + } + } + + private loadPage = async (page: number, pageSize = PAGE_SIZE + 1) => { + const {policyId, searchTerm, filters, actions} = this.props; + + if (!policyId || !this.mounted) { + return; + } + + this.setState({loading: true}); + + const searchFilters = {...filters, page, per_page: pageSize}; + + try { + const action = await actions.searchChannels(policyId, searchTerm, searchFilters); + const data = action.data.channels || []; + + // Check if we have more data than the page size, indicating there's a next page + const hasNextPage = data.length > PAGE_SIZE; + + // If we have more data than needed, remove the extra item (which is used to check for next page) + const channels = hasNextPage ? data.slice(0, PAGE_SIZE) : data; + + // Get the ID of the last channel for the next cursor + const lastChannelId = channels.length > 0 ? channels[channels.length - 1].id : ''; + + this.setState({ + after: lastChannelId, + loading: false, + }); + } catch (error) { + this.setState({loading: false}); + } + }; + + private nextPage = async () => { + const {after, cursorHistory, page} = this.state; + + // Save current cursor to history for "previous" navigation + const newCursorHistory = [...cursorHistory, after]; + + this.setState({ + loading: true, + page: page + 1, + cursorHistory: newCursorHistory, + }); + + await this.loadPage(page + 1, PAGE_SIZE); + }; + + private previousPage = async () => { + const {cursorHistory, page} = this.state; + + if (cursorHistory.length === 0) { + return; + } + + // Remove the current cursor from history + const newCursorHistory = [...cursorHistory]; + newCursorHistory.pop(); + + this.setState({ + loading: true, + page: page - 1, + cursorHistory: newCursorHistory, + }); + + await this.loadPage(page - 1, PAGE_SIZE); + }; + + private getVisibleTotalCount = (): number => { + const {channelsToAdd, totalCount} = this.props; + const channelsToAddCount = Object.keys(channelsToAdd).length; + return (totalCount + channelsToAddCount); + }; + + public getPaginationProps = (): {startCount: number; endCount: number; total: number} => { + const {page} = this.state; + const startCount = (page * PAGE_SIZE) + 1; + const total = this.getVisibleTotalCount(); + const endCount = Math.min((page + 1) * PAGE_SIZE, total); + + return {startCount, endCount, total}; + }; + + private removeChannel = (channel: ChannelWithTeamData) => { + const {channelsToRemove, onRemoveCallback, onUndoRemoveCallback} = this.props; + const {page} = this.state; + + // Toggle between adding and removing the channel + if (channelsToRemove[channel.id] === channel) { + // If the channel is already marked for removal, undo it + onUndoRemoveCallback(channel); + return; + } + + // If the channel is not marked for removal, mark it + onRemoveCallback(channel); + + const {endCount} = this.getPaginationProps(); + if (endCount > this.getVisibleTotalCount() && (endCount % PAGE_SIZE) === 1 && page > 0) { + this.setState({page: page - 1}); + } + }; + + getColumns = (): Column[] => { + return [ + { + name: ( + + ), + field: 'name', + fixed: true, + }, + { + name: ( + + ), + field: 'team', + fixed: true, + }, + { + name: '', + field: 'remove', + textAlign: 'right', + fixed: true, + }, + ]; + }; + + getRows = () => { + const {channels, channelsToRemove, channelsToAdd} = this.props; + const {startCount, endCount} = this.getPaginationProps(); + + // Combine channels to add with existing channels + const channelsToDisplay = [ + ...Object.values(channelsToAdd), + ...channels, + ].slice(startCount - 1, endCount); + + return channelsToDisplay.map((channel) => { + // Determine which icon to display based on channel type + let iconToDisplay = ; + if (channel.type === Constants.PRIVATE_CHANNEL) { + iconToDisplay = ; + } + if (isArchivedChannel(channel)) { + iconToDisplay = ( + + ); + } + + const isMarkedForRemoval = channelsToRemove[channel.id] === channel; + + // Determine the button text and action based on the channel state + const buttonClassName = `group-actions TeamList_editText${isMarkedForRemoval ? ' marked-for-removal' : ''}`; + const buttonText = isMarkedForRemoval ? ( + + ) : ( + + ); + + return { + cells: { + id: channel.id, + name: ( +
+ {iconToDisplay} +
+ + {channel.display_name} + +
+
+ ), + team: channel.team_display_name, + remove: ( + { + e.preventDefault(); + this.removeChannel(channel); + }} + href='#' + > + {buttonText} + + ), + }, + }; + }); + }; + + onSearch = (searchTerm: string) => { + this.props.actions.setChannelListSearch(searchTerm); + }; + + onFilter = (filterOptions: FilterOptions) => { + const filters: ChannelSearchOpts = {}; + const {team_ids: teamIds} = filterOptions.teams.values; + if ((teamIds.value as string[]).length) { + filters.team_ids = teamIds.value as string[]; + } + this.props.actions.setChannelListFilters(filters); + }; + + render() { + const rows: Row[] = this.getRows(); + const columns: Column[] = this.getColumns(); + const {startCount, endCount, total} = this.getPaginationProps(); + + const filterOptions: FilterOptions = { + teams: { + name: 'Teams', + values: { + team_ids: { + name: ( + + ), + value: [], + }, + }, + keys: ['team_ids'], + type: TeamFilterDropdown, + }, + }; + + const filterProps = { + options: filterOptions, + keys: ['teams'], + onFilter: this.onFilter, + }; + + return ( +
+ +
+ ); + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/index.ts b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/index.ts new file mode 100644 index 0000000000..dfb43629f8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/index.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import memoize from 'memoize-one'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import type {Dispatch} from 'redux'; + +import type {Channel, ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels'; + +import {searchAccessControlPolicyChannels as searchChannels} from 'mattermost-redux/actions/access_control'; +import {searchChannelsInheritsPolicy, makeGetChannelsInAccessControlPolicy} from 'mattermost-redux/selectors/entities/access_control'; +import {filterChannelList} from 'mattermost-redux/selectors/entities/channels'; +import {filterChannelsMatchingTerm, channelListToMap} from 'mattermost-redux/utils/channel_utils'; + +import {setChannelListSearch, setChannelListFilters} from 'actions/views/search'; + +import type {GlobalState} from 'types/store'; + +import ChannelList from './channel_list'; + +type OwnProps = { + policyId?: string; + channelsToAdd: Record; +} + +const EMPTY_FILTERS: ChannelSearchOpts = {}; +const EMPTY_SEARCH_TERM = ''; + +function searchChannelsToAdd(channels: Record, term: string, filters: ChannelSearchOpts): Record { + const filteredChannels = filterChannelsMatchingTerm(Object.values(channels), term); + const filteredWithFilters = filterChannelList(filteredChannels, filters); + return channelListToMap(filteredWithFilters); +} + +function makeMapStateToProps() { + const getPolicyChannels = makeGetChannelsInAccessControlPolicy(); + const memoizedSearchChannelsToAdd = memoize(searchChannelsToAdd); + return (state: GlobalState, ownProps: OwnProps) => { + const {channelsToAdd, policyId} = ownProps; + const searchTerm = state.views.search.channelListSearch.term || EMPTY_SEARCH_TERM; + const filters = state.views.search.channelListSearch?.filters || EMPTY_FILTERS; + + let channels: ChannelWithTeamData[] = []; + let totalCount = 0; + + if (searchTerm || Object.keys(filters).length !== 0) { + channels = policyId ? searchChannelsInheritsPolicy(state, policyId, searchTerm, filters) as ChannelWithTeamData[] : []; + const filteredChannelsToAdd = memoizedSearchChannelsToAdd(channelsToAdd, searchTerm, filters) as Record; + totalCount = channels.length; + return { + channels, + totalCount, + searchTerm, + channelsToAdd: filteredChannelsToAdd, + filters, + }; + } + + channels = policyId ? getPolicyChannels(state, {policyId}) as ChannelWithTeamData[] : []; + totalCount = channels.length; + + return { + channels, + totalCount, + searchTerm, + channelsToAdd, + filters, + }; + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + searchChannels, + setChannelListSearch, + setChannelListFilters, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(ChannelList); diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/index.ts b/webapp/channels/src/components/admin_console/access_control/policy_details/index.ts new file mode 100644 index 0000000000..accb0fda49 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/index.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import type {Dispatch} from 'redux'; + +import {getAccessControlPolicy as fetchPolicy, createAccessControlPolicy as createPolicy, deleteAccessControlPolicy as deletePolicy, searchAccessControlPolicyChannels as searchChannels, assignChannelsToAccessControlPolicy, unassignChannelsFromAccessControlPolicy, getAccessControlFields, updateAccessControlPolicyActive} from 'mattermost-redux/actions/access_control'; +import {createJob} from 'mattermost-redux/actions/jobs'; +import {getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control'; + +import {setNavigationBlocked} from 'actions/admin_actions.jsx'; + +import type {GlobalState} from 'types/store'; + +import PolicyDetails from './policy_details'; + +type OwnProps = { + match: { + params: { + policy_id: string; + }; + }; +} + +function mapStateToProps(state: GlobalState, ownProps: OwnProps) { + const policyId = ownProps.match.params.policy_id; + const policy = getPolicy(state, policyId); + return { + policy, + policyId, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + fetchPolicy, + createPolicy, + deletePolicy, + searchChannels, + assignChannelsToAccessControlPolicy, + unassignChannelsFromAccessControlPolicy, + setNavigationBlocked, + getAccessControlFields, + createJob, + updateAccessControlPolicyActive, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(PolicyDetails); diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.scss b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.scss new file mode 100644 index 0000000000..50428d530c --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.scss @@ -0,0 +1,28 @@ +.AccessControlPolicySettings { + .form-group { + min-height: 40px; + } + + .delete-policy { + .btn-primary:not(:disabled) { + background-color: var(--dnd-indicator); + } + } + + .vertically-centered-label { + padding-top: 7px; + } + + .EditPolicy__error { + display: flex; + align-items: center; + margin-left: 12px; + color: var(--error-text); + font-size: 14px; + + i { + margin-right: 4px; + font-size: 16px; + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.test.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.test.tsx new file mode 100644 index 0000000000..7488c95327 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.test.tsx @@ -0,0 +1,167 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; +import {act} from 'react-dom/test-utils'; + +import type {ChannelWithTeamData} from '@mattermost/types/channels'; + +import PolicyDetails from './policy_details'; + +jest.mock('utils/browser_history', () => ({ + getHistory: () => ({ + push: jest.fn(), + }), +})); + +describe('components/admin_console/access_control/policy_details/PolicyDetails', () => { + const mockCreatePolicy = jest.fn(); + const mockUpdatePolicy = jest.fn(); + const mockDeletePolicy = jest.fn(); + const mockSearchChannels = jest.fn(); + const mockSetChannelListSearch = jest.fn(); + const mockSetChannelListFilters = jest.fn(); + const mockOnRemoveCallback = jest.fn(); + const mockOnUndoRemoveCallback = jest.fn(); + const mockOnAddCallback = jest.fn(); + const mockFetchPolicy = jest.fn(); + const mockSetNavigationBlocked = jest.fn(); + const mockAssignChannelsToAccessControlPolicy = jest.fn(); + const mockUnassignChannelsFromAccessControlPolicy = jest.fn(); + const mockGetAccessControlExpressionAutocomplete = jest.fn(); + const mockGetAccessControlFields = jest.fn(); + const mockCreateJob = jest.fn(); + const mockUpdateAccessControlPolicyActive = jest.fn(); + + const defaultProps = { + policyId: 'policy1', + channels: [ + {id: 'channel1', name: 'Channel 1', display_name: 'Channel 1', team_display_name: 'Team 1', type: 'O'} as ChannelWithTeamData, + {id: 'channel2', name: 'channel2', display_name: 'Channel 2', team_display_name: 'Team 2', type: 'P'} as ChannelWithTeamData, + ], + totalCount: 2, + searchTerm: '', + filters: {}, + onRemoveCallback: mockOnRemoveCallback, + onUndoRemoveCallback: mockOnUndoRemoveCallback, + onAddCallback: mockOnAddCallback, + channelsToRemove: {}, + channelsToAdd: {}, + autocompleteResult: {entities: {}}, + actions: { + createPolicy: mockCreatePolicy, + updatePolicy: mockUpdatePolicy, + deletePolicy: mockDeletePolicy, + searchChannels: mockSearchChannels, + setChannelListSearch: mockSetChannelListSearch, + setChannelListFilters: mockSetChannelListFilters, + fetchPolicy: mockFetchPolicy, + setNavigationBlocked: mockSetNavigationBlocked, + assignChannelsToAccessControlPolicy: mockAssignChannelsToAccessControlPolicy, + unassignChannelsFromAccessControlPolicy: mockUnassignChannelsFromAccessControlPolicy, + getAccessControlExpressionAutocomplete: mockGetAccessControlExpressionAutocomplete, + getAccessControlFields: mockGetAccessControlFields, + createJob: mockCreateJob, + updateAccessControlPolicyActive: mockUpdateAccessControlPolicyActive, + }, + }; + + beforeEach(() => { + mockCreatePolicy.mockReset(); + mockUpdatePolicy.mockReset(); + mockDeletePolicy.mockReset(); + mockSearchChannels.mockReset(); + mockSetChannelListSearch.mockReset(); + mockSetChannelListFilters.mockReset(); + mockOnRemoveCallback.mockReset(); + mockOnUndoRemoveCallback.mockReset(); + mockOnAddCallback.mockReset(); + mockFetchPolicy.mockReset(); + mockSetNavigationBlocked.mockReset(); + mockAssignChannelsToAccessControlPolicy.mockReset(); + mockUnassignChannelsFromAccessControlPolicy.mockReset(); + mockGetAccessControlExpressionAutocomplete.mockReset(); + mockGetAccessControlFields.mockReset(); + mockCreateJob.mockReset(); + mockUpdateAccessControlPolicyActive.mockReset(); + }); + + test('should match snapshot with new policy', () => { + const props = { + ...defaultProps, + policyId: '', + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot with existing policy', () => { + const props = { + ...defaultProps, + actions: { + ...defaultProps.actions, + fetchPolicy: jest.fn().mockResolvedValue({ + data: { + policy: { + id: 'policy1', + name: 'Policy 1', + rules: [{expression: 'true'}], + }, + }, + }), + }, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should handle delete policy', async () => { + const props = { + ...defaultProps, + policyId: 'policy1', + actions: { + ...defaultProps.actions, + deletePolicy: mockDeletePolicy.mockResolvedValue({data: {}}), + }, + }; + + const wrapper = shallow(); + + // Find the delete-policy card + const deleteCard = wrapper.find('Card.delete-policy'); + expect(deleteCard.exists()).toBe(true); + + // Find the header with button inside the card + const deleteButtonHeader = deleteCard.find('TitleAndButtonCardHeader'); + expect(deleteButtonHeader.exists()).toBe(true); + + const onClickProp = deleteButtonHeader.props().onClick; + expect(onClickProp).toBeDefined(); + + // Click the delete button + await act(async () => { + if (onClickProp) { + await onClickProp({} as React.MouseEvent); + } + }); + + // Update the wrapper to see the modal + wrapper.update(); + + // Find the confirmation modal + const confirmationModal = wrapper.find('GenericModal'); + expect(confirmationModal.exists()).toBe(true); + + // Trigger the confirmation + await act(async () => { + // Get the handleConfirm prop with proper type assertion + const handleConfirm = (confirmationModal.props() as any).handleConfirm; + if (handleConfirm) { + await handleConfirm(); + } + }); + + expect(mockDeletePolicy).toHaveBeenCalledWith('policy1'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx new file mode 100644 index 0000000000..08fc8c25e2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx @@ -0,0 +1,585 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import cloneDeep from 'lodash/cloneDeep'; +import React, {useState, useEffect} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; +import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control'; +import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels'; +import type {JobTypeBase} from '@mattermost/types/jobs'; +import type {PropertyField} from '@mattermost/types/properties'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import BlockableLink from 'components/admin_console/blockable_link'; +import BooleanSetting from 'components/admin_console/boolean_setting'; +import Card from 'components/card/card'; +import TitleAndButtonCardHeader from 'components/card/title_and_button_card_header/title_and_button_card_header'; +import ChannelSelectorModal from 'components/channel_selector_modal'; +import SaveButton from 'components/save_button'; +import AdminHeader from 'components/widgets/admin_console/admin_header'; +import TextSetting from 'components/widgets/settings/text_setting'; + +import {getHistory} from 'utils/browser_history'; +import {JobTypes} from 'utils/constants'; + +import ChannelList from './channel_list'; + +import CELEditor from '../editors/cel_editor/editor'; +import TableEditor from '../editors/table_editor/table_editor'; +import PolicyConfirmationModal from '../modals/confirmation/confirmation_modal'; + +import './policy_details.scss'; + +const DEFAULT_PAGE_SIZE = 10; + +interface PolicyActions { + fetchPolicy: (id: string) => Promise; + createPolicy: (policy: AccessControlPolicy) => Promise; + deletePolicy: (id: string) => Promise; + searchChannels: (id: string, term: string, opts: ChannelSearchOpts) => Promise; + setNavigationBlocked: (blocked: boolean) => void; + assignChannelsToAccessControlPolicy: (policyId: string, channelIds: string[]) => Promise; + unassignChannelsFromAccessControlPolicy: (policyId: string, channelIds: string[]) => Promise; + getAccessControlFields: (after: string, limit: number) => Promise; + createJob: (job: JobTypeBase & { data: any }) => Promise; + updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise; +} + +export interface PolicyDetailsProps { + policy?: AccessControlPolicy; + policyId?: string; + actions: PolicyActions; +} + +interface ChannelChanges { + removed: Record; + added: Record; + removedCount: number; +} + +function PolicyDetails({ + policy, + policyId, + actions, +}: PolicyDetailsProps): JSX.Element { + const [policyName, setPolicyName] = useState(policy?.name || ''); + const [expression, setExpression] = useState(policy?.rules?.[0]?.expression || ''); + const [autoSyncMembership, setAutoSyncMembership] = useState(policy?.active || false); + const [serverError, setServerError] = useState(undefined); + const [addChannelOpen, setAddChannelOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'cel' | 'table'>('table'); + const [channelChanges, setChannelChanges] = useState({ + removed: {}, + added: {}, + removedCount: 0, + }); + const [saveNeeded, setSaveNeeded] = useState(false); + const [channelsCount, setChannelsCount] = useState(0); + const [autocompleteResult, setAutocompleteResult] = useState([]); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false); + const {formatMessage} = useIntl(); + useEffect(() => { + loadPage(); + }, [policyId]); + + // Check if expression is simple enough for table mode + const isSimpleExpression = (expr: string): boolean => { + if (!expr) { + return true; + } + + // Expression is simple if it only contains user.attributes.X == "Y" or user.attributes.X in ["Y", "Z"] + // or user.attributes.X.startsWith/endsWith/contains("Y") + return expr.split('&&').every((condition) => { + const trimmed = condition.trim(); + return trimmed.match(/^user\.attributes\.\w+\s*(==|!=)\s*['"][^'"]+['"]$/) || + trimmed.match(/^user\.attributes\.\w+\s+in\s+\[.*?\]$/) || + trimmed.match(/^user\.attributes\.\w+\.startsWith\(['"][^'"]+['"].*?\)$/) || + trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]+['"].*?\)$/) || + trimmed.match(/^user\.attributes\.\w+\.contains\(['"][^'"]+['"].*?\)$/); + }); + }; + + const loadPage = async () => { + // Fetch autocomplete fields first, as they are general and needed for both new and existing policies. + const fieldsPromise = actions.getAccessControlFields('', 100).then((result) => { + if (result.data) { + setAutocompleteResult(result.data); + } + }); + + if (policyId) { + // For existing policies, fetch policy details and channels + const policyPromise = actions.fetchPolicy(policyId).then((result) => { + setPolicyName(result.data?.name || ''); + setExpression(result.data?.rules?.[0]?.expression || ''); + setAutoSyncMembership(result.data?.active || false); + }); + + const channelsPromise = actions.searchChannels(policyId, '', {per_page: DEFAULT_PAGE_SIZE}).then((result) => { + setChannelsCount(result.data?.total_count || 0); + }); + + // Wait for all fetches for an existing policy + await Promise.all([fieldsPromise, policyPromise, channelsPromise]); + } else { + // For new policies, just ensure general fields are fetched. + // Policy name, expression, etc., are already initialized from props or defaults by useState. + await fieldsPromise; + } + }; + + const handleSubmit = async (apply = false) => { + let success = true; + let currentPolicyId = policyId; + + // --- Step 1: Create/Update Policy --- + await actions.createPolicy({ + id: currentPolicyId || '', + name: policyName, + rules: [{expression, actions: ['*']}] as AccessControlPolicyRule[], + type: 'parent', + version: 'v0.1', + }).then((result) => { + if (result.error) { + setServerError(result.error.message); + success = false; + return; + } + currentPolicyId = result.data?.id; + setPolicyName(result.data?.name || ''); + setExpression(result.data?.rules?.[0]?.expression || ''); + setAutoSyncMembership(result.data?.active || false); + }); + + if (!currentPolicyId || !success) { + return; + } + + // --- Step 2: Update Policy Active --- + try { + await actions.updateAccessControlPolicyActive(currentPolicyId, autoSyncMembership); + } catch (error) { + setServerError(`Error updating policy active status: ${error.message}`); + success = false; + return; + } + + // --- Step 3: Assign Channels --- + if (success) { + try { + if (channelChanges.removedCount > 0) { + await actions.unassignChannelsFromAccessControlPolicy(currentPolicyId, Object.keys(channelChanges.removed)); + } + if (Object.keys(channelChanges.added).length > 0) { + await actions.assignChannelsToAccessControlPolicy(currentPolicyId, Object.keys(channelChanges.added)); + } + + setChannelChanges({removed: {}, added: {}, removedCount: 0}); + } catch (error) { + setServerError(`Error assigning channels: ${error.message}`); + success = false; + return; + } + } + + // --- Step 4: Create Job if necessary --- + if (apply) { + try { + const job: JobTypeBase & { data: any } = { + type: JobTypes.ACCESS_CONTROL_SYNC, + data: {parent_id: currentPolicyId}, + }; + await actions.createJob(job); + } catch (error) { + setServerError(`Error creating job: ${error.message}`); + success = false; + return; + } + } + + // --- Step 5: Navigate lastly --- + setSaveNeeded(false); + setShowConfirmationModal(false); + actions.setNavigationBlocked(false); + getHistory().push('/admin_console/user_management/attribute_based_access_control'); + }; + + const handleDelete = async () => { + if (!policyId) { + return; // Should not happen if delete button is enabled correctly + } + + let success = true; + + // --- Step 1: Unassign Channels (if necessary) --- + if (channelChanges.removedCount > 0) { + try { + await actions.unassignChannelsFromAccessControlPolicy(policyId, Object.keys(channelChanges.removed)); + } catch (error) { + setServerError(`Error unassigning channels: ${error.message}`); + success = false; + } + } + + // --- Step 2: Delete Policy and Navigate --- + if (success) { + try { + await actions.deletePolicy(policyId); + } catch (error) { + setServerError(`Error deleting policy: ${error.message}`); + } + } + + if (success) { + getHistory().push('/admin_console/user_management/attribute_based_access_control'); + } + }; + + const handleChannelChanges = (channels: ChannelWithTeamData[], isAdding: boolean) => { + setChannelChanges((prev) => { + const newChanges = cloneDeep(prev); + + channels.forEach((channel) => { + if (isAdding) { + if (newChanges.removed[channel.id]) { + delete newChanges.removed[channel.id]; + newChanges.removedCount--; + } else { + newChanges.added[channel.id] = channel; + } + } else if (newChanges.added[channel.id]) { + delete newChanges.added[channel.id]; + } else if (!newChanges.removed[channel.id]) { + newChanges.removedCount++; + newChanges.removed[channel.id] = channel; + } + }); + + return newChanges; + }); + setSaveNeeded(true); + actions.setNavigationBlocked(true); + }; + + const handleUndoRemove = (channel: ChannelWithTeamData) => { + setChannelChanges((prev) => { + const newChanges = cloneDeep(prev); + if (newChanges.removed[channel.id]) { + delete newChanges.removed[channel.id]; + newChanges.removedCount--; + } + return newChanges; + }); + setSaveNeeded(true); + actions.setNavigationBlocked(true); + }; + + const hasChannels = () => { + // If there are channels on the server (minus any pending removals) or newly added channels + return ( + (channelsCount > channelChanges.removedCount) || + (Object.keys(channelChanges.added).length > 0) + ); + }; + + return ( +
+ +
+ + +
+
+
+
+
+ + } + value={policyName} + placeholder={formatMessage({ + id: 'admin.access_control.policy.edit_policy.policyName.placeholder', + defaultMessage: 'Add a unique policy name', + })} + onChange={(_, value) => { + setPolicyName(value); + setSaveNeeded(true); + }} + labelClassName='col-sm-4 vertically-centered-label' + inputClassName='col-sm-8' + /> + + +
+ } + value={autoSyncMembership} + onChange={(_, value) => { + setAutoSyncMembership(value); + setSaveNeeded(true); + }} + setByEnv={false} + helpText={ + + } + /> +
+ + + + + ) : ( + + ) + } + onClick={() => setEditorMode(editorMode === 'table' ? 'cel' : 'table')} + isDisabled={editorMode === 'cel' && !isSimpleExpression(expression)} + tooltipText={ + editorMode === 'cel' && !isSimpleExpression(expression) ? + 'Complex expression detected. Simple expressions editor is not available at the moment.' : + undefined + } + /> + + + {editorMode === 'cel' ? ( + { + setExpression(value); + setSaveNeeded(true); + }} + onValidate={() => {}} + userAttributes={autocompleteResult.map((attr) => ({ + attribute: attr.name, + values: [], + }))} + /> + ) : ( + { + setExpression(value); + setSaveNeeded(true); + }} + onValidate={() => {}} + userAttributes={autocompleteResult.map((attr) => ({ + attribute: attr.name, + values: [], + }))} + /> + )} + + + + + + + } + subtitle={ + + } + buttonText={ + + } + onClick={() => setAddChannelOpen(true)} + /> + + + handleChannelChanges([channel], false)} + onUndoRemoveCallback={handleUndoRemove} + channelsToRemove={channelChanges.removed} + channelsToAdd={channelChanges.added} + policyId={policyId} + /> + + + {policyId && ( + + + + } + subtitle={ + hasChannels() ? ( + + ) : ( + + ) + } + buttonText={ + + } + onClick={() => { + if (hasChannels()) { + return; + } + setShowDeleteConfirmationModal(true); + }} + isDisabled={hasChannels()} + /> + + + )} +
+
+ + {addChannelOpen && ( + setAddChannelOpen(false)} + onChannelsSelected={(channels) => handleChannelChanges(channels, true)} + groupID={''} + alreadySelected={Object.values(channelChanges.added).map((channel) => channel.id)} + excludeAccessControlPolicyEnforced={true} + excludeTypes={['O', 'D', 'G']} + /> + )} + + {showConfirmationModal && ( + setShowConfirmationModal(false)} + onConfirm={handleSubmit} + channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length} + /> + )} + + {showDeleteConfirmationModal && ( + setShowDeleteConfirmationModal(false)} + handleConfirm={handleDelete} + handleCancel={() => setShowDeleteConfirmationModal(false)} + modalHeaderText={ + + } + confirmButtonText={ + + } + confirmButtonClassName='btn btn-danger' + isDeleteModal={true} + compassDesign={true} + > + + + )} + +
+ { + if (hasChannels()) { + setShowConfirmationModal(true); + } else { + handleSubmit(); + } + }} + defaultMessage={ + + } + /> + + + + {serverError && ( + + + + + )} +
+
+ ); +} + +export default PolicyDetails; diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 3d5b61052c..3da1fa0406 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -41,6 +41,9 @@ import {isCloudLicense} from 'utils/license_utils'; import {ID_PATH_PATTERN} from 'utils/path'; import {getSiteURL} from 'utils/url'; +import PolicyList from './access_control'; +import AccessControlPolicyJobs from './access_control/jobs'; +import PolicyDetails from './access_control/policy_details'; import * as DefinitionConstants from './admin_definition_constants'; import AuditLoggingCertificateUploadSetting from './audit_logging'; import Audits from './audits'; @@ -77,6 +80,7 @@ import { GroupsFeatureDiscovery, MobileSecurityFeatureDiscovery, } from './feature_discovery/features'; +import AttributeBasedAccessControlFeatureDiscovery from './feature_discovery/features/attribute_based_access_control'; import FeatureFlags, {messages as featureFlagsMessages} from './feature_flags'; import GroupDetails from './group_settings/group_details'; import GroupSettings from './group_settings/group_settings'; @@ -664,6 +668,117 @@ const AdminDefinition: AdminDefinitionType = { }, restrictedIndicator: getRestrictedIndicator(true, LicenseSkus.Enterprise), }, + access_control_policy_details_edit: { + url: `user_management/attribute_based_access_control/edit_policy/:policy_id(${ID_PATH_PATTERN})`, + isHidden: it.any( + it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'), + it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)), + it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + ), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'), + ), + schema: { + id: 'AccessControlPolicy', + component: PolicyDetails, + }, + + }, + access_control_policy_details: { + url: 'user_management/attribute_based_access_control/edit_policy', + isHidden: it.any( + it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'), + it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)), + it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'), + ), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + schema: { + id: 'AccessControlPolicy', + component: PolicyDetails, + }, + }, + attribute_based_access_control: { + url: 'user_management/attribute_based_access_control', + title: defineMessage({id: 'admin.sidebar.attributeBasedAccessControl', defaultMessage: 'Attribute-Based Access'}), + isHidden: it.any( + it.not(it.licensedForSku(LicenseSkus.EnterpriseAdvanced)), + it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'), + ), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.SYSTEM_ROLES)), + schema: { + id: 'AttributeBasedAccessControl', + isBeta: true, + name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access'}), + sections: [ + { + key: 'admin.accesscontrol.settings', + settings: [ + { + type: 'bool', + key: 'AccessControlSettings.EnableAttributeBasedAccessControl', + label: defineMessage({id: 'admin.accesscontrol.enableTitle', defaultMessage: 'Allow attribute based access controls on this server'}), + help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies'}), + }, + ], + }, + { + key: 'admin.accesscontrol.policies', + isHidden: it.any( + it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'), + it.stateIsFalse('AccessControlSettings.EnableAttributeBasedAccessControl'), + ), + settings: [ + { + type: 'custom', + component: PolicyList, + key: 'PolicyListPanel', + }, + ], + }, + { + key: 'admin.accesscontrol.policyjobs', + isHidden: it.any( + it.configIsFalse('AccessControlSettings', 'EnableAttributeBasedAccessControl'), + it.stateIsFalse('AccessControlSettings.EnableAttributeBasedAccessControl'), + ), + settings: [ + { + type: 'custom', + component: AccessControlPolicyJobs, + key: 'AcessControlPolicyJobs', + }, + ], + }, + ], + }, + restrictedIndicator: getRestrictedIndicator(false, LicenseSkus.EnterpriseAdvanced), + }, + attribute_based_access_control_feature_discovery: { + url: 'user_management/attribute_based_access_control', + isDiscovery: true, + title: defineMessage({id: 'admin.sidebar.attributeBasedAccessControl', defaultMessage: 'Attribute-Based Access'}), + isHidden: it.any( + it.licensedForSku(LicenseSkus.EnterpriseAdvanced), + it.not(it.enterpriseReady), + it.configIsFalse('FeatureFlags', 'AttributeBasedAccessControl'), + ), + schema: { + id: 'AttributeBasedAccessControl', + name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access (Beta)'}), + settings: [ + { + type: 'custom', + component: AttributeBasedAccessControlFeatureDiscovery, + key: 'AttributeBasedAccessControlFeatureDiscovery', + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ABOUT.EDITION_AND_LICENSE)), + }, + ], + }, + restrictedIndicator: getRestrictedIndicator(true, LicenseSkus.EnterpriseAdvanced), + }, }, }, environment: { diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap index e4142c3b33..9547865d2f 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap @@ -1496,6 +1496,30 @@ exports[`components/AdminSidebar should match snapshot with license with enterpr /> } /> + + } + title={ + + } + /> } /> + + } + /> { Scope: 'scope', } as Office365Settings, FeatureFlags: { + AttributeBasedAccessControl: true, CustomProfileAttributes: true, CloudDedicatedExportUI: true, CloudIPFiltering: true, @@ -521,6 +522,7 @@ describe('components/AdminSidebar', () => { Scope: 'scope', } as Office365Settings, FeatureFlags: { + AttributeBasedAccessControl: true, CustomProfileAttributes: true, CloudDedicatedExportUI: true, CloudIPFiltering: true, diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx new file mode 100644 index 0000000000..9c1f5e9328 --- /dev/null +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessage} from 'react-intl'; + +import {LicenseSkus} from 'utils/constants'; + +import SystemRolesSVG from './images/system_roles_svg'; + +import FeatureDiscovery from '../index'; + +const AttributeBasedAccessControlFeatureDiscovery: React.FC = () => { + return ( + + } + /> + ); +}; + +export default AttributeBasedAccessControlFeatureDiscovery; diff --git a/webapp/channels/src/components/admin_console/jobs/table.scss b/webapp/channels/src/components/admin_console/jobs/table.scss index 94df110933..4fd61513d4 100644 --- a/webapp/channels/src/components/admin_console/jobs/table.scss +++ b/webapp/channels/src/components/admin_console/jobs/table.scss @@ -26,3 +26,18 @@ width: 30px; } } + +.JobTable { + .clickable { + cursor: pointer; + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + } + + .DataGrid_footer { + padding: 8px; + border-top: solid 1px rgba(0, 0, 0, 0.1); + text-align: right; + } +} diff --git a/webapp/channels/src/components/admin_console/jobs/table.test.tsx b/webapp/channels/src/components/admin_console/jobs/table.test.tsx index e145233423..3ee2a5b6af 100644 --- a/webapp/channels/src/components/admin_console/jobs/table.test.tsx +++ b/webapp/channels/src/components/admin_console/jobs/table.test.tsx @@ -6,8 +6,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import JobCancelButton from './job_cancel_button'; -import JobTable from './table'; import type {Props} from './table'; +import JobTable from './table'; describe('components/admin_console/jobs/table', () => { const createJobButtonText = ( diff --git a/webapp/channels/src/components/admin_console/jobs/table.tsx b/webapp/channels/src/components/admin_console/jobs/table.tsx index 88adb13cef..38776b90a1 100644 --- a/webapp/channels/src/components/admin_console/jobs/table.tsx +++ b/webapp/channels/src/components/admin_console/jobs/table.tsx @@ -9,6 +9,9 @@ import type {Job, JobType} from '@mattermost/types/jobs'; import type {ActionResult} from 'mattermost-redux/types/actions'; +import NextIcon from 'components/widgets/icons/fa_next_icon'; +import PreviousIcon from 'components/widgets/icons/fa_previous_icon'; + import {JobTypes} from 'utils/constants'; import JobCancelButton from './job_cancel_button'; @@ -31,6 +34,8 @@ export type Props = { createJobButtonText: React.ReactNode; hideTable?: boolean; jobData?: any; + onRowClick?: (job: Job) => void; + perPage?: number; actions: { getJobsByType: (jobType: JobType) => void; cancelJob: (jobId: string) => Promise; @@ -38,9 +43,20 @@ export type Props = { }; } -class JobTable extends React.PureComponent { +type State = { + currentPage: number; +} + +class JobTable extends React.PureComponent { interval: ReturnType|null = null; + constructor(props: Props) { + super(props); + this.state = { + currentPage: 0, + }; + } + componentDidMount() { this.props.actions.getJobsByType(this.props.jobType); this.interval = setInterval(this.reload, 15000); @@ -84,24 +100,45 @@ class JobTable extends React.PureComponent { this.reload(); }; + handleNextPage = () => { + if (this.props.perPage) { + const totalPages = Math.ceil(this.props.jobs.length / this.props.perPage); + if (this.state.currentPage < totalPages) { + this.setState({currentPage: this.state.currentPage + 1}); + } + } + }; + + handlePrevPage = () => { + if (this.state.currentPage > 0) { + this.setState({currentPage: this.state.currentPage - 1}); + } + }; + render() { + const {perPage} = this.props; + const {currentPage} = this.state; + + let paginatedJobs = this.props.jobs; + let startIndex = 0; + let endIndex = this.props.jobs.length; + + if (perPage) { + startIndex = currentPage * perPage; + endIndex = Math.min(startIndex + perPage, this.props.jobs.length); + paginatedJobs = this.props.jobs.slice(startIndex, endIndex); + } + const showFilesColumn = this.props.jobType === JobTypes.MESSAGE_EXPORT && this.props.downloadExportResults; - const items = this.props.jobs.map((job) => { + const hideDetailsColumn = this.props.jobType === JobTypes.ACCESS_CONTROL_SYNC; + const items = paginatedJobs.map((job) => { return ( this.props.onRowClick!(job) : undefined} + className={this.props.onRowClick ? 'clickable' : ''} > - - - - {showFilesColumn && - - } { /> - {this.getExtraInfoText(job)} + {showFilesColumn && + + } + {!hideDetailsColumn && ( + {this.getExtraInfoText(job)} + )} + + + ); }); + const renderFooter = (): JSX.Element | null => { + let footer: JSX.Element | null = null; + + if (perPage) { + const firstPage = startIndex <= 0; + const lastPage = endIndex >= this.props.jobs.length; + + footer = ( +
+
+ + + +
+
+ ); + } + + return footer; + }; + return (
@@ -143,21 +237,12 @@ class JobTable extends React.PureComponent { > - - {showFilesColumn && - - - - } { defaultMessage='Run Time' /> - + {showFilesColumn && + + } + {!hideDetailsColumn && ( + + + + )} + {items} + {perPage && this.props.jobs.length > 0 && ( + renderFooter() + )}
}
diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.scss b/webapp/channels/src/components/admin_console/schema_admin_settings.scss index 32bae08f20..896cf9a54a 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.scss +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.scss @@ -29,3 +29,8 @@ .config-section:nth-child(1) { margin-top: 0; } + +.admin-header-beta-badge { + margin-right: auto; + margin-inline-start: 8px; +} diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx index 998a0d3a97..b2fb309690 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.tsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.tsx @@ -32,6 +32,7 @@ import Markdown from 'components/markdown'; import SaveButton from 'components/save_button'; import AdminHeader from 'components/widgets/admin_console/admin_header'; import WarningIcon from 'components/widgets/icons/fa_warning_icon'; +import BetaTag from 'components/widgets/tag/beta_tag'; import WithTooltip from 'components/with_tooltip'; import * as I18n from 'i18n/i18n.jsx'; @@ -39,7 +40,7 @@ import Constants from 'utils/constants'; import {mappingValueFromRoles, rolesFromMapping} from 'utils/policy_roles_adapter'; import Setting from './setting'; -import type {AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types'; +import type {AdminDefinitionConfigSchemaSection, AdminDefinitionSetting, AdminDefinitionSettingBanner, AdminDefinitionSettingDropdownOption, AdminDefinitionSubSectionSchema, ConsoleAccess} from './types'; import './schema_admin_settings.scss'; @@ -330,10 +331,19 @@ export class SchemaAdminSettings extends React.PureComponent { name = this.props.schema.name; } + const betaBadge = this.props.schema.isBeta && ( + + ); + if (typeof name === 'string') { return ( {name} + {betaBadge} ); } @@ -343,6 +353,7 @@ export class SchemaAdminSettings extends React.PureComponent { + {betaBadge} ); }; @@ -434,6 +445,13 @@ export class SchemaAdminSettings extends React.PureComponent { return Boolean(setting.isHidden); }; + isSectionHidden = (section: AdminDefinitionConfigSchemaSection) => { + if (typeof section.isHidden === 'function') { + return section.isHidden(this.props.config, this.state, this.props.license); + } + return Boolean(section.isHidden); + }; + buildButtonSetting = (setting: AdminDefinitionSetting) => { if (!this.props.schema || setting.type !== 'button') { return (<>); @@ -1058,6 +1076,10 @@ export class SchemaAdminSettings extends React.PureComponent { const sections: React.ReactNode[] = []; schema.sections.forEach((section) => { + if (this.isSectionHidden(section)) { + return; + } + const settingsList: React.ReactNode[] = []; if (section.settings) { section.settings.forEach((setting) => { diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/__snapshots__/channel_details.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/__snapshots__/channel_details.test.tsx.snap index 606e53335a..835e1cbb77 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/__snapshots__/channel_details.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/__snapshots__/channel_details.test.tsx.snap @@ -125,12 +125,15 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match toPublic={true} />
@@ -62,6 +64,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelModes should match s isPublic={true} isSynced={false} onToggle={[MockFunction]} + policyEnforced={false} + policyEnforcedToggleAvailable={false} /> diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.scss b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.scss new file mode 100644 index 0000000000..4722f92159 --- /dev/null +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.scss @@ -0,0 +1,80 @@ +.policy-table-container { + width: 100%; + margin-block: -20px; + + .policy-table { + width: 100%; + margin-bottom: 0; + background-color: var(--center-channel-bg); + border-collapse: collapse; + + th, td { + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + line-height: 20px; + padding-block: 16px; + text-align: left; + white-space: nowrap; + } + + thead tr { + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + } + + th { + background-color: var(--center-channel-bg); + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + } + + td { + background-color: var(--center-channel-bg); + color: var(--center-channel-color); + font-size: 14px; + + &.policy-name { + font-weight: 600; + } + } + + tbody tr:last-child td { + border-bottom: none; + } + + .text-right { + width: 50px; + padding-right: 16px; + text-align: right; + } + + .policy-edit-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(var(--center-channel-color-rgb), 0.72); + text-decoration: none; + + &:hover { + color: rgba(var(--center-channel-color-rgb), 0.9); + } + + i { + font-size: 16px; + } + } + } +} + +// Style for the Remove policy button +#channel_access_control_with_policy { + .btn.btn-primary { + border-color: var(--dnd-indicator); + background-color: var(--dnd-indicator); + color: var(--button-color); + + &:hover, &:active, &:focus { + border-color: rgba(var(--dnd-indicator-rgb), 0.88); + background-color: rgba(var(--dnd-indicator-rgb), 0.88); + } + } +} diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx new file mode 100644 index 0000000000..ffebb09b3f --- /dev/null +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; +import {Link} from 'react-router-dom'; + +import type {AccessControlPolicy} from '@mattermost/types/access_control'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import PolicySelectionModal from 'components/admin_console/access_control/modals/policy_selection/policy_selection_modal'; +import AdminPanelWithButton from 'components/widgets/admin_console/admin_panel_with_button'; + +import './channel_access_control_policy.scss'; + +interface Props { + accessControlPolicies: AccessControlPolicy[]; + actions: { + searchPolicies: (term: string, type: string, after: string, limit: number) => Promise; + onPolicySelected?: (policy: AccessControlPolicy) => void; + onPolicyRemoved: () => void; + }; +} + +export const ChannelAccessControl: React.FC = (props: Props): JSX.Element => { + const {accessControlPolicies, actions} = props; + const [showPolicySelectionModal, setShowPolicySelectionModal] = useState(false); + + const intl = useIntl(); + + const handlePolicySelected = (policy: AccessControlPolicy) => { + if (actions.onPolicySelected && policy) { + actions.onPolicySelected(policy); + } + setShowPolicySelectionModal(false); + }; + + const handleClosePolicyModal = () => { + setShowPolicySelectionModal(false); + }; + + const handleOpenPolicyModal = () => { + setShowPolicySelectionModal(true); + }; + + const renderTable = () => { + if (!accessControlPolicies || accessControlPolicies.length === 0) { + return null; + } + + return ( +
+ + + + + + + + + {accessControlPolicies.map((policy) => ( + + + + + ))} + +
+ + + + + +
{policy.name} + + + +
+
+ ); + }; + + // Attribute based access is enabled, but no policy + if (accessControlPolicies.length === 0) { + return ( + { + handleOpenPolicyModal(); + }} + > + + + ); + } + + return ( + { + actions.onPolicyRemoved(); + }} + > +
+
+
+ {!accessControlPolicies && ( +
+ +
+ )} + {renderTable()} +
+
+
+
+ ); +}; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.test.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.test.tsx index a36bdcb03d..20e47eaf52 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.test.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.test.tsx @@ -93,6 +93,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { updateChannelMemberSchemeRoles: jest.fn(), deleteChannel: jest.fn(), unarchiveChannel: jest.fn(), + getAccessControlPolicy: jest.fn(), + deleteAccessControlPolicy: jest.fn(), + assignChannelToAccessControlPolicy: jest.fn(), + searchPolicies: jest.fn(), }; const additionalProps = { @@ -100,6 +104,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { guestAccountsEnabled: true, channelModerationEnabled: true, channelGroupsEnabled: true, + abacSupported: true, isDisabled: false, }; @@ -217,6 +222,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { updateChannelMemberSchemeRoles: jest.fn(), deleteChannel: jest.fn(), unarchiveChannel: jest.fn(), + getAccessControlPolicy: jest.fn(), + deleteAccessControlPolicy: jest.fn(), + assignChannelToAccessControlPolicy: jest.fn(), + searchPolicies: jest.fn(), }; const additionalProps = { @@ -225,6 +234,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { channelModerationEnabled: true, channelGroupsEnabled: false, isDisabled: false, + abacSupported: false, }; if (!testChannel.id) { @@ -342,6 +352,10 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { updateChannelMemberSchemeRoles: jest.fn(), deleteChannel: jest.fn(), unarchiveChannel: jest.fn(), + getAccessControlPolicy: jest.fn(), + deleteAccessControlPolicy: jest.fn(), + assignChannelToAccessControlPolicy: jest.fn(), + searchPolicies: jest.fn(), }; const additionalProps = { @@ -350,6 +364,7 @@ describe('admin_console/team_channel_settings/channel/ChannelDetails', () => { channelModerationEnabled: true, channelGroupsEnabled: false, isDisabled: false, + abacSupported: true, }; if (!testChannel.id) { diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx index 52d97d3dba..f616391e23 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx @@ -5,6 +5,7 @@ import cloneDeep from 'lodash/cloneDeep'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import type {AccessControlPolicy} from '@mattermost/types/access_control'; import type {Channel, ChannelModeration as ChannelPermissions, ChannelModerationPatch} from '@mattermost/types/channels'; import {SyncableType} from '@mattermost/types/groups'; import type {SyncablePatch, Group} from '@mattermost/types/groups'; @@ -25,6 +26,7 @@ import AdminHeader from 'components/widgets/admin_console/admin_header'; import {getHistory} from 'utils/browser_history'; import Constants from 'utils/constants'; +import {ChannelAccessControl} from './channel_access_control_policy'; import {ChannelGroups} from './channel_groups'; import ChannelMembers from './channel_members'; import ChannelModeration from './channel_moderation'; @@ -50,6 +52,7 @@ export interface ChannelDetailsProps { guestAccountsEnabled: boolean; channelModerationEnabled: boolean; channelGroupsEnabled: boolean; + abacSupported: boolean; isDisabled?: boolean; actions: ChannelDetailsActions; } @@ -81,6 +84,9 @@ interface ChannelDetailsState { teamScheme?: Scheme; isLocalArchived: boolean; showArchiveConfirmModal: boolean; + policyToggled: boolean; + accessControlPolicies: AccessControlPolicy[]; + abacSupported: boolean; } export type ChannelDetailsActions = { @@ -102,6 +108,10 @@ export type ChannelDetailsActions = { updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise; deleteChannel: (channelId: string) => Promise; unarchiveChannel: (channelId: string) => Promise; + getAccessControlPolicy: (channelId: string) => Promise; + searchPolicies: (term: string, type: string, after: string, limit: number) => Promise; + assignChannelToAccessControlPolicy: (policyId: string, channelId: string) => Promise; + deleteAccessControlPolicy: (policyId: string) => Promise; }; export default class ChannelDetails extends React.PureComponent { @@ -129,6 +139,9 @@ export default class ChannelDetails extends React.PureComponent this.restrictChannelMentions()); } actions.getChannel(channelID); + + if (channel?.policy_enforced) { + this.fetchAccessControlPolicies(channelID); + this.setState({policyToggled: true}); + } } if (channel?.team_id) { @@ -204,7 +223,7 @@ export default class ChannelDetails extends React.PureComponent { + private setToggles = (isSynced: boolean, isPublic: boolean, policyEnforced: boolean) => { const {channel} = this.props; const isOriginallyPublic = channel?.type === Constants.OPEN_CHANNEL; this.setState( @@ -213,6 +232,7 @@ export default class ChannelDetails extends React.PureComponent this.processGroupsChange(this.state.groups), ); @@ -387,7 +407,7 @@ export default class ChannelDetails extends React.PureComponent} + /> + ); + saveNeeded = true; + this.setState({serverError, saving: false, saveNeeded}); + actions.setNavigationBlocked(saveNeeded); + return; + } + + if (isSynced) { + serverError = ( + } + /> + ); + saveNeeded = true; + this.setState({serverError, saving: false, saveNeeded}); + actions.setNavigationBlocked(saveNeeded); + return; + } + } + + if (accessControlPolicies.length > 0) { + await actions.assignChannelToAccessControlPolicy(accessControlPolicies[0].id, channelID).catch((error) => { + this.setState({ + serverError: , + saving: false, + saveNeeded: true, + }); + actions.setNavigationBlocked(true); + }); + } else { + await actions.deleteAccessControlPolicy(channelID).catch((error) => { + this.setState({ + serverError: , + saving: false, + saveNeeded: true, + }); + }); + } + let privacyChanging = isPrivacyChanging; if (serverError == null) { privacyChanging = false; @@ -691,6 +764,61 @@ export default class ChannelDetails extends React.PureComponent { + this.setState({ + accessControlPolicies: [policy], + saveNeeded: true, + }); + this.props.actions.setNavigationBlocked(true); + }; + + private onPolicyRemoved = () => { + this.setState({ + accessControlPolicies: [], + saveNeeded: true, + }); + this.props.actions.setNavigationBlocked(true); + }; + + private fetchAccessControlPolicies = (channelId: string) => { + if (!channelId) { + return; + } + + // Always try to fetch policies, even if policy_enforced is false + // This ensures we have policies when toggling the flag + this.props.actions.getAccessControlPolicy(channelId).then((result) => { + if (result.data) { + const currentAccessControlPolicy = result.data; + const policies: AccessControlPolicy[] = []; + const promises: Array> = []; + + if (currentAccessControlPolicy.imports && currentAccessControlPolicy.imports.length > 0) { + for (const policyId of currentAccessControlPolicy.imports) { + const promise = this.props.actions.getAccessControlPolicy(policyId).then((policyResult) => { + if (policyResult.data) { + policies.push(policyResult.data as AccessControlPolicy); + } + }); + + promises.push(promise); + } + + Promise.all(promises).then(() => { + this.setState({ + accessControlPolicies: policies, + }); + }); + } else { + // If there are no imports, still update state with empty array + this.setState({ + accessControlPolicies: [], + }); + } + } + }); + }; + public render = () => { const { totalGroups, @@ -711,6 +839,8 @@ export default class ChannelDetails extends React.PureComponent - {this.props.channelGroupsEnabled && + {this.props.abacSupported && policyToggled && ( + + )} + + {this.props.channelGroupsEnabled && !policyToggled && { isDefault={false} isDisabled={false} groupsSupported={true} + policyEnforced={false} + policyEnforcedToggleAvailable={false} />, ); expect(wrapper).toMatchSnapshot(); @@ -30,6 +32,8 @@ describe('admin_console/team_channel_settings/channel/ChannelModes', () => { isDefault={false} isDisabled={false} groupsSupported={false} + policyEnforced={false} + policyEnforcedToggleAvailable={false} />, ); expect(wrapper).toMatchSnapshot(); diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_modes.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_modes.tsx index 4fc64368fa..df511ebe13 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_modes.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_modes.tsx @@ -13,24 +13,27 @@ interface Props { isPublic: boolean; isSynced: boolean; isDefault: boolean; - onToggle: (isSynced: boolean, isPublic: boolean) => void; + onToggle: (isSynced: boolean, isPublic: boolean, policyEnforced: boolean) => void; isDisabled?: boolean; groupsSupported?: boolean; + abacSupported?: boolean; + policyEnforced: boolean; + policyEnforcedToggleAvailable: boolean; } const SyncGroupsToggle: React.SFC = (props: Props): JSX.Element => { - const {isPublic, isSynced, isDefault, onToggle, isDisabled} = props; + const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced} = props; return ( { if (isDefault) { return; } - onToggle(!isSynced, isPublic); + onToggle(!isSynced, isPublic, policyEnforced); }} title={( = (props: Props): JSX.Element => { }; const AllowAllToggle: React.SFC = (props: Props): JSX.Element | null => { - const {isPublic, isSynced, isDefault, onToggle, isDisabled} = props; + const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced} = props; if (isSynced) { return null; } return ( { if (isDefault) { return; } - onToggle(isSynced, !isPublic); + onToggle(isSynced, !isPublic, policyEnforced); }} title={( = (props: Props): JSX.Element | null => { ); }; +const PolicyEnforceToggle: React.SFC = (props: Props): JSX.Element | null => { + const {isPublic, isSynced, isDefault, onToggle, isDisabled, policyEnforced, policyEnforcedToggleAvailable} = props; + if (isSynced) { + return null; + } + return ( + { + if (isDefault || !policyEnforcedToggleAvailable) { + return; + } + onToggle(isSynced, isPublic, !policyEnforced); + }} + title={( + + )} + subTitle={isDefault || isPublic ? ( + + ) : ( + + ) + } + /> + ); +}; + export const ChannelModes: React.SFC = (props: Props): JSX.Element => { - const {isPublic, isSynced, isDefault, onToggle, isDisabled, groupsSupported} = props; + const {isPublic, isSynced, isDefault, onToggle, isDisabled, groupsSupported, policyEnforced, policyEnforcedToggleAvailable, abacSupported} = props; return ( = (props: Props): JSX.Element => { >
- {groupsSupported && + {!policyEnforced && groupsSupported && } = (props: Props): JSX.Element => { isDefault={isDefault} onToggle={onToggle} isDisabled={isDisabled} + policyEnforced={policyEnforced} + policyEnforcedToggleAvailable={policyEnforcedToggleAvailable} /> + {abacSupported && + + }
); }; + diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/index.ts b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/index.ts index 2d89a0ba91..8bc52009e0 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/index.ts +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/index.ts @@ -7,6 +7,7 @@ import type {Dispatch} from 'redux'; import type {GlobalState} from '@mattermost/types/store'; +import {getAccessControlPolicy, deleteAccessControlPolicy, assignChannelsToAccessControlPolicy, searchAccessControlPolicies} from 'mattermost-redux/actions/access_control'; import { addChannelMember, deleteChannel, @@ -36,7 +37,7 @@ import {getTeam} from 'mattermost-redux/selectors/entities/teams'; import {setNavigationBlocked} from 'actions/admin_actions'; -import {isMinimumEnterpriseLicense, isMinimumProfessionalLicense} from 'utils/license_utils'; +import {isMinimumEnterpriseAdvancedLicense, isMinimumEnterpriseLicense, isMinimumProfessionalLicense} from 'utils/license_utils'; import ChannelDetails from './channel_details'; @@ -60,6 +61,8 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { // Channel Groups is only available for Enterprise and above const channelGroupsEnabled = isLicensed && isMinimumEnterpriseLicense(license); + const abacSupported = isLicensed && isMinimumEnterpriseAdvancedLicense(license) && config.FeatureFlagAttributeBasedAccessControl === 'true'; + const guestAccountsEnabled = config.EnableGuestAccounts === 'true'; const channelID = ownProps.match.params.channel_id; const channel = getChannel(state, channelID); @@ -81,10 +84,14 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { guestAccountsEnabled, channelModerationEnabled, channelGroupsEnabled, + abacSupported, }; } function mapDispatchToProps(dispatch: Dispatch) { + const assignChannelToAccessControlPolicy = (policyId: string, channelId: string) => { + return assignChannelsToAccessControlPolicy(policyId, [channelId]); + }; return { actions: bindActionCreators({ getGroups: fetchAssociatedGroups, @@ -105,6 +112,10 @@ function mapDispatchToProps(dispatch: Dispatch) { updateChannelMemberSchemeRoles, deleteChannel, unarchiveChannel, + getAccessControlPolicy, + assignChannelToAccessControlPolicy, + deleteAccessControlPolicy, + searchPolicies: searchAccessControlPolicies, }, dispatch), }; } diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap index 6b7fef3dc1..ac0e935385 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap @@ -86,9 +86,17 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn "keys": Array [ "group_constrained", "exclude_group_constrained", + "access_control_policy_enforced", ], "name": "Management", "values": Object { + "access_control_policy_enforced": Object { + "name": , + "value": false, + }, "exclude_group_constrained": Object { "name": - + + + , "name": , + "value": false, + }, "exclude_group_constrained": Object { "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": - + + + , "name": , + "value": false, + }, "exclude_group_constrained": Object { "name": - + + + , "name": Promise; - getData: (page: number, perPage: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, includeDeleted?: boolean) => Promise; + getData: (page: number, perPage: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, includeDeleted?: boolean, accessControlPolicyEnforced?: boolean, excludeAccessControlPolicyEnforced?: boolean) => Promise; }; data: ChannelWithTeamData[]; total: number; @@ -58,6 +58,10 @@ const messages = defineMessages({ id: 'admin.channel_settings.channel_row.managementMethod.manual', defaultMessage: 'Manual Invites', }, + attribute_based: { + id: 'admin.channel_settings.channel_row.managementMethod.attribute_based', + defaultMessage: 'Attribute Based', + }, }); export default class ChannelList extends React.PureComponent { @@ -102,7 +106,7 @@ export default class ChannelList extends React.PureComponent - + + {(() => { + if (channel.policy_enforced) { + return ; + } else if (channel.group_constrained) { + return ; + } + return ; + })()} + ), edit: ( @@ -256,10 +269,10 @@ export default class ChannelList extends React.PureComponent { const filters: ChannelSearchOpts = {}; - const {group_constrained: groupConstrained, exclude_group_constrained: excludeGroupConstrained} = filterOptions.management.values; + const {group_constrained: groupConstrained, exclude_group_constrained: excludeGroupConstrained, access_control_policy_enforced: accessControlPolicyEnforced} = filterOptions.management.values; const {public: publicChannels, private: privateChannels, deleted} = filterOptions.channels.values; const {team_ids: teamIds} = filterOptions.teams.values; - if (publicChannels.value || privateChannels.value || deleted.value || groupConstrained.value || excludeGroupConstrained.value || (teamIds.value as string[]).length) { + if (publicChannels.value || privateChannels.value || deleted.value || groupConstrained.value || excludeGroupConstrained.value || (teamIds.value as string[]).length || accessControlPolicyEnforced.value) { filters.public = publicChannels.value as boolean; if (filters.public) { trackEvent('admin_channels_page', 'public_filter_applied_to_channel_list'); @@ -275,6 +288,11 @@ export default class ChannelList extends React.PureComponent + ), + value: false, + }, }, - keys: ['group_constrained', 'exclude_group_constrained'], + keys: ['group_constrained', 'exclude_group_constrained', 'access_control_policy_enforced'], }, channels: { name: 'Channels', diff --git a/webapp/channels/src/components/admin_console/types.ts b/webapp/channels/src/components/admin_console/types.ts index ad3730a5fe..ae06ff7e43 100644 --- a/webapp/channels/src/components/admin_console/types.ts +++ b/webapp/channels/src/components/admin_console/types.ts @@ -22,6 +22,7 @@ type Component = any type AdminDefinitionConfigSchemaComponent = { id: string; component: Component; + isBeta?: boolean; } export type ConsoleAccess = {read: {[key: string]: boolean}; write: {[key: string]: boolean}} @@ -164,6 +165,7 @@ AdminDefinitionSettingRadio | AdminDefinitionSettingRole; type AdminDefinitionConfigSchemaSettings = { id: string; name: string | MessageDescriptor; + isBeta?: boolean; isHidden?: Check; onConfigLoad?: (config: Partial) => {[x: string]: string}; onConfigSave?: (displayVal: any) => any; @@ -181,6 +183,7 @@ export type AdminDefinitionConfigSchemaSection = { header?: string | MessageDescriptor; footer?: string | MessageDescriptor; component?: Component; + isHidden?: Check; } type RestrictedIndicatorType = { diff --git a/webapp/channels/src/components/card/title_and_button_card_header/title_and_button_card_header.tsx b/webapp/channels/src/components/card/title_and_button_card_header/title_and_button_card_header.tsx index 11c2123c15..6c63cb182a 100644 --- a/webapp/channels/src/components/card/title_and_button_card_header/title_and_button_card_header.tsx +++ b/webapp/channels/src/components/card/title_and_button_card_header/title_and_button_card_header.tsx @@ -3,16 +3,39 @@ import React from 'react'; +import WithTooltip from 'components/with_tooltip'; + type Props = { title: React.ReactNode; subtitle?: React.ReactNode; buttonText?: React.ReactNode; isDisabled?: boolean; onClick?: () => void; + tooltipText?: string; }; // This component can be used in the card header const TitleAndButtonCardHeader: React.FC = (props: Props) => { + let button = ( + + ); + + if (props.isDisabled && props.tooltipText) { + button = ( + + {button} + + ); + } + return ( <>
@@ -27,16 +50,8 @@ const TitleAndButtonCardHeader: React.FC = (props: Props) => { }
{ - props.buttonText && props.onClick && - + props.buttonText && props.onClick && button } - ); }; diff --git a/webapp/channels/src/components/channel_selector_modal/channel_selector_modal.tsx b/webapp/channels/src/components/channel_selector_modal/channel_selector_modal.tsx index e58356839a..e2e2e6244f 100644 --- a/webapp/channels/src/components/channel_selector_modal/channel_selector_modal.tsx +++ b/webapp/channels/src/components/channel_selector_modal/channel_selector_modal.tsx @@ -24,13 +24,15 @@ type Props = { intl: IntlShape; groupID: string; actions: { - loadChannels: (page?: number, perPage?: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, excludePolicyConstrained?: boolean) => Promise>; + loadChannels: (page?: number, perPage?: number, notAssociatedToGroup?: string, excludeDefaultChannels?: boolean, excludePolicyConstrained?: boolean, excludeAccessControlPolicyEnforced?: boolean) => Promise>; setModalSearchTerm: (term: string) => void; searchAllChannels: (term: string, opts?: ChannelSearchOpts) => Promise>; }; alreadySelected?: string[]; excludePolicyConstrained?: boolean; + excludeAccessControlPolicyEnforced?: boolean; excludeTeamIds?: string[]; + excludeTypes?: string[]; } type State = { @@ -56,7 +58,7 @@ export class ChannelSelectorModal extends React.PureComponent { }; componentDidMount() { - this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => { + this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => { this.setState({channels: response.data!.sort(compareChannels)}); this.setChannelsLoadingState(false); }); @@ -68,7 +70,7 @@ export class ChannelSelectorModal extends React.PureComponent { const searchTerm = this.props.searchTerm; if (searchTerm === '') { - this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => { + this.props.actions.loadChannels(0, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => { this.setState({channels: response.data!.sort(compareChannels)}); this.setChannelsLoadingState(false); }); @@ -130,7 +132,7 @@ export class ChannelSelectorModal extends React.PureComponent { handlePageChange = (page: number, prevPage: number) => { if (page > prevPage) { this.setChannelsLoadingState(true); - this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained).then((response) => { + this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => { const newState = [...this.state.channels]; const stateChannelIDs = this.state.channels.map((stateChannel) => stateChannel.id); response.data!.forEach((serverChannel) => { @@ -221,6 +223,9 @@ export class ChannelSelectorModal extends React.PureComponent { if (this.props.excludeTeamIds) { options = options.filter((channel) => this.props.excludeTeamIds?.indexOf(channel.team_id) === -1); } + if (this.props.excludeTypes) { + options = options.filter((channel) => this.props.excludeTypes?.indexOf(channel.type) === -1); + } const values = this.state.values.map((i): ChannelWithTeamDataValue => ({...i, label: i.display_name, value: i.id})); return ( diff --git a/webapp/channels/src/components/searchable_user_list/searchable_user_list_container.tsx b/webapp/channels/src/components/searchable_user_list/searchable_user_list_container.tsx index f3faae5bd4..0068870185 100644 --- a/webapp/channels/src/components/searchable_user_list/searchable_user_list_container.tsx +++ b/webapp/channels/src/components/searchable_user_list/searchable_user_list_container.tsx @@ -15,6 +15,7 @@ type Props = { total: number; extraInfo?: {[key: string]: Array}; nextPage: (page: number) => void; + previousPage?: (page: number) => void; search: (term: string) => void; actions?: React.ReactNode[]; actionProps?: { @@ -53,6 +54,7 @@ export default function SearchableUserListContainer(props: Props) { const previousPage = () => { setPage(page - 1); + props.previousPage?.(page - 1); }; const search = (term: string) => { diff --git a/webapp/channels/src/components/widgets/tag/beta_tag.tsx b/webapp/channels/src/components/widgets/tag/beta_tag.tsx index 291a03a7a0..f61f95cf5a 100644 --- a/webapp/channels/src/components/widgets/tag/beta_tag.tsx +++ b/webapp/channels/src/components/widgets/tag/beta_tag.tsx @@ -6,20 +6,21 @@ import React from 'react'; import {useIntl} from 'react-intl'; import Tag from './tag'; -import type {TagSize} from './tag'; +import type {TagSize, TagVariant} from './tag'; type Props = { className?: string; size?: TagSize; + variant?: TagVariant; } -const BetaTag = ({className = '', size = 'xs'}: Props) => { +const BetaTag = ({className = '', size = 'xs', variant = 'info'}: Props) => { const {formatMessage} = useIntl(); return ( CEL Documentation.", + "admin.access_control.cel_help_modal.important_notes_title": "Important Notes", + "admin.access_control.cel_help_modal.subheader": "With CEL you can define conditions to filter user attributes and control resource access.", + "admin.access_control.cel_help_modal.title": "Common Expression Language (CEL)", + "admin.access_control.cel.line_and_column_number": "L{lineNumber}:{columnNumber}", + "admin.access_control.cel.validateSyntax": "Validate syntax", + "admin.access_control.cel.validating": "Validating...", + "admin.access_control.delete": "Delete", + "admin.access_control.edit": "Edit", + "admin.access_control.edit_policy.apply_policy": "Apply policy", + "admin.access_control.edit_policy.cancel": "Cancel", + "admin.access_control.edit_policy.save": "Save", + "admin.access_control.edit_policy.save_policy": "Save policy", + "admin.access_control.edit_policy.serverError": "There are errors in the form above: {serverError}", + "admin.access_control.jobTable.details.subheader": "Finished at {finishedAt}", + "admin.access_control.policies.add_policy": "Add policy", + "admin.access_control.policies.applies_to": "Applies to", + "admin.access_control.policies.description": "Create policies containing attribute based access rules and the resources they apply to.", + "admin.access_control.policies.menu.aria_label": "Policy actions menu", + "admin.access_control.policies.name": "Name", + "admin.access_control.policies.resources.channels": "{count, number} {count, plural, one {channel} other {channels}}", + "admin.access_control.policies.resources.none": "None", + "admin.access_control.policies.title": "Access Control Policies", + "admin.access_control.policy.channels_affected": "Are you sure you want to save and apply the access control policy?", + "admin.access_control.policy.edit_policy.autoSyncMembership": "Auto-add members based on access rules:", + "admin.access_control.policy.edit_policy.autoSyncMembership.description": "Users who match the attribute values configured below will be automatically added as new members. Regardless of this setting, users who later no longer match the configured attribute values will be removed from the channel after the next sync.", + "admin.access_control.policy.edit_policy.channel_selector.addChannels": "Add channels", + "admin.access_control.policy.edit_policy.channel_selector.remove": "Remove", + "admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this attribute based access policy will apply to.", + "admin.access_control.policy.edit_policy.channel_selector.title": "Assigned channels", + "admin.access_control.policy.edit_policy.channel_selector.to_be_removed": "To be removed", + "admin.access_control.policy.edit_policy.delete_confirmation.confirm_button": "Delete Policy", + "admin.access_control.policy.edit_policy.delete_confirmation.message": "Are you sure you want to delete this policy? This action cannot be undone.", + "admin.access_control.policy.edit_policy.delete_confirmation.title": "Confirm Policy Deletion", + "admin.access_control.policy.edit_policy.delete_policy.delete": "Delete", + "admin.access_control.policy.edit_policy.delete_policy.subtitle": "This policy will be deleted and cannot be recovered.", + "admin.access_control.policy.edit_policy.delete_policy.subtitle.has_resources": "Remove all assigned resources (eg. Channels) to be able to delete this policy", + "admin.access_control.policy.edit_policy.delete_policy.title": "Delete policy", + "admin.access_control.policy.edit_policy.policyName": "Access control policy name:", + "admin.access_control.policy.edit_policy.policyName.placeholder": "Add a unique policy name", + "admin.access_control.policy.edit_policy.switch_to_advanced": "Switch to Advanced Mode", + "admin.access_control.policy.edit_policy.switch_to_simple": "Switch to Simple Mode", + "admin.access_control.policy.edit_policy.title": "Edit Access Control Policy", + "admin.access_control.policy.enforce_immediately": "Enforce policy immediately", + "admin.access_control.policy.save_only": "Are you sure you want to save this access control policy?", + "admin.access_control.policy.save_policy_confirmation_body": "Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.", + "admin.access_control.policy.save_policy_confirmation_body.inactive": "Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.", + "admin.access_control.policy.save_policy_confirmation_subheader": "{count} channels will be affected.", + "admin.access_control.policy.save_policy_confirmation_title": "Save access control policy ", + "admin.access_control.table_editor.add_attribute": "Add attribute", + "admin.access_control.table_editor.attribute": "Attribute", + "admin.access_control.table_editor.blank_state": "Select a user attribute and values to create a rule", + "admin.access_control.table_editor.learnMore": "Learn more about creating access expressions with examples.", + "admin.access_control.table_editor.operator": "Operator", + "admin.access_control.table_editor.operator.contains": "contains", + "admin.access_control.table_editor.operator.ends_with": "ends with", + "admin.access_control.table_editor.operator.in": "in", + "admin.access_control.table_editor.operator.is": "is", + "admin.access_control.table_editor.operator.is_not": "is not", + "admin.access_control.table_editor.operator.starts_with": "starts with", + "admin.access_control.table_editor.remove_row": "Remove row", + "admin.access_control.table_editor.selector.filter_attributes": "Search attributes...", + "admin.access_control.table_editor.selector.filter_operators": "Search operators...", + "admin.access_control.table_editor.selector.select_attribute": "Select attribute", + "admin.access_control.table_editor.test_access_rule": "Test access rule", + "admin.access_control.table_editor.value.placeholder": "Add value...", + "admin.access_control.table_editor.values": "Values", + "admin.access_control.table_editor.values.placeholder": "Add values...", + "admin.access_control.testResults": "Access Rule Test Results", + "admin.accesscontrol.enableDesc": "Allow access restrictions based on user attributes using custom access policies", + "admin.accesscontrol.enableTitle": "Allow attribute based access controls on this servers", + "admin.accesscontrol.title": "Attribute-Based Access", "admin.advance.cluster": "High Availability", "admin.advance.metrics": "Performance Monitoring", "admin.announcement_banner_feature_discovery.copy": "Create announcement banners to notify all members of important information.", "admin.announcement_banner_feature_discovery.title": "Create custom announcement banners with Mattermost Professional", + "admin.attribute_based_access_control_feature_discovery.desc": "Create policies containing access rules based on user attributes and apply them to channels and other resources within Mattermost.", + "admin.attribute_based_access_control_feature_discovery.title": "Use attribute based access policies to control channel access with Mattermost Enterprise Advanced", "admin.audit_logging_experimental.certificate.help_text": "The certificate file used for audit logging encryption.", "admin.audit_logging_experimental.certificate.remove_button": "Remove Certificate", "admin.audit_logging_experimental.certificate.remove_help_text": "Remove the certificate used for audit logging encryption.", @@ -456,31 +530,48 @@ "admin.cacheSettings.redisPassword": "Redis Password", "admin.cacheSettings.redisPasswordDesc": "The password of the Redis server.", "admin.cacheSettings.title": "Cache Settings", + "admin.channel_details.policy_public_error": "You cannot assign a policy to a public channel.", + "admin.channel_details.policy_synced_error": "You cannot assign a policy to a synced channel.", "admin.channel_list.archived": "Archived", + "admin.channel_list.attributed_based": "Attribute Based", "admin.channel_list.group_sync": "Group Sync", "admin.channel_list.manual_invites": "Manual Invites", "admin.channel_list.private": "Private", "admin.channel_list.public": "Public", + "admin.channel_settings.channel_detail.access_control_policy_actions": "Actions", + "admin.channel_settings.channel_detail.access_control_policy_description": "Select an access policy for this channel to restrict membership.", + "admin.channel_settings.channel_detail.access_control_policy_name": "Name", + "admin.channel_settings.channel_detail.access_control_policy_title": "Access policy", "admin.channel_settings.channel_detail.archive_confirm.button": "Save and Archive Channel", "admin.channel_settings.channel_detail.archive_confirm.message": "Saving will archive the channel from the team and make it's contents inaccessible for all users. Are you sure you wish to save and archive this channel?", "admin.channel_settings.channel_detail.archive_confirm.title": "Save and Archive Channel", "admin.channel_settings.channel_detail.channel_configuration": "Channel Configuration", "admin.channel_settings.channel_detail.channelOrganizationsMessage": "Shared with trusted organizations", + "admin.channel_settings.channel_detail.go_to_policy.aria_label": "Go to the policy", "admin.channel_settings.channel_detail.groupsDescription": "Select groups to be added to this channel.", "admin.channel_settings.channel_detail.groupsTitle": "Groups", + "admin.channel_settings.channel_detail.link_policy": "Link to a policy", "admin.channel_settings.channel_detail.manageDescription": "Choose between inviting members manually or syncing members automatically from groups.", "admin.channel_settings.channel_detail.manageTitle": "Channel Management", "admin.channel_settings.channel_detail.membersDescription": "A list of users who are currently in the channel right now", "admin.channel_settings.channel_detail.membersTitle": "Members", + "admin.channel_settings.channel_detail.policy_following": "This channel is currently using the following access policy.", "admin.channel_settings.channel_detail.profileDescription": "Summary of the channel, including the channel name.", "admin.channel_settings.channel_detail.profileTitle": "Channel Profile", + "admin.channel_settings.channel_detail.remove_policy": "Remove policy", + "admin.channel_settings.channel_detail.select_policy": "Select an access policy for this channel to restrict membership", + "admin.channel_settings.channel_detail.select_policy_description": "An access control policy will restrict channel membership based on user attributes.", + "admin.channel_settings.channel_detail.select_policy_title": "Select an Access Control Policy", "admin.channel_settings.channel_detail.syncedGroupsDescription": "Add and remove channel members based on their group membership.", "admin.channel_settings.channel_detail.syncedGroupsTitle": "Synced Groups", "admin.channel_settings.channel_details.add_group": "Add Group", "admin.channel_settings.channel_details.archiveChannel": "Archive Channel", + "admin.channel_settings.channel_details.attribute_based_description": "Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.", "admin.channel_settings.channel_details.isDefaultDescr": "This default channel cannot be converted into a private channel.", "admin.channel_settings.channel_details.isPublic": "Public channel or private channel", "admin.channel_settings.channel_details.isPublicDescr": "Select Public for a channel any user can find and join. {br}Select Private to require channel invitations to join. {br}Use this switch to change this channel from public to private or from private to public.", + "admin.channel_settings.channel_details.policy_enforced_title": "Enable attribute based channel access", + "admin.channel_settings.channel_details.private_channel_only": "Only private channels can be attribute based.", "admin.channel_settings.channel_details.syncGroupMembers": "Sync Group Members", "admin.channel_settings.channel_details.syncGroupMembersDescr": "When enabled, adding and removing users from groups will add or remove them from this channel. The only way of inviting members to this channel is by adding the groups they belong to. Learn More", "admin.channel_settings.channel_details.unarchiveChannel": "Unarchive Channel", @@ -528,6 +619,7 @@ "admin.channel_settings.channel_moderation.subtitleMembers": "Manage the actions available to channel members.", "admin.channel_settings.channel_moderation.title": "Advanced Access Control", "admin.channel_settings.channel_row.configure": "Edit", + "admin.channel_settings.channel_row.managementMethod.attribute_based": "Attribute Based", "admin.channel_settings.channel_row.managementMethod.group": "Group Sync", "admin.channel_settings.channel_row.managementMethod.manual": "Manual Invites", "admin.channel_settings.description": "Manage channel settings.", @@ -1274,6 +1366,7 @@ "admin.ip_filtering.your_current_ip_is": "Your current IP address is {ip}", "admin.ip_filtering.your_current_ip_is_not_in_allowed_rules": "Your IP address {ip} is not included in your allowed IP address rules.", "admin.jobTable.cancelButton": "Cancel", + "admin.jobTable.details.title": "Job Details", "admin.jobTable.downloadLink": "Download", "admin.jobTable.headerExtraInfo": "Details", "admin.jobTable.headerFiles": "Files", @@ -1291,6 +1384,11 @@ "admin.jobTable.statusPending": "Pending", "admin.jobTable.statusSuccess": "Success", "admin.jobTable.statusWarning": "Warning", + "admin.jobTable.syncResults.added": "Added ({count, number})", + "admin.jobTable.syncResults.error": "An error occurred while syncing the channels.", + "admin.jobTable.syncResults.noResultsSearchable": "No channels match your search or filter.", + "admin.jobTable.syncResults.removed": "Removed ({count, number})", + "admin.jobTable.syncResults.userListTitle": "Channel Membership Changes", "admin.ldap_feature_discovery_cloud.call_to_action.primary_sales": "Contact sales", "admin.ldap_feature_discovery.call_to_action.primary": "Start trial", "admin.ldap_feature_discovery.call_to_action.secondary": "Learn more", @@ -2466,6 +2564,7 @@ "admin.set_by_env": "This setting has been set through an environment variable. It cannot be changed through the System Console.", "admin.sidebar.about": "About", "admin.sidebar.announcement": "System-wide Notifications", + "admin.sidebar.attributeBasedAccessControl": "Attribute-Based Access", "admin.sidebar.audit_logging_experimental": "Audit Logging", "admin.sidebar.authentication": "Authentication", "admin.sidebar.billing": "Billing & Account", @@ -2931,6 +3030,8 @@ "admin.user_item.userMFARemoveFailed": "Failed to remove user's MFA", "admin.user_item.userNotFound": "Cannot load User", "admin.user_item.userUpdateFailed": "Failed to update user", + "admin.user_settings.policy_list.no_policies_found": "No policies found", + "admin.user_settings.policy_list.search_policy_errored": "Something went wrong. Try again", "admin.userManagement.userDetail.addTeam": "Add Team", "admin.userManagement.userDetail.authenticationMethod": "Authentication Method", "admin.userManagement.userDetail.email": "Email", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts index 974e39778c..1e57917836 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/admin.ts @@ -60,4 +60,13 @@ export default keyMirror({ REMOVE_DATA_RETENTION_CUSTOM_POLICY_TEAMS_FAILURE: null, REMOVE_DATA_RETENTION_CUSTOM_POLICY_CHANNELS_SUCCESS: null, REMOVE_DATA_RETENTION_CUSTOM_POLICY_CHANNELS_FAILURE: null, + + RECEIVED_ACCESS_CONTROL_POLICIES: null, + RECEIVED_ACCESS_CONTROL_POLICY: null, + CREATE_ACCESS_CONTROL_POLICY_SUCCESS: null, + DELETE_ACCESS_CONTROL_POLICY_SUCCESS: null, + RECEIVED_ACCESS_CONTROL_CHILD_POLICIES: null, + RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH: null, + ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS: null, + UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts new file mode 100644 index 0000000000..a0c26d17aa --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {batchActions} from 'redux-batched-actions'; + +import type {AccessControlPoliciesResult, AccessControlPolicy, AccessControlTestResult} from '@mattermost/types/access_control'; +import type {ChannelSearchOpts, ChannelsWithTotalCount} from '@mattermost/types/channels'; +import type {ServerError} from '@mattermost/types/errors'; + +import {AdminTypes, ChannelTypes, UserTypes} from 'mattermost-redux/action_types'; +import {Client4} from 'mattermost-redux/client'; +import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; + +import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; + +export function getAccessControlPolicy(id: string) { + return bindClientFunc({ + clientFunc: Client4.getAccessControlPolicy, + onSuccess: [AdminTypes.RECEIVED_ACCESS_CONTROL_POLICY], + params: [ + id, + ], + }); +} + +export function createAccessControlPolicy(policy: AccessControlPolicy): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.updateOrCreateAccessControlPolicy(policy); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + return {error}; + } + + dispatch( + {type: AdminTypes.CREATE_ACCESS_CONTROL_POLICY_SUCCESS, data}, + ); + + return {data}; + }; +} + +export function deleteAccessControlPolicy(id: string) { + return bindClientFunc({ + clientFunc: Client4.deleteAccessControlPolicy, + onSuccess: [AdminTypes.DELETE_ACCESS_CONTROL_POLICY_SUCCESS], + params: [ + id, + ], + }); +} + +export function searchAccessControlPolicies(term: string, type: string, after: string, limit: number): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.searchAccessControlPolicies(term, type, after, limit); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + return {error}; + } + + dispatch( + {type: AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH, data: data.policies}, + ); + + return {data}; + }; +} + +export function searchAccessControlPolicyChannels(id: string, term: string, opts: ChannelSearchOpts): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.searchChildAccessControlPolicyChannels(id, term, opts); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + return {error}; + } + + const childs: Record = {}; + childs[id] = data.channels.map((channel) => channel.id); + + dispatch(batchActions([ + {type: AdminTypes.RECEIVED_ACCESS_CONTROL_CHILD_POLICIES, data: childs}, + {type: ChannelTypes.RECEIVED_CHANNELS, data: data.channels}, + ])); + + return {data}; + }; +} + +export function assignChannelsToAccessControlPolicy(policyId: string, channelIds: string[]) { + return bindClientFunc({ + clientFunc: Client4.assignChannelsToAccessControlPolicy, + onSuccess: [AdminTypes.ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS], + params: [ + policyId, + channelIds, + ], + }); +} + +export function unassignChannelsFromAccessControlPolicy(policyId: string, channelIds: string[]) { + return bindClientFunc({ + clientFunc: Client4.unassignChannelsFromAccessControlPolicy, + onSuccess: [AdminTypes.UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS], + params: [ + policyId, + channelIds, + ], + }); +} + +export function getAccessControlFields(after: string, limit: number) { + return bindClientFunc({ + clientFunc: Client4.getAccessControlFields, + params: [ + after, + limit, + ], + }); +} + +export function updateAccessControlPolicyActive(policyId: string, active: boolean) { + return bindClientFunc({ + clientFunc: Client4.updateAccessControlPolicyActive, + params: [ + policyId, + active, + ], + }); +} + +export function searchUsersForExpression(expression: string, term: string, after: string, limit: number): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.testAccessControlExpression(expression, term, after, limit); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + return {error}; + } + + dispatch( + {type: UserTypes.RECEIVED_PROFILES, data: data.users}, + ); + + return {data}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts index 1c3e18f0a4..143cd90817 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts @@ -1063,11 +1063,8 @@ describe('Actions.Channels', () => { const mockQuery = { page: 0, per_page: 50, - not_associated_to_group: '', - exclude_default_channels: false, include_total_count: true, include_deleted: false, - exclude_policy_constrained: false, }; nock(Client4.getBaseRoute()). get('/channels'). diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index 597564ecd1..0c6e389eda 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -789,13 +789,13 @@ export function getArchivedChannels(teamId: string, page = 0, perPage: number = }; } -export function getAllChannelsWithCount(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, includeDeleted = false, excludePolicyConstrained = false): ActionFuncAsync { +export function getAllChannelsWithCount(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, includeDeleted = false, excludePolicyConstrained = false, accessControlPolicyEnforced = false, excludeAccessControlPolicyEnforced = false): ActionFuncAsync { return async (dispatch, getState) => { dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null}); let payload; try { - payload = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, true, includeDeleted, excludePolicyConstrained); + payload = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, true, includeDeleted, excludePolicyConstrained, accessControlPolicyEnforced, excludeAccessControlPolicyEnforced); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch({type: ChannelTypes.GET_ALL_CHANNELS_FAILURE, error}); @@ -821,13 +821,13 @@ export function getAllChannelsWithCount(page = 0, perPage: number = General.CHAN }; } -export function getAllChannels(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, excludePolicyConstrained = false): ActionFuncAsync { +export function getAllChannels(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, excludePolicyConstrained = false, excludeAccessControlPolicyEnforced = false, accessControlPolicyEnforced = false): ActionFuncAsync { return async (dispatch, getState) => { dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null}); let channels; try { - channels = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, false, false, excludePolicyConstrained); + channels = await Client4.getAllChannels(page, perPage, notAssociatedToGroup, excludeDefaultChannels, false, false, excludePolicyConstrained, accessControlPolicyEnforced, excludeAccessControlPolicyEnforced); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch({type: ChannelTypes.GET_ALL_CHANNELS_FAILURE, error}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts index e43ccd666a..f31d14e8b5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/admin.ts @@ -3,6 +3,7 @@ import {combineReducers} from 'redux'; +import type {AccessControlPolicy} from '@mattermost/types/access_control'; import type {ClusterInfo, AnalyticsRow, AnalyticsState, AdminState} from '@mattermost/types/admin'; import type {Audit} from '@mattermost/types/audits'; import type {Compliance} from '@mattermost/types/compliance'; @@ -649,6 +650,64 @@ function dataRetentionCustomPoliciesCount(state = 0, action: MMReduxAction) { } } +function accessControlPolicies(state: IDMappedObjects = {}, action: MMReduxAction) { + switch (action.type) { + case AdminTypes.CREATE_ACCESS_CONTROL_POLICY_SUCCESS: + case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICY: + return { + ...state, + [action.data.id]: action.data, + }; + case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES: { + const nextState: IDMappedObjects = {}; + for (const policy of action.data) { + nextState[policy.id] = policy; + } + return nextState; + } + case AdminTypes.DELETE_ACCESS_CONTROL_POLICY_SUCCESS: { + const nextState = {...state}; + Reflect.deleteProperty(nextState, action.data.id); + return nextState; + } + case AdminTypes.RECEIVED_ACCESS_CONTROL_POLICIES_SEARCH: { + const nextState = {...state}; + for (const policy of action.data) { + nextState[policy.id] = policy; + } + return nextState; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function channelsForAccessControlPolicy(state: Record = {}, action: MMReduxAction) { + switch (action.type) { + case AdminTypes.RECEIVED_ACCESS_CONTROL_CHILD_POLICIES: + if (action.data) { + return {...state, ...action.data}; + } + return state; + case AdminTypes.ASSIGN_CHANNELS_TO_ACCESS_CONTROL_POLICY_SUCCESS: + return { + ...state, + [action.data.policyId]: action.data.channelIds, + }; + case AdminTypes.UNASSIGN_CHANNELS_FROM_ACCESS_CONTROL_POLICY_SUCCESS: + return { + ...state, + [action.data.policyId]: action.data.channelIds, + }; + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + export default combineReducers({ // array of LogObjects each representing a log entry (JSON) @@ -711,4 +770,10 @@ export default combineReducers({ // the last trial license the server used. prevTrialLicense, + + // object with policy ids as keys and objects representing the policies as values + accessControlPolicies, + + // object with policy ids as keys and arrays of channel ids as values + channelsForAccessControlPolicy, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/access_control.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/access_control.ts new file mode 100644 index 0000000000..a56c194b21 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/access_control.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Channel, ChannelWithTeamData, ChannelSearchOpts} from '@mattermost/types/channels'; +import type {GlobalState} from '@mattermost/types/store'; + +import {filterChannelsMatchingTerm} from 'mattermost-redux/utils/channel_utils'; + +import {filterChannelList} from './channels'; + +import {createSelector} from '../create_selector'; + +export function getAccessControlPolicy(state: GlobalState, id: string) { + return state.entities.admin.accessControlPolicies[id]; +} + +export const getChannelIdsForAccessControlPolicy = createSelector( + 'getChannelIdsForAccessControlPolicy', + (state: GlobalState, parentId: string) => state.entities.admin.channelsForAccessControlPolicy[parentId], + (channelIds) => (Array.isArray(channelIds) ? channelIds : []), +) as (state: GlobalState, parentId: string) => string[]; + +export function makeGetChannelsInAccessControlPolicy() { + return (createSelector( + 'getChannelsInAccessControlPolicy', + (state: GlobalState) => state.entities.channels.channels, + (state: GlobalState, props: {policyId: string}) => getChannelIdsForAccessControlPolicy(state, props.policyId), + (state: GlobalState) => state.entities.teams.teams, + (channels, ids, teams) => { + if (!ids) { + return []; + } + + const policyChannels: ChannelWithTeamData[] = []; + + ids.forEach((channelId) => { + const channel = channels[channelId]; + if (channel) { + const team = teams[channel.team_id] || {}; + policyChannels.push({ + ...channel, + team_id: channel.team_id, + team_display_name: team.display_name || '', + team_name: team.name || '', + team_update_at: team.update_at || 0, + }); + } + }); + + return policyChannels; + }) as (b: GlobalState, a: { + policyId: string; + }) => ChannelWithTeamData[]); +} + +export function searchChannelsInheritsPolicy(state: GlobalState, policyId: string, term: string, filters: ChannelSearchOpts): Channel[] { + const channelsInPolicy = makeGetChannelsInAccessControlPolicy(); + const channelArray = channelsInPolicy(state, {policyId}); + let channels = filterChannelList(channelArray, filters); + channels = filterChannelsMatchingTerm(channels, term); + + return channels; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index dccc2edcfd..b2e2e68587 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -119,6 +119,8 @@ const state: GlobalState = { dataRetentionCustomPolicies: {}, dataRetentionCustomPoliciesCount: 0, prevTrialLicense: {}, + accessControlPolicies: {}, + channelsForAccessControlPolicy: {}, }, jobs: { jobs: {}, diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 2840bbf5ba..0fc2234733 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -344,6 +344,7 @@ export const ModalIdentifiers = { CHANNEL_MEMBERS: 'channel_members', CHANNEL_SETTINGS: 'channel_settings', TEAM_MEMBERS: 'team_members', + TEST_RESULTS: 'test_results', ADD_USER_TO_CHANNEL: 'add_user_to_channel', ADD_USER_TO_ROLE: 'add_user_to_role', ADD_USER_TO_TEAM: 'add_user_to_team', @@ -933,6 +934,7 @@ export const JobTypes = { BLEVE_POST_INDEXING: 'bleve_post_indexing', LDAP_SYNC: 'ldap_sync', MESSAGE_EXPORT: 'message_export', + ACCESS_CONTROL_SYNC: 'access_control_sync', } as const; export const JobStatuses = { diff --git a/webapp/channels/webpack.config.js b/webapp/channels/webpack.config.js index a4bf515fae..bbe032e9d6 100644 --- a/webapp/channels/webpack.config.js +++ b/webapp/channels/webpack.config.js @@ -13,6 +13,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const webpack = require('webpack'); const {ModuleFederationPlugin} = require('webpack').container; const WebpackPwaManifest = require('webpack-pwa-manifest'); +const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); const packageJson = require('./package.json'); @@ -267,6 +268,40 @@ var config = { sizes: '96x96', }], }), + new MonacoWebpackPlugin({ + languages: [], + + // don't include features we disable. these generally correspond to the options + // passed to editor initialization in note-content-editor.tsx + // @see https://github.com/microsoft/monaco-editor/blob/main/webpack-plugin/README.md#options + features: [ + '!bracketMatching', + '!codeAction', + '!codelens', + '!colorPicker', + '!comment', + '!diffEditor', + '!diffEditorBreadcrumbs', + '!folding', + '!gotoError', + '!gotoLine', + '!gotoSymbol', + '!gotoZoom', + '!inspectTokens', + '!multicursor', + '!parameterHints', + '!quickCommand', + '!quickHelp', + '!quickOutline', + '!referenceSearch', + '!rename', + '!snippet', + '!stickyScroll', + '!suggest', + '!toggleHighContrast', + '!unicodeHighlighter', + ], + }), ], }; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index f1f336eb64..958f2d7083 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -108,6 +108,8 @@ "marked": "github:mattermost/marked#3b13ba8ddf725327ddf0298361d6d304a021f2d1", "memoize-one": "6.0.0", "moment-timezone": "0.5.38", + "monaco-editor": "0.52.2", + "monaco-editor-webpack-plugin": "7.1.0", "p-queue": "7.3.0", "pdfjs-dist": "4.4.168", "process": "0.11.10", @@ -5090,7 +5092,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -6735,8 +6736,7 @@ "node_modules/@types/estree": { "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "node_modules/@types/express": { "version": "4.17.21", @@ -6942,8 +6942,7 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/json-stable-stringify": { "version": "1.1.0", @@ -7005,7 +7004,6 @@ "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -8037,7 +8035,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", - "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -8046,26 +8043,22 @@ "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", - "dev": true + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", - "dev": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -8075,14 +8068,12 @@ "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -8094,7 +8085,6 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", - "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -8103,7 +8093,6 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", - "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -8111,14 +8100,12 @@ "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -8134,7 +8121,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -8147,7 +8133,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -8159,7 +8144,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -8173,7 +8157,6 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", - "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -8240,14 +8223,12 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", @@ -8306,7 +8287,6 @@ "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, "peerDependencies": { "acorn": "^8" } @@ -8436,7 +8416,6 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -9233,7 +9212,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, "engines": { "node": "*" } @@ -9881,7 +9859,6 @@ "version": "4.24.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10038,8 +10015,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/buffer-xor": { "version": "1.0.3", @@ -10187,7 +10163,6 @@ "version": "1.0.30001674", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz", "integrity": "sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10355,7 +10330,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, "engines": { "node": ">=6.0" } @@ -12126,8 +12100,7 @@ "node_modules/electron-to-chromium": { "version": "1.5.49", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.49.tgz", - "integrity": "sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==", - "dev": true + "integrity": "sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==" }, "node_modules/elliptic": { "version": "6.6.0", @@ -12187,7 +12160,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, "engines": { "node": ">= 4" } @@ -12509,8 +12481,7 @@ "node_modules/es-module-lexer": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", - "dev": true + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==" }, "node_modules/es-object-atoms": { "version": "1.0.0", @@ -12564,7 +12535,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -13322,7 +13292,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -14495,8 +14464,7 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, "node_modules/global": { "version": "4.4.0", @@ -14642,8 +14610,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -20234,7 +20201,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -20447,7 +20413,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, "engines": { "node": ">=6.11.5" } @@ -20772,8 +20737,7 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" }, "node_modules/merge2": { "version": "1.4.1", @@ -21016,6 +20980,36 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==" + }, + "node_modules/monaco-editor-webpack-plugin": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz", + "integrity": "sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==", + "dependencies": { + "loader-utils": "^2.0.2" + }, + "peerDependencies": { + "monaco-editor": ">= 0.31.0", + "webpack": "^4.5.0 || 5.x" + } + }, + "node_modules/monaco-editor-webpack-plugin/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -21135,8 +21129,7 @@ "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nice-try": { "version": "1.0.5", @@ -21258,8 +21251,7 @@ "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/nopt": { "version": "5.0.0", @@ -24637,7 +24629,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -26364,7 +26355,6 @@ "version": "5.36.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -26382,7 +26372,6 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", - "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -26416,7 +26405,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -26425,7 +26413,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -26439,7 +26426,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -26457,7 +26443,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -26471,14 +26456,12 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/terser/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -26487,7 +26470,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -27058,8 +27040,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -27147,7 +27128,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -27338,7 +27318,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -27374,7 +27353,6 @@ "version": "5.95.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", - "dev": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -27655,7 +27633,6 @@ "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -27668,7 +27645,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -27681,7 +27657,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "engines": { "node": ">=4.0" } @@ -27690,7 +27665,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -27708,7 +27682,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -27717,7 +27690,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, "engines": { "node": ">=10.13.0" } diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 6aec978094..ee14c49171 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3,6 +3,7 @@ /* eslint-disable max-lines */ +import type {AccessControlPolicy, CELExpressionError, AccessControlTestResult, AccessControlPoliciesResult, AccessControlPolicyChannelsResult, AccessControlVisualAST} from '@mattermost/types/access_control'; import type {ClusterInfo, AnalyticsRow, SchemaMigration, LogFilterQuery} from '@mattermost/types/admin'; import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/types/apps'; import type {Audit} from '@mattermost/types/audits'; @@ -108,7 +109,7 @@ import type { import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts'; import type {PreferenceType} from '@mattermost/types/preferences'; import type {ProductNotices} from '@mattermost/types/product_notices'; -import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties'; +import type {PropertyField, UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties'; import type {Reaction} from '@mattermost/types/reactions'; import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters'; import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports'; @@ -1547,7 +1548,9 @@ export default class Client4 { excludeDefaultChannels: boolean | undefined, includeTotalCount: false | undefined, includeDeleted: boolean | undefined, - excludePolicyConstrained: boolean | undefined + excludePolicyConstrained: boolean | undefined, + accessControlPolicyEnforced: boolean | undefined, + excludeAccessControlPolicyEnforced: boolean | undefined ): Promise; getAllChannels( page: number | undefined, @@ -1556,7 +1559,9 @@ export default class Client4 { excludeDefaultChannels: boolean | undefined, includeTotalCount: true, includeDeleted: boolean | undefined, - excludePolicyConstrained: boolean | undefined + excludePolicyConstrained: boolean | undefined, + accessControlPolicyEnforced: boolean | undefined, + excludeAccessControlPolicyEnforced: boolean | undefined ): Promise; getAllChannels( page = 0, @@ -1566,16 +1571,36 @@ export default class Client4 { includeTotalCount = false, includeDeleted = false, excludePolicyConstrained = false, + accessControlPolicyEnforced = false, + excludeAccessControlPolicyEnforced = false, ) { - const queryData = { + const queryData: Record = { page, per_page: perPage, - not_associated_to_group: notAssociatedToGroup, - exclude_default_channels: excludeDefaultChannels, include_total_count: includeTotalCount, include_deleted: includeDeleted, - exclude_policy_constrained: excludePolicyConstrained, }; + + if (notAssociatedToGroup) { + queryData.not_associated_to_group = notAssociatedToGroup; + } + + if (excludeDefaultChannels) { + queryData.exclude_default_channels = excludeDefaultChannels; + } + + if (excludePolicyConstrained) { + queryData.exclude_policy_constrained = excludePolicyConstrained; + } + + if (accessControlPolicyEnforced) { + queryData.access_control_policy_enforced = accessControlPolicyEnforced; + } + + if (excludeAccessControlPolicyEnforced) { + queryData.exclude_access_control_policy_enforced = excludeAccessControlPolicyEnforced; + } + return this.doFetch( `${this.getChannelsRoute()}${buildQueryString(queryData)}`, {method: 'get'}, @@ -4382,6 +4407,111 @@ export default class Client4 { {method: 'post', headers: {'Connection-Id': connectionId}}, ); }; + + getAccessControlPolicy = (id: string) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${id}`, + {method: 'get'}, + ); + }; + + updateOrCreateAccessControlPolicy = (policy: AccessControlPolicy) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies`, + {method: 'put', body: JSON.stringify(policy)}, + ); + }; + + deleteAccessControlPolicy = (id: string) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${id}`, + {method: 'delete'}, + ); + }; + + getAccessControlPolicies = (after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/search`, + {method: 'post', body: JSON.stringify({type: 'parent', cursor: {id: after}, limit})}, + ); + }; + + getChildPolicies = (parentId: string, after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/search`, + {method: 'post', body: JSON.stringify({parent_id: parentId, cursor: {id: after}, limit})}, + ); + }; + + getChannelsForAccessControlPolicy = (policyId: string, after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${policyId}/resources/channels?after=${after}&limit=${limit}`, + {method: 'get'}, + ); + }; + + searchAccessControlPolicies = (term: string, type: string, after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/search`, + {method: 'post', body: JSON.stringify({term, type, cursor: {id: after}, limit, include_children: true})}, + ); + }; + + searchChildAccessControlPolicyChannels = (policyId: string, term: string, opts: ChannelSearchOpts) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${policyId}/resources/channels/search?term=${term}`, + {method: 'post', body: JSON.stringify({term, ...opts})}, + ); + }; + + updateAccessControlPolicyActive = (policyId: string, active: boolean) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${policyId}/activate?active=${active}`, + {method: 'get'}, + ); + }; + + assignChannelsToAccessControlPolicy = (policyId: string, channelIds: string[]) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${policyId}/assign`, + {method: 'post', body: JSON.stringify({channel_ids: channelIds})}, + ); + }; + + unassignChannelsFromAccessControlPolicy = (policyId: string, channelIds: string[]) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/${policyId}/unassign`, + {method: 'delete', body: JSON.stringify({channel_ids: channelIds})}, + ); + }; + + getAccessControlFields = (after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?after=${after}&limit=${limit}`, + {method: 'get'}, + ); + }; + + checkAccessControlExpression = (expression: string) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/cel/check`, + {method: 'post', body: JSON.stringify({expression})}, + ); + }; + + testAccessControlExpression = (expression: string, term: string, after: string, limit: number) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/cel/test`, + {method: 'post', body: JSON.stringify({expression, term, after, limit})}, + ); + }; + + expressionToVisualFormat = (expression: string) => { + return this.doFetch( + `${this.getBaseRoute()}/access_control_policies/cel/visual_ast`, + {method: 'post', body: JSON.stringify({expression})}, + ); + }; } export function parseAndMergeNestedHeaders(originalHeaders: any) { diff --git a/webapp/platform/types/src/access_control.ts b/webapp/platform/types/src/access_control.ts new file mode 100644 index 0000000000..33b01496c5 --- /dev/null +++ b/webapp/platform/types/src/access_control.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChannelWithTeamData} from './channels'; +import type {UserProfile} from './users'; +export type AccessControlPolicy = { + id: string; + name: string; + type: string; + revision?: number; + created_at?: number; + version?: string; + active?: boolean; + imports?: string[]; + props?: Record; + rules: AccessControlPolicyRule[]; +} + +export type AccessControlPolicyCursor = { + id: string; +} + +export type AccessControlPoliciesResult = { + policies: AccessControlPolicy[]; + total: number; +} + +export type AccessControlPolicySearchOpts = { + term: string; + type: string; + cursor: AccessControlPolicyCursor; + limit: number; +} + +export type AccessControlPolicyChannelsResult = { + channels: ChannelWithTeamData[]; + total: number; +} + +export type AccessControlPolicyRule = { + actions?: string[]; + expression: string; +} + +export type CELExpressionError = { + message: string; + line: number; + column: number; +} + +export type AccessControlTestResult = { + users: UserProfile[]; + total: number; +} + +export type AccessControlEntity = { + name: string; + attributes: AccessControlAttribute[]; +} + +export type AccessControlAttribute = { + name: string; + values: string[]; +} + +export type AccessControlVisualAST = { + conditions: AccessControlVisualASTNode[]; +} + +export type AccessControlVisualASTNode = { + attribute: string; + operator: string; + value: any; + value_type: number; +} diff --git a/webapp/platform/types/src/admin.ts b/webapp/platform/types/src/admin.ts index 9fdacbac9e..fd3ddbeaf1 100644 --- a/webapp/platform/types/src/admin.ts +++ b/webapp/platform/types/src/admin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {AccessControlPolicy} from './access_control'; import type {Audit} from './audits'; import type {Compliance} from './compliance'; import type {AdminConfig, ClientLicense, EnvironmentConfig} from './config'; @@ -10,7 +11,7 @@ import type {PluginRedux, PluginStatusRedux} from './plugins'; import type {SamlCertificateStatus, SamlMetadataResponse} from './saml'; import type {Team} from './teams'; import type {UserAccessToken, UserProfile} from './users'; -import type {RelationOneToOne} from './utilities'; +import type {RelationOneToOne, IDMappedObjects} from './utilities'; export enum LogLevelEnum { SILLY = 'silly', @@ -69,6 +70,8 @@ export type AdminState = { dataRetentionCustomPolicies: DataRetentionCustomPolicies; dataRetentionCustomPoliciesCount: number; prevTrialLicense: ClientLicense; + accessControlPolicies: IDMappedObjects; + channelsForAccessControlPolicy: Record; }; export type AnalyticsState = { diff --git a/webapp/platform/types/src/channels.ts b/webapp/platform/types/src/channels.ts index beeda9a0e2..23635374f3 100644 --- a/webapp/platform/types/src/channels.ts +++ b/webapp/platform/types/src/channels.ts @@ -68,6 +68,7 @@ export type Channel = { props?: Record; policy_id?: string | null; banner_info?: ChannelBanner; + policy_enforced?: boolean; }; export type ServerChannel = Channel & { @@ -230,4 +231,7 @@ export type ChannelSearchOpts = { deleted?: boolean; page?: number; per_page?: number; + access_control_policy_enforced?: boolean; + exclude_access_control_policy_enforced?: boolean; + parent_access_control_policy_id?: string; }; diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index ad43fd867c..34dc704be1 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -125,6 +125,7 @@ export type ClientConfig = { FeatureFlagAppsEnabled: string; FeatureFlagCallsEnabled: string; FeatureFlagCustomProfileAttributes: string; + FeatureFlagAttributeBasedAccessControl: string; FeatureFlagWebSocketEventScope: string; ForgotPasswordLink: string; GiphySdkKey: string; diff --git a/webapp/platform/types/src/jobs.ts b/webapp/platform/types/src/jobs.ts index 5c232d369d..c1b635a8b5 100644 --- a/webapp/platform/types/src/jobs.ts +++ b/webapp/platform/types/src/jobs.ts @@ -3,7 +3,7 @@ import type {IDMappedObjects} from './utilities'; -export type JobType = 'data_retention' | 'elasticsearch_post_indexing' | 'bleve_post_indexing' | 'ldap_sync' | 'message_export'; +export type JobType = 'data_retention' | 'elasticsearch_post_indexing' | 'bleve_post_indexing' | 'ldap_sync' | 'message_export' | 'access_control_sync'; export type JobStatus = 'pending' | 'in_progress' | 'success' | 'error' | 'cancel_requested' | 'canceled' | 'warning'; export type Job = JobTypeBase & { id: string;