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`] = ` +
+
+
+
{'Synchronize access control policies with system resources and permissions.'}
+(modalData: ModalData
) => void;
+ };
+}
+
+function TestResultsModal({
+ onExited,
+ actions,
+}: Props): JSX.Element {
+ const dispatch = useDispatch
+
+
+
+
+ this.props.onRowClick!(job) : undefined}
+ className={this.props.onRowClick ? 'clickable' : ''}
>
-
);
});
+ 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 = (
+
-
- {showFilesColumn &&
-
- }
- {this.getExtraInfoText(job)}
+ {showFilesColumn &&
+
+ }
+ {!hideDetailsColumn && (
+ {this.getExtraInfoText(job)}
+ )}
+
+
-
{items}
+ {perPage && this.props.jobs.length > 0 && (
+ renderFooter()
+ )}
- {showFilesColumn &&
-
-
- }
-
+ {showFilesColumn &&
+
+ }
+ {!hideDetailsColumn && (
+
+
+ )}
+
+
+
+
+
+
+
+ {accessControlPolicies.map((policy) => (
+
+
+
+
+
+
+
+ ))}
+
+ {policy.name}
+
+
+
+
+
+