[MM-61756] Attribute Based Access Control - Phase 1 (#30785)

Attribute Based Access Control - Base
* MM-63662

* MM-63919

* MM-63954

* MM-63955 

* MM-63425

* MM-63426

* MM-63458

* MM-63459

* MM-63603

* MM-63845

* MM-64146

* MM-64199

* MM-64201

* MM-64233

* MM-64247

* MM-64268

---------

Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Pablo Andrés Vélez Vidal <pablovv2012@gmail.com>
Co-authored-by: abhijit-singh <abhijitsingh0702@gmail.com>
Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
Этот коммит содержится в:
Ibrahim Serdar Acikgoz
2025-05-15 11:33:08 +02:00
коммит произвёл GitHub
родитель 4b445cbf16
Коммит a344b3225b
156 изменённых файлов: 14382 добавлений и 621 удалений

Просмотреть файл

@@ -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

524
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"

Просмотреть файл

@@ -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'

Просмотреть файл

@@ -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');
});
});

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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';
});
},

Просмотреть файл

@@ -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

515
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))
}
}

Просмотреть файл

@@ -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)
}

613
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)
})
}

Просмотреть файл

@@ -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))

Просмотреть файл

@@ -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))
}
}

293
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
}

455
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)
})
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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)
}
}

Просмотреть файл

@@ -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)),

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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

Просмотреть файл

@@ -0,0 +1 @@
DROP VIEW IF EXISTS AttributeView;

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -0,0 +1 @@
DROP MATERIALIZED VIEW IF EXISTS AttributeView;

Просмотреть файл

@@ -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();

Просмотреть файл

@@ -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}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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},

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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")
}
})
}

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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,
)
}

Просмотреть файл

@@ -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}

Просмотреть файл

@@ -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 {

12
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
}

13
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
}

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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 {

42
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
}

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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"
)

Просмотреть файл

@@ -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) {

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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=

Просмотреть файл

@@ -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."

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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,
}
}

Просмотреть файл

@@ -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"`
}

30
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"`
}

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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"`
}

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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"

Просмотреть файл

@@ -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",

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;
exports[`components/admin_console/access_control/PolicyList should match snapshot with policies 1`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;
exports[`components/admin_console/access_control/PolicyList should match snapshot with search error 1`] = `
<div
className="PolicyTable"
>
<div
className="policy-header"
>
<div
className="policy-header-text"
>
<h1>
<MemoizedFormattedMessage
defaultMessage="Access Control Policies"
id="admin.access_control.policies.title"
/>
</h1>
<p>
<MemoizedFormattedMessage
defaultMessage="Create policies containing attribute based access rules and the resources they apply to."
id="admin.access_control.policies.description"
/>
</p>
</div>
<button
className="btn btn-primary"
onClick={[Function]}
>
<i
className="icon icon-plus"
/>
<span>
<MemoizedFormattedMessage
defaultMessage="Add policy"
id="admin.access_control.policies.add_policy"
/>
</span>
</button>
</div>
<DataGrid
columns={
Array [
Object {
"field": "name",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Name"
id="admin.access_control.policies.name"
/>,
"width": 5,
},
Object {
"field": "resources",
"name": <Memo(MemoizedFormattedMessage)
defaultMessage="Applies to"
id="admin.access_control.policies.applies_to"
/>,
"textAlign": "center",
"width": 4,
},
Object {
"className": "actions-column",
"field": "actions",
"name": <span />,
"width": 1,
},
]
}
endCount={0}
loading={false}
nextPage={[Function]}
onSearch={[Function]}
page={0}
placeholderEmpty={
<Memo(MemoizedFormattedMessage)
defaultMessage="No policies found"
id="admin.user_settings.policy_list.no_policies_found"
/>
}
previousPage={[Function]}
rows={Array []}
rowsContainerStyles={
Object {
"minHeight": "0px",
}
}
startCount={1}
term=""
total={0}
/>
</div>
`;

Просмотреть файл

@@ -0,0 +1,257 @@
.cel-editor {
margin-bottom: 24px;
&__container {
position: relative;
display: flex;
overflow: auto;
height: auto;
flex-direction: column;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
margin-bottom: 8px;
background: var(--center-channel-bg);
overflow-y: auto;
.policy-editor-placeholder {
position: absolute;
z-index: 10;
top: 13px;
left: 30px;
color: rgba(0,0,0, 0.40);
font-family: monospace;
font-size: 12px;
pointer-events: none;
user-select: none;
white-space: pre-wrap;
}
}
&__cursor-position {
position: absolute;
right: 8px;
border-radius: 4px;
color: var(--button-color);
font-family: monospace;
font-size: 12px;
pointer-events: none;
}
&__input {
overflow: auto;
width: 100%;
height: auto;
min-height: 120px;
max-height: 600px;
//overflow-y: auto;
flex-grow: 0;
padding: 12px;
border: 1px solid var(--button-bg);
border-radius: 4px 4px 0 0;
font-family: monospace;
resize: vertical;
transition: border-color 0.15s ease;
&:focus {
outline: none;
}
&::placeholder {
color: rgba(var(--center-channel-color-rgb), 0.56);
}
.policyEditor {
outline: none;
}
.cel-editor__container[data-status-color='var(--error-text)'] & {
border-color: var(--error-text);
}
.cel-editor__container[data-status-color='var(--online-indicator)'] & {
border-color: var(--online-indicator);
}
.cel-editor__container[data-status-color='var(--button-bg)'] & {
border-color: var(--button-bg);
}
}
&__footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-top: 8px;
.help-text-container {
flex: 1;
padding-right: 16px;
color: var(--center-channel-color-72);
font-size: 12px;
}
}
&__actions {
display: flex;
align-items: center;
}
&__loading {
display: flex;
align-items: center;
i {
margin-right: 8px;
}
}
&__status-bar {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
padding: 0 8px;
border-radius: 0 0 4px 4px;
color: var(--button-color);
font-family: monospace;
font-size: 12px;
transition: background-color 0.15s ease;
&:not([data-validation-state="validated"]):not([data-validation-state="error"]):not([data-validation-state="validating"]) {
cursor: pointer;
&:hover {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--button-bg);
}
&:active {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--button-bg);
}
}
}
&__error-message {
display: flex;
align-items: center;
.icon {
margin-right: 4px;
font-size: 14px;
}
}
&__error {
display: flex;
align-items: center;
color: var(--button-color);
}
&__status-message {
display: flex;
align-items: center;
.icon {
margin-right: 4px;
font-size: 14px;
&.icon-refresh {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
}
&__inline-validate-btn {
padding: 0;
border: none;
background: transparent;
color: var(--button-color);
cursor: pointer;
font-size: 12px;
font-weight: 600;
&:hover {
text-decoration: underline;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.cel-editor__loading {
display: flex;
align-items: center;
i {
margin-right: 4px;
font-size: 12px;
}
}
}
&__validate-btn {
display: none;
}
}
.cel-test-results-modal {
.modal-body {
max-height: 70vh;
overflow-y: auto;
}
.cel-test-attributes {
padding: 10px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
margin-bottom: 20px;
.cel-attribute-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
margin: 0 4px 4px 0;
background: rgba(var(--center-channel-color-rgb), 0.08);
font-size: 12px;
}
}
.cel-subjects-list {
.cel-subject-item {
padding: 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 4px;
margin-bottom: 12px;
.cel-subject-header {
margin-bottom: 8px;
font-weight: 600;
}
.cel-subject-attributes {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
.cel-subject-attribute {
.cel-attribute-key {
margin-right: 4px;
font-weight: 500;
}
.cel-attribute-value {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
}
}
}
}
}
.Card__body.expanded:has(.cel-editor) {
height: max-content !important;
}

Просмотреть файл

@@ -0,0 +1,363 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as monaco from 'monaco-editor';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import type {AccessControlTestResult} from '@mattermost/types/access_control';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {Client4} from 'mattermost-redux/client';
import {MonacoLanguageProvider} from './language_provider';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {TestButton, HelpText} from '../shared';
import './editor.scss';
export const POLICY_LANGUAGE = 'expressionLanguage';
const VALIDATE_POLICY_SYNTAX_COMMAND_ID = 'policyEditorValidateSyntaxCommand';
const MONACO_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
extraEditorClassName: 'policyEditor',
language: POLICY_LANGUAGE,
automaticLayout: true,
minimap: {enabled: false},
lineNumbers: 'off',
scrollBeyondLastLine: false,
wordWrap: 'on',
renderLineHighlight: 'none',
lineNumbersMinChars: 1,
occurrencesHighlight: 'off',
stickyScroll: {enabled: false},
autoClosingBrackets: 'never',
autoClosingQuotes: 'never',
autoIndent: 'keep',
autoSurround: 'never',
codeLens: false,
folding: false,
fontFamily: 'monospace',
hideCursorInOverviewRuler: true,
fontSize: 12,
guides: {indentation: false},
links: true,
matchBrackets: 'never',
multiCursorLimit: 1,
overviewRulerBorder: false,
quickSuggestions: false,
renderControlCharacters: false,
scrollbar: {
horizontal: 'hidden',
useShadows: false,
},
selectionHighlight: false,
showFoldingControls: 'never',
suggestOnTriggerCharacters: true,
unicodeHighlight: {
ambiguousCharacters: false,
invisibleCharacters: false,
},
unusualLineTerminators: 'auto',
wordWrapColumn: 400,
wrappingIndent: 'none',
wrappingStrategy: 'advanced',
contextmenu: false,
};
interface CELEditorProps {
value: string;
onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void;
placeholder?: string;
className?: string;
userAttributes: Array<{
attribute: string;
values: string[];
}>;
}
// TODO: this is just a sample schema for the editor, we need to get the actual schema from the server
function CELEditor({
value,
onChange,
onValidate,
placeholder = 'user.attributes.<attribute> == <value>',
className = '',
userAttributes,
}: CELEditorProps): JSX.Element {
const [editorState, setEditorState] = useState({
expression: value,
isValidating: false,
isValid: true,
cursorPosition: {line: 1, column: 1},
validationErrors: [] as string[],
statusBarColor: 'var(--button-bg)',
showTestResults: false,
testResults: null as AccessControlTestResult | null,
});
const schemas = {
user: ['attributes'],
'user.attributes': userAttributes.map((attr) => attr.attribute),
};
const editorRef = useRef(null);
const monacoRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [showHelpModal, setShowHelpModal] = useState(false);
useEffect(() => {
setEditorState((prev) => ({...prev, expression: value}));
}, [value]);
useEffect(() => {
if (monacoRef.current && monacoRef.current.getValue() !== editorState.expression) {
monacoRef.current.setValue(editorState.expression);
}
}, [editorState.expression]);
const handleChange = useCallback((newValue: string) => {
setEditorState((prev) => ({
...prev,
expression: newValue,
statusBarColor: 'var(--button-bg)',
validationErrors: [],
}));
onChange(newValue);
}, [onChange]);
const validateSyntax = useCallback(async () => {
setEditorState((prev) => ({...prev, isValidating: true}));
try {
const errors = await Client4.checkAccessControlExpression(editorState.expression);
const isValid = errors.length === 0;
setEditorState((prev) => ({
...prev,
isValid,
validationErrors: errors.map((error) => `${error.message} @L${error.line}:${error.column + 1}`),
statusBarColor: isValid ? 'var(--online-indicator)' : 'var(--error-text)',
isValidating: false,
}));
onValidate?.(isValid);
} catch (error) {
setEditorState((prev) => ({
...prev,
isValid: false,
validationErrors: [error.detailed_error || 'Unknown error'],
statusBarColor: 'var(--error-text)',
isValidating: false,
}));
onValidate?.(false);
}
}, [editorState.expression, onValidate]);
// initialize monaco editor
useEffect(() => {
if (!editorRef.current || monacoRef.current) {
return () => {};
}
monacoRef.current = monaco.editor.create(editorRef.current, MONACO_EDITOR_OPTIONS);
// Set the initial value from the expression state
monacoRef.current.setValue(editorState.expression);
monacoRef.current.getModel()?.onDidChangeContent(() => {
const newValue = monacoRef.current?.getValue() || '';
handleChange(newValue);
});
monacoRef.current.onDidChangeCursorPosition((e) => {
setEditorState((prev) => ({
...prev,
cursorPosition: {line: e.position.lineNumber, column: e.position.column},
}));
});
// To disable monaco's default behavior of opening the find and replace widget
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF,
command: null,
});
monaco.editor.addCommand({
id: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
run: validateSyntax,
});
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.Alt | monaco.KeyCode.Enter,
command: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
});
return () => {
if (monacoRef.current) {
monacoRef.current.dispose();
monacoRef.current = null;
}
};
}, []);
return (
<div className={`cel-editor ${className}`}>
<MonacoLanguageProvider schemas={schemas}/>
<div
className='cel-editor__container'
data-status-color={editorState.statusBarColor}
>
{!editorState.expression && (
<div
className='policy-editor-placeholder'
aria-label='CEL Expression Editor'
>
{placeholder}
</div>
)}
<div
ref={editorRef}
className='cel-editor__input'
/>
<div
className='cel-editor__status-bar'
style={{backgroundColor: editorState.statusBarColor}}
onClick={() => {
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}
}}
data-validation-state={
(() => {
if (editorState.isValidating) {
return 'validating';
}
if (editorState.validationErrors.length > 0) {
return 'error';
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return 'validated';
}
return 'unvalidated';
})()
}
>
<div className='cel-editor__status-message'>
{(() => {
if (editorState.validationErrors.length > 0) {
return (
<span className='cel-editor__error'>
<i
className='icon icon-refresh'
onClick={validateSyntax}
role='button'
aria-label='Retry validation'
/>
{editorState.validationErrors[0]}
</span>
);
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return (
<span className='cel-editor__valid'>
<i className='icon icon-check'/>
{'Valid'}
</span>
);
}
return (
<button
className='cel-editor__inline-validate-btn'
onClick={validateSyntax}
disabled={editorState.isValidating}
>
<span className='cel-editor__loading'>
{editorState.isValidating ? (
<>
<i className='fa fa-spinner fa-spin'/>
<FormattedMessage
id='admin.access_control.cel.validating'
defaultMessage='Validating...'
/>
</>
) : (
<>
<i className='icon icon-magnify'/>
<FormattedMessage
id='admin.access_control.cel.validateSyntax'
defaultMessage='Validate syntax'
/>
</>
)}
</span>
</button>
);
})()}
</div>
<div className='cel-editor__cursor-position'>
<FormattedMessage
id='admin.access_control.cel.line_and_column_number'
defaultMessage='L{lineNumber}:{columnNumber}'
values={{
lineNumber: editorState.cursorPosition.line,
columnNumber: editorState.cursorPosition.column,
}}
/>
</div>
</div>
</div>
<div className='cel-editor__footer'>
<div className='help-text-container'>
<div>
<HelpText
message={'Write rules like `user.<attribute> == <value>`. Use `&&` / `||` (and/or) for multiple conditions. Group conditions with `()`.'}
onLearnMoreClick={() => setShowHelpModal(true)}
/>
</div>
</div>
<TestButton
onClick={() => setEditorState((prev) => ({...prev, showTestResults: true}))}
disabled={!editorState.isValid || editorState.isValidating}
/>
</div>
{editorState.showTestResults && (
<TestResultsModal
onExited={() => setEditorState((prev) => ({...prev, showTestResults: false}))}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
return searchUsersForExpression(editorState.expression, term, after, limit);
},
}}
/>
)}
{showHelpModal && (
<CELHelpModal
onExited={() => setShowHelpModal(false)}
/>
)}
</div>
);
}
export default CELEditor;

Просмотреть файл

@@ -0,0 +1,275 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as monaco from 'monaco-editor';
import {useEffect} from 'react';
const POLICY_LANGUAGE_NAME = 'expressionLanguage';
// Enhanced schema interface to support different types of values
interface SchemaValue {
[key: string]: string[] | boolean | SchemaValue;
}
interface SchemaMap {
[schemaName: string]: string[] | SchemaValue | boolean;
}
interface MonacoLanguageProviderProps {
schemas: SchemaMap;
}
export function MonacoLanguageProvider({schemas}: MonacoLanguageProviderProps) {
useEffect(() => {
// Register our custom expression language
if (
!monaco.languages.
getLanguages().
some((lang) => lang.id === POLICY_LANGUAGE_NAME)
) {
monaco.languages.register({id: POLICY_LANGUAGE_NAME});
// Define language tokenizer
monaco.languages.setMonarchTokensProvider(POLICY_LANGUAGE_NAME, {
tokenizer: {
root: [
// Comments
[/\/\/.*$/, 'comment'],
// Object and property paths
[/[a-zA-Z][\w$]*(?=\.)/, 'variable'],
[/\./, 'delimiter'],
[/[a-zA-Z][\w$]*/, 'property'],
// Operators
[/&&|\|\||==|!=/, 'operator'],
// Whitespace
[/[ \t\r\n]+/, 'white'],
// Parentheses
[/[()]/, '@brackets'],
// String literals
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/"/, {token: 'string.quote', bracket: '@open', next: '@string'}],
[/'([^'\\]|\\.)*$/, 'string.invalid'],
[
/'/,
{token: 'string.quote', bracket: '@open', next: '@string2'},
],
// Numbers
[/\d+/, 'number'],
],
string: [
[/[^\\"]+/, 'string'],
[/"/, {token: 'string.quote', bracket: '@close', next: '@pop'}],
],
string2: [
[/[^'\\]+/, 'string'],
[/'/, {token: 'string.quote', bracket: '@close', next: '@pop'}],
],
},
});
}
// Get properties from a schema path
const getPropertiesFromPath = (path: string): string[] => {
const schemaItem = schemas[path];
if (!schemaItem) {
return [];
}
if (Array.isArray(schemaItem)) {
return schemaItem;
} else if (typeof schemaItem === 'object') {
return Object.keys(schemaItem);
}
return [];
};
// Get allowed values for a property or path
const getValuesForPath = (fullPath: string): string[] | null => {
// Check if the path exists directly in schemas
const directValue = schemas[fullPath];
if (Array.isArray(directValue)) {
return directValue;
}
// Otherwise, try to parse it as parent.property
const pathParts = fullPath.split('.');
if (pathParts.length >= 2) {
const property = pathParts.pop();
if (!property) {
return null;
}
const parentPath = pathParts.join('.');
const schemaItem = schemas[parentPath];
if (!schemaItem || Array.isArray(schemaItem) || typeof schemaItem === 'boolean') {
return null;
}
const propValue = schemaItem[property];
if (Array.isArray(propValue)) {
return propValue;
} else if (propValue === true) {
return null; // Property exists but no predefined values
}
}
return null;
};
// Create a completion item provider for our language
const disposable = monaco.languages.registerCompletionItemProvider(
'expressionLanguage',
{
triggerCharacters: ['.', ' ', '"', "'", '='],
provideCompletionItems: (model, position) => {
const lineNumber = position.lineNumber;
const column = position.column;
const lineContent = model.getLineContent(lineNumber);
const textBeforePosition = lineContent.substring(0, column - 1);
// Check if we're after an operator that expects a value
// Pattern: path followed by an operator that expects a value
const valueOperatorPattern =
/(\w+(?:\.\w+)*)\s+(==|!=|>|<|>=|<=)\s+["']?(\w*)$/;
const valueMatch = textBeforePosition.match(valueOperatorPattern);
if (valueMatch) {
const [, fullPath, , currentValue] = valueMatch;
// Get values for this full path
const allowedValues = getValuesForPath(fullPath);
if (allowedValues && allowedValues.length > 0) {
// Create range that includes the characters already typed
const wordStartColumn = column - currentValue.length;
return {
suggestions: allowedValues.
filter((val) =>
val.
toString().
toLowerCase().
startsWith(currentValue.toLowerCase()),
).
map((val) => ({
label: val.toString(),
kind: monaco.languages.CompletionItemKind.Value,
insertText: `"${val}"`,
range: {
startLineNumber: lineNumber,
startColumn: wordStartColumn,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
}
// Check if we should suggest operators
// Pattern: an entity (word possibly with dots) followed by space
const operatorPattern = /(\w+(?:\.\w+)*)\s+$/;
const operatorMatch = textBeforePosition.match(operatorPattern);
if (operatorMatch) {
// We have an entity followed by space - suggest operators
const operators = ['&&', '||', '==', '!=', 'in'];
return {
suggestions: operators.map((op) => ({
label: op,
kind: monaco.languages.CompletionItemKind.Operator,
insertText: op + ' ',
range: {
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
// Check for dot completion (property access)
const dotMatch = textBeforePosition.match(/(\w+)(?:\.(\w+))*\.$/);
if (dotMatch) {
const fullPath = dotMatch[0].slice(0, -1); // Remove trailing dot
// Get properties for this path
const properties = getPropertiesFromPath(fullPath);
if (properties.length > 0) {
return {
suggestions: properties.map((field) => ({
label: field,
kind: monaco.languages.CompletionItemKind.Field,
insertText: field,
range: {
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: lineNumber,
endColumn: column,
},
})),
};
}
}
// When not after a dot or space, suggest root objects
const wordMatch = textBeforePosition.match(
/(?:^|\s+|[&|=!<>()]|\()(\w*)$/,
);
if (wordMatch) {
const word = wordMatch[1] || '';
const wordStartColumn = column - word.length;
// Filter schemas that are root objects (don't contain dots)
const rootSchemas = Object.keys(schemas).filter(
(key) => !key.includes('.'),
);
const suggestions = rootSchemas.
filter((schema) =>
schema.toLowerCase().startsWith(word.toLowerCase()),
).
map((schema) => ({
label: schema,
kind: monaco.languages.CompletionItemKind.Class,
insertText: schema,
range: {
startLineNumber: lineNumber,
startColumn: wordStartColumn,
endLineNumber: lineNumber,
endColumn: column,
},
}));
return {suggestions};
}
return {suggestions: []};
},
},
);
// Cleanup function
return () => {
disposable.dispose();
};
}, [schemas]);
return null; // This component doesn't render anything
}

Просмотреть файл

@@ -0,0 +1,55 @@
.editor__test-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border: 1px solid var(--button-bg);
border-radius: 4px;
background: transparent;
color: var(--button-bg);
cursor: pointer;
font-weight: 600;
i {
margin-right: 8px;
}
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
&:disabled {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.32);
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: not-allowed;
opacity: 0.4;
}
}
.editor__add-row-button {
display: flex;
align-items: center;
padding: 10px 16px;
border: 1px solid var(--button-bg);
border-radius: 4px;
background-color: transparent;
color: var(--button-bg);
cursor: pointer;
font-weight: 600;
i {
margin-right: 8px;
font-size: 14px;
}
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
&:disabled {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.32);
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: not-allowed;
opacity: 0.4;
}
}

Просмотреть файл

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import './shared.scss';
import Markdown from 'components/markdown';
interface TestButtonProps {
onClick: () => void;
disabled: boolean;
}
interface AddAttributeButtonProps {
onClick: () => void;
disabled: boolean;
}
interface HelpTextProps {
message: string;
onLearnMoreClick?: () => void;
}
export function TestButton({onClick, disabled}: TestButtonProps): JSX.Element {
return (
<button
className='btn btn-sm btn-tertiary'
onClick={onClick}
disabled={disabled}
>
<i className='icon icon-lock-outline'/>
<FormattedMessage
id='admin.access_control.table_editor.test_access_rule'
defaultMessage='Test access rule'
/>
</button>
);
}
export function AddAttributeButton({onClick, disabled}: AddAttributeButtonProps): JSX.Element {
return (
<button
className='btn btn-sm btn-tertiary'
onClick={onClick}
disabled={disabled}
>
<i className='icon icon-plus'/>
<FormattedMessage
id='admin.access_control.table_editor.add_attribute'
defaultMessage='Add attribute'
/>
</button>
);
}
export function HelpText({message, onLearnMoreClick}: HelpTextProps): JSX.Element {
return (
<div className='editor__help-text'>
<Markdown
message={message}
options={{mentionHighlight: false}}
/>
{onLearnMoreClick && (
<a
href='#'
className='editor__learn-more'
onClick={onLearnMoreClick}
>
<FormattedMessage
id='admin.access_control.table_editor.learnMore'
defaultMessage='Learn more about creating access expressions with examples.'
/>
</a>
)}
</div>
);
}

Просмотреть файл

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {CheckIcon, MenuVariantIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import * as Menu from 'components/menu';
import './selector_menus.scss';
interface AttributeOption {
attribute: string;
values: string[];
}
interface AttributeSelectorProps {
currentAttribute: string;
availableAttributes: AttributeOption[];
disabled: boolean;
onChange: (attribute: string) => void;
}
const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled, onChange}: AttributeSelectorProps) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const options = useMemo(() => {
return availableAttributes.filter((attr) => {
return attr.attribute.toLowerCase().includes(filter.toLowerCase());
});
}, [availableAttributes, filter]);
const handleAttributeChange = (attribute: string) => {
onChange(attribute);
setFilter('');
};
// TODO: We can use different icons for different attributes types
const AttributeIcon = (props: IconProps) => <MenuVariantIcon {...props}/>;
return (
<Menu.Container
menuButton={{
id: 'attribute-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<>
<AttributeIcon/>
{currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})}
</>
),
dataTestId: 'attributeSelectorMenuButton',
disabled,
}}
menu={{
id: 'attribute-selector-menu',
'aria-label': 'Select attribute',
className: 'select-attribute-mui-menu',
}}
>
{[
<Menu.InputItem
key='filter_attributes'
id='filter_attributes'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>,
]}
{options.map((option) => {
const {attribute} = option;
return (
<Menu.Item
id={`attribute-${attribute}`}
key={attribute}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={attribute === currentAttribute}
onClick={() => handleAttributeChange(attribute)}
labels={<span>{attribute}</span>}
leadingElement={<AttributeIcon size={18}/>}
trailingElements={attribute === currentAttribute && (
<CheckIcon/>
)}
/>
);
})}
</Menu.Container>
);
};
export default AttributeSelectorMenu;

Просмотреть файл

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ComponentType} from 'react';
import React, {useMemo, useState} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {defineMessage, FormattedMessage, useIntl} from 'react-intl';
import {CheckIcon} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as Menu from 'components/menu';
import './selector_menus.scss';
const AlphaEIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
transform='rotate(90)'
{...rest}
>
<path d='M9,17A2,2 0 0,1 7,15V7H9V15H11V8H13V15H15V7H17V15A2,2 0 0,1 15,17H9Z'/>
</svg>
);
const EqualIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M19,10H5V8H19V10M19,16H5V14H19V16Z'/>
</svg>
);
const FunctionIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M15.6,5.29C14.5,5.19 13.53,6 13.43,7.11L13.18,10H16V12H13L12.56,17.07C12.37,19.27 10.43,20.9 8.23,20.7C6.92,20.59 5.82,19.86 5.17,18.83L6.67,17.33C6.91,18.07 7.57,18.64 8.4,18.71C9.5,18.81 10.47,18 10.57,16.89L11,12H8V10H11.17L11.44,6.93C11.63,4.73 13.57,3.1 15.77,3.3C17.08,3.41 18.18,4.14 18.83,5.17L17.33,6.67C17.09,5.93 16.43,5.36 15.6,5.29Z'/>
</svg>
);
const NotEqualIcon: React.FC<IconProps> = ({size, color, ...rest}: IconProps): JSX.Element => (
<svg
xmlns='http://www.w3.org/2000/svg'
version='1.1'
width={size || '1em'}
height={size || '1em'}
fill={color || 'currentColor'}
viewBox='0 0 24 24'
{...rest}
>
<path d='M21,10H9V8H21V10M21,16H9V14H21V16M4,5H6V16H4V5M6,18V20H4V18H6Z'/>
</svg>
);
interface OperatorSelectorProps {
currentOperator: string;
disabled: boolean;
onChange: (operator: string) => void;
}
const OperatorSelectorMenu = ({currentOperator, disabled, onChange}: OperatorSelectorProps) => {
const handleOperatorChange = (descriptor: OperatorDescriptor) => {
onChange(descriptor.operatorValue);
setFilter('');
};
const currentOperatorDescriptor = useMemo(() => {
return getOperatorDescriptor(currentOperator);
}, [currentOperator]);
const CurrentOperatorIcon = currentOperatorDescriptor.icon;
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const filteredOperators = useMemo(() => {
return Object.values(OPERATOR_DESCRIPTORS).filter((desc) => {
const label = formatMessage(desc.label);
return label.toLowerCase().includes(filter.toLowerCase());
});
}, [filter, formatMessage]);
return (
<Menu.Container
menuButton={{
id: 'operator-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<>
<CurrentOperatorIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.64)'
/>
<FormattedMessage {...currentOperatorDescriptor.label}/>
</>
),
dataTestId: 'operatorSelectorMenuButton',
disabled,
}}
menu={{
id: 'operator-selector-menu',
'aria-label': 'Select operator',
className: 'select-operator-mui-menu',
}}
>
<Menu.InputItem
key='filter_operators'
id='filter_operators'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_operators', defaultMessage: 'Search operators...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>
{filteredOperators.map((descriptor) => {
const {id, icon: Icon, label} = descriptor;
return (
<Menu.Item
id={id}
key={id}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={id === currentOperatorDescriptor.id}
onClick={() => handleOperatorChange(descriptor)}
labels={<FormattedMessage {...label}/>}
leadingElement={<Icon size={18}/>}
trailingElements={id === currentOperatorDescriptor.id && (
<CheckIcon/>
)}
/>
);
})}
</Menu.Container>
);
};
export default OperatorSelectorMenu;
const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => {
for (const descriptor of Object.values(OPERATOR_DESCRIPTORS)) {
if (descriptor.operatorValue === operatorValue) {
return descriptor;
}
}
return OPERATOR_DESCRIPTORS.is;
};
type OperatorID = 'is' | 'is_not' | 'in' | 'starts_with' | 'ends_with' | 'contains';
type OperatorDescriptor = {
id: OperatorID;
operatorValue: string;
icon: ComponentType<IconProps>;
label: MessageDescriptor;
};
const OPERATOR_DESCRIPTORS: IDMappedObjects<OperatorDescriptor> = {
is: {
id: 'is',
operatorValue: 'is',
icon: EqualIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.is',
defaultMessage: 'is',
}),
},
is_not: {
id: 'is_not',
operatorValue: 'is not',
icon: NotEqualIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.is_not',
defaultMessage: 'is not',
}),
},
in: {
id: 'in',
operatorValue: 'in',
icon: AlphaEIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.in',
defaultMessage: 'in',
}),
},
starts_with: {
id: 'starts_with',
operatorValue: 'starts with',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.starts_with',
defaultMessage: 'starts with',
}),
},
ends_with: {
id: 'ends_with',
operatorValue: 'ends with',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.ends_with',
defaultMessage: 'ends with',
}),
},
contains: {
id: 'contains',
operatorValue: 'contains',
icon: FunctionIcon,
label: defineMessage({
id: 'admin.access_control.table_editor.operator.contains',
defaultMessage: 'contains',
}),
},
};

Просмотреть файл

@@ -0,0 +1,46 @@
.field-selector-menu-button {
width: 100%;
height: 40px;
justify-content: start;
border-color: transparent;
border-radius: 0;
box-shadow: none;
font-weight: normal;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04)
}
&:focus,
&[aria-expanded="true"] {
background: rgba(var(--button-bg-rgb), 0.08);
}
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
svg {
margin-right: 8px;
}
}
.select-attribute-mui-menu,
.select-operator-mui-menu {
margin-top: 0;
.MenuItem {
height: 40px;
svg {
margin-right: 8px;
}
}
}

Просмотреть файл

@@ -0,0 +1,174 @@
.table-editor {
position: relative;
margin-bottom: 24px;
&__table {
overflow: hidden;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
}
&__header {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: rgba(var(--center-channel-color-rgb), 0.04);
}
&__column-header {
flex: 1;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
padding-inline: 10px;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2.3;
}
}
&__column-header-actions {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&__row {
display: flex;
align-items: center;
padding: 0;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}
&__cell {
flex: 1;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2;
}
}
&__cell-actions {
width: 40px;
text-align: right;
}
&__attribute-select,
&__operator-select {
width: 100%;
}
&__select {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 4px;
appearance: none;
background-color: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
cursor: pointer;
}
&:focus {
background-color: rgba(var(--button-bg-rgb), 0.08);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__row-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
color: var(--error-text);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__actions-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
.editor__help-text {
margin-right: 32px;
p {
margin-bottom: 0;
}
}
}
&__blank-state {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
span {
color: var(--center-channel-color-64);
}
}
&__add-button-container {
display: flex;
align-items: center;
padding: 8px;
}
}
.editor__help-text {
color: var(--center-channel-color-72);
font-size: 12px;
p {
margin-bottom: 0;
}
a {
display: inline-block;
margin-top: 8px;
color: var(--link-color);
&:hover {
text-decoration: underline;
}
}
}

Просмотреть файл

@@ -0,0 +1,317 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {Client4} from 'mattermost-redux/client';
import AttributeSelectorMenu from './attribute_selector_menu';
import OperatorSelectorMenu from './operator_selector_menu';
import type {TableRow} from './table_row';
import ValuesEditor from './values_editor';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {AddAttributeButton, TestButton, HelpText} from '../shared';
import './table_editor.scss';
interface TableEditorProps {
value: string;
onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void;
disabled?: boolean;
userAttributes: Array<{
attribute: string;
values: string[];
}>;
}
// Parse CEL expression into table rows
const parseExpression = async (expr: string): Promise<TableRow[]> => {
const tableRows: TableRow[] = [];
if (!expr) {
return tableRows;
}
const rawVisualAST = await Client4.expressionToVisualFormat(expr);
for (const node of rawVisualAST.conditions) {
let attr;
if (node.attribute.startsWith('user.attributes.')) {
attr = node.attribute.slice(16); // wow, there is no trim-prefix
} else {
throw new Error(`Unknown attribute: ${node.attribute}`);
}
let op;
switch (node.operator) {
case '==':
op = 'is';
break;
case 'in':
op = 'in';
break;
case '!=':
op = 'is not';
break;
case 'startsWith':
op = 'starts with';
break;
case 'endsWith':
op = 'ends with';
break;
case 'contains':
op = 'contains';
break;
default:
throw new Error(`Unknown operator: ${node.operator}`);
}
let values;
if (Array.isArray(node.value)) {
values = node.value;
} else {
values = [node.value];
}
tableRows.push({
attribute: attr,
operator: op,
values,
});
}
return tableRows;
};
function TableEditor({
value,
onChange,
onValidate,
disabled = false,
userAttributes,
}: TableEditorProps): JSX.Element {
const {formatMessage} = useIntl();
const [rows, setRows] = useState<TableRow[]>([]);
const [showTestResults, setShowTestResults] = useState(false);
const [showHelpModal, setShowHelpModal] = useState(false);
// Update rows when value changes externally
useEffect(() => {
parseExpression(value).then((rows) => {
setRows(rows);
});
}, [value]);
// Update the CEL expression when table changes
const updateExpression = (newRows: TableRow[]) => {
const validRows = newRows.filter((row) => row.attribute && row.values.length > 0);
const expr = validRows.map((row) => {
if (row.operator === 'is') {
return `user.attributes.${row.attribute} == "${row.values[0]}"`;
}
if (row.operator === 'is not') {
return `user.attributes.${row.attribute} != "${row.values[0]}"`;
}
if (row.operator === 'starts with') {
return `user.attributes.${row.attribute}.startsWith("${row.values[0]}")`;
}
if (row.operator === 'ends with') {
return `user.attributes.${row.attribute}.endsWith("${row.values[0]}")`;
}
if (row.operator === 'contains') {
return `user.attributes.${row.attribute}.contains("${row.values[0]}")`;
}
const valuesStr = row.values.map((val) => `"${val}"`).join(', ');
return `user.attributes.${row.attribute} in [${valuesStr}]`;
}).join(' && ');
onChange(expr);
if (onValidate) {
onValidate(true);
}
};
const addRow = () => {
// Find first available attribute
const availableAttrs = getAvailableAttributes();
if (availableAttrs.length === 0) {
return;
}
const newRows = [...rows, {
attribute: availableAttrs[0].attribute,
operator: 'is',
values: [],
}];
setRows(newRows);
updateExpression(newRows);
};
const removeRow = (index: number) => {
const newRows = rows.filter((_, i) => i !== index);
setRows(newRows);
updateExpression(newRows);
};
const updateRowAttribute = (index: number, attribute: string) => {
const newRows = [...rows];
newRows[index].attribute = attribute;
setRows(newRows);
updateExpression(newRows);
};
const updateRowOperator = (index: number, operator: string) => {
const newRows = [...rows];
newRows[index].operator = operator;
if ((operator !== 'in') && newRows[index].values.length > 1) {
newRows[index].values = newRows[index].values.length > 0 ? [newRows[index].values[0]] : [];
}
setRows(newRows);
updateExpression(newRows);
};
const updateRowValues = (index: number, values: string[]) => {
const newRows = [...rows];
newRows[index].values = values;
setRows(newRows);
updateExpression(newRows);
};
// Get available attributes (excluding ones already used)
const getAvailableAttributes = () => {
const usedAttributes = new Set(rows.map((row) => row.attribute));
return userAttributes.filter((attr) => !usedAttributes.has(attr.attribute));
};
return (
<div className='table-editor'>
<div className='table-editor__table'>
<div className='table-editor__header'>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.attribute'
defaultMessage='Attribute'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.operator'
defaultMessage='Operator'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.values'
defaultMessage='Values'
/>
</div>
<div className='table-editor__column-header-actions'/>
</div>
<div className='table-editor__rows'>
{rows.length === 0 ? (
<div className='table-editor__blank-state'>
<span>
{formatMessage({
id: 'admin.access_control.table_editor.blank_state',
defaultMessage: 'Select a user attribute and values to create a rule',
})}
</span>
</div>
) : (
rows.map((row, index) => (
<div
key={index}
className='table-editor__row'
>
<div className='table-editor__cell'>
<AttributeSelectorMenu
currentAttribute={row.attribute}
availableAttributes={getAvailableAttributes().concat(
row.attribute ? [{attribute: row.attribute, values: []}] : [],
)}
disabled={disabled}
onChange={(attribute) => updateRowAttribute(index, attribute)}
/>
</div>
<div className='table-editor__cell'>
<OperatorSelectorMenu
currentOperator={row.operator}
disabled={disabled}
onChange={(operator) => updateRowOperator(index, operator)}
/>
</div>
<div className='table-editor__cell'>
<ValuesEditor
row={row}
disabled={disabled}
updateValues={(values) => updateRowValues(index, values)}
/>
</div>
<div className='table-editor__cell-actions'>
<button
className='table-editor__row-remove'
onClick={() => removeRow(index)}
disabled={disabled}
aria-label={formatMessage({id: 'admin.access_control.table_editor.remove_row', defaultMessage: 'Remove row'})}
>
<i className='icon icon-trash-can-outline'/>
</button>
</div>
</div>
))
)}
</div>
<div className='table-editor__add-button-container'>
<AddAttributeButton
onClick={addRow}
disabled={disabled || getAvailableAttributes().length === 0}
/>
</div>
</div>
<div className='table-editor__actions-row'>
<HelpText
message={'Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).'}
/>
<TestButton
onClick={() => setShowTestResults(true)}
disabled={disabled || !value}
/>
</div>
{showTestResults && (
<TestResultsModal
onExited={() => setShowTestResults(false)}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
return searchUsersForExpression(value, term, after, limit);
},
}}
/>
)}
{showHelpModal && (
<CELHelpModal
onExited={() => setShowHelpModal(false)}
/>
)}
</div>
);
}
export default TableEditor;

Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface TableRow {
attribute: string;
operator: string;
values: string[];
}

Просмотреть файл

@@ -0,0 +1,78 @@
.values-editor {
position: relative;
width: 100%;
.select__multi-value {
display: flex;
height: 24px;
align-items: center;
padding: 0;
border: none;
border-radius: 4px;
margin: 2px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.select__multi-value__label {
padding: 0 8px;
color: var(--center-channel-color);
font-size: 12px;
line-height: 16px;
}
.select__multi-value__remove {
display: flex;
height: 100%;
align-items: center;
padding: 0 4px;
border-radius: 0 4px 4px 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--center-channel-color);
}
}
.select__control {
min-height: 40px;
border: none;
border-radius: 0;
overflow-y: auto;
&--is-focused {
border: none;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
cursor: text;
}
}
&__simple-input {
width: 100%;
height: 40px;
padding: 0 16px;
border: none;
background: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
}
&:focus {
background: rgba(var(--center-channel-color-rgb), 0.06);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}

Просмотреть файл

@@ -0,0 +1,122 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import CreatableSelect from 'react-select/creatable';
import Constants from 'utils/constants';
import './values_editor.scss';
import type {TableRow} from './table_row';
export type ValuesEditorProps = {
row: TableRow;
disabled: boolean;
updateValues: (values: string[]) => void;
}
function ValuesEditor({row, disabled, updateValues}: ValuesEditorProps) {
const {formatMessage} = useIntl();
const isMulti = row.operator === 'in';
const [inputValue, setInputValue] = useState('');
const [isEditing, setIsEditing] = useState(false);
// Format options for react-select
const value = useMemo(() => {
return row.values.map((val) => ({
label: val,
value: val,
}));
}, [row.values]);
// Handle input submission for single value
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}
};
// For single value mode, use a simple input field
if (!isMulti) {
const displayValue = row.values.length > 0 ? row.values[0] : '';
return (
<div className='values-editor'>
<input
type='text'
className='values-editor__simple-input'
value={isEditing ? inputValue : displayValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
setIsEditing(true);
if (displayValue) {
setInputValue(displayValue);
}
}}
onBlur={() => {
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.value.placeholder', defaultMessage: 'Add value...'})}
disabled={disabled}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
/>
</div>
);
}
// For multi-value mode, continue using CreatableSelect
const customComponents = {
DropdownIndicator: () => null,
IndicatorsContainer: () => null,
};
const handleChange = (newValue: any) => {
if (!newValue) {
updateValues([]);
} else if (Array.isArray(newValue)) {
updateValues(newValue.map((option) => option.value));
}
};
return (
<div className='values-editor'>
<CreatableSelect
isMulti={true}
isClearable={true}
isDisabled={disabled}
components={customComponents}
value={value}
onChange={handleChange}
onCreateOption={(inputValue) => {
const val = inputValue.trim();
if (!val) {
return;
}
if (!row.values.includes(val)) {
updateValues([...row.values, val]);
}
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.values.placeholder', defaultMessage: 'Add values...'})}
classNamePrefix='select'
menuPortalTarget={document.body}
/>
</div>
);
}
export default ValuesEditor;

Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {searchAccessControlPolicies, deleteAccessControlPolicy} from 'mattermost-redux/actions/access_control';
import PolicyList from './policies';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
searchPolicies: searchAccessControlPolicies,
deletePolicy: deleteAccessControlPolicy,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(PolicyList);

Просмотреть файл

@@ -0,0 +1,77 @@
.AccessControlSyncJobTable {
overflow: auto;
.policy-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&-text {
h1 {
margin-bottom: 8px;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: rgba(63, 67, 80, 0.72);
}
}
.btn-primary {
display: flex;
align-items: center;
padding: 10px 16px;
gap: 8px;
.icon {
font-size: 18px;
}
}
}
.job-table__access-control {
.job-table__table {
max-height: 400px;
padding: 0px;
border: none;
}
.table > thead > tr > th,
.table > tbody > tr > th,
.table > tfoot > tr > th,
.table > thead > tr > td,
.table > tbody > tr > td,
.table > tfoot > tr > td {
border-top: none;
padding-block: 10px;
}
.table > thead > tr > th {
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.16);
font-size: 1em;
font-weight: 600;
}
.cancel-button-field {
width: 30px;
}
.table-row {
min-height: 40px;
cursor: pointer;
}
}
.DataGrid_footer {
border-bottom: 0;
color: rgba(var(--sys-center-channel-color-rgb), 0.56);
}
.actions-column {
justify-content: flex-end !important;
text-align: right;
}
}

Просмотреть файл

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import type {JobType, JobTypeBase, Job} from '@mattermost/types/jobs';
import type {ActionResult} from 'mattermost-redux/types/actions';
import JobsTable from 'components/admin_console/jobs';
import {JobTypes} from 'utils/constants';
import JobDetailsModal from '../modals/job_details/job_details_modal';
import './access_control_sync_job_table.scss';
type Props = {
actions: {
createJob: (job: JobTypeBase) => Promise<ActionResult>;
getJobsByType: (jobType: JobType) => void;
};
};
export default function AccessControlSyncJobTable(props: Props): JSX.Element {
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [showModal, setShowModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Load jobs when component mounts
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
// Set up polling interval
const interval = setInterval(() => {
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
}, 15000);
return () => {
clearInterval(interval);
};
}, [props.actions]);
const handleCreateJob = async (e?: React.SyntheticEvent) => {
e?.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
const job = {
type: JobTypes.ACCESS_CONTROL_SYNC,
};
try {
await props.actions.createJob(job);
// Immediately fetch updated job list
props.actions.getJobsByType(JobTypes.ACCESS_CONTROL_SYNC);
} finally {
// Reset submitting state after a short delay to prevent rapid re-clicks
setTimeout(() => {
setIsSubmitting(false);
}, 1000);
}
};
const handleRowClick = (job: Job) => {
setSelectedJob(job);
setShowModal(true);
};
const handleModalClose = () => {
setShowModal(false);
setSelectedJob(null);
};
return (
<div className='AccessControlSyncJobTable'>
<div className='policy-header'>
<div className='policy-header-text'>
<h1>{'Access Control Sync Jobs'}</h1>
<p>{'Synchronize access control policies with system resources and permissions.'}</p>
</div>
<button
className='btn btn-primary'
onClick={handleCreateJob}
disabled={isSubmitting}
>
<i className='icon icon-plus'/>
<span>{isSubmitting ? 'Running Job...' : 'Run Sync Job'}</span>
</button>
</div>
<JobsTable
perPage={5}
jobType={JobTypes.ACCESS_CONTROL_SYNC}
hideJobCreateButton={true}
className={'job-table__access-control'}
createJobButtonText={'Create Job'}
disabled={false}
createJobHelpText={<></>}
onRowClick={handleRowClick}
/>
{showModal && selectedJob && (
<JobDetailsModal
job={selectedJob}
onExited={handleModalClose}
/>
)}
</div>
);
}

Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {createJob, getJobsByType} from 'mattermost-redux/actions/jobs';
import AccessControlSyncJobTable from './access_control_sync_job_table';
const mapDispatchToProps = (dispatch: Dispatch) => ({
actions: bindActionCreators({
createJob,
getJobsByType,
}, dispatch),
});
export default connect(null, mapDispatchToProps)(AccessControlSyncJobTable);

Просмотреть файл

@@ -0,0 +1,39 @@
// Content layout and styling
.cel-help-modal__content-container {
display: flex;
flex-direction: column;
.cel-help-modal__content {
padding: 32px;
padding-top: 0;
}
// Important notes section
.cel-help-additional-info-modal__content {
display: flex;
flex-direction: column;
padding: 20px;
background-color: rgba(var(--button-bg-rgb), 0.08);
// Header with icon
.cel-help-additional-info-modal__header {
display: flex;
align-items: center;
margin-bottom: 16px;
i {
margin-right: 8px;
color: var(--button-bg);
font-size: 24px;
}
.cel-help-additional-info-modal__title {
font-size: 16px;
font-weight: 600;
}
}
}
}

Просмотреть файл

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import ExternalLink from 'components/external_link';
import Markdown from 'components/markdown';
import './cel_help_modal.scss';
type Props = {
onExited: () => void;
onHide?: () => void;
};
const CELHelpModal: React.FC<Props> = ({onExited, onHide}: Props) => {
return (
<GenericModal
id='CELHelpModal'
className='cel-help-modal--centered'
aria-labelledby='CELHelpModalLabel'
onExited={onExited}
onHide={onHide}
modalHeaderText={(
<FormattedMessage
id='admin.access_control.cel_help_modal.title'
defaultMessage='Common Expression Language (CEL)'
/>
)}
modalSubheaderText={(
<FormattedMessage
id='admin.access_control.cel_help_modal.subheader'
defaultMessage='With CEL you can define conditions to filter user attributes and control resource access.'
/>
)}
compassDesign={true}
bodyPadding={false}
modalLocation='top'
>
<div className='cel-help-modal__content-container'>
<div className='cel-help-modal__content'>
<Markdown
message={'### Basic Syntax\nCEL expressions evaluate to boolean values (`true`/`false`) to determine if access should be granted.\n### Common Examples\n- To match a specific program:\n&ensp;`user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n&ensp;`user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n&ensp;`user.attributes.Email.endsWith("example.com")`\n- To combine conditions (for this example with `OR` operator, altertanitvely use `&&` for `AND` operation):\n&ensp;`user.attrs.Program == "Alpha" || user.attrs.Team == "Operations"`\n### Supported Operators and functions\n- `==`, `!=`, `&&`, `||`, `in`, `contains()`, `startsWith()`, `endsWith()`'}
/>
</div>
<div className='cel-help-additional-info-modal__content'>
<div className='cel-help-additional-info-modal__header'>
<i className='icon icon-information-outline'/>
<span className='cel-help-additional-info-modal__title'>
<FormattedMessage
id='admin.access_control.cel_help_modal.important_notes_title'
defaultMessage='Important Notes'
/>
</span>
</div>
<div className='cel-help-additional-info-modal__text'>
<Markdown
message={'- Operators like `<` or `>` are forbidden due to incorrect string comparison.\n- Only `user.attributes` are supported; any other variables are not supported yet.'}
/>
<FormattedMessage
id='admin.access_control.cel_help_modal.external_link'
defaultMessage='For more information, visit <link>CEL Documentation</link>.'
values={{
link: (msg: React.ReactNode) => (
<ExternalLink
href='https://cel.dev/'
location='cel_help_modal'
>
{msg}
</ExternalLink>
),
}}
/>
</div>
</div>
</div>
</GenericModal>
);
};
export default CELHelpModal;

Просмотреть файл

@@ -0,0 +1,70 @@
.PolicyConfirmationModal {
.modal-body {
.enforce-toggle {
margin-top: 20px;
.enforce-checkbox-label {
display: flex;
align-items: center;
color: #3d3c40;
cursor: pointer;
input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
}
}
.confirmation {
margin-top: 20px;
color: #3d3c40;
font-weight: 600;
}
}
.modal-footer {
.btn-cancel {
padding: 10px 16px;
border: none;
border-radius: 4px;
margin-right: 10px;
background: #f2f4f8;
color: #3d3c40;
font-size: 14px;
font-weight: 600;
&:hover {
background: #e8eaed;
}
}
.btn-apply {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #C74A4A;
color: white;
font-size: 14px;
font-weight: 600;
&:hover {
background: #b73535;
}
}
.btn-save {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #166de0;
color: white;
font-size: 14px;
font-weight: 600;
&:hover {
background: #0f5fc0;
}
}
}
}

Просмотреть файл

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import './confirmation_modal.scss';
import GenericModal from '@mattermost/components/src/generic_modal/generic_modal';
type Props = {
active: boolean;
onExited: () => void;
onConfirm: (apply: boolean) => void;
channelsAffected: number;
}
export default function PolicyConfirmationModal({active, onExited, onConfirm, channelsAffected}: Props) {
const {formatMessage} = useIntl();
const [enforceImmediately, setEnforceImmediately] = useState(true);
return (
<GenericModal
className={'PolicyConfirmationModal'}
show={true}
onExited={onExited}
onHide={onExited}
compassDesign={true}
modalHeaderText={
<FormattedMessage
id='admin.access_control.policy.save_policy_confirmation_title'
defaultMessage='Save access control policy '
/>
}
modalSubheaderText={
<FormattedMessage
id='admin.access_control.policy.save_policy_confirmation_subheader'
defaultMessage='{count} channels will be affected.'
values={{count: channelsAffected}}
/>
}
footerContent={
<div>
<button
type='button'
className='btn-cancel'
onClick={onExited}
>
{formatMessage({id: 'admin.access_control.edit_policy.cancel', defaultMessage: 'Cancel'})}
</button>
<button
type='button'
className={enforceImmediately ? 'btn-apply' : 'btn-save'}
onClick={() => onConfirm(enforceImmediately)}
>
{enforceImmediately ?
formatMessage({id: 'admin.access_control.edit_policy.apply_policy', defaultMessage: 'Apply policy'}) :
formatMessage({id: 'admin.access_control.edit_policy.save_policy', defaultMessage: 'Save policy'})
}
</button>
</div>
}
>
<div className='body'>
{active ? (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body',
defaultMessage: 'Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
) : (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.inactive',
defaultMessage: 'Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
)}
</div>
<div className='enforce-toggle'>
<label className='enforce-checkbox-label'>
<input
type='checkbox'
checked={enforceImmediately}
onChange={(e) => setEnforceImmediately(e.target.checked)}
/>
<span>{formatMessage({
id: 'admin.access_control.policy.enforce_immediately',
defaultMessage: 'Enforce policy immediately',
})}</span>
</label>
</div>
<div className='confirmation'>
{enforceImmediately ?
formatMessage({
id: 'admin.access_control.policy.channels_affected',
defaultMessage: 'Are you sure you want to save and apply the access control policy?',
}) :
formatMessage({
id: 'admin.access_control.policy.save_only',
defaultMessage: 'Are you sure you want to save this access control policy?',
})
}
</div>
</GenericModal>
);
}

Просмотреть файл

@@ -0,0 +1,103 @@
#job-details-modal {
.modal-header{
padding: 16px 64px 4px 32px;
}
.filtered-user-list {
height: 440px;
cursor: pointer;
}
.modal-header-with-status {
display: flex;
align-items: center;
font-size: 26px;
font-weight: 600;
gap: 10px;
}
// Status indicator
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
&.status-success { background-color: var(--online-indicator); }
&.status-error { background-color: var(--error-text); }
&.status-in-progress { background-color: var(--away-indicator); }
&.status-pending { background-color: var(--offline-indicator); }
}
.filter-row--full {
position: relative;
padding: 0 32px;
.input-clear {
top: 16px;
right: 14px;
}
#searchIcon {
position: absolute;
z-index: 2;
top: 16px;
left: 42px;
color: rgba(var(--center-channel-color-rgb), 0.64);
pointer-events: none;
}
#searchChannelsTextbox {
height: 48px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: none;
font-size: 16px;
padding-inline: 40px;
&::placeholder {
color: var(--center-channel-color);
}
&:focus {
border: 2px solid var(--button-bg);
}
}
}
.sync-job-channel-count-label {
display: flex;
margin: 10px 16px;
color: var(--center-channel-color-64);
font-size: 12px;
}
.changes-cell {
text-align: center;
.changes-summary {
.added {
color: var(--online-indicator);
font-weight: 600;
}
.removed {
color: var(--error-text);
font-weight: 600;
}
}
}
.more-modal__list .more-modal__details{
padding-left: 0;
}
.error-status-content {
display: flex;
height: 100%;
flex-direction: column;
padding: 0px 32px 32px;
&__title {
color: var(--error-text);
}
}
}

Просмотреть файл

@@ -0,0 +1,238 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Job} from '@mattermost/types/jobs';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as ChannelActions from 'mattermost-redux/actions/channels';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import CodeBlock from 'components/code_block/code_block';
import type {GlobalState} from 'types/store';
import SearchableSyncJobChannelList from './searchable_sync_job_channel_list';
import type {SyncResults} from './searchable_sync_job_channel_list';
import UserListModal, {type ChannelMembersSyncResults} from '../user_sync/user_sync_modal';
import './job_details_modal.scss';
// Component to display job status
type StatusIndicatorProps = {
status: string;
};
const StatusIndicator = ({status}: StatusIndicatorProps): JSX.Element => {
let statusClass = 'status-indicator';
if (status === 'success') {
statusClass += ' status-success';
} else if (status === 'error' || status === 'canceled') {
statusClass += ' status-error';
} else if (status === 'in_progress') {
statusClass += ' status-in-progress';
} else {
statusClass += ' status-pending';
}
return (
<div className='status-wrapper'>
<div
className={statusClass}
title={status}
/>
</div>
);
};
type Props = {
job: Job ;
onExited: () => void;
};
export default function JobDetailsModal({job, onExited}: Props): JSX.Element {
const dispatch = useDispatch();
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [selectedChannelName, setSelectedChannelName] = useState<string>('');
const [selectedChannelResults, setSelectedChannelResults] = useState<ChannelMembersSyncResults | null>(null);
const [channelLookup, setChannelLookup] = useState<IDMappedObjects<Channel>>({});
const [teamLookup, setTeamLookup] = useState<IDMappedObjects<Team>>({});
const [syncResults, setSyncResults] = useState<SyncResults | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [allChannelsForList, setAllChannelsForList] = useState<Channel[]>([]);
const pageSize = 10;
// Get state for lookups
const state = useSelector((state: GlobalState) => state);
// Parse sync results initially
useEffect(() => {
if (job?.data?.sync_results) {
const parsedResults = JSON.parse(job.data.sync_results);
setSyncResults(parsedResults);
// Collect all channel IDs and user IDs for lookup
const channelIds: string[] = [];
// Use a safer type cast for Object.entries
Object.entries(parsedResults).forEach((entry) => {
const channelId = entry[0];
channelIds.push(channelId);
});
// Fetch channel and user data if we have IDs
if (channelIds.length > 0) {
// Fetch each channel individually
channelIds.forEach((id) => {
dispatch(ChannelActions.getChannel(id));
});
}
}
}, [job?.data?.sync_results, dispatch]);
// Build channel lookup from state and prepare allChannelsForList
useEffect(() => {
if (syncResults) {
const channels: IDMappedObjects<Channel> = {};
const teams: IDMappedObjects<Team> = {};
const channelsForList: Channel[] = [];
Object.keys(syncResults).forEach((channelId) => {
const channel = getChannel(state, channelId);
if (channel) {
channels[channelId] = channel;
channelsForList.push(channel);
if (!teams[channel.team_id]) {
const team = getTeam(state, channel.team_id);
if (team) {
teams[team.id] = team;
}
}
}
});
setTeamLookup(teams);
setChannelLookup(channels);
setAllChannelsForList(channelsForList);
}
}, [syncResults, state]);
const handleViewDetails = (channelId: string, channelName: string, results: ChannelMembersSyncResults) => {
setSelectedChannel(channelId);
setSelectedChannelName(channelName);
setSelectedChannelResults(results);
};
const handleCloseUserListModal = () => {
setSelectedChannel(null);
setSelectedChannelName('');
setSelectedChannelResults(null);
};
// Filter and search channels for SearchableSyncJobChannelList
const getFilteredChannels = () => {
let channels = allChannelsForList;
if (searchTerm) {
channels = channels.filter((channel) =>
channel.display_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
channel.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(channelLookup[channel.id] && teamLookup[channelLookup[channel.id].team_id]?.name.toLowerCase().includes(searchTerm.toLowerCase())),
);
}
// Add filtering by type (Public, Private, Archived) if needed based on currentFilter
// For now, it shows all channels from syncResults
return channels;
};
const filteredChannels = getFilteredChannels();
const noResultsText = (
<span className='no-results-message'>
<FormattedMessage
id='admin.jobTable.syncResults.noResultsSearchable'
defaultMessage='No channels match your search or filter.'
/>
</span>
);
return (
<GenericModal
id='job-details-modal'
onExited={onExited}
compassDesign={true}
modalHeaderText={
<div className='modal-header-with-status'>
<FormattedMessage
id='admin.jobTable.details.title'
defaultMessage='Job Details'
/>
<StatusIndicator status={job.status}/>
</div>
}
modalSubheaderText={
<div className='modal-subheader-text'>
<FormattedMessage
id='admin.access_control.jobTable.details.subheader'
defaultMessage='Finished at {finishedAt}'
values={{
finishedAt: new Date(job.last_activity_at).toLocaleString(),
}}
/>
</div>
}
show={true}
bodyPadding={false}
>
{job.status === 'error' ? (
<div className='error-status-content'>
<div className='error-status-content__title'>
<FormattedMessage
id='admin.jobTable.syncResults.error'
defaultMessage='An error occurred while syncing the channels.'
/>
</div>
<CodeBlock
code={JSON.stringify(job.data, null, 2)}
language='json'
/>
</div>
) : (
job.type.includes('access_control_sync') && syncResults && (
<SearchableSyncJobChannelList
channels={filteredChannels}
teams={teamLookup}
channelsPerPage={pageSize}
nextPage={() => {}}
isSearch={Boolean(searchTerm)}
search={setSearchTerm}
onViewDetails={handleViewDetails}
noResultsText={noResultsText}
syncResults={syncResults}
/>
)
)}
{selectedChannel && selectedChannelResults && (
<UserListModal
channelId={selectedChannel}
channelName={selectedChannelName}
syncResults={selectedChannelResults}
onClose={handleCloseUserListModal}
/>
)}
</GenericModal>
);
}

Просмотреть файл

@@ -0,0 +1,313 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useEffect} from 'react';
import {FormattedMessage, defineMessages, injectIntl, type WrappedComponentProps} from 'react-intl';
import {ArchiveOutlineIcon, GlobeIcon, LockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils';
import MagnifyingGlassSVG from 'components/common/svg_images_components/magnifying_glass_svg';
import LoadingScreen from 'components/loading_screen';
import QuickInput from 'components/quick_input';
import {isArchivedChannel} from 'utils/channel_utils';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import type {ChannelMembersSyncResults} from '../user_sync/user_sync_modal';
export type SyncResults = {
[channelId: string]: ChannelMembersSyncResults;
};
interface Props extends WrappedComponentProps {
channels: Channel[];
teams: IDMappedObjects<Team>;
channelsPerPage: number;
nextPage: (page: number) => void;
isSearch: boolean;
search: (term: string) => void;
onViewDetails?: (channelId: string, channelName: string, results: ChannelMembersSyncResults) => void;
noResultsText: JSX.Element;
loading?: boolean;
syncResults: SyncResults;
}
const SearchableSyncJobChannelList = (props: Props) => {
const [page, setPage] = useState(0);
const [nextDisabled, setNextDisabled] = useState(false);
const [channelSearchValue, setChannelSearchValue] = useState('');
const [isSearch, setIsSearch] = useState(props.isSearch);
const channelListScroll = useRef<HTMLDivElement>(null);
// Handle getDerivedStateFromProps
useEffect(() => {
setIsSearch(props.isSearch);
if (props.isSearch && !isSearch) {
setPage(0);
}
}, [props.isSearch, isSearch]);
// Handle componentDidMount and componentWillUnmount
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isEnterKeyPressed = isKeyPressed(e, Constants.KeyCodes.ENTER);
if (isEnterKeyPressed && (e.shiftKey || e.ctrlKey || e.altKey)) {
return;
}
if (isEnterKeyPressed && target?.classList.contains('more-modal__row')) {
target.click();
}
};
const handleRowClick = (channel: Channel) => {
if (props.onViewDetails && props.syncResults[channel.id]) {
props.onViewDetails(channel.id, channel.display_name, props.syncResults[channel.id]);
}
};
const createChannelRow = (channel: Channel) => {
const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase();
let channelTypeIcon;
if (isArchivedChannel(channel)) {
channelTypeIcon = <ArchiveOutlineIcon size={18}/>;
} else if (isPrivateChannel(channel)) {
channelTypeIcon = <LockOutlineIcon size={18}/>;
} else {
channelTypeIcon = <GlobeIcon size={18}/>;
}
const team = props.teams[channel.team_id];
const channelMoreInfoContainer = (
<div
id='channelMoreInfoContainer'
aria-label={`${team.display_name}`}
>
<span className='more-modal__description'>{`${team.display_name}`}</span>
</div>
);
const channelSyncData = props.syncResults[channel.id];
const syncChangesDisplay = channelSyncData ? (
<div className='changes-cell'>
<span className='changes-summary'>
<span className='added'>
{'+' + (channelSyncData.MembersAdded?.length || 0)}
</span>
{' / '}
<span className='removed'>
{'-' + (channelSyncData.MembersRemoved?.length || 0)}
</span>
</span>
</div>
) : null;
return (
<div
className='more-modal__row job-sync-row'
key={channel.id}
id={`ChannelRow-${channel.name}`}
data-testid={`ChannelRow-${channel.name}`}
aria-label={ariaLabel}
onClick={() => handleRowClick(channel)}
tabIndex={0}
>
<div className='more-modal__details'>
<div className='style--none more-modal__name'>
{channelTypeIcon}
<span id='channelName'>{channel.display_name}</span>
</div>
{team && channelMoreInfoContainer}
</div>
<div className='more-modal__actions'>
{syncChangesDisplay}
</div>
</div>
);
};
const nextPage = (e: React.MouseEvent) => {
e.preventDefault();
setPage(page + 1);
setNextDisabled(true);
props.nextPage(page + 1);
channelListScroll.current?.scrollTo({top: 0});
};
const previousPage = (e: React.MouseEvent) => {
e.preventDefault();
setPage(page - 1);
channelListScroll.current?.scrollTo({top: 0});
};
const handleChange = (e?: React.FormEvent<HTMLInputElement>) => {
if (e?.currentTarget) {
setChannelSearchValue(e.currentTarget.value);
props.search(e.currentTarget.value);
}
};
const handleClear = () => {
setChannelSearchValue('');
props.search('');
};
const getEmptyStateMessage = () => {
return (
<FormattedMessage
id='more_channels.noMore'
tagName='strong'
defaultMessage='No results for {text}'
values={{text: channelSearchValue}}
/>
);
};
const channels = props.channels;
let listContent;
let nextButton;
let previousButton;
if (props.loading && channels.length === 0) {
listContent = <LoadingScreen/>;
} else if (channels.length === 0) {
listContent = (
<div
className='no-channel-message channel-switcher__suggestion-box'
aria-label={channelSearchValue.length > 0 ? props.intl.formatMessage(messages.noMore, {text: channelSearchValue}) : props.intl.formatMessage({id: 'widgets.channels_input.empty', defaultMessage: 'No channels found'})
}
>
<MagnifyingGlassSVG/>
<h3 className='primary-message'>
{getEmptyStateMessage()}
</h3>
{props.noResultsText}
</div>
);
} else {
const pageStart = page * props.channelsPerPage;
const pageEnd = pageStart + props.channelsPerPage;
const channelsToDisplay = props.channels.slice(pageStart, pageEnd);
listContent = channelsToDisplay.map(createChannelRow);
if (channelsToDisplay.length >= props.channelsPerPage && pageEnd < props.channels.length) {
nextButton = (
<button
className='btn btn-sm btn-tertiary filter-control filter-control__next'
onClick={nextPage}
disabled={nextDisabled}
aria-label={props.intl.formatMessage({id: 'more_channels.next', defaultMessage: 'Next'})}
>
<FormattedMessage
id='more_channels.next'
defaultMessage='Next'
/>
</button>
);
}
if (page > 0) {
previousButton = (
<button
className='btn btn-sm btn-tertiary filter-control filter-control__prev'
onClick={previousPage}
aria-label={props.intl.formatMessage({id: 'more_channels.prev', defaultMessage: 'Previous'})}
>
<FormattedMessage
id='more_channels.prev'
defaultMessage='Previous'
/>
</button>
);
}
}
const input = (
<div className='filter-row filter-row--full'>
<span
id='searchIcon'
aria-hidden='true'
>
<i className='icon icon-magnify'/>
</span>
<QuickInput
id='searchChannelsTextbox'
className='form-control filter-textbox'
placeholder={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search channels'})}
onInput={handleChange}
clearable={true}
onClear={handleClear}
value={channelSearchValue}
aria-label={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search Channels'})}
/>
</div>
);
let channelCountLabel;
if (channels.length === 0) {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'});
} else if (channels.length === 1) {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_one', defaultMessage: '1 Result'});
} else if (channels.length > 1) {
channelCountLabel = props.intl.formatMessage(messages.channelCount, {count: channels.length});
} else {
channelCountLabel = props.intl.formatMessage({id: 'more_channels.count_zero', defaultMessage: '0 Results'});
}
return (
<div className='filtered-user-list'>
{input}
<div className='more-modal__dropdown'>
<span className='sync-job-channel-count-label'>
{channelCountLabel}
</span>
</div>
<div
role='search'
className='more-modal__list'
tabIndex={-1}
>
<div
id='moreChannelsList'
tabIndex={-1}
ref={channelListScroll}
>
{listContent}
</div>
</div>
<div className='filter-controls'>
{previousButton}
{nextButton}
</div>
</div>
);
};
const messages = defineMessages({
channelCount: {
id: 'more_channels.count',
defaultMessage: '{count} Results',
},
noMore: {
id: 'more_channels.noMore',
defaultMessage: 'No results for {text}',
},
});
export default injectIntl(SearchableSyncJobChannelList);

Просмотреть файл

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import PolicyList from 'components/admin_console/access_control/policies';
type Props = {
show: boolean;
onHide: () => void;
onPolicySelected: (policy: AccessControlPolicy) => void;
actions: {
searchPolicies: (term: string, type: string, after: string, limit: number) => Promise<ActionResult>;
};
};
export default function PolicySelectionModal(props: Props): JSX.Element {
const {show, onHide, onPolicySelected, actions} = props;
return (
<GenericModal
id='PolicySelectionModal'
compassDesign={true}
show={show}
onHide={onHide}
backdrop='static'
modalHeaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_title'
defaultMessage='Select an Access Control Policy'
/>
)}
modalSubheaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_description'
defaultMessage='An access control policy will restrict channel membership based on user attributes.'
/>
)}
>
<PolicyList
simpleMode={true}
onPolicySelected={onPolicySelected}
actions={{
searchPolicies: actions.searchPolicies,
deletePolicy: () => Promise.resolve({data: {}}),
}}
/>
</GenericModal>
);
}

Просмотреть файл

@@ -0,0 +1,4 @@
#testResultsModalLabel {
padding: 32px 0;
text-align: center;
}

Просмотреть файл

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useCallback} from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {AccessControlTestResult} from '@mattermost/types/access_control';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container';
import type {ModalData} from 'types/actions';
import type {ActionFuncAsync} from 'types/store';
import './test_modal.scss';
const USERS_TO_FETCH = 50;
const USERS_PER_PAGE = 10;
type Props = {
onExited: () => void;
actions: {
searchUsers: (term: string, after: string, limit: number) => ActionFuncAsync<AccessControlTestResult>;
openModal?: <P>(modalData: ModalData<P>) => void;
};
}
function TestResultsModal({
onExited,
actions,
}: Props): JSX.Element {
const dispatch = useDispatch<any>();
const [term, setTerm] = useState<string>('');
const [users, setUsers] = useState<UserProfile[]>([]);
const [total, setTotal] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true);
const [cursorHistory, setCursorHistory] = useState<string[]>([]); // Stores the 'after' cursor for page 1, page 2, etc.
const fetchUsers = useCallback(async (searchTerm: string, cursor: string, reset: boolean = false) => {
setLoading(true);
const result: ActionResult<AccessControlTestResult> = await dispatch(actions.searchUsers(searchTerm, cursor, USERS_TO_FETCH));
if (result?.data) {
const newUsers = result.data.users;
if (reset) {
setUsers(newUsers);
} else {
setUsers((prevUsers) => [...prevUsers, ...newUsers]);
}
setTotal(result.data.total);
} else {
setUsers([]);
setTotal(0);
}
setLoading(false);
}, [dispatch, actions]);
useEffect(() => {
fetchUsers(term, '');
}, []);
const handleSearch = (newTerm: string) => {
setCursorHistory([]);
setTerm(newTerm);
fetchUsers(newTerm, '', true);
};
const handleNextPage = (page: number) => {
if (loading || !users.length) {
return;
}
if (page * USERS_PER_PAGE < USERS_TO_FETCH) {
return;
}
const cursorForNextPage = users[users.length - 1].id;
setCursorHistory([...cursorHistory, cursorForNextPage]);
fetchUsers(term, cursorForNextPage);
};
return (
<Modal
dialogClassName='a11y__modal more-modal'
show={true}
onHide={onExited}
role='none'
aria-labelledby='testResultsModalLabel'
id='testResultsModal'
>
<Modal.Header
closeButton={true}
style={{display: 'flex', alignItems: 'center'}}
>
<Modal.Title
componentClass='h1'
id='testResultsModalLabel'
>
<FormattedMessage
id='admin.access_control.testResults'
defaultMessage='Access Rule Test Results'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={total}
nextPage={handleNextPage}
search={handleSearch}
actionUserProps={{}}
/>
</Modal.Body>
</Modal>
);
}
export default TestResultsModal;

Просмотреть файл

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {UserGroupsSVG} from 'components/common/svg_images_components/user_groups_svg';
import SearchableUserList from 'components/searchable_user_list/searchable_user_list_container';
import type {ActionFuncAsync} from 'types/store';
type SyncedUserListProps = {
userIds: string[];
noResultsMessageId: string;
noResultsDefaultMessage: string;
actions: {
getProfilesByIds: (userIds: string[]) => ActionFuncAsync<UserProfile[]>;
};
};
const USERS_PER_PAGE = 10;
// TODO: this component should be improved:
// - make pagination work
// - improve search
export const SyncedUserList = ({userIds, noResultsMessageId, noResultsDefaultMessage, actions}: SyncedUserListProps): JSX.Element => {
const dispatch = useDispatch<any>();
const [users, setUsers] = useState<UserProfile[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const totalUsers = userIds.length;
const fetchUsers = useCallback(async (page: number) => {
const startIndex = page * USERS_PER_PAGE;
const endIndex = startIndex + USERS_PER_PAGE;
const idsToFetch = userIds.slice(startIndex, endIndex);
await dispatch(actions.getProfilesByIds(idsToFetch)).then((result: ActionResult<UserProfile[]>) => {
if (result?.data) {
setUsers([...result.data]);
} else {
setUsers([]);
}
});
}, [userIds]);
useEffect(() => {
fetchUsers(currentPage);
}, [currentPage]);
const handleSearch = (searchTerm: string) => {
if (searchTerm === '') {
fetchUsers(0);
} else {
setUsers(users.filter((user) => {
return user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.nickname.toLowerCase().includes(searchTerm.toLowerCase());
}));
}
};
if (userIds.length === 0) {
return (
<div
className='no-user-message'
aria-label='No users found'
>
<UserGroupsSVG className='empty-state-svg'/>
<h3 className='primary-message'>
<FormattedMessage
id={noResultsMessageId}
tagName='strong'
defaultMessage={noResultsDefaultMessage}
/>
</h3>
</div>
);
}
return (
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={totalUsers}
nextPage={() => {
setCurrentPage(currentPage + 1);
}}
previousPage={() => {
setCurrentPage(currentPage - 1);
}}
search={handleSearch}
actionUserProps={{}}
/>
);
};
export default SyncedUserList;

Просмотреть файл

@@ -0,0 +1,56 @@
#user-list-modal-dialog {
.modal-header {
padding-bottom: 0px;
}
.tabs {
display: flex;
padding: 0 32px;
border-bottom: 1px solid var(--center-channel-color-16);
margin-bottom: 12px;
}
.tab-button {
padding: 10px 15px;
border: none;
margin-bottom: -1px;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.64);
cursor: pointer;
font-weight: 600;
&.active {
border-bottom: 2px solid var(--button-bg);
color: var(--button-bg);
}
&:hover:not(.active) {
color: var(--center-channel-color);
}
}
.tab-content {
.filtered-user-list {
height: 440px;
overflow-y: auto;
}
.no-user-message {
display: flex;
height: 440px;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--center-channel-color-rgb), 0.64);
text-align: center;
.empty-state-svg {
margin-bottom: 20px;
}
.primary-message {
padding-bottom: 24px;
}
}
}
}

Просмотреть файл

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
import {SyncedUserList} from './synced_user_list';
import './user_sync_modal.scss';
// Types for sync results
export type ChannelMembersSyncResults = {
MembersAdded: string[];
MembersRemoved: string[];
};
// Modal for showing detailed user lists
type UserListModalProps = {
channelId: string;
channelName: string;
syncResults: ChannelMembersSyncResults;
onClose: () => void;
};
export const UserListModal = ({channelId, channelName, syncResults, onClose}: UserListModalProps): JSX.Element => {
const [activeTab, setActiveTab] = useState<'added' | 'removed'>('added');
const handleTabChange = (tab: 'added' | 'removed') => {
setActiveTab(tab);
};
const displayName = channelName || channelId;
return (
<GenericModal
className='a11y__modal more-modal'
id='user-list-modal-dialog'
onExited={onClose}
show={true}
onHide={onClose}
compassDesign={true}
bodyPadding={false}
modalHeaderText={
<FormattedMessage
id='admin.jobTable.syncResults.userListTitle'
defaultMessage='Channel Membership Changes'
/>
}
modalSubheaderText={`${displayName} - (${channelId})`}
>
<div className='tabs'>
<button
className={`tab-button ${activeTab === 'added' ? 'active' : ''}`}
onClick={() => handleTabChange('added')}
>
<FormattedMessage
id='admin.jobTable.syncResults.added'
defaultMessage='Added ({count, number})'
values={{
count: syncResults.MembersAdded.length,
}}
/>
</button>
<button
className={`tab-button ${activeTab === 'removed' ? 'active' : ''}`}
onClick={() => handleTabChange('removed')}
>
<FormattedMessage
id='admin.jobTable.syncResults.removed'
defaultMessage='Removed ({count, number})'
values={{
count: syncResults.MembersRemoved.length,
}}
/>
</button>
</div>
<div className='tab-content'>
{activeTab === 'added' && (
<SyncedUserList
userIds={syncResults.MembersAdded}
noResultsMessageId='admin.jobTable.syncResults.noUsersAdded'
noResultsDefaultMessage='No users were added'
actions={{
getProfilesByIds,
}}
/>
)}
{activeTab === 'removed' && (
<SyncedUserList
userIds={syncResults.MembersRemoved}
noResultsMessageId='admin.jobTable.syncResults.noUsersRemoved'
noResultsDefaultMessage='No users were removed'
actions={{
getProfilesByIds,
}}
/>
)}
</div>
</GenericModal>
);
};
export default UserListModal;

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше