[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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
4b445cbf16
Коммит
a344b3225b
@@ -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
Обычный файл
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
Обычный файл
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))
|
||||
}
|
||||
}
|
||||
27
server/channels/api4/access_control_local.go
Обычный файл
27
server/channels/api4/access_control_local.go
Обычный файл
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (api *API) InitAccessControlPolicyLocal() {
|
||||
if !api.srv.Config().FeatureFlags.AttributeBasedAccessControl {
|
||||
return
|
||||
}
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("", api.APILocal(createAccessControlPolicy)).Methods(http.MethodPut)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APILocal(searchAccessControlPolicies)).Methods(http.MethodPost)
|
||||
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APILocal(checkExpression)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APILocal(testExpression)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APILocal(getFieldsAutocomplete)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APILocal(convertToVisualAST)).Methods(http.MethodPost)
|
||||
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(getAccessControlPolicy)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(deleteAccessControlPolicy)).Methods(http.MethodDelete)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APILocal(updateActiveStatus)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APILocal(assignAccessPolicy)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APILocal(unassignAccessPolicy)).Methods(http.MethodDelete)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APILocal(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APILocal(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost)
|
||||
}
|
||||
613
server/channels/api4/access_control_test.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
|
||||
}
|
||||
|
||||
253
server/channels/store/sqlstore/attributes_store.go
Обычный файл
253
server/channels/store/sqlstore/attributes_store.go
Обычный файл
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||
sq "github.com/mattermost/squirrel"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type SqlAttributesStore struct {
|
||||
*SqlStore
|
||||
metrics einterfaces.MetricsInterface
|
||||
|
||||
selectQueryBuilder sq.SelectBuilder
|
||||
}
|
||||
|
||||
func attributesSliceColumns(prefix ...string) []string {
|
||||
var p string
|
||||
if len(prefix) == 1 {
|
||||
p = prefix[0] + "."
|
||||
} else if len(prefix) > 1 {
|
||||
panic("cannot accept multiple prefixes")
|
||||
}
|
||||
|
||||
return []string{
|
||||
p + "TargetID as ID",
|
||||
p + "TargetType as Type",
|
||||
p + "Attributes",
|
||||
}
|
||||
}
|
||||
|
||||
func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.AttributesStore {
|
||||
s := &SqlAttributesStore{
|
||||
SqlStore: sqlStore,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...).From("AttributeView")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SqlAttributesStore) RefreshAttributes() error {
|
||||
if s.DriverName() == model.DatabaseDriverPostgres {
|
||||
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil {
|
||||
return errors.Wrap(err, "error refreshing materialized view AttributeView")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error) {
|
||||
query := s.selectQueryBuilder.Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}})
|
||||
|
||||
q, args, err := query.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to build query for subject")
|
||||
}
|
||||
|
||||
row := s.GetReplica().QueryRowxContext(rctx.Context(), q, args...)
|
||||
if err := row.Err(); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get subject")
|
||||
}
|
||||
|
||||
var subject model.Subject
|
||||
var properties []byte
|
||||
|
||||
if err := row.Scan(&subject.ID, &subject.Type, &properties); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("Attributes", ID)
|
||||
}
|
||||
return nil, errors.Wrap(err, "failed to scan subject row")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(properties, &subject.Attributes); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal attributes")
|
||||
}
|
||||
|
||||
return &subject, nil
|
||||
}
|
||||
|
||||
func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select(getUsersColumns()...).From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID").
|
||||
OrderBy("Users.Id ASC")
|
||||
|
||||
count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID")
|
||||
|
||||
if opts.Query != "" {
|
||||
query = query.Where(sq.Expr(opts.Query, opts.Args...))
|
||||
count = count.Where(sq.Expr(opts.Query, opts.Args...))
|
||||
}
|
||||
|
||||
argCount := len(opts.Args)
|
||||
|
||||
if opts.Limit > 0 {
|
||||
query = query.Limit(uint64(opts.Limit))
|
||||
} else if opts.Limit > MaxPerPage {
|
||||
query = query.Limit(uint64(MaxPerPage))
|
||||
}
|
||||
|
||||
if !opts.AllowInactive {
|
||||
query = query.Where("Users.DeleteAt = 0")
|
||||
count = count.Where("Users.DeleteAt = 0")
|
||||
}
|
||||
|
||||
if opts.TeamID != "" {
|
||||
argCount++
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = query.Where("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = ? AND DeleteAt = 0)", opts.TeamID)
|
||||
count = count.Where("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = ? AND DeleteAt = 0)", opts.TeamID)
|
||||
} else {
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
|
||||
count = count.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ExcludeChannelMembers != "" {
|
||||
argCount++
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = query.Where(sq.Expr("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = ?)", opts.ExcludeChannelMembers))
|
||||
} else {
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = $%d)", argCount), opts.ExcludeChannelMembers))
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Cursor.TargetID != "" {
|
||||
argCount++
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = query.Where(sq.Expr("TargetID > ?", opts.Cursor.TargetID))
|
||||
} else {
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("TargetID > $%d", argCount), opts.Cursor.TargetID))
|
||||
}
|
||||
}
|
||||
|
||||
searchFields := make([]string, 0, len(UserSearchTypeNames))
|
||||
for _, field := range UserSearchTypeNames {
|
||||
searchFields = append(searchFields, strings.Join([]string{"Users", field}, "."))
|
||||
}
|
||||
|
||||
if term := opts.Term; strings.TrimSpace(term) != "" {
|
||||
_, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount)
|
||||
_, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount)
|
||||
}
|
||||
|
||||
q, args, err := query.ToSql()
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(err, "failed to build query for subjects")
|
||||
}
|
||||
|
||||
users := []*model.User{}
|
||||
if err = s.GetReplica().Select(&users, q, args...); err != nil {
|
||||
return nil, 0, errors.Wrapf(err, "failed to find Users with term=%s and searchType=%v", opts.Term, searchFields)
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
u.Sanitize(map[string]bool{})
|
||||
}
|
||||
|
||||
var total int64
|
||||
|
||||
if !opts.IgnoreCount {
|
||||
err = s.GetReplica().GetBuilder(&total, count)
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrapf(err, "failed to count Users with term=%s and searchType=%v", opts.Term, searchFields)
|
||||
}
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select(channelMemberSliceColumns()...).From("ChannelMembers").LeftJoin("AttributeView ON ChannelMembers.UserId = AttributeView.TargetID").
|
||||
OrderBy("ChannelMembers.UserId ASC")
|
||||
|
||||
if opts.Query != "" {
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("(NOT (%s) OR AttributeView.TargetID IS NULL)", opts.Query), opts.Args...))
|
||||
}
|
||||
|
||||
argCount := len(opts.Args)
|
||||
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = query.Where(sq.Eq{"ChannelMembers.ChannelId": channelID})
|
||||
} else {
|
||||
argCount++
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.ChannelId = $%d", argCount), channelID))
|
||||
}
|
||||
|
||||
if opts.Limit > 0 {
|
||||
query = query.Limit(uint64(opts.Limit))
|
||||
} else if opts.Limit > MaxPerPage {
|
||||
query = query.Limit(uint64(MaxPerPage))
|
||||
}
|
||||
|
||||
if opts.Cursor.TargetID != "" {
|
||||
argCount++
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = query.Where(sq.Expr("ChannelMembers.UserId > ?", opts.Cursor.TargetID))
|
||||
} else {
|
||||
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.UserId > $%d", argCount), opts.Cursor.TargetID))
|
||||
}
|
||||
}
|
||||
|
||||
q, args, err := query.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to build query for subjects")
|
||||
}
|
||||
|
||||
members := []*model.ChannelMember{}
|
||||
if err := s.GetReplica().Select(&members, q, args...); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to find channel members with for channel id=%s", channelID)
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, isPostgreSQL bool, prevArgs int) (int, sq.SelectBuilder) {
|
||||
for _, term := range terms {
|
||||
searchFields := []string{}
|
||||
termArgs := []any{}
|
||||
for _, field := range fields {
|
||||
if isPostgreSQL {
|
||||
prevArgs++
|
||||
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs))
|
||||
} else {
|
||||
searchFields = append(searchFields, fmt.Sprintf("%s LIKE ? escape '*' ", field))
|
||||
}
|
||||
termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@")))
|
||||
}
|
||||
if isPostgreSQL {
|
||||
prevArgs++
|
||||
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs))
|
||||
} else {
|
||||
searchFields = append(searchFields, "Id = ?")
|
||||
}
|
||||
termArgs = append(termArgs, strings.TrimLeft(term, "@"))
|
||||
query = query.Where(fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")), termArgs...)
|
||||
}
|
||||
|
||||
return prevArgs, query
|
||||
}
|
||||
14
server/channels/store/sqlstore/attributes_store_test.go
Обычный файл
14
server/channels/store/sqlstore/attributes_store_test.go
Обычный файл
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||
)
|
||||
|
||||
func TestAttributesStore(t *testing.T) {
|
||||
StoreTestWithSqlStore(t, storetest.TestAttributesStore)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
282
server/channels/store/storetest/attributes_store.go
Обычный файл
282
server/channels/store/storetest/attributes_store.go
Обычный файл
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package storetest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testPropertyGroupName = "test_property_group"
|
||||
testPropertyA = "test_property_a"
|
||||
testPropertyB = "test_property_b"
|
||||
testPropertyValueA1 = "value_a1"
|
||||
testPropertyValueA2 = "value_a2"
|
||||
testPropertyValueB1 = "value_b1"
|
||||
)
|
||||
|
||||
var (
|
||||
testTeamID = model.NewId()
|
||||
)
|
||||
|
||||
func TestAttributesStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
t.Run("RefreshAndGet", func(t *testing.T) { testAttributesStoreRefresh(t, rctx, ss) })
|
||||
t.Run("SearchUsers", func(t *testing.T) { testAttributesStoreSearchUsers(t, rctx, ss, s) })
|
||||
}
|
||||
|
||||
func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.User, string, func()) {
|
||||
maxUsersPerTeam := 50
|
||||
|
||||
u1 := model.User{
|
||||
Email: MakeEmail(),
|
||||
Username: model.NewUsername(),
|
||||
}
|
||||
|
||||
_, err := ss.User().Save(rctx, &u1)
|
||||
require.NoError(t, err, "couldn't save user")
|
||||
|
||||
_, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: testTeamID, UserId: u1.Id}, maxUsersPerTeam)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
u2 := model.User{
|
||||
Email: MakeEmail(),
|
||||
Username: model.NewUsername(),
|
||||
}
|
||||
_, err = ss.User().Save(rctx, &u2)
|
||||
require.NoError(t, err, "couldn't save user")
|
||||
|
||||
_, nErr = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: testTeamID, UserId: u2.Id}, maxUsersPerTeam)
|
||||
require.NoError(t, nErr)
|
||||
|
||||
// user3 does not have any attributes
|
||||
u3 := model.User{
|
||||
Email: MakeEmail(),
|
||||
Username: model.NewUsername(),
|
||||
}
|
||||
|
||||
_, err = ss.User().Save(rctx, &u3)
|
||||
require.NoError(t, err, "couldn't save user")
|
||||
|
||||
// user3 does not have any attributes
|
||||
u4 := model.User{
|
||||
Email: MakeEmail(),
|
||||
Username: model.NewUsername(),
|
||||
}
|
||||
|
||||
_, err = ss.User().Save(rctx, &u4)
|
||||
require.NoError(t, err, "couldn't save user")
|
||||
|
||||
group, err := ss.PropertyGroup().Register(testPropertyGroupName)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, group.ID)
|
||||
require.Equal(t, testPropertyGroupName, group.Name)
|
||||
groupID := group.ID
|
||||
|
||||
fieldA, err := ss.PropertyField().Create(&model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: testPropertyA,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fieldB, err := ss.PropertyField().Create(&model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: testPropertyB,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
vala1, err := json.Marshal(testPropertyValueA1)
|
||||
require.NoError(t, err)
|
||||
vala2, err := json.Marshal(testPropertyValueA2)
|
||||
require.NoError(t, err)
|
||||
valab1, err := json.Marshal(testPropertyValueB1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pva1, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u1.Id,
|
||||
TargetType: "user",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pvb1, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u1.Id,
|
||||
TargetType: "user",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldB.ID,
|
||||
Value: valab1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pva2, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u2.Id,
|
||||
TargetType: "user",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pva3, err := ss.PropertyValue().Create(&model.PropertyValue{
|
||||
TargetID: u3.Id,
|
||||
TargetType: "user",
|
||||
GroupID: groupID,
|
||||
FieldID: fieldA.ID,
|
||||
Value: vala1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return []*model.User{&u1, &u2, &u3}, groupID, func() {
|
||||
for _, pv := range []*model.PropertyValue{pva1, pvb1, pva2, pva3} {
|
||||
dErr := ss.PropertyValue().Delete(groupID, pv.ID)
|
||||
require.NoError(t, dErr, "couldn't delete property value")
|
||||
}
|
||||
for _, field := range []*model.PropertyField{fieldA, fieldB} {
|
||||
dErr := ss.PropertyField().Delete(groupID, field.ID)
|
||||
require.NoError(t, dErr, "couldn't delete property field")
|
||||
}
|
||||
for _, u := range []*model.User{&u1, &u2, &u3, &u4} {
|
||||
dErr := ss.User().PermanentDelete(rctx, u.Id)
|
||||
require.NoError(t, dErr, "couldn't delete user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAttributesStoreRefresh(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
users, groupID, cleanup := createTestUsers(t, rctx, ss)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
t.Run("Refresh attributes", func(t *testing.T) {
|
||||
err := ss.Attributes().RefreshAttributes()
|
||||
require.NoError(t, err, "couldn't refresh attributes")
|
||||
|
||||
// Check if the attributes are set correctly
|
||||
for _, user := range users {
|
||||
subject, err := ss.Attributes().GetSubject(rctx, user.Id, groupID)
|
||||
require.NoError(t, err, "couldn't get subject")
|
||||
|
||||
require.Equal(t, user.Id, subject.ID)
|
||||
require.Equal(t, "user", subject.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get non-existing subject", func(t *testing.T) {
|
||||
subject, err := ss.Attributes().GetSubject(rctx, "non-existing-id", groupID)
|
||||
require.Error(t, err, "expected error when getting non-existing subject")
|
||||
require.IsType(t, &store.ErrNotFound{}, err, "expected not found error")
|
||||
require.Nil(t, subject, "expected nil subject for non-existing ID")
|
||||
})
|
||||
}
|
||||
|
||||
func testAttributesStoreSearchUsers(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
users, _, cleanup := createTestUsers(t, rctx, ss)
|
||||
t.Cleanup(cleanup)
|
||||
require.Len(t, users, 3, "expected 3 users")
|
||||
|
||||
err := ss.Attributes().RefreshAttributes()
|
||||
require.NoError(t, err, "couldn't refresh attributes")
|
||||
|
||||
t.Run("Search users without query", func(t *testing.T) {
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{})
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Len(t, subjects, 4, "expected 4 users")
|
||||
require.Equal(t, int64(4), count, "expected count 4 users")
|
||||
})
|
||||
|
||||
t.Run("Search users without query, limit by team", func(t *testing.T) {
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
|
||||
TeamID: testTeamID,
|
||||
})
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Len(t, subjects, 2, "expected 2 users")
|
||||
require.Equal(t, int64(2), count, "expected count 2 users")
|
||||
})
|
||||
|
||||
t.Run("Search users with a random value query", func(t *testing.T) {
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
|
||||
Query: "Attributes ->> '$." + testPropertyA + "' = ?",
|
||||
Args: []any{"random_value"},
|
||||
})
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Empty(t, subjects, "expected no users with the query")
|
||||
require.Equal(t, int64(0), count, "expected count 0 users")
|
||||
})
|
||||
|
||||
t.Run("Search users with a valid value query", func(t *testing.T) {
|
||||
var query string
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = "Attributes ->> '$." + testPropertyB + "' = ?"
|
||||
} else {
|
||||
query = "Attributes ->> '" + testPropertyB + "' = $1::text"
|
||||
}
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
|
||||
Query: query,
|
||||
Args: []any{testPropertyValueB1},
|
||||
})
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Len(t, subjects, 1, "expected 1 user with the query")
|
||||
require.Equal(t, subjects[0].Id, users[0].Id, "expected user ID to match")
|
||||
require.Equal(t, int64(1), count, "expected count 1 user")
|
||||
})
|
||||
|
||||
t.Run("Search users with a valid value query and limit", func(t *testing.T) {
|
||||
var query string
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = "Attributes ->> '$." + testPropertyA + "' = ?"
|
||||
} else {
|
||||
query = "Attributes ->> '" + testPropertyA + "' = $1::text"
|
||||
}
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
|
||||
Query: query,
|
||||
Args: []any{testPropertyValueA1},
|
||||
Limit: 1,
|
||||
})
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Len(t, subjects, 1, "expected 1 user with the query")
|
||||
if users[0].Id < users[2].Id {
|
||||
require.Equal(t, subjects[0].Id, users[0].Id, "expected user ID to match")
|
||||
} else {
|
||||
require.Equal(t, subjects[0].Id, users[2].Id, "expected user ID to match")
|
||||
}
|
||||
require.Equal(t, int64(2), count, "expected count 1 user")
|
||||
})
|
||||
|
||||
t.Run("Search users with pagination", func(t *testing.T) {
|
||||
var query string
|
||||
if s.DriverName() == model.DatabaseDriverMysql {
|
||||
query = "Attributes ->> '$." + testPropertyA + "' = ?"
|
||||
} else {
|
||||
query = "Attributes ->> '" + testPropertyA + "' = $1::text"
|
||||
}
|
||||
|
||||
cursor := strings.Repeat("0", 26)
|
||||
for i := 0; i < 5; i++ {
|
||||
subjects, count, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
|
||||
Query: query,
|
||||
Args: []any{testPropertyValueA1},
|
||||
Limit: 1,
|
||||
Cursor: model.SubjectCursor{
|
||||
TargetID: cursor,
|
||||
},
|
||||
})
|
||||
if len(subjects) == 0 {
|
||||
break
|
||||
}
|
||||
cursor = subjects[0].Id
|
||||
|
||||
require.NoError(t, err, "couldn't search users")
|
||||
require.Len(t, subjects, 1, "expected 1 user with the query")
|
||||
require.Equal(t, int64(2), count, "expected count 2 user with the query")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
145
server/channels/store/storetest/mocks/AttributesStore.go
Обычный файл
145
server/channels/store/storetest/mocks/AttributesStore.go
Обычный файл
@@ -0,0 +1,145 @@
|
||||
// Code generated by mockery v2.42.2. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// AttributesStore is an autogenerated mock type for the AttributesStore type
|
||||
type AttributesStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID, opts
|
||||
func (_m *AttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) {
|
||||
ret := _m.Called(rctx, channelID, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetChannelMembersToRemove")
|
||||
}
|
||||
|
||||
var r0 []*model.ChannelMember
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.ChannelMember, error)); ok {
|
||||
return rf(rctx, channelID, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.ChannelMember); ok {
|
||||
r0 = rf(rctx, channelID, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.ChannelMember)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) error); ok {
|
||||
r1 = rf(rctx, channelID, opts)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetSubject provides a mock function with given fields: rctx, ID, groupID
|
||||
func (_m *AttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) {
|
||||
ret := _m.Called(rctx, ID, groupID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetSubject")
|
||||
}
|
||||
|
||||
var r0 *model.Subject
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.Subject, error)); ok {
|
||||
return rf(rctx, ID, groupID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Subject); ok {
|
||||
r0 = rf(rctx, ID, groupID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok {
|
||||
r1 = rf(rctx, ID, groupID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// RefreshAttributes provides a mock function with given fields:
|
||||
func (_m *AttributesStore) RefreshAttributes() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RefreshAttributes")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SearchUsers provides a mock function with given fields: rctx, opts
|
||||
func (_m *AttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) {
|
||||
ret := _m.Called(rctx, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SearchUsers")
|
||||
}
|
||||
|
||||
var r0 []*model.User
|
||||
var r1 int64
|
||||
var r2 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, model.SubjectSearchOptions) ([]*model.User, int64, error)); ok {
|
||||
return rf(rctx, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, model.SubjectSearchOptions) []*model.User); ok {
|
||||
r0 = rf(rctx, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.User)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, model.SubjectSearchOptions) int64); ok {
|
||||
r1 = rf(rctx, opts)
|
||||
} else {
|
||||
r1 = ret.Get(1).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(request.CTX, model.SubjectSearchOptions) error); ok {
|
||||
r2 = rf(rctx, opts)
|
||||
} else {
|
||||
r2 = ret.Error(2)
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// NewAttributesStore creates a new instance of AttributesStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewAttributesStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *AttributesStore {
|
||||
mock := &AttributesStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -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
Обычный файл
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
Обычный файл
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()
|
||||
|
||||
402
server/einterfaces/mocks/AccessControlServiceInterface.go
Обычный файл
402
server/einterfaces/mocks/AccessControlServiceInterface.go
Обычный файл
@@ -0,0 +1,402 @@
|
||||
// Code generated by mockery v2.42.2. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make einterfaces-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// AccessControlServiceInterface is an autogenerated mock type for the AccessControlServiceInterface type
|
||||
type AccessControlServiceInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// AccessEvaluation provides a mock function with given fields: rctx, accessRequest
|
||||
func (_m *AccessControlServiceInterface) AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (model.AccessDecision, *model.AppError) {
|
||||
ret := _m.Called(rctx, accessRequest)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for AccessEvaluation")
|
||||
}
|
||||
|
||||
var r0 model.AccessDecision
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) (model.AccessDecision, *model.AppError)); ok {
|
||||
return rf(rctx, accessRequest)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, model.AccessRequest) model.AccessDecision); ok {
|
||||
r0 = rf(rctx, accessRequest)
|
||||
} else {
|
||||
r0 = ret.Get(0).(model.AccessDecision)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, model.AccessRequest) *model.AppError); ok {
|
||||
r1 = rf(rctx, accessRequest)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CheckExpression provides a mock function with given fields: rctx, expression
|
||||
func (_m *AccessControlServiceInterface) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CheckExpression")
|
||||
}
|
||||
|
||||
var r0 []model.CELExpressionError
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]model.CELExpressionError, *model.AppError)); ok {
|
||||
return rf(rctx, expression)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) []model.CELExpressionError); ok {
|
||||
r0 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]model.CELExpressionError)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeletePolicy provides a mock function with given fields: rctx, id
|
||||
func (_m *AccessControlServiceInterface) DeletePolicy(rctx request.CTX, id string) *model.AppError {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeletePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ExpressionToVisualAST provides a mock function with given fields: rctx, expression
|
||||
func (_m *AccessControlServiceInterface) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ExpressionToVisualAST")
|
||||
}
|
||||
|
||||
var r0 *model.VisualExpression
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.VisualExpression, *model.AppError)); ok {
|
||||
return rf(rctx, expression)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.VisualExpression); ok {
|
||||
r0 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.VisualExpression)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID
|
||||
func (_m *AccessControlServiceInterface) GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) {
|
||||
ret := _m.Called(rctx, channelID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetChannelMembersToRemove")
|
||||
}
|
||||
|
||||
var r0 []*model.ChannelMember
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.ChannelMember, *model.AppError)); ok {
|
||||
return rf(rctx, channelID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.ChannelMember); ok {
|
||||
r0 = rf(rctx, channelID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.ChannelMember)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, channelID)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPolicy provides a mock function with given fields: rctx, id
|
||||
func (_m *AccessControlServiceInterface) GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPolicyRuleAttributes provides a mock function with given fields: rctx, policyID, action
|
||||
func (_m *AccessControlServiceInterface) GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) {
|
||||
ret := _m.Called(rctx, policyID, action)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPolicyRuleAttributes")
|
||||
}
|
||||
|
||||
var r0 map[string][]string
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (map[string][]string, *model.AppError)); ok {
|
||||
return rf(rctx, policyID, action)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) map[string][]string); ok {
|
||||
r0 = rf(rctx, policyID, action)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[string][]string)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, policyID, action)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Init provides a mock function with given fields: rctx
|
||||
func (_m *AccessControlServiceInterface) Init(rctx request.CTX) *model.AppError {
|
||||
ret := _m.Called(rctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Init")
|
||||
}
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok {
|
||||
r0 = rf(rctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NormalizePolicy provides a mock function with given fields: rctx, policy
|
||||
func (_m *AccessControlServiceInterface) NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, policy)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for NormalizePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, policy)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok {
|
||||
r1 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// QueryUsersForExpression provides a mock function with given fields: rctx, expression, opts
|
||||
func (_m *AccessControlServiceInterface) QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for QueryUsersForExpression")
|
||||
}
|
||||
|
||||
var r0 []*model.User
|
||||
var r1 int64
|
||||
var r2 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok {
|
||||
return rf(rctx, expression, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.User); ok {
|
||||
r0 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.User)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) int64); ok {
|
||||
r1 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
r1 = ret.Get(1).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(request.CTX, string, model.SubjectSearchOptions) *model.AppError); ok {
|
||||
r2 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// QueryUsersForResource provides a mock function with given fields: rctx, resourceID, action, opts
|
||||
func (_m *AccessControlServiceInterface) QueryUsersForResource(rctx request.CTX, resourceID string, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
||||
ret := _m.Called(rctx, resourceID, action, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for QueryUsersForResource")
|
||||
}
|
||||
|
||||
var r0 []*model.User
|
||||
var r1 int64
|
||||
var r2 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok {
|
||||
return rf(rctx, resourceID, action, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) []*model.User); ok {
|
||||
r0 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.User)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.SubjectSearchOptions) int64); ok {
|
||||
r1 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
r1 = ret.Get(1).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.SubjectSearchOptions) *model.AppError); ok {
|
||||
r2 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// SavePolicy provides a mock function with given fields: rctx, policy
|
||||
func (_m *AccessControlServiceInterface) SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, policy)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SavePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, policy)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok {
|
||||
r1 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewAccessControlServiceInterface creates a new instance of AccessControlServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewAccessControlServiceInterface(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *AccessControlServiceInterface {
|
||||
mock := &AccessControlServiceInterface{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
71
server/einterfaces/mocks/AccessControlSyncJobInterface.go
Обычный файл
71
server/einterfaces/mocks/AccessControlSyncJobInterface.go
Обычный файл
@@ -0,0 +1,71 @@
|
||||
// Code generated by mockery v2.42.2. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make einterfaces-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// AccessControlSyncJobInterface is an autogenerated mock type for the AccessControlSyncJobInterface type
|
||||
type AccessControlSyncJobInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// MakeScheduler provides a mock function with given fields:
|
||||
func (_m *AccessControlSyncJobInterface) MakeScheduler() jobs.Scheduler {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for MakeScheduler")
|
||||
}
|
||||
|
||||
var r0 jobs.Scheduler
|
||||
if rf, ok := ret.Get(0).(func() jobs.Scheduler); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(jobs.Scheduler)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MakeWorker provides a mock function with given fields:
|
||||
func (_m *AccessControlSyncJobInterface) MakeWorker() model.Worker {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for MakeWorker")
|
||||
}
|
||||
|
||||
var r0 model.Worker
|
||||
if rf, ok := ret.Get(0).(func() model.Worker); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(model.Worker)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NewAccessControlSyncJobInterface creates a new instance of AccessControlSyncJobInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewAccessControlSyncJobInterface(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *AccessControlSyncJobInterface {
|
||||
mock := &AccessControlSyncJobInterface{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
372
server/einterfaces/mocks/PolicyAdministrationPointInterface.go
Обычный файл
372
server/einterfaces/mocks/PolicyAdministrationPointInterface.go
Обычный файл
@@ -0,0 +1,372 @@
|
||||
// Code generated by mockery v2.42.2. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make einterfaces-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// PolicyAdministrationPointInterface is an autogenerated mock type for the PolicyAdministrationPointInterface type
|
||||
type PolicyAdministrationPointInterface struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CheckExpression provides a mock function with given fields: rctx, expression
|
||||
func (_m *PolicyAdministrationPointInterface) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CheckExpression")
|
||||
}
|
||||
|
||||
var r0 []model.CELExpressionError
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]model.CELExpressionError, *model.AppError)); ok {
|
||||
return rf(rctx, expression)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) []model.CELExpressionError); ok {
|
||||
r0 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]model.CELExpressionError)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeletePolicy provides a mock function with given fields: rctx, id
|
||||
func (_m *PolicyAdministrationPointInterface) DeletePolicy(rctx request.CTX, id string) *model.AppError {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeletePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ExpressionToVisualAST provides a mock function with given fields: rctx, expression
|
||||
func (_m *PolicyAdministrationPointInterface) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ExpressionToVisualAST")
|
||||
}
|
||||
|
||||
var r0 *model.VisualExpression
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.VisualExpression, *model.AppError)); ok {
|
||||
return rf(rctx, expression)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.VisualExpression); ok {
|
||||
r0 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.VisualExpression)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, expression)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetChannelMembersToRemove provides a mock function with given fields: rctx, channelID
|
||||
func (_m *PolicyAdministrationPointInterface) GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) {
|
||||
ret := _m.Called(rctx, channelID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetChannelMembersToRemove")
|
||||
}
|
||||
|
||||
var r0 []*model.ChannelMember
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.ChannelMember, *model.AppError)); ok {
|
||||
return rf(rctx, channelID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.ChannelMember); ok {
|
||||
r0 = rf(rctx, channelID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.ChannelMember)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, channelID)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPolicy provides a mock function with given fields: rctx, id
|
||||
func (_m *PolicyAdministrationPointInterface) GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPolicyRuleAttributes provides a mock function with given fields: rctx, policyID, action
|
||||
func (_m *PolicyAdministrationPointInterface) GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) {
|
||||
ret := _m.Called(rctx, policyID, action)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPolicyRuleAttributes")
|
||||
}
|
||||
|
||||
var r0 map[string][]string
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (map[string][]string, *model.AppError)); ok {
|
||||
return rf(rctx, policyID, action)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) map[string][]string); ok {
|
||||
r0 = rf(rctx, policyID, action)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(map[string][]string)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
|
||||
r1 = rf(rctx, policyID, action)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Init provides a mock function with given fields: rctx
|
||||
func (_m *PolicyAdministrationPointInterface) Init(rctx request.CTX) *model.AppError {
|
||||
ret := _m.Called(rctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Init")
|
||||
}
|
||||
|
||||
var r0 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok {
|
||||
r0 = rf(rctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NormalizePolicy provides a mock function with given fields: rctx, policy
|
||||
func (_m *PolicyAdministrationPointInterface) NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, policy)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for NormalizePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, policy)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok {
|
||||
r1 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// QueryUsersForExpression provides a mock function with given fields: rctx, expression, opts
|
||||
func (_m *PolicyAdministrationPointInterface) QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
||||
ret := _m.Called(rctx, expression, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for QueryUsersForExpression")
|
||||
}
|
||||
|
||||
var r0 []*model.User
|
||||
var r1 int64
|
||||
var r2 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok {
|
||||
return rf(rctx, expression, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, model.SubjectSearchOptions) []*model.User); ok {
|
||||
r0 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.User)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, model.SubjectSearchOptions) int64); ok {
|
||||
r1 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
r1 = ret.Get(1).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(request.CTX, string, model.SubjectSearchOptions) *model.AppError); ok {
|
||||
r2 = rf(rctx, expression, opts)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// QueryUsersForResource provides a mock function with given fields: rctx, resourceID, action, opts
|
||||
func (_m *PolicyAdministrationPointInterface) QueryUsersForResource(rctx request.CTX, resourceID string, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
|
||||
ret := _m.Called(rctx, resourceID, action, opts)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for QueryUsersForResource")
|
||||
}
|
||||
|
||||
var r0 []*model.User
|
||||
var r1 int64
|
||||
var r2 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError)); ok {
|
||||
return rf(rctx, resourceID, action, opts)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.SubjectSearchOptions) []*model.User); ok {
|
||||
r0 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.User)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.SubjectSearchOptions) int64); ok {
|
||||
r1 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
r1 = ret.Get(1).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.SubjectSearchOptions) *model.AppError); ok {
|
||||
r2 = rf(rctx, resourceID, action, opts)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// SavePolicy provides a mock function with given fields: rctx, policy
|
||||
func (_m *PolicyAdministrationPointInterface) SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) {
|
||||
ret := _m.Called(rctx, policy)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SavePolicy")
|
||||
}
|
||||
|
||||
var r0 *model.AccessControlPolicy
|
||||
var r1 *model.AppError
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok {
|
||||
return rf(rctx, policy)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.AccessControlPolicy) *model.AccessControlPolicy); ok {
|
||||
r0 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.AccessControlPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.AccessControlPolicy) *model.AppError); ok {
|
||||
r1 = rf(rctx, policy)
|
||||
} else {
|
||||
if ret.Get(1) != nil {
|
||||
r1 = ret.Get(1).(*model.AppError)
|
||||
}
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewPolicyAdministrationPointInterface creates a new instance of PolicyAdministrationPointInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewPolicyAdministrationPointInterface(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *PolicyAdministrationPointInterface {
|
||||
mock := &PolicyAdministrationPointInterface{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -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
Обычный файл
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
Обычный файл
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 `user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n `user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n `user.attributes.Email.endsWith("example.com")`\n- To combine conditions (for this example with `OR` operator, altertanitvely use `&&` for `AND` operation):\n `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;
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user