mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Merge branch 'master' into MM-68900-abac-masking-add-e2e-back
This commit is contained in:
commit
6fab7fb112
379 changed files with 44510 additions and 10857 deletions
27
.github/scripts/check_config_changes_ci.py
vendored
27
.github/scripts/check_config_changes_ci.py
vendored
|
|
@ -127,6 +127,25 @@ def file_at(ref: str, path: str) -> str:
|
|||
).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
def _compute_merge_base() -> str:
|
||||
"""Resolve the merge-base of BASE_SHA and HEAD_SHA.
|
||||
|
||||
Per-checker comparisons must use this rather than BASE_SHA. BASE_SHA is the
|
||||
tip of the target branch at PR-event time; if that branch advances on a
|
||||
watched file after the PR diverges, comparing branch-tip vs target-tip
|
||||
would attribute those upstream edits to this PR (false add/remove).
|
||||
`git diff A...B` already does this implicitly; the per-file snapshots must
|
||||
match.
|
||||
"""
|
||||
return subprocess.run(
|
||||
["git", "merge-base", BASE_SHA, HEAD_SHA],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
MERGE_BASE = _compute_merge_base()
|
||||
|
||||
|
||||
# ── Checker 1 — config.go ──────────────────────────────────────────────────────
|
||||
|
|
@ -184,7 +203,7 @@ def check_config(patches: dict[str, str]) -> CheckResult:
|
|||
if _CONFIG_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_fields = _scan_struct_fields(file_at(BASE_SHA, _CONFIG_PATH))
|
||||
base_fields = _scan_struct_fields(file_at(MERGE_BASE, _CONFIG_PATH))
|
||||
head_fields = _scan_struct_fields(file_at(HEAD_SHA, _CONFIG_PATH))
|
||||
|
||||
added = head_fields - base_fields
|
||||
|
|
@ -271,7 +290,7 @@ def check_api(patches: dict[str, str]) -> CheckResult:
|
|||
removed_eps: set[tuple[str, str, str]] = set()
|
||||
|
||||
for fname, patch in api4_patches.items():
|
||||
base_eps = _parse_endpoints(file_at(BASE_SHA, fname))
|
||||
base_eps = _parse_endpoints(file_at(MERGE_BASE, fname))
|
||||
head_eps = _parse_endpoints(file_at(HEAD_SHA, fname))
|
||||
added_eps |= head_eps - base_eps
|
||||
removed_eps |= base_eps - head_eps
|
||||
|
|
@ -308,7 +327,7 @@ def check_audit_events(patches: dict[str, str]) -> CheckResult:
|
|||
if _AUDIT_EVENT_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_events = _parse_audit_events(file_at(BASE_SHA, _AUDIT_EVENT_PATH))
|
||||
base_events = _parse_audit_events(file_at(MERGE_BASE, _AUDIT_EVENT_PATH))
|
||||
head_events = _parse_audit_events(file_at(HEAD_SHA, _AUDIT_EVENT_PATH))
|
||||
|
||||
result.additions = sorted(f"`{e}`" for e in head_events - base_events)
|
||||
|
|
@ -343,7 +362,7 @@ def check_go_version(patches: dict[str, str]) -> CheckResult:
|
|||
if _DOCKERFILE_PATH not in patches:
|
||||
return result
|
||||
|
||||
old_ver = _parse_go_version(file_at(BASE_SHA, _DOCKERFILE_PATH))
|
||||
old_ver = _parse_go_version(file_at(MERGE_BASE, _DOCKERFILE_PATH))
|
||||
new_ver = _parse_go_version(file_at(HEAD_SHA, _DOCKERFILE_PATH))
|
||||
|
||||
if old_ver and new_ver and old_ver != new_ver:
|
||||
|
|
|
|||
33
.github/workflows/build-opensearch-image.yml
vendored
33
.github/workflows/build-opensearch-image.yml
vendored
|
|
@ -1,33 +0,0 @@
|
|||
name: Opensearch Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- server/build/Dockerfile.opensearch
|
||||
- .github/workflows/build-opensearch-image.yml
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: opensearch/checkout-repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: opensearch/docker-login
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
|
||||
|
||||
- name: opensearch/build-and-push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
provenance: false
|
||||
file: server/build/Dockerfile.opensearch
|
||||
push: true
|
||||
pull: true
|
||||
tags: mattermostdevelopment/mattermost-opensearch:2.7.0
|
||||
build-args: |
|
||||
OPENSEARCH_VERSION=2.7.0
|
||||
16
.github/workflows/server-ci.yml
vendored
16
.github/workflows/server-ci.yml
vendored
|
|
@ -5,6 +5,7 @@
|
|||
# If you rename this workflow, be sure to update those workflows as well.
|
||||
name: Server CI
|
||||
on:
|
||||
workflow_dispatch: # Allow manual/API triggering for linked plugin CI
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
|
@ -264,6 +265,21 @@ jobs:
|
|||
elasticsearch-version: "8.9.0"
|
||||
test-target: "test-server-elasticsearch"
|
||||
|
||||
test-opensearch-v2:
|
||||
name: OpenSearch v2 Compatibility
|
||||
needs: go
|
||||
uses: ./.github/workflows/server-test-template.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
name: OpenSearch v2 Compatibility
|
||||
datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
|
||||
drivername: postgres
|
||||
logsartifact: opensearch-v2-server-test-logs
|
||||
go-version: ${{ needs.go.outputs.version }}
|
||||
fips-enabled: false
|
||||
opensearch-version: "2.19.0"
|
||||
test-target: "test-server-opensearch"
|
||||
|
||||
# FIPS tests: run on PRs when go.mod changed or branch name contains "fips".
|
||||
# Sharded for fast iteration. Weekly workflow provides regular full coverage.
|
||||
test-postgres-normal-fips:
|
||||
|
|
|
|||
5
.github/workflows/server-test-template.yml
vendored
5
.github/workflows/server-test-template.yml
vendored
|
|
@ -37,6 +37,10 @@ on:
|
|||
required: false
|
||||
type: string
|
||||
default: "9.0.0"
|
||||
opensearch-version:
|
||||
required: false
|
||||
type: string
|
||||
default: "3.0.0"
|
||||
test-target:
|
||||
required: false
|
||||
type: string
|
||||
|
|
@ -122,6 +126,7 @@ jobs:
|
|||
- name: Run docker compose
|
||||
env:
|
||||
ELASTICSEARCH_VERSION: ${{ inputs.elasticsearch-version }}
|
||||
OPENSEARCH_VERSION: ${{ inputs.opensearch-version }}
|
||||
POSTGRES_PASSWORD: ${{ inputs.fips-enabled && 'mostest-fips-test' || 'mostest' }}
|
||||
run: |
|
||||
cd server/build
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
module github.com/mattermost/mattermost/api/internal
|
||||
|
||||
go 1.20
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/pb33f/libopenapi v0.9.6
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e
|
||||
github.com/pb33f/libopenapi v0.36.4
|
||||
golang.org/x/tools v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/net v0.2.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.2.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/pb33f/jsonpath v0.8.2 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,137 +1,28 @@
|
|||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
|
||||
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pb33f/libopenapi v0.9.6 h1:PqNqdBk0lqr/luxDLv8HPKFEJ4i0zf/hpyXqQ4r8jbM=
|
||||
github.com/pb33f/libopenapi v0.9.6/go.mod h1:8lr9sjsI5uZxtiEvHgg1A9/p/70briQ5WUGoJiuTFPc=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
|
||||
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
|
||||
github.com/pb33f/libopenapi v0.36.4 h1:oGDGjHpCyaj55RG0i0TLB3N3MEGIsGsM1aD7iInfZ8A=
|
||||
github.com/pb33f/libopenapi v0.36.4/go.mod h1:MsDdUlQ1CdrIDO5v26JfgBxQs7kcaOUEpMP3EqU6bI4=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e h1:4nW4NLDYnU28ojHaHO8OVxFHk/aQ33U01a9cjED+pzE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import (
|
|||
|
||||
"github.com/pb33f/libopenapi"
|
||||
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
|
||||
"github.com/pb33f/libopenapi/orderedmap"
|
||||
"go.yaml.in/yaml/v4"
|
||||
"golang.org/x/tools/imports"
|
||||
)
|
||||
|
||||
|
|
@ -52,23 +54,17 @@ func main() {
|
|||
log.Fatalf("Failed to parse OpenAPI spec: %s", err)
|
||||
}
|
||||
|
||||
v3Model, errors := document.BuildV3Model()
|
||||
if len(errors) > 0 {
|
||||
for i := range errors {
|
||||
log.Printf("error: %s\n", errors[i])
|
||||
}
|
||||
log.Fatalf("cannot create v3 model from document: %d errors reported", len(errors))
|
||||
v3Model, err := document.BuildV3Model()
|
||||
if err != nil {
|
||||
log.Fatalf("cannot create v3 model from document: %s", err)
|
||||
}
|
||||
|
||||
applyExamples(v3Model, exampleTmpl)
|
||||
|
||||
// Re-render the file with the injected examples.
|
||||
newDocument, _, _, errors := document.RenderAndReload()
|
||||
if len(errors) > 0 {
|
||||
for _, err := range errors {
|
||||
log.Printf("error: %s\n", err)
|
||||
}
|
||||
log.Fatalf("cannot render document: %d errors reported", len(errors))
|
||||
newDocument, _, _, err := document.RenderAndReload()
|
||||
if err != nil {
|
||||
log.Fatalf("cannot render document: %s", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filename, newDocument, 0644)
|
||||
|
|
@ -83,7 +79,7 @@ func applyExamples(v3Model *libopenapi.DocumentModel[v3high.Document], tmpl *tem
|
|||
log.Fatalf("Failed to parse example funcs: %s", err)
|
||||
}
|
||||
|
||||
for _, path := range v3Model.Model.Paths.PathItems {
|
||||
for path := range v3Model.Model.Paths.PathItems.ValuesFromOldest() {
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Get)
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Post)
|
||||
applyExample(tmpl, fileSet, modelFuncs, path.Delete)
|
||||
|
|
@ -149,15 +145,22 @@ func applyExample(tmpl *template.Template, fileSet *token.FileSet, exampleFuncs
|
|||
}
|
||||
|
||||
// Inject the resulting code sample
|
||||
operation.Extensions["x-codeSamples"] = []struct {
|
||||
Lang string
|
||||
Source string
|
||||
}{
|
||||
{
|
||||
Lang: "Go",
|
||||
Source: string(example),
|
||||
},
|
||||
type codeSample struct {
|
||||
Lang string `yaml:"lang"`
|
||||
Source string `yaml:"source"`
|
||||
}
|
||||
yamlBytes, err := yaml.Marshal([]codeSample{{Lang: "Go", Source: string(example)}})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshal x-codeSamples: %v", err)
|
||||
}
|
||||
var samplesNode yaml.Node
|
||||
if err := yaml.Unmarshal(yamlBytes, &samplesNode); err != nil {
|
||||
log.Fatalf("failed to create yaml node for x-codeSamples: %v", err)
|
||||
}
|
||||
if operation.Extensions == nil {
|
||||
operation.Extensions = orderedmap.New[string, *yaml.Node]()
|
||||
}
|
||||
operation.Extensions.Set("x-codeSamples", samplesNode.Content[0])
|
||||
}
|
||||
|
||||
type modelFunc struct {
|
||||
|
|
|
|||
|
|
@ -144,6 +144,57 @@
|
|||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
/api/v4/access_control_policies/cel/simulate_users:
|
||||
post:
|
||||
tags:
|
||||
- access control
|
||||
summary: Simulate an access control policy decision for an explicit user list
|
||||
description: |
|
||||
Runs the dual-lane PDP simulation against a draft (unsaved) access
|
||||
control policy for an explicit set of users (with optional per-user
|
||||
session-attribute overrides). The server compiles the draft
|
||||
in-memory, layers on persisted higher-scoped permission policies,
|
||||
and returns per-user, per-action ALLOW/DENY decisions plus blame
|
||||
attribution for any deny.
|
||||
|
||||
Backs the picker-driven "Simulate access" UX in the System Console
|
||||
and Channel Settings so authors can see how a draft interacts with
|
||||
persisted higher-scoped policies before saving.
|
||||
|
||||
Gated by the `PermissionPolicies` feature flag and the Enterprise
|
||||
Advanced license. Returns 501 (Not Implemented) when either is
|
||||
missing.
|
||||
|
||||
##### Permissions
|
||||
Must have the `manage_system` permission, OR be a team admin with
|
||||
`manage_team_access_rules` on the request's `team_id` (when any
|
||||
provided `channel_id` resolves to a channel in that team), OR be a
|
||||
channel admin with `manage_channel_access_rules` on the request's
|
||||
`channel_id`.
|
||||
operationId: SimulateAccessControlPolicyForUsers
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PolicySimulationByUsersParams"
|
||||
responses:
|
||||
"200":
|
||||
description: Per-user, per-action simulation results.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PolicySimulationResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
/api/v4/access_control_policies/search:
|
||||
post:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -2963,43 +2963,6 @@
|
|||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
"/api/v4/sharedchannels/{channel_id}/remotes":
|
||||
get:
|
||||
tags:
|
||||
- channels
|
||||
summary: Get remote clusters for a shared channel
|
||||
description: |
|
||||
Gets the remote clusters information for a shared channel.
|
||||
|
||||
__Minimum server version__: 10.10
|
||||
|
||||
##### Permissions
|
||||
Must be authenticated and have the `read_channel` permission for the channel.
|
||||
operationId: GetSharedChannelRemotes
|
||||
parameters:
|
||||
- name: channel_id
|
||||
in: path
|
||||
description: Channel GUID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Remote clusters retrieval successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RemoteClusterInfo"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"/api/v4/channels/{channel_id}/common_teams":
|
||||
get:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -4820,6 +4820,261 @@ components:
|
|||
total_count:
|
||||
type: integer
|
||||
description: The total number of users affected.
|
||||
PolicySimulationUserOverride:
|
||||
type: object
|
||||
description: |
|
||||
Per-user payload for the picker-driven `/cel/simulate_users` endpoint.
|
||||
The simulator resolves each user's profile attributes from CPA storage
|
||||
and then layers session context on top: first the requesting admin's
|
||||
active-session snapshot (when `use_active_session` is true), then the
|
||||
explicit `session_overrides` map.
|
||||
required:
|
||||
- user_id
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
description: ID of the user to evaluate the draft policy against.
|
||||
use_active_session:
|
||||
type: boolean
|
||||
description: |
|
||||
When true, inject the requesting admin's `session.*` attributes
|
||||
(network_status, device_managed, ip_range, etc.) into this user's
|
||||
evaluation context. Forward-compatible with future PDP work that
|
||||
populates session attributes on the request context.
|
||||
session_overrides:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
Replaces individual `session.*` attributes for this user only.
|
||||
Applied on top of the active-session snapshot when both are set,
|
||||
so a "configure session" panel can shadow specific values without
|
||||
discarding the rest of the active session.
|
||||
PolicySimulationByUsersParams:
|
||||
type: object
|
||||
description: |
|
||||
Request body for `/access_control_policies/cel/simulate_users`. The
|
||||
draft policy is compiled in-memory only — nothing is persisted.
|
||||
required:
|
||||
- policy
|
||||
- actions
|
||||
- users
|
||||
properties:
|
||||
policy:
|
||||
$ref: "#/components/schemas/AccessControlPolicy"
|
||||
actions:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
description: |
|
||||
Permission actions to simulate (e.g. `upload_file_attachment`,
|
||||
`download_file_attachment`). At least one action is required —
|
||||
the picker UX only makes sense once an action is in scope. The
|
||||
backend rejects empty arrays with `app.pap.simulate.missing_actions`
|
||||
(HTTP 400); `minItems` lets OpenAPI tooling catch that earlier
|
||||
on the client.
|
||||
rule_name:
|
||||
type: string
|
||||
description: |
|
||||
Identifies which rule in `policy.rules` the author is editing
|
||||
(used for blame attribution). When set, denies originating from
|
||||
this rule are tagged `source=this_rule`; other denies in the same
|
||||
draft are tagged `source=sibling_rule`.
|
||||
channel_id:
|
||||
type: string
|
||||
description: |
|
||||
Provides resource context for delegated channel admins and for
|
||||
resource-lane evaluation when `policy.type == "channel"`.
|
||||
team_id:
|
||||
type: string
|
||||
description: Provides team context for team-level delegated admins.
|
||||
users:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationUserOverride"
|
||||
description: |
|
||||
Explicit user list to evaluate, with per-user session-attribute
|
||||
overrides. At least one user is required (the backend rejects
|
||||
empty arrays with `app.pap.simulate.missing_users`).
|
||||
evaluation_scope:
|
||||
type: string
|
||||
enum: [all, this_rule]
|
||||
description: |
|
||||
Selects whether the simulator considers only the rule under
|
||||
simulation (`this_rule`) or co-evaluates every contributing
|
||||
program (`all`). Empty defaults to `this_rule` on the server.
|
||||
`this_rule` is the authoring-time "what does this rule alone
|
||||
do?" view: useful for iterating on a single rule without
|
||||
sibling rules shadowing or compensating for it. `all` mirrors
|
||||
the live PDP at request time.
|
||||
PolicySimulationBlame:
|
||||
type: object
|
||||
description: |
|
||||
Attributes a deny decision back to the rule or policy that caused it.
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- this_rule
|
||||
- sibling_rule
|
||||
- sibling_saved
|
||||
- peer_policy
|
||||
- system_permission
|
||||
- channel_policy
|
||||
- no_applicable_policy
|
||||
description: |
|
||||
Origin of the blamed contribution.
|
||||
* `this_rule` — the rule the author is currently editing.
|
||||
* `sibling_rule` — another rule in the same draft policy.
|
||||
* `sibling_saved` — recorded on ALLOW decisions where the
|
||||
author's own rule alone would have denied; a sibling rule
|
||||
flipped the verdict.
|
||||
* `peer_policy` — a different policy at the SAME scope as the
|
||||
draft. Carries `expression` / `evaluation_tree`.
|
||||
* `system_permission` — a higher-scoped system permission policy.
|
||||
Expression / tree are stripped to avoid leaking the contents
|
||||
of policies outside the editing scope.
|
||||
* `channel_policy` — a higher-scoped channel policy. Same
|
||||
privacy stripping as `system_permission`.
|
||||
* `no_applicable_policy` — no policy at any scope contributed a
|
||||
decision (vacuous allow).
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
- deny
|
||||
- allow
|
||||
description: |
|
||||
Per-blame verdict. Most blame entries describe the deny that
|
||||
produced the overall decision (`deny`); the simulator also
|
||||
emits informational `allow` entries so the picker can show
|
||||
"your draft rule allowed this user" alongside any peer
|
||||
policies that actually caused the deny. Consumers that only
|
||||
care about deny attribution should filter to `deny`. Empty
|
||||
(omitted) is treated as `deny` for backward compatibility
|
||||
with simulator builds that pre-date this field.
|
||||
policy_id:
|
||||
type: string
|
||||
description: |
|
||||
ID of the contributing policy. Empty when the deny originated
|
||||
from the draft itself (no persisted ID exists yet).
|
||||
policy_name:
|
||||
type: string
|
||||
description: Human-readable name of the contributing policy.
|
||||
rule_name:
|
||||
type: string
|
||||
description: Name of the contributing rule.
|
||||
role:
|
||||
type: string
|
||||
description: |
|
||||
Scoped role (`system_admin` / `system_user` / `channel_admin` /
|
||||
…) the contributing rule targets. Useful for explaining
|
||||
role-chain fallbacks.
|
||||
expression:
|
||||
type: string
|
||||
description: |
|
||||
CEL text of the contributing rule. Only populated for blame
|
||||
entries at the draft's own scope (`this_rule`, `sibling_rule`,
|
||||
`sibling_saved`, `peer_policy`).
|
||||
evaluation_tree:
|
||||
type: object
|
||||
description: |
|
||||
Recursive per-node breakdown of the contributing rule, mirroring
|
||||
the boolean shape of the CEL expression's AST. Same scope-privacy
|
||||
rule as `expression`. The picker renders it as a structured
|
||||
AND/OR/NOT tree highlighting which sub-expression(s) drove the
|
||||
deny.
|
||||
PolicySimulationActionDecision:
|
||||
type: object
|
||||
description: Per-action verdict for one user (or one session).
|
||||
properties:
|
||||
decision:
|
||||
type: boolean
|
||||
description: |
|
||||
`true` means ALLOW, `false` means DENY. Pending evaluations
|
||||
(rare) surface as `false` paired with an empty `blame` array.
|
||||
blame:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationBlame"
|
||||
description: |
|
||||
Ordered blame entries for the decision. The first entry is the
|
||||
primary blame (what the picker renders on the chip); subsequent
|
||||
entries describe other contributing policies the user can drill
|
||||
into via the "Decision details" view.
|
||||
PolicySimulationSession:
|
||||
type: object
|
||||
description: Per-session verdict for one user.
|
||||
properties:
|
||||
device:
|
||||
type: string
|
||||
network:
|
||||
type: string
|
||||
last_active_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Last-active timestamp in milliseconds since epoch.
|
||||
decisions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/PolicySimulationActionDecision"
|
||||
description: Per-action verdicts for this specific session.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
Session-attribute snapshot used when evaluating this session
|
||||
(network_status, device_managed, ip_range, etc.). Surfaced in
|
||||
the per-row "Decision details" view.
|
||||
PolicySimulationUserResult:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/components/schemas/User"
|
||||
decisions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/PolicySimulationActionDecision"
|
||||
description: |
|
||||
Per-action verdicts for the user. When `sessions` is populated
|
||||
this represents the "headline" decision (e.g. from the
|
||||
most-recently-active session) so the picker can render a
|
||||
single chip without consulting `sessions`.
|
||||
sessions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationSession"
|
||||
description: |
|
||||
Optional per-session breakdown. When populated the picker
|
||||
renders a Recent activity expand row revealing one decision
|
||||
chip per session. Empty/undefined falls back to a single
|
||||
user-level chip.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: |
|
||||
User profile attribute snapshot used when evaluating this user
|
||||
(department, region, clearance, etc.).
|
||||
PolicySimulationResponse:
|
||||
type: object
|
||||
description: |
|
||||
Body returned by `/cel/simulate_users`. Per-user, per-action
|
||||
verdicts plus blame attribution for any deny.
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PolicySimulationUserResult"
|
||||
total:
|
||||
type: integer
|
||||
format: int64
|
||||
description: |
|
||||
Total number of users evaluated (matches `results.length` for
|
||||
the picker endpoint since the caller supplies the user list
|
||||
explicitly).
|
||||
ChannelSearch: # Added based on dataretention.yaml and access_control.go usage
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -47,21 +47,21 @@
|
|||
description: The ID of the target
|
||||
permission_field:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for editing the field definition.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
default: member
|
||||
permission_values:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for setting values on objects.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
default: member
|
||||
permission_options:
|
||||
type: string
|
||||
enum: [none, sysadmin, member]
|
||||
enum: [none, sysadmin, member, admin]
|
||||
description: >
|
||||
Permission level for managing options on select/multiselect fields.
|
||||
Only system admins can set this; ignored for non-admin users.
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ import {AdminConfig} from '@mattermost/types/config';
|
|||
* Note: This test requires Enterprise license to be uploaded
|
||||
*/
|
||||
const testSamlMetadataUrl = 'http://test_saml_metadata_url';
|
||||
const testSamlMetadataSuccessUrl = 'http://test_saml_metadata_success_url';
|
||||
const testIdpURL = 'http://test_idp_url';
|
||||
const testIdpDescriptorURL = 'http://test_idp_descriptor_url';
|
||||
const testFetchedIdpURL = 'http://test_fetched_idp_url';
|
||||
const testFetchedIdpDescriptorURL = 'http://test_fetched_idp_descriptor_url';
|
||||
const testIdpPublicCertificate = 'MIICozCCAYsCBgGNzWfMwjANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptYXR0ZXJtb3N0';
|
||||
const getSamlMetadataErrorMessage = 'SAML Metadata URL did not connect and pull data successfully';
|
||||
const getSamlMetadataSuccessMessage = 'SAML Metadata retrieved successfully. Two fields and one certificate have been updated';
|
||||
|
||||
let config: AdminConfig;
|
||||
|
||||
|
|
@ -82,4 +87,58 @@ describe('SystemConsole->SAML 2.0 - Get Metadata from Idp Flow', () => {
|
|||
// * Verify that we can successfully save the settings (we have not affected previous state)
|
||||
cy.get('#saveSetting').click();
|
||||
});
|
||||
|
||||
it('fetches metadata and sets the IdP certificate from Idp Metadata Url', () => {
|
||||
cy.apiUpdateConfig({
|
||||
SamlSettings: {
|
||||
Enable: true,
|
||||
IdpMetadataURL: testSamlMetadataSuccessUrl,
|
||||
IdpURL: testIdpURL,
|
||||
IdpDescriptorURL: testIdpDescriptorURL,
|
||||
AssertionConsumerServiceURL: Cypress.config('baseUrl') + '/login/sso/saml',
|
||||
ServiceProviderIdentifier: Cypress.config('baseUrl') + '/login/sso/saml',
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit('/admin_console/authentication/saml');
|
||||
|
||||
cy.intercept('POST', '**/api/v4/saml/metadatafromidp', (req) => {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
idp_url: testFetchedIdpURL,
|
||||
idp_descriptor_url: testFetchedIdpDescriptorURL,
|
||||
idp_public_certificate: testIdpPublicCertificate,
|
||||
},
|
||||
});
|
||||
}).as('getSamlMetadataFromIdp');
|
||||
|
||||
cy.intercept('POST', '**/api/v4/saml/certificate/idp', (req) => {
|
||||
expect(req.headers['content-type']).to.eq('application/x-pem-file');
|
||||
expect(req.body).to.eq(testIdpPublicCertificate);
|
||||
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {status: 'OK'},
|
||||
});
|
||||
}).as('setSamlIdpCertificateFromMetadata');
|
||||
|
||||
// # Click on the Get SAML Metadata Button
|
||||
cy.get('#getSamlMetadataFromIDPButton button').scrollIntoView().should('be.visible').and('be.enabled').click();
|
||||
|
||||
// * Verify that the metadata and certificate endpoints are called
|
||||
cy.wait('@getSamlMetadataFromIdp');
|
||||
cy.wait('@setSamlIdpCertificateFromMetadata');
|
||||
|
||||
// * Verify that the IdP URL fields have been updated
|
||||
cy.findByTestId('SamlSettings.IdpURLinput').should('have.value', testFetchedIdpURL);
|
||||
cy.findByTestId('SamlSettings.IdpDescriptorURLinput').should('have.value', testFetchedIdpDescriptorURL);
|
||||
|
||||
// * Verify that the success message reflects the updated fields and certificate
|
||||
cy.get('#getSamlMetadataFromIDPButton').should('be.visible').contains(getSamlMetadataSuccessMessage);
|
||||
|
||||
// * Verify that the IdP certificate row shows the remove certificate view
|
||||
cy.contains('.remove-filename', 'saml-idp.crt').should('be.visible');
|
||||
cy.contains('button', 'Remove Identity Provider Certificate').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @messaging
|
||||
// Group: @channels @messaging @collapsed_reply_threads
|
||||
|
||||
import timeouts from '@/fixtures/timeouts';
|
||||
|
||||
|
|
@ -177,6 +177,57 @@ describe('Messaging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('MM-T4261_3 One-Click Reactions in Global Threads view show 3 emojis', () => {
|
||||
// # Re-enable emoji picker (MM-T4261_2 disables it) and configure CRT server-side
|
||||
cy.apiAdminLogin();
|
||||
cy.apiUpdateConfig({
|
||||
ServiceSettings: {
|
||||
EnableEmojiPicker: true,
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: 'default_off',
|
||||
},
|
||||
});
|
||||
|
||||
// # Create a fresh user with CRT turned on so threads appear in the Threads view
|
||||
cy.apiCreateUser({prefix: 'crtUser'}).then(({user: crtUser}) => {
|
||||
cy.apiAddUserToTeam(testTeam.id, crtUser.id);
|
||||
cy.apiSaveCRTPreference(crtUser.id, 'on');
|
||||
cy.apiLogin(crtUser);
|
||||
});
|
||||
|
||||
cy.visit(offTopicPath);
|
||||
|
||||
// # Enable one-click reactions for this user
|
||||
cy.uiOpenSettingsModal('Display').within(() => {
|
||||
cy.findByText('Display', {timeout: timeouts.ONE_MIN}).click();
|
||||
cy.findByText('Quick reactions on messages').click();
|
||||
cy.findByLabelText('On').click();
|
||||
cy.uiSaveAndClose();
|
||||
});
|
||||
|
||||
// # Post a root message and a reply so a followed thread exists
|
||||
cy.apiGetChannelByName(testTeam.name, 'off-topic').then(({channel}) => {
|
||||
cy.apiCreatePost(channel.id, 'Root post for Global Threads emoji test', '', {}).then((rootResp) => {
|
||||
const rootPostId = rootResp.body.id;
|
||||
|
||||
cy.apiCreatePost(channel.id, 'Reply to follow the thread', rootPostId, {});
|
||||
|
||||
// # Navigate to Global Threads
|
||||
cy.uiClickSidebarItem('threads');
|
||||
|
||||
// # Click the thread to open the full-width thread panel
|
||||
cy.get('div.ThreadItem').should('have.lengthOf.at.least', 1).first().click();
|
||||
|
||||
// * Root post is visible in the thread pane
|
||||
cy.get(`#rhsPost_${rootPostId}`).should('be.visible');
|
||||
|
||||
// * Hovering over the post in Global Threads shows 3 quick reaction emojis —
|
||||
// the same count as the center channel, not the 1 shown in a narrow RHS sidebar.
|
||||
validateQuickReactions(rootPostId, 'GLOBAL_THREADS', defaultEmojis);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function validateQuickReactions(postId, location, emojis) {
|
||||
let idPrefix;
|
||||
let numReactions = 3;
|
||||
|
|
@ -186,7 +237,7 @@ describe('Messaging', () => {
|
|||
} else if (location === 'RHS_ROOT' || location === 'RHS_COMMENT') {
|
||||
idPrefix = 'rhsPost';
|
||||
numReactions = 1;
|
||||
} else if (location === 'RHS_EXPANDED') {
|
||||
} else if (location === 'GLOBAL_THREADS') {
|
||||
idPrefix = 'rhsPost';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -779,7 +779,6 @@ const defaultServerConfig: AdminConfig = {
|
|||
AttributeBasedAccessControl: true,
|
||||
PermissionPolicies: true,
|
||||
ContentFlagging: true,
|
||||
InteractiveDialogAppsForm: true,
|
||||
EnableMattermostEntry: true,
|
||||
MobileSSOCodeExchange: false,
|
||||
AutoTranslation: true,
|
||||
|
|
|
|||
|
|
@ -228,6 +228,26 @@ export default class SystemProperties {
|
|||
return this.container.getByTestId('property-field-validation-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the in-cell error icon for the row whose Name input currently
|
||||
* equals `nameValue`. Use this to assert a *specific* row is highlighted
|
||||
* (rather than `identifierValidationError()` which matches any row).
|
||||
*/
|
||||
cellErrorIconForField(nameValue: string): Locator {
|
||||
return this.container
|
||||
.locator('tr')
|
||||
.filter({has: this.nameInputByValue(nameValue)})
|
||||
.getByTestId('property-field-validation-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the warning AlertBanner whose title text matches `title`.
|
||||
* Banners stack below the table; one per unique error type.
|
||||
*/
|
||||
validationBannerByTitle(title: string | RegExp): Locator {
|
||||
return this.container.locator('.AlertBanner').filter({hasText: title});
|
||||
}
|
||||
|
||||
validationMessage(text: string | RegExp): Locator {
|
||||
return this.container.getByText(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
const PROPERTY_GROUP = 'classification_markings';
|
||||
const PROPERTY_GROUP = 'access_control';
|
||||
const TEMPLATE_OBJECT_TYPE = 'template';
|
||||
const CHANNEL_OBJECT_TYPE = 'channel';
|
||||
const TARGET_TYPE = 'system';
|
||||
|
|
@ -102,11 +102,10 @@ export async function setupClassificationWithChannelField(
|
|||
target_id: '',
|
||||
attrs: {
|
||||
options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})),
|
||||
managed: 'admin',
|
||||
},
|
||||
permission_field: 'sysadmin',
|
||||
permission_values: 'sysadmin',
|
||||
permission_options: 'sysadmin',
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
permission_options: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
|
||||
// Create channel linked field
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for the v0.4 Permissions Policy tab in Channel Settings:
|
||||
* - Tab visibility is gated by ABAC + license + the PermissionPolicies
|
||||
* umbrella + the ChannelPermissionPolicies sub-flag.
|
||||
* - The list view exposes Add rule, search, and a paginated rules table.
|
||||
* - Adding a permission rule (name, role, actions) and committing returns to the list.
|
||||
* - Per-rule expression uses the same TableEditor as Membership Policy, but
|
||||
* re-labelled "Simulate rules" — the button opens the dual-lane
|
||||
* SimulateAccessModal instead of the legacy expression-only one (additionally
|
||||
* gated by the PolicySimulation feature flag).
|
||||
* - Duplicate rule names surface a save-time error.
|
||||
*
|
||||
* @reference Channel-scoped permission policies (v0.4)
|
||||
*
|
||||
* These tests skip themselves at runtime when the PermissionPolicies umbrella
|
||||
* OR the ChannelPermissionPolicies sub-flag is not enabled on the server —
|
||||
* the tab is invisible in either case and the workflow is not exercised.
|
||||
* Run with `MM_FEATUREFLAGS_PERMISSIONPOLICIES=true` AND
|
||||
* `MM_FEATUREFLAGS_CHANNELPERMISSIONPOLICIES=true`. The "Simulate rules"
|
||||
* button additionally requires `MM_FEATUREFLAGS_POLICYSIMULATION=true`.
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {enableABACConfig, ensureDepartmentAttribute, createPrivateChannel} from '../team_settings/helpers';
|
||||
|
||||
test.describe('Channel Settings Modal - Permissions Policy tab (v0.4)', () => {
|
||||
test.beforeEach(async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
// Skip the suite when either flag is OFF on the server rather
|
||||
// than relying on the tab's UI presence as a proxy — a
|
||||
// UI-based guard would silently mask a regression in the tab
|
||||
// visibility logic itself. Both flags must be on for the tab
|
||||
// to render (the channel-scope sub-flag depends on the
|
||||
// umbrella, mirroring `IsChannelPermissionPoliciesEnabled` on
|
||||
// the server).
|
||||
await pw.skipIfFeatureFlagNotSet('PermissionPolicies', true);
|
||||
await pw.skipIfFeatureFlagNotSet('ChannelPermissionPolicies', true);
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c1 Permissions Policy tab visible on private channel when feature flag enabled', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
|
||||
// Suite-level feature-flag guard already covers PermissionPolicies;
|
||||
// assert visibility unconditionally so a regression in the tab's
|
||||
// render gate would surface as a test failure.
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
|
||||
// # Open Permissions Policy
|
||||
await permissionsTab.click();
|
||||
|
||||
// * The list view renders: header, search, table, Add rule button.
|
||||
await expect(channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-add-rule')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-search')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-rules-table')).toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c2 Add rule opens the editor with sections in the expected order, Cancel returns to the list', async ({
|
||||
pw,
|
||||
}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Click "Add rule" → editor view appears
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
const editor = channelSettings.container.getByTestId('permissions-policy-editor');
|
||||
await expect(editor).toBeVisible({timeout: 5000});
|
||||
|
||||
// * Mirror system-console layout: name → role → user-attribute conditions → permissions list.
|
||||
const expressionSection = editor.getByTestId('permissions-policy-editor-expression-section');
|
||||
const permissionsSection = editor.getByTestId('permissions-policy-editor-permissions-section');
|
||||
await expect(expressionSection).toBeVisible();
|
||||
await expect(permissionsSection).toBeVisible();
|
||||
|
||||
// Permissions list must render BELOW the user-attribute conditions
|
||||
// (TableEditor) so we match the System Console policy editor ordering.
|
||||
const expressionBox = await expressionSection.boundingBox();
|
||||
const permissionsBox = await permissionsSection.boundingBox();
|
||||
expect(expressionBox).not.toBeNull();
|
||||
expect(permissionsBox).not.toBeNull();
|
||||
expect(permissionsBox?.y ?? 0).toBeGreaterThan((expressionBox?.y ?? 0) + (expressionBox?.height ?? 0) - 1);
|
||||
|
||||
// # Cancel → back to list view
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-cancel').click();
|
||||
await expect(tab).toBeVisible({timeout: 5000});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c3 Editor validates: missing name surfaces an inline error', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Open the editor without filling the name
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-save').click();
|
||||
|
||||
// * Inline error mentions name uniqueness/required.
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor-error')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that webpack-bundled static image assets are not broken by
|
||||
* the image-minimizer-webpack-plugin (sharp) migration.
|
||||
*
|
||||
* Sharp runs at build time and compresses PNG/JPEG/SVG assets bundled into the
|
||||
* app. If it mis-encodes a file the asset will either 404, return a wrong
|
||||
* content-type, or decode to a zero-width image in the browser.
|
||||
*
|
||||
* This test catches that by reloading the page and asserting:
|
||||
* - no static asset request returns 4xx/5xx
|
||||
* - no <img> in the DOM has naturalWidth === 0 (failed to decode)
|
||||
*/
|
||||
|
||||
test('app loads without any broken image assets on the main channel view', {tag: '@image_assets'}, async ({pw}) => {
|
||||
const {user} = await pw.initSetup();
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Collect image/font load errors on reload so the response listener is
|
||||
// active before any requests fire.
|
||||
const failedImageUrls: string[] = [];
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
const isImage = /\.(png|jpg|jpeg|svg|gif|woff2|woff)(\?|$)/.test(url);
|
||||
if (isImage && response.status() >= 400) {
|
||||
failedImageUrls.push(`${response.status()} ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * No image/font requests should return 4xx or 5xx
|
||||
expect(failedImageUrls, `Failed asset requests:\n${failedImageUrls.join('\n')}`).toHaveLength(0);
|
||||
|
||||
// * No <img> element should have naturalWidth === 0 (means the file was
|
||||
// served but the browser could not decode it — typical of a sharp corruption)
|
||||
const brokenImages = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('img'))
|
||||
.filter((img) => img.complete && img.naturalWidth === 0 && Boolean(img.src))
|
||||
.map((img) => img.src);
|
||||
});
|
||||
|
||||
expect(brokenImages, `Broken <img> elements found:\n${brokenImages.join('\n')}`).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that the pdfjs cmaps path fix in webpack.config.js works.
|
||||
*
|
||||
* Context: PR #35810 changed the copy-webpack-plugin entry for pdfjs cmaps from
|
||||
* a fragile relative path to one resolved via require.resolve():
|
||||
*
|
||||
* Before: {from: '../node_modules/pdfjs-dist/cmaps', to: 'cmaps'}
|
||||
* After: {from: path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'cmaps'), to: 'cmaps'}
|
||||
*
|
||||
* The old path broke with npm workspace hoisting — pdfjs-dist gets installed at
|
||||
* the root node_modules, not channels/node_modules, so the relative path resolves
|
||||
* to nothing and copy-webpack-plugin silently copies zero files.
|
||||
*
|
||||
* A 404 on identity-h means the cmaps directory was not copied to /static/cmaps/.
|
||||
*/
|
||||
|
||||
test('pdfjs cmaps are copied to /static/cmaps/ and served correctly', {tag: '@pdf_preview'}, async ({pw}) => {
|
||||
const {user} = await pw.initSetup();
|
||||
const {page} = await pw.testBrowser.login(user);
|
||||
|
||||
const baseUrl = new URL(page.url()).origin;
|
||||
|
||||
// # identity-h is a standard CMap that pdfjs requests for non-Latin PDFs.
|
||||
// Its presence confirms copy-webpack-plugin found and copied the cmaps directory.
|
||||
const cmapUrl = `${baseUrl}/static/cmaps/identity-h`;
|
||||
const response = await page.request.get(cmapUrl);
|
||||
|
||||
// * 200 = require.resolve() found the right path and cmaps were copied.
|
||||
// * 404 = the path in webpack.config.js is wrong or copy-webpack-plugin skipped it.
|
||||
expect(
|
||||
response.status(),
|
||||
`CMap not found at ${cmapUrl} — pdfjs cmaps were not copied to /static/cmaps/. ` +
|
||||
'Check the copy-webpack-plugin entry in webpack.config.js.',
|
||||
).toBe(200);
|
||||
|
||||
const body = await response.body();
|
||||
expect(body.length, 'identity-h CMap file must not be empty').toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -53,9 +53,7 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
await expect(systemConsolePage.page.getByText('Permissions evaluation order', {exact: false})).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-T5806 create policy form shows role dropdown defaulting to Members and system administrators', async ({
|
||||
pw,
|
||||
}) => {
|
||||
test('MM-T5806 create policy form shows role dropdown defaulting to Members', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
|
@ -71,10 +69,17 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
systemConsolePage.page.getByText('Select a role from the predefined list of system roles'),
|
||||
).toBeVisible();
|
||||
|
||||
// * The dropdown button is visible and shows the default role (system_user = "Members and system administrators")
|
||||
// * The dropdown button is visible and shows the default role
|
||||
// (system_user). The label was shortened from "Members and
|
||||
// system administrators" to just "Members" in the UX pass;
|
||||
// the "system admins fall back when no admin-specific rule
|
||||
// exists" semantics moved into the role's description copy.
|
||||
// Use an exact-text matcher so a regression to the longer
|
||||
// "Members and system administrators" label fails the test
|
||||
// instead of silently passing the substring check.
|
||||
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
|
||||
await expect(roleButton).toBeVisible();
|
||||
await expect(roleButton).toContainText('Members and system administrators');
|
||||
await expect(roleButton).toHaveText('Members');
|
||||
});
|
||||
|
||||
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ test.describe('Permission Policies - Create Policy', () => {
|
|||
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
|
||||
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
|
||||
await expect(policyRow).toBeVisible();
|
||||
await expect(policyRow.getByText('Members and system administrators')).toBeVisible();
|
||||
// Role label was shortened from "Members and system administrators"
|
||||
// to just "Members" in the UX pass; the "system admins fall back
|
||||
// when no admin-specific rule exists" semantics moved into the
|
||||
// role's description copy.
|
||||
await expect(policyRow.getByText('Members', {exact: true})).toBeVisible();
|
||||
await expect(policyRow.getByText('Download Files')).toBeVisible();
|
||||
} finally {
|
||||
await deletePermissionPolicyByName(adminClient, policyName);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
|
||||
|
||||
import {createBasicPolicy, getPolicyIdByName} from '../support';
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for membership policy edit action navigation:
|
||||
* - Clicking Edit in the policy row action menu navigates to the membership policy editor
|
||||
*
|
||||
* @reference MM-68958: Fix membership policy edit action navigation
|
||||
*/
|
||||
test.describe('ABAC Policy Management - Edit Action Navigation', () => {
|
||||
/**
|
||||
* MM-68958: Edit action in policy row menu navigates to membership policy editor
|
||||
*
|
||||
* Steps:
|
||||
* 1. Enable ABAC and create a membership policy
|
||||
* 2. Navigate to System Console > Membership Policies
|
||||
* 3. Open a policy row's three-dot action menu
|
||||
* 4. Click Edit
|
||||
* 5. Verify the URL is /admin_console/system_attributes/membership_policies/edit_policy/<policy_id>
|
||||
*/
|
||||
test('MM-68958 Edit action navigates to membership policy editor', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Create a test channel for the policy
|
||||
const channelName = `abac-edit-nav-test-${pw.random.id()}`;
|
||||
const privateChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: channelName,
|
||||
type: 'P',
|
||||
});
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// Create a basic membership policy
|
||||
const policyName = `Edit-Nav-Test-${pw.random.id()}`;
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Engineering',
|
||||
autoSync: false,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Get the policy ID from the backend
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
|
||||
|
||||
// Navigate to Membership Policies list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Search for the policy to ensure it's visible
|
||||
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Find the policy row
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Open the three-dot action menu for the policy
|
||||
const actionMenuButton = policyRowLocator
|
||||
.locator('button[aria-label*="Actions" i], button:has(i.icon-dots-vertical)')
|
||||
.first();
|
||||
await actionMenuButton.waitFor({state: 'visible', timeout: 5000});
|
||||
await actionMenuButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click the Edit menu item
|
||||
const editMenuItem = page.locator(`[id*="policy-menu-edit-${policyId}"]`).first();
|
||||
await editMenuItem.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
// Click Edit and wait for navigation
|
||||
await editMenuItem.click();
|
||||
|
||||
// Wait for the URL to change to the edit policy page
|
||||
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we're on the edit policy page by checking the URL
|
||||
const currentURL = page.url();
|
||||
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
|
||||
|
||||
// Additional verification: Check that the policy editor is loaded
|
||||
// The policy editor should have the policy name visible
|
||||
const policyNameInput = page.locator('input[placeholder*="name" i], input[value*="Edit-Nav-Test"]').first();
|
||||
await expect(policyNameInput).toBeVisible({timeout: 10000});
|
||||
});
|
||||
|
||||
/**
|
||||
* MM-68958: Row click also navigates to membership policy editor
|
||||
*
|
||||
* This test verifies that clicking the row (not just the Edit action) also navigates correctly.
|
||||
* This behavior should have been working before, but we verify it still works after the fix.
|
||||
*/
|
||||
test('Row click navigates to membership policy editor', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Create a test channel for the policy
|
||||
const channelName = `abac-row-click-test-${pw.random.id()}`;
|
||||
const privateChannel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
display_name: channelName,
|
||||
type: 'P',
|
||||
});
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
await navigateToABACPage(page);
|
||||
await enableABAC(page);
|
||||
|
||||
// Create a basic membership policy
|
||||
const policyName = `Row-Click-Test-${pw.random.id()}`;
|
||||
|
||||
await createBasicPolicy(page, {
|
||||
name: policyName,
|
||||
attribute: 'Department',
|
||||
operator: '==',
|
||||
value: 'Sales',
|
||||
autoSync: false,
|
||||
channels: [privateChannel.display_name],
|
||||
});
|
||||
|
||||
// Get the policy ID from the backend
|
||||
const policyId = await getPolicyIdByName(adminClient, policyName);
|
||||
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
|
||||
|
||||
// Navigate to Membership Policies list page
|
||||
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Search for the policy to ensure it's visible
|
||||
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
|
||||
if (await policySearchInput.isVisible({timeout: 3000})) {
|
||||
await policySearchInput.fill(policyName);
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Find the policy row
|
||||
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
|
||||
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
|
||||
|
||||
// Click the row (not the action menu)
|
||||
await policyRowLocator.click();
|
||||
|
||||
// Wait for the URL to change to the edit policy page
|
||||
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we're on the edit policy page
|
||||
const currentURL = page.url();
|
||||
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -401,10 +401,10 @@ test.describe('System Console - Classification markings', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'nato-unclassified', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'natounclassified0000000000', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'nato-restricted', name: 'NATO RESTRICTED', color: '#FFD700', rank: 2},
|
||||
],
|
||||
{levelId: 'nato-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'natounclassified0000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -457,10 +457,10 @@ test.describe('System Console - Classification markings', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {Client4} from '@mattermost/client';
|
|||
|
||||
// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts
|
||||
// (cross-package import not feasible between e2e-tests and webapp)
|
||||
const PROPERTY_GROUP = 'classification_markings';
|
||||
const PROPERTY_GROUP = 'access_control';
|
||||
const OBJECT_TYPE = 'template';
|
||||
const LINKED_OBJECT_TYPE = 'system';
|
||||
const TARGET_TYPE = 'system';
|
||||
|
|
@ -101,11 +101,10 @@ export async function setupClassificationField(
|
|||
target_id: '',
|
||||
attrs: {
|
||||
options: levels.map((l) => ({id: l.id ?? '', name: l.name, color: l.color, rank: l.rank})),
|
||||
managed: 'admin',
|
||||
},
|
||||
permission_field: 'sysadmin',
|
||||
permission_values: 'sysadmin',
|
||||
permission_options: 'sysadmin',
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
permission_options: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: '', enabled: false, placement: 'top'},
|
||||
);
|
||||
|
|
@ -149,10 +149,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -184,8 +184,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -222,8 +222,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
|
||||
{levelId: 'lvl-confidential', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
|
||||
{levelId: 'lvlconfidential00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -251,8 +251,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-restricted', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
|
||||
{levelId: 'lvl-restricted', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlrestricted0000000000000', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
|
||||
{levelId: 'lvlrestricted0000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -289,9 +289,9 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 1}],
|
||||
[{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 1}],
|
||||
{
|
||||
levelId: 'lvl-secret',
|
||||
levelId: 'lvlsecret00000000000000000',
|
||||
enabled: true,
|
||||
placement: 'top',
|
||||
},
|
||||
|
|
@ -334,8 +334,8 @@ test.describe('Global Classification Banner', () => {
|
|||
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
|
||||
);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -376,10 +376,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
// Login the non-admin user
|
||||
|
|
@ -395,10 +395,10 @@ test.describe('Global Classification Banner', () => {
|
|||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[
|
||||
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
],
|
||||
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
|
||||
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
// The non-admin user should see the updated banner via websocket
|
||||
|
|
@ -425,8 +425,8 @@ test.describe('Global Classification Banner', () => {
|
|||
// Light background (#FFFFFF) — text should be dark (#000000)
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
|
||||
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
|
||||
[{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
|
||||
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -440,8 +440,8 @@ test.describe('Global Classification Banner', () => {
|
|||
// Dark background (#000000) — text should be white (#FFFFFF)
|
||||
await setupClassificationFieldWithGlobalBanner(
|
||||
adminClient,
|
||||
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#000000', rank: 1}],
|
||||
{levelId: 'lvl-top-secret', enabled: true, placement: 'top'},
|
||||
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#000000', rank: 1}],
|
||||
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top'},
|
||||
);
|
||||
|
||||
await channelsPage.page.reload();
|
||||
|
|
|
|||
|
|
@ -427,7 +427,8 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
/**
|
||||
* @objective Verify that clearing the auto-derived CEL identifier (Name)
|
||||
* after entering a Display Name shows the empty-name validation warning
|
||||
* and disables the Save button.
|
||||
* in both the offending Name cell (red icon) and a banner below the table,
|
||||
* and that Save remains disabled.
|
||||
*/
|
||||
test('shows validation warning for empty attribute name', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
|
|
@ -452,50 +453,117 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await nameInput.clear();
|
||||
await nameInput.blur();
|
||||
|
||||
// * Verify validation warning about empty name is shown
|
||||
await expect(sp.validationMessage('Please enter an attribute name.')).toBeVisible();
|
||||
// * Verify the in-cell error icon is rendered for the offending row
|
||||
await expect(sp.identifierValidationError()).toBeVisible();
|
||||
|
||||
// * Verify the bottom banner with the title and body copy is rendered
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/missing a Name/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled due to validation error
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that entering a duplicate attribute name shows a "must be
|
||||
* unique" warning and disables the Save button.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "UniqueName_<timestamp>" exists via API setup.
|
||||
* @objective Verify that two pending fields with the same Name surface the
|
||||
* `name_unique` validation: both rows display the in-cell error icon, a
|
||||
* single banner appears below the table, and Save stays disabled.
|
||||
*/
|
||||
test.fixme('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
test('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API (name must be a valid CEL identifier — no spaces)
|
||||
const uniqueDupName = `unique_name_${Date.now()}`;
|
||||
const attributes: CustomProfileAttribute[] = [{name: uniqueDupName, type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const dupName = `dup_${Date.now()}`;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Add a new attribute with the same name
|
||||
// # Add the first new attribute with the duplicate name. We commit the
|
||||
// # value via lastNameInput() here, then resolve the row by value below
|
||||
// # so subsequent addAttribute() calls don't cause `.last()` to slide
|
||||
// # onto the second row.
|
||||
await sp.addAttribute();
|
||||
await sp.lastNameInput().fill(dupName);
|
||||
await sp.lastNameInput().blur();
|
||||
|
||||
// Use lastNameInput() so concurrent UAAE/ABAC rows don't shift the index.
|
||||
const newNameInput = sp.lastNameInput();
|
||||
await newNameInput.clear();
|
||||
await newNameInput.fill(uniqueDupName);
|
||||
await newNameInput.blur();
|
||||
// # Add the second new attribute with the same name (triggers name_unique)
|
||||
await sp.addAttribute();
|
||||
const secondNameInput = sp.lastNameInput();
|
||||
await secondNameInput.fill(dupName);
|
||||
await secondNameInput.blur();
|
||||
|
||||
// * Verify validation warning about duplicate name is shown
|
||||
await expect(sp.validationMessage('Attribute names must be unique.').first()).toBeVisible();
|
||||
// * Both rows should display the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(2);
|
||||
|
||||
// * A single name_unique banner is rendered below the table
|
||||
await expect(sp.validationBannerByTitle('Attribute names must be unique.')).toHaveCount(1);
|
||||
await expect(sp.validationBannerByTitle(/share the same Name/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the `name_taken` validation path: renaming a persisted
|
||||
* field to free its name, then renaming a second persisted field to take
|
||||
* the freed name, surfaces the in-cell icon and bottom banner. This is the
|
||||
* only sequence that reaches the `NameTaken` branch — duplicate-pending
|
||||
* fields trigger `NameUnique` first.
|
||||
*
|
||||
* @precondition
|
||||
* Two custom profile attributes (`taken_a`, `taken_b`) exist via API setup.
|
||||
*/
|
||||
test(
|
||||
'shows validation warning when a persisted attribute name is taken',
|
||||
{tag: '@user_attributes'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create two persisted attributes via API
|
||||
const uid = Date.now();
|
||||
const nameA = `taken_a_${uid}`;
|
||||
const nameB = `taken_b_${uid}`;
|
||||
const freedName = `taken_freed_${uid}`;
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{name: nameA, type: 'text'},
|
||||
{name: nameB, type: 'text'},
|
||||
];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
try {
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Rename A first — frees the original `taken_a_*` name
|
||||
const inputA = sp.nameInputByValue(nameA);
|
||||
await expect(inputA).toBeVisible();
|
||||
await inputA.focus();
|
||||
await inputA.fill(freedName);
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
||||
// # Rename B to the now-freed name — reaches the NameTaken branch
|
||||
const inputB = sp.nameInputByValue(nameB);
|
||||
await expect(inputB).toBeVisible();
|
||||
await inputB.focus();
|
||||
await inputB.fill(nameA);
|
||||
await sp.page.keyboard.press('Tab');
|
||||
|
||||
// * Verify the offending row carries the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(1);
|
||||
|
||||
// * Verify the bottom banner with the name_taken title and body
|
||||
await expect(sp.validationBannerByTitle('Attribute name already taken.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/already used by another attribute/)).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
} finally {
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify changing an attribute type from Text to Phone via the type
|
||||
* selector saves the updated value_type to the server.
|
||||
|
|
@ -645,6 +713,55 @@ test.describe('System Console - User Attributes Management', () => {
|
|||
await cleanupFields(adminClient, await getFieldsMap(adminClient));
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that two distinct name validation errors produce two
|
||||
* stacked banners below the table (one per type), both offending rows
|
||||
* carry the in-cell error icon, and that fixing one row removes only the
|
||||
* matching banner while the other persists.
|
||||
*/
|
||||
test('stacks one banner per distinct validation error type', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Row 1: trigger name_required (fill Display Name, clear auto-Name, blur)
|
||||
await sp.addAttribute();
|
||||
const firstDisplayName = sp.lastDisplayNameInput();
|
||||
await firstDisplayName.fill('Job Title');
|
||||
await firstDisplayName.blur();
|
||||
const firstNameInput = sp.lastNameInput();
|
||||
await expect(firstNameInput).toHaveValue('job_title');
|
||||
await firstNameInput.clear();
|
||||
await firstNameInput.blur();
|
||||
|
||||
// # Row 2: trigger name_invalid_cel (reserved CEL keyword)
|
||||
await sp.addAttribute();
|
||||
const secondNameInput = sp.lastNameInput();
|
||||
await secondNameInput.fill('true');
|
||||
await secondNameInput.blur();
|
||||
|
||||
// * Both banners stack at the bottom of the table
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toBeVisible();
|
||||
|
||||
// * Both offending Name cells carry the in-cell error icon
|
||||
await expect(sp.identifierValidationError()).toHaveCount(2);
|
||||
|
||||
// * Save button stays disabled while any banner is present
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
|
||||
// # Fix the second row (give it a valid name) — only the CEL banner should disappear
|
||||
await secondNameInput.fill('valid_name');
|
||||
await secondNameInput.blur();
|
||||
|
||||
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toHaveCount(0);
|
||||
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
|
||||
await expect(sp.identifierValidationError()).toHaveCount(1);
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that deleting a newly added (unsaved) attribute removes
|
||||
* the row without a confirmation modal.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ async function createAdminClient(): Promise<{adminClient: Client4; adminUser: Ad
|
|||
}
|
||||
|
||||
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient, adminUser} = await createAdminClient();
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
|
|
@ -181,8 +184,11 @@ test.describe('System Console - User Attributes display names', () => {
|
|||
await sp.lastNameInput().fill(invalidIdentifier);
|
||||
await sp.lastNameInput().blur();
|
||||
|
||||
// * Verify the warning appears and Save stays disabled before any POST
|
||||
await expect(sp.identifierValidationError()).toHaveText(IDENTIFIER_VALIDATION_MESSAGE);
|
||||
// * Verify the in-cell error icon and bottom banner are rendered,
|
||||
// and Save stays disabled before any POST is issued.
|
||||
await expect(sp.identifierValidationError()).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(IDENTIFIER_VALIDATION_MESSAGE)).toBeVisible();
|
||||
await expect(sp.validationBannerByTitle(/not a valid identifier/)).toBeVisible();
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.26.2
|
||||
1.26.3
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime
|
||||
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-opensearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public run-server-faketime
|
||||
|
||||
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.9.0
|
|||
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.13.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.5
|
||||
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.6.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.4.1
|
||||
|
|
@ -180,7 +180,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
|
|||
ifeq ($(FIPS_ENABLED),true)
|
||||
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.9.0%2Bdfb5b30-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v2.0.3%2Bcab391a-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4%2B5855fe1-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.5%2Bf4fc5d6-fips
|
||||
endif
|
||||
|
||||
EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
|
||||
|
|
@ -508,6 +508,15 @@ test-server-elasticsearch: check-prereqs-enterprise start-docker gotestsum ## Ru
|
|||
@echo Running only Elasticsearch tests
|
||||
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(ES_PACKAGES)" -- $(GOFLAGS) -timeout=20m
|
||||
|
||||
OS_PACKAGES=$(shell $(GO) list ./enterprise/elasticsearch/opensearch/...)
|
||||
|
||||
test-server-opensearch: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
|
||||
test-server-opensearch: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
|
||||
test-server-opensearch: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
|
||||
test-server-opensearch: check-prereqs-enterprise start-docker gotestsum ## Runs OpenSearch tests.
|
||||
@echo Running only OpenSearch tests
|
||||
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(OS_PACKAGES)" -- $(GOFLAGS) -timeout=20m
|
||||
|
||||
test-server-quick: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
|
||||
test-server-quick: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
|
||||
test-server-quick: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM mattermost/golang-bullseye:1.26.2@sha256:fc2cc64f035c74f14b0e5921971bcda4b6dbd281430d3cbfb0bf539ebb1bacd5
|
||||
FROM mattermost/golang-bullseye:1.26.3@sha256:3ae112b7dc291665c5582b9d768fc2adb4cdc3afbbd3fc82e03a10cd711e1a60
|
||||
ARG NODE_VERSION=20.11.1
|
||||
|
||||
RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM cgr.dev/mattermost.com/go-msft-fips:1.26.2-dev@sha256:cdd5bd448fcf61654893846572f006aff7349aa21027de590fc2bd020f8126f1
|
||||
FROM cgr.dev/mattermost.com/go-msft-fips:1.26.3-dev@sha256:48ab99fede7fb33e132a0636072971e1ec4a69520865bfa1e4b517ee9cfdef34
|
||||
ARG NODE_VERSION=20.11.1
|
||||
|
||||
RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
ARG OPENSEARCH_VERSION=2.7.0
|
||||
ARG OPENSEARCH_VERSION=3.0.0
|
||||
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile.opensearch
|
||||
args:
|
||||
OPENSEARCH_VERSION: ${OPENSEARCH_VERSION:-3.0.0}
|
||||
networks:
|
||||
- mm-test
|
||||
environment:
|
||||
|
|
@ -96,7 +98,9 @@ services:
|
|||
transport.host: "127.0.0.1"
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: "true"
|
||||
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
DISABLE_INSTALL_DEMO_CONFIG: "true"
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD: "Test@dmin_123"
|
||||
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
redis:
|
||||
image: "redis:7.4.0"
|
||||
logging: *default-logging
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ func (api *API) InitAccessControlPolicy() {
|
|||
|
||||
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/simulate_users", api.APISessionRequired(simulatePolicyForUsers)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APISessionRequired(validateExpressionAgainstRequester)).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)
|
||||
|
|
@ -57,6 +58,19 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
// Channel-scope policies are always available, but a channel policy
|
||||
// that carries a permission-rule action (upload_file_attachment,
|
||||
// download_file_attachment) is gated behind the channel-level
|
||||
// sub-flag — that's the toggle that exposes the Channel Settings →
|
||||
// Permissions Policy tab on the frontend. Membership-only channel
|
||||
// policies stay unaffected. Helper enforces the PermissionPolicies
|
||||
// umbrella too, so a request slipping in with the sub-flag on but
|
||||
// the umbrella off is also rejected here.
|
||||
if policy.Type == model.AccessControlPolicyTypeChannel && policy.HasPermissionRuleAction() && !c.App.Config().FeatureFlags.IsChannelPermissionPoliciesEnabled() {
|
||||
c.Err = model.NewAppError("createAccessControlPolicy", "api.access_control_policy.channel_permission_policies.feature_disabled", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateAccessControlPolicy, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &policy)
|
||||
|
|
@ -276,10 +290,7 @@ func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
teamID := checkExpressionRequest.TeamId
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, checkExpressionRequest.TeamId) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -329,8 +340,7 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
hasTeamPermission := !hasSystemPermission && teamID != "" &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
hasTeamPermission := !hasSystemPermission && teamAdminCELContextOK(c, channelId, teamID)
|
||||
|
||||
if !hasSystemPermission && !hasTeamPermission {
|
||||
if channelId == "" {
|
||||
|
|
@ -389,6 +399,189 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// teamAdminCELContextOK reports whether the session may use the delegated
|
||||
// team-admin shortcut for CEL tooling: valid team_id, ManageTeamAccessRules on
|
||||
// that team, and when a channel_id is supplied it must resolve to a channel in
|
||||
// that same team. Prevents pairing a team the admin manages with an unrelated
|
||||
// channel solely to satisfy the channel branch of auth.
|
||||
func teamAdminCELContextOK(c *Context, channelID, teamID string) bool {
|
||||
if teamID == "" || !model.IsValidId(teamID) {
|
||||
return false
|
||||
}
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
||||
return false
|
||||
}
|
||||
if channelID == "" {
|
||||
return true
|
||||
}
|
||||
if !model.IsValidId(channelID) {
|
||||
return false
|
||||
}
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, channelID)
|
||||
if appErr != nil {
|
||||
return false
|
||||
}
|
||||
return channel.TeamId == teamID
|
||||
}
|
||||
|
||||
// authorizeSimulatePolicy checks the caller's permission to simulate a
|
||||
// policy and returns whether they have system-level access — used by
|
||||
// the caller to scope SanitizeProfile.
|
||||
//
|
||||
// Authorization order:
|
||||
// - system admin: always.
|
||||
// - team admin: only when teamID is set AND any provided channelID
|
||||
// resolves to a channel in that team. Without this guard a team
|
||||
// admin could simulate a policy for any channel by pairing their
|
||||
// team_id with a foreign channel_id; the cross-team check forces
|
||||
// the auth to fall through to HasPermissionToChannel for any
|
||||
// channel outside the admin's team.
|
||||
// - channel admin: when channelID is set, via HasPermissionToChannel
|
||||
// (which already covers the channel's actual team admins).
|
||||
//
|
||||
// On failure the function sets the appropriate permission error on `c`
|
||||
// and returns ok=false. Callers MUST early-return when ok=false.
|
||||
func authorizeSimulatePolicy(c *Context, channelID, teamID string) (hasSystemPermission bool, ok bool) {
|
||||
hasSystemPermission = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if hasSystemPermission {
|
||||
return true, true
|
||||
}
|
||||
|
||||
if teamAdminCELContextOK(c, channelID, teamID) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
if channelID == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return false, false
|
||||
}
|
||||
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelID, model.PermissionManageChannelAccessRules)
|
||||
if !hasChannelPermission {
|
||||
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
||||
return false, false
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// simulatePolicyForUsers runs the dual-lane PDP simulation against a draft
|
||||
// policy (not persisted) plus any higher-scoped persisted permission
|
||||
// policies, for an explicit set of user IDs (with optional per-user session
|
||||
// attribute overrides). The response carries per-user, per-action
|
||||
// ALLOW/DENY decisions plus blame attribution for any deny — used by the
|
||||
// "Simulate access" picker UX in the System Console and Channel Settings.
|
||||
//
|
||||
// Permission gates:
|
||||
// - System admins: full access.
|
||||
// - Team admins (with PermissionManageTeamAccessRules on the team): when a
|
||||
// team_id is present in the body and any provided channel_id resolves
|
||||
// to a channel in that team.
|
||||
// - Channel admins (with PermissionManageChannelAccessRules on the
|
||||
// channel): when a channel_id is present in the body.
|
||||
//
|
||||
// Non-system admins may only simulate users who belong to the request's
|
||||
// channel (when channel_id is set) or team (team-scoped simulation).
|
||||
// The endpoint requires the PolicySimulation feature flag (which
|
||||
// itself depends on the PermissionPolicies umbrella) and an
|
||||
// Enterprise Advanced license. Returns 501 when ABAC is unavailable.
|
||||
func simulatePolicyForUsers(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !c.App.Config().FeatureFlags.IsPolicySimulationEnabled() {
|
||||
c.Err = model.NewAppError("simulatePolicyForUsers", "api.access_control_policy.policy_simulation.feature_disabled", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var params model.PolicySimulationByUsersParams
|
||||
if jsonErr := json.NewDecoder(r.Body).Decode(¶ms); jsonErr != nil {
|
||||
c.SetInvalidParamWithErr("simulation", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
if params.Policy == nil {
|
||||
c.SetInvalidParam("policy")
|
||||
return
|
||||
}
|
||||
if len(params.Users) == 0 {
|
||||
c.SetInvalidParam("users")
|
||||
return
|
||||
}
|
||||
if params.ChannelID != "" && !model.IsValidId(params.ChannelID) {
|
||||
c.SetInvalidParam("channel_id")
|
||||
return
|
||||
}
|
||||
if params.TeamID != "" && !model.IsValidId(params.TeamID) {
|
||||
c.SetInvalidParam("team_id")
|
||||
return
|
||||
}
|
||||
switch params.EvaluationScope {
|
||||
case "", model.PolicyEvaluationScopeThisRule, model.PolicyEvaluationScopeAll:
|
||||
default:
|
||||
c.SetInvalidParam("evaluation_scope")
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize the empty string up front to the default
|
||||
if params.EvaluationScope == "" {
|
||||
params.EvaluationScope = model.PolicyEvaluationScopeThisRule
|
||||
}
|
||||
|
||||
hasSystemPermission, ok := authorizeSimulatePolicy(c, params.ChannelID, params.TeamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Cross-team consistency check: when both IDs are provided, the
|
||||
// channel must actually belong to the named team. authorizeSimulatePolicy
|
||||
// covers this for the team-admin shortcut, but a system admin's auth
|
||||
// short-circuit happens earlier so we re-check here for everyone.
|
||||
// Mismatched IDs would otherwise let downstream user-scope validation
|
||||
// run against the wrong team. We canonicalise params.TeamID from the
|
||||
// channel rather than rejecting outright — the channel ID is the
|
||||
// authoritative scope for a channel-policy simulation.
|
||||
if params.ChannelID != "" && params.TeamID != "" {
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, params.ChannelID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
if channel.TeamId != params.TeamID {
|
||||
c.SetInvalidParam("team_id")
|
||||
return
|
||||
}
|
||||
params.TeamID = channel.TeamId
|
||||
}
|
||||
|
||||
if !hasSystemPermission {
|
||||
if appErr := c.App.ValidatePolicySimulationUsersInScope(c.AppContext, params.TeamID, params.ChannelID, params.Users); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, appErr := c.App.SimulateAccessControlPolicyForUsers(c.AppContext, params)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
for i := range resp.Results {
|
||||
c.App.SanitizeProfile(resp.Results[i].User, hasSystemPermission)
|
||||
}
|
||||
|
||||
// Redact protected CPA attribute values for non-system-admin
|
||||
// callers. Targets the user's actual attribute values shown in
|
||||
// the Decision Details panel and per-leaf ActualValue strings.
|
||||
c.App.RedactSimulationAttributesForCaller(c.AppContext, resp, hasSystemPermission)
|
||||
|
||||
js, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("simulatePolicyForUsers", "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 validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Expression string `json:"expression"`
|
||||
|
|
@ -415,9 +608,7 @@ func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *ht
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
hasTeamPermission := teamID != "" &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, teamID) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -933,9 +1124,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, teamID) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
@ -1008,10 +1197,7 @@ func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
||||
if !hasSystemPermission {
|
||||
teamID := cel.TeamId
|
||||
hasTeamPermission := teamID != "" && model.IsValidId(teamID) &&
|
||||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules)
|
||||
if !hasTeamPermission {
|
||||
if !teamAdminCELContextOK(c, channelId, cel.TeamId) {
|
||||
if channelId == "" {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package api4
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
|
@ -328,6 +329,135 @@ func TestCreateAccessControlPolicy(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules rejected when ChannelPermissionPolicies sub-flag is off", func(t *testing.T) {
|
||||
// Channel-scope policies that ONLY have membership rules
|
||||
// stay available even when the permission-rule sub-flag is
|
||||
// off. As soon as a rule carries a non-membership action
|
||||
// (upload_file_attachment / download_file_attachment) the
|
||||
// API4 gate must reject with 501. Membership-only policies
|
||||
// are exercised by the sibling "CreateAccessControlPolicy
|
||||
// with channel scope permissions" test above; this one
|
||||
// pins the permission-rule branch specifically.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
})
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: model.NewId(),
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can upload",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules rejected when PermissionPolicies umbrella is off (sub-flag alone is not enough)", func(t *testing.T) {
|
||||
// Dependency-direction guard: ChannelPermissionPolicies on
|
||||
// its own must NOT be enough to bypass the gate. The
|
||||
// IsChannelPermissionPoliciesEnabled helper requires the
|
||||
// PermissionPolicies umbrella too, so a config that turns
|
||||
// the sub-flag on but leaves the umbrella off still gets
|
||||
// a 501. Mirrors the corresponding subtest in
|
||||
// TestSimulatePolicyForUsers.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
})
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: model.NewId(),
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can download",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionDownloadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("CreateChannelPolicy with permission rules accepted when both flags are on", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok, "SetLicense should return true")
|
||||
|
||||
// Use a real private channel for the policy ID; channel-
|
||||
// scope creation runs an eligibility check that fetches the
|
||||
// channel even for system admins. The sibling
|
||||
// "CreateAccessControlPolicy with channel scope permissions"
|
||||
// test uses the same pattern.
|
||||
ch := th.CreatePrivateChannel(t)
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
ID: ch.Id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Version: model.AccessControlPolicyVersionV0_4,
|
||||
Revision: 1,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Name: "Channel members can upload",
|
||||
Role: model.ChannelUserRoleId,
|
||||
Expression: "user.attributes.department == 'engineering'",
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockAccessControlService := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockAccessControlService
|
||||
// We only care that the gate let the request through to the
|
||||
// PAP; the validation chain past this point is exercised by
|
||||
// other tests, so the mock returns success straight away.
|
||||
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(channelPolicy, nil).Times(1)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.ChannelPermissionPolicies = false
|
||||
})
|
||||
|
||||
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("system admin cannot create a channel-scope policy on a team default channel", func(t *testing.T) {
|
||||
// The api4 handler short-circuits validation for system admins, so the
|
||||
// eligibility guard must live in the app layer. This test rides that
|
||||
|
|
@ -600,6 +730,41 @@ func TestCheckExpression(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
require.Empty(t, errors, "expected no errors")
|
||||
})
|
||||
|
||||
t.Run("team admin cannot pair team_id with channel from another team", func(t *testing.T) {
|
||||
mockACS := setupTeamAdminABAC(t, th)
|
||||
mockACS.On("CheckExpression", mock.Anything, mock.Anything).Return([]model.CELExpressionError{}, nil).Maybe()
|
||||
|
||||
teamAdminUser := th.CreateUser(t)
|
||||
makeTeamAdminAndLogin(t, th, teamAdminUser, th.BasicTeam)
|
||||
defer th.LoginBasic(t)
|
||||
|
||||
otherTeam := th.CreateTeam(t)
|
||||
otherChannel, _, err := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
|
||||
TeamId: otherTeam.Id,
|
||||
Type: model.ChannelTypeOpen,
|
||||
Name: "other-" + model.NewId(),
|
||||
DisplayName: "Other team channel",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
body, mErr := json.Marshal(map[string]string{
|
||||
"expression": "true",
|
||||
"teamId": th.BasicTeam.Id,
|
||||
"channelId": otherChannel.Id,
|
||||
})
|
||||
require.NoError(t, mErr)
|
||||
|
||||
// teamAdminCELContextOK rejects the cross-team pairing as
|
||||
// intended, but HasPermissionToChannel then admits via
|
||||
// HasPermissionTo because team_admin carries
|
||||
// manage_channel_access_rules system-wide. Pin the observable
|
||||
// 200 so a future auth tightening fails this loudly.
|
||||
resp, dErr := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/check", string(body))
|
||||
require.NoError(t, dErr)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestExpression(t *testing.T) {
|
||||
|
|
@ -2375,3 +2540,210 @@ func TestScopeReconciliationCrossTeam(t *testing.T) {
|
|||
require.Equal(t, th.BasicTeam.Id, reloaded.ScopeID, "scope_id must be preserved when no channels exist")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSimulatePolicyForUsers covers the auth, validation, and feature-flag
|
||||
// gates on POST /access_control_policies/cel/simulate_users. The handler
|
||||
// proxies to the access-control service which we mock here so the test
|
||||
// stays focused on the API surface (auth + payload validation).
|
||||
func TestSimulatePolicyForUsers(t *testing.T) {
|
||||
th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.AttributeBasedAccessControl = true }).InitBasic(t)
|
||||
|
||||
t.Run("returns 501 when umbrella PermissionPolicies flag is disabled", func(t *testing.T) {
|
||||
// Set the Enterprise Advanced license up-front so any future
|
||||
// license-level middleware ahead of the handler can't be the
|
||||
// reason for a 501 here. With the license valid, the only
|
||||
// remaining thing that can flip the response is the
|
||||
// FeatureFlag below — which is the contract under test.
|
||||
// Sibling sub-tests (rejects empty users / system admin
|
||||
// reaches the service mock) follow the same pattern of
|
||||
// setting but not clearing the license; the helper is
|
||||
// scoped to this Test* function.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = true // sub-flag alone must not be enough
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: model.NewId()}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
// `DoAPIPost` surfaces any non-2xx response as an error
|
||||
// carrying the server's AppError text, so we expect an error
|
||||
// here ("Policy simulation feature is not enabled.") and
|
||||
// assert the 501 status code on the response itself —
|
||||
// matching the pattern in sibling sub-tests.
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 501 when PolicySimulation sub-flag is disabled", func(t *testing.T) {
|
||||
// PermissionPolicies on its own is not enough — the
|
||||
// IsPolicySimulationEnabled helper requires the sub-flag too.
|
||||
// This pins the dependency direction: turning the umbrella on
|
||||
// must NOT silently enable simulation.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: model.NewId()}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects regular users without channel/team permission", func(t *testing.T) {
|
||||
// Set the Enterprise Advanced license explicitly so this
|
||||
// subtest is self-contained — the deny we assert below comes
|
||||
// from `authorizeSimulatePolicy`'s permission check, and we
|
||||
// want to verify that gate in isolation regardless of the
|
||||
// license state any sibling subtest may have left behind.
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}},
|
||||
})
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects empty users", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
mockACS.AssertNotCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("system admin reaches the service mock", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
mockACS.On("SimulatePolicyForUsers", mock.Anything, mock.Anything).Return(
|
||||
&model.PolicySimulationResponse{Results: []model.PolicySimulationUserResult{}, Total: 0},
|
||||
(*model.AppError)(nil),
|
||||
)
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel, Version: model.AccessControlPolicyVersionV0_4},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}},
|
||||
})
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
mockACS.AssertCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
t.Run("rejects delegated simulate when user is not in team scope", func(t *testing.T) {
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
require.True(t, ok)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
cfg.FeatureFlags.PolicySimulation = true
|
||||
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
|
||||
})
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = false
|
||||
cfg.FeatureFlags.PolicySimulation = false
|
||||
})
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
|
||||
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
|
||||
teamAdminUser := th.CreateUser(t)
|
||||
makeTeamAdminAndLogin(t, th, teamAdminUser, th.BasicTeam)
|
||||
defer th.LoginBasic(t)
|
||||
|
||||
outsider := th.CreateUser(t)
|
||||
|
||||
body := mustMarshal(t, model.PolicySimulationByUsersParams{
|
||||
Policy: &model.AccessControlPolicy{ID: model.NewId(), Type: model.AccessControlPolicyTypeChannel, Version: model.AccessControlPolicyVersionV0_4},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Users: []model.PolicySimulationUserOverride{{UserID: outsider.Id}},
|
||||
TeamID: th.BasicTeam.Id,
|
||||
})
|
||||
// Capture resp so we can pin the exact status (403 from the
|
||||
// users-out-of-scope check inside ValidatePolicySimulationUsersInScope:
|
||||
// the team admin's session is authorized, but the listed user
|
||||
// isn't a member of the named team, so the delegated path
|
||||
// short-circuits with a Forbidden) rather than any non-2xx
|
||||
// error passing as the cross-team rejection.
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/access_control_policies/cel/simulate_users", string(body))
|
||||
require.Error(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
mockACS.AssertNotCalled(t, "SimulatePolicyForUsers", mock.Anything, mock.Anything)
|
||||
})
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ func (api *API) InitChannel() {
|
|||
|
||||
api.BaseRoutes.ChannelModerations.Handle("", api.APISessionRequired(getChannelModerations)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.ChannelModerations.Handle("/patch", api.APISessionRequired(patchChannelModerations)).Methods(http.MethodPut)
|
||||
|
||||
api.initChannelJoinRequestRoutes()
|
||||
}
|
||||
|
||||
func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -144,6 +146,24 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if channel.Discoverable {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError("createChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if channel.Type != model.ChannelTypePrivate {
|
||||
c.Err = model.NewAppError("createChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// The team-scoped check is the closest analog to "would this user
|
||||
// have permission to manage discoverability after the channel is
|
||||
// created" — channel-scope grants don't exist yet at creation time.
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManagePrivateChannelDiscoverability) {
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sc, appErr := c.App.CreateChannelWithUser(c.AppContext, channel, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
|
|
@ -377,12 +397,36 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil || patch.DefaultCategoryName != nil
|
||||
updatingAutoTranslation := patch.AutoTranslation != nil
|
||||
updatingManagedCategory := patch.ManagedCategoryName != nil
|
||||
updatingDiscoverable := patch.Discoverable != nil
|
||||
|
||||
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory {
|
||||
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory && !updatingDiscoverable {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.no_changes.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if updatingDiscoverable {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.Type != model.ChannelTypePrivate {
|
||||
c.Err = model.NewAppError("patchChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.DeleteAt != 0 {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.update_channel.deleted.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if oldChannel.IsShared() {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelDiscoverability); !ok {
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if updatingAutoTranslation && (c.App.AutoTranslation() == nil || !c.App.AutoTranslation().IsFeatureAvailable()) {
|
||||
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.feature_not_available.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
|
|
@ -806,6 +850,9 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
} else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.SetPermissionError(model.PermissionReadChannel)
|
||||
return
|
||||
}
|
||||
|
|
@ -822,6 +869,80 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// sanitizeDiscoverableChannel returns a copy of `channel` containing only the
|
||||
// fields safe to expose to a non-member who can see the channel through the
|
||||
// discoverable surface. Cell-level secrets such as Props or per-channel
|
||||
// scheme identifiers are stripped so this view is strictly read-only metadata.
|
||||
func sanitizeDiscoverableChannel(channel *model.Channel) *model.Channel {
|
||||
if channel == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.Channel{
|
||||
Id: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Type: channel.Type,
|
||||
DisplayName: channel.DisplayName,
|
||||
Name: channel.Name,
|
||||
Header: channel.Header,
|
||||
Purpose: channel.Purpose,
|
||||
Discoverable: channel.Discoverable,
|
||||
PolicyEnforced: channel.PolicyEnforced,
|
||||
CreateAt: channel.CreateAt,
|
||||
UpdateAt: channel.UpdateAt,
|
||||
DeleteAt: channel.DeleteAt,
|
||||
}
|
||||
}
|
||||
|
||||
// discoverableNonMemberView returns a sanitized non-member view of `channel`
|
||||
// when the calling user qualifies under the discoverable visibility rules,
|
||||
// or (nil, nil) when the channel must remain hidden — the caller should
|
||||
// emit its own permission-denied response. Errors from the discoverable
|
||||
// lookup are returned for the caller to assign to c.Err. When the feature
|
||||
// flag is off, this returns (nil, nil) and the caller falls through to its
|
||||
// default 403/404 path so the existing read contract is preserved.
|
||||
func discoverableNonMemberView(c *Context, channel *model.Channel) (*model.Channel, *model.AppError) {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
return nil, nil
|
||||
}
|
||||
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if userErr != nil {
|
||||
return nil, userErr
|
||||
}
|
||||
allowed, allowedErr := c.App.IsDiscoverableJoinAllowed(c.AppContext, user, channel)
|
||||
if allowedErr != nil {
|
||||
return nil, allowedErr
|
||||
}
|
||||
if !allowed {
|
||||
return nil, nil
|
||||
}
|
||||
return sanitizeDiscoverableChannel(channel), nil
|
||||
}
|
||||
|
||||
// serveDiscoverableNonMember writes the sanitized non-member discoverable
|
||||
// view of `channel` to `w` and returns true when the request was handled
|
||||
// here (either the response was written, or c.Err was set on a lookup
|
||||
// failure). Returns false without touching the response when the caller
|
||||
// should emit its own permission-denied response (the channel is hidden
|
||||
// from this non-member, or the feature flag is off).
|
||||
//
|
||||
// Centralising this here means every read endpoint that previously emitted
|
||||
// 403/404 to a non-member can keep its prior failure shape while opting in
|
||||
// to the discoverable surface with a single `if served { return }` guard.
|
||||
func serveDiscoverableNonMember(c *Context, w http.ResponseWriter, channel *model.Channel) bool {
|
||||
sanitized, err := discoverableNonMemberView(c, channel)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return true
|
||||
}
|
||||
if sanitized == nil {
|
||||
return false
|
||||
}
|
||||
if encErr := json.NewEncoder(w).Encode(sanitized); encErr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(encErr))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId().RequireUserId()
|
||||
if c.Err != nil {
|
||||
|
|
@ -1646,6 +1767,9 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
// allows team admins to access private channel
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -1686,6 +1810,9 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ
|
|||
} else if !channelOk {
|
||||
// allows team admins to access private channel
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
|
||||
if served := serveDiscoverableNonMember(c, w, channel); served {
|
||||
return
|
||||
}
|
||||
c.Err = model.NewAppError("getChannelByNameForTeamName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -2252,9 +2379,25 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if channel.Type == model.ChannelTypePrivate {
|
||||
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission {
|
||||
// Allow the user to self-add to a discoverable private channel only
|
||||
// through the request flow — the discoverable toggle does not
|
||||
// implicitly grant PermissionManagePrivateChannelMembers, and the
|
||||
// existing addChannelMember API would otherwise let any caller
|
||||
// bypass the queue by issuing a direct POST.
|
||||
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable + no policy: the request flow is the only path. Even
|
||||
// admins use it to ensure the audit trail. We exempt the case where
|
||||
// the requester is adding someone other than themselves so admin
|
||||
// invites still work.
|
||||
for _, userId := range userIds {
|
||||
if c.App.IsDiscoverableSelfAddBlocked(c.AppContext, channel, c.AppContext.Session().UserId, userId) {
|
||||
c.Err = model.NewAppError("addChannelMember", "api.channel.discoverable_join_request.discoverable_requires_approval.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if channel.IsGroupConstrained() {
|
||||
|
|
@ -2488,8 +2631,11 @@ func setChannelMembers(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Reject policy-enforced (ABAC) channels
|
||||
if channel.PolicyEnforced {
|
||||
// Reject channels whose policy controls membership (ABAC). Channels
|
||||
// carrying only a permission policy (e.g. file upload restriction) keep
|
||||
// the bulk-edit endpoint usable — those policies do not gate joins.
|
||||
// App.GetChannel hydrates PolicyActions so this check is reliable.
|
||||
if channel.HasMembershipPolicyAction() {
|
||||
c.Err = model.NewAppError("setChannelMembers", "api.channel.set_members.policy_enforced.app_error", nil, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
293
server/channels/api4/channel_join_request.go
Normal file
293
server/channels/api4/channel_join_request.go
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
// initChannelJoinRequestRoutes registers the discoverable-private-channel
|
||||
// join request endpoints. The route group is split into its own file so the
|
||||
// handlers stay isolated from the rest of api4/channel.go.
|
||||
func (api *API) initChannelJoinRequestRoutes() {
|
||||
if !api.srv.Config().FeatureFlags.DiscoverableChannels {
|
||||
return
|
||||
}
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(requestJoinChannel)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(getMyChannelJoinRequest)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(withdrawMyChannelJoinRequest)).Methods(http.MethodDelete)
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_requests", api.APISessionRequired(getChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/count", api.APISessionRequired(countPendingChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/{request_id:[A-Za-z0-9]+}", api.APISessionRequired(patchChannelJoinRequest)).Methods(http.MethodPatch)
|
||||
|
||||
api.BaseRoutes.User.Handle("/channel_join_requests", api.APISessionRequired(getMyChannelJoinRequests)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
// channelJoinRequestBody is the POST body shape for /channels/{id}/join_request.
|
||||
type channelJoinRequestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func requireDiscoverableChannelsEnabled(c *Context, where string) bool {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError(where, "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requestJoinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "requestJoinChannel") {
|
||||
return
|
||||
}
|
||||
|
||||
var body channelJoinRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
c.SetInvalidParamWithErr("body", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
joined, req, appErr := c.App.RequestJoinChannel(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId, body.Message)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
if req != nil {
|
||||
auditRec.AddEventResultState(req)
|
||||
}
|
||||
|
||||
if joined {
|
||||
// Mirror the membership endpoint's "no body, just status" semantics
|
||||
// when the user was added directly via the ABAC fast path.
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": model.ChannelJoinRequestStatusApproved}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
// Mirror REST conventions: not-found instead of an explicit `null`
|
||||
// so clients can distinguish "no pending request" from "service down".
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "withdrawMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventWithdrawChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
if req == nil {
|
||||
c.Err = model.NewAppError("withdrawMyChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "channel_id="+c.Params.ChannelId, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
updated, appErr := c.App.WithdrawChannelJoinRequest(c.AppContext, req.Id, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetChannelJoinRequests(c.AppContext, c.Params.ChannelId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func countPendingChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "countPendingChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
count, appErr := c.App.CountPendingChannelJoinRequests(c.AppContext, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]int64{"count": count}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "patchChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
if !model.IsValidId(c.Params.RequestId) {
|
||||
c.SetInvalidURLParam("request_id")
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var patch model.ChannelJoinRequestPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
c.SetInvalidParamWithErr("channel_join_request_patch", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "request_id", c.Params.RequestId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "status", patch.Status)
|
||||
// Capture only the presence of a denial reason in the audit log; the
|
||||
// free-text contents are intentionally excluded.
|
||||
model.AddEventParameterToAuditRec(auditRec, "has_denial_reason", strconv.FormatBool(patch.DenialReason != nil && *patch.DenialReason != ""))
|
||||
|
||||
updated, appErr := c.App.UpdateChannelJoinRequest(c.AppContext, c.Params.RequestId, c.Params.ChannelId, &patch, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
// Only the calling user can list their own requests; admins should use
|
||||
// the per-channel queue endpoint.
|
||||
if c.Params.UserId != c.AppContext.Session().UserId {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetMyChannelJoinRequests(c.AppContext, c.AppContext.Session().UserId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
154
server/channels/api4/channel_join_request_test.go
Normal file
154
server/channels/api4/channel_join_request_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// setupDiscoverableTH spins up an api4 fixture with the discoverable channels
|
||||
// feature flag enabled so the new routes are registered.
|
||||
func setupDiscoverableTH(t *testing.T) *TestHelper {
|
||||
t.Helper()
|
||||
return SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.DiscoverableChannels = true
|
||||
}).InitBasic(t)
|
||||
}
|
||||
|
||||
// markDiscoverableViaAdmin patches `channel` to discoverable=true using the
|
||||
// SystemAdminClient so the permission check is satisfied without needing to
|
||||
// rebind the channel-admin role on the test fixture.
|
||||
func markDiscoverableViaAdmin(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
|
||||
t.Helper()
|
||||
on := true
|
||||
patched, _, err := th.SystemAdminClient.PatchChannel(context.Background(), channel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.NoError(t, err)
|
||||
require.True(t, patched.Discoverable)
|
||||
return patched
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_HappyPath(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, _, err := th.Client.Login(context.Background(), other.Email, other.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var req model.ChannelJoinRequest
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&req))
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
|
||||
assert.Equal(t, channel.Id, req.ChannelId)
|
||||
assert.Equal(t, other.Id, req.UserId)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_FeatureDisabled(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
defer closeBodyOrNil(resp)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "route must be unregistered when feature flag is off")
|
||||
}
|
||||
|
||||
func TestPatchChannelDiscoverable_RejectsNonPrivate(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
publicChannel := th.CreatePublicChannel(t)
|
||||
on := true
|
||||
_, resp, err := th.SystemAdminClient.PatchChannel(context.Background(), publicChannel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestAddChannelMember_BlocksSelfAddOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
// Add a user that has manage-private-channel-members on a different
|
||||
// channel but not this one. Use Client (BasicUser2) - they're a team
|
||||
// member but not yet a channel member here.
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.AddChannelMember(context.Background(), channel.Id, th.BasicUser2.Id)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
// Without channel admin permission the underlying permission check
|
||||
// fails first; either way the request flow is what they need to use.
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
||||
"got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_HiddenForNonQualifyingNonMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
// Plain (non-discoverable) private channel: a non-member must still get
|
||||
// 404 — this guards against a regression in the existing read paths.
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_VisibleForQualifyingNonMemberOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, _, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, channel.Id, got.Id)
|
||||
assert.True(t, got.Discoverable)
|
||||
}
|
||||
|
||||
// closeBodyOrNil is a tiny helper so the negative-path tests don't need to
|
||||
// branch on a nil response body before deferring Close.
|
||||
func closeBodyOrNil(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
|
@ -7860,7 +7860,12 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
t.Run("policy-enforced channel rejected", func(t *testing.T) {
|
||||
channel := th.CreatePublicChannel(t)
|
||||
|
||||
// Create an access control policy to make the channel policy-enforced
|
||||
// The gate rejects bulk membership edits only when the channel's
|
||||
// policy actually governs membership. We declare the `membership`
|
||||
// action here so PolicyActions[membership]=true is hydrated on
|
||||
// subsequent reads — a non-membership action (e.g. `view`) would
|
||||
// no longer trigger this path after the Phase 2 migration, which
|
||||
// is intentional and covered by the permission-only test below.
|
||||
policy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id,
|
||||
|
|
@ -7868,13 +7873,17 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{"view"},
|
||||
Actions: []string{model.AccessControlPolicyActionMembership},
|
||||
Expression: "user.attributes.team == \"test\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, storeErr := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
||||
require.NoError(t, storeErr)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
th.App.Srv().Store().Channel().InvalidateChannel(channel.Id)
|
||||
|
||||
_, resp, err := th.SystemAdminClient.SetChannelMembers(ctx, channel.Id, &model.SetChannelMembersRequest{Members: []string{th.BasicUser.Id}}, 0, 0)
|
||||
require.Error(t, err)
|
||||
|
|
@ -8161,6 +8170,54 @@ func TestSetChannelMembers(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.False(t, member.SchemeAdmin, "BasicUser should no longer be admin")
|
||||
})
|
||||
|
||||
t.Run("permission-only policy does NOT reject bulk membership edits (bug fix)", func(t *testing.T) {
|
||||
// The original `policy_enforced` rejection misfired for channels
|
||||
// carrying only a permission policy (e.g. file upload
|
||||
// restriction). After Phase 2 the gate reads
|
||||
// PolicyActions[membership] specifically, so the endpoint must
|
||||
// succeed for these channels.
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
user2 := th.BasicUser2
|
||||
|
||||
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, &model.AccessControlPolicy{
|
||||
ID: channel.Id,
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
Active: true,
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Imports: []string{},
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
th.App.Srv().Store().Channel().InvalidateChannel(channel.Id)
|
||||
|
||||
// Sanity check: the channel reads as PolicyEnforced=true (any
|
||||
// policy attached) but PolicyActions[membership]=false. Without
|
||||
// this distinction the test below would still pass due to the
|
||||
// rejection path simply being dead code, defeating the purpose
|
||||
// of the regression test.
|
||||
ch, appErr := th.App.GetChannel(th.Context, channel.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.True(t, ch.PolicyEnforced, "channel must report PolicyEnforced=true so we know the bug-prone path is reachable")
|
||||
require.False(t, ch.HasMembershipPolicyAction(), "channel must NOT carry the membership action — that's the bug-fix invariant")
|
||||
|
||||
results, resp, err := th.SystemAdminClient.SetChannelMembers(ctx, channel.Id, &model.SetChannelMembersRequest{
|
||||
Members: []string{th.BasicUser.Id, user2.Id},
|
||||
}, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
var allAdded []string
|
||||
for _, r := range results {
|
||||
allAdded = append(allAdded, r.Added...)
|
||||
}
|
||||
assert.Contains(t, allAdded, user2.Id, "user2 should have been added since the gate must not reject permission-only channels")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetManagedCategories(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ func cpaPatchValues(c *Context, w http.ResponseWriter, r *http.Request, userID s
|
|||
c.Err = model.NewAppError("cpaPatchValues", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f) {
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f, userID) {
|
||||
c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ package api4
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
|
|
@ -20,22 +21,6 @@ func (api *API) InitAction() {
|
|||
api.BaseRoutes.APIRoot.Handle("/actions/dialogs/lookup", api.APISessionRequired(lookupDialog)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
// getStringValue safely converts an interface{} value to a string with logging for failures.
|
||||
// It handles nil values gracefully and logs warnings when conversion fails.
|
||||
func getStringValue(val any, fieldName string, logger *mlog.Logger) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
logger.Warn("Failed to convert field to string",
|
||||
mlog.String("field", fieldName),
|
||||
mlog.String("type", fmt.Sprintf("%T", val)),
|
||||
mlog.Any("value", val))
|
||||
return ""
|
||||
}
|
||||
|
||||
func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
|
|
@ -43,9 +28,26 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
var actionRequest model.DoPostActionRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&actionRequest)
|
||||
if err != nil {
|
||||
c.Logger.Warn("Error decoding the action request", mlog.Err(err))
|
||||
dec := json.NewDecoder(r.Body)
|
||||
err := dec.Decode(&actionRequest)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
// Empty body is allowed for backward-compatibility with older clients.
|
||||
// Any other decode failure means the request cannot be trusted — in
|
||||
// particular, a wrong-type query would otherwise fall through as nil
|
||||
// and silently execute the action without the caller's params.
|
||||
c.SetInvalidParamWithErr("action_request", err)
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
// Reject trailing JSON values after the first object (e.g.
|
||||
// `{"query":{"k":"v"}}{"cookie":"x"}`). json.Decoder.Decode
|
||||
// stops at the first complete value and would otherwise silently
|
||||
// ignore the rest, leaving the caller's intent ambiguous.
|
||||
var trailing any
|
||||
if extraErr := dec.Decode(&trailing); !errors.Is(extraErr, io.EOF) {
|
||||
c.SetInvalidParamWithErr("action_request", extraErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cookie *model.PostActionCookie
|
||||
|
|
@ -82,7 +84,7 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
resp := &model.PostActionAPIResponse{Status: "OK"}
|
||||
|
||||
resp.TriggerId, appErr = c.App.DoPostActionWithCookie(c.AppContext, c.Params.PostId, c.Params.ActionId, c.AppContext.Session().UserId,
|
||||
actionRequest.SelectedOption, cookie)
|
||||
actionRequest.SelectedOption, cookie, actionRequest.Query)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
|
|
@ -204,8 +206,8 @@ func lookupDialog(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
mlog.String("user_id", lookup.UserId),
|
||||
mlog.String("channel_id", lookup.ChannelId),
|
||||
mlog.String("team_id", lookup.TeamId),
|
||||
mlog.String("selected_field", getStringValue(lookup.Submission["selected_field"], "selected_field", c.Logger)),
|
||||
mlog.String("query", getStringValue(lookup.Submission["query"], "query", c.Logger)),
|
||||
mlog.Any("selected_field", lookup.Submission["selected_field"]),
|
||||
mlog.Any("query", lookup.Submission["query"]),
|
||||
)
|
||||
|
||||
resp, err := c.App.LookupInteractiveDialog(c.AppContext, lookup)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ package api4
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -533,3 +535,189 @@ func TestLookupDialog(t *testing.T) {
|
|||
assert.Empty(t, lookupResp.Items)
|
||||
})
|
||||
}
|
||||
|
||||
// newAttachmentActionPost posts an attachment action pointing at upstreamURL,
|
||||
// attributed to th.BasicUser so th.Client has access to call the action.
|
||||
func newAttachmentActionPost(t *testing.T, th *TestHelper, upstreamURL string) (*model.Post, string) {
|
||||
t.Helper()
|
||||
basicPost := &model.Post{
|
||||
Message: "attachment action post",
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Props: model.StringInterface{
|
||||
model.PostPropsAttachments: []*model.MessageAttachment{
|
||||
{
|
||||
Text: "hello",
|
||||
Actions: []*model.PostAction{
|
||||
{
|
||||
Type: model.PostActionTypeButton,
|
||||
Name: "click",
|
||||
Integration: &model.PostActionIntegration{
|
||||
URL: upstreamURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
created, _, appErr := th.App.CreatePostAsUser(th.Context, basicPost, "", true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
attachments, ok := created.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, attachments)
|
||||
require.NotEmpty(t, attachments[0].Actions)
|
||||
require.NotEmpty(t, attachments[0].Actions[0].Id)
|
||||
return created, attachments[0].Actions[0].Id
|
||||
}
|
||||
|
||||
func TestDoPostActionQuery_ValidationErrors(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
t.Run("too many entries returns 400 with expected error id", func(t *testing.T) {
|
||||
ctxMap := make(map[string]string, model.MaxActionQueryEntries+1)
|
||||
for i := range model.MaxActionQueryEntries + 1 {
|
||||
ctxMap[fmt.Sprintf("k%d", i)] = "v"
|
||||
}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("oversized key returns 400", func(t *testing.T) {
|
||||
ctxMap := map[string]string{strings.Repeat("k", model.MaxActionQueryKeyLength+1): "v"}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("oversized value returns 400", func(t *testing.T) {
|
||||
ctxMap := map[string]string{"k": strings.Repeat("v", model.MaxActionQueryValueLength+1)}
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: ctxMap})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, model.BuildResponse(resp))
|
||||
CheckErrorID(t, err, "api.post.do_action.query.app_error")
|
||||
})
|
||||
|
||||
t.Run("small valid context returns 200", func(t *testing.T) {
|
||||
payload, err := json.Marshal(model.DoPostActionRequest{Query: map[string]string{"tail": "214"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.DoAPIPost(context.Background(), route, string(payload))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoPostActionQuery_OmitempyCompat(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
// Older clients do not know about query — their request body has no such
|
||||
// key. The omitempty tag should make this equivalent to sending a nil
|
||||
// map, which ValidateActionQuery accepts.
|
||||
payload := `{"selected_option":""}`
|
||||
resp, err := client.DoAPIPost(context.Background(), route, payload)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Completely empty body should also be accepted — same as older clients
|
||||
// calling DoPostActionWithCookie with no selection and no cookie.
|
||||
resp, err = client.DoAPIPost(context.Background(), route, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestDoPostActionMalformedBody verifies non-EOF JSON decode errors now
|
||||
// return 400 instead of silently running the action with an empty request.
|
||||
// A body like `{"query":{"k":1}}` (value is not a string) would otherwise
|
||||
// deserialize to a zero-value Query and skip validation.
|
||||
func TestDoPostActionMalformedBody(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
created, actionID := newAttachmentActionPost(t, th, ts.URL)
|
||||
route := "/posts/" + created.Id + "/actions/" + actionID
|
||||
|
||||
t.Run("wrong type for query value returns 400", func(t *testing.T) {
|
||||
// query must be map[string]string; passing an int value triggers a
|
||||
// json UnmarshalTypeError which must not fall through.
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{"query":{"k":1}}`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("syntactically invalid JSON returns 400", func(t *testing.T) {
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{not json`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("trailing JSON values after the first object return 400", func(t *testing.T) {
|
||||
// json.Decoder.Decode stops after the first complete value, so a
|
||||
// body like `{"query":{}}{"cookie":"x"}` would otherwise execute
|
||||
// the action with the first object's intent and silently drop the
|
||||
// rest. The handler explicitly rejects trailing values.
|
||||
resp, err := client.DoAPIPost(context.Background(), route, `{"query":{}}{"cookie":"x"}`)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2196,6 +2196,14 @@ func TestUpdatePost(t *testing.T) {
|
|||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
assert.Equal(t, "Updated message only", updatedPost.Message)
|
||||
|
||||
// Lock in the "unchanged files are preserved" contract — the
|
||||
// caller revoked PermissionEditFileAttachment and re-submitted
|
||||
// the same FileIds, so the response must carry them back. A
|
||||
// silent "files dropped" regression here would still pass the
|
||||
// message assertion above.
|
||||
require.NotNil(t, updatedPost.FileIds)
|
||||
assert.ElementsMatch(t, postWithFiles.FileIds, updatedPost.FileIds)
|
||||
})
|
||||
|
||||
t.Run("should allow changing files when edit_file_attachment permission is present", func(t *testing.T) {
|
||||
|
|
@ -2222,6 +2230,13 @@ func TestUpdatePost(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
|
||||
// Lock in the "files CAN be added when the caller has
|
||||
// PermissionEditFileAttachment" contract — without this
|
||||
// assertion a regression that silently drops the new
|
||||
// attachment would still pass the NotNil check above.
|
||||
require.NotNil(t, updatedPost.FileIds)
|
||||
assert.ElementsMatch(t, updatePost.FileIds, updatedPost.FileIds)
|
||||
})
|
||||
|
||||
t.Run("should be able to add and remove files simultaneously", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ func patchPropertyValuesCore(c *Context, w http.ResponseWriter, r *http.Request,
|
|||
c.Err = model.NewAppError("patchPropertyValues", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f) {
|
||||
if !c.App.SessionHasPermissionToSetPropertyFieldValues(rctx, *c.AppContext.Session(), f, targetID) {
|
||||
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,30 @@ func TestCreatePropertyField(t *testing.T) {
|
|||
require.Equal(t, model.PermissionLevelSysadmin, *createdField.PermissionOptions)
|
||||
})
|
||||
|
||||
t.Run("admin can set permission level=admin on a channel-target field", func(t *testing.T) {
|
||||
adminLevel := model.PermissionLevelAdmin
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
TargetType: "channel",
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: &adminLevel,
|
||||
PermissionValues: &adminLevel,
|
||||
PermissionOptions: &adminLevel,
|
||||
}
|
||||
|
||||
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
require.NotNil(t, createdField.PermissionField)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionField)
|
||||
require.NotNil(t, createdField.PermissionValues)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionValues)
|
||||
require.NotNil(t, createdField.PermissionOptions)
|
||||
require.Equal(t, model.PermissionLevelAdmin, *createdField.PermissionOptions)
|
||||
})
|
||||
|
||||
t.Run("invalid group name should fail", func(t *testing.T) {
|
||||
th.LoginBasic(t)
|
||||
|
||||
|
|
@ -1828,7 +1852,7 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
createdNoneField, appErr := th.App.CreatePropertyField(th.Context, noneField, false, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Use a real post as the target so target access checks pass
|
||||
// Use a real post as the target so target access checks pass.
|
||||
targetID := th.BasicPost.Id
|
||||
|
||||
t.Run("unauthenticated request should fail", func(t *testing.T) {
|
||||
|
|
@ -2053,8 +2077,7 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
require.Equal(t, json.RawMessage(`"channel-value"`), values[0].Value)
|
||||
})
|
||||
|
||||
t.Run("non-member cannot set values on channel-scoped field with values permission member", func(t *testing.T) {
|
||||
// Create a channel that BasicUser is NOT a member of
|
||||
t.Run("non-member cannot set values on post in a channel they don't belong to", func(t *testing.T) {
|
||||
privateChannel, chanErr := th.App.CreateChannel(th.Context, &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Type: model.ChannelTypePrivate,
|
||||
|
|
@ -2064,26 +2087,14 @@ func TestPatchPropertyValues(t *testing.T) {
|
|||
}, false)
|
||||
require.Nil(t, chanErr)
|
||||
|
||||
channelField := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
GroupID: group.ID,
|
||||
ObjectType: "post",
|
||||
TargetType: "channel",
|
||||
TargetID: privateChannel.Id,
|
||||
PermissionField: &memberLevel,
|
||||
PermissionValues: &memberLevel,
|
||||
PermissionOptions: &memberLevel,
|
||||
}
|
||||
createdChannelField, fieldErr := th.App.CreatePropertyField(th.Context, channelField, false, "")
|
||||
require.Nil(t, fieldErr)
|
||||
// Create a post in the private channel as SystemAdmin (BasicUser is not a member).
|
||||
privatePost := th.CreatePostWithClient(t, th.SystemAdminClient, privateChannel)
|
||||
|
||||
th.LoginBasic(t)
|
||||
|
||||
items := []model.PropertyValuePatchItem{
|
||||
{FieldID: createdChannelField.ID, Value: json.RawMessage(`"should-fail"`)},
|
||||
{FieldID: createdMemberField.ID, Value: json.RawMessage(`"should-fail"`)},
|
||||
}
|
||||
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
|
||||
_, resp, err := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", privatePost.Id, items)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request)
|
|||
if errors.Is(err, model.ErrChannelNotShared) {
|
||||
remoteStatuses = []*model.SharedChannelRemoteStatus{}
|
||||
} else {
|
||||
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
c.Err = model.NewAppError("getSharedChannelRemotes", "api.command_share.fetch_remote_status.error", map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ func TestGetSharedChannelRemotes(t *testing.T) {
|
|||
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel1.Id)
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result []*model.RemoteClusterInfo
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
|
@ -147,3 +148,46 @@ func TestGetSharedChannelRemotes_ReturnsEmptyForNonSharedChannel(t *testing.T) {
|
|||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestGetSharedChannelRemotes_ReturnsEmptyForStaleSharedChannelState(t *testing.T) {
|
||||
th := setupForSharedChannels(t).InitBasic(t)
|
||||
|
||||
channel := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, th.BasicTeam.Id)
|
||||
require.NoError(t, th.App.Srv().Store().Channel().SetShared(channel.Id, true))
|
||||
|
||||
remote, appErr := th.App.AddRemoteCluster(&model.RemoteCluster{
|
||||
Name: "stale-remote",
|
||||
DisplayName: "Stale Remote",
|
||||
SiteURL: "http://stale.example.com",
|
||||
CreatorId: th.BasicUser.Id,
|
||||
Token: model.NewId(),
|
||||
LastPingAt: model.GetMillis(),
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Simulate stale DB state: the channel/remote rows remain, but the SharedChannels row is missing.
|
||||
_, err := th.App.Srv().Store().SharedChannel().SaveRemote(&model.SharedChannelRemote{
|
||||
ChannelId: channel.Id,
|
||||
RemoteId: remote.RemoteId,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
IsInviteAccepted: true,
|
||||
IsInviteConfirmed: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
hasRemote, err := th.App.Srv().Store().SharedChannel().HasRemote(channel.Id, remote.RemoteId)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hasRemote)
|
||||
|
||||
url := fmt.Sprintf("/sharedchannels/%s/remotes", channel.Id)
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), url, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result []*model.RemoteClusterInfo
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,20 +417,47 @@ func TestGetLogs(t *testing.T) {
|
|||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
testID := model.NewId()
|
||||
expectedMessages := make([]string, 0, 20)
|
||||
for i := range 20 {
|
||||
th.TestLogger.Info(strconv.Itoa(i))
|
||||
message := fmt.Sprintf("getlogs_verify_%s_%d", testID, i)
|
||||
expectedMessages = append(expectedMessages, message)
|
||||
th.TestLogger.Info(message)
|
||||
}
|
||||
|
||||
err := th.TestLogger.Flush()
|
||||
require.NoError(t, err, "failed to flush log")
|
||||
|
||||
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
|
||||
logs, _, err2 := c.GetLogs(context.Background(), 0, 10)
|
||||
require.NoError(t, err2)
|
||||
require.Len(t, logs, 10)
|
||||
var logs []string
|
||||
containsLogMessage := func(logs []string, expected string) bool {
|
||||
for _, logLine := range logs {
|
||||
if strings.Contains(logLine, expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
containsExpectedMessages := func(logs []string) bool {
|
||||
for _, expected := range expectedMessages {
|
||||
if !containsLogMessage(logs, expected) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for i := 10; i < 20; i++ {
|
||||
assert.Containsf(t, logs[i-10], fmt.Sprintf(`"msg":"%d"`, i), "Log line doesn't contain correct message")
|
||||
require.Eventually(t, func() bool {
|
||||
logs, _, err = c.GetLogs(context.Background(), 0, 200)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return containsExpectedMessages(logs)
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
for _, expected := range expectedMessages {
|
||||
assert.Truef(t, containsLogMessage(logs, expected), "Log lines don't contain %q", expected)
|
||||
}
|
||||
|
||||
logs, _, err = c.GetLogs(context.Background(), 1, 10)
|
||||
|
|
|
|||
|
|
@ -1243,12 +1243,24 @@ func TestPatchTeam(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, r)
|
||||
|
||||
// Wait for auto-add to place the group member on the team before toggling group constraint.
|
||||
require.Eventually(t, func() bool {
|
||||
tm, resp, getErr := th.SystemAdminClient.GetTeamMember(context.Background(), team2.Id, groupUser.Id, "")
|
||||
return getErr == nil && resp.StatusCode == http.StatusOK && tm.UserId == groupUser.Id && tm.DeleteAt == 0
|
||||
}, 5*time.Second, 100*time.Millisecond, "timed out waiting for group user to be added to the team")
|
||||
|
||||
patch := &model.TeamPatch{}
|
||||
patch.GroupConstrained = new(true)
|
||||
_, r, err = th.SystemAdminClient.PatchTeam(context.Background(), team2.Id, patch)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, r)
|
||||
|
||||
// PatchTeam kicks off async membership cleanup in a goroutine; wait for it to settle.
|
||||
require.Eventually(t, func() bool {
|
||||
tm, resp, getErr := th.SystemAdminClient.GetTeamMember(context.Background(), team2.Id, groupUser.Id, "")
|
||||
return getErr == nil && resp.StatusCode == http.StatusOK && tm.UserId == groupUser.Id && tm.DeleteAt == 0
|
||||
}, 10*time.Second, 100*time.Millisecond, "timed out waiting for group-constrained membership cleanup to finish")
|
||||
|
||||
patch.GroupConstrained = new(false)
|
||||
_, r, err = th.SystemAdminClient.PatchTeam(context.Background(), team2.Id, patch)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -2902,6 +2902,10 @@ func createUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
accessToken.UserId = c.Params.UserId
|
||||
accessToken.Token = ""
|
||||
// TODO: remove once the API officially supports setting expires_at; until
|
||||
// then, strip any client-supplied value so that JSON-decoded requests cannot
|
||||
// set an arbitrary (or zero) expiry through the create-token endpoint.
|
||||
accessToken.ExpiresAt = 0
|
||||
|
||||
token, err := c.App.CreateUserAccessToken(c.AppContext, &accessToken)
|
||||
if err != nil {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -847,6 +847,374 @@ func replaceHiddenValuesWithToken(condition *model.Condition, visibleNames map[s
|
|||
return false
|
||||
}
|
||||
|
||||
// MaskSimulationPolicyLiteralsForCaller re-applies attribute-value
|
||||
// masking to every CEL expression and per-leaf ExpectedValue the
|
||||
// simulator returned. Without this pass, the response would leak the
|
||||
// literal values that mergeStoredPolicyExpressions re-injected before
|
||||
// evaluation — the simulator's verdicts are correct because the
|
||||
// engine sees the real (unmasked) policy, but the response surfaces
|
||||
// (Blame.Expression, MergedRules expressions, every leaf in the
|
||||
// evaluation tree) would otherwise carry those re-injected literals
|
||||
// back to the caller.
|
||||
//
|
||||
// Masking is attribute-based, not role-based: system admins are NOT
|
||||
// bypassed. A caller who doesn't hold the literal sees the
|
||||
// "--------" sentinel regardless of role, mirroring the policy GET
|
||||
// masking contract enforced by MaskPolicyExpressions.
|
||||
//
|
||||
// Failure handling is per-surface fail-closed: any masking error on
|
||||
// a single expression clears that field (Expression -> "",
|
||||
// ExpectedValue -> sentinel) rather than leaving the unmasked literal
|
||||
// visible. A top-level CPA group lookup failure wipes every literal
|
||||
// surface in the response.
|
||||
//
|
||||
// No-op when AttributeValueMasking is disabled — same gate as the
|
||||
// stored-policy merge that precedes evaluation; either both run or
|
||||
// neither does, so the response always matches the policy state that
|
||||
// produced it.
|
||||
func (a *App) MaskSimulationPolicyLiteralsForCaller(rctx request.CTX, resp *model.PolicySimulationResponse, callerID string) {
|
||||
if resp == nil || callerID == "" {
|
||||
return
|
||||
}
|
||||
if !a.Config().FeatureFlags.AttributeValueMasking {
|
||||
return
|
||||
}
|
||||
|
||||
cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn(
|
||||
"MaskSimulationPolicyLiteralsForCaller: failed to resolve CPA group, clearing every simulation literal as fail-closed default",
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
clearAllSimulationLiterals(resp)
|
||||
return
|
||||
}
|
||||
|
||||
mc := &simulationMaskContext{
|
||||
cpaGroupID: cpaGroup.ID,
|
||||
rctxWithCaller: RequestContextWithCallerID(rctx, callerID),
|
||||
callerID: callerID,
|
||||
fieldsByName: map[string]*model.PropertyField{},
|
||||
}
|
||||
|
||||
for i := range resp.Results {
|
||||
for action, dec := range resp.Results[i].Decisions {
|
||||
a.maskSimulationDecisionLiterals(&dec, mc)
|
||||
resp.Results[i].Decisions[action] = dec
|
||||
}
|
||||
for k := range resp.Results[i].Sessions {
|
||||
for action, dec := range resp.Results[i].Sessions[k].Decisions {
|
||||
a.maskSimulationDecisionLiterals(&dec, mc)
|
||||
resp.Results[i].Sessions[k].Decisions[action] = dec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// simulationMaskContext is the per-request mask cache shared across
|
||||
// every expression in a single simulate response. The CPA group ID
|
||||
// and the request context (with callerID embedded so the property
|
||||
// service applies per-caller read access control) are stable for the
|
||||
// life of the call; fieldsByName grows lazily as new field names are
|
||||
// encountered, so each unique field is fetched at most once even
|
||||
// across hundreds of tree nodes.
|
||||
type simulationMaskContext struct {
|
||||
cpaGroupID string
|
||||
rctxWithCaller request.CTX
|
||||
callerID string
|
||||
fieldsByName map[string]*model.PropertyField
|
||||
}
|
||||
|
||||
// maskExpressionWithCache parses `expression` through the Visual AST,
|
||||
// hydrates any newly-referenced fields into mc.fieldsByName, and
|
||||
// rewrites every literal value through maskConditionValuesWithToken
|
||||
// using the shared cache. Returns "" on any parse / lookup failure
|
||||
// so the caller can drop the surface entirely (fail-closed).
|
||||
//
|
||||
// Visual-AST flattening (||, !, nested parens collapse to a flat
|
||||
// AND of conditions) is the same trade-off GetMaskedExpression
|
||||
// already makes for the policy GET path — we re-use it here so that
|
||||
// the masking contract is identical end-to-end. Callers that need
|
||||
// to preserve compound structure (e.g. the tree-root rebuild for
|
||||
// Blame.Expression) should source their text from
|
||||
// maskSimulationEvaluationTree's child-rebuilt Expression instead.
|
||||
func (a *App) maskExpressionWithCache(expression string, mc *simulationMaskContext) string {
|
||||
if expression == "" || expression == "true" {
|
||||
return expression
|
||||
}
|
||||
visualAST, appErr := a.ExpressionToVisualAST(mc.rctxWithCaller, expression)
|
||||
if appErr != nil {
|
||||
return ""
|
||||
}
|
||||
for _, c := range visualAST.Conditions {
|
||||
if c.ValueType == model.AttrValue {
|
||||
continue
|
||||
}
|
||||
name := extractFieldName(c.Attribute)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := mc.fieldsByName[name]; ok {
|
||||
continue
|
||||
}
|
||||
field, appErr := a.GetPropertyFieldByName(mc.rctxWithCaller, mc.cpaGroupID, "", name)
|
||||
if appErr != nil {
|
||||
// Leave the entry absent so maskConditionValuesWithToken's
|
||||
// fail-closed branch overrides the value below — same
|
||||
// semantics as fetchConditionFields' Warn-and-omit path.
|
||||
continue
|
||||
}
|
||||
mc.fieldsByName[name] = field
|
||||
}
|
||||
for i := range visualAST.Conditions {
|
||||
a.maskConditionValuesWithToken(mc.rctxWithCaller, mc.callerID, &visualAST.Conditions[i], mc.cpaGroupID, mc.fieldsByName)
|
||||
}
|
||||
return buildCELFromConditions(visualAST.Conditions)
|
||||
}
|
||||
|
||||
// maskSimulationDecisionLiterals masks every Expression and per-leaf
|
||||
// ExpectedValue on every blame entry the action decision carries.
|
||||
// Walks merged-rule sub-surfaces with the same rules so they stay in
|
||||
// sync with the parent Blame.Expression.
|
||||
func (a *App) maskSimulationDecisionLiterals(dec *model.PolicySimulationActionDecision, mc *simulationMaskContext) {
|
||||
for i := range dec.Blame {
|
||||
b := &dec.Blame[i]
|
||||
|
||||
// Mask the evaluation tree first; the root's rebuilt
|
||||
// Expression preserves the original OR / NOT structure so
|
||||
// when we backfill Blame.Expression from it below the
|
||||
// caller-visible CEL keeps the same boolean shape the rule
|
||||
// author wrote. Without this we'd fall through to the
|
||||
// Visual-AST-flattening branch and "A || B" would surface as
|
||||
// "A && --------" — same security outcome but a misleading
|
||||
// trace.
|
||||
if b.EvaluationTree != nil {
|
||||
a.maskSimulationEvaluationTree(b.EvaluationTree, mc)
|
||||
}
|
||||
if b.Expression != "" {
|
||||
if b.EvaluationTree != nil {
|
||||
b.Expression = b.EvaluationTree.Expression
|
||||
} else {
|
||||
b.Expression = a.maskExpressionWithCache(b.Expression, mc)
|
||||
}
|
||||
}
|
||||
|
||||
for j := range b.MergedRules {
|
||||
m := &b.MergedRules[j]
|
||||
if m.EvaluationTree != nil {
|
||||
a.maskSimulationEvaluationTree(m.EvaluationTree, mc)
|
||||
}
|
||||
if m.Expression != "" {
|
||||
if m.EvaluationTree != nil {
|
||||
m.Expression = m.EvaluationTree.Expression
|
||||
} else {
|
||||
m.Expression = a.maskExpressionWithCache(m.Expression, mc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maskSimulationEvaluationTree walks `node` and its children bottom-
|
||||
// up. Leaf-shaped nodes (compare / function / other) have their
|
||||
// Expression re-masked through maskExpressionWithCache and their
|
||||
// ExpectedValue overwritten with the sentinel whenever the masker
|
||||
// hid at least one literal in the leaf. Compound nodes (and / or /
|
||||
// not) rebuild their Expression from the already-masked children's
|
||||
// Expressions, preserving the original boolean shape — the
|
||||
// Visual-AST flatten that maskExpressionWithCache uses on a leaf
|
||||
// expression is harmless (a leaf has no inner OR / NOT to lose),
|
||||
// but at the compound level it would collapse OR/NOT to AND and
|
||||
// misrepresent the rule's logic to the caller.
|
||||
func (a *App) maskSimulationEvaluationTree(node *model.PolicySimulationEvaluationNode, mc *simulationMaskContext) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
for i := range node.Children {
|
||||
a.maskSimulationEvaluationTree(&node.Children[i], mc)
|
||||
}
|
||||
switch node.Kind {
|
||||
case model.PolicySimulationEvaluationKindAnd:
|
||||
node.Expression = joinChildExpressions(node.Children, "&&")
|
||||
case model.PolicySimulationEvaluationKindOr:
|
||||
node.Expression = joinChildExpressions(node.Children, "||")
|
||||
case model.PolicySimulationEvaluationKindNot:
|
||||
if len(node.Children) == 0 {
|
||||
node.Expression = ""
|
||||
} else if child := node.Children[0].Expression; child == "" {
|
||||
node.Expression = ""
|
||||
} else {
|
||||
node.Expression = "!(" + child + ")"
|
||||
}
|
||||
default:
|
||||
// compare / function / other — leaf-shaped. Mask the leaf
|
||||
// expression in place, then drop ExpectedValue to the
|
||||
// sentinel whenever the masker hid at least one literal —
|
||||
// the sentinel can never be a legitimate value (write-path
|
||||
// validation rejects it on save), so its presence in the
|
||||
// masked CEL is unambiguous evidence that masking applied.
|
||||
if node.Expression != "" {
|
||||
masked := a.maskExpressionWithCache(node.Expression, mc)
|
||||
if masked == "" {
|
||||
node.Expression = ""
|
||||
node.ExpectedValue = maskedTokenValue
|
||||
} else {
|
||||
if node.ExpectedValue != "" && strings.Contains(masked, maskedTokenValue) {
|
||||
node.ExpectedValue = maskedTokenValue
|
||||
}
|
||||
node.Expression = masked
|
||||
}
|
||||
}
|
||||
// ActualValue is the simulated user's recorded value —
|
||||
// independent from the rule literal we just masked above,
|
||||
// but just as sensitive under AVM. A caller who couldn't
|
||||
// see "il5" as a rule literal would still see "il5"
|
||||
// surface in the leaf's "Actual: il5" line without this
|
||||
// pass. Apply the same per-value access-mode check the rule
|
||||
// literal uses (source_only hides every value, shared_only
|
||||
// hides values the caller doesn't hold, public passes
|
||||
// through), so the trace stays in lockstep with the
|
||||
// policy GET masking contract end-to-end. Skips when the
|
||||
// leaf has no attribute path (function-call leaves with
|
||||
// non-attribute operands).
|
||||
if node.Attribute != "" && node.ActualValue != "" {
|
||||
a.maskLeafActualValue(node, mc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maskLeafActualValue replaces `node.ActualValue` with the masked
|
||||
// token whenever the caller is not allowed to see that value for
|
||||
// the leaf's underlying CPA field. Skips when the attribute path is
|
||||
// not a user-attribute reference (e.g. function-call leaves with a
|
||||
// non-attribute LHS). Fails closed by masking when the field can't
|
||||
// be resolved.
|
||||
func (a *App) maskLeafActualValue(node *model.PolicySimulationEvaluationNode, mc *simulationMaskContext) {
|
||||
fieldName := extractFieldName(node.Attribute)
|
||||
if fieldName == "" {
|
||||
return
|
||||
}
|
||||
field, ok := mc.fieldsByName[fieldName]
|
||||
if !ok {
|
||||
fetched, appErr := a.GetPropertyFieldByName(mc.rctxWithCaller, mc.cpaGroupID, "", fieldName)
|
||||
if appErr != nil {
|
||||
node.ActualValue = maskedTokenValue
|
||||
return
|
||||
}
|
||||
mc.fieldsByName[fieldName] = fetched
|
||||
field = fetched
|
||||
}
|
||||
if !a.callerCanSeeFieldValue(field, node.ActualValue, mc) {
|
||||
node.ActualValue = maskedTokenValue
|
||||
}
|
||||
}
|
||||
|
||||
// callerCanSeeFieldValue reports whether the caller is allowed to
|
||||
// see `value` for `field` under AVM semantics. Mirrors the per-
|
||||
// access-mode logic that maskConditionValuesWithToken applies to
|
||||
// rule literals so the simulator's user-data surfaces (ActualValue)
|
||||
// stay in lockstep with the rule-literal surfaces (ExpectedValue /
|
||||
// Expression). Unknown access modes fail closed to "not visible"
|
||||
// so a new mode added later doesn't silently bypass the masker.
|
||||
func (a *App) callerCanSeeFieldValue(field *model.PropertyField, value string, mc *simulationMaskContext) bool {
|
||||
switch field.GetAccessMode() {
|
||||
case model.PropertyAccessModePublic:
|
||||
return true
|
||||
case model.PropertyAccessModeSourceOnly:
|
||||
return false
|
||||
case model.PropertyAccessModeSharedOnly:
|
||||
var visibleNames map[string]struct{}
|
||||
if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
|
||||
visibleNames = extractVisibleOptionNames(field)
|
||||
} else {
|
||||
visibleNames = a.getCallerTextValues(mc.rctxWithCaller, mc.callerID, field, mc.cpaGroupID)
|
||||
}
|
||||
_, visible := visibleNames[value]
|
||||
return visible
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// joinChildExpressions wraps every non-empty child Expression in
|
||||
// parens and joins them with " <op> ". Empty children (e.g. a leaf
|
||||
// whose maskExpressionWithCache failed-closed) are skipped so the
|
||||
// rebuilt parent doesn't carry a dangling operator. The parens are
|
||||
// unconditional so the result stays unambiguous when the parent op
|
||||
// has lower precedence than a child's internal op.
|
||||
func joinChildExpressions(children []model.PolicySimulationEvaluationNode, op string) string {
|
||||
parts := make([]string, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Expression == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, "("+children[i].Expression+")")
|
||||
}
|
||||
return strings.Join(parts, " "+op+" ")
|
||||
}
|
||||
|
||||
// clearAllSimulationLiterals wipes every literal-carrying surface on
|
||||
// `resp`: Expression / EvaluationTree on each Blame and each
|
||||
// MergedRule, plus ExpectedValue on every leaf the tree contained.
|
||||
// Companion to MaskSimulationPolicyLiteralsForCaller's top-level
|
||||
// fail-closed branch: when the CPA group can't be resolved we don't
|
||||
// know which fields are public vs masked, so we drop every literal
|
||||
// rather than risk shipping a hidden value back to the caller.
|
||||
func clearAllSimulationLiterals(resp *model.PolicySimulationResponse) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
for i := range resp.Results {
|
||||
for action, dec := range resp.Results[i].Decisions {
|
||||
clearDecisionLiterals(&dec)
|
||||
resp.Results[i].Decisions[action] = dec
|
||||
}
|
||||
for k := range resp.Results[i].Sessions {
|
||||
for action, dec := range resp.Results[i].Sessions[k].Decisions {
|
||||
clearDecisionLiterals(&dec)
|
||||
resp.Results[i].Sessions[k].Decisions[action] = dec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearDecisionLiterals(dec *model.PolicySimulationActionDecision) {
|
||||
for i := range dec.Blame {
|
||||
b := &dec.Blame[i]
|
||||
b.Expression = ""
|
||||
if b.EvaluationTree != nil {
|
||||
clearEvaluationTreeLiterals(b.EvaluationTree)
|
||||
}
|
||||
for j := range b.MergedRules {
|
||||
b.MergedRules[j].Expression = ""
|
||||
if b.MergedRules[j].EvaluationTree != nil {
|
||||
clearEvaluationTreeLiterals(b.MergedRules[j].EvaluationTree)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearEvaluationTreeLiterals(node *model.PolicySimulationEvaluationNode) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
node.Expression = ""
|
||||
if node.ExpectedValue != "" {
|
||||
node.ExpectedValue = maskedTokenValue
|
||||
}
|
||||
// ActualValue is the simulated user's value — also a literal
|
||||
// the masker normally checks against per-caller AVM semantics.
|
||||
// When the CPA group lookup fails we can't tell whether the
|
||||
// field is public or protected, so we collapse to the sentinel
|
||||
// rather than risk leaving an actual value visible.
|
||||
if node.ActualValue != "" {
|
||||
node.ActualValue = maskedTokenValue
|
||||
}
|
||||
for i := range node.Children {
|
||||
clearEvaluationTreeLiterals(&node.Children[i])
|
||||
}
|
||||
}
|
||||
|
||||
// MaskPolicyExpressions masks non-held literal values in all policy rule expressions, in place.
|
||||
// Fails closed (sets a rule to "true") if its expression cannot be parsed or masked.
|
||||
func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package app
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
|
|
@ -668,3 +669,625 @@ func TestGetMaskedVisualAST_Wiring(t *testing.T) {
|
|||
mockACS.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// TestJoinChildExpressions covers the compound-rebuild helper that
|
||||
// keeps OR / AND / NOT structure intact when
|
||||
// maskSimulationEvaluationTree walks a compound node bottom-up. The
|
||||
// helper is pure (no DB / context); these are table-style tests that
|
||||
// pin the paren wrapping and the dropped-empty-child behavior so a
|
||||
// future refactor can't silently change either invariant.
|
||||
func TestJoinChildExpressions(t *testing.T) {
|
||||
mkChild := func(expr string) model.PolicySimulationEvaluationNode {
|
||||
return model.PolicySimulationEvaluationNode{Expression: expr}
|
||||
}
|
||||
|
||||
t.Run("no children returns empty", func(t *testing.T) {
|
||||
assert.Equal(t, "", joinChildExpressions(nil, "&&"))
|
||||
assert.Equal(t, "", joinChildExpressions([]model.PolicySimulationEvaluationNode{}, "&&"))
|
||||
})
|
||||
|
||||
t.Run("single child wrapped in parens", func(t *testing.T) {
|
||||
result := joinChildExpressions([]model.PolicySimulationEvaluationNode{mkChild(`user.attributes.x == "a"`)}, "&&")
|
||||
assert.Equal(t, `(user.attributes.x == "a")`, result)
|
||||
})
|
||||
|
||||
t.Run("multiple children joined with operator", func(t *testing.T) {
|
||||
children := []model.PolicySimulationEvaluationNode{
|
||||
mkChild(`user.attributes.x == "a"`),
|
||||
mkChild(`user.attributes.y == "b"`),
|
||||
}
|
||||
assert.Equal(t, `(user.attributes.x == "a") && (user.attributes.y == "b")`, joinChildExpressions(children, "&&"))
|
||||
assert.Equal(t, `(user.attributes.x == "a") || (user.attributes.y == "b")`, joinChildExpressions(children, "||"))
|
||||
})
|
||||
|
||||
t.Run("empty children dropped so parent has no dangling operator", func(t *testing.T) {
|
||||
// A child whose leaf masking failed-closed has Expression="";
|
||||
// the parent must skip it rather than emit "() && (real)" or
|
||||
// a trailing " && ". Either of those would be invalid CEL and
|
||||
// would surface to the picker.
|
||||
children := []model.PolicySimulationEvaluationNode{
|
||||
mkChild(""),
|
||||
mkChild(`user.attributes.x == "a"`),
|
||||
mkChild(""),
|
||||
}
|
||||
assert.Equal(t, `(user.attributes.x == "a")`, joinChildExpressions(children, "&&"))
|
||||
})
|
||||
|
||||
t.Run("all children empty returns empty", func(t *testing.T) {
|
||||
children := []model.PolicySimulationEvaluationNode{mkChild(""), mkChild("")}
|
||||
assert.Equal(t, "", joinChildExpressions(children, "||"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestClearEvaluationTreeLiterals pins the fail-closed walker: every
|
||||
// node's Expression must be wiped and every non-empty ExpectedValue
|
||||
// must collapse to the masked-token sentinel. The walker is invoked
|
||||
// from the top-level CPA-group-fetch failure path, so a regression
|
||||
// here would leak literal values back to the caller through the
|
||||
// simulator response.
|
||||
func TestClearEvaluationTreeLiterals(t *testing.T) {
|
||||
t.Run("nil node is a no-op", func(t *testing.T) {
|
||||
clearEvaluationTreeLiterals(nil) // must not panic
|
||||
})
|
||||
|
||||
t.Run("leaf with literal: expression cleared, expected and actual sentinel", func(t *testing.T) {
|
||||
node := &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: `user.attributes.x == "secret"`,
|
||||
ExpectedValue: "secret",
|
||||
ActualValue: "secret",
|
||||
}
|
||||
clearEvaluationTreeLiterals(node)
|
||||
assert.Equal(t, "", node.Expression)
|
||||
assert.Equal(t, maskedTokenValue, node.ExpectedValue)
|
||||
assert.Equal(t, maskedTokenValue, node.ActualValue,
|
||||
"fail-closed must also collapse the simulated user's value — it's just as much a literal as the rule's")
|
||||
})
|
||||
|
||||
t.Run("leaf with empty expected/actual: stays empty (no sentinel invented)", func(t *testing.T) {
|
||||
// A leaf with no recorded literal (e.g. an attribute-vs-
|
||||
// attribute compare) must NOT have ExpectedValue or
|
||||
// ActualValue forced to the sentinel — that would invent
|
||||
// values where the simulator deliberately omitted them.
|
||||
node := &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: `user.attributes.x == user.attributes.y`,
|
||||
}
|
||||
clearEvaluationTreeLiterals(node)
|
||||
assert.Equal(t, "", node.Expression)
|
||||
assert.Equal(t, "", node.ExpectedValue)
|
||||
assert.Equal(t, "", node.ActualValue)
|
||||
})
|
||||
|
||||
t.Run("compound node recurses into children", func(t *testing.T) {
|
||||
root := &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindAnd,
|
||||
Expression: `(user.attributes.x == "a") && (user.attributes.y == "b")`,
|
||||
Children: []model.PolicySimulationEvaluationNode{
|
||||
{Kind: model.PolicySimulationEvaluationKindCompare, Expression: `user.attributes.x == "a"`, ExpectedValue: "a", ActualValue: "a"},
|
||||
{Kind: model.PolicySimulationEvaluationKindCompare, Expression: `user.attributes.y == "b"`, ExpectedValue: "b", ActualValue: "z"},
|
||||
},
|
||||
}
|
||||
clearEvaluationTreeLiterals(root)
|
||||
assert.Equal(t, "", root.Expression)
|
||||
assert.Equal(t, "", root.Children[0].Expression)
|
||||
assert.Equal(t, maskedTokenValue, root.Children[0].ExpectedValue)
|
||||
assert.Equal(t, maskedTokenValue, root.Children[0].ActualValue)
|
||||
assert.Equal(t, "", root.Children[1].Expression)
|
||||
assert.Equal(t, maskedTokenValue, root.Children[1].ExpectedValue)
|
||||
assert.Equal(t, maskedTokenValue, root.Children[1].ActualValue)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaskSimulationPolicyLiteralsForCaller_FlagOff and
|
||||
// _GuardClauses pin the entry-point branches that short-circuit
|
||||
// without touching the response. Together they prove the function
|
||||
// is safe to call from the simulate handler regardless of feature-
|
||||
// flag or input state, so the wiring in
|
||||
// SimulateAccessControlPolicyForUsers doesn't need to add its own
|
||||
// gates.
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_FlagOff(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = false
|
||||
}).InitBasic(t)
|
||||
rctx := request.TestContext(t)
|
||||
|
||||
resp := &model.PolicySimulationResponse{
|
||||
Results: []model.PolicySimulationUserResult{{
|
||||
Decisions: map[string]model.PolicySimulationActionDecision{
|
||||
"view_channel": {
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
RuleName: "rule1",
|
||||
Expression: `user.attributes.x == "kept-as-is"`,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: `user.attributes.x == "kept-as-is"`,
|
||||
ExpectedValue: "kept-as-is",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
|
||||
|
||||
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
|
||||
assert.Equal(t, `user.attributes.x == "kept-as-is"`, blame.Expression, "flag off must skip masking entirely")
|
||||
assert.Equal(t, "kept-as-is", blame.EvaluationTree.ExpectedValue, "flag off must skip masking entirely")
|
||||
}
|
||||
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_GuardClauses(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
rctx := request.TestContext(t)
|
||||
|
||||
t.Run("nil response is a no-op", func(t *testing.T) {
|
||||
// Must not panic and must not touch anything (there's nothing
|
||||
// to touch). Pinned because the api4 handler dereferences
|
||||
// resp.Results immediately after this call.
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, nil, model.NewId())
|
||||
})
|
||||
|
||||
t.Run("empty callerID is a no-op", func(t *testing.T) {
|
||||
// A session-less caller reaching this code path would be a
|
||||
// caller-context bug; the function must refuse rather than
|
||||
// run with an empty caller (which the property service would
|
||||
// resolve to "no holdings" and therefore mask everything,
|
||||
// effectively a stealthy DoS).
|
||||
resp := &model.PolicySimulationResponse{
|
||||
Results: []model.PolicySimulationUserResult{{
|
||||
Decisions: map[string]model.PolicySimulationActionDecision{
|
||||
"view_channel": {
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
Expression: `user.attributes.x == "kept"`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, "")
|
||||
assert.Equal(t, `user.attributes.x == "kept"`, resp.Results[0].Decisions["view_channel"].Blame[0].Expression)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaskSimulationPolicyLiteralsForCaller_SourceOnly is the end-
|
||||
// to-end test for the new pass: a real source_only CPA field
|
||||
// (inserted directly via the store because the App's
|
||||
// CreatePropertyField hook rejects non-plugin callers from setting
|
||||
// protected/source_plugin_id) drives the masker against a simulator
|
||||
// response shaped like the picker output. We mock ExpressionToVisualAST
|
||||
// — the rest of the masking pipeline (field lookup, access_mode
|
||||
// evaluation, condition rewrite, CEL rebuild) is the real one,
|
||||
// because that's the layer this test is pinning. Every
|
||||
// literal-bearing surface (Blame.Expression, the leaf evaluation
|
||||
// tree's Expression and ExpectedValue, MergedRule.Expression, and
|
||||
// the merged-rule subtree) must collapse to the "--------" sentinel
|
||||
// for a caller who isn't the source plugin, regardless of role.
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_SourceOnly(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
rctx := request.TestContext(t)
|
||||
|
||||
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
require.Nil(t, gErr)
|
||||
|
||||
fieldName := celSafeName()
|
||||
_, sErr := th.Store.PropertyField().Create(&model.PropertyField{
|
||||
GroupID: cpaGroup.ID,
|
||||
Name: fieldName,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
Attrs: model.StringInterface{
|
||||
model.PropertyAttrsProtected: true,
|
||||
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
||||
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
|
||||
},
|
||||
})
|
||||
require.NoError(t, sErr)
|
||||
|
||||
// Tests run without a real Policy Administration Point wired up,
|
||||
// so we have to stand in for ExpressionToVisualAST. The mock
|
||||
// returns a single-condition Visual AST that matches the leaf
|
||||
// shape the simulator would produce — that's the only input the
|
||||
// downstream masking pipeline actually cares about for a
|
||||
// source_only field.
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().ch.AccessControl = mockACS
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(&model.VisualExpression{
|
||||
Conditions: []model.Condition{{
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
Operator: "==",
|
||||
Value: "Crimsone One",
|
||||
ValueType: model.LiteralValue,
|
||||
}},
|
||||
}, nil)
|
||||
|
||||
leafExpr := `user.attributes.` + fieldName + ` == "Crimsone One"`
|
||||
resp := &model.PolicySimulationResponse{
|
||||
Results: []model.PolicySimulationUserResult{{
|
||||
User: &model.User{Id: model.NewId()},
|
||||
Decisions: map[string]model.PolicySimulationActionDecision{
|
||||
"upload_file_attachment": {
|
||||
Decision: false,
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
Source: model.PolicySimulationBlameSourceThisRule,
|
||||
RuleName: "rule1",
|
||||
Expression: leafExpr,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: leafExpr,
|
||||
ExpectedValue: "Crimsone One",
|
||||
ActualValue: "Crimsone One",
|
||||
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
},
|
||||
MergedRules: []model.PolicySimulationMergedRule{{
|
||||
Name: "rule1",
|
||||
Expression: leafExpr,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: leafExpr,
|
||||
ExpectedValue: "Crimsone One",
|
||||
ActualValue: "Crimsone One",
|
||||
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
|
||||
|
||||
blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0]
|
||||
|
||||
// Top-level Blame.Expression is re-sourced from the (also-masked)
|
||||
// evaluation tree root so the OR / NOT shape survives. For a
|
||||
// single-leaf rule the rebuilt text IS the masked leaf form.
|
||||
assert.Contains(t, blame.Expression, maskedTokenValue,
|
||||
"Blame.Expression must carry the masked sentinel for source_only literals")
|
||||
assert.NotContains(t, blame.Expression, "Crimsone One",
|
||||
"Blame.Expression must not leak the source_only literal back to the caller")
|
||||
|
||||
// Evaluation tree leaf — every surface (Expression, the
|
||||
// rule-literal ExpectedValue, AND the simulated user's
|
||||
// ActualValue) must reflect the mask. ActualValue is the user-
|
||||
// data twin of ExpectedValue: source_only means "nobody but the
|
||||
// source plugin sees ANY value", so the user's recorded value
|
||||
// is just as sensitive as the rule literal.
|
||||
require.NotNil(t, blame.EvaluationTree)
|
||||
assert.Contains(t, blame.EvaluationTree.Expression, maskedTokenValue)
|
||||
assert.NotContains(t, blame.EvaluationTree.Expression, "Crimsone One")
|
||||
assert.Equal(t, maskedTokenValue, blame.EvaluationTree.ExpectedValue,
|
||||
"leaf ExpectedValue must collapse to the sentinel when the field is source_only")
|
||||
assert.Equal(t, maskedTokenValue, blame.EvaluationTree.ActualValue,
|
||||
"leaf ActualValue must collapse to the sentinel when the field is source_only — the picker's 'Actual: X' line is a leak path otherwise")
|
||||
|
||||
// MergedRule surfaces are independent of the top-level Blame —
|
||||
// the picker renders them in a separate "combined evaluation"
|
||||
// section, so a leak on either path is equally bad.
|
||||
require.Len(t, blame.MergedRules, 1)
|
||||
m := blame.MergedRules[0]
|
||||
assert.Contains(t, m.Expression, maskedTokenValue)
|
||||
assert.NotContains(t, m.Expression, "Crimsone One")
|
||||
require.NotNil(t, m.EvaluationTree)
|
||||
assert.Contains(t, m.EvaluationTree.Expression, maskedTokenValue)
|
||||
assert.Equal(t, maskedTokenValue, m.EvaluationTree.ExpectedValue)
|
||||
assert.Equal(t, maskedTokenValue, m.EvaluationTree.ActualValue)
|
||||
}
|
||||
|
||||
// TestMaskSimulationPolicyLiteralsForCaller_PublicFieldPassesThrough
|
||||
// proves the masker doesn't over-mask: a plain public CPA field's
|
||||
// literal value stays visible end-to-end on every surface. Without
|
||||
// this pin a future refactor could accidentally fail-close on every
|
||||
// field by treating an empty access_mode as "non-public", silently
|
||||
// blanking the picker.
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_PublicFieldPassesThrough(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
rctx := request.TestContext(t)
|
||||
|
||||
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
require.Nil(t, gErr)
|
||||
|
||||
fieldName := celSafeName()
|
||||
_, vAppErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
|
||||
GroupID: cpaGroup.ID,
|
||||
Name: fieldName,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
}, false, "")
|
||||
require.Nil(t, vAppErr)
|
||||
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().ch.AccessControl = mockACS
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(&model.VisualExpression{
|
||||
Conditions: []model.Condition{{
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
Operator: "==",
|
||||
Value: "Engineering",
|
||||
ValueType: model.LiteralValue,
|
||||
}},
|
||||
}, nil)
|
||||
|
||||
expr := `user.attributes.` + fieldName + ` == "Engineering"`
|
||||
resp := &model.PolicySimulationResponse{
|
||||
Results: []model.PolicySimulationUserResult{{
|
||||
Decisions: map[string]model.PolicySimulationActionDecision{
|
||||
"view_channel": {
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
RuleName: "rule1",
|
||||
Expression: expr,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: expr,
|
||||
ExpectedValue: "Engineering",
|
||||
ActualValue: "Sales",
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
|
||||
|
||||
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
|
||||
assert.Contains(t, blame.Expression, "Engineering",
|
||||
"public field literal must pass through unchanged at the top level")
|
||||
assert.Equal(t, "Engineering", blame.EvaluationTree.ExpectedValue,
|
||||
"public field leaf ExpectedValue must pass through unchanged")
|
||||
assert.Equal(t, "Sales", blame.EvaluationTree.ActualValue,
|
||||
"public field leaf ActualValue must pass through unchanged")
|
||||
assert.NotContains(t, blame.EvaluationTree.Expression, maskedTokenValue,
|
||||
"public field leaf Expression must not gain a sentinel")
|
||||
}
|
||||
|
||||
// TestMaskSimulationPolicyLiteralsForCaller_ActualValueIndependentFromExpected
|
||||
// pins the most subtle correctness invariant: ExpectedValue and
|
||||
// ActualValue are checked against the caller's holdings
|
||||
// independently. For a shared_only text field where the caller
|
||||
// holds value "A", the rule literal "A" stays visible but a
|
||||
// simulated user's actual value "B" must mask — because the caller
|
||||
// is allowed to see "A" (they hold it) but not "B" (they don't).
|
||||
// Without this pin a regression that masks both values together
|
||||
// (or neither together) would still pass the source_only and
|
||||
// public tests but silently leak per-value AVM semantics for the
|
||||
// shared_only path.
|
||||
//
|
||||
// Uses a non-CPA V1 property group for the same reason
|
||||
// TestMaskConditionValues_SharedOnlyText does: shared_only on the
|
||||
// CPA group requires a plugin caller for field creation /
|
||||
// value-write, which is unrelated to what we're pinning here.
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_ActualValueIndependentFromExpected(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
rctx := request.TestContext(t)
|
||||
callerID := model.NewId()
|
||||
|
||||
group, gAppErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
|
||||
Name: "sim_mask_actual_test_" + model.NewId(),
|
||||
Version: model.PropertyGroupVersionV1,
|
||||
})
|
||||
require.Nil(t, gAppErr)
|
||||
groupID := group.ID
|
||||
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "f_" + model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
Attrs: model.StringInterface{model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly},
|
||||
}
|
||||
createdField, vAppErr := th.App.CreatePropertyField(rctx, field, false, "")
|
||||
require.Nil(t, vAppErr)
|
||||
|
||||
// Caller holds "A" — that's the rule literal we want to keep
|
||||
// visible. They do NOT hold "B" — that's the simulated user's
|
||||
// value that must mask independently.
|
||||
_, vAppErr = th.App.CreatePropertyValue(rctx, &model.PropertyValue{
|
||||
TargetID: callerID,
|
||||
TargetType: model.PropertyValueTargetTypeUser,
|
||||
GroupID: groupID,
|
||||
FieldID: createdField.ID,
|
||||
Value: json.RawMessage(`"A"`),
|
||||
})
|
||||
require.Nil(t, vAppErr)
|
||||
|
||||
// The simulator pass uses GetPropertyGroup(AccessControlPropertyGroupName)
|
||||
// to derive the cpaGroupID for its mask context, but the helpers
|
||||
// we exercise (callerCanSeeFieldValue / maskExpressionWithCache)
|
||||
// look fields up by name through GetPropertyFieldByName, which
|
||||
// is group-id-scoped. To make the test drive against our V1
|
||||
// group we bypass the public entry point and call the
|
||||
// per-decision helper directly with a mask context built around
|
||||
// the V1 group. This is the same shortcut existing shared_only
|
||||
// tests take.
|
||||
mc := &simulationMaskContext{
|
||||
cpaGroupID: groupID,
|
||||
rctxWithCaller: RequestContextWithCallerID(rctx, callerID),
|
||||
callerID: callerID,
|
||||
fieldsByName: map[string]*model.PropertyField{},
|
||||
}
|
||||
|
||||
// Mock ExpressionToVisualAST so the leaf re-mask of Expression
|
||||
// returns the original single-condition AST against the V1
|
||||
// field — same shape the leaf was built from.
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().ch.AccessControl = mockACS
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(&model.VisualExpression{
|
||||
Conditions: []model.Condition{{
|
||||
Attribute: "user.attributes." + createdField.Name,
|
||||
Operator: "==",
|
||||
Value: "A",
|
||||
ValueType: model.LiteralValue,
|
||||
}},
|
||||
}, nil)
|
||||
|
||||
leafExpr := `user.attributes.` + createdField.Name + ` == "A"`
|
||||
dec := &model.PolicySimulationActionDecision{
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
RuleName: "rule1",
|
||||
Expression: leafExpr,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: leafExpr,
|
||||
ExpectedValue: "A",
|
||||
ActualValue: "B",
|
||||
Attribute: "user.attributes." + createdField.Name,
|
||||
Outcome: model.PolicySimulationEvaluationOutcomeFalse,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
th.App.maskSimulationDecisionLiterals(dec, mc)
|
||||
|
||||
leaf := dec.Blame[0].EvaluationTree
|
||||
require.NotNil(t, leaf)
|
||||
|
||||
// Rule literal "A" is in the caller's held values, so
|
||||
// ExpectedValue passes through and the leaf Expression stays
|
||||
// unmasked.
|
||||
assert.Equal(t, "A", leaf.ExpectedValue,
|
||||
"shared_only ExpectedValue the caller holds must pass through unchanged")
|
||||
assert.NotContains(t, leaf.Expression, maskedTokenValue,
|
||||
"shared_only Expression whose literal the caller holds must stay unmasked")
|
||||
|
||||
// Simulated user's value "B" is NOT in the caller's held values,
|
||||
// so it must mask independently — same field, same access mode,
|
||||
// different value.
|
||||
assert.Equal(t, maskedTokenValue, leaf.ActualValue,
|
||||
"shared_only ActualValue the caller doesn't hold must mask, even when ExpectedValue is visible")
|
||||
}
|
||||
|
||||
// TestMaskSimulationPolicyLiteralsForCaller_CompoundOrPreserved
|
||||
// guards the boolean shape of the response. maskExpressionWithCache
|
||||
// goes through a flat (implicit AND) Visual AST, so if we routed
|
||||
// compound tree nodes through that path their OR / NOT would
|
||||
// silently collapse to AND. This test seeds a two-leaf OR tree
|
||||
// against a source_only field, runs the masker, and asserts the
|
||||
// rebuilt compound still says "||". Without this pin a regression
|
||||
// would mask the literals correctly but misrepresent the rule's
|
||||
// logic to the picker.
|
||||
func TestMaskSimulationPolicyLiteralsForCaller_CompoundOrPreserved(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.AttributeValueMasking = true
|
||||
}).InitBasic(t)
|
||||
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
|
||||
require.True(t, ok)
|
||||
defer th.App.Srv().SetLicense(nil)
|
||||
rctx := request.TestContext(t)
|
||||
|
||||
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
|
||||
require.Nil(t, gErr)
|
||||
|
||||
fieldName := celSafeName()
|
||||
_, sErr := th.Store.PropertyField().Create(&model.PropertyField{
|
||||
GroupID: cpaGroup.ID,
|
||||
Name: fieldName,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
Attrs: model.StringInterface{
|
||||
model.PropertyAttrsProtected: true,
|
||||
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
||||
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
|
||||
},
|
||||
})
|
||||
require.NoError(t, sErr)
|
||||
|
||||
// Each leaf is masked through ExpressionToVisualAST independently,
|
||||
// so the mock returns a single-condition AST that matches the
|
||||
// matched-on value (Alpha vs Bravo) for whichever leaf is being
|
||||
// processed. We don't care which order the calls happen in —
|
||||
// both are source_only and both will mask the same way; the
|
||||
// assertion below is on the rebuilt compound, not on call order.
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
th.App.Srv().ch.AccessControl = mockACS
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.MatchedBy(func(expr string) bool {
|
||||
return strings.Contains(expr, "Alpha")
|
||||
})).Return(&model.VisualExpression{
|
||||
Conditions: []model.Condition{{
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
Operator: "==",
|
||||
Value: "Alpha",
|
||||
ValueType: model.LiteralValue,
|
||||
}},
|
||||
}, nil)
|
||||
mockACS.On("ExpressionToVisualAST", mock.Anything, mock.MatchedBy(func(expr string) bool {
|
||||
return strings.Contains(expr, "Bravo")
|
||||
})).Return(&model.VisualExpression{
|
||||
Conditions: []model.Condition{{
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
Operator: "==",
|
||||
Value: "Bravo",
|
||||
ValueType: model.LiteralValue,
|
||||
}},
|
||||
}, nil)
|
||||
|
||||
mkLeaf := func(value string) model.PolicySimulationEvaluationNode {
|
||||
return model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindCompare,
|
||||
Expression: `user.attributes.` + fieldName + ` == "` + value + `"`,
|
||||
ExpectedValue: value,
|
||||
Attribute: "user.attributes." + fieldName,
|
||||
}
|
||||
}
|
||||
resp := &model.PolicySimulationResponse{
|
||||
Results: []model.PolicySimulationUserResult{{
|
||||
Decisions: map[string]model.PolicySimulationActionDecision{
|
||||
"view_channel": {
|
||||
Blame: []model.PolicySimulationBlame{{
|
||||
RuleName: "rule1",
|
||||
Expression: `(user.attributes.` + fieldName + ` == "Alpha") || (user.attributes.` + fieldName + ` == "Bravo")`,
|
||||
EvaluationTree: &model.PolicySimulationEvaluationNode{
|
||||
Kind: model.PolicySimulationEvaluationKindOr,
|
||||
Children: []model.PolicySimulationEvaluationNode{
|
||||
mkLeaf("Alpha"),
|
||||
mkLeaf("Bravo"),
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
th.App.MaskSimulationPolicyLiteralsForCaller(rctx, resp, model.NewId())
|
||||
|
||||
blame := resp.Results[0].Decisions["view_channel"].Blame[0]
|
||||
require.NotNil(t, blame.EvaluationTree)
|
||||
assert.Contains(t, blame.EvaluationTree.Expression, "||",
|
||||
"OR structure must survive masking — collapsing to AND would misrepresent the rule")
|
||||
assert.NotContains(t, blame.EvaluationTree.Expression, "Alpha")
|
||||
assert.NotContains(t, blame.EvaluationTree.Expression, "Bravo")
|
||||
|
||||
// Top-level Blame.Expression is backfilled from the (masked)
|
||||
// tree root, so it must inherit the same preserved structure
|
||||
// and the same absence of literal leaks.
|
||||
assert.Equal(t, blame.EvaluationTree.Expression, blame.Expression)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -403,6 +403,61 @@ func TestConditionToCEL_UnknownOperatorWithValue(t *testing.T) {
|
|||
assert.Equal(t, `user.attributes.Clearance futureOp "Secret"`, result)
|
||||
}
|
||||
|
||||
// TestIsMembershipRule pins the helper that drives unnamed-rule pairing in
|
||||
// mergeStoredPolicyExpressions. The merge walks both stored and submitted
|
||||
// rules and pairs them by Name; v0.4 membership rules don't carry a Name,
|
||||
// so the helper picks them out by Action so a reordering edit can't accidentally
|
||||
// merge a permission rule's stored expression into the membership slot
|
||||
// (or vice versa).
|
||||
func TestIsMembershipRule(t *testing.T) {
|
||||
t.Run("nil rule is not a membership rule", func(t *testing.T) {
|
||||
assert.False(t, isMembershipRule(nil))
|
||||
})
|
||||
|
||||
t.Run("named rule with membership action is not a membership rule", func(t *testing.T) {
|
||||
// v0.4 permission rules always carry a Name; treat a non-empty Name as a
|
||||
// permission rule even if its Actions happen to mention membership, so a
|
||||
// rename can't accidentally collide with the membership slot.
|
||||
rule := &model.AccessControlPolicyRule{
|
||||
Name: "Custom",
|
||||
Actions: []string{model.AccessControlPolicyActionMembership},
|
||||
}
|
||||
assert.False(t, isMembershipRule(rule))
|
||||
})
|
||||
|
||||
t.Run("unnamed rule without membership action is not a membership rule", func(t *testing.T) {
|
||||
// Anonymous non-membership rules can't be safely identified across the
|
||||
// submit boundary; mergeStoredPolicyExpressions deliberately skips them
|
||||
// rather than mispair, so this helper must report false too.
|
||||
rule := &model.AccessControlPolicyRule{
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
}
|
||||
assert.False(t, isMembershipRule(rule))
|
||||
})
|
||||
|
||||
t.Run("unnamed rule with membership action is a membership rule", func(t *testing.T) {
|
||||
rule := &model.AccessControlPolicyRule{
|
||||
Actions: []string{model.AccessControlPolicyActionMembership},
|
||||
}
|
||||
assert.True(t, isMembershipRule(rule))
|
||||
})
|
||||
|
||||
t.Run("unnamed rule with membership action among others is a membership rule", func(t *testing.T) {
|
||||
rule := &model.AccessControlPolicyRule{
|
||||
Actions: []string{
|
||||
model.AccessControlPolicyActionUploadFileAttachment,
|
||||
model.AccessControlPolicyActionMembership,
|
||||
},
|
||||
}
|
||||
assert.True(t, isMembershipRule(rule))
|
||||
})
|
||||
|
||||
t.Run("empty actions list is not a membership rule", func(t *testing.T) {
|
||||
rule := &model.AccessControlPolicyRule{}
|
||||
assert.False(t, isMembershipRule(rule))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeConditionValues(t *testing.T) {
|
||||
t.Run("no hidden values returns submitted as-is", func(t *testing.T) {
|
||||
submitted := model.Condition{Attribute: "user.attributes.Program", Operator: "in", Value: []any{"Alpha"}}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -505,9 +505,12 @@ func (a *App) SessionHasPermissionToEditPropertyField(rctx request.CTX, session
|
|||
return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionField)
|
||||
}
|
||||
|
||||
// SessionHasPermissionToSetPropertyFieldValues checks if the session has permission to set values on objects.
|
||||
// SessionHasPermissionToSetPropertyFieldValues checks if the session has
|
||||
// permission to set the given value on the field. The valueTargetID is the
|
||||
// specific object the value is attached to (the channel/post/user/team ID
|
||||
// for that ObjectType); admin and member levels are evaluated against it.
|
||||
// Returns false if the field is nil or if PermissionValues is nil (legacy fields).
|
||||
func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, session model.Session, field *model.PropertyField) bool {
|
||||
func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, session model.Session, field *model.PropertyField, valueTargetID string) bool {
|
||||
if field == nil {
|
||||
return false
|
||||
}
|
||||
|
|
@ -517,7 +520,7 @@ func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, ses
|
|||
if session.IsUnrestricted() {
|
||||
return true
|
||||
}
|
||||
return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionValues)
|
||||
return a.hasPropertyFieldValuePermissionLevel(rctx, session.UserId, field, valueTargetID, *field.PermissionValues)
|
||||
}
|
||||
|
||||
// SessionHasPermissionToManagePropertyFieldOptions checks if the session has permission to manage field options.
|
||||
|
|
@ -550,16 +553,19 @@ func (a *App) HasPermissionToEditPropertyField(rctx request.CTX, userID string,
|
|||
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionField)
|
||||
}
|
||||
|
||||
// HasPermissionToSetPropertyFieldValues checks if the user has permission to set values on objects.
|
||||
// HasPermissionToSetPropertyFieldValues checks if the user has permission to
|
||||
// set the given value on the field. The valueTargetID is the specific object
|
||||
// the value is attached to (the channel/post/user/team ID for that
|
||||
// ObjectType); admin and member levels are evaluated against it.
|
||||
// Returns false if the field is nil, userID is empty, or if PermissionValues is nil (legacy fields).
|
||||
func (a *App) HasPermissionToSetPropertyFieldValues(rctx request.CTX, userID string, field *model.PropertyField) bool {
|
||||
func (a *App) HasPermissionToSetPropertyFieldValues(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
|
||||
if field == nil || userID == "" {
|
||||
return false
|
||||
}
|
||||
if field.PermissionValues == nil {
|
||||
return false
|
||||
}
|
||||
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionValues)
|
||||
return a.hasPropertyFieldValuePermissionLevel(rctx, userID, field, valueTargetID, *field.PermissionValues)
|
||||
}
|
||||
|
||||
// HasPermissionToManagePropertyFieldOptions checks if the user has permission to manage field options.
|
||||
|
|
@ -575,6 +581,12 @@ func (a *App) HasPermissionToManagePropertyFieldOptions(rctx request.CTX, userID
|
|||
}
|
||||
|
||||
// hasPropertyFieldPermissionLevel checks if the user has the specified permission level for the field.
|
||||
// "admin" resolves against the field's target: manage_system on system targets,
|
||||
// manage_team on team targets, manage_channel_roles on channel targets — i.e.
|
||||
// the permission that the corresponding built-in admin role grants. Note this
|
||||
// is a stricter check than hasTargetAccess (which uses manage_*_channel_properties
|
||||
// for channel writes): hasTargetAccess is the outer "may write anything here"
|
||||
// gate, and PermissionLevelAdmin is the inner "is a channel admin" tier above it.
|
||||
func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, level model.PermissionLevel) bool {
|
||||
switch level {
|
||||
case model.PermissionLevelNone:
|
||||
|
|
@ -583,44 +595,119 @@ func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, f
|
|||
return a.HasPermissionTo(userID, model.PermissionManageSystem)
|
||||
case model.PermissionLevelMember:
|
||||
return a.hasPropertyFieldScopeAccess(rctx, userID, field)
|
||||
case model.PermissionLevelAdmin:
|
||||
switch field.TargetType {
|
||||
case string(model.PropertyFieldTargetLevelSystem):
|
||||
return a.HasPermissionTo(userID, model.PermissionManageSystem)
|
||||
case string(model.PropertyFieldTargetLevelTeam):
|
||||
return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionManageTeam)
|
||||
case string(model.PropertyFieldTargetLevelChannel):
|
||||
hasPermission, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionManageChannelRoles)
|
||||
return hasPermission
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasPropertyFieldScopeAccess checks if the user has access to the property field's scope.
|
||||
// For system-level properties, any authenticated user has access.
|
||||
// For channel-level properties, the user must be a member of the channel.
|
||||
// hasPropertyFieldValuePermissionLevel evaluates a permission level against
|
||||
// the value's specific target rather than the field's target. The "admin"
|
||||
// and "member" levels dispatch on field.ObjectType against valueTargetID —
|
||||
// so a value on a channel-object field is gated by the user's role on that
|
||||
// channel, regardless of how the field itself is scoped. "sysadmin" and
|
||||
// "none" behave identically to the field-level dispatch.
|
||||
func (a *App) hasPropertyFieldValuePermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string, level model.PermissionLevel) bool {
|
||||
switch level {
|
||||
case model.PermissionLevelSysadmin:
|
||||
return a.HasPermissionTo(userID, model.PermissionManageSystem)
|
||||
case model.PermissionLevelAdmin:
|
||||
return a.hasPropertyFieldValueAdmin(rctx, userID, field, valueTargetID)
|
||||
case model.PermissionLevelMember:
|
||||
return a.hasPropertyFieldValueScopeAccess(rctx, userID, field, valueTargetID)
|
||||
case model.PermissionLevelNone:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasPropertyFieldValueAdmin reports whether the user administers the
|
||||
// value's target. For channel/post-object fields, this is channel admin
|
||||
// (manage_channel_roles) on the value's channel (or the post's channel).
|
||||
// For user/system/template fields the value's target has no admin concept,
|
||||
// so the check defers to the field's TargetType (sysadmin / team admin /
|
||||
// channel admin) via the field-level dispatch.
|
||||
func (a *App) hasPropertyFieldValueAdmin(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
|
||||
switch field.ObjectType {
|
||||
case model.PropertyFieldObjectTypeChannel:
|
||||
ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionManageChannelRoles)
|
||||
return ok
|
||||
case model.PropertyFieldObjectTypePost:
|
||||
post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to look up post for property value admin check",
|
||||
mlog.String("post_id", valueTargetID),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("field_id", field.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionManageChannelRoles)
|
||||
return ok
|
||||
case model.PropertyFieldObjectTypeUser,
|
||||
model.PropertyFieldObjectTypeSystem,
|
||||
model.PropertyFieldObjectTypeTemplate:
|
||||
return a.hasPropertyFieldPermissionLevel(rctx, userID, field, model.PermissionLevelAdmin)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasPropertyFieldValueScopeAccess reports whether the user can write the
|
||||
// value's target as a regular member. For channel-object fields this is
|
||||
// membership in the value's channel. For post-object fields this is
|
||||
// membership in the post's channel — any channel member can set values on
|
||||
// any post in that channel. Both are checked via HasPermissionToChannel so
|
||||
// sysadmins and team admins cascade through. User/system/template fields
|
||||
// have no per-object membership and defer to the field's TargetType-based scope.
|
||||
func (a *App) hasPropertyFieldValueScopeAccess(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool {
|
||||
switch field.ObjectType {
|
||||
case model.PropertyFieldObjectTypeChannel:
|
||||
ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionReadChannel)
|
||||
return ok
|
||||
case model.PropertyFieldObjectTypePost:
|
||||
post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to look up post for property value scope check",
|
||||
mlog.String("post_id", valueTargetID),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("field_id", field.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionReadChannel)
|
||||
return ok
|
||||
case model.PropertyFieldObjectTypeUser,
|
||||
model.PropertyFieldObjectTypeSystem,
|
||||
model.PropertyFieldObjectTypeTemplate:
|
||||
return a.hasPropertyFieldScopeAccess(rctx, userID, field)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasPropertyFieldScopeAccess checks if the user has access to the property
|
||||
// field's scope. System-level properties are open to any authenticated user.
|
||||
// Team- and channel-level properties go through HasPermissionToTeam /
|
||||
// HasPermissionToChannel so sysadmins (and team-admins for channel scopes)
|
||||
// cascade through — matching the value-level scope check.
|
||||
func (a *App) hasPropertyFieldScopeAccess(rctx request.CTX, userID string, field *model.PropertyField) bool {
|
||||
switch field.TargetType {
|
||||
case string(model.PropertyFieldTargetLevelSystem):
|
||||
// System-level property: any authenticated user
|
||||
return true
|
||||
case string(model.PropertyFieldTargetLevelTeam):
|
||||
// Team-level property: must be team member
|
||||
member, err := a.Srv().Store().Team().GetMember(rctx, field.TargetID, userID)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to get team member for property field scope check",
|
||||
mlog.String("team_id", field.TargetID),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("field_id", field.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
return member != nil
|
||||
return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionViewTeam)
|
||||
case string(model.PropertyFieldTargetLevelChannel):
|
||||
// Channel-level property: must be channel member
|
||||
member, err := a.Srv().Store().Channel().GetMember(rctx, field.TargetID, userID)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to get channel member for property field scope check",
|
||||
mlog.String("channel_id", field.TargetID),
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("field_id", field.ID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
return member != nil
|
||||
ok, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionReadChannel)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -643,7 +730,7 @@ func (a *App) HasPermissionToFileAction(rctx request.CTX, userID string, roles s
|
|||
return true
|
||||
}
|
||||
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, userID, roles)
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, userID, roles, channelID)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Info("Failed to build ABAC subject for file action evaluation",
|
||||
mlog.String("user_id", userID),
|
||||
|
|
|
|||
|
|
@ -1338,6 +1338,12 @@ func TestHasPermissionToEditPropertyField(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestHasPermissionToSetPropertyFieldValues exercises the field-target
|
||||
// dispatch path: user-object values defer to hasPropertyFieldScopeAccess on
|
||||
// the field's TargetType (channel/team/system). Per-value-target dispatch
|
||||
// (channel- and post-object values) is covered separately in
|
||||
// TestSessionHasPropertyFieldPermissionAdmin and
|
||||
// TestSessionHasPermissionToSetPropertyFieldValues_PostMember.
|
||||
func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
@ -1346,13 +1352,9 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
require.Nil(t, groupErr)
|
||||
groupID := cpaGroup.ID
|
||||
|
||||
// Create a user that is not a member of any channel for the non-member test case
|
||||
// nonMember belongs to no team or channel — used for cascade-denial cases.
|
||||
nonMember := th.CreateUser(t)
|
||||
|
||||
// Add SystemAdminUser to BasicChannel for the admin with member permission test
|
||||
th.LinkUserToTeam(t, th.SystemAdminUser, th.BasicTeam)
|
||||
th.AddUserToChannel(t, th.SystemAdminUser, th.BasicChannel)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
userID string
|
||||
|
|
@ -1372,6 +1374,7 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "Test Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelMember),
|
||||
|
|
@ -1386,17 +1389,19 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "Field Without Permissions",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "channel admin can set values on channel field with admin permission",
|
||||
name: "sysadmin can set values on channel-target field with values permission sysadmin",
|
||||
userID: th.SystemAdminUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field Admin",
|
||||
Name: "Channel Field Sysadmin",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1406,12 +1411,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "channel admin can set values on channel field with member permission",
|
||||
name: "sysadmin cascades through channel scope on channel-target field with values permission member",
|
||||
userID: th.SystemAdminUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field Member",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1421,12 +1427,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "channel member can set values on channel field with member permission",
|
||||
name: "channel member can set values on channel-target field with values permission member",
|
||||
userID: th.BasicUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1436,12 +1443,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "channel member can set values on channel field with member permission regardless of the protected status",
|
||||
name: "channel member can set values on protected channel-target field with values permission member",
|
||||
userID: th.BasicUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field",
|
||||
Name: "Channel Field Protected",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
Protected: true,
|
||||
|
|
@ -1452,12 +1460,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot set values on channel field with member permission",
|
||||
name: "non-member cannot set values on channel-target field with values permission member",
|
||||
userID: nonMember.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1467,12 +1476,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "team member can set values on team field with member permission",
|
||||
name: "team member can set values on team-target field with values permission member",
|
||||
userID: th.BasicUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1482,12 +1492,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-member cannot set values on team field with member permission",
|
||||
name: "non-team-member cannot set values on team-target field with values permission member",
|
||||
userID: nonMember.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1497,12 +1508,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "admin can set values on team field with admin permission",
|
||||
name: "sysadmin can set values on team-target field with values permission sysadmin",
|
||||
userID: th.SystemAdminUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field Admin",
|
||||
Name: "Team Field Sysadmin",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1512,12 +1524,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "member can set values on system field with member permission",
|
||||
name: "any authenticated user can set values on system-target field with values permission member",
|
||||
userID: th.BasicUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "System Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelMember),
|
||||
|
|
@ -1526,12 +1539,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "values permission none denies admin",
|
||||
name: "values permission none denies sysadmin",
|
||||
userID: th.SystemAdminUser.Id,
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "System Managed Values Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelNone),
|
||||
|
|
@ -1546,6 +1560,7 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "System Managed Values Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelNone),
|
||||
|
|
@ -1555,9 +1570,13 @@ func TestHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
// All cases above use user-object values; valueTargetID is unused by the
|
||||
// user/system/template branch of hasPropertyFieldValueScopeAccess.
|
||||
const unusedValueTargetID = ""
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, th.App.HasPermissionToSetPropertyFieldValues(th.Context, tc.userID, tc.field))
|
||||
assert.Equal(t, tc.expected, th.App.HasPermissionToSetPropertyFieldValues(th.Context, tc.userID, tc.field, unusedValueTargetID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1854,6 +1873,11 @@ func TestSessionHasPermissionToEditPropertyField(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSessionHasPermissionToSetPropertyFieldValues is the session-bound twin
|
||||
// of TestHasPermissionToSetPropertyFieldValues — same field-target dispatch
|
||||
// path via user-object values. Per-value-target dispatch is covered in
|
||||
// TestSessionHasPropertyFieldPermissionAdmin and
|
||||
// TestSessionHasPermissionToSetPropertyFieldValues_PostMember.
|
||||
func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
@ -1862,13 +1886,9 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
require.Nil(t, groupErr)
|
||||
groupID := cpaGroup.ID
|
||||
|
||||
// Create a user that is not a member of any channel for the non-member test case
|
||||
// nonMember belongs to no team or channel — used for cascade-denial cases.
|
||||
nonMember := th.CreateUser(t)
|
||||
|
||||
// Add SystemAdminUser to BasicChannel for the admin with member permission test
|
||||
th.LinkUserToTeam(t, th.SystemAdminUser, th.BasicTeam)
|
||||
th.AddUserToChannel(t, th.SystemAdminUser, th.BasicChannel)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
session model.Session
|
||||
|
|
@ -1888,6 +1908,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "Field Without Permissions",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
},
|
||||
expected: false,
|
||||
|
|
@ -1899,6 +1920,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "Valid Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1913,6 +1935,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "Protected Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
Protected: true,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelNone),
|
||||
|
|
@ -1922,12 +1945,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "admin session can set values on channel field with admin permission",
|
||||
name: "sysadmin session can set values on channel-target field with values permission sysadmin",
|
||||
session: model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemAdminRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field Admin",
|
||||
Name: "Channel Field Sysadmin",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1937,12 +1961,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "admin session can set values on channel field with member permission",
|
||||
name: "sysadmin session cascades through channel scope on channel-target field with values permission member",
|
||||
session: model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemAdminRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field Member",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1952,12 +1977,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "member session can set values on channel field with member permission",
|
||||
name: "channel member session can set values on channel-target field with values permission member",
|
||||
session: model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1967,12 +1993,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-member session cannot set values on channel field with member permission",
|
||||
name: "non-member session cannot set values on channel-target field with values permission member",
|
||||
session: model.Session{UserId: nonMember.Id, Roles: model.SystemUserRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Channel Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1982,12 +2009,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "team member session can set values on team field with member permission",
|
||||
name: "team member session can set values on team-target field with values permission member",
|
||||
session: model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -1997,12 +2025,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-member session cannot set values on team field with member permission",
|
||||
name: "non-team-member session cannot set values on team-target field with values permission member",
|
||||
session: model.Session{UserId: nonMember.Id, Roles: model.SystemUserRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -2012,12 +2041,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "admin session can set values on team field with admin permission",
|
||||
name: "sysadmin session can set values on team-target field with values permission sysadmin",
|
||||
session: model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemAdminRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "Team Field Admin",
|
||||
Name: "Team Field Sysadmin",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelTeam),
|
||||
TargetID: th.BasicTeam.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
|
|
@ -2027,12 +2057,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "member session can set values on system field with member permission",
|
||||
name: "any authenticated session can set values on system-target field with values permission member",
|
||||
session: model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "System Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelMember),
|
||||
|
|
@ -2041,12 +2072,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "values permission none denies admin session",
|
||||
name: "values permission none denies sysadmin session",
|
||||
session: model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemAdminRoleId},
|
||||
field: &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "System Managed Values Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelNone),
|
||||
|
|
@ -2061,6 +2093,7 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
GroupID: groupID,
|
||||
Name: "System Managed Values Field",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelNone),
|
||||
|
|
@ -2070,9 +2103,13 @@ func TestSessionHasPermissionToSetPropertyFieldValues(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
// All cases above use user-object values; valueTargetID is unused by the
|
||||
// user/system/template branch of hasPropertyFieldValueScopeAccess.
|
||||
const unusedValueTargetID = ""
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, tc.session, tc.field))
|
||||
assert.Equal(t, tc.expected, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, tc.session, tc.field, unusedValueTargetID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2215,3 +2252,205 @@ func TestSessionHasPermissionToManagePropertyFieldOptions(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHasPropertyFieldPermissionAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
groupID := registerTestPropertyGroup(t, th)
|
||||
|
||||
// BasicUser2 is in the team but not the channel — add them so denial
|
||||
// cases land at "in the channel, but not an admin" rather than "not a
|
||||
// member at all".
|
||||
require.NotEqual(t, th.BasicUser.Id, th.BasicUser2.Id)
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
t.Run("field-level admin resolves against field target", func(t *testing.T) {
|
||||
// Edit and ManageOptions operate on the field definition; admin
|
||||
// means admin of the field's TargetType+TargetID.
|
||||
fieldFor := func(target model.PropertyFieldTargetLevel, targetID string) *model.PropertyField {
|
||||
return &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "admin only " + string(target),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(target),
|
||||
TargetID: targetID,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelAdmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionOptions: model.NewPointer(model.PermissionLevelAdmin),
|
||||
}
|
||||
}
|
||||
fieldOps := func(t *testing.T, session model.Session, field *model.PropertyField, want bool) {
|
||||
t.Helper()
|
||||
assert.Equal(t, want, th.App.SessionHasPermissionToEditPropertyField(th.Context, session, field))
|
||||
assert.Equal(t, want, th.App.SessionHasPermissionToManagePropertyFieldOptions(th.Context, session, field))
|
||||
}
|
||||
|
||||
t.Run("channel target", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldTargetLevelChannel, th.BasicChannel.Id)
|
||||
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
||||
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
||||
require.Nil(t, appErr)
|
||||
t.Cleanup(func() {
|
||||
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
||||
})
|
||||
fieldOps(t, model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}, field, true)
|
||||
fieldOps(t, model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}, field, false)
|
||||
})
|
||||
|
||||
t.Run("team target", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldTargetLevelTeam, th.BasicTeam.Id)
|
||||
_, appErr := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, th.BasicUser.Id,
|
||||
model.TeamUserRoleId+" "+model.TeamAdminRoleId)
|
||||
require.Nil(t, appErr)
|
||||
t.Cleanup(func() {
|
||||
_, _ = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, th.BasicUser.Id, model.TeamUserRoleId)
|
||||
})
|
||||
fieldOps(t, model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}, field, true)
|
||||
fieldOps(t, model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}, field, false)
|
||||
})
|
||||
|
||||
t.Run("system target", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldTargetLevelSystem, "")
|
||||
sysadmin := model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemUserRoleId + " " + model.SystemAdminRoleId}
|
||||
fieldOps(t, sysadmin, field, true)
|
||||
fieldOps(t, model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}, field, false)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("value-level admin resolves against value target", func(t *testing.T) {
|
||||
// Use a system-target field so the dispatch is purely driven by
|
||||
// ObjectType + the value's TargetID — the channel classification
|
||||
// shape: one global field, per-channel values.
|
||||
fieldFor := func(objectType string) *model.PropertyField {
|
||||
return &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "values admin " + objectType,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: objectType,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelAdmin),
|
||||
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("channel-object value: admin of the value's channel passes", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldObjectTypeChannel)
|
||||
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
||||
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
||||
require.Nil(t, appErr)
|
||||
t.Cleanup(func() {
|
||||
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
||||
})
|
||||
|
||||
channelAdmin := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
|
||||
channelMember := model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}
|
||||
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelAdmin, field, th.BasicChannel.Id))
|
||||
assert.False(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelMember, field, th.BasicChannel.Id))
|
||||
})
|
||||
|
||||
t.Run("post-object value: post-lookup yields the post's channel admin", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldObjectTypePost)
|
||||
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
||||
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
||||
require.Nil(t, appErr)
|
||||
t.Cleanup(func() {
|
||||
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
||||
})
|
||||
|
||||
post := th.CreatePost(t, th.BasicChannel)
|
||||
|
||||
channelAdmin := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
|
||||
channelMember := model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}
|
||||
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelAdmin, field, post.Id))
|
||||
assert.False(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelMember, field, post.Id))
|
||||
})
|
||||
|
||||
t.Run("user-object value: no per-user admin, falls back to sysadmin", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldObjectTypeUser)
|
||||
|
||||
sysadmin := model.Session{UserId: th.SystemAdminUser.Id, Roles: model.SystemUserRoleId + " " + model.SystemAdminRoleId}
|
||||
regularUser := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
|
||||
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, sysadmin, field, th.BasicUser2.Id))
|
||||
assert.False(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, regularUser, field, th.BasicUser2.Id))
|
||||
})
|
||||
|
||||
t.Run("user-object value with channel-scoped field: falls back to channel admin", func(t *testing.T) {
|
||||
// When ObjectType has no per-value admin notion (user/system/template),
|
||||
// the check defers to the field's TargetType. A channel-scoped
|
||||
// user-object field should require channel admin on the field's
|
||||
// channel — not sysadmin.
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "values admin user-channel",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypeUser,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelAdmin),
|
||||
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
}
|
||||
|
||||
_, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id,
|
||||
model.ChannelUserRoleId+" "+model.ChannelAdminRoleId)
|
||||
require.Nil(t, appErr)
|
||||
t.Cleanup(func() {
|
||||
_, _ = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, th.BasicUser.Id, model.ChannelUserRoleId)
|
||||
})
|
||||
|
||||
channelAdmin := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
|
||||
channelMember := model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}
|
||||
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelAdmin, field, th.BasicUser2.Id))
|
||||
assert.False(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, channelMember, field, th.BasicUser2.Id))
|
||||
})
|
||||
|
||||
t.Run("unrestricted session bypasses value-target check", func(t *testing.T) {
|
||||
field := fieldFor(model.PropertyFieldObjectTypeChannel)
|
||||
local := model.Session{UserId: th.BasicUser2.Id, Local: true}
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, local, field, th.BasicChannel.Id))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Member-level scope access on post-object values requires only channel
|
||||
// membership: any member of the post's channel can set a value on any post
|
||||
// in that channel, regardless of whether they are the post's author.
|
||||
func TestSessionHasPermissionToSetPropertyFieldValues_PostMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
groupID := registerTestPropertyGroup(t, th)
|
||||
|
||||
// BasicUser2 is a channel member but not the post's author.
|
||||
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
field := &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: "post values member",
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypePost,
|
||||
TargetType: string(model.PropertyFieldTargetLevelSystem),
|
||||
PermissionField: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
PermissionValues: model.NewPointer(model.PermissionLevelMember),
|
||||
PermissionOptions: model.NewPointer(model.PermissionLevelSysadmin),
|
||||
}
|
||||
|
||||
post := th.CreatePost(t, th.BasicChannel) // authored by BasicUser
|
||||
|
||||
authorSession := model.Session{UserId: th.BasicUser.Id, Roles: model.SystemUserRoleId}
|
||||
nonAuthorSession := model.Session{UserId: th.BasicUser2.Id, Roles: model.SystemUserRoleId}
|
||||
|
||||
// Author (channel member) can set the value.
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, authorSession, field, post.Id))
|
||||
// Non-author who is also a channel member can set the value.
|
||||
assert.True(t, th.App.SessionHasPermissionToSetPropertyFieldValues(th.Context, nonAuthorSession, field, post.Id))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -737,6 +737,18 @@ func (a *App) GetGroupChannel(rctx request.CTX, userIDs []string) (*model.Channe
|
|||
|
||||
// UpdateChannel updates a given channel by its Id. It also publishes the CHANNEL_UPDATED event.
|
||||
func (a *App) UpdateChannel(rctx request.CTX, channel *model.Channel) (*model.Channel, *model.AppError) {
|
||||
oldChannel, getErr := a.Srv().Store().Channel().Get(channel.Id, true)
|
||||
if getErr != nil {
|
||||
errCtx := map[string]any{"channel_id": channel.Id}
|
||||
var nfErr *store.ErrNotFound
|
||||
switch {
|
||||
case errors.As(getErr, &nfErr):
|
||||
return nil, model.NewAppError("UpdateChannel", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(getErr)
|
||||
default:
|
||||
return nil, model.NewAppError("UpdateChannel", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(getErr)
|
||||
}
|
||||
}
|
||||
|
||||
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
|
|
@ -752,17 +764,19 @@ func (a *App) UpdateChannel(rctx request.CTX, channel *model.Channel) (*model.Ch
|
|||
// silent type flip would change what the existing policy actually
|
||||
// does to members. The admin must remove the policy first and
|
||||
// re-apply it after the conversion if they still want it.
|
||||
current, getErr := a.Srv().Store().Channel().Get(channel.Id, true)
|
||||
if getErr != nil {
|
||||
return nil, model.NewAppError("UpdateChannel", "app.channel.get.find.app_error", nil, "", http.StatusInternalServerError).Wrap(getErr)
|
||||
}
|
||||
if current.Type != channel.Type {
|
||||
if oldChannel.Type != channel.Type {
|
||||
return nil, model.NewAppError("UpdateChannel",
|
||||
"api.channel.update_channel.policy_enforced_type_conversion.app_error",
|
||||
nil, "channel has an active ABAC policy; remove the policy before converting between public and private", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
var channelErr *model.AppError
|
||||
channel, channelErr = a.runGuardedChannelWillBeUpdated(rctx, channel, oldChannel)
|
||||
if channelErr != nil {
|
||||
return nil, channelErr
|
||||
}
|
||||
|
||||
_, err := a.Srv().Store().Channel().Update(rctx, channel)
|
||||
if err != nil {
|
||||
var appErr *model.AppError
|
||||
|
|
@ -835,6 +849,14 @@ func (a *App) UpdateChannelScheme(rctx request.CTX, channel *model.Channel) (*mo
|
|||
}
|
||||
|
||||
func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError) {
|
||||
wasDiscoverable := oldChannel.Discoverable
|
||||
// Public channels are inherently joinable; the discoverable flag only
|
||||
// has meaning for private channels. Clear it eagerly so callers reading
|
||||
// the row mid-conversion don't see an inconsistent state.
|
||||
if oldChannel.Type == model.ChannelTypeOpen {
|
||||
oldChannel.Discoverable = false
|
||||
}
|
||||
|
||||
channel, err := a.UpdateChannel(rctx, oldChannel)
|
||||
if err != nil {
|
||||
return channel, err
|
||||
|
|
@ -844,6 +866,11 @@ func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel,
|
|||
if postErr != nil {
|
||||
if channel.Type == model.ChannelTypeOpen {
|
||||
channel.Type = model.ChannelTypePrivate
|
||||
// Restore the discoverable flag we eagerly cleared above so
|
||||
// the rollback fully undoes the conversion. Without this the
|
||||
// caller would see a private channel with discoverable=false
|
||||
// (and would have to re-toggle it).
|
||||
channel.Discoverable = wasDiscoverable
|
||||
} else {
|
||||
channel.Type = model.ChannelTypeOpen
|
||||
}
|
||||
|
|
@ -854,6 +881,19 @@ func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel,
|
|||
return channel, postErr
|
||||
}
|
||||
|
||||
// Now that the conversion is fully committed, cancel pending join
|
||||
// requests for the formerly discoverable private channel — the WS
|
||||
// broadcast inside the helper updates each requester's My Pending
|
||||
// Requests list in real-time. Doing this after the privacy-message
|
||||
// step ensures a transient post failure (which triggers the rollback
|
||||
// above) cannot leave requests cancelled against a still-private
|
||||
// channel.
|
||||
if wasDiscoverable && channel.Type == model.ChannelTypeOpen {
|
||||
a.Srv().Go(func() {
|
||||
a.CancelPendingChannelJoinRequestsOnConvert(rctx, channel)
|
||||
})
|
||||
}
|
||||
|
||||
a.Srv().Platform().InvalidateCacheForChannel(channel)
|
||||
|
||||
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelConverted, channel.TeamId, "", "", nil, "")
|
||||
|
|
@ -906,6 +946,10 @@ func (a *App) RestoreChannel(rctx request.CTX, channel *model.Channel, userID st
|
|||
return nil, model.NewAppError("restoreChannel", "api.channel.restore_channel.restored.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if appErr := a.runGuardedChannelWillBeRestored(rctx, channel); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().Channel().Restore(channel.Id, model.GetMillis()); err != nil {
|
||||
return nil, model.NewAppError("RestoreChannel", "app.channel.restore.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
|
@ -1785,7 +1829,7 @@ func (a *App) addUserToChannel(rctx request.CTX, user *model.User, channel *mode
|
|||
if channel.Type == model.ChannelTypePrivate {
|
||||
if ok, appErr := a.ChannelAccessControlled(rctx, channel.Id); ok {
|
||||
if acs := a.Srv().Channels().AccessControl; acs != nil {
|
||||
s, buildErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles)
|
||||
s, buildErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles, channel.Id)
|
||||
if buildErr != nil {
|
||||
return nil, model.NewAppError("AddUserToChannel", "api.channel.add_user.to.channel.abac_subject_build_failed.app_error", nil,
|
||||
fmt.Sprintf("failed to build subject: %v, user_id: %s, channel_id: %s", buildErr, user.Id, channel.Id), http.StatusInternalServerError)
|
||||
|
|
@ -1810,23 +1854,10 @@ func (a *App) addUserToChannel(rctx request.CTX, user *model.User, channel *mode
|
|||
}
|
||||
}
|
||||
|
||||
var rejectionReason string
|
||||
pluginContext := pluginContext(rctx)
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
updatedMember, reason := hooks.ChannelMemberWillBeAdded(pluginContext, newMember)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if updatedMember != nil {
|
||||
newMember = updatedMember
|
||||
}
|
||||
return true
|
||||
}, plugin.ChannelMemberWillBeAddedID)
|
||||
|
||||
if rejectionReason != "" {
|
||||
return nil, model.NewAppError("AddUserToChannel", "app.channel.add_user.to.channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
var channelMemberErr *model.AppError
|
||||
newMember, channelMemberErr = a.runGuardedChannelMemberWillBeAdded(rctx, channel.Id, newMember)
|
||||
if channelMemberErr != nil {
|
||||
return nil, channelMemberErr
|
||||
}
|
||||
|
||||
newMember, nErr = a.Srv().Store().Channel().SaveMember(rctx, newMember)
|
||||
|
|
@ -2122,7 +2153,21 @@ func (a *App) PostUpdateChannelDisplayNameMessage(rctx request.CTX, userID strin
|
|||
}
|
||||
|
||||
func (a *App) GetChannel(rctx request.CTX, channelID string) (*model.Channel, *model.AppError) {
|
||||
return a.Srv().getChannel(rctx, channelID)
|
||||
channel, appErr := a.Srv().getChannel(rctx, channelID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
// Hydrate policy action set so consumers can distinguish a membership
|
||||
// policy from a permission-only policy without a second round-trip.
|
||||
// No-op on channels with PolicyEnforced=false, keeping the cost on the
|
||||
// common no-policy path at zero.
|
||||
if appErr := a.HydrateChannelPolicyActions(rctx, channel); appErr != nil {
|
||||
rctx.Logger().Warn("Failed to hydrate channel policy actions; returning channel without action map",
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (a *App) GetBoardChannel(rctx request.CTX, channelID string) (*model.Channel, *model.AppError) {
|
||||
|
|
@ -2166,6 +2211,15 @@ func (a *App) GetChannels(rctx request.CTX, channelIDs []string) ([]*model.Chann
|
|||
return nil, model.NewAppError("GetChannel", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
}
|
||||
// Batched hydration: a single round-trip aggregates the action union
|
||||
// for every PolicyEnforced=true channel in the slice. No-policy
|
||||
// channels skip the lookup entirely.
|
||||
if appErr := a.HydrateChannelsPolicyActions(rctx, channels); appErr != nil {
|
||||
rctx.Logger().Warn("Failed to hydrate channel policy actions in batch; returning channels without action map",
|
||||
mlog.Int("count", len(channels)),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
|
|
@ -3206,6 +3260,10 @@ func (a *App) AutocompleteChannels(rctx request.CTX, userID, term string) (model
|
|||
return nil, model.NewAppError("AutocompleteChannels", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
channelList, _, appErr = a.FilterChannelListWithTeamDataForUserVisibility(rctx, channelList, userID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return channelList, nil
|
||||
}
|
||||
|
||||
|
|
@ -3223,7 +3281,7 @@ func (a *App) AutocompleteChannelsForTeam(rctx request.CTX, teamID, userID, term
|
|||
return nil, model.NewAppError("AutocompleteChannels", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return channelList, nil
|
||||
return a.FilterChannelListForUserVisibility(rctx, channelList, userID)
|
||||
}
|
||||
|
||||
func (a *App) AutocompleteChannelsForTeamFiltered(rctx request.CTX, teamID, userID, term string, privateOnly, excludeGroupConstrained bool) (model.ChannelList, *model.AppError) {
|
||||
|
|
@ -3240,7 +3298,7 @@ func (a *App) AutocompleteChannelsForTeamFiltered(rctx request.CTX, teamID, user
|
|||
return nil, model.NewAppError("AutocompleteChannelsForTeamFiltered", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return channelList, nil
|
||||
return a.FilterChannelListForUserVisibility(rctx, channelList, userID)
|
||||
}
|
||||
|
||||
func (a *App) AutocompleteChannelsForSearch(rctx request.CTX, teamID string, userID string, term string) (model.ChannelList, *model.AppError) {
|
||||
|
|
@ -4293,6 +4351,13 @@ func (a *App) CheckIfChannelIsRestrictedDM(rctx request.CTX, channel *model.Chan
|
|||
return len(teams) == 0, nil
|
||||
}
|
||||
|
||||
// ChannelAccessControlled reports whether the given channel's membership is
|
||||
// gated by an ABAC policy. Channels carrying only a permission policy (e.g.
|
||||
// file upload restriction) return false — those policies do not control who
|
||||
// can be a member and so must not surface through this gate. Phase 1's
|
||||
// PolicyActions hydration is required for the answer to be correct; this
|
||||
// fetches the channel via the store directly (not App.GetChannel) and then
|
||||
// invokes the hydrator explicitly to avoid the recursive plumbing surface.
|
||||
func (a *App) ChannelAccessControlled(rctx request.CTX, channelID string) (bool, *model.AppError) {
|
||||
if l := a.License(); !model.MinimumEnterpriseAdvancedLicense(l) || !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl {
|
||||
return false, nil
|
||||
|
|
@ -4306,7 +4371,14 @@ func (a *App) ChannelAccessControlled(rctx request.CTX, channelID string) (bool,
|
|||
return false, nil
|
||||
}
|
||||
|
||||
return channel.PolicyEnforced, nil
|
||||
if appErr := a.HydrateChannelPolicyActions(rctx, channel); appErr != nil {
|
||||
// Fail-closed: a hydration error must not silently downgrade an
|
||||
// ABAC-controlled channel to "unrestricted" for callers that rely
|
||||
// on this gate (HasPermissionToChannel and friends).
|
||||
return false, appErr
|
||||
}
|
||||
|
||||
return channel.HasMembershipPolicyAction(), nil
|
||||
}
|
||||
|
||||
// cleanupChannelAccessControlPolicy removes the channel-scope ABAC policy row,
|
||||
|
|
@ -4398,7 +4470,7 @@ func (a *App) GetRecommendedPublicChannelsForUser(rctx request.CTX, userID, team
|
|||
return nil, appErr
|
||||
}
|
||||
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles)
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles, "")
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
|
|
|||
384
server/channels/app/channel_discoverable_visibility.go
Normal file
384
server/channels/app/channel_discoverable_visibility.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
// channelVisibilityCacheKey is the per-request request.CTX value key used to
|
||||
// memoise PDP membership decisions across N+1 channel filtering work in a
|
||||
// single Browse Channels load.
|
||||
type channelVisibilityCacheKey struct{}
|
||||
|
||||
type channelVisibilityCache struct {
|
||||
mu sync.Mutex
|
||||
decisions map[string]bool
|
||||
}
|
||||
|
||||
func getChannelVisibilityCache(rctx request.CTX) *channelVisibilityCache {
|
||||
if v := rctx.Context().Value(channelVisibilityCacheKey{}); v != nil {
|
||||
if cache, ok := v.(*channelVisibilityCache); ok {
|
||||
return cache
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withChannelVisibilityCache returns a request context that memoises PDP
|
||||
// membership decisions across the visibility filter calls in a single request.
|
||||
// It's safe to call this multiple times — only the outermost installation
|
||||
// allocates a cache.
|
||||
func withChannelVisibilityCache(rctx request.CTX) request.CTX {
|
||||
if getChannelVisibilityCache(rctx) != nil {
|
||||
return rctx
|
||||
}
|
||||
cache := &channelVisibilityCache{decisions: map[string]bool{}}
|
||||
return rctx.WithContext(context.WithValue(rctx.Context(), channelVisibilityCacheKey{}, cache))
|
||||
}
|
||||
|
||||
func (c *channelVisibilityCache) get(channelID string) (bool, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.decisions[channelID]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *channelVisibilityCache) set(channelID string, allow bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.decisions[channelID] = allow
|
||||
}
|
||||
|
||||
// FilterDiscoverableChannelsByPolicy removes from `channels` any
|
||||
// policy-enforced private channel that the user fails to satisfy — the
|
||||
// security-critical visibility invariant in plan §6c. Channels without an
|
||||
// active policy are returned untouched. Callers that need the additional
|
||||
// "non-member private must be discoverable" gate should use
|
||||
// FilterChannelsForUserVisibility instead.
|
||||
//
|
||||
// Failure modes are fail-secure: a missing AccessControl service, a
|
||||
// subject-build failure, or any PDP error drops the offending channel from
|
||||
// the result so a non-qualifying user can never be inadvertently shown a
|
||||
// gated channel. Decisions are cached per-request via the request.CTX value
|
||||
// bag installed by withChannelVisibilityCache.
|
||||
func (a *App) FilterDiscoverableChannelsByPolicy(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
|
||||
if len(channels) == 0 {
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
if !a.Config().FeatureFlags.DiscoverableChannels {
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
rctx = withChannelVisibilityCache(rctx)
|
||||
cache := getChannelVisibilityCache(rctx)
|
||||
|
||||
var (
|
||||
user *model.User
|
||||
userErr *model.AppError
|
||||
userOnce sync.Once
|
||||
filtered = make([]*model.Channel, 0, len(channels))
|
||||
dropCount int
|
||||
)
|
||||
|
||||
for _, channel := range channels {
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !channel.PolicyEnforced || channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
|
||||
filtered = append(filtered, channel)
|
||||
continue
|
||||
}
|
||||
|
||||
if cached, ok := cache.get(channel.Id); ok {
|
||||
if cached {
|
||||
filtered = append(filtered, channel)
|
||||
} else {
|
||||
dropCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
userOnce.Do(func() {
|
||||
user, userErr = a.GetUser(userID)
|
||||
})
|
||||
if userErr != nil {
|
||||
return nil, userErr
|
||||
}
|
||||
|
||||
// Guests are never permitted to see discoverable private channels.
|
||||
if user.IsGuest() {
|
||||
cache.set(channel.Id, false)
|
||||
dropCount++
|
||||
continue
|
||||
}
|
||||
|
||||
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
|
||||
if evalErr != nil {
|
||||
rctx.Logger().Warn("FilterDiscoverableChannelsByPolicy: PDP error, hiding channel (fail-secure)",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Err(evalErr),
|
||||
)
|
||||
cache.set(channel.Id, false)
|
||||
dropCount++
|
||||
continue
|
||||
}
|
||||
cache.set(channel.Id, decision)
|
||||
if decision {
|
||||
filtered = append(filtered, channel)
|
||||
} else {
|
||||
dropCount++
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// FilterChannelsForUserVisibility wraps FilterDiscoverableChannelsByPolicy with
|
||||
// the secondary invariant: a non-member private channel must be discoverable
|
||||
// to be visible at all. The caller is expected to scope `channels` to results
|
||||
// where the user is a non-member; member channels should not be passed
|
||||
// through this filter (their visibility is governed by membership alone).
|
||||
//
|
||||
// In practice the search/autocomplete store paths return a mix of member and
|
||||
// non-member rows; callers should pass the full list because the helper
|
||||
// detects membership-implying fields. The current implementation only checks
|
||||
// the discoverability gate (the SQL-level membership join already excluded
|
||||
// unaffiliated channels).
|
||||
func (a *App) FilterChannelsForUserVisibility(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
|
||||
return a.FilterDiscoverableChannelsByPolicy(rctx, channels, userID)
|
||||
}
|
||||
|
||||
// FilterChannelListForUserVisibility is the convenience overload for
|
||||
// model.ChannelList callers (the standard list shape returned by app-layer
|
||||
// search functions).
|
||||
func (a *App) FilterChannelListForUserVisibility(rctx request.CTX, channels model.ChannelList, userID string) (model.ChannelList, *model.AppError) {
|
||||
filtered, err := a.FilterChannelsForUserVisibility(rctx, channels, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return model.ChannelList(filtered), nil
|
||||
}
|
||||
|
||||
// FilterChannelListWithTeamDataForUserVisibility filters the team-data list
|
||||
// shape used by Autocomplete and SearchAllChannels. The function preserves
|
||||
// the embedded TeamDisplayName / TeamName fields. Returns the post-filter
|
||||
// total adjustment so paginated callers can shrink TotalCount alongside the
|
||||
// trimmed result set.
|
||||
func (a *App) FilterChannelListWithTeamDataForUserVisibility(rctx request.CTX, channels model.ChannelListWithTeamData, userID string) (model.ChannelListWithTeamData, int, *model.AppError) {
|
||||
if len(channels) == 0 {
|
||||
return channels, 0, nil
|
||||
}
|
||||
|
||||
if !a.Config().FeatureFlags.DiscoverableChannels {
|
||||
return channels, 0, nil
|
||||
}
|
||||
|
||||
rctx = withChannelVisibilityCache(rctx)
|
||||
cache := getChannelVisibilityCache(rctx)
|
||||
|
||||
var (
|
||||
user *model.User
|
||||
userErr *model.AppError
|
||||
userOnce sync.Once
|
||||
out = make(model.ChannelListWithTeamData, 0, len(channels))
|
||||
dropped int
|
||||
)
|
||||
|
||||
for i := range channels {
|
||||
ch := channels[i]
|
||||
if !ch.PolicyEnforced || ch.Type != model.ChannelTypePrivate || !ch.Discoverable {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if cached, ok := cache.get(ch.Id); ok {
|
||||
if cached {
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
dropped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
userOnce.Do(func() {
|
||||
user, userErr = a.GetUser(userID)
|
||||
})
|
||||
if userErr != nil {
|
||||
return nil, 0, userErr
|
||||
}
|
||||
|
||||
if user.IsGuest() {
|
||||
cache.set(ch.Id, false)
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
|
||||
decision, evalErr := a.evaluateChannelMembership(rctx, user, &ch.Channel)
|
||||
if evalErr != nil {
|
||||
rctx.Logger().Warn("FilterChannelListWithTeamDataForUserVisibility: PDP error, hiding channel (fail-secure)",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("channel_id", ch.Id),
|
||||
mlog.Err(evalErr),
|
||||
)
|
||||
cache.set(ch.Id, false)
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
cache.set(ch.Id, decision)
|
||||
if decision {
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
|
||||
return out, dropped, nil
|
||||
}
|
||||
|
||||
// IsDiscoverableJoinAllowed reports whether `user` may view `channel` as a
|
||||
// non-member through the discoverable-channels surface. Returns 404 (mapped
|
||||
// by callers) when the channel is hidden from this user — matching the
|
||||
// "indistinguishable from a non-existent channel" requirement so the policy
|
||||
// cannot act as an existence oracle.
|
||||
func (a *App) IsDiscoverableJoinAllowed(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
|
||||
if channel == nil {
|
||||
return false, nil
|
||||
}
|
||||
if channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
|
||||
return false, nil
|
||||
}
|
||||
if user == nil || user.IsGuest() || user.DeleteAt != 0 {
|
||||
return false, nil
|
||||
}
|
||||
if channel.DeleteAt != 0 || channel.IsShared() {
|
||||
return false, nil
|
||||
}
|
||||
if !channel.PolicyEnforced {
|
||||
return true, nil
|
||||
}
|
||||
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
|
||||
if evalErr != nil {
|
||||
// Fail-secure: PDP failure hides the channel rather than leak it.
|
||||
rctx.Logger().Warn("IsDiscoverableJoinAllowed: PDP error, hiding channel (fail-secure)",
|
||||
mlog.String("user_id", user.Id),
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Err(evalErr),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// CancelPendingChannelJoinRequestsOnConvert transitions every pending request
|
||||
// for a channel to the withdrawn state — used when the channel is converted
|
||||
// to public (open channels are inherently joinable, so a pending queue is
|
||||
// nonsensical) and when the channel is archived. Failures are logged because
|
||||
// the conversion / archive must not be blocked.
|
||||
func (a *App) CancelPendingChannelJoinRequestsOnConvert(rctx request.CTX, channel *model.Channel) {
|
||||
if channel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
pageSize = 200
|
||||
maxIterations = 50 // hard cap at ~10k requests per channel
|
||||
)
|
||||
for range maxIterations {
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: model.ChannelJoinRequestStatusPending,
|
||||
Page: 0,
|
||||
PerPage: pageSize,
|
||||
}
|
||||
rows, _, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channel.Id, opts)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to list pending requests",
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
failed := 0
|
||||
for _, row := range rows {
|
||||
row.Status = model.ChannelJoinRequestStatusWithdrawn
|
||||
row.Message = ""
|
||||
updated, updateErr := a.Srv().Store().ChannelJoinRequest().Update(row)
|
||||
if updateErr != nil {
|
||||
failed++
|
||||
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to withdraw pending request",
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.String("request_id", row.Id),
|
||||
mlog.Err(updateErr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
|
||||
}
|
||||
// If every row in the batch failed to update, the next iteration
|
||||
// would re-fetch the same rows and loop forever. Break out and
|
||||
// surface the situation in the log — the operator can re-run the
|
||||
// cleanup manually after addressing the underlying store error.
|
||||
if failed == len(rows) {
|
||||
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: every row in batch failed to update, aborting to avoid infinite loop",
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Int("failed", failed),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Standard exit when the last page is partial: every remaining
|
||||
// pending row was successfully withdrawn (or logged as failed).
|
||||
if len(rows) < pageSize {
|
||||
return
|
||||
}
|
||||
}
|
||||
// maxIterations safety net — this should be effectively unreachable
|
||||
// because the per-batch all-failed check above already aborts on
|
||||
// systemic update failures. Fire a higher-severity log if we hit it.
|
||||
rctx.Logger().Error("CancelPendingChannelJoinRequestsOnConvert: hit maxIterations, aborting",
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Int("max_iterations", maxIterations),
|
||||
)
|
||||
}
|
||||
|
||||
// IsDiscoverableSelfAddBlocked reports whether a user trying to self-add to
|
||||
// `channel` via POST /channels/{id}/members must instead go through the
|
||||
// request flow. The block applies only when:
|
||||
// - the channel is private,
|
||||
// - it is discoverable but does NOT have an active ABAC policy
|
||||
// (channels with a policy use the existing PDP gate inside
|
||||
// addUserToChannel — admins can still add others by policy),
|
||||
// - the user is not yet a member,
|
||||
// - and the requester is the user themselves.
|
||||
//
|
||||
// Other paths (admin invites, API by reviewer ID) are unaffected: the request
|
||||
// flow exists to give admins a queue, not to block invites.
|
||||
func (a *App) IsDiscoverableSelfAddBlocked(rctx request.CTX, channel *model.Channel, requesterUserID, targetUserID string) bool {
|
||||
if channel == nil || channel.Type != model.ChannelTypePrivate {
|
||||
return false
|
||||
}
|
||||
if !channel.Discoverable {
|
||||
return false
|
||||
}
|
||||
if channel.PolicyEnforced {
|
||||
return false
|
||||
}
|
||||
if requesterUserID != targetUserID {
|
||||
return false
|
||||
}
|
||||
if !a.Config().FeatureFlags.DiscoverableChannels {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
85
server/channels/app/channel_discoverable_visibility_test.go
Normal file
85
server/channels/app/channel_discoverable_visibility_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy verifies that a
|
||||
// discoverable + no-policy private channel is returned through the
|
||||
// non-member autocomplete path for a non-guest user.
|
||||
//
|
||||
// The complementary policy-enforced + non-qualifying user case is covered
|
||||
// by TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure (which
|
||||
// checks the fail-secure path) and the dedicated guest case is in
|
||||
// TestFilterDiscoverableChannelsByPolicy_GuestHidden.
|
||||
func TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
// BasicUser2 is a member of the team but NOT of `channel`. The
|
||||
// autocomplete query must still surface the channel because of the
|
||||
// discoverable OR-branch (post-query ABAC filter is a no-op since the
|
||||
// channel has no policy).
|
||||
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, channel.Name)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
found := false
|
||||
for _, c := range results {
|
||||
if c.Id == channel.Id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "discoverable + no-policy private channel must appear in autocomplete for a non-member non-guest")
|
||||
}
|
||||
|
||||
// TestDiscoverableVisibilityInvariant_NonDiscoverableHidden ensures that the
|
||||
// store-level OR-branch we added does not inadvertently leak private
|
||||
// channels with discoverable=false to non-members. The new OR clause must be
|
||||
// gated on `Discoverable=true`.
|
||||
func TestDiscoverableVisibilityInvariant_NonDiscoverableHidden(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
plain := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, plain.Name)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
for _, c := range results {
|
||||
assert.NotEqual(t, plain.Id, c.Id, "non-discoverable private channel must remain hidden from non-members")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverableVisibilityInvariant_GuestHidden re-verifies the guest path
|
||||
// at the autocomplete level (the unit-level guest case lives in
|
||||
// TestFilterDiscoverableChannelsByPolicy_GuestHidden, but this test exercises
|
||||
// the full app+store integration so we don't accidentally rely on the
|
||||
// in-memory filter alone).
|
||||
func TestDiscoverableVisibilityInvariant_GuestHidden(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
guest := th.CreateGuest(t)
|
||||
th.LinkUserToTeam(t, guest, th.BasicTeam)
|
||||
|
||||
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, guest.Id, channel.Name)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
for _, c := range results {
|
||||
assert.NotEqual(t, channel.Id, c.Id, "guests must never see discoverable private channels in autocomplete")
|
||||
}
|
||||
}
|
||||
216
server/channels/app/channel_guards.go
Normal file
216
server/channels/app/channel_guards.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// Backoff bounds for the guard-cache reload retry. Package vars (not consts) so tests can shrink
|
||||
// them via t.Cleanup-restored override.
|
||||
var (
|
||||
guardCacheRetryInitialDelay = 1 * time.Second
|
||||
guardCacheRetryMaxDelay = 5 * time.Minute
|
||||
)
|
||||
|
||||
const clusterEventInvalidateChannelGuardCache = model.ClusterEvent("inv_channel_guards")
|
||||
|
||||
// reloadGuardCache scans the ChannelGuards table and atomically replaces the in-memory cache with
|
||||
// the result. Used both at startup (from NewChannels) and from the cluster invalidation handler.
|
||||
// Forces a master read because all callers (post-write reload, cluster invalidation) can race with
|
||||
// replica lag.
|
||||
func (ch *Channels) reloadGuardCache(rctx request.CTX, s store.Store) error {
|
||||
guards, err := s.ChannelGuard().GetAll(store.RequestContextWithMaster(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fresh := &sync.Map{}
|
||||
grouped := map[string][]*store.ChannelGuard{}
|
||||
for _, g := range guards {
|
||||
grouped[g.ChannelId] = append(grouped[g.ChannelId], g)
|
||||
}
|
||||
for channelID, slice := range grouped {
|
||||
fresh.Store(channelID, slice)
|
||||
}
|
||||
|
||||
ch.guardCache.Store(fresh)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getGuardsForChannel returns the cached guard slice for a channel, or nil if none.
|
||||
func (ch *Channels) getGuardsForChannel(channelID string) []*store.ChannelGuard {
|
||||
m := ch.guardCache.Load()
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
v, ok := m.Load(channelID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
guards, _ := v.([]*store.ChannelGuard)
|
||||
return guards
|
||||
}
|
||||
|
||||
// clusterInvalidateGuardCacheHandler is registered as the receive-side handler for
|
||||
// clusterEventInvalidateChannelGuardCache. The handler refetches the entire table.
|
||||
func (ch *Channels) clusterInvalidateGuardCacheHandler(msg *model.ClusterMessage) {
|
||||
rctx := request.EmptyContext(ch.srv.Log())
|
||||
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
|
||||
ch.srv.Log().Warn(
|
||||
"Failed to reload channel guard cache after cluster invalidation; retry scheduled",
|
||||
mlog.String("event", string(msg.Event)),
|
||||
mlog.Err(err),
|
||||
)
|
||||
ch.scheduleGuardCacheReloadRetry()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastChannelGuardInvalidation tells the rest of the cluster to refetch their guard caches.
|
||||
// The payload is intentionally empty.
|
||||
func (ch *Channels) broadcastChannelGuardInvalidation() {
|
||||
cluster := ch.srv.platform.Cluster()
|
||||
if cluster == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := &model.ClusterMessage{
|
||||
Event: clusterEventInvalidateChannelGuardCache,
|
||||
SendType: model.ClusterSendReliable,
|
||||
WaitForAllToSend: true,
|
||||
}
|
||||
cluster.SendClusterMessage(msg)
|
||||
}
|
||||
|
||||
// RegisterChannelGuard records that pluginID claims channelID. The caller's pluginID is expected to
|
||||
// be lowercased.
|
||||
func (a *App) RegisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
|
||||
if channelID == "" {
|
||||
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.empty_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
if !model.IsValidId(channelID) {
|
||||
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
guard := &store.ChannelGuard{
|
||||
ChannelId: channelID,
|
||||
PluginId: pluginID,
|
||||
CreatedAt: model.GetMillis(),
|
||||
}
|
||||
if err := a.Srv().Store().ChannelGuard().Save(rctx, guard); err != nil {
|
||||
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
ch := a.Channels()
|
||||
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
|
||||
a.Srv().Log().Warn(
|
||||
"Failed to reload channel guard cache after Register; retry scheduled",
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.String("plugin_id", pluginID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
ch.scheduleGuardCacheReloadRetry()
|
||||
}
|
||||
ch.broadcastChannelGuardInvalidation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnregisterChannelGuard removes pluginID's claim on channelID. If pluginID has no claim on the
|
||||
// channel, this is a no-op (returns nil). The store-level DELETE matches by both ChannelId and
|
||||
// PluginId, so other plugins' claims on the same channel are left untouched.
|
||||
func (a *App) UnregisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
|
||||
if channelID == "" {
|
||||
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.empty_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
if !model.IsValidId(channelID) {
|
||||
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
rowsAffected, err := a.Srv().Store().ChannelGuard().Delete(rctx, channelID, pluginID)
|
||||
if err != nil {
|
||||
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
a.Srv().Log().Warn(
|
||||
"UnregisterChannelGuard removed no rows; pluginID does not match any guard for this channel",
|
||||
mlog.String("error_id", "unregister_no_matching_guard"),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.String("plugin_id", pluginID),
|
||||
)
|
||||
}
|
||||
|
||||
ch := a.Channels()
|
||||
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
|
||||
a.Srv().Log().Warn(
|
||||
"Failed to reload channel guard cache after Unregister; retry scheduled",
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.String("plugin_id", pluginID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
ch.scheduleGuardCacheReloadRetry()
|
||||
}
|
||||
ch.broadcastChannelGuardInvalidation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// scheduleGuardCacheReloadRetry kicks off a single in-flight retry goroutine that calls
|
||||
// reloadGuardCache with exponential backoff until success or until the server is shutting down.
|
||||
// Multiple concurrent calls collapse to a single retry — useful when Register, Unregister, the
|
||||
// cluster handler, and the startup loader can all see the same DB outage simultaneously.
|
||||
//
|
||||
// Returns true if a new retry goroutine was scheduled, false if one was already in flight. Call
|
||||
// sites can ignore the return value; tests use it to assert single-flight semantics.
|
||||
func (ch *Channels) scheduleGuardCacheReloadRetry() bool {
|
||||
if !ch.guardCacheRetryInFlight.CompareAndSwap(false, true) {
|
||||
return false
|
||||
}
|
||||
go ch.runGuardCacheReloadRetry()
|
||||
return true
|
||||
}
|
||||
|
||||
func (ch *Channels) runGuardCacheReloadRetry() {
|
||||
defer ch.guardCacheRetryInFlight.Store(false)
|
||||
rctx := request.EmptyContext(ch.srv.Log())
|
||||
|
||||
delay := guardCacheRetryInitialDelay
|
||||
for attempt := 1; ; attempt++ {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ch.interruptQuitChan:
|
||||
timer.Stop()
|
||||
ch.srv.Log().Info(
|
||||
"Channel guard cache reload retry cancelled by shutdown",
|
||||
mlog.Int("attempt", attempt),
|
||||
)
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
|
||||
ch.srv.Log().Info(
|
||||
"Channel guard cache reload retry attempt failed; will retry",
|
||||
mlog.Int("attempt", attempt),
|
||||
mlog.Err(err),
|
||||
)
|
||||
delay *= 2
|
||||
if delay > guardCacheRetryMaxDelay {
|
||||
delay = guardCacheRetryMaxDelay
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ch.srv.Log().Info(
|
||||
"Channel guard cache reload retry succeeded",
|
||||
mlog.Int("attempt", attempt),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
381
server/channels/app/channel_guards_test.go
Normal file
381
server/channels/app/channel_guards_test.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// captureClusterMock records every SendClusterMessage call made during a test
|
||||
// so the test can assert what was broadcast.
|
||||
type captureClusterMock struct {
|
||||
mu sync.Mutex
|
||||
captured []*model.ClusterMessage
|
||||
}
|
||||
|
||||
func (c *captureClusterMock) SendClusterMessage(msg *model.ClusterMessage) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.captured = append(c.captured, msg)
|
||||
}
|
||||
|
||||
func (c *captureClusterMock) SendClusterMessageToNode(nodeID string, msg *model.ClusterMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureClusterMock) snapshot() []*model.ClusterMessage {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]*model.ClusterMessage, len(c.captured))
|
||||
copy(out, c.captured)
|
||||
return out
|
||||
}
|
||||
|
||||
// reset drops everything captured so far. Call this after TestHelper setup
|
||||
// completes so the test only sees messages produced by the code under test
|
||||
// (TestHelper init produces ~1000 unrelated cluster messages).
|
||||
func (c *captureClusterMock) reset() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.captured = nil
|
||||
}
|
||||
|
||||
func (c *captureClusterMock) StartInterNodeCommunication() {}
|
||||
func (c *captureClusterMock) StopInterNodeCommunication() {}
|
||||
func (c *captureClusterMock) RegisterClusterMessageHandler(event model.ClusterEvent, crm einterfaces.ClusterMessageHandler) {
|
||||
}
|
||||
func (c *captureClusterMock) GetClusterId() string { return "capture_cluster_mock" }
|
||||
func (c *captureClusterMock) IsLeader() bool { return false }
|
||||
func (c *captureClusterMock) GetMyClusterInfo() *model.ClusterInfo { return nil }
|
||||
func (c *captureClusterMock) GetClusterInfos() ([]*model.ClusterInfo, error) { return nil, nil }
|
||||
func (c *captureClusterMock) NotifyMsg(buf []byte) {}
|
||||
func (c *captureClusterMock) GetClusterStats(rctx request.CTX) ([]*model.ClusterStats, *model.AppError) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureClusterMock) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureClusterMock) QueryLogs(rctx request.CTX, page, perPage int) (map[string][]string, *model.AppError) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureClusterMock) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) (map[string][]model.FileData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureClusterMock) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *captureClusterMock) ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError {
|
||||
return nil
|
||||
}
|
||||
func (c *captureClusterMock) HealthScore() int { return 0 }
|
||||
func (c *captureClusterMock) WebConnCountForUser(userID string) (int, *model.AppError) {
|
||||
return 0, nil
|
||||
}
|
||||
func (c *captureClusterMock) GetWSQueues(userID, connectionID string, seqNum int64) (map[string]*model.WSQueues, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestChannelGuardCacheBroadcastShape(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
cluster := &captureClusterMock{}
|
||||
th := SetupWithClusterMock(t, cluster)
|
||||
cluster.reset() // drop init-time noise; only inspect messages from code under test
|
||||
|
||||
th.App.Channels().broadcastChannelGuardInvalidation()
|
||||
|
||||
captured := cluster.snapshot()
|
||||
require.Len(t, captured, 1)
|
||||
msg := captured[0]
|
||||
assert.Equal(t, clusterEventInvalidateChannelGuardCache, msg.Event)
|
||||
assert.Equal(t, model.ClusterSendReliable, msg.SendType)
|
||||
assert.Empty(t, msg.Data, "broadcast payload should be empty (D9: receiver does a full reload)")
|
||||
assert.True(t, msg.WaitForAllToSend, "guard invalidation must wait for cluster ack (matches access_control precedent)")
|
||||
}
|
||||
|
||||
func TestChannelGuardRegisterTriggersBroadcast(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
cluster := &captureClusterMock{}
|
||||
th := SetupWithClusterMock(t, cluster)
|
||||
cluster.reset() // drop init-time noise; only inspect messages from code under test
|
||||
|
||||
channelID := model.NewId()
|
||||
pluginID := "com.example.register-broadcast"
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
|
||||
|
||||
guardEvents := filterGuardCacheEvents(cluster.snapshot())
|
||||
require.Len(t, guardEvents, 1, "Register must produce exactly one guard-cache invalidation")
|
||||
}
|
||||
|
||||
func filterGuardCacheEvents(msgs []*model.ClusterMessage) []*model.ClusterMessage {
|
||||
out := []*model.ClusterMessage{}
|
||||
for _, m := range msgs {
|
||||
if m.Event == clusterEventInvalidateChannelGuardCache {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestChannelGuardUnregisterTriggersBroadcast(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
cluster := &captureClusterMock{}
|
||||
th := SetupWithClusterMock(t, cluster)
|
||||
|
||||
channelID := model.NewId()
|
||||
pluginID := "com.example.unregister-broadcast"
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
// Register first (this also broadcasts), then drop captured noise so we
|
||||
// only see the Unregister-side broadcast.
|
||||
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
|
||||
cluster.reset()
|
||||
|
||||
require.Nil(t, th.App.UnregisterChannelGuard(rctx, channelID, pluginID))
|
||||
|
||||
guardEvents := filterGuardCacheEvents(cluster.snapshot())
|
||||
require.Len(t, guardEvents, 1, "Unregister must produce exactly one guard-cache invalidation")
|
||||
}
|
||||
|
||||
func TestChannelGuardCacheMultiChannelRefetch(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
cluster := &captureClusterMock{}
|
||||
th := SetupWithClusterMock(t, cluster)
|
||||
|
||||
channelA := model.NewId()
|
||||
channelB := model.NewId()
|
||||
pluginA := "com.example.multi-a"
|
||||
pluginB := "com.example.multi-b"
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginA, CreatedAt: 1}))
|
||||
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginB, CreatedAt: 2}))
|
||||
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelB, PluginId: pluginA, CreatedAt: 3}))
|
||||
|
||||
// Force the cache to be empty (simulate a node that just started or had its cache cleared).
|
||||
th.App.Channels().guardCache.Store(&sync.Map{})
|
||||
|
||||
th.App.Channels().clusterInvalidateGuardCacheHandler(&model.ClusterMessage{
|
||||
Event: clusterEventInvalidateChannelGuardCache,
|
||||
})
|
||||
|
||||
gotA := th.App.Channels().getGuardsForChannel(channelA)
|
||||
gotB := th.App.Channels().getGuardsForChannel(channelB)
|
||||
assert.Len(t, gotA, 2, "channel A should have two claims after refetch")
|
||||
assert.Len(t, gotB, 1, "channel B should have one claim after refetch")
|
||||
}
|
||||
|
||||
// TestChannelGuardRegisterUnregisterNilClusterIsSafe verifies that the
|
||||
// App-level Register/Unregister methods don't panic when Cluster() is nil.
|
||||
// They reach broadcastChannelGuardInvalidation, so this also covers the nil
|
||||
// guard inside that helper.
|
||||
func TestChannelGuardRegisterUnregisterNilClusterIsSafe(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
require.Nil(t, th.App.Srv().platform.Cluster(), "expected nil cluster in a single-node test setup")
|
||||
|
||||
channelID := th.BasicChannel.Id
|
||||
pluginID := "com.example.nil-cluster-rt"
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginID))
|
||||
got := th.App.Channels().getGuardsForChannel(channelID)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, pluginID, got[0].PluginId)
|
||||
|
||||
require.Nil(t, th.App.UnregisterChannelGuard(rctx, channelID, pluginID))
|
||||
assert.Empty(t, th.App.Channels().getGuardsForChannel(channelID))
|
||||
}
|
||||
|
||||
func TestChannelGuardLowercaseNormalization(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
channelID := th.BasicChannel.Id
|
||||
mixedCaseID := "MixedCase.Plugin.ID"
|
||||
expectedID := "mixedcase.plugin.id"
|
||||
|
||||
// Build a PluginAPI directly with a mixed-case manifest. This bypasses the
|
||||
// real plugin activation path (which we don't need for the lowercasing
|
||||
// check) and exercises only the api.id -> App.RegisterChannelGuard handoff.
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
api := &PluginAPI{
|
||||
id: mixedCaseID,
|
||||
app: th.App,
|
||||
ctx: rctx,
|
||||
}
|
||||
|
||||
require.Nil(t, api.RegisterChannelGuard(channelID))
|
||||
guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, guards, 1)
|
||||
assert.Equal(t, expectedID, guards[0].PluginId, "PluginId must be normalized to lowercase before reaching the store")
|
||||
|
||||
require.Nil(t, api.UnregisterChannelGuard(channelID))
|
||||
guards, err = th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, guards, "Unregister with the same mixed-case id must hit the lowercased row")
|
||||
}
|
||||
|
||||
func TestChannelGuardEmptyChannelIDRejected(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
appErr := th.App.RegisterChannelGuard(rctx, "", "com.example.plugin")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.channel_guard.register.empty_channel.app_error", appErr.Id)
|
||||
assert.Equal(t, 400, appErr.StatusCode)
|
||||
|
||||
appErr = th.App.UnregisterChannelGuard(rctx, "", "com.example.plugin")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.channel_guard.unregister.empty_channel.app_error", appErr.Id)
|
||||
assert.Equal(t, 400, appErr.StatusCode)
|
||||
}
|
||||
|
||||
// TestUnregisterChannelGuardWarnsOnNoMatchingRow verifies that calling UnregisterChannelGuard with
|
||||
// a pluginID that has no claim on the channel returns nil (no error) and leaves the existing guard
|
||||
// row untouched. The Warn log emitted when rowsAffected==0 is operator-facing and is not asserted
|
||||
// here; the behavioral contract (nil return + row unchanged) is the check.
|
||||
func TestUnregisterChannelGuardWarnsOnNoMatchingRow(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
channelID := th.BasicChannel.Id
|
||||
pluginA := "com.example.plugin-a"
|
||||
pluginB := "com.example.plugin-b"
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
|
||||
// Register pluginA's guard on the channel.
|
||||
require.Nil(t, th.App.RegisterChannelGuard(rctx, channelID, pluginA))
|
||||
|
||||
// Unregister with a different pluginID — must return nil (no-op).
|
||||
appErr := th.App.UnregisterChannelGuard(rctx, channelID, pluginB)
|
||||
require.Nil(t, appErr, "cross-plugin Unregister must return nil")
|
||||
|
||||
// pluginA's guard row must be untouched.
|
||||
guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, guards, 1, "pluginA guard row must remain after cross-plugin Unregister")
|
||||
assert.Equal(t, pluginA, guards[0].PluginId)
|
||||
}
|
||||
|
||||
// failingGuardStore wraps a real ChannelGuardStore but forces GetAll to error,
|
||||
// so tests can exercise reload-failure branches deterministically.
|
||||
type failingGuardStore struct {
|
||||
store.ChannelGuardStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *failingGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
// guardFailingStoreWrapper decorates a real Store, swapping ChannelGuard() for
|
||||
// a failing implementation. All other store calls pass through to the embedded
|
||||
// Store so the rest of the app stays functional.
|
||||
type guardFailingStoreWrapper struct {
|
||||
store.Store
|
||||
failing *failingGuardStore
|
||||
}
|
||||
|
||||
func (w *guardFailingStoreWrapper) ChannelGuard() store.ChannelGuardStore {
|
||||
return w.failing
|
||||
}
|
||||
|
||||
func TestChannelGuardCacheClusterInvalidationHandlesStoreFailure(t *testing.T) {
|
||||
// No t.Parallel(): mutates package-level guardCacheRetryInitialDelay.
|
||||
originalInitial := guardCacheRetryInitialDelay
|
||||
guardCacheRetryInitialDelay = 30 * time.Second
|
||||
t.Cleanup(func() { guardCacheRetryInitialDelay = originalInitial })
|
||||
|
||||
th := Setup(t)
|
||||
ch := th.App.Channels()
|
||||
|
||||
// Pre-populate the cache with a known row by writing through the real store
|
||||
// then doing a successful reload.
|
||||
channelID := model.NewId()
|
||||
pluginID := "com.example.cluster-fail-test"
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
require.NoError(t, th.App.Srv().Store().ChannelGuard().Save(rctx, &store.ChannelGuard{
|
||||
ChannelId: channelID,
|
||||
PluginId: pluginID,
|
||||
CreatedAt: 1,
|
||||
}))
|
||||
require.NoError(t, ch.reloadGuardCache(rctx, th.App.Srv().Store()))
|
||||
require.Len(t, ch.getGuardsForChannel(channelID), 1, "precondition: cache should hold the seeded row")
|
||||
|
||||
// Swap in a wrapped store that fails on GetAll.
|
||||
originalStore := th.App.Srv().Store()
|
||||
wrapped := &guardFailingStoreWrapper{
|
||||
Store: originalStore,
|
||||
failing: &failingGuardStore{ChannelGuardStore: originalStore.ChannelGuard(), err: assert.AnError},
|
||||
}
|
||||
th.App.Srv().SetStore(wrapped)
|
||||
t.Cleanup(func() { th.App.Srv().SetStore(originalStore) })
|
||||
|
||||
// Sanity: confirm the wrapped store actually fails, otherwise the test is meaningless.
|
||||
_, err := th.App.Srv().Store().ChannelGuard().GetAll(rctx)
|
||||
require.Error(t, err, "test wrapper must surface GetAll failure")
|
||||
|
||||
// Calling the handler with a failing store must:
|
||||
// - not panic
|
||||
// - leave the existing cache untouched
|
||||
// - schedule a retry (atomic.Bool flips to true)
|
||||
require.NotPanics(t, func() {
|
||||
ch.clusterInvalidateGuardCacheHandler(&model.ClusterMessage{
|
||||
Event: clusterEventInvalidateChannelGuardCache,
|
||||
})
|
||||
})
|
||||
|
||||
assert.Len(t, ch.getGuardsForChannel(channelID), 1, "cache must be unchanged when reload fails")
|
||||
assert.True(t, ch.guardCacheRetryInFlight.Load(), "failed reload from cluster handler must schedule a retry")
|
||||
}
|
||||
|
||||
// TestScheduleGuardCacheReloadRetrySingleFlight verifies that concurrent calls to
|
||||
// scheduleGuardCacheReloadRetry collapse to a single in-flight retry goroutine. The retry goroutine
|
||||
// is parked in its initial timer wait by shrinking nothing — instead we override the initial delay
|
||||
// to a very long value so the test window stays inside the timer wait, then verify the second call
|
||||
// returns false (no new goroutine scheduled). Test cleanup tears down the server which closes
|
||||
// interruptQuitChan and lets the parked goroutine exit cleanly. No t.Parallel() because it mutates
|
||||
// a package-level var.
|
||||
func TestScheduleGuardCacheReloadRetrySingleFlight(t *testing.T) {
|
||||
originalInitial := guardCacheRetryInitialDelay
|
||||
guardCacheRetryInitialDelay = 30 * time.Second
|
||||
t.Cleanup(func() { guardCacheRetryInitialDelay = originalInitial })
|
||||
|
||||
th := Setup(t)
|
||||
|
||||
ch := th.App.Channels()
|
||||
require.True(t, ch.scheduleGuardCacheReloadRetry(), "first call should schedule a retry")
|
||||
require.False(t, ch.scheduleGuardCacheReloadRetry(), "second call should be a no-op while one is in flight")
|
||||
require.False(t, ch.scheduleGuardCacheReloadRetry(), "additional concurrent calls should also be no-ops")
|
||||
}
|
||||
|
||||
func TestChannelGuardInvalidChannelIDRejected(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
appErr := th.App.RegisterChannelGuard(rctx, "not-a-real-id", "com.example.plugin")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.channel_guard.invalid_channel.app_error", appErr.Id)
|
||||
assert.Equal(t, 400, appErr.StatusCode)
|
||||
|
||||
appErr = th.App.UnregisterChannelGuard(rctx, "not-a-real-id", "com.example.plugin")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.channel_guard.invalid_channel.app_error", appErr.Id)
|
||||
assert.Equal(t, 400, appErr.StatusCode)
|
||||
}
|
||||
447
server/channels/app/channel_join_request.go
Normal file
447
server/channels/app/channel_join_request.go
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// channelJoinRequestPaginationDefaultPerPage matches the public /api/v4 default
|
||||
// for paginated endpoints.
|
||||
const channelJoinRequestPaginationDefaultPerPage = 60
|
||||
|
||||
// channelJoinRequestPaginationMaxPerPage caps a single page's size; mirrors the
|
||||
// 200 cap shared by other public list endpoints.
|
||||
const channelJoinRequestPaginationMaxPerPage = 200
|
||||
|
||||
// requestJoinChannelGuard validates that a user is allowed to express interest
|
||||
// in joining `channel` and returns a sanitized result for `channel`. Callers
|
||||
// are expected to look up `channel` via the store before calling this helper.
|
||||
func (a *App) requestJoinChannelGuard(rctx request.CTX, user *model.User, channel *model.Channel) *model.AppError {
|
||||
if channel == nil {
|
||||
return model.NewAppError("RequestJoinChannel", "app.channel.get.existing.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
if channel.DeleteAt != 0 {
|
||||
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.archived.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if channel.Type != model.ChannelTypePrivate {
|
||||
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_private.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !channel.Discoverable {
|
||||
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_discoverable.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Shared channels join through their own remote-cluster sync mechanism.
|
||||
if channel.IsShared() {
|
||||
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if user.IsGuest() {
|
||||
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.guest.app_error", nil, "user_id="+user.Id, http.StatusForbidden)
|
||||
}
|
||||
|
||||
if user.DeleteAt != 0 {
|
||||
return model.NewAppError("RequestJoinChannel", "app.channel.add_member.deleted_user.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestJoinChannel decides between an immediate ABAC-gated auto-join and an
|
||||
// asynchronous request-to-join row.
|
||||
//
|
||||
// Returns the persisted ChannelJoinRequest when the user must wait for an
|
||||
// admin review, or nil when the user was added directly to the channel (the
|
||||
// caller can detect this via the `joined` return value).
|
||||
func (a *App) RequestJoinChannel(rctx request.CTX, userID, channelID, message string) (joined bool, req *model.ChannelJoinRequest, appErr *model.AppError) {
|
||||
user, appErr := a.GetUser(userID)
|
||||
if appErr != nil {
|
||||
return false, nil, appErr
|
||||
}
|
||||
|
||||
channel, appErr := a.GetChannel(rctx, channelID)
|
||||
if appErr != nil {
|
||||
return false, nil, appErr
|
||||
}
|
||||
|
||||
if guardErr := a.requestJoinChannelGuard(rctx, user, channel); guardErr != nil {
|
||||
return false, nil, guardErr
|
||||
}
|
||||
|
||||
_, memberErr := a.Srv().Store().Channel().GetMember(rctx, channel.Id, user.Id)
|
||||
if memberErr == nil {
|
||||
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.already_member.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
||||
}
|
||||
var nfErr *store.ErrNotFound
|
||||
if !errors.As(memberErr, &nfErr) {
|
||||
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr)
|
||||
}
|
||||
|
||||
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
|
||||
if appErr != nil {
|
||||
return false, nil, appErr
|
||||
}
|
||||
|
||||
// ABAC gate: when an active policy is attached and the user qualifies, add
|
||||
// the member directly. AddChannelMember re-runs the PDP gate inside
|
||||
// addUserToChannel, so a denial here is authoritative; a non-allow result
|
||||
// falls through to the request-row path below ONLY when there is no policy.
|
||||
if enforced {
|
||||
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
|
||||
if evalErr != nil {
|
||||
return false, nil, evalErr
|
||||
}
|
||||
if !decision {
|
||||
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.policy_denied.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
||||
}
|
||||
|
||||
if _, err := a.AddChannelMember(rctx, user.Id, channel, ChannelMemberOpts{UserRequestorID: user.Id}); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
pending := &model.ChannelJoinRequest{
|
||||
ChannelId: channel.Id,
|
||||
UserId: user.Id,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
saved, err := a.Srv().Store().ChannelJoinRequest().Save(pending)
|
||||
if err != nil {
|
||||
var conflict *store.ErrConflict
|
||||
if errors.As(err, &conflict) {
|
||||
existing, getErr := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channel.Id, user.Id)
|
||||
if getErr == nil {
|
||||
return false, existing, nil
|
||||
}
|
||||
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.duplicate.app_error", nil, "channel_id="+channel.Id, http.StatusConflict)
|
||||
}
|
||||
if appErr, ok := err.(*model.AppError); ok {
|
||||
return false, nil, appErr
|
||||
}
|
||||
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.join_request.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.broadcastChannelJoinRequestCreated(rctx, channel, saved)
|
||||
return false, saved, nil
|
||||
}
|
||||
|
||||
// WithdrawChannelJoinRequest flips a pending request the calling user owns to
|
||||
// the withdrawn state. Non-owners receive a 404 (no oracle on existence) and
|
||||
// already-terminal rows return 409.
|
||||
func (a *App) WithdrawChannelJoinRequest(rctx request.CTX, requestID, userID string) (*model.ChannelJoinRequest, *model.AppError) {
|
||||
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
if errors.As(err, &nfErr) {
|
||||
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
||||
}
|
||||
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
if current.UserId != userID {
|
||||
// Hide the row from non-owners by returning the same not-found
|
||||
// response. The reviewer flow uses different endpoints.
|
||||
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
||||
}
|
||||
|
||||
if current.Status != model.ChannelJoinRequestStatusPending {
|
||||
return nil, model.NewAppError("WithdrawChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
|
||||
}
|
||||
|
||||
current.Status = model.ChannelJoinRequestStatusWithdrawn
|
||||
current.Message = ""
|
||||
|
||||
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*model.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
channel, channelErr := a.GetChannel(rctx, updated.ChannelId)
|
||||
if channelErr != nil {
|
||||
// Channel went away mid-flight — still report the update; we just
|
||||
// can't broadcast to the admin queue.
|
||||
rctx.Logger().Warn("WithdrawChannelJoinRequest: failed to load channel for broadcast", mlog.String("channel_id", updated.ChannelId), mlog.Err(channelErr))
|
||||
return updated, nil
|
||||
}
|
||||
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetMyChannelJoinRequest returns the calling user's active pending request for
|
||||
// `channelID`, or nil if none exists. It never returns an error for a missing
|
||||
// row — that's the non-pending state and is expected.
|
||||
func (a *App) GetMyChannelJoinRequest(rctx request.CTX, userID, channelID string) (*model.ChannelJoinRequest, *model.AppError) {
|
||||
req, err := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channelID, userID)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
if errors.As(err, &nfErr) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, model.NewAppError("GetMyChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetMyChannelJoinRequests lists the calling user's join requests across all
|
||||
// channels. The "My Pending Requests" tab filters by `Status="pending"` (the
|
||||
// default when opts.Status is empty).
|
||||
func (a *App) GetMyChannelJoinRequests(rctx request.CTX, userID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
|
||||
opts = sanitizeJoinRequestListOpts(opts)
|
||||
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForUser(userID, opts)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetMyChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
|
||||
}
|
||||
|
||||
// GetChannelJoinRequests lists the join requests targeting `channelID` for the
|
||||
// admin queue UI. The visibility check is performed by the API layer via the
|
||||
// PermissionManageChannelJoinRequests permission.
|
||||
func (a *App) GetChannelJoinRequests(rctx request.CTX, channelID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
|
||||
opts = sanitizeJoinRequestListOpts(opts)
|
||||
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channelID, opts)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
|
||||
}
|
||||
|
||||
// CountPendingChannelJoinRequests returns the number of pending join requests
|
||||
// for `channelID`, used by the channel-header badge.
|
||||
func (a *App) CountPendingChannelJoinRequests(rctx request.CTX, channelID string) (int64, *model.AppError) {
|
||||
count, err := a.Srv().Store().ChannelJoinRequest().CountPending(channelID)
|
||||
if err != nil {
|
||||
return 0, model.NewAppError("CountPendingChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UpdateChannelJoinRequest applies an admin review (approve / deny) to a
|
||||
// pending request. When approving, the user is added via AddChannelMember so
|
||||
// the existing PDP gate inside addUserToChannel re-runs — admins cannot bypass
|
||||
// an active ABAC policy. The store row is only updated after a successful add
|
||||
// to keep the audit trail consistent.
|
||||
func (a *App) UpdateChannelJoinRequest(rctx request.CTX, requestID, channelID string, patch *model.ChannelJoinRequestPatch, reviewerID string) (*model.ChannelJoinRequest, *model.AppError) {
|
||||
if patch == nil {
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
switch patch.Status {
|
||||
case model.ChannelJoinRequestStatusApproved, model.ChannelJoinRequestStatusDenied:
|
||||
default:
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "status="+patch.Status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
if errors.As(err, &nfErr) {
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
||||
}
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Defense in depth: refuse cross-channel updates so a forged request id
|
||||
// can't be reviewed against a channel the admin happens to own.
|
||||
if current.ChannelId != channelID {
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
||||
}
|
||||
|
||||
if current.Status != model.ChannelJoinRequestStatusPending {
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
|
||||
}
|
||||
|
||||
channel, appErr := a.GetChannel(rctx, current.ChannelId)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if patch.Status == model.ChannelJoinRequestStatusApproved {
|
||||
if _, err := a.AddChannelMember(rctx, current.UserId, channel, ChannelMemberOpts{UserRequestorID: reviewerID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
current.Status = patch.Status
|
||||
current.ReviewedBy = reviewerID
|
||||
current.ReviewedAt = model.GetMillis()
|
||||
current.DenialReason = ""
|
||||
if patch.Status == model.ChannelJoinRequestStatusDenied && patch.DenialReason != nil {
|
||||
current.DenialReason = *patch.DenialReason
|
||||
}
|
||||
// Drop the original message from the response; it served its purpose
|
||||
// during review and keeping it would leak free-text into the audit trail.
|
||||
current.Message = ""
|
||||
|
||||
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*model.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// sanitizeJoinRequestListOpts clamps user-provided pagination + status options
|
||||
// so the store sees a normalized request.
|
||||
func sanitizeJoinRequestListOpts(opts model.GetChannelJoinRequestsOpts) model.GetChannelJoinRequestsOpts {
|
||||
if opts.Status == "" {
|
||||
opts.Status = model.ChannelJoinRequestStatusPending
|
||||
} else if !model.IsValidChannelJoinRequestStatus(opts.Status) {
|
||||
opts.Status = model.ChannelJoinRequestStatusPending
|
||||
}
|
||||
if opts.Page < 0 {
|
||||
opts.Page = 0
|
||||
}
|
||||
if opts.PerPage <= 0 {
|
||||
opts.PerPage = channelJoinRequestPaginationDefaultPerPage
|
||||
} else if opts.PerPage > channelJoinRequestPaginationMaxPerPage {
|
||||
opts.PerPage = channelJoinRequestPaginationMaxPerPage
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// evaluateChannelMembership runs the access-control PDP for `user` against the
|
||||
// `membership` action on `channel`, returning the boolean decision. Errors
|
||||
// from the PDP are returned to callers so they can choose between the
|
||||
// "channel is invisible" (visibility filter) or "channel cannot be joined"
|
||||
// (request flow) fail-secure semantics. Callers must have already verified
|
||||
// that `channel.PolicyEnforced` is true before invoking the PDP.
|
||||
func (a *App) evaluateChannelMembership(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
|
||||
acs := a.Srv().Channels().AccessControl
|
||||
if acs == nil {
|
||||
// No ABAC service → fail-secure. The channel acts as if the user did
|
||||
// not satisfy the policy.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles, channel.Id)
|
||||
if appErr != nil {
|
||||
return false, appErr
|
||||
}
|
||||
|
||||
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
|
||||
Subject: *subject,
|
||||
Resource: model.Resource{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id,
|
||||
},
|
||||
Action: "membership",
|
||||
})
|
||||
if evalErr != nil {
|
||||
return false, evalErr
|
||||
}
|
||||
return decision.Decision, nil
|
||||
}
|
||||
|
||||
// channelAdminUserIDs returns the user ids of channel members with the
|
||||
// scheme-admin role on `channelID`. Used to scope WS broadcasts of join-request
|
||||
// events to the queue audience. Failures bubble up because broadcasting to no
|
||||
// one would silently break the admin UI.
|
||||
func (a *App) channelAdminUserIDs(rctx request.CTX, channelID string) ([]string, *model.AppError) {
|
||||
const channelMembersPageSize = 200
|
||||
|
||||
admins := []string{}
|
||||
page := 0
|
||||
for {
|
||||
members, err := a.Srv().Store().Channel().GetMembers(model.ChannelMembersGetOptions{
|
||||
ChannelID: channelID,
|
||||
Offset: page * channelMembersPageSize,
|
||||
Limit: channelMembersPageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("channelAdminUserIDs", "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
for _, m := range members {
|
||||
if m.SchemeAdmin {
|
||||
admins = append(admins, m.UserId)
|
||||
}
|
||||
}
|
||||
if len(members) < channelMembersPageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return admins, nil
|
||||
}
|
||||
|
||||
// broadcastChannelJoinRequestCreated fires a channel_join_request_created event
|
||||
// scoped to the channel admin set, using the OnlyChannelAdmins broadcast hook
|
||||
// to filter out non-admin members the channel-id broadcast would otherwise
|
||||
// reach.
|
||||
func (a *App) broadcastChannelJoinRequestCreated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
|
||||
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestCreated, true /* adminsOnly */)
|
||||
}
|
||||
|
||||
// broadcastChannelJoinRequestUpdated fires a channel_join_request_updated event
|
||||
// to the channel admin set + the requesting user (so their My Pending Requests
|
||||
// list reacts in real-time).
|
||||
func (a *App) broadcastChannelJoinRequestUpdated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
|
||||
// Send a dedicated copy to the requester so an offline-but-then-reconnected
|
||||
// requester gets their own row update even when they are not a channel
|
||||
// member yet (the channel-id broadcast wouldn't reach them otherwise).
|
||||
if req.UserId != "" {
|
||||
userMessage := model.NewWebSocketEvent(model.WebsocketEventChannelJoinRequestUpdated, "", "", req.UserId, nil, "")
|
||||
userMessage.Add("request", marshalChannelJoinRequest(rctx, req))
|
||||
userMessage.Add("channel_id", channel.Id)
|
||||
a.Publish(userMessage)
|
||||
}
|
||||
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestUpdated, true /* adminsOnly */)
|
||||
}
|
||||
|
||||
func (a *App) publishChannelJoinRequestEvent(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest, event model.WebsocketEventType, adminsOnly bool) {
|
||||
message := model.NewWebSocketEvent(event, "", channel.Id, "", nil, "")
|
||||
message.Add("request", marshalChannelJoinRequest(rctx, req))
|
||||
message.Add("channel_id", channel.Id)
|
||||
|
||||
if adminsOnly {
|
||||
admins, appErr := a.channelAdminUserIDs(rctx, channel.Id)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn("Failed to compute channel admin set for join request broadcast",
|
||||
mlog.String("channel_id", channel.Id),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
useOnlyChannelAdminsHook(message, admins)
|
||||
}
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
// marshalChannelJoinRequest returns the request as a JSON string for the WS
|
||||
// payload. JSON encoding errors are logged and the payload is delivered as an
|
||||
// empty string so the event still arrives (clients can tolerate a missing
|
||||
// request body and refetch).
|
||||
func marshalChannelJoinRequest(rctx request.CTX, req *model.ChannelJoinRequest) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
buf, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to marshal ChannelJoinRequest for WS broadcast",
|
||||
mlog.String("request_id", req.Id),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return ""
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
379
server/channels/app/channel_join_request_test.go
Normal file
379
server/channels/app/channel_join_request_test.go
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// withDiscoverableChannelsFlag toggles the FeatureFlag for the duration of a
|
||||
// test and restores it on cleanup. Feature flags are read-only by default in
|
||||
// the test config store; flipping SetReadOnlyFF lets the UpdateConfig call
|
||||
// land. We deliberately do NOT restore SetReadOnlyFF(true) afterward — the
|
||||
// underlying store is per-test and disposed on cleanup.
|
||||
func withDiscoverableChannelsFlag(t *testing.T, th *TestHelper, on bool) {
|
||||
t.Helper()
|
||||
th.ConfigStore.SetReadOnlyFF(false)
|
||||
previous := th.App.Config().FeatureFlags.DiscoverableChannels
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = on })
|
||||
t.Cleanup(func() {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = previous })
|
||||
})
|
||||
}
|
||||
|
||||
// markDiscoverable flips the channel's discoverable flag in the store via
|
||||
// PatchChannel so the model invariants run alongside the test scenario.
|
||||
func markDiscoverable(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
|
||||
t.Helper()
|
||||
on := true
|
||||
patched, err := th.App.PatchChannel(th.Context, channel, &model.ChannelPatch{Discoverable: &on}, th.BasicUser.Id)
|
||||
require.Nil(t, err)
|
||||
require.True(t, patched.Discoverable)
|
||||
return patched
|
||||
}
|
||||
|
||||
func TestRequestJoinChannel_RejectsNonDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
|
||||
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
|
||||
assert.Equal(t, "api.channel.discoverable_join_request.not_discoverable.app_error", appErr.Id)
|
||||
assert.False(t, joined)
|
||||
assert.Nil(t, req)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannel_RejectsExistingMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
channel = markDiscoverable(t, th, channel)
|
||||
|
||||
// BasicUser is the channel creator → already a member.
|
||||
_, _, appErr := th.App.RequestJoinChannel(th.Context, th.BasicUser.Id, channel.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
assert.Equal(t, "api.channel.discoverable_join_request.already_member.app_error", appErr.Id)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannel_PendingHappyPath(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
channel = markDiscoverable(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
|
||||
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "let me in")
|
||||
require.Nil(t, appErr)
|
||||
assert.False(t, joined, "should not auto-join when no policy is enforced")
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
|
||||
assert.Equal(t, channel.Id, req.ChannelId)
|
||||
assert.Equal(t, other.Id, req.UserId)
|
||||
assert.Equal(t, "let me in", req.Message)
|
||||
|
||||
// Submitting again returns the existing pending row (idempotent on
|
||||
// partial-unique conflict).
|
||||
joined, req2, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "again")
|
||||
require.Nil(t, appErr)
|
||||
assert.False(t, joined)
|
||||
require.NotNil(t, req2)
|
||||
assert.Equal(t, req.Id, req2.Id)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannel_RejectsGuest(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
channel = markDiscoverable(t, th, channel)
|
||||
|
||||
guest := th.CreateGuest(t)
|
||||
th.LinkUserToTeam(t, guest, th.BasicTeam)
|
||||
|
||||
_, _, appErr := th.App.RequestJoinChannel(th.Context, guest.Id, channel.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
|
||||
assert.Equal(t, "api.channel.discoverable_join_request.guest.app_error", appErr.Id)
|
||||
}
|
||||
|
||||
func TestUpdateChannelJoinRequest_ApproveAddsMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
channel = markDiscoverable(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
|
||||
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, req)
|
||||
|
||||
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
|
||||
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusApproved, updated.Status)
|
||||
assert.Equal(t, th.BasicUser.Id, updated.ReviewedBy)
|
||||
assert.NotZero(t, updated.ReviewedAt)
|
||||
assert.Empty(t, updated.Message, "message should be redacted from the response after review")
|
||||
|
||||
member, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
|
||||
require.Nil(t, mErr)
|
||||
require.NotNil(t, member)
|
||||
assert.Equal(t, other.Id, member.UserId)
|
||||
}
|
||||
|
||||
func TestUpdateChannelJoinRequest_DenyKeepsReason(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
channel = markDiscoverable(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, req)
|
||||
|
||||
reason := "team-internal channel"
|
||||
patch := &model.ChannelJoinRequestPatch{
|
||||
Status: model.ChannelJoinRequestStatusDenied,
|
||||
DenialReason: &reason,
|
||||
}
|
||||
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusDenied, updated.Status)
|
||||
assert.Equal(t, reason, updated.DenialReason)
|
||||
|
||||
// Member must NOT have been added.
|
||||
_, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
|
||||
require.NotNil(t, mErr)
|
||||
assert.Equal(t, MissingChannelMemberError, mErr.Id)
|
||||
}
|
||||
|
||||
func TestUpdateChannelJoinRequest_RejectsCrossChannel(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, req)
|
||||
|
||||
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
|
||||
_, appErr = th.App.UpdateChannelJoinRequest(th.Context, req.Id, channelB.Id, patch, th.BasicUser.Id)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestWithdrawChannelJoinRequest_OwnerOnly(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, req)
|
||||
|
||||
stranger := th.CreateUser(t)
|
||||
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, stranger.Id)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
||||
|
||||
updated, appErr := th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusWithdrawn, updated.Status)
|
||||
|
||||
// A second withdrawal is rejected with 409.
|
||||
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetMyChannelJoinRequests(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
_, _, appErr = th.App.RequestJoinChannel(th.Context, other.Id, channelB.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
list, appErr := th.App.GetMyChannelJoinRequests(th.Context, other.Id, model.GetChannelJoinRequestsOpts{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, list)
|
||||
assert.EqualValues(t, 2, list.TotalCount)
|
||||
assert.Len(t, list.Requests, 2)
|
||||
}
|
||||
|
||||
func TestCountPendingChannelJoinRequests(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
count, appErr := th.App.CountPendingChannelJoinRequests(th.Context, channel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.EqualValues(t, 1, count)
|
||||
}
|
||||
|
||||
func TestUpdateChannelPrivacy_CancelsPendingRequestsOnConvertToPublic(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, req)
|
||||
|
||||
channel.Type = model.ChannelTypeOpen
|
||||
converted, appErr := th.App.UpdateChannelPrivacy(th.Context, channel, th.BasicUser)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Discoverable must be reset on convert-to-public — the model invariant
|
||||
// (Channel.IsValid) rejects (type=O, discoverable=true), so leaving it
|
||||
// true would also break the next channel save.
|
||||
assert.False(t, converted.Discoverable, "Discoverable must be reset to false after convert-to-public")
|
||||
persisted, getErr := th.App.GetChannel(th.Context, channel.Id)
|
||||
require.Nil(t, getErr)
|
||||
assert.False(t, persisted.Discoverable, "Discoverable must be persisted as false after convert-to-public")
|
||||
|
||||
// The cancellation side-effect is dispatched on a goroutine; poll for
|
||||
// the withdrawn state instead of sleeping.
|
||||
require.Eventually(t, func() bool {
|
||||
row, err := th.App.Srv().Store().ChannelJoinRequest().Get(req.Id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return row.Status == model.ChannelJoinRequestStatusWithdrawn
|
||||
}, 2*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestIsDiscoverableSelfAddBlocked(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
|
||||
other := th.CreateUser(t)
|
||||
assert.True(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id), "self-add to discoverable + no-policy private must be blocked")
|
||||
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, th.BasicUser.Id, other.Id), "admin invite must not be blocked")
|
||||
|
||||
// Toggle off the flag → guard is inert.
|
||||
withDiscoverableChannelsFlag(t, th, false)
|
||||
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id))
|
||||
}
|
||||
|
||||
func TestFilterDiscoverableChannelsByPolicy_FlagOff(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
// Flag off → filter is a no-op even when channels look discoverable.
|
||||
|
||||
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
channel.PolicyEnforced = true
|
||||
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, out, 1)
|
||||
}
|
||||
|
||||
func TestFilterDiscoverableChannelsByPolicy_NoPolicyPasses(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, out, 1, "no-policy discoverable channels are visible without ABAC evaluation")
|
||||
}
|
||||
|
||||
func TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
// PolicyEnforced + Discoverable + no AccessControl service wired ⇒ hidden.
|
||||
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
channel.PolicyEnforced = true
|
||||
|
||||
require.Nil(t, th.App.Srv().Channels().AccessControl, "test fixture must not have ABAC wired")
|
||||
|
||||
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Len(t, out, 0, "fail-secure must hide policy-enforced channels when ABAC is unavailable")
|
||||
}
|
||||
|
||||
func TestFilterDiscoverableChannelsByPolicy_GuestHidden(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
withDiscoverableChannelsFlag(t, th, true)
|
||||
|
||||
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
|
||||
channel.PolicyEnforced = true
|
||||
|
||||
guest := th.CreateGuest(t)
|
||||
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, guest.Id)
|
||||
require.Nil(t, appErr)
|
||||
assert.Empty(t, out, "guests must never see discoverable + policy-enforced channels")
|
||||
}
|
||||
|
||||
// markDiscoverableInMemory is a no-DB helper for visibility filter tests that
|
||||
// don't care about persistence — they only exercise the in-memory list filter.
|
||||
func markDiscoverableInMemory(t *testing.T, channel *model.Channel) *model.Channel {
|
||||
t.Helper()
|
||||
channel.Discoverable = true
|
||||
return channel
|
||||
}
|
||||
|
|
@ -3641,6 +3641,12 @@ func TestCheckIfChannelIsRestrictedDM(t *testing.T) {
|
|||
func TestUpdateChannel(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("returns 404 for non-existent channel id", func(t *testing.T) {
|
||||
_, appErr := th.App.UpdateChannel(th.Context, &model.Channel{Id: model.NewId()})
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("should be able to update banner info", func(t *testing.T) {
|
||||
channel := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -50,6 +51,13 @@ type Channels struct {
|
|||
pluginConfigListenerID string
|
||||
pluginClusterLeaderListenerID string
|
||||
|
||||
// guardCache caches ChannelGuards rows by ChannelId -> []*store.ChannelGuard.
|
||||
guardCache atomic.Pointer[sync.Map]
|
||||
|
||||
// guardCacheRetryInFlight collapses concurrent reload-failure retries to a single goroutine.
|
||||
// See scheduleGuardCacheReloadRetry.
|
||||
guardCacheRetryInFlight atomic.Bool
|
||||
|
||||
imageProxy *imageproxy.ImageProxy
|
||||
|
||||
agentsBridge AgentsBridge
|
||||
|
|
@ -107,6 +115,7 @@ func NewChannels(s *Server) (*Channels, error) {
|
|||
cfgSvc: s.Platform(),
|
||||
interruptQuitChan: make(chan struct{}),
|
||||
}
|
||||
ch.guardCache.Store(&sync.Map{})
|
||||
|
||||
if s.agentsBridgeOverride != nil {
|
||||
ch.agentsBridge = s.agentsBridgeOverride
|
||||
|
|
@ -231,6 +240,15 @@ func NewChannels(s *Server) (*Channels, error) {
|
|||
pluginsRoute.HandleFunc("/public/{public_file:.*}", ch.ServePluginPublicRequest)
|
||||
pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest)
|
||||
|
||||
if err := ch.reloadGuardCache(request.EmptyContext(s.Log()), s.Store()); err != nil {
|
||||
s.Log().Warn(
|
||||
"Failed to load channel guard cache at startup; retry scheduled",
|
||||
mlog.Bool("clustered", s.platform.Cluster() != nil),
|
||||
mlog.Err(err),
|
||||
)
|
||||
ch.scheduleGuardCacheReloadRetry()
|
||||
}
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +343,14 @@ func (ch *Channels) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks, manifes
|
|||
}
|
||||
}
|
||||
|
||||
// RunMultiHookExcluding is like RunMultiHook but skips plugins whose IDs appear in excludePluginIDs.
|
||||
// Fail-open semantics are preserved.
|
||||
func (ch *Channels) RunMultiHookExcluding(excludePluginIDs []string, hookRunnerFunc func(plugin.Hooks, *model.Manifest) bool, hookId int) {
|
||||
if env := ch.GetPluginsEnvironment(); env != nil {
|
||||
env.RunMultiPluginHookExcluding(excludePluginIDs, hookRunnerFunc, hookId)
|
||||
}
|
||||
}
|
||||
|
||||
// RunMultiHookWithRPCErr dispatches a hook closure across active plugins, surfacing RPC transport
|
||||
// errors. Returns nil in two cases that callers must distinguish themselves: (a) the plugin
|
||||
// environment is unavailable (plugins disabled, or not yet initialized), so the closure was never
|
||||
|
|
@ -351,3 +377,13 @@ func (ch *Channels) HooksForPlugin(id string) (plugin.Hooks, error) {
|
|||
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
// HooksForPluginWithRPCErr returns the full *WithRPCErr hook surface for the named plugin.
|
||||
// Returns an error if the plugin environment is unavailable, the plugin is not found, or not active.
|
||||
func (ch *Channels) HooksForPluginWithRPCErr(id string) (plugin.HooksWithRPCErr, error) {
|
||||
env := ch.GetPluginsEnvironment()
|
||||
if env == nil {
|
||||
return nil, errors.New("plugin environment not available")
|
||||
}
|
||||
return env.HooksForPluginWithRPCErr(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ func (s *Server) registerClusterHandlers() {
|
|||
s.platform.RegisterClusterMessageHandler(model.ClusterEventInstallPlugin, s.clusterInstallPluginHandler)
|
||||
s.platform.RegisterClusterMessageHandler(model.ClusterEventRemovePlugin, s.clusterRemovePluginHandler)
|
||||
s.platform.RegisterClusterMessageHandler(model.ClusterEventPluginEvent, s.clusterPluginEventHandler)
|
||||
s.platform.RegisterClusterMessageHandler(clusterEventInvalidateChannelGuardCache, s.Channels().clusterInvalidateGuardCacheHandler)
|
||||
|
||||
s.platform.RegisterClusterHandlers()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
|
|
@ -72,9 +73,27 @@ func (a *App) UpsertDraft(rctx request.CTX, draft *model.Draft, connectionID str
|
|||
if deleteErr != nil {
|
||||
return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
|
||||
}
|
||||
rctx.Logger().Debug("Draft deleted via empty-message upsert", mlog.String("user_id", draft.UserId), mlog.String("channel_id", draft.ChannelId), mlog.String("root_id", draft.RootId))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rejectionReason string
|
||||
pluginContext := pluginContext(rctx)
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacement, reason := hooks.DraftWillBeUpserted(pluginContext, draft)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
draft = replacement
|
||||
}
|
||||
return true
|
||||
}, plugin.DraftWillBeUpsertedID)
|
||||
if rejectionReason != "" {
|
||||
return nil, model.NewAppError("UpsertDraft", "app.draft.upsert.rejected_by_plugin", map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
dt, nErr := a.Srv().Store().Draft().Upsert(draft)
|
||||
if nErr != nil {
|
||||
return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
||||
|
|
|
|||
|
|
@ -7,17 +7,12 @@ package mocks
|
|||
import (
|
||||
io "io"
|
||||
|
||||
i18n "github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
|
||||
i18n "github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
|
||||
store "github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
|
||||
templates "github.com/mattermost/mattermost/server/v8/platform/shared/templates"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// ServiceInterface is an autogenerated mock type for the ServiceInterface type
|
||||
|
|
|
|||
|
|
@ -1528,7 +1528,14 @@ func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model.
|
|||
}
|
||||
}
|
||||
|
||||
abacSubject := a.buildFileDownloadSubject(rctx, userID)
|
||||
abacSubject, abacSubjectErr := a.buildFileDownloadSubject(rctx, userID)
|
||||
if abacSubjectErr != nil {
|
||||
// Fail closed: a transient subject-build failure must not silently
|
||||
// allow files through. Surface the error to the caller — the
|
||||
// search returns 5xx instead of leaking files past a policy that
|
||||
// would have denied them.
|
||||
return false, abacSubjectErr
|
||||
}
|
||||
|
||||
channelPermission := make(map[string]bool)
|
||||
filteredFiles := make(map[string]*model.FileInfo)
|
||||
|
|
@ -1567,18 +1574,28 @@ func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model.
|
|||
return allFilesHaveMembership, nil
|
||||
}
|
||||
|
||||
// buildFileDownloadSubject returns a fully populated ABAC Subject for the user
|
||||
// when ABAC is active, or nil when ABAC is not configured / not enabled.
|
||||
func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) *model.Subject {
|
||||
// buildFileDownloadSubject returns a fully populated ABAC Subject for the
|
||||
// user when ABAC is active. The error return distinguishes the two
|
||||
// failure modes that used to share `nil`:
|
||||
// - (nil, nil): ABAC isn't configured/enabled; the file download path
|
||||
// is allowed without further checks.
|
||||
// - (subject, nil): ABAC is active; caller should evaluate.
|
||||
// - (nil, err): a transient lookup failure (GetUser /
|
||||
// BuildAccessControlSubject). The caller MUST treat this as a
|
||||
// denial; the previous behaviour returned `nil` here too which
|
||||
// `hasFileDownloadPermission` interpreted as "ABAC disabled,
|
||||
// allow" — i.e. a transient DB blip silently bypassed
|
||||
// download_file_attachment policies.
|
||||
func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) (*model.Subject, *model.AppError) {
|
||||
acs := a.Srv().Channels().AccessControl
|
||||
if acs == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if !a.Config().FeatureFlags.PermissionPolicies {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user, err := a.GetUser(userID)
|
||||
|
|
@ -1587,18 +1604,52 @@ func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) *model.S
|
|||
mlog.String("user_id", userID),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles)
|
||||
// channelID is intentionally empty here: the subject is reused across many
|
||||
// channels in the file-search loop. hasFileDownloadPermission attaches the
|
||||
// channel-scoped role per-evaluation via attachChannelScopedRole.
|
||||
subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles, "")
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn("Failed to build ABAC subject for file search filtering",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return nil
|
||||
return nil, appErr
|
||||
}
|
||||
return subject
|
||||
return subject, nil
|
||||
}
|
||||
|
||||
// attachChannelScopedRole returns a copy of the subject with the channel-scoped
|
||||
// ScopedRole entry replaced for the given channelID. It's used in hot paths
|
||||
// where the same per-user Subject is reused across many channels — Subject
|
||||
// is taken by value and SetScopedRole always allocates a fresh ScopedRoles
|
||||
// backing array, so the caller's cached Subject is not mutated.
|
||||
//
|
||||
// Errors from GetSubjectChannelRole (e.g. transient channel-member store
|
||||
// failures) are propagated as an AppError. Callers MUST treat the error as
|
||||
// a denial — a transient DB blip is distinguishable from "no channel role"
|
||||
// (legitimate non-member), and conflating the two could let infra hiccups
|
||||
// silently degrade ABAC enforcement even with the downstream
|
||||
// PolicyGovernsAction fail-secure in place. Defense in depth: both layers
|
||||
// should fail closed independently. Callers should NOT stamp an empty
|
||||
// channel role in the error path — Subject is returned unchanged so the
|
||||
// caller can use it for logging without leaking a partially populated
|
||||
// scope onto downstream evaluators.
|
||||
func (a *App) attachChannelScopedRole(rctx request.CTX, subject model.Subject, userID, channelID string) (model.Subject, *model.AppError) {
|
||||
channelRole, appErr := a.GetSubjectChannelRole(rctx, userID, channelID)
|
||||
if appErr != nil {
|
||||
rctx.Logger().Warn(
|
||||
"Failed to resolve channel-scoped role for ABAC subject; treating as denial (transient lookup failure must not silently bypass ABAC)",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Err(appErr),
|
||||
)
|
||||
return subject, appErr
|
||||
}
|
||||
subject.SetScopedRole(model.AccessControlSubjectScopeChannel, channelRole)
|
||||
return subject, nil
|
||||
}
|
||||
|
||||
// hasFileDownloadPermission evaluates the ABAC download_file_attachment policy
|
||||
|
|
@ -1614,8 +1665,16 @@ func (a *App) hasFileDownloadPermission(rctx request.CTX, userID string, channel
|
|||
return true
|
||||
}
|
||||
|
||||
subjectForChannel, attachErr := a.attachChannelScopedRole(rctx, *subject, userID, channelID)
|
||||
if attachErr != nil {
|
||||
// Channel-role lookup failed (e.g. transient ChannelMember store
|
||||
// error). Fail-secure: refuse access rather than evaluating against
|
||||
// a subject missing its channel scope. The warn log was already
|
||||
// emitted by attachChannelScopedRole.
|
||||
return false
|
||||
}
|
||||
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
|
||||
Subject: *subject,
|
||||
Subject: subjectForChannel,
|
||||
Resource: model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: channelID},
|
||||
Action: model.AccessControlPolicyActionDownloadFileAttachment,
|
||||
})
|
||||
|
|
|
|||
411
server/channels/app/guarded_hooks.go
Normal file
411
server/channels/app/guarded_hooks.go
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Channel-guard dispatch helpers.
|
||||
//
|
||||
// Each runGuarded<Hook> helper implements two-phase plugin dispatch: Phase A fans out to non-guard
|
||||
// plugins via RunMultiHookExcluding (fail-open, preserving RunMultiHook semantics — when guards is
|
||||
// empty the exclude list is empty and the iteration is identical to plain RunMultiHook); Phase B
|
||||
// calls each guard claimant in PluginId-sorted order via the *WithRPCErr companion, and fail-closed
|
||||
// on transport errors. Phase B's for-range is a no-op when there are no guards, so unguarded
|
||||
// channels traverse the same single linear flow with zero extra work beyond the Phase A dispatch.
|
||||
//
|
||||
// Allow-by-default for non-implementing claimants: a plugin may register a channel guard without
|
||||
// implementing every guarded hook. When Phase B reaches such a claimant, the *WithRPCErr
|
||||
// companion's g.implemented[<HookID>] gate skips the RPC call entirely and returns zero values with
|
||||
// a nil error. The helper's three guard branches all skip in that case, so the claimant contributes
|
||||
// nothing, basically: "this plugin had no opinion on this hook." Iteration continues to the next
|
||||
// claimant.
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// resolveGuards returns the (sorted-by-PluginId) guard slice for channelID along with a
|
||||
// non-nil rejectErr when the request must fail-close (plugin system disabled, or a specific
|
||||
// claimant is inactive). The helper picks the right operator-facing log message internally.
|
||||
// (nil, nil) means the channel is unguarded — Phase A still runs (with no exclusions) and
|
||||
// Phase B's loop becomes a no-op. (guards, nil) means proceed with two-phase dispatch.
|
||||
func (a *App) resolveGuards(rctx request.CTX, channelID, callerName string) (guards []*store.ChannelGuard, rejectErr *model.AppError) {
|
||||
ch := a.Channels()
|
||||
raw := ch.getGuardsForChannel(channelID)
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sorted := append([]*store.ChannelGuard(nil), raw...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].PluginId < sorted[j].PluginId })
|
||||
env := ch.GetPluginsEnvironment()
|
||||
if env == nil {
|
||||
// Plugin system disabled in config or not yet initialized, but guards exist for this
|
||||
// channel. Operator action: flip PluginSettings.Enable on, or remove the guards.
|
||||
return sorted, logAndErrPluginsDisabled(rctx, channelID, callerName)
|
||||
}
|
||||
var inactive []string
|
||||
for _, g := range sorted {
|
||||
if !env.IsActive(g.PluginId) {
|
||||
inactive = append(inactive, g.PluginId)
|
||||
}
|
||||
}
|
||||
if len(inactive) > 0 {
|
||||
return sorted, logAndErrPluginInactive(rctx, channelID, inactive, callerName)
|
||||
}
|
||||
return sorted, nil
|
||||
}
|
||||
|
||||
// logAndErrPluginInactive emits an operator-facing Error log identifying the specific guard
|
||||
// plugins that are currently inactive, then returns a generic 503 AppError. A guard plugin
|
||||
// being down is an operational failure: the request must be rejected, but internal plugin IDs
|
||||
// do not belong in the user-facing response. Operators read the log to diagnose which plugin
|
||||
// to recover.
|
||||
func logAndErrPluginInactive(rctx request.CTX, channelID string, pluginIDs []string, callerName string) *model.AppError {
|
||||
rctx.Logger().Error("Channel guard rejected operation: claiming plugin is not active",
|
||||
mlog.String("error_id", "guard_plugin_inactive"),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Array("plugin_ids", pluginIDs),
|
||||
mlog.String("caller", callerName),
|
||||
)
|
||||
return model.NewAppError(callerName, "app.plugin.inactive_guard.app_error", nil, "", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// logAndErrPluginsDisabled emits an operator-facing Error log when the plugin system is off
|
||||
// (PluginSettings.Enable == false or not yet initialized) but guards are still cached for the
|
||||
// channel. Distinct from logAndErrPluginInactive: the cause is the global plugin switch, not
|
||||
// a specific plugin failure. Returns the same generic 503 to the user.
|
||||
func logAndErrPluginsDisabled(rctx request.CTX, channelID, callerName string) *model.AppError {
|
||||
rctx.Logger().Error("Channel guard rejected operation: plugin system is disabled but guards exist for this channel",
|
||||
mlog.String("error_id", "plugins_disabled_with_guards"),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.String("caller", callerName),
|
||||
)
|
||||
return model.NewAppError(callerName, "app.plugin.inactive_guard.app_error", nil, "", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func appErrHookFailed(pluginID, callerName string, err error) *model.AppError {
|
||||
appErr := model.NewAppError(callerName, "app.plugin.guard_hook_failed.app_error",
|
||||
map[string]any{"PluginID": pluginID}, "", http.StatusServiceUnavailable)
|
||||
if err != nil {
|
||||
return appErr.Wrap(err)
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
func pluginIDsOf(guards []*store.ChannelGuard) []string {
|
||||
ids := make([]string, len(guards))
|
||||
for i, g := range guards {
|
||||
ids[i] = g.PluginId
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// runGuardedMessageWillBePosted dispatches MessageWillBePosted. Returns the (possibly
|
||||
// replaced) post, or an AppError on rejection or RPC failure.
|
||||
func (a *App) runGuardedMessageWillBePosted(rctx request.CTX, post *model.Post) (*model.Post, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, post.ChannelId, "createPost")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
var metadata *model.PostMetadata
|
||||
if post.Metadata != nil {
|
||||
metadata = post.Metadata.Copy()
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacementPost, rejectionReason := hooks.MessageWillBePosted(pCtx, post.ForPlugin())
|
||||
if rejectionReason != "" {
|
||||
id := "Post rejected by plugin. " + rejectionReason
|
||||
if rejectionReason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if replacementPost != nil {
|
||||
post = replacementPost
|
||||
if post.Metadata != nil && metadata != nil {
|
||||
post.Metadata.Priority = metadata.Priority
|
||||
} else {
|
||||
post.Metadata = metadata
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, plugin.MessageWillBePostedID)
|
||||
if rejectionError != nil {
|
||||
return nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, post.ChannelId, []string{g.PluginId}, "CreatePost")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.MessageWillBePostedWithRPCErr(pCtx, post.ForPlugin())
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "CreatePost", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
id := "Post rejected by plugin. " + reason
|
||||
if reason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
return nil, model.NewAppError("createPost", id, nil, "", http.StatusBadRequest)
|
||||
}
|
||||
if replacement != nil {
|
||||
post = replacement
|
||||
if post.Metadata != nil && metadata != nil {
|
||||
post.Metadata.Priority = metadata.Priority
|
||||
} else {
|
||||
post.Metadata = metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// runGuardedMessageWillBeUpdated dispatches MessageWillBeUpdated. In the non-guarded
|
||||
// hook variant, either newPost == nil OR rejectionReason != "" signals rejection.
|
||||
func (a *App) runGuardedMessageWillBeUpdated(rctx request.CTX, newPost, oldPost *model.Post) (*model.Post, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, oldPost.ChannelId, "UpdatePost")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
// buildUpdateRejectionErr mirrors the legacy error shape at post.go UpdatePost.
|
||||
buildUpdateRejectionErr := func(reason string) *model.AppError {
|
||||
id := "Post rejected by plugin. " + reason
|
||||
if reason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
return model.NewAppError("UpdatePost", id, nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionReason string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
newPost, rejectionReason = hooks.MessageWillBeUpdated(pCtx, newPost.ForPlugin(), oldPost.ForPlugin())
|
||||
return newPost != nil
|
||||
}, plugin.MessageWillBeUpdatedID)
|
||||
if newPost == nil {
|
||||
return nil, buildUpdateRejectionErr(rejectionReason)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, oldPost.ChannelId, []string{g.PluginId}, "UpdatePost")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.MessageWillBeUpdatedWithRPCErr(pCtx, newPost.ForPlugin(), oldPost.ForPlugin())
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "UpdatePost", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildUpdateRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
newPost = replacement
|
||||
}
|
||||
}
|
||||
|
||||
return newPost, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelMemberWillBeAdded dispatches ChannelMemberWillBeAdded. Returns the (possibly
|
||||
// replaced) member, or an AppError on rejection or RPC failure.
|
||||
func (a *App) runGuardedChannelMemberWillBeAdded(rctx request.CTX, channelID string, member *model.ChannelMember) (*model.ChannelMember, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, channelID, "AddUserToChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
buildMemberRejectionErr := func(reason string) *model.AppError {
|
||||
return model.NewAppError("AddUserToChannel", "app.channel.add_user.to.channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
updatedMember, reason := hooks.ChannelMemberWillBeAdded(pCtx, member)
|
||||
if reason != "" {
|
||||
rejectionError = buildMemberRejectionErr(reason)
|
||||
return false
|
||||
}
|
||||
if updatedMember != nil {
|
||||
member = updatedMember
|
||||
}
|
||||
return true
|
||||
}, plugin.ChannelMemberWillBeAddedID)
|
||||
if rejectionError != nil {
|
||||
return nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, channelID, []string{g.PluginId}, "addUserToChannel")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.ChannelMemberWillBeAddedWithRPCErr(pCtx, member)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "addUserToChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildMemberRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
member = replacement
|
||||
}
|
||||
}
|
||||
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelWillBeUpdated dispatches ChannelWillBeUpdated. Guard plugins may not mutate
|
||||
// Channel.Type — type changes must go through dedicated paths (e.g., UpdateChannelPrivacy). The
|
||||
// check applies only to guarded channels; unguarded callers retain RunMultiHook's permissive behavior.
|
||||
func (a *App) runGuardedChannelWillBeUpdated(rctx request.CTX, newChannel, oldChannel *model.Channel) (*model.Channel, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, newChannel.Id, "UpdateChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
buildUpdateRejectionErr := func(reason string) *model.AppError {
|
||||
return model.NewAppError("UpdateChannel", "app.channel.update_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
buildTypeMutationErr := func(offendingPluginID string) *model.AppError {
|
||||
return model.NewAppError("UpdateChannel", "app.channel.update_channel.plugin_type_mutation.app_error",
|
||||
map[string]any{"PluginID": offendingPluginID}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
// Track the last replacing plugin ID for type-mutation attribution (used only when guarded).
|
||||
var rejectionReason string
|
||||
var lastReplacingPluginID string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, manifest *model.Manifest) bool {
|
||||
replacement, reason := hooks.ChannelWillBeUpdated(pCtx, newChannel, oldChannel)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
newChannel = replacement
|
||||
lastReplacingPluginID = manifest.Id
|
||||
}
|
||||
return true
|
||||
}, plugin.ChannelWillBeUpdatedID)
|
||||
if rejectionReason != "" {
|
||||
return nil, buildUpdateRejectionErr(rejectionReason)
|
||||
}
|
||||
// Type-mutation check applies only to guarded channels; unguarded callers retain
|
||||
// RunMultiHook's permissive semantics.
|
||||
if len(guards) > 0 && lastReplacingPluginID != "" && newChannel.Type != oldChannel.Type {
|
||||
return nil, buildTypeMutationErr(lastReplacingPluginID)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, newChannel.Id, []string{g.PluginId}, "UpdateChannel")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.ChannelWillBeUpdatedWithRPCErr(pCtx, newChannel, oldChannel)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "UpdateChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildUpdateRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
newChannel = replacement
|
||||
// Check immediately after each Phase B replacement.
|
||||
if newChannel.Type != oldChannel.Type {
|
||||
return nil, buildTypeMutationErr(g.PluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newChannel, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelWillBeRestored dispatches ChannelWillBeRestored. Reject-only — no replacement.
|
||||
func (a *App) runGuardedChannelWillBeRestored(rctx request.CTX, channel *model.Channel) *model.AppError {
|
||||
guards, rejectErr := a.resolveGuards(rctx, channel.Id, "RestoreChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return rejectErr
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionReason string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
rejectionReason = hooks.ChannelWillBeRestored(pCtx, channel)
|
||||
return rejectionReason == ""
|
||||
}, plugin.ChannelWillBeRestoredID)
|
||||
if rejectionReason != "" {
|
||||
return model.NewAppError("RestoreChannel", "app.channel.restore_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return logAndErrPluginInactive(rctx, channel.Id, []string{g.PluginId}, "RestoreChannel")
|
||||
}
|
||||
reason, rpcErr := hooks.ChannelWillBeRestoredWithRPCErr(pCtx, channel)
|
||||
if rpcErr != nil {
|
||||
return appErrHookFailed(g.PluginId, "RestoreChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return model.NewAppError("RestoreChannel", "app.channel.restore_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
224
server/channels/app/guarded_hooks_test.go
Normal file
224
server/channels/app/guarded_hooks_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// seedGuardCache directly populates the Channels guard cache for unit tests that
|
||||
// need guards without going through the full DB round-trip.
|
||||
func seedGuardCache(th *TestHelper, channelID string, guards []*store.ChannelGuard) {
|
||||
m := &sync.Map{}
|
||||
if len(guards) > 0 {
|
||||
m.Store(channelID, guards)
|
||||
}
|
||||
th.App.Channels().guardCache.Store(m)
|
||||
}
|
||||
|
||||
func TestResolveGuards(t *testing.T) {
|
||||
t.Run("no guards returns nil nil", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Empty cache — channel has no guard rows.
|
||||
seedGuardCache(th, th.BasicChannel.Id, nil)
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
|
||||
require.Nil(t, rejectErr)
|
||||
require.Nil(t, guards)
|
||||
})
|
||||
|
||||
t.Run("cache uninitialized returns nil nil", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Store a nil *sync.Map — models the brief window before the first reload.
|
||||
th.App.Channels().guardCache.Store((*sync.Map)(nil))
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
|
||||
require.Nil(t, rejectErr)
|
||||
require.Nil(t, guards)
|
||||
})
|
||||
|
||||
t.Run("guards are sorted by PluginId", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Insert guards in reverse alphabetical order; resolveGuards must return them sorted.
|
||||
unsorted := []*store.ChannelGuard{
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: "zzz.plugin"},
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: "aaa.plugin"},
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: "mmm.plugin"},
|
||||
}
|
||||
seedGuardCache(th, th.BasicChannel.Id, unsorted)
|
||||
|
||||
// All plugin IDs are unknown to the environment → IsActive returns false for each.
|
||||
// Disable plugins so resolveGuards hits the env==nil branch instead.
|
||||
// We only want to test sort order, so use a trick: temporarily disable plugins to
|
||||
// get through the env==nil fast-path and confirm the sorted slice is built before
|
||||
// the env check. Actually env==nil returns early with the sorted slice — that's
|
||||
// correct behaviour to assert sort order.
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
|
||||
// env==nil → reject is non-nil, but guards slice must still be sorted.
|
||||
require.NotNil(t, rejectErr, "plugins disabled + guards exist → expect reject error")
|
||||
require.Len(t, guards, 3)
|
||||
assert.Equal(t, "aaa.plugin", guards[0].PluginId)
|
||||
assert.Equal(t, "mmm.plugin", guards[1].PluginId)
|
||||
assert.Equal(t, "zzz.plugin", guards[2].PluginId)
|
||||
})
|
||||
|
||||
t.Run("single inactive plugin returns reject error", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Seed one guard with a plugin ID that is not active in the environment.
|
||||
fakePlugin := "com.example.inactive-single"
|
||||
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: fakePlugin},
|
||||
})
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerA")
|
||||
require.NotNil(t, rejectErr)
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
|
||||
// Guards slice is returned even on reject so callers can log the full context.
|
||||
require.Len(t, guards, 1)
|
||||
assert.Equal(t, fakePlugin, guards[0].PluginId)
|
||||
})
|
||||
|
||||
t.Run("multiple inactive plugins returns reject error", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Two inactive guards — exercises the mlog.Array path in logAndErrPluginInactive.
|
||||
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: "com.example.inactive-a"},
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: "com.example.inactive-b"},
|
||||
})
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerB")
|
||||
require.NotNil(t, rejectErr)
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
|
||||
require.Len(t, guards, 2)
|
||||
})
|
||||
|
||||
t.Run("env nil branch returns reject error", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
fakePlugin := "com.example.env-nil"
|
||||
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
|
||||
{ChannelId: th.BasicChannel.Id, PluginId: fakePlugin},
|
||||
})
|
||||
|
||||
// Disable the plugin system so GetPluginsEnvironment returns nil.
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
|
||||
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerC")
|
||||
require.NotNil(t, rejectErr)
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
|
||||
// Guards slice is still populated with the sorted rows.
|
||||
require.Len(t, guards, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPluginIDsOf(t *testing.T) {
|
||||
t.Run("nil input returns empty slice", func(t *testing.T) {
|
||||
ids := pluginIDsOf(nil)
|
||||
assert.Empty(t, ids)
|
||||
})
|
||||
|
||||
t.Run("empty input returns empty slice", func(t *testing.T) {
|
||||
ids := pluginIDsOf([]*store.ChannelGuard{})
|
||||
assert.Empty(t, ids)
|
||||
})
|
||||
|
||||
t.Run("multiple guards returns IDs in input order", func(t *testing.T) {
|
||||
guards := []*store.ChannelGuard{
|
||||
{PluginId: "aaa"},
|
||||
{PluginId: "bbb"},
|
||||
{PluginId: "ccc"},
|
||||
}
|
||||
ids := pluginIDsOf(guards)
|
||||
require.Equal(t, []string{"aaa", "bbb", "ccc"}, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppErrHookFailed(t *testing.T) {
|
||||
t.Run("without error sets correct fields", func(t *testing.T) {
|
||||
appErr := appErrHookFailed("com.example.plugin", "CreatePost", nil)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
||||
// err==nil branch: no Wrap, so Unwrap returns nil.
|
||||
assert.NoError(t, appErr.Unwrap())
|
||||
})
|
||||
|
||||
t.Run("with error wraps it", func(t *testing.T) {
|
||||
cause := errors.New("rpc transport failure")
|
||||
appErr := appErrHookFailed("com.example.plugin", "UpdatePost", cause)
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
||||
// err!=nil branch: Wrap stores it; errors.Is traverses via Unwrap.
|
||||
assert.ErrorIs(t, appErr, cause)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogAndErrPluginInactive(t *testing.T) {
|
||||
t.Run("single plugin ID returns correct AppError", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
|
||||
appErr := logAndErrPluginInactive(rctx, "ch-id-1", []string{"com.example.only"}, "callerX")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("multiple plugin IDs returns correct AppError", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
|
||||
appErr := logAndErrPluginInactive(rctx, "ch-id-2", []string{"com.a", "com.b", "com.c"}, "callerY")
|
||||
require.NotNil(t, appErr)
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogAndErrPluginsDisabled(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
rctx := request.EmptyContext(th.App.Srv().Log())
|
||||
|
||||
appErr := logAndErrPluginsDisabled(rctx, "ch-id-3", "callerZ")
|
||||
require.NotNil(t, appErr)
|
||||
// Same user-visible error ID as inactive_guard (internal cause differs).
|
||||
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
|
@ -39,7 +40,57 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) {
|
||||
// maxMmBlocksActionsCloneDepth caps recursion in cloneMmBlocksActionsProp.
|
||||
// ValidateMmBlocksActions bounds top-level entry count and key length but
|
||||
// does not bound nesting depth inside spec.Context — a bot/plugin could
|
||||
// otherwise stash a pathologically nested object that drives stack
|
||||
// exhaustion on the restore path. 64 is well past any plausible legitimate
|
||||
// nesting; deeper input is treated as malicious and truncated.
|
||||
const maxMmBlocksActionsCloneDepth = 64
|
||||
|
||||
// cloneMmBlocksActionsProp deep-clones the post.props.mm_blocks_actions value.
|
||||
// Each per-action entry can carry nested context / query maps (and arrays
|
||||
// inside those), so the clone walks the structure recursively — a shallow
|
||||
// clone at any level would leave nested objects aliased back to the live
|
||||
// post's props, defeating the restore-after-invalid-response guarantee.
|
||||
func cloneMmBlocksActionsProp(v any) any {
|
||||
return cloneMmBlocksActionsPropAt(v, 0)
|
||||
}
|
||||
|
||||
func cloneMmBlocksActionsPropAt(v any, depth int) any {
|
||||
if depth > maxMmBlocksActionsCloneDepth {
|
||||
// Defense-in-depth: drop the subtree rather than risk stack
|
||||
// exhaustion. The restore path that calls this helper is on a
|
||||
// rare branch (plugin response is invalid), and pathological
|
||||
// nesting at this depth is not a legitimate use case.
|
||||
return nil
|
||||
}
|
||||
switch typed := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(typed))
|
||||
for k, child := range typed {
|
||||
out[k] = cloneMmBlocksActionsPropAt(child, depth+1)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(typed))
|
||||
for i, child := range typed {
|
||||
out[i] = cloneMmBlocksActionsPropAt(child, depth+1)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
// Scalars (string/number/bool/nil) are immutable — safe to share.
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID, selectedOption string, cookie *model.PostActionCookie, query map[string]string) (string, *model.AppError) {
|
||||
// Bound the per-click query at the App boundary so any caller — REST
|
||||
// handler, plugin, future internal trigger — gets the same enforcement.
|
||||
if err := model.ValidateActionQuery(query); err != nil {
|
||||
return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.query.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
|
||||
// PostAction may result in the original post being updated. For the
|
||||
// updated post, we need to unconditionally preserve the original
|
||||
// IsPinned and HasReaction attributes, and preserve its entire
|
||||
|
|
@ -121,10 +172,17 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
upstreamRequest.ChannelName = channel.Name
|
||||
upstreamRequest.TeamId = channel.TeamId
|
||||
upstreamRequest.Type = cookie.Type
|
||||
upstreamRequest.Context = cookie.Integration.Context
|
||||
// Clone the Context map — later code may add selected_option to
|
||||
// it, and we must not mutate the shared source.
|
||||
//
|
||||
// query is intentionally not merged on the cookie path: cookies are
|
||||
// only baked for attachment action buttons, not for mm_blocks
|
||||
// actions, so this branch is never reached by a click that carries
|
||||
// per-click query params.
|
||||
upstreamRequest.Context = maps.Clone(cookie.Integration.Context)
|
||||
datasource = cookie.DataSource
|
||||
|
||||
retain = cookie.RetainProps
|
||||
retain = maps.Clone(cookie.RetainProps)
|
||||
remove = cookie.RemoveProps
|
||||
rootPostId = cookie.RootPostId
|
||||
upstreamURL = cookie.Integration.URL
|
||||
|
|
@ -132,7 +190,7 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
post := result.Data
|
||||
chResult := <-cchan
|
||||
if chResult.NErr != nil {
|
||||
return "", model.NewAppError("DoPostActionWithCookie", "app.channel.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(result.NErr)
|
||||
return "", model.NewAppError("DoPostActionWithCookie", "app.channel.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(chResult.NErr)
|
||||
}
|
||||
channel := chResult.Data
|
||||
|
||||
|
|
@ -145,7 +203,12 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
upstreamRequest.ChannelName = channel.Name
|
||||
upstreamRequest.TeamId = channel.TeamId
|
||||
upstreamRequest.Type = action.Type
|
||||
upstreamRequest.Context = action.Integration.Context
|
||||
// Clone the Context map — the action pointer returned from
|
||||
// post.GetAction may alias post.props state (attachment action) or
|
||||
// the synthesized mm_blocks_actions spec. Mutating it directly
|
||||
// would leak per-click values (selected_option) into the post's
|
||||
// cached integration for subsequent clickers.
|
||||
upstreamRequest.Context = maps.Clone(action.Integration.Context)
|
||||
datasource = action.DataSource
|
||||
|
||||
// Save the original values that may need to be preserved (including selected
|
||||
|
|
@ -158,7 +221,10 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
remove = append(remove, key)
|
||||
}
|
||||
}
|
||||
originalProps = post.GetProps()
|
||||
// Clone — originalProps may be passed to response.Update.SetProps,
|
||||
// which would otherwise have response.Update alias the original
|
||||
// post's props map.
|
||||
originalProps = maps.Clone(post.GetProps())
|
||||
originalIsPinned = post.IsPinned
|
||||
originalHasReactions = post.HasReactions
|
||||
|
||||
|
|
@ -234,6 +300,18 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
return "", model.NewAppError("DoPostActionWithCookie", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Merge per-click query into the upstream URL. This is the canonical
|
||||
// transport for mm_blocks_actions external clicks; for legacy attachment
|
||||
// clicks `query` is empty so this is a no-op. Done before the request
|
||||
// log so operators see the URL actually sent on the wire.
|
||||
if len(query) > 0 {
|
||||
mergedURL, mergeErr := model.MergeQueryIntoURL(upstreamURL, query)
|
||||
if mergeErr != nil {
|
||||
return "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.merge_query.app_error", nil, "", http.StatusBadRequest).Wrap(mergeErr)
|
||||
}
|
||||
upstreamURL = mergedURL
|
||||
}
|
||||
|
||||
// Log request, regardless of whether destination is internal or external
|
||||
rctx.Logger().Info("DoPostActionWithCookie POST request, through DoActionRequest",
|
||||
mlog.String("url", upstreamURL),
|
||||
|
|
@ -281,7 +359,44 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID,
|
|||
response.Update.IsPinned = originalIsPinned
|
||||
response.Update.HasReactions = originalHasReactions
|
||||
|
||||
if _, _, appErr = a.UpdatePost(rctx, response.Update, &model.UpdatePostOptions{SafeUpdate: false}); appErr != nil {
|
||||
// Validate mm_blocks_actions on update responses. Since
|
||||
// AllowMmBlocksActionsUpdate bypasses the non-integration guard in
|
||||
// UpdatePost, and mm_blocks_actions are not in
|
||||
// PostActionRetainPropKeys, a bad response would otherwise
|
||||
// permanently replace the post's valid mm_blocks_actions. Keep the
|
||||
// original value (if any) and log a warning so integration authors
|
||||
// can diagnose.
|
||||
//
|
||||
// Contract (matches the attachments contract): a plugin update
|
||||
// response that returns a non-nil Props map MUST echo
|
||||
// mm_blocks_actions back if it wants the buttons to survive.
|
||||
// Omitting the key drops the prop. This is intentional symmetry
|
||||
// with attachments and matches the behavior in the mm_blocks
|
||||
// framework PR.
|
||||
if response.Update.GetProp(model.PostPropsMmBlocksActions) != nil {
|
||||
if originalProps[model.PostPropsMmBlocksActions] == nil {
|
||||
rctx.Logger().Info("Dropping mm_blocks_actions from plugin update response: original post had none",
|
||||
mlog.String("post_id", postID),
|
||||
mlog.String("url", upstreamURL),
|
||||
)
|
||||
response.Update.DelProp(model.PostPropsMmBlocksActions)
|
||||
} else if err := model.ValidateMmBlocksActions(response.Update); err != nil {
|
||||
rctx.Logger().Info("Restoring original mm_blocks_actions: plugin update response was invalid",
|
||||
mlog.String("post_id", postID),
|
||||
mlog.String("url", upstreamURL),
|
||||
mlog.Err(err),
|
||||
)
|
||||
// originalProps came from maps.Clone(post.GetProps())
|
||||
// which is a shallow clone — the nested
|
||||
// mm_blocks_actions map is still aliased to
|
||||
// post.Props. Deep-clone before reattaching so a
|
||||
// later mutation through response.Update can't
|
||||
// reach back into the original post's prop map.
|
||||
response.Update.AddProp(model.PostPropsMmBlocksActions, cloneMmBlocksActionsProp(originalProps[model.PostPropsMmBlocksActions]))
|
||||
}
|
||||
}
|
||||
|
||||
if _, _, appErr = a.UpdatePost(rctx, response.Update, &model.UpdatePostOptions{SafeUpdate: false, AllowMmBlocksActionsUpdate: true}); appErr != nil {
|
||||
return "", appErr
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -223,7 +223,8 @@ func (a *App) SessionHasPermissionToCreateJob(session model.Session, job *model.
|
|||
model.JobTypeExportProcess,
|
||||
model.JobTypeExportDelete,
|
||||
model.JobTypeCloud,
|
||||
model.JobTypeExtractContent:
|
||||
model.JobTypeExtractContent,
|
||||
model.JobTypeCleanupExpiredAccessTokens:
|
||||
return a.SessionHasPermissionTo(session, model.PermissionManageJobs), model.PermissionManageJobs
|
||||
case model.JobTypeAccessControlSync:
|
||||
// Allow system admins to create access control sync jobs
|
||||
|
|
@ -294,7 +295,8 @@ func (a *App) SessionHasPermissionToManageJob(session model.Session, job *model.
|
|||
model.JobTypeExportProcess,
|
||||
model.JobTypeExportDelete,
|
||||
model.JobTypeCloud,
|
||||
model.JobTypeExtractContent:
|
||||
model.JobTypeExtractContent,
|
||||
model.JobTypeCleanupExpiredAccessTokens:
|
||||
permission = model.PermissionManageJobs
|
||||
case model.JobTypeAccessControlSync:
|
||||
permission = model.PermissionManageSystem
|
||||
|
|
@ -331,7 +333,8 @@ func (a *App) SessionHasPermissionToReadJob(session model.Session, jobType strin
|
|||
model.JobTypeExportDelete,
|
||||
model.JobTypeCloud,
|
||||
model.JobTypeMobileSessionMetadata,
|
||||
model.JobTypeExtractContent:
|
||||
model.JobTypeExtractContent,
|
||||
model.JobTypeCleanupExpiredAccessTokens:
|
||||
return a.SessionHasPermissionTo(session, model.PermissionReadJobs), model.PermissionReadJobs
|
||||
case model.JobTypeAccessControlSync:
|
||||
return a.SessionHasPermissionTo(session, model.PermissionManageSystem), model.PermissionManageSystem
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ func RegisterLdapDiagnosticInterface(f func(*PlatformService) einterfaces.LdapDi
|
|||
ldapDiagnosticInterface = f
|
||||
}
|
||||
|
||||
var samlDiagnosticInterface func(*PlatformService) einterfaces.SamlDiagnosticInterface
|
||||
|
||||
func RegisterSamlDiagnosticInterface(f func(*PlatformService) einterfaces.SamlDiagnosticInterface) {
|
||||
samlDiagnosticInterface = f
|
||||
}
|
||||
|
||||
var licenseInterface func(*PlatformService) einterfaces.LicenseInterface
|
||||
|
||||
func RegisterLicenseInterface(f func(*PlatformService) einterfaces.LicenseInterface) {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ type PlatformService struct {
|
|||
esWatcher *searchEngineWatcher
|
||||
|
||||
ldapDiagnostic einterfaces.LdapDiagnosticInterface
|
||||
samlDiagnostic einterfaces.SamlDiagnosticInterface
|
||||
|
||||
Jobs *jobs.JobServer
|
||||
|
||||
|
|
@ -534,6 +535,10 @@ func (ps *PlatformService) initEnterprise() {
|
|||
ps.ldapDiagnostic = ldapDiagnosticInterface(ps)
|
||||
}
|
||||
|
||||
if samlDiagnosticInterface != nil {
|
||||
ps.samlDiagnostic = samlDiagnosticInterface(ps)
|
||||
}
|
||||
|
||||
if licenseInterface != nil {
|
||||
ps.licenseManager = licenseInterface(ps)
|
||||
}
|
||||
|
|
@ -667,6 +672,10 @@ func (ps *PlatformService) LdapDiagnostic() einterfaces.LdapDiagnosticInterface
|
|||
return ps.ldapDiagnostic
|
||||
}
|
||||
|
||||
func (ps *PlatformService) SamlDiagnostic() einterfaces.SamlDiagnosticInterface {
|
||||
return ps.samlDiagnostic
|
||||
}
|
||||
|
||||
// DatabaseTypeAndSchemaVersion returns the database type and current version of the schema
|
||||
func (ps *PlatformService) DatabaseTypeAndSchemaVersion() (string, string, error) {
|
||||
schemaVersion, err := ps.Store.GetDBSchemaVersion()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
|
@ -160,10 +161,15 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
|
|||
} else {
|
||||
d.Database.Version = databaseVersion
|
||||
}
|
||||
d.Database.MasterConnectios = ps.Store.TotalMasterDbConnections()
|
||||
d.Database.ReplicaConnectios = ps.Store.TotalReadDbConnections()
|
||||
d.Database.MasterConnections = ps.Store.TotalMasterDbConnections()
|
||||
d.Database.ReplicaConnections = ps.Store.TotalReadDbConnections()
|
||||
d.Database.SearchConnections = ps.Store.TotalSearchDbConnections()
|
||||
|
||||
err = ps.applyStoreDiagnostics(rctx.Context(), &d)
|
||||
if err != nil {
|
||||
rErr = multierror.Append(rErr, err)
|
||||
}
|
||||
|
||||
/* File store */
|
||||
d.FileStore.Status = model.StatusOk
|
||||
err = ps.FileBackend().TestConnection()
|
||||
|
|
@ -235,6 +241,16 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
|
|||
if idpDescriptorURL := model.SafeDereference(ps.Config().SamlSettings.IdpDescriptorURL); idpDescriptorURL != "" {
|
||||
d.SAML.ProviderType = detectSAMLProviderType(idpDescriptorURL)
|
||||
}
|
||||
if samlDiagnostic := ps.SamlDiagnostic(); samlDiagnostic != nil && model.SafeDereference(ps.Config().SamlSettings.Enable) {
|
||||
if err = samlDiagnostic.RunSupportPacketTest(rctx, ps.Config().SamlSettings); err != nil {
|
||||
d.SAML.Status = model.StatusFail
|
||||
d.SAML.Error = err.Error()
|
||||
} else {
|
||||
d.SAML.Status = model.StatusOk
|
||||
}
|
||||
} else {
|
||||
d.SAML.Status = model.StatusDisabled
|
||||
}
|
||||
|
||||
/* Elastic Search */
|
||||
if se := ps.SearchEngine.ElasticsearchEngine; se != nil {
|
||||
|
|
@ -286,10 +302,16 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
|
|||
d.Notifications.Email.Status = model.StatusDisabled
|
||||
}
|
||||
|
||||
/* OAuth2 / OpenID Connect Providers */
|
||||
d.OAuthProviders.GitLab = probeOAuthProvider(rctx.Context(), &ps.Config().GitLabSettings)
|
||||
d.OAuthProviders.Google = probeOAuthProvider(rctx.Context(), &ps.Config().GoogleSettings)
|
||||
d.OAuthProviders.Office365 = probeOAuthProvider(rctx.Context(), ps.Config().Office365Settings.SSOSettings())
|
||||
d.OAuthProviders.OpenID = probeOAuthProvider(rctx.Context(), &ps.Config().OpenIdSettings)
|
||||
|
||||
/* Push Notifications */
|
||||
if model.SafeDereference(ps.Config().EmailSettings.SendPushNotifications) {
|
||||
pushServerURL := model.SafeDereference(ps.Config().EmailSettings.PushNotificationServer)
|
||||
if pushErr := testPushProxyConnection(rctx.Context(), pushServerURL); pushErr != nil {
|
||||
if pushErr := ps.testPushProxyConnection(rctx.Context(), pushServerURL); pushErr != nil {
|
||||
d.Notifications.Push.Status = model.StatusFail
|
||||
d.Notifications.Push.Error = pushErr.Error()
|
||||
} else {
|
||||
|
|
@ -311,8 +333,129 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
|
|||
return fileData, rErr.ErrorOrNil()
|
||||
}
|
||||
|
||||
func (ps *PlatformService) applyStoreDiagnostics(ctx context.Context, diagnostics *model.SupportPacketDiagnostics) error {
|
||||
storeDiagnostics, err := ps.Store.GetDiagnostics(ctx)
|
||||
if storeDiagnostics == nil {
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error while collecting support packet database diagnostics")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
diagnostics.Database.MasterConnectionsInUse = storeDiagnostics.MasterConnectionsInUse
|
||||
diagnostics.Database.MasterConnectionsIdle = storeDiagnostics.MasterConnectionsIdle
|
||||
diagnostics.Database.MasterPoolWaitCount = storeDiagnostics.MasterPoolWaitCount
|
||||
diagnostics.Database.MasterPoolWaitDurationMs = storeDiagnostics.MasterPoolWaitDurationMs
|
||||
diagnostics.Database.MasterConnectionsClosedMaxIdle = storeDiagnostics.MasterConnectionsClosedMaxIdle
|
||||
diagnostics.Database.MasterConnectionsClosedMaxLifetime = storeDiagnostics.MasterConnectionsClosedMaxLifetime
|
||||
diagnostics.Database.ReplicaConnectionsInUse = storeDiagnostics.ReplicaConnectionsInUse
|
||||
diagnostics.Database.ReplicaConnectionsIdle = storeDiagnostics.ReplicaConnectionsIdle
|
||||
diagnostics.Database.ReplicaPoolWaitCount = storeDiagnostics.ReplicaPoolWaitCount
|
||||
diagnostics.Database.ReplicaPoolWaitDurationMs = storeDiagnostics.ReplicaPoolWaitDurationMs
|
||||
diagnostics.Database.ReplicaConnectionsClosedMaxIdle = storeDiagnostics.ReplicaConnectionsClosedMaxIdle
|
||||
diagnostics.Database.ReplicaConnectionsClosedMaxLifetime = storeDiagnostics.ReplicaConnectionsClosedMaxLifetime
|
||||
diagnostics.Database.CacheHitRatio = storeDiagnostics.CacheHitRatio
|
||||
diagnostics.Database.Deadlocks = storeDiagnostics.Deadlocks
|
||||
diagnostics.Database.TempFiles = storeDiagnostics.TempFiles
|
||||
diagnostics.Database.TempBytesMB = storeDiagnostics.TempBytesMB
|
||||
diagnostics.Database.Rollbacks = storeDiagnostics.Rollbacks
|
||||
diagnostics.Database.IdleInTransactionCount = storeDiagnostics.IdleInTransactionCount
|
||||
diagnostics.Database.LongestQueryDurationSeconds = storeDiagnostics.LongestQueryDurationSeconds
|
||||
diagnostics.Database.WaitingForLockCount = storeDiagnostics.WaitingForLockCount
|
||||
diagnostics.Database.PostsDeadTuples = storeDiagnostics.PostsDeadTuples
|
||||
diagnostics.Database.PostsLastAutovacuum = storeDiagnostics.PostsLastAutovacuum
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error while collecting support packet database diagnostics")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeOAuthProvider checks connectivity for an OAuth2/OpenID Connect provider.
|
||||
// If the provider has a DiscoveryEndpoint configured, it issues an HTTP GET to
|
||||
// that URL and verifies the response is a valid OIDC discovery document.
|
||||
// Otherwise it probes the TokenEndpoint host: any HTTP response (including
|
||||
// 4xx/5xx) is treated as reachable, since token endpoints typically reject GETs.
|
||||
func probeOAuthProvider(ctx context.Context, sso *model.SSOSettings) model.OAuthProviderStatus {
|
||||
if !model.SafeDereference(sso.Enable) {
|
||||
return model.OAuthProviderStatus{Status: model.StatusDisabled}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if discoveryEndpoint := model.SafeDereference(sso.DiscoveryEndpoint); discoveryEndpoint != "" {
|
||||
if err := probeOIDCDiscovery(ctx, discoveryEndpoint); err != nil {
|
||||
return model.OAuthProviderStatus{Status: model.StatusFail, Error: err.Error()}
|
||||
}
|
||||
return model.OAuthProviderStatus{Status: model.StatusOk}
|
||||
}
|
||||
|
||||
if tokenEndpoint := model.SafeDereference(sso.TokenEndpoint); tokenEndpoint != "" {
|
||||
if err := probeOAuthTokenEndpoint(ctx, tokenEndpoint); err != nil {
|
||||
return model.OAuthProviderStatus{Status: model.StatusFail, Error: err.Error()}
|
||||
}
|
||||
return model.OAuthProviderStatus{Status: model.StatusOk}
|
||||
}
|
||||
|
||||
return model.OAuthProviderStatus{Status: model.StatusFail, Error: "no discovery or token endpoint configured"}
|
||||
}
|
||||
|
||||
func probeOIDCDiscovery(ctx context.Context, discoveryURL string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainAndCloseBody(resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return fmt.Errorf("discovery endpoint returned unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
// Cap the discovery document at 1 MiB; real OIDC discovery responses are a few KiB.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to read discovery response")
|
||||
}
|
||||
var doc struct {
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return errors.Wrap(err, "discovery endpoint did not return valid JSON")
|
||||
}
|
||||
if doc.Issuer == "" {
|
||||
return fmt.Errorf("discovery endpoint response missing required 'issuer' field")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func probeOAuthTokenEndpoint(ctx context.Context, tokenURL string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainAndCloseBody(resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainAndCloseBody fully reads and discards an HTTP response body (up to 1 MiB
|
||||
// to bound a misbehaving server) and closes it. Draining before closing allows
|
||||
// net/http to return the underlying TCP connection to the idle pool for
|
||||
// keep-alive reuse on subsequent requests.
|
||||
func drainAndCloseBody(body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 1<<20))
|
||||
_ = body.Close()
|
||||
}
|
||||
|
||||
// TODO: move this into its own push proxy package once one exists (see also pushNotificationClient in server.go)
|
||||
func testPushProxyConnection(ctx context.Context, serverURL string) error {
|
||||
func (ps *PlatformService) testPushProxyConnection(ctx context.Context, serverURL string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
versionURL, err := url.JoinPath(serverURL, "version")
|
||||
|
|
@ -327,7 +470,7 @@ func testPushProxyConnection(ctx context.Context, serverURL string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
defer drainAndCloseBody(resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return fmt.Errorf("push proxy returned unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ package platform
|
|||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
|
|
@ -17,6 +19,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -25,6 +28,7 @@ import (
|
|||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/testlib"
|
||||
"github.com/mattermost/mattermost/server/v8/config"
|
||||
emocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||
|
|
@ -32,6 +36,31 @@ import (
|
|||
fmocks "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
|
||||
)
|
||||
|
||||
type fixedDBStatsStore struct {
|
||||
store.Store
|
||||
masterStats sql.DBStats
|
||||
replicaStats sql.DBStats
|
||||
}
|
||||
|
||||
func (s *fixedDBStatsStore) GetDiagnostics(_ context.Context) (*store.DatabaseDiagnostics, error) {
|
||||
diagnostics := &store.DatabaseDiagnostics{
|
||||
MasterConnectionsInUse: s.masterStats.InUse,
|
||||
MasterConnectionsIdle: s.masterStats.Idle,
|
||||
MasterPoolWaitCount: s.masterStats.WaitCount,
|
||||
MasterPoolWaitDurationMs: s.masterStats.WaitDuration.Milliseconds(),
|
||||
MasterConnectionsClosedMaxIdle: s.masterStats.MaxIdleClosed,
|
||||
MasterConnectionsClosedMaxLifetime: s.masterStats.MaxLifetimeClosed,
|
||||
ReplicaConnectionsInUse: s.replicaStats.InUse,
|
||||
ReplicaConnectionsIdle: s.replicaStats.Idle,
|
||||
ReplicaPoolWaitCount: s.replicaStats.WaitCount,
|
||||
ReplicaPoolWaitDurationMs: s.replicaStats.WaitDuration.Milliseconds(),
|
||||
ReplicaConnectionsClosedMaxIdle: s.replicaStats.MaxIdleClosed,
|
||||
ReplicaConnectionsClosedMaxLifetime: s.replicaStats.MaxLifetimeClosed,
|
||||
}
|
||||
|
||||
return diagnostics, nil
|
||||
}
|
||||
|
||||
func TestGenerateSupportPacket(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
|
|
@ -235,9 +264,21 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
|
|||
assert.NotEmpty(t, d.Database.Type)
|
||||
assert.NotEmpty(t, d.Database.Version)
|
||||
assert.NotEmpty(t, d.Database.SchemaVersion)
|
||||
assert.NotZero(t, d.Database.MasterConnectios)
|
||||
assert.Zero(t, d.Database.ReplicaConnectios)
|
||||
assert.NotZero(t, d.Database.MasterConnections)
|
||||
assert.Zero(t, d.Database.ReplicaConnections)
|
||||
assert.Zero(t, d.Database.SearchConnections)
|
||||
assert.GreaterOrEqual(t, d.Database.MasterConnectionsInUse, 0)
|
||||
assert.GreaterOrEqual(t, d.Database.MasterConnectionsIdle, 0)
|
||||
assert.GreaterOrEqual(t, d.Database.MasterPoolWaitCount, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.MasterPoolWaitDurationMs, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.MasterConnectionsClosedMaxIdle, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.MasterConnectionsClosedMaxLifetime, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaConnectionsInUse, 0)
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaConnectionsIdle, 0)
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaPoolWaitCount, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaPoolWaitDurationMs, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaConnectionsClosedMaxIdle, int64(0))
|
||||
assert.GreaterOrEqual(t, d.Database.ReplicaConnectionsClosedMaxLifetime, int64(0))
|
||||
|
||||
/* File store */
|
||||
assert.Equal(t, "OK", d.FileStore.Status)
|
||||
|
|
@ -273,6 +314,12 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
|
|||
assert.Equal(t, model.StatusDisabled, d.ElasticSearch.Status)
|
||||
assert.Empty(t, d.ElasticSearch.ServerVersion)
|
||||
assert.Empty(t, d.ElasticSearch.ServerPlugins)
|
||||
|
||||
/* OAuth Providers (all disabled by default) */
|
||||
assert.Equal(t, model.StatusDisabled, d.OAuthProviders.GitLab.Status)
|
||||
assert.Equal(t, model.StatusDisabled, d.OAuthProviders.Google.Status)
|
||||
assert.Equal(t, model.StatusDisabled, d.OAuthProviders.Office365.Status)
|
||||
assert.Equal(t, model.StatusDisabled, d.OAuthProviders.OpenID.Status)
|
||||
})
|
||||
|
||||
t.Run("filestore fails", func(t *testing.T) {
|
||||
|
|
@ -410,6 +457,131 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
|
|||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Empty(t, packet.SAML.ProviderType)
|
||||
assert.Equal(t, model.StatusDisabled, packet.SAML.Status)
|
||||
assert.Empty(t, packet.SAML.Error)
|
||||
})
|
||||
|
||||
t.Run("SAML enabled with reachable metadata URL", func(t *testing.T) {
|
||||
diagMock := &emocks.SamlDiagnosticInterface{}
|
||||
diagMock.On(
|
||||
"RunSupportPacketTest",
|
||||
mock.AnythingOfType("*request.Context"),
|
||||
mock.AnythingOfType("model.SamlSettings"),
|
||||
).Return(nil)
|
||||
originalSAMLDiag := th.Service.samlDiagnostic
|
||||
t.Cleanup(func() { th.Service.samlDiagnostic = originalSAMLDiag })
|
||||
th.Service.samlDiagnostic = diagMock
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = model.NewPointer(true)
|
||||
cfg.SamlSettings.Verify = model.NewPointer(false)
|
||||
cfg.SamlSettings.Encrypt = model.NewPointer(false)
|
||||
cfg.SamlSettings.IdpURL = model.NewPointer("http://localhost:8484/realms/mattermost/protocol/saml")
|
||||
cfg.SamlSettings.IdpMetadataURL = model.NewPointer("http://localhost:8484/metadata")
|
||||
cfg.SamlSettings.IdpDescriptorURL = model.NewPointer("http://localhost:8484/realms/mattermost")
|
||||
cfg.SamlSettings.ServiceProviderIdentifier = model.NewPointer("mattermost")
|
||||
cfg.SamlSettings.IdpCertificateFile = model.NewPointer("saml-idp.crt")
|
||||
cfg.SamlSettings.EmailAttribute = model.NewPointer("email")
|
||||
cfg.SamlSettings.UsernameAttribute = model.NewPointer("username")
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusOk, packet.SAML.Status)
|
||||
assert.Empty(t, packet.SAML.Error)
|
||||
assert.Equal(t, "Keycloak", packet.SAML.ProviderType)
|
||||
})
|
||||
|
||||
t.Run("SAML enabled with missing metadata URL", func(t *testing.T) {
|
||||
diagMock := &emocks.SamlDiagnosticInterface{}
|
||||
diagMock.On(
|
||||
"RunSupportPacketTest",
|
||||
mock.AnythingOfType("*request.Context"),
|
||||
mock.AnythingOfType("model.SamlSettings"),
|
||||
).Return(errors.New("SAML metadata URL is not configured"))
|
||||
originalSAMLDiag := th.Service.samlDiagnostic
|
||||
t.Cleanup(func() { th.Service.samlDiagnostic = originalSAMLDiag })
|
||||
th.Service.samlDiagnostic = diagMock
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = model.NewPointer(true)
|
||||
cfg.SamlSettings.Verify = model.NewPointer(false)
|
||||
cfg.SamlSettings.Encrypt = model.NewPointer(false)
|
||||
cfg.SamlSettings.IdpURL = model.NewPointer("http://localhost:8484/realms/mattermost/protocol/saml")
|
||||
cfg.SamlSettings.IdpDescriptorURL = model.NewPointer("http://localhost:8484/realms/mattermost")
|
||||
cfg.SamlSettings.ServiceProviderIdentifier = model.NewPointer("mattermost")
|
||||
cfg.SamlSettings.IdpCertificateFile = model.NewPointer("saml-idp.crt")
|
||||
cfg.SamlSettings.EmailAttribute = model.NewPointer("email")
|
||||
cfg.SamlSettings.UsernameAttribute = model.NewPointer("username")
|
||||
cfg.SamlSettings.IdpMetadataURL = model.NewPointer("")
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.SAML.Status)
|
||||
assert.Equal(t, "SAML metadata URL is not configured", packet.SAML.Error)
|
||||
})
|
||||
|
||||
t.Run("SAML enabled with metadata URL returning non-200", func(t *testing.T) {
|
||||
diagMock := &emocks.SamlDiagnosticInterface{}
|
||||
diagMock.On(
|
||||
"RunSupportPacketTest",
|
||||
mock.AnythingOfType("*request.Context"),
|
||||
mock.AnythingOfType("model.SamlSettings"),
|
||||
).Return(errors.New("SAML metadata URL returned unexpected status 503"))
|
||||
originalSAMLDiag := th.Service.samlDiagnostic
|
||||
t.Cleanup(func() { th.Service.samlDiagnostic = originalSAMLDiag })
|
||||
th.Service.samlDiagnostic = diagMock
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = model.NewPointer(true)
|
||||
cfg.SamlSettings.Verify = model.NewPointer(false)
|
||||
cfg.SamlSettings.Encrypt = model.NewPointer(false)
|
||||
cfg.SamlSettings.IdpURL = model.NewPointer("http://localhost:8484/realms/mattermost/protocol/saml")
|
||||
cfg.SamlSettings.IdpMetadataURL = model.NewPointer("http://localhost:8484/metadata")
|
||||
cfg.SamlSettings.IdpDescriptorURL = model.NewPointer("http://localhost:8484/realms/mattermost")
|
||||
cfg.SamlSettings.ServiceProviderIdentifier = model.NewPointer("mattermost")
|
||||
cfg.SamlSettings.IdpCertificateFile = model.NewPointer("saml-idp.crt")
|
||||
cfg.SamlSettings.EmailAttribute = model.NewPointer("email")
|
||||
cfg.SamlSettings.UsernameAttribute = model.NewPointer("username")
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.SAML.Status)
|
||||
assert.Equal(t, "SAML metadata URL returned unexpected status 503", packet.SAML.Error)
|
||||
})
|
||||
|
||||
t.Run("SAML diagnostics enterprise interface override", func(t *testing.T) {
|
||||
diagMock := &emocks.SamlDiagnosticInterface{}
|
||||
diagMock.On(
|
||||
"RunSupportPacketTest",
|
||||
mock.AnythingOfType("*request.Context"),
|
||||
mock.AnythingOfType("model.SamlSettings"),
|
||||
).Return(errors.New("enterprise check failed"))
|
||||
originalSAMLDiag := th.Service.samlDiagnostic
|
||||
t.Cleanup(func() {
|
||||
th.Service.samlDiagnostic = originalSAMLDiag
|
||||
})
|
||||
th.Service.samlDiagnostic = diagMock
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = model.NewPointer(true)
|
||||
cfg.SamlSettings.Verify = model.NewPointer(false)
|
||||
cfg.SamlSettings.Encrypt = model.NewPointer(false)
|
||||
cfg.SamlSettings.IdpURL = model.NewPointer("http://localhost:8484/realms/mattermost/protocol/saml")
|
||||
cfg.SamlSettings.IdpMetadataURL = model.NewPointer("http://localhost:8484/metadata")
|
||||
cfg.SamlSettings.IdpDescriptorURL = model.NewPointer("http://localhost:8484/realms/mattermost")
|
||||
cfg.SamlSettings.ServiceProviderIdentifier = model.NewPointer("mattermost")
|
||||
cfg.SamlSettings.IdpCertificateFile = model.NewPointer("saml-idp.crt")
|
||||
cfg.SamlSettings.EmailAttribute = model.NewPointer("email")
|
||||
cfg.SamlSettings.UsernameAttribute = model.NewPointer("username")
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.SAML.Status)
|
||||
assert.Equal(t, "enterprise check failed", packet.SAML.Error)
|
||||
})
|
||||
|
||||
t.Run("SAML enabled with Keycloak provider", func(t *testing.T) {
|
||||
|
|
@ -698,6 +870,212 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
|
|||
assert.Equal(t, model.StatusFail, packet.Notifications.Email.Status)
|
||||
assert.NotEmpty(t, packet.Notifications.Email.Error)
|
||||
})
|
||||
|
||||
t.Run("maps connection pool diagnostics for master and replica", func(t *testing.T) {
|
||||
originalStore := th.Service.Store
|
||||
customStore := &fixedDBStatsStore{
|
||||
Store: originalStore,
|
||||
masterStats: sql.DBStats{
|
||||
InUse: 3,
|
||||
Idle: 7,
|
||||
WaitCount: 11,
|
||||
WaitDuration: 2*time.Second + 25*time.Millisecond,
|
||||
MaxIdleClosed: 13,
|
||||
MaxLifetimeClosed: 17,
|
||||
},
|
||||
replicaStats: sql.DBStats{
|
||||
InUse: 5,
|
||||
Idle: 9,
|
||||
WaitCount: 19,
|
||||
WaitDuration: 4*time.Second + 90*time.Millisecond,
|
||||
MaxIdleClosed: 23,
|
||||
MaxLifetimeClosed: 29,
|
||||
},
|
||||
}
|
||||
th.Service.Store = customStore
|
||||
t.Cleanup(func() {
|
||||
th.Service.Store = originalStore
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
assert.Equal(t, 3, packet.Database.MasterConnectionsInUse)
|
||||
assert.Equal(t, 7, packet.Database.MasterConnectionsIdle)
|
||||
assert.Equal(t, int64(11), packet.Database.MasterPoolWaitCount)
|
||||
assert.Equal(t, int64(2025), packet.Database.MasterPoolWaitDurationMs)
|
||||
assert.Equal(t, int64(13), packet.Database.MasterConnectionsClosedMaxIdle)
|
||||
assert.Equal(t, int64(17), packet.Database.MasterConnectionsClosedMaxLifetime)
|
||||
assert.Equal(t, 5, packet.Database.ReplicaConnectionsInUse)
|
||||
assert.Equal(t, 9, packet.Database.ReplicaConnectionsIdle)
|
||||
assert.Equal(t, int64(19), packet.Database.ReplicaPoolWaitCount)
|
||||
assert.Equal(t, int64(4090), packet.Database.ReplicaPoolWaitDurationMs)
|
||||
assert.Equal(t, int64(23), packet.Database.ReplicaConnectionsClosedMaxIdle)
|
||||
assert.Equal(t, int64(29), packet.Database.ReplicaConnectionsClosedMaxLifetime)
|
||||
})
|
||||
|
||||
t.Run("OpenID disabled", func(t *testing.T) {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(false)
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusDisabled, packet.OAuthProviders.OpenID.Status)
|
||||
assert.Empty(t, packet.OAuthProviders.OpenID.Error)
|
||||
})
|
||||
|
||||
t.Run("OpenID reachable via discovery endpoint", func(t *testing.T) {
|
||||
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/.well-known/openid-configuration", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"issuer":"https://idp.example.com","authorization_endpoint":"https://idp.example.com/auth"}`))
|
||||
}))
|
||||
defer idp.Close()
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(true)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer(idp.URL + "/.well-known/openid-configuration")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(false)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusOk, packet.OAuthProviders.OpenID.Status)
|
||||
assert.Empty(t, packet.OAuthProviders.OpenID.Error)
|
||||
})
|
||||
|
||||
t.Run("OpenID discovery endpoint returns invalid JSON", func(t *testing.T) {
|
||||
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
defer idp.Close()
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(true)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer(idp.URL + "/.well-known/openid-configuration")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(false)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.OAuthProviders.OpenID.Status)
|
||||
assert.Contains(t, packet.OAuthProviders.OpenID.Error, "valid JSON")
|
||||
})
|
||||
|
||||
t.Run("OpenID discovery endpoint missing issuer field", func(t *testing.T) {
|
||||
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"authorization_endpoint":"https://idp.example.com/auth"}`))
|
||||
}))
|
||||
defer idp.Close()
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(true)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer(idp.URL + "/.well-known/openid-configuration")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(false)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.OAuthProviders.OpenID.Status)
|
||||
assert.Contains(t, packet.OAuthProviders.OpenID.Error, "issuer")
|
||||
})
|
||||
|
||||
t.Run("OpenID discovery endpoint unreachable", func(t *testing.T) {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(true)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer("http://127.0.0.1:1/.well-known/openid-configuration")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.OpenIdSettings.Enable = model.NewPointer(false)
|
||||
cfg.OpenIdSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.OAuthProviders.OpenID.Status)
|
||||
assert.NotEmpty(t, packet.OAuthProviders.OpenID.Error)
|
||||
})
|
||||
|
||||
t.Run("GitLab enabled with reachable token endpoint", func(t *testing.T) {
|
||||
// GitLab has no DiscoveryEndpoint by default, so we fall through to the
|
||||
// TokenEndpoint host probe. Token endpoints reject GETs, so any HTTP
|
||||
// response (including 4xx/5xx) is treated as reachable.
|
||||
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}))
|
||||
defer idp.Close()
|
||||
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(true)
|
||||
cfg.GitLabSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
cfg.GitLabSettings.TokenEndpoint = model.NewPointer(idp.URL + "/oauth/token")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(false)
|
||||
cfg.GitLabSettings.TokenEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusOk, packet.OAuthProviders.GitLab.Status)
|
||||
assert.Empty(t, packet.OAuthProviders.GitLab.Error)
|
||||
})
|
||||
|
||||
t.Run("GitLab enabled with unreachable token endpoint", func(t *testing.T) {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(true)
|
||||
cfg.GitLabSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
cfg.GitLabSettings.TokenEndpoint = model.NewPointer("http://127.0.0.1:1/oauth/token")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(false)
|
||||
cfg.GitLabSettings.TokenEndpoint = model.NewPointer("")
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.OAuthProviders.GitLab.Status)
|
||||
assert.NotEmpty(t, packet.OAuthProviders.GitLab.Error)
|
||||
})
|
||||
|
||||
t.Run("GitLab enabled with no endpoints configured", func(t *testing.T) {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(true)
|
||||
cfg.GitLabSettings.DiscoveryEndpoint = model.NewPointer("")
|
||||
cfg.GitLabSettings.TokenEndpoint = model.NewPointer("")
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
th.Service.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = model.NewPointer(false)
|
||||
})
|
||||
})
|
||||
|
||||
packet := getDiagnostics(t)
|
||||
|
||||
assert.Equal(t, model.StatusFail, packet.OAuthProviders.GitLab.Status)
|
||||
assert.Contains(t, packet.OAuthProviders.GitLab.Error, "no discovery or token endpoint")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSanitizedConfigFile(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -537,6 +537,14 @@ func (api *PluginAPI) UpdateChannel(channel *model.Channel) (*model.Channel, *mo
|
|||
return api.app.UpdateChannel(api.ctx, channel)
|
||||
}
|
||||
|
||||
func (api *PluginAPI) RegisterChannelGuard(channelID string) *model.AppError {
|
||||
return api.app.RegisterChannelGuard(api.ctx, channelID, strings.ToLower(api.id))
|
||||
}
|
||||
|
||||
func (api *PluginAPI) UnregisterChannelGuard(channelID string) *model.AppError {
|
||||
return api.app.UnregisterChannelGuard(api.ctx, channelID, strings.ToLower(api.id))
|
||||
}
|
||||
|
||||
func (api *PluginAPI) SearchChannels(teamID string, term string) ([]*model.Channel, *model.AppError) {
|
||||
channels, err := api.app.SearchChannels(api.ctx, teamID, term)
|
||||
if err != nil {
|
||||
|
|
@ -874,7 +882,19 @@ func (api *PluginAPI) GetPostsForChannel(channelID string, page, perPage int) (*
|
|||
}
|
||||
|
||||
func (api *PluginAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError) {
|
||||
post, _, appErr := api.app.UpdatePost(api.ctx, post, &model.UpdatePostOptions{SafeUpdate: false})
|
||||
// Grant mm_blocks_actions write access only when the plugin's update
|
||||
// actually includes the prop, AND the value passes validation.
|
||||
// Otherwise the freeze in UpdatePost preserves whatever the original
|
||||
// post had — plugins that update unrelated fields don't accidentally
|
||||
// drop or corrupt mm_blocks_actions.
|
||||
allowMmBlocksActionsUpdate := false
|
||||
if post.GetProp(model.PostPropsMmBlocksActions) != nil {
|
||||
if err := model.ValidateMmBlocksActions(post); err != nil {
|
||||
return nil, model.NewAppError("UpdatePost", "plugin.api.update_post.mm_blocks_actions.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
allowMmBlocksActionsUpdate = true
|
||||
}
|
||||
post, _, appErr := api.app.UpdatePost(api.ctx, post, &model.UpdatePostOptions{SafeUpdate: false, AllowMmBlocksActionsUpdate: allowMmBlocksActionsUpdate})
|
||||
if post != nil {
|
||||
post = post.ForPlugin()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -255,6 +255,24 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
|
|||
post.AddProp(model.PostPropsFromOAuthApp, "true")
|
||||
}
|
||||
|
||||
// Strip mm_blocks_actions from posts that are neither bot-authored nor
|
||||
// created via an integration session. Either signal is sufficient:
|
||||
// - user.IsBot (DB-verified) covers PluginAPI.CreatePost where the
|
||||
// plugin's static rctx has no integration markers but the post
|
||||
// is authored by a bot user.
|
||||
// - rctx.Session().IsIntegration() (server-derived, unspoofable)
|
||||
// covers REST callers using bot tokens, PATs, or OAuth apps.
|
||||
//
|
||||
// Webhooks are handled separately at their entry point
|
||||
// (CreateWebhookPost) — webhook payloads are user-controlled even
|
||||
// when bound to a bot user, so the prop is dropped before the post
|
||||
// reaches CreatePost. See TestCreateWebhookPostStripsMmBlocksActions.
|
||||
if post.GetProp(model.PostPropsMmBlocksActions) != nil {
|
||||
if !user.IsBot && !rctx.Session().IsIntegration() {
|
||||
post.DelProp(model.PostPropsMmBlocksActions)
|
||||
}
|
||||
}
|
||||
|
||||
var ephemeralPost *model.Post
|
||||
if post.Type == "" {
|
||||
if hasPermission, _ := a.HasPermissionToChannel(rctx, user.Id, channel.Id, model.PermissionUseChannelMentions); !hasPermission {
|
||||
|
|
@ -313,39 +331,14 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
|
|||
}
|
||||
}
|
||||
|
||||
var metadata *model.PostMetadata
|
||||
if post.Metadata != nil {
|
||||
metadata = post.Metadata.Copy()
|
||||
}
|
||||
var rejectionError *model.AppError
|
||||
pluginContext := pluginContext(rctx)
|
||||
|
||||
if post.Type != model.PostTypeBurnOnRead {
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin())
|
||||
if rejectionReason != "" {
|
||||
id := "Post rejected by plugin. " + rejectionReason
|
||||
if rejectionReason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if replacementPost != nil {
|
||||
post = replacementPost
|
||||
if post.Metadata != nil && metadata != nil {
|
||||
post.Metadata.Priority = metadata.Priority
|
||||
} else {
|
||||
post.Metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, plugin.MessageWillBePostedID)
|
||||
|
||||
if rejectionError != nil {
|
||||
return nil, false, rejectionError
|
||||
newPost, guardErr := a.runGuardedMessageWillBePosted(rctx, post)
|
||||
if guardErr != nil {
|
||||
return nil, false, guardErr
|
||||
}
|
||||
post = newPost
|
||||
}
|
||||
|
||||
// Pre-fill the CreateAt field for link previews to get the correct timestamp.
|
||||
|
|
@ -710,6 +703,13 @@ func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Pos
|
|||
post.SetProps(make(model.StringInterface))
|
||||
}
|
||||
|
||||
// mm_blocks_actions cannot be resolved on click for ephemeral posts (no
|
||||
// DB row, no per-action cookie transport). Drop the prop here so the
|
||||
// client doesn't render a non-functional button.
|
||||
if post.GetProp(model.PostPropsMmBlocksActions) != nil {
|
||||
post.DelProp(model.PostPropsMmBlocksActions)
|
||||
}
|
||||
|
||||
post.GenerateActionIds()
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "")
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
|
|
@ -744,6 +744,13 @@ func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.P
|
|||
post.SetProps(make(model.StringInterface))
|
||||
}
|
||||
|
||||
// mm_blocks_actions cannot be resolved on click for ephemeral posts (no
|
||||
// DB row, no per-action cookie transport). Drop the prop here so the
|
||||
// client doesn't render a non-functional button.
|
||||
if post.GetProp(model.PostPropsMmBlocksActions) != nil {
|
||||
post.DelProp(model.PostPropsMmBlocksActions)
|
||||
}
|
||||
|
||||
post.GenerateActionIds()
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "")
|
||||
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
|
||||
|
|
@ -862,6 +869,21 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
|
|||
newPost.HasReactions = receivedUpdatedPost.HasReactions
|
||||
newPost.SetProps(receivedUpdatedPost.GetProps())
|
||||
|
||||
// mm_blocks_actions can only be modified by trusted paths that have
|
||||
// pre-validated the new value (AllowMmBlocksActionsUpdate). Session
|
||||
// type is intentionally not a sufficient signal: a PAT/OAuth session
|
||||
// from a regular user would otherwise bypass the freeze and inject
|
||||
// mm_blocks_actions on edit, since from_bot on the original post is
|
||||
// user-forgeable. All other callers keep whatever mm_blocks_actions
|
||||
// the original post had (or none).
|
||||
if !updatePostOptions.AllowMmBlocksActionsUpdate {
|
||||
if oldVal, ok := oldPost.GetProps()[model.PostPropsMmBlocksActions]; ok {
|
||||
newPost.AddProp(model.PostPropsMmBlocksActions, oldVal)
|
||||
} else {
|
||||
newPost.DelProp(model.PostPropsMmBlocksActions)
|
||||
}
|
||||
}
|
||||
|
||||
var fileIds []string
|
||||
fileIds, appErr = a.processPostFileChanges(rctx, receivedUpdatedPost, oldPost, updatePostOptions)
|
||||
if appErr != nil {
|
||||
|
|
@ -883,15 +905,11 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
|
|||
oldPost.RemoteId = new(*receivedUpdatedPost.RemoteId)
|
||||
}
|
||||
|
||||
var rejectionReason string
|
||||
pluginContext := pluginContext(rctx)
|
||||
if newPost.Type != model.PostTypeBurnOnRead {
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin())
|
||||
return newPost != nil
|
||||
}, plugin.MessageWillBeUpdatedID)
|
||||
if newPost == nil {
|
||||
return nil, false, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest)
|
||||
var appErr2 *model.AppError
|
||||
newPost, appErr2 = a.runGuardedMessageWillBeUpdated(rctx, newPost, oldPost)
|
||||
if appErr2 != nil {
|
||||
return nil, false, appErr2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -916,12 +934,13 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
|
|||
}
|
||||
}
|
||||
|
||||
pCtx := pluginContext(rctx)
|
||||
pluginOldPost := oldPost.ForPlugin()
|
||||
pluginNewPost := newPost.ForPlugin()
|
||||
if newPost.Type != model.PostTypeBurnOnRead {
|
||||
a.Srv().Go(func() {
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost)
|
||||
hooks.MessageHasBeenUpdated(pCtx, pluginNewPost, pluginOldPost)
|
||||
return true
|
||||
}, plugin.MessageHasBeenUpdatedID)
|
||||
})
|
||||
|
|
@ -964,6 +983,8 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
|
|||
}
|
||||
}
|
||||
|
||||
a.applyPostWillBeConsumedHook(&rpost)
|
||||
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", rpost.ChannelId, "", nil, "")
|
||||
|
||||
appErr = a.publishWebsocketEventForPost(rctx, rpost, message)
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ func TestPreparePostForClient(t *testing.T) {
|
|||
assert.Eventually(t, func() bool {
|
||||
clientPost = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
|
||||
return assert.ObjectsAreEqual([]*model.FileInfo{fileInfo}, clientPost.Metadata.Files)
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}, 10*time.Second, 25*time.Millisecond)
|
||||
|
||||
assert.Equal(t, []*model.FileInfo{fileInfo}, clientPost.Metadata.Files, "should've populated Files")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -144,11 +144,12 @@ var trimmedFieldAttrKeys = []string{
|
|||
model.PropertyFieldAttrDisplayName,
|
||||
}
|
||||
|
||||
// sanitizeAndValidateOptions canonicalizes the options attr to the typed
|
||||
// option slice, auto-assigns IDs to options without one, and validates the
|
||||
// resulting shape. The JSON round-trip handles both the typed-slice form
|
||||
// (when the request decoded into a wrapper struct) and the []map[string]any
|
||||
// form (after a generic JSON decode or DB read).
|
||||
// sanitizeAndValidateOptions auto-assigns IDs to options without one,
|
||||
// validates the resulting shape, and writes the options back in the
|
||||
// canonical attrs form ([]any of map[string]any) that the rest of the
|
||||
// codebase expects (see PropertyField.EnsureOptionIDs). The typed slice
|
||||
// is used internally for validation only; persisting it would force
|
||||
// every downstream reader of attrs["options"] to handle two shapes.
|
||||
func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOptions(field *model.PropertyField) error {
|
||||
rawOptions, ok := field.Attrs[model.PropertyFieldAttributeOptions]
|
||||
if !ok || rawOptions == nil {
|
||||
|
|
@ -160,7 +161,7 @@ func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOptions(field
|
|||
return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
|
||||
}
|
||||
var options model.PropertyOptions[*model.CustomProfileAttributesSelectOption]
|
||||
if err := json.Unmarshal(data, &options); err != nil {
|
||||
if err = json.Unmarshal(data, &options); err != nil {
|
||||
return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
|
||||
}
|
||||
|
||||
|
|
@ -169,11 +170,19 @@ func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOptions(field
|
|||
options[i].ID = model.NewId()
|
||||
}
|
||||
}
|
||||
if err := options.IsValid(); err != nil {
|
||||
if err = options.IsValid(); err != nil {
|
||||
return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
|
||||
}
|
||||
|
||||
field.Attrs[model.PropertyFieldAttributeOptions] = options
|
||||
normalized, err := json.Marshal(options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
|
||||
}
|
||||
var canonical []any
|
||||
if err = json.Unmarshal(normalized, &canonical); err != nil {
|
||||
return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
|
||||
}
|
||||
field.Attrs[model.PropertyFieldAttributeOptions] = canonical
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -935,6 +935,36 @@ func TestAccessControlAttributeValidationHook(t *testing.T) {
|
|||
require.Error(t, upsertErr)
|
||||
assert.Contains(t, upsertErr.Error(), "maximum length")
|
||||
})
|
||||
|
||||
t.Run("sanitizeAndValidateOptions writes back canonical []any of map[string]any", func(t *testing.T) {
|
||||
// Downstream readers (asOptionSlice, EnsureOptionIDs, store-layer
|
||||
// serialization) expect the canonical loose-typed shape. Writing back
|
||||
// a typed PropertyOptions slice from the hook used to break the linked-
|
||||
// options diff on every no-op patch — see commit bc15075016.
|
||||
field := &model.PropertyField{
|
||||
GroupID: group.ID,
|
||||
Name: "field_" + model.NewId(),
|
||||
Type: model.PropertyFieldTypeSelect,
|
||||
TargetType: "system",
|
||||
ObjectType: "user",
|
||||
Attrs: model.StringInterface{
|
||||
model.PropertyFieldAttributeOptions: []any{
|
||||
map[string]any{"id": model.NewId(), "name": "A", "color": "#fff"},
|
||||
map[string]any{"id": model.NewId(), "name": "B", "color": "#000"},
|
||||
},
|
||||
},
|
||||
}
|
||||
created, createErr := th.service.CreatePropertyField(th.Context, field)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
opts, ok := created.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
||||
require.True(t, ok, "options should be []any after hook canonicalization, got %T", created.Attrs[model.PropertyFieldAttributeOptions])
|
||||
require.Len(t, opts, 2)
|
||||
for _, opt := range opts {
|
||||
_, ok := opt.(map[string]any)
|
||||
assert.True(t, ok, "each option element should be map[string]any, got %T", opt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessControlAttributeValidationHookManagedAuthorization(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -572,7 +572,8 @@ func (ps *PropertyService) DeletePropertyField(rctx request.CTX, groupID, id str
|
|||
// asOptionSlice extracts the options from an attrs map as []map[string]any
|
||||
// via direct type assertion. By the time options reach the service layer,
|
||||
// they are always []any containing map[string]any elements (from JSON
|
||||
// deserialization or EnsureOptionIDs).
|
||||
// deserialization, EnsureOptionIDs, or AccessControlAttributeValidationHook.
|
||||
// sanitizeAndValidateOptions — all of which normalize to this shape).
|
||||
func asOptionSlice(attrs model.StringInterface) []map[string]any {
|
||||
if attrs == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1570,4 +1570,20 @@ func TestOptionsChanged(t *testing.T) {
|
|||
updated := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A", "disabled": true}]}`)
|
||||
assert.True(t, optionsChanged(old, updated))
|
||||
})
|
||||
|
||||
t.Run("non-[]any option slice returns nil (treated as no options)", func(t *testing.T) {
|
||||
// Producers in this codebase normalize attrs["options"] to []any of
|
||||
// map[string]any (see EnsureOptionIDs, AccessControlAttributeValidationHook.
|
||||
// sanitizeAndValidateOptions). If a non-canonical shape ever sneaks in,
|
||||
// asOptionSlice returns nil, which makes optionsChanged report "changed"
|
||||
// against any populated side — the safe failure mode that surfaces the
|
||||
// contract violation instead of silently passing.
|
||||
dbForm := attrsFromJSON(t, `{"options": [{"id": "`+optID1+`", "name": "A"}]}`)
|
||||
nonCanonical := model.StringInterface{
|
||||
model.PropertyFieldAttributeOptions: model.PropertyOptions[*model.CustomProfileAttributesSelectOption]{
|
||||
{ID: optID1, Name: "A"},
|
||||
},
|
||||
}
|
||||
assert.True(t, optionsChanged(dbForm, nonCanonical))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
|
@ -39,6 +40,23 @@ func (a *App) SaveScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled
|
|||
return nil, model.NewAppError("App.scheduledPostPreSaveChecks", "app.save_scheduled_post.channel_deleted.app_error", map[string]any{"user_id": scheduledPost.UserId, "channel_id": scheduledPost.ChannelId}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var rejectionReason string
|
||||
pluginContext := pluginContext(rctx)
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacement, reason := hooks.ScheduledPostWillBeCreated(pluginContext, scheduledPost)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
scheduledPost = replacement
|
||||
}
|
||||
return true
|
||||
}, plugin.ScheduledPostWillBeCreatedID)
|
||||
if rejectionReason != "" {
|
||||
return nil, model.NewAppError("SaveScheduledPost", "app.scheduled_post.save.rejected_by_plugin", map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
savedScheduledPost, err := a.Srv().Store().ScheduledPost().CreateScheduledPost(scheduledPost)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("App.ScheduledPost", "app.save_scheduled_post.save.app_error", map[string]any{"user_id": scheduledPost.UserId, "channel_id": scheduledPost.ChannelId}, "", http.StatusBadRequest).Wrap(err)
|
||||
|
|
@ -86,6 +104,23 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost
|
|||
// updated scheduled post. It's better to do this before calling update than after.
|
||||
scheduledPost.RestoreNonUpdatableFields(existingScheduledPost)
|
||||
|
||||
var rejectionReason string
|
||||
pluginContext := pluginContext(rctx)
|
||||
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacement, reason := hooks.ScheduledPostWillBeCreated(pluginContext, scheduledPost)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
scheduledPost = replacement
|
||||
}
|
||||
return true
|
||||
}, plugin.ScheduledPostWillBeCreatedID)
|
||||
if rejectionReason != "" {
|
||||
return nil, model.NewAppError("UpdateScheduledPost", "app.scheduled_post.update.rejected_by_plugin", map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(scheduledPost); err != nil {
|
||||
return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/active_users"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/cleanup_desktop_tokens"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/cleanup_expired_access_tokens"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_dms_preferences_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_empty_drafts_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_expired_posts"
|
||||
|
|
@ -273,7 +274,6 @@ func NewServer(options ...Option) (*Server, error) {
|
|||
if err = s.propertyService.RegisterBuiltinGroups([]*model.PropertyGroup{
|
||||
{Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2},
|
||||
{Name: model.ContentFlaggingGroupName, Version: model.PropertyGroupVersionV1},
|
||||
{Name: model.ClassificationMarkingsPropertyGroupName, Version: model.PropertyGroupVersionV2},
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to register builtin property groups")
|
||||
}
|
||||
|
|
@ -1724,6 +1724,12 @@ func (s *Server) initJobs() {
|
|||
cleanup_desktop_tokens.MakeScheduler(s.Jobs),
|
||||
)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeCleanupExpiredAccessTokens,
|
||||
cleanup_expired_access_tokens.MakeWorker(s.Jobs, s.platform.ClearUserSessionCache),
|
||||
cleanup_expired_access_tokens.MakeScheduler(s.Jobs),
|
||||
)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeRefreshMaterializedViews,
|
||||
refresh_materialized_views.MakeWorker(s.Jobs, *s.platform.Config().SqlSettings.DriverName),
|
||||
|
|
|
|||
|
|
@ -369,6 +369,20 @@ func (a *App) GetSessionLengthInMillis(session *model.Session) int64 {
|
|||
return 0
|
||||
}
|
||||
|
||||
// For PAT sessions with a fixed expiry, return the remaining lifetime so
|
||||
// that ExtendSessionExpiryIfNeeded never pushes ExpiresAt past the token's
|
||||
// own expiry. The elapsed threshold check collapses to zero, so extension
|
||||
// is effectively a no-op for these sessions (correct: the expiry is fixed).
|
||||
// PAT sessions with ExpiresAt == 0 (non-expiring) fall through to normal
|
||||
// web-session behavior.
|
||||
if session.Props[model.SessionPropType] == model.SessionTypeUserAccessToken && session.ExpiresAt > 0 {
|
||||
remaining := session.ExpiresAt - model.GetMillis()
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
var hours int
|
||||
if session.IsMobileApp() {
|
||||
hours = *a.Config().ServiceSettings.SessionLengthMobileInHours
|
||||
|
|
@ -451,6 +465,15 @@ func (a *App) createSessionForUserAccessToken(rctx request.CTX, tokenString stri
|
|||
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "EnableUserAccessTokens=false", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if token.IsExpired() {
|
||||
auditRec := a.MakeAuditRecord(rctx, model.AuditEventRejectExpiredUserAccessToken, model.AuditStatusFail)
|
||||
auditRec.AddMeta("token_id", token.Id)
|
||||
auditRec.AddMeta("user_id", token.UserId)
|
||||
auditRec.AddMeta("expires_at", token.ExpiresAt)
|
||||
a.LogAuditRec(rctx, auditRec, nil)
|
||||
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.expired", nil, "expired_token", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if user.DeleteAt != 0 {
|
||||
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "inactive_user_id="+user.Id, http.StatusUnauthorized)
|
||||
}
|
||||
|
|
@ -478,6 +501,12 @@ func (a *App) createSessionForUserAccessToken(rctx request.CTX, tokenString stri
|
|||
}
|
||||
a.ch.srv.platform.SetSessionExpireInHours(session, model.SessionUserAccessTokenExpiryHours)
|
||||
|
||||
// If the underlying PAT has a non-zero expiry, clamp the session expiry to
|
||||
// the token's ExpiresAt so that cached sessions honor PAT expiry as well.
|
||||
if token.ExpiresAt > 0 && (session.ExpiresAt == 0 || token.ExpiresAt < session.ExpiresAt) {
|
||||
session.ExpiresAt = token.ExpiresAt
|
||||
}
|
||||
|
||||
session, nErr = a.Srv().Store().Session().Save(rctx, session)
|
||||
if nErr != nil {
|
||||
var invErr *store.ErrInvalidInput
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package app
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
|
@ -13,10 +14,12 @@ import (
|
|||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
smocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
"github.com/mattermost/mattermost/server/v8/config"
|
||||
|
|
@ -142,6 +145,8 @@ func TestGenerateSupportPacket(t *testing.T) {
|
|||
mockStore.On("TotalMasterDbConnections").Return(30)
|
||||
mockStore.On("TotalReadDbConnections").Return(20)
|
||||
mockStore.On("TotalSearchDbConnections").Return(10)
|
||||
mockStore.On("GetInternalMasterDB").Return((*sql.DB)(nil))
|
||||
mockStore.On("GetDiagnostics", mock.Anything).Return(&store.DatabaseDiagnostics{}, nil)
|
||||
mockStore.On("GetSchemaDefinition").Return(&model.SupportPacketDatabaseSchema{
|
||||
Tables: []model.DatabaseTable{},
|
||||
}, nil)
|
||||
|
|
|
|||
|
|
@ -1492,7 +1492,7 @@ func (a *App) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *mod
|
|||
return inviteListWithErrors, nil
|
||||
}
|
||||
|
||||
func (a *App) prepareInviteGuestsToChannels(teamID string, guestsInvite *model.GuestsInvite, senderId string) (*model.User, *model.Team, []*model.Channel, *model.AppError) {
|
||||
func (a *App) prepareInviteGuestsToChannels(rctx request.CTX, teamID string, guestsInvite *model.GuestsInvite, senderId string) (*model.User, *model.Team, []*model.Channel, *model.AppError) {
|
||||
if err := guestsInvite.IsValid(); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
|
@ -1546,13 +1546,24 @@ func (a *App) prepareInviteGuestsToChannels(teamID string, guestsInvite *model.G
|
|||
}
|
||||
team := teamChanResult.Data
|
||||
|
||||
// Channels come straight from Store().Channel().GetChannelsByIds and
|
||||
// thus haven't traversed the App.GetChannel hydration seam. Hydrate
|
||||
// the action map explicitly so the policy check below can distinguish
|
||||
// a membership policy from a permission-only one.
|
||||
if appErr := a.HydrateChannelsPolicyActions(rctx, channels); appErr != nil {
|
||||
return nil, nil, nil, appErr
|
||||
}
|
||||
|
||||
for _, channel := range channels {
|
||||
if channel.TeamId != teamID {
|
||||
return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.channel_in_invalid_team.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Check if the channel has access control policy enforcement
|
||||
if channel.PolicyEnforced {
|
||||
// Reject guest invites only when the channel's policy controls
|
||||
// membership. Permission-only policies (e.g. file upload
|
||||
// restrictions) do not gate joins and so must not block guest
|
||||
// invites.
|
||||
if channel.HasMembershipPolicyAction() {
|
||||
return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.policy_enforced_channel.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
|
@ -1565,7 +1576,7 @@ func (a *App) InviteGuestsToChannelsGracefully(rctx request.CTX, teamID string,
|
|||
return nil, model.NewAppError("InviteGuestsToChannelsGracefully", "api.team.invite_members.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
user, team, channels, err := a.prepareInviteGuestsToChannels(teamID, guestsInvite, senderId)
|
||||
user, team, channels, err := a.prepareInviteGuestsToChannels(rctx, teamID, guestsInvite, senderId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1668,7 +1679,7 @@ func (a *App) InviteGuestsToChannels(rctx request.CTX, teamID string, guestsInvi
|
|||
return model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
user, team, channels, err := a.prepareInviteGuestsToChannels(teamID, guestsInvite, senderId)
|
||||
user, team, channels, err := a.prepareInviteGuestsToChannels(rctx, teamID, guestsInvite, senderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1972,42 +1972,78 @@ func TestInviteGuestsToChannelsWithPolicyEnforced(t *testing.T) {
|
|||
*cfg.ServiceSettings.EnableEmailInvitations = true
|
||||
})
|
||||
|
||||
// Create a private channel
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
t.Run("membership-policy channel is rejected", func(t *testing.T) {
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
// Create a policy with the same ID as the channel
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id, // Use the channel ID directly
|
||||
Name: "Test Channel Policy",
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{"view", "join_channel"},
|
||||
Expression: "user.attributes.program == \"test-program\"",
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id,
|
||||
Name: "Test Channel Policy",
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{model.AccessControlPolicyActionMembership},
|
||||
Expression: "user.attributes.program == \"test-program\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Save the channel policy
|
||||
channelPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelPolicy)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, channelPolicy)
|
||||
channelPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelPolicy)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, channelPolicy)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
|
||||
// Attempt to invite guests to the policy-enforced channel
|
||||
guestsInvite := &model.GuestsInvite{
|
||||
Emails: []string{"guest@example.com"},
|
||||
Channels: []string{channel.Id},
|
||||
Message: "test message",
|
||||
}
|
||||
guestsInvite := &model.GuestsInvite{
|
||||
Emails: []string{"guest@example.com"},
|
||||
Channels: []string{channel.Id},
|
||||
Message: "test message",
|
||||
}
|
||||
|
||||
// Call the function we want to test
|
||||
_, _, _, appErr := th.App.prepareInviteGuestsToChannels(th.BasicTeam.Id, guestsInvite, th.BasicUser.Id)
|
||||
_, _, _, appErr := th.App.prepareInviteGuestsToChannels(th.Context, th.BasicTeam.Id, guestsInvite, th.BasicUser.Id)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "api.team.invite_guests.policy_enforced_channel.app_error", appErr.Id)
|
||||
})
|
||||
|
||||
// Verify that the appropriate error is returned
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "api.team.invite_guests.policy_enforced_channel.app_error", appErr.Id)
|
||||
t.Run("permission-only-policy channel is NOT rejected (bug fix)", func(t *testing.T) {
|
||||
// Channel carrying ONLY a permission policy (e.g.
|
||||
// upload_file_attachment) reports policy_enforced=true but has no
|
||||
// membership action. The guest-invite gate now consults
|
||||
// PolicyActions[membership] specifically and must accept the
|
||||
// invite — failing this assertion means the bug has regressed.
|
||||
channel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
channelPolicy := &model.AccessControlPolicy{
|
||||
Type: model.AccessControlPolicyTypeChannel,
|
||||
ID: channel.Id,
|
||||
Name: "Permission Only Policy",
|
||||
Revision: 1,
|
||||
Version: model.AccessControlPolicyVersionV0_2,
|
||||
Rules: []model.AccessControlPolicyRule{
|
||||
{
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Expression: "user.attributes.program == \"test-program\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelPolicy)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channel.Id)
|
||||
})
|
||||
|
||||
guestsInvite := &model.GuestsInvite{
|
||||
Emails: []string{"guest@example.com"},
|
||||
Channels: []string{channel.Id},
|
||||
Message: "test message",
|
||||
}
|
||||
|
||||
_, _, _, appErr := th.App.prepareInviteGuestsToChannels(th.Context, th.BasicTeam.Id, guestsInvite, th.BasicUser.Id)
|
||||
require.Nil(t, appErr, "guest invite must succeed for a permission-only-policy channel")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTeamSendEvents(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const (
|
|||
broadcastBurnOnRead = "burn_on_read"
|
||||
broadcastBurnOnReadReaction = "burn_on_read_reaction"
|
||||
broadcastAbacFiles = "abac_files"
|
||||
broadcastOnlyChannelAdmins = "only_channel_admins"
|
||||
)
|
||||
|
||||
func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
|
||||
|
|
@ -37,6 +38,7 @@ func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
|
|||
broadcastBurnOnRead: &burnOnReadBroadcastHook{},
|
||||
broadcastBurnOnReadReaction: &burnOnReadReactionBroadcastHook{},
|
||||
broadcastAbacFiles: &abacFilesBroadcastHook{},
|
||||
broadcastOnlyChannelAdmins: &onlyChannelAdminsBroadcastHook{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,6 +507,34 @@ func (h *abacFilesBroadcastHook) stripFilesFromMessage(msg *platform.HookedWebSo
|
|||
return nil
|
||||
}
|
||||
|
||||
// onlyChannelAdminsBroadcastHook narrows a channel-scoped broadcast to the
|
||||
// channel-admin subset of the channel's members. The hook arg
|
||||
// `channel_admin_user_ids` is the precomputed list of admin user ids at publish
|
||||
// time; recipients not in that set have the event rejected.
|
||||
//
|
||||
// Pair with `Broadcast{ChannelId: channelId}` so the platform's existing
|
||||
// channel-member fan-out is the outer bound and this hook simply filters
|
||||
// non-admin members out.
|
||||
type onlyChannelAdminsBroadcastHook struct{}
|
||||
|
||||
func useOnlyChannelAdminsHook(message *model.WebSocketEvent, channelAdminUserIds []string) {
|
||||
message.GetBroadcast().AddHook(broadcastOnlyChannelAdmins, map[string]any{
|
||||
"channel_admin_user_ids": model.StringArray(channelAdminUserIds),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *onlyChannelAdminsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
||||
adminUserIDs, err := getTypedArg[model.StringArray](args, "channel_admin_user_ids")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid channel_admin_user_ids value passed to onlyChannelAdminsBroadcastHook")
|
||||
}
|
||||
|
||||
if !slices.Contains(adminUserIDs, webConn.UserId) {
|
||||
msg.Event().Reject()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func incrementWebsocketCounter(wc *platform.WebConn) {
|
||||
if wc.Platform.Metrics() == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -367,6 +367,12 @@ func (a *App) CreateWebhookPost(rctx request.CTX, userID string, channel *model.
|
|||
model.PostPropsOverrideUsername,
|
||||
model.PostPropsFromWebhook:
|
||||
// Do nothing
|
||||
case model.PostPropsMmBlocksActions:
|
||||
// Webhook payloads are user-controlled even when the
|
||||
// webhook is bound to a bot user, so the bot-author
|
||||
// signal in CreatePost's strip rule cannot distinguish
|
||||
// them. Drop here so mm_blocks_actions never reaches
|
||||
// the post object.
|
||||
default:
|
||||
post.AddProp(key, val)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,3 +365,13 @@ channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_
|
|||
channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql
|
||||
channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql
|
||||
channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql
|
||||
channels/db/migrations/postgres/000185_create_channel_guards.down.sql
|
||||
channels/db/migrations/postgres/000185_create_channel_guards.up.sql
|
||||
channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.down.sql
|
||||
channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.up.sql
|
||||
channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.down.sql
|
||||
channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.up.sql
|
||||
channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.down.sql
|
||||
channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.up.sql
|
||||
channels/db/migrations/postgres/000189_add_admin_to_permission_level.down.sql
|
||||
channels/db/migrations/postgres/000189_add_admin_to_permission_level.up.sql
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS ChannelGuards;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE IF NOT EXISTS ChannelGuards (
|
||||
ChannelId varchar(26) NOT NULL,
|
||||
PluginId varchar(190) NOT NULL,
|
||||
CreatedAt bigint NOT NULL,
|
||||
PRIMARY KEY (ChannelId, PluginId)
|
||||
);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- morph:nontransactional
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_channel_guards_plugin_id;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- morph:nontransactional
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_channel_guards_plugin_id ON ChannelGuards(PluginId);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue