Merge remote-tracking branch 'origin/master' into MM-68649

This commit is contained in:
Devin Binnie 2026-05-25 10:25:22 -04:00
commit 7022e5fb99
404 changed files with 45947 additions and 10959 deletions

View file

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

View file

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

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- master
- release-*
paths:
- server/build/Dockerfile.buildenv
- server/build/Dockerfile.buildenv-fips
@ -57,7 +58,7 @@ jobs:
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
provenance: false
@ -103,7 +104,7 @@ jobs:
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
provenance: false

View file

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

View file

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

View file

@ -11240,7 +11240,7 @@ SOFTWARE.
## react-intl
This product contains 'react-intl' by Eric Ferraiuolo.
This product contains 'react-intl'.
Internationalize React apps. This library provides React components and an API to format dates, numbers, and strings, including pluralization and handling translations.
@ -11255,7 +11255,7 @@ Internationalize React apps. This library provides React components and an API t
## react-intl
This product contains 'react-intl' by Eric Ferraiuolo.
This product contains 'react-intl'.
Internationalize React apps. This library provides React components and an API to format dates, numbers, and strings, including pluralization and handling translations.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -550,22 +550,60 @@
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/file/test:
post:
tags:
- system
summary: Test the configured file storage backend
description: >
Send a test to validate that the server can connect to the configured
file storage backend (Amazon S3 or Azure Blob Storage). Optionally
provide a configuration in the request body to test. If no valid
configuration is present in the request body the current server
configuration will be tested.
##### Permissions
Must have `manage_system` permission.
__Minimum server version__: 11.10
operationId: TestFileStoreConnection
requestBody:
description: Mattermost configuration
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/Config"
responses:
"200":
description: File storage test successful
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/file/s3_test:
post:
tags:
- system
summary: Test AWS S3 connection
description: >
Send a test to validate if can connect to AWS S3. Optionally provide a
configuration in the request body to test. If no valid configuration is
present in the request body the current server configuration will be
tested.
Deprecated alias for `/api/v4/file/test` kept for backwards
compatibility. New callers should use `/api/v4/file/test`, which is
backend-agnostic.
##### Permissions
Must have `manage_system` permission.
__Minimum server version__: 4.8
deprecated: true
operationId: TestS3Connection
requestBody:
description: Mattermost configuration
@ -581,6 +619,8 @@
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"403":
$ref: "#/components/responses/Forbidden"
"500":

View file

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

View file

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

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @not_cloud @system_console
describe('Environment - File Storage (Azure Blob Storage)', () => {
before(() => {
cy.shouldNotRunOnCloudEdition();
cy.apiAdminLogin();
});
beforeEach(() => {
cy.visit('/admin_console/environment/file_storage');
cy.findByTestId('FileSettings.DriverNamedropdown').should('be.visible');
});
it('shows the Azure Blob Storage option in the File Storage System dropdown', () => {
// * Verify the Azure option is present alongside Local and S3
cy.findByTestId('FileSettings.DriverNamedropdown').
find('option[value="azureblob"]').
should('have.text', 'Azure Blob Storage');
});
it('enables Azure-only fields and disables S3-only fields when Azure is selected', () => {
// # Select the Azure driver
cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob');
// * Azure fields are enabled
cy.findByTestId('FileSettings.AzureStorageAccountinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureContainerinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzurePathPrefixinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureEndpointinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureRequestTimeoutMillisecondsnumber').should('not.be.disabled');
// * S3 fields are disabled when the driver is not S3
cy.findByTestId('FileSettings.AmazonS3Bucketinput').should('be.disabled');
cy.findByTestId('FileSettings.AmazonS3AccessKeyIdinput').should('be.disabled');
// * Local directory is also disabled
cy.findByTestId('FileSettings.Directoryinput').should('be.disabled');
});
it('hides Azure-only fields when the S3 driver is selected', () => {
// # Select the S3 driver
cy.findByTestId('FileSettings.DriverNamedropdown').select('amazons3');
// * Azure fields are not rendered when the driver is not Azure
cy.findByTestId('FileSettings.AzureStorageAccountinput').should('not.exist');
cy.findByTestId('FileSettings.AzureContainerinput').should('not.exist');
cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.exist');
});
it('exposes the backend-agnostic Test Connection button when Azure is selected', () => {
// # Select the Azure driver
cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob');
// * The renamed button is rendered and is no longer S3-named
cy.get('#TestFileStoreConnection').scrollIntoView().should('be.visible');
cy.get('#TestFileStoreConnection').findByText('Test Connection').should('exist');
cy.get('#TestS3Connection').should('not.exist');
});
});

View file

@ -243,7 +243,7 @@ describe('Environment', () => {
// # Click Save button to save the settings
cy.get('#saveSetting').click().wait(TIMEOUTS.ONE_SEC);
cy.get('#TestS3Connection').scrollIntoView().should('be.visible').within(() => {
cy.get('#TestFileStoreConnection').scrollIntoView().should('be.visible').within(() => {
cy.findByText('Test Connection').should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
waitForAlert('Connection unsuccessful: S3 Bucket is required');
});
@ -254,7 +254,7 @@ describe('Environment', () => {
// # Click Save button to save the settings
cy.get('#saveSetting').click().wait(TIMEOUTS.ONE_SEC);
cy.get('#TestS3Connection').scrollIntoView().should('be.visible').within(() => {
cy.get('#TestFileStoreConnection').scrollIntoView().should('be.visible').within(() => {
cy.findByText('Test Connection').should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
waitForAlert('Connection unsuccessful: Unable to authenticate against the file storage backend. Verify your credentials and authentication settings.');
});

View file

@ -779,7 +779,6 @@ const defaultServerConfig: AdminConfig = {
AttributeBasedAccessControl: true,
PermissionPolicies: true,
ContentFlagging: true,
InteractiveDialogAppsForm: true,
EnableMattermostEntry: true,
MobileSSOCodeExchange: false,
AutoTranslation: true,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
1.26.2
1.26.3

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,7 +47,9 @@ func (api *API) InitSystem() {
api.BaseRoutes.APIRoot.Handle("/notifications/test", api.APISessionRequired(testNotifications)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/email/test", api.APISessionRequired(testEmail)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/site_url/test", api.APISessionRequired(testSiteURL)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/file/s3_test", api.APISessionRequired(testS3)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/file/test", api.APISessionRequired(testFileStore)).Methods(http.MethodPost)
// Deprecated: use /file/test instead. Kept as a thin compatibility wrapper.
api.BaseRoutes.APIRoot.Handle("/file/s3_test", api.APISessionRequired(testFileStore)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/database/recycle", api.APISessionRequired(databaseRecycle)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/caches/invalidate", api.APISessionRequired(invalidateCaches)).Methods(http.MethodPost)
@ -560,7 +562,7 @@ func getSupportedTimezones(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func testS3(c *Context, w http.ResponseWriter, r *http.Request) {
func testFileStore(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil {
@ -570,28 +572,51 @@ func testS3(c *Context, w http.ResponseWriter, r *http.Request) {
cfg = c.App.Config()
}
if checkHasNilFields(&cfg.FileSettings) {
c.Err = model.NewAppError("testS3", "api.file.test_connection_s3_settings_nil.app_error", nil, "", http.StatusBadRequest)
return
}
// PermissionTestS3 is kept for backwards compatibility -- it was named
// after the only supported test endpoint at the time it was introduced.
// The new /file/test endpoint is backend-agnostic but reuses the same
// permission to avoid a role migration.
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionTestS3) {
c.SetPermissionError(model.PermissionTestS3)
return
}
appErr := c.App.CheckMandatoryS3Fields(&cfg.FileSettings)
if appErr != nil {
c.Err = appErr
if checkHasNilFields(&cfg.FileSettings) {
c.Err = model.NewAppError("testFileStore", "api.file.test_connection_settings_nil.app_error", nil, "", http.StatusBadRequest)
return
}
if *cfg.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting {
cfg.FileSettings.AmazonS3SecretAccessKey = c.App.Config().FileSettings.AmazonS3SecretAccessKey
// Validate mandatory fields per driver. TestFileStoreConnectionWithConfig
// will catch missing fields by failing to construct the backend, but a
// dedicated validation step lets us surface a clearer error.
driver := ""
if cfg.FileSettings.DriverName != nil {
driver = *cfg.FileSettings.DriverName
}
switch driver {
case model.ImageDriverLocal:
// Local driver has no mandatory fields beyond the directory, which has a default.
case model.ImageDriverS3:
if appErr := c.App.CheckMandatoryS3Fields(&cfg.FileSettings); appErr != nil {
c.Err = appErr
return
}
case model.ImageDriverAzure:
if appErr := c.App.CheckMandatoryAzureFields(&cfg.FileSettings); appErr != nil {
c.Err = appErr
return
}
default:
c.Err = model.NewAppError("testFileStore", "api.file.test_connection_unsupported_driver.app_error", map[string]any{"Driver": driver}, "", http.StatusBadRequest)
return
}
appErr = c.App.TestFileStoreConnectionWithConfig(&cfg.FileSettings)
if appErr != nil {
// A client editing the admin UI and clicking Test Connection without
// re-entering the secret would send the FakeSetting placeholder back, so we
// need to desanitize this first.
config.Desanitize(c.App.Config(), cfg)
if appErr := c.App.TestFileStoreConnectionWithConfig(&cfg.FileSettings); appErr != nil {
c.Err = appErr
return
}

View file

@ -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)
@ -698,9 +725,105 @@ func TestS3TestConnection(t *testing.T) {
config.FileSettings = model.FileSettings{}
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &config)
require.Error(t, err)
CheckErrorID(t, err, "api.file.test_connection_s3_settings_nil.app_error")
CheckErrorID(t, err, "api.file.test_connection_settings_nil.app_error")
CheckBadRequestStatus(t, resp)
})
t.Run("desanitizes FakeSetting using running config", func(t *testing.T) {
// Seed the running config with valid Minio credentials so the
// running config's AmazonS3SecretAccessKey is the real secret.
th.App.UpdateConfig(func(c *model.Config) {
c.FileSettings.DriverName = model.NewPointer(model.ImageDriverS3)
c.FileSettings.AmazonS3AccessKeyId = model.NewPointer(model.MinioAccessKey)
c.FileSettings.AmazonS3SecretAccessKey = model.NewPointer(model.MinioSecretKey)
c.FileSettings.AmazonS3Bucket = model.NewPointer(model.MinioBucket)
c.FileSettings.AmazonS3Endpoint = model.NewPointer(s3Endpoint)
c.FileSettings.AmazonS3Region = model.NewPointer("us-east-1")
c.FileSettings.AmazonS3PathPrefix = model.NewPointer("")
c.FileSettings.AmazonS3SSL = model.NewPointer(false)
})
// Build a request body that mirrors what the System Console sends
// after the admin clicks Test Connection without re-entering the
// secret: every field present, but the secret slot is the
// FakeSetting placeholder.
body := model.Config{FileSettings: model.FileSettings{}}
body.FileSettings.SetDefaults(false)
body.FileSettings.DriverName = model.NewPointer(model.ImageDriverS3)
body.FileSettings.AmazonS3AccessKeyId = model.NewPointer(model.MinioAccessKey)
body.FileSettings.AmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
body.FileSettings.AmazonS3Bucket = model.NewPointer(model.MinioBucket)
body.FileSettings.AmazonS3Endpoint = model.NewPointer(s3Endpoint)
body.FileSettings.AmazonS3Region = model.NewPointer("us-east-1")
body.FileSettings.AmazonS3PathPrefix = model.NewPointer("")
body.FileSettings.AmazonS3SSL = model.NewPointer(false)
// If desanitize is not running, the server tests with the literal
// "********" string as the secret and Minio returns a 403 auth
// error. A 200 here proves the placeholder was swapped for the
// real running-config value before the connection test.
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &body)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("unsupported driver", func(t *testing.T) {
unsupported := model.FileSettings{}
unsupported.SetDefaults(false)
unsupported.DriverName = model.NewPointer("bogus")
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &model.Config{FileSettings: unsupported})
require.Error(t, err)
CheckErrorID(t, err, "api.file.test_connection_unsupported_driver.app_error")
CheckBadRequestStatus(t, resp)
})
t.Run("empty driver name", func(t *testing.T) {
empty := model.FileSettings{}
empty.SetDefaults(false)
empty.DriverName = model.NewPointer("")
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &model.Config{FileSettings: empty})
require.Error(t, err)
CheckErrorID(t, err, "api.file.test_connection_unsupported_driver.app_error")
CheckBadRequestStatus(t, resp)
})
t.Run("azure missing mandatory fields", func(t *testing.T) {
// CheckMandatoryAzureFields rejects requests that don't carry an
// Azure storage account, container, and access key. Each missing
// field path must produce the same 400 with the dedicated error
// ID so admins get a clear signal in the System Console toast.
base := model.FileSettings{}
base.SetDefaults(false)
base.DriverName = model.NewPointer(model.ImageDriverAzure)
cases := []struct {
name string
mut func(*model.FileSettings)
}{
{"missing storage account", func(fs *model.FileSettings) {
fs.AzureContainer = model.NewPointer("mattermost")
fs.AzureAccessKey = model.NewPointer("secret")
}},
{"missing container", func(fs *model.FileSettings) {
fs.AzureStorageAccount = model.NewPointer("acmemattermost")
fs.AzureAccessKey = model.NewPointer("secret")
}},
{"missing access key", func(fs *model.FileSettings) {
fs.AzureStorageAccount = model.NewPointer("acmemattermost")
fs.AzureContainer = model.NewPointer("mattermost")
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fs := base
tc.mut(&fs)
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &model.Config{FileSettings: fs})
require.Error(t, err)
CheckErrorID(t, err, "api.admin.test_azure.missing_azure_field")
CheckBadRequestStatus(t, resp)
})
}
})
}
func TestSupportedTimezones(t *testing.T) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}
@ -646,9 +733,9 @@ func (a *App) HasPermissionToFileAction(rctx request.CTX, userID string, roles s
var subject *model.Subject
var appErr *model.AppError
if rctx.Session().UserId == userID {
subject, appErr = a.BuildAccessControlSubjectForSession(rctx)
subject, appErr = a.BuildAccessControlSubjectForSession(rctx, channelID)
} else {
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",

View file

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -3278,7 +3278,8 @@ func TestScrubPost(t *testing.T) {
// Verify non-content fields are preserved
require.Equal(t, postId, post.Id)
require.Equal(t, createAt, post.CreateAt)
require.Equal(t, updateAt, post.UpdateAt)
// scrubPost refreshes UpdateAt when scrubbing content.
require.GreaterOrEqual(t, post.UpdateAt, updateAt)
require.Equal(t, editAt, post.EditAt)
require.Equal(t, userId, post.UserId)
require.Equal(t, channelId, post.ChannelId)

View file

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

View file

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

View file

@ -60,16 +60,33 @@ func (a *App) ExportFileBackend() filestore.FileBackend {
}
func (a *App) CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppError {
var fileBackendSettings filestore.FileBackendSettings
bucket := settings.AmazonS3Bucket
if a.License().IsCloud() && a.Config().FeatureFlags.CloudDedicatedExportUI && a.Config().FileSettings.DedicatedExportStore != nil && *a.Config().FileSettings.DedicatedExportStore {
fileBackendSettings = filestore.NewExportFileBackendSettingsFromConfig(settings, false, false)
} else {
fileBackendSettings = filestore.NewFileBackendSettingsFromConfig(settings, false, false)
bucket = settings.ExportAmazonS3Bucket
}
if bucket == nil || *bucket == "" {
return model.NewAppError("CheckMandatoryS3Fields", "api.admin.test_s3.missing_s3_bucket", nil, "", http.StatusBadRequest)
}
return nil
}
err := fileBackendSettings.CheckMandatoryS3Fields()
if err != nil {
return model.NewAppError("CheckMandatoryS3Fields", "api.admin.test_s3.missing_s3_bucket", nil, "", http.StatusBadRequest).Wrap(err)
func (a *App) CheckMandatoryAzureFields(settings *model.FileSettings) *model.AppError {
storageAccount := settings.AzureStorageAccount
accessKey := settings.AzureAccessKey
container := settings.AzureContainer
if a.License().IsCloud() && a.Config().FeatureFlags.CloudDedicatedExportUI && a.Config().FileSettings.DedicatedExportStore != nil && *a.Config().FileSettings.DedicatedExportStore {
storageAccount = settings.ExportAzureStorageAccount
accessKey = settings.ExportAzureAccessKey
container = settings.ExportAzureContainer
}
if storageAccount == nil || *storageAccount == "" {
return model.NewAppError("CheckMandatoryAzureFields", "api.admin.test_azure.missing_azure_field", nil, "missing azure storage account setting", http.StatusBadRequest)
}
if container == nil || *container == "" {
return model.NewAppError("CheckMandatoryAzureFields", "api.admin.test_azure.missing_azure_field", nil, "missing azure container setting", http.StatusBadRequest)
}
if accessKey == nil || *accessKey == "" {
return model.NewAppError("CheckMandatoryAzureFields", "api.admin.test_azure.missing_azure_field", nil, "missing azure access key setting", http.StatusBadRequest)
}
return nil
}
@ -1528,7 +1545,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,24 +1591,34 @@ 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
}
var subject *model.Subject
var appErr *model.AppError
if rctx.Session().UserId == userID {
subject, appErr = a.BuildAccessControlSubjectForSession(rctx)
subject, appErr = a.BuildAccessControlSubjectForSession(rctx, "")
} else {
user, err := a.GetUser(userID)
if err != nil {
@ -1592,18 +1626,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
@ -1619,8 +1687,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,
})

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

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

View file

@ -2256,6 +2256,34 @@ func (a *App) importDirectChannel(rctx request.CTX, data *imports.DirectChannelI
if existing.LastViewedAt > m.LastViewedAt {
continue
}
} else {
// The channel pre-existed (either from a concurrent worker that committed the Channels
// row but had not finished its SaveMember loop yet, an earlier import that crashed
// mid-loop, or a GM whose membership has since drifted) without this participant.
// Insert the ChannelMembers row first so UpdateMultipleMembers below has something
// to UPDATE — otherwise it returns ErrNotFound and aborts the import.
toInsert := &model.ChannelMember{
UserId: u.Id,
ChannelId: channel.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeUser: !u.IsGuest(),
SchemeGuest: u.IsGuest(),
}
if _, nErr := a.Srv().Store().Channel().SaveMember(rctx, toInsert); nErr != nil {
var cErr *store.ErrConflict
// A concurrent importer may have inserted the row in the meantime — the row
// exists, which is all we need before the UPDATE.
if !errors.As(nErr, &cErr) {
return model.NewAppError("BulkImport", "app.import.import_direct_channel.save_member.error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
} else {
if histErr := a.Srv().Store().ChannelMemberHistory().LogJoinEvent(u.Id, channel.Id, model.GetMillis()); histErr != nil {
rctx.Logger().Warn("Failed to log channel member history join event during import",
mlog.String("user_id", u.Id),
mlog.String("channel_id", channel.Id),
mlog.Err(histErr))
}
}
}
m.UserId = u.Id
m.ChannelId = channel.Id

View file

@ -4195,6 +4195,99 @@ func TestImportImportDirectChannel(t *testing.T) {
}
}
})
// Regression test for MM-68736: when the GM hash already exists in Channels but one
// of the import's participants is missing from ChannelMembers, the importer used to
// call UpdateMultipleMembers for the missing user, which UPDATEs zero rows, then
// SELECTs the row back, gets sql.ErrNoRows, and fails the import with
// "ChannelMember not found". The fix inserts the missing membership row first.
t.Run("GROUP channel with pre-existing partial membership recovers all participants", func(t *testing.T) {
userA := th.CreateUser(t)
userB := th.CreateUser(t)
userC := th.CreateUser(t)
userIDs := []string{userA.Id, userB.Id, userC.Id}
// Pre-create the GM through the regular path so the Channels row and full
// membership exist, then drop userC at the store level to simulate the
// drifted/partial state described in the ticket.
gm, appErr := th.App.createGroupChannel(th.Context, userIDs, userA.Id)
require.Nil(t, appErr)
require.NoError(t, th.App.Srv().Store().Channel().RemoveMember(th.Context, gm.Id, userC.Id))
preMembers, appErr := th.App.GetChannelMembersPage(th.Context, gm.Id, 0, 100)
require.Nil(t, appErr)
require.Len(t, preMembers, 2, "precondition: userC should not be a member before import")
lastView := model.GetMillis()
data := imports.DirectChannelImportData{
Participants: []*imports.DirectChannelMemberImportData{
{Username: model.NewPointer(userA.Username)},
{Username: model.NewPointer(userB.Username)},
{
Username: model.NewPointer(userC.Username),
LastViewedAt: model.NewPointer(lastView),
},
},
}
appErr = th.App.importDirectChannel(th.Context, &data, false)
require.Nil(t, appErr, "import must not fail on pre-existing GM with partial membership")
postMembers, appErr := th.App.GetChannelMembersPage(th.Context, gm.Id, 0, 100)
require.Nil(t, appErr)
require.Len(t, postMembers, 3, "userC should have been re-added by the import")
var restored *model.ChannelMember
for i := range postMembers {
if postMembers[i].UserId == userC.Id {
restored = &postMembers[i]
break
}
}
require.NotNil(t, restored, "userC must be present in the channel members after the import")
// LastViewedAt from the import data should have been applied via the
// subsequent UpdateMultipleMembers UPDATE on the newly-inserted row.
require.Equal(t, lastView, restored.LastViewedAt)
})
// Companion to the regression above: re-running the import after the channel is
// fully populated should remain idempotent and not fall through any insert path.
t.Run("GROUP channel re-import with full membership stays idempotent", func(t *testing.T) {
userA := th.CreateUser(t)
userB := th.CreateUser(t)
userC := th.CreateUser(t)
data := imports.DirectChannelImportData{
Participants: []*imports.DirectChannelMemberImportData{
{Username: model.NewPointer(userA.Username)},
{Username: model.NewPointer(userB.Username)},
{Username: model.NewPointer(userC.Username)},
},
}
appErr := th.App.importDirectChannel(th.Context, &data, false)
require.Nil(t, appErr)
gm, appErr := th.App.GetGroupChannel(th.Context, []string{userA.Id, userB.Id, userC.Id})
require.Nil(t, appErr)
// Capture the cutoff so we can confirm no membership-change rows are
// logged by the second import. The fix only calls LogJoinEvent after a
// successful SaveMember insert, so an empty result here proves the
// missing-member branch was not entered for any participant.
beforeSecondRun := model.GetMillis()
appErr = th.App.importDirectChannel(th.Context, &data, false)
require.Nil(t, appErr)
members, appErr := th.App.GetChannelMembersPage(th.Context, gm.Id, 0, 100)
require.Nil(t, appErr)
require.Len(t, members, 3)
changes, nErr := th.App.Srv().Store().ChannelMemberHistory().GetMembershipChanges(gm.Id, beforeSecondRun, 100)
require.NoError(t, nErr)
require.Empty(t, changes, "second import must not log any membership changes when all participants are already members")
})
}
func TestImportImportDirectPost(t *testing.T) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more