diff --git a/.github/scripts/check_config_changes_ci.py b/.github/scripts/check_config_changes_ci.py index be00bc7fef8..ad3f3ce3e15 100644 --- a/.github/scripts/check_config_changes_ci.py +++ b/.github/scripts/check_config_changes_ci.py @@ -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: diff --git a/.github/workflows/build-opensearch-image.yml b/.github/workflows/build-opensearch-image.yml deleted file mode 100644 index 21f7ff4e29e..00000000000 --- a/.github/workflows/build-opensearch-image.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index f6e6cf138bb..c7537dad254 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -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: diff --git a/.github/workflows/server-test-template.yml b/.github/workflows/server-test-template.yml index 46e5e797888..a8168fa2f47 100644 --- a/.github/workflows/server-test-template.yml +++ b/.github/workflows/server-test-template.yml @@ -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 diff --git a/api/server/go.mod b/api/server/go.mod index efbd23ebc6a..a5da0c1ecec 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -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 ) diff --git a/api/server/go.sum b/api/server/go.sum index 641702aadd5..d6ebaf5dc76 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -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= diff --git a/api/server/main.go b/api/server/main.go index bfcf5edf562..0e35c1e2552 100644 --- a/api/server/main.go +++ b/api/server/main.go @@ -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 { diff --git a/api/v4/source/access_control.yaml b/api/v4/source/access_control.yaml index 02402b81703..3bcfd84b757 100644 --- a/api/v4/source/access_control.yaml +++ b/api/v4/source/access_control.yaml @@ -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: diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml index bcf74c0fdb1..6e3c2ec2536 100644 --- a/api/v4/source/channels.yaml +++ b/api/v4/source/channels.yaml @@ -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: diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index ba31ba5325f..6f40b0de1bd 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -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: diff --git a/api/v4/source/properties.yaml b/api/v4/source/properties.yaml index 4984dfc399d..1eab6240015 100644 --- a/api/v4/source/properties.yaml +++ b/api/v4/source/properties.yaml @@ -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. diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/saml/saml_metadata_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/saml/saml_metadata_spec.ts index 324a21e3593..d10e4200913 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/saml/saml_metadata_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/saml/saml_metadata_spec.ts @@ -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'); + }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_recently_used_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_recently_used_spec.js index 2899f5364d3..cae4c1c07a2 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_recently_used_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_recently_used_spec.js @@ -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'; } diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index b4448dd89b9..8e965d4d195 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -779,7 +779,6 @@ const defaultServerConfig: AdminConfig = { AttributeBasedAccessControl: true, PermissionPolicies: true, ContentFlagging: true, - InteractiveDialogAppsForm: true, EnableMattermostEntry: true, MobileSSOCodeExchange: false, AutoTranslation: true, diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_attributes/system_properties.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_attributes/system_properties.ts index 5af51a245f4..c65b2f673b9 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_attributes/system_properties.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/system_attributes/system_properties.ts @@ -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); } diff --git a/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts index 17814a7a9fc..bcc48151c56 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts @@ -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[2]); // Create channel linked field diff --git a/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts new file mode 100644 index 00000000000..cafda1f1a4f --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_settings/channel_perm_rules_v0_4.spec.ts @@ -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(); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/image_assets/webpack_static_assets.spec.ts b/e2e-tests/playwright/specs/functional/channels/image_assets/webpack_static_assets.spec.ts new file mode 100644 index 00000000000..b93bdaa009c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/image_assets/webpack_static_assets.spec.ts @@ -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 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 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 elements found:\n${brokenImages.join('\n')}`).toHaveLength(0); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/pdf_preview/pdf_preview.spec.ts b/e2e-tests/playwright/specs/functional/channels/pdf_preview/pdf_preview.spec.ts new file mode 100644 index 00000000000..9337d4ce963 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/pdf_preview/pdf_preview.spec.ts @@ -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); +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_form.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_form.spec.ts index afd596a2e52..4d036fb33b1 100644 --- a/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_form.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_form.spec.ts @@ -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}) => { diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_save.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_save.spec.ts index 2db2b1f677b..4b77f8d01d8 100644 --- a/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_save.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/abac/policies/permission_policies_create_save.spec.ts @@ -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); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/policy_management/edit_action_navigation.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/policy_management/edit_action_navigation.spec.ts new file mode 100644 index 00000000000..84d827cd85e --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/policy_management/edit_action_navigation.spec.ts @@ -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/ + */ + 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}`); + }); +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts index 0d4dd3e9d03..af2c9577386 100644 --- a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts @@ -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); diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts index 32e1a8ceb03..67155b5764d 100644 --- a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts @@ -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[2]); } diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/global_classification_banner.spec.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/global_classification_banner.spec.ts index 0d1a3cd441e..77162454db1 100644 --- a/e2e-tests/playwright/specs/functional/system_console/site_configuration/global_classification_banner.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/global_classification_banner.spec.ts @@ -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(); diff --git a/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes.spec.ts b/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes.spec.ts index a580d14a450..3153c8f7cfa 100644 --- a/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes.spec.ts @@ -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_" 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. diff --git a/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes_display_name.spec.ts b/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes_display_name.spec.ts index ca5eccdaacb..0e4a2bbf482 100644 --- a/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes_display_name.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes_display_name.spec.ts @@ -35,6 +35,9 @@ async function createAdminClient(): Promise<{adminClient: Client4; adminUser: Ad } async function setupTest(pw: PlaywrightExtended): Promise { + 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(); } diff --git a/server/.go-version b/server/.go-version index c7c3f3333e1..f8f73814096 100644 --- a/server/.go-version +++ b/server/.go-version @@ -1 +1 @@ -1.26.2 +1.26.3 diff --git a/server/Makefile b/server/Makefile index ea33ed33a93..3ec286eaa70 100644 --- a/server/Makefile +++ b/server/Makefile @@ -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) diff --git a/server/build/Dockerfile.buildenv b/server/build/Dockerfile.buildenv index dde37dbddc6..54583233d27 100644 --- a/server/build/Dockerfile.buildenv +++ b/server/build/Dockerfile.buildenv @@ -1,4 +1,4 @@ -FROM mattermost/golang-bullseye:1.26.2@sha256:fc2cc64f035c74f14b0e5921971bcda4b6dbd281430d3cbfb0bf539ebb1bacd5 +FROM mattermost/golang-bullseye:1.26.3@sha256:3ae112b7dc291665c5582b9d768fc2adb4cdc3afbbd3fc82e03a10cd711e1a60 ARG NODE_VERSION=20.11.1 RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg diff --git a/server/build/Dockerfile.buildenv-fips b/server/build/Dockerfile.buildenv-fips index 17f8ceadfff..9a986d75132 100644 --- a/server/build/Dockerfile.buildenv-fips +++ b/server/build/Dockerfile.buildenv-fips @@ -1,4 +1,4 @@ -FROM cgr.dev/mattermost.com/go-msft-fips:1.26.2-dev@sha256:cdd5bd448fcf61654893846572f006aff7349aa21027de590fc2bd020f8126f1 +FROM cgr.dev/mattermost.com/go-msft-fips:1.26.3-dev@sha256:48ab99fede7fb33e132a0636072971e1ec4a69520865bfa1e4b517ee9cfdef34 ARG NODE_VERSION=20.11.1 RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec diff --git a/server/build/Dockerfile.opensearch b/server/build/Dockerfile.opensearch index b0363e24bb1..5bcd21ac9d1 100644 --- a/server/build/Dockerfile.opensearch +++ b/server/build/Dockerfile.opensearch @@ -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 diff --git a/server/build/docker-compose.common.yml b/server/build/docker-compose.common.yml index eb91fcebcfb..b92631bf534 100644 --- a/server/build/docker-compose.common.yml +++ b/server/build/docker-compose.common.yml @@ -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 diff --git a/server/channels/api4/access_control.go b/server/channels/api4/access_control.go index af0c87b23ac..6f971e2d0ed 100644 --- a/server/channels/api4/access_control.go +++ b/server/channels/api4/access_control.go @@ -32,6 +32,7 @@ func (api *API) InitAccessControlPolicy() { api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APISessionRequired(checkExpression)).Methods(http.MethodPost) api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APISessionRequired(testExpression)).Methods(http.MethodPost) + api.BaseRoutes.AccessControlPolicies.Handle("/cel/simulate_users", api.APISessionRequired(simulatePolicyForUsers)).Methods(http.MethodPost) api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APISessionRequired(validateExpressionAgainstRequester)).Methods(http.MethodPost) api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APISessionRequired(getFieldsAutocomplete)).Methods(http.MethodGet) api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APISessionRequired(convertToVisualAST)).Methods(http.MethodPost) @@ -57,6 +58,19 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques return } + // Channel-scope policies are always available, but a channel policy + // that carries a permission-rule action (upload_file_attachment, + // download_file_attachment) is gated behind the channel-level + // sub-flag — that's the toggle that exposes the Channel Settings → + // Permissions Policy tab on the frontend. Membership-only channel + // policies stay unaffected. Helper enforces the PermissionPolicies + // umbrella too, so a request slipping in with the sub-flag on but + // the umbrella off is also rejected here. + if policy.Type == model.AccessControlPolicyTypeChannel && policy.HasPermissionRuleAction() && !c.App.Config().FeatureFlags.IsChannelPermissionPoliciesEnabled() { + c.Err = model.NewAppError("createAccessControlPolicy", "api.access_control_policy.channel_permission_policies.feature_disabled", nil, "", http.StatusNotImplemented) + return + } + auditRec := c.MakeAuditRecord(model.AuditEventCreateAccessControlPolicy, model.AuditStatusFail) defer c.LogAuditRec(auditRec) model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &policy) @@ -276,10 +290,7 @@ func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) { hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) if !hasSystemPermission { - teamID := checkExpressionRequest.TeamId - hasTeamPermission := teamID != "" && model.IsValidId(teamID) && - c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) - if !hasTeamPermission { + if !teamAdminCELContextOK(c, channelId, checkExpressionRequest.TeamId) { if channelId == "" { c.SetPermissionError(model.PermissionManageSystem) return @@ -329,8 +340,7 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) { } hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) - hasTeamPermission := !hasSystemPermission && teamID != "" && - c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) + hasTeamPermission := !hasSystemPermission && teamAdminCELContextOK(c, channelId, teamID) if !hasSystemPermission && !hasTeamPermission { if channelId == "" { @@ -389,6 +399,189 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) { } } +// teamAdminCELContextOK reports whether the session may use the delegated +// team-admin shortcut for CEL tooling: valid team_id, ManageTeamAccessRules on +// that team, and when a channel_id is supplied it must resolve to a channel in +// that same team. Prevents pairing a team the admin manages with an unrelated +// channel solely to satisfy the channel branch of auth. +func teamAdminCELContextOK(c *Context, channelID, teamID string) bool { + if teamID == "" || !model.IsValidId(teamID) { + return false + } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) { + return false + } + if channelID == "" { + return true + } + if !model.IsValidId(channelID) { + return false + } + channel, appErr := c.App.GetChannel(c.AppContext, channelID) + if appErr != nil { + return false + } + return channel.TeamId == teamID +} + +// authorizeSimulatePolicy checks the caller's permission to simulate a +// policy and returns whether they have system-level access — used by +// the caller to scope SanitizeProfile. +// +// Authorization order: +// - system admin: always. +// - team admin: only when teamID is set AND any provided channelID +// resolves to a channel in that team. Without this guard a team +// admin could simulate a policy for any channel by pairing their +// team_id with a foreign channel_id; the cross-team check forces +// the auth to fall through to HasPermissionToChannel for any +// channel outside the admin's team. +// - channel admin: when channelID is set, via HasPermissionToChannel +// (which already covers the channel's actual team admins). +// +// On failure the function sets the appropriate permission error on `c` +// and returns ok=false. Callers MUST early-return when ok=false. +func authorizeSimulatePolicy(c *Context, channelID, teamID string) (hasSystemPermission bool, ok bool) { + hasSystemPermission = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) + if hasSystemPermission { + return true, true + } + + if teamAdminCELContextOK(c, channelID, teamID) { + return false, true + } + + if channelID == "" { + c.SetPermissionError(model.PermissionManageSystem) + return false, false + } + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelID, model.PermissionManageChannelAccessRules) + if !hasChannelPermission { + c.SetPermissionError(model.PermissionManageChannelAccessRules) + return false, false + } + return false, true +} + +// simulatePolicyForUsers runs the dual-lane PDP simulation against a draft +// policy (not persisted) plus any higher-scoped persisted permission +// policies, for an explicit set of user IDs (with optional per-user session +// attribute overrides). The response carries per-user, per-action +// ALLOW/DENY decisions plus blame attribution for any deny — used by the +// "Simulate access" picker UX in the System Console and Channel Settings. +// +// Permission gates: +// - System admins: full access. +// - Team admins (with PermissionManageTeamAccessRules on the team): when a +// team_id is present in the body and any provided channel_id resolves +// to a channel in that team. +// - Channel admins (with PermissionManageChannelAccessRules on the +// channel): when a channel_id is present in the body. +// +// Non-system admins may only simulate users who belong to the request's +// channel (when channel_id is set) or team (team-scoped simulation). +// The endpoint requires the PolicySimulation feature flag (which +// itself depends on the PermissionPolicies umbrella) and an +// Enterprise Advanced license. Returns 501 when ABAC is unavailable. +func simulatePolicyForUsers(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.IsPolicySimulationEnabled() { + c.Err = model.NewAppError("simulatePolicyForUsers", "api.access_control_policy.policy_simulation.feature_disabled", nil, "", http.StatusNotImplemented) + return + } + + var params model.PolicySimulationByUsersParams + if jsonErr := json.NewDecoder(r.Body).Decode(¶ms); jsonErr != nil { + c.SetInvalidParamWithErr("simulation", jsonErr) + return + } + + if params.Policy == nil { + c.SetInvalidParam("policy") + return + } + if len(params.Users) == 0 { + c.SetInvalidParam("users") + return + } + if params.ChannelID != "" && !model.IsValidId(params.ChannelID) { + c.SetInvalidParam("channel_id") + return + } + if params.TeamID != "" && !model.IsValidId(params.TeamID) { + c.SetInvalidParam("team_id") + return + } + switch params.EvaluationScope { + case "", model.PolicyEvaluationScopeThisRule, model.PolicyEvaluationScopeAll: + default: + c.SetInvalidParam("evaluation_scope") + return + } + + // Normalize the empty string up front to the default + if params.EvaluationScope == "" { + params.EvaluationScope = model.PolicyEvaluationScopeThisRule + } + + hasSystemPermission, ok := authorizeSimulatePolicy(c, params.ChannelID, params.TeamID) + if !ok { + return + } + + // Cross-team consistency check: when both IDs are provided, the + // channel must actually belong to the named team. authorizeSimulatePolicy + // covers this for the team-admin shortcut, but a system admin's auth + // short-circuit happens earlier so we re-check here for everyone. + // Mismatched IDs would otherwise let downstream user-scope validation + // run against the wrong team. We canonicalise params.TeamID from the + // channel rather than rejecting outright — the channel ID is the + // authoritative scope for a channel-policy simulation. + if params.ChannelID != "" && params.TeamID != "" { + channel, appErr := c.App.GetChannel(c.AppContext, params.ChannelID) + if appErr != nil { + c.Err = appErr + return + } + if channel.TeamId != params.TeamID { + c.SetInvalidParam("team_id") + return + } + params.TeamID = channel.TeamId + } + + if !hasSystemPermission { + if appErr := c.App.ValidatePolicySimulationUsersInScope(c.AppContext, params.TeamID, params.ChannelID, params.Users); appErr != nil { + c.Err = appErr + return + } + } + + resp, appErr := c.App.SimulateAccessControlPolicyForUsers(c.AppContext, params) + if appErr != nil { + c.Err = appErr + return + } + + for i := range resp.Results { + c.App.SanitizeProfile(resp.Results[i].User, hasSystemPermission) + } + + // Redact protected CPA attribute values for non-system-admin + // callers. Targets the user's actual attribute values shown in + // the Decision Details panel and per-leaf ActualValue strings. + c.App.RedactSimulationAttributesForCaller(c.AppContext, resp, hasSystemPermission) + + js, err := json.Marshal(resp) + if err != nil { + c.Err = model.NewAppError("simulatePolicyForUsers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *http.Request) { var request struct { Expression string `json:"expression"` @@ -415,9 +608,7 @@ func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *ht hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) if !hasSystemPermission { - hasTeamPermission := teamID != "" && - c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) - if !hasTeamPermission { + if !teamAdminCELContextOK(c, channelId, teamID) { if channelId == "" { c.SetPermissionError(model.PermissionManageSystem) return @@ -933,9 +1124,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) { hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) if !hasSystemPermission { - hasTeamPermission := teamID != "" && model.IsValidId(teamID) && - c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) - if !hasTeamPermission { + if !teamAdminCELContextOK(c, channelId, teamID) { if channelId == "" { c.SetPermissionError(model.PermissionManageSystem) return @@ -1008,10 +1197,7 @@ func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) { hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) if !hasSystemPermission { - teamID := cel.TeamId - hasTeamPermission := teamID != "" && model.IsValidId(teamID) && - c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) - if !hasTeamPermission { + if !teamAdminCELContextOK(c, channelId, cel.TeamId) { if channelId == "" { c.SetPermissionError(model.PermissionManageSystem) return diff --git a/server/channels/api4/access_control_test.go b/server/channels/api4/access_control_test.go index 0a4d5251666..23b6cf810c1 100644 --- a/server/channels/api4/access_control_test.go +++ b/server/channels/api4/access_control_test.go @@ -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 +} diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index a41996dea04..815f556da33 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -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 } diff --git a/server/channels/api4/channel_join_request.go b/server/channels/api4/channel_join_request.go new file mode 100644 index 00000000000..8f928d3c3b9 --- /dev/null +++ b/server/channels/api4/channel_join_request.go @@ -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)) + } +} diff --git a/server/channels/api4/channel_join_request_test.go b/server/channels/api4/channel_join_request_test.go new file mode 100644 index 00000000000..57bfc586854 --- /dev/null +++ b/server/channels/api4/channel_join_request_test.go @@ -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() +} diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 6ec3a26f758..d7f888b102d 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -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) { diff --git a/server/channels/api4/custom_profile_attributes.go b/server/channels/api4/custom_profile_attributes.go index 186f0845e90..772d26130d4 100644 --- a/server/channels/api4/custom_profile_attributes.go +++ b/server/channels/api4/custom_profile_attributes.go @@ -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 } diff --git a/server/channels/api4/integration_action.go b/server/channels/api4/integration_action.go index c0ab52462a1..70a9f4c0f23 100644 --- a/server/channels/api4/integration_action.go +++ b/server/channels/api4/integration_action.go @@ -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) diff --git a/server/channels/api4/integration_action_test.go b/server/channels/api4/integration_action_test.go index d9b6cd7fd9d..dd61ecc973c 100644 --- a/server/channels/api4/integration_action_test.go +++ b/server/channels/api4/integration_action_test.go @@ -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) + }) +} diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 75147a4b33e..ca9a3522573 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -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) { diff --git a/server/channels/api4/properties.go b/server/channels/api4/properties.go index a5e3e521e08..acc6985e7b9 100644 --- a/server/channels/api4/properties.go +++ b/server/channels/api4/properties.go @@ -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 } diff --git a/server/channels/api4/properties_test.go b/server/channels/api4/properties_test.go index 73489f9a612..3d498d6a486 100644 --- a/server/channels/api4/properties_test.go +++ b/server/channels/api4/properties_test.go @@ -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) }) diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index 69921e1249a..d0a3b82d868 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -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 } } diff --git a/server/channels/api4/shared_channel_remotes_test.go b/server/channels/api4/shared_channel_remotes_test.go index d05e677bfdf..a5ee887d026 100644 --- a/server/channels/api4/shared_channel_remotes_test.go +++ b/server/channels/api4/shared_channel_remotes_test.go @@ -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) +} diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index f0aef8bd91b..43d4e7afbdd 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -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) diff --git a/server/channels/api4/team_test.go b/server/channels/api4/team_test.go index 2644dbd556e..8f480dbd2a5 100644 --- a/server/channels/api4/team_test.go +++ b/server/channels/api4/team_test.go @@ -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) diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 2f4d96571eb..545de311912 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -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 { diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index de36aa68401..b2265864375 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -8,12 +8,14 @@ import ( "errors" "net/http" "slices" + "strings" "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" + "github.com/mattermost/mattermost/server/v8/einterfaces" ) const attributeViewRefreshInterval = 30 * time.Second @@ -207,54 +209,129 @@ func (a *App) mergeStoredPolicyExpressions(rctx request.CTX, policy *model.Acces return appErr } - for i, rule := range policy.Rules { - if i >= len(existingPolicy.Rules) { - continue - } - storedRule := existingPolicy.Rules[i] - storedExpr := storedRule.Expression - if storedExpr == "" || storedExpr == "true" { - continue - } - mergedExpr, appErr := a.mergeExpressionWithMaskedValues(rctx, policy.ID, rule.Expression, storedExpr, callerID) - if appErr != nil { - return appErr - } - policy.Rules[i].Expression = mergedExpr - // If hidden values were re-injected into the expression, the caller was - // working from a masked view of this rule. Lock Actions to the stored - // value too — without this, a caller who sees "--------" could swap the - // action type (e.g., "membership" → "upload_file_attachment") and the - // merge would restore the hidden CEL value while silently removing the - // original access restriction. - if mergedExpr != rule.Expression { - policy.Rules[i].Actions = storedRule.Actions + // Pair submitted and stored rules by Name so that a reorder / + // insert / delete in the editor doesn't swap one rule's masked + // values into a sibling rule's expression. v0.4 permission rules + // are required to carry a unique Name; the membership rule (no + // Name) is pinned by its membership Action so it round-trips + // through reorders too. + storedByName := make(map[string]*model.AccessControlPolicyRule, len(existingPolicy.Rules)) + var storedMembership *model.AccessControlPolicyRule + for i := range existingPolicy.Rules { + r := &existingPolicy.Rules[i] + switch { + case r.Name != "": + storedByName[r.Name] = r + case isMembershipRule(r): + if storedMembership == nil { + storedMembership = r + } } } - // Any stored rules beyond the submitted set were dropped by the caller. If any of those - // contain values the caller cannot see, block the save — otherwise we'd silently widen - // access by removing a rule whose hidden conditions the caller could not audit. - if len(existingPolicy.Rules) > len(policy.Rules) { - for i := len(policy.Rules); i < len(existingPolicy.Rules); i++ { - storedExpr := existingPolicy.Rules[i].Expression - if storedExpr == "" || storedExpr == "true" { + pairedNames := make(map[string]bool, len(existingPolicy.Rules)) + membershipPaired := false + + for i := range policy.Rules { + rule := &policy.Rules[i] + var stored *model.AccessControlPolicyRule + switch { + case rule.Name != "": + stored = storedByName[rule.Name] + if stored != nil { + pairedNames[rule.Name] = true + } + case isMembershipRule(rule): + if !membershipPaired { + stored = storedMembership + membershipPaired = true + } + } + if stored == nil { + // New rule with no corresponding stored entry — nothing to + // re-inject. The validate step (when run from the save + // path) is what rejects forbidden literals on a brand-new + // rule; the merge has nothing useful to do here. + continue + } + if stored.Expression == "" || stored.Expression == "true" { + continue + } + // Snapshot the caller-submitted expression so we can tell + // post-merge whether mergeExpressionWithMaskedValues actually + // re-injected hidden literals (vs. echoing the submission + // back unchanged). Doing this here, before the merge call, + // lets the Actions-locking guard below use a plain `!=` check + // regardless of whether `rule` is a pointer or a copy. + submittedExpr := rule.Expression + mergedExpr, appErr := a.mergeExpressionWithMaskedValues(rctx, policy.ID, submittedExpr, stored.Expression, callerID) + if appErr != nil { + return appErr + } + rule.Expression = mergedExpr + // Hidden values were re-injected → caller was working from a + // masked view. Lock Actions AND Role to stored so they can't + // silently swap the gate's action type or role audience while + // reusing the hidden CEL. + if mergedExpr != submittedExpr { + rule.Actions = stored.Actions + rule.Role = stored.Role + } + } + + // Any stored rule the caller didn't include in the submission was + // dropped. If a dropped rule carries values the caller couldn't + // see, block the save — otherwise we'd silently widen access by + // removing a rule whose hidden conditions the caller could not + // audit. Same side-channel reasoning as the per-condition + // deletion guard inside mergeExpressionWithMaskedValues. + for i := range existingPolicy.Rules { + stored := &existingPolicy.Rules[i] + switch { + case stored.Name != "": + if pairedNames[stored.Name] { continue } - hasMasked, appErr := a.expressionHasMaskedValuesForCaller(rctx, storedExpr, callerID) - if appErr != nil { - return appErr - } - if hasMasked { - return model.NewAppError("mergeStoredPolicyExpressions", "app.pap.save_policy.masked_rule_deleted", nil, - "cannot remove a rule that contains attribute values you do not hold", http.StatusForbidden) + case isMembershipRule(stored): + if membershipPaired { + continue } + default: + // Legacy anonymous non-membership rule — can't safely + // identify it across the submission boundary, skip the + // guard rather than reject every save. + continue + } + if stored.Expression == "" || stored.Expression == "true" { + continue + } + hasMasked, appErr := a.expressionHasMaskedValuesForCaller(rctx, stored.Expression, callerID) + if appErr != nil { + return appErr + } + if hasMasked { + return model.NewAppError("MergeStoredPolicyExpressions", "app.pap.save_policy.masked_rule_deleted", nil, + "cannot remove a rule that contains attribute values you do not hold", http.StatusForbidden) } } return nil } +// isMembershipRule reports whether a rule fills the policy's +// membership slot for the merge-time pairing logic. v0.4 membership +// rules carry no Name and the membership action; legacy v0.1/v0.2 +// channel policies used the wildcard "*" (rejected at v0.3+ IsValid) +// for the same role, so both anchor the same single storedMembership +// pairing slot. +func isMembershipRule(rule *model.AccessControlPolicyRule) bool { + if rule == nil || rule.Name != "" { + return false + } + return slices.Contains(rule.Actions, model.AccessControlPolicyActionMembership) || + slices.Contains(rule.Actions, "*") +} + // expressionHasMaskedValuesForCaller reports whether storedExpr contains any value the caller cannot see. func (a *App) expressionHasMaskedValuesForCaller(rctx request.CTX, storedExpr, callerID string) (bool, *model.AppError) { maskedAST, appErr := a.GetMaskedVisualAST(rctx, storedExpr, callerID) @@ -515,6 +592,867 @@ func (a *App) TestExpression(rctx request.CTX, expression string, opts model.Sub return res, count, nil } +// SimulateAccessControlPolicyForUsers proxies to the enterprise PDP +// service so the /cel/simulate_users handler can preview how a draft +// policy would resolve for an explicit set of users. The caller picks +// users (with optional per-user session attribute overrides); the +// response carries per-user, per-action decisions with blame attribution. +// +// Post-processing happens in two stages before the response leaves the +// server: +// +// 1. enrichBlameForDraftScope inspects every blame entry. Same-scope +// entries (this_rule / sibling_rule / sibling_saved against the +// draft, or system_permission entries whose blamed policy shares the +// draft's scope) gain the failing rule's CEL Expression so the picker +// can render an evaluation trace. system_permission entries that turn +// out to be at the draft's scope are reclassified to peer_policy. +// Truly upper-scoped entries (system_permission with a different +// scope, channel_policy) are deliberately left expression-less so +// the UI cannot leak the contents of a policy outside the editing +// scope. +// 2. filterResponseToEditingRuleScope (only when EvaluationScope == +// "this_rule") is a defensive backstop that strips any non-editing- +// rule blame entries that may have leaked through despite the +// simulator restricting contributions to the editing rule. The +// simulator side does the heavy lifting (skipping sibling rules and +// system permission policies entirely); this filter drops anything +// that isn't a draft-side blame on the editing rule and flips +// orphaned denies back to allow. +// +// Returns NotImplemented when the access control service is unavailable +// (no enterprise license / ABAC disabled). +func (a *App) SimulateAccessControlPolicyForUsers(rctx request.CTX, params model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError) { + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("SimulateAccessControlPolicyForUsers", "app.pap.simulate.unavailable", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + // The editor masks raw CEL literal values for callers who don't + // hold them on every GET / search response, replacing them with + // the "--------" sentinel. The frontend hands that masked policy + // right back to us when the admin clicks "Simulate access", so + // without re-injecting the stored hidden values the simulator + // would evaluate the sentinel as a literal — every condition + // would compare against "--------" and the verdicts would be + // meaningless. + // + // Reuse the same per-rule merge the save path uses to re-inject + // the stored hidden values so the simulator evaluates the real + // CEL. We deliberately do NOT run the save-side write-path value + // validation here: simulate doesn't persist anything, so + // rejecting submissions that carry forbidden literal values is a + // save-only invariant. The merge alone is what makes the + // simulator see the unmasked policy. + if a.Config().FeatureFlags.AttributeValueMasking { + if appErr := a.mergeStoredPolicyExpressions(rctx, params.Policy, rctx.Session().UserId); appErr != nil { + return nil, appErr + } + } + + resp, appErr := acs.SimulatePolicyForUsers(rctx, params) + if appErr != nil { + return nil, appErr + } + + if resp != nil { + enrichBlameForDraftScope(rctx, acs, params.Policy, resp) + if isThisRuleScope(params.EvaluationScope) { + filterResponseToEditingRuleScope(resp, params.RuleName) + } + + // mergeStoredPolicyExpressions re-injected the stored hidden + // values so the simulator could evaluate the real CEL — and + // enrichBlameForDraftScope just copied those unmasked + // expressions into Blame.Expression / MergedRules / the + // evaluation tree. Re-mask every literal-bearing surface + // before the response leaves the server so the caller never + // sees a value they couldn't see via the policy GET path. + // Same flag gate as the merge above: either both run or + // neither does, so the response always matches the policy + // state that produced it. + if a.Config().FeatureFlags.AttributeValueMasking { + a.MaskSimulationPolicyLiteralsForCaller(rctx, resp, rctx.Session().UserId) + } + } + + return resp, nil +} + +// ValidatePolicySimulationUsersInScope ensures every user listed for a delegated +// (non-system-admin) simulation belongs to the channel when channel_id is set, +// otherwise to the team when team_id is set. Call only after the caller has +// passed authorizeSimulatePolicy. +func (a *App) ValidatePolicySimulationUsersInScope(rctx request.CTX, teamID, channelID string, users []model.PolicySimulationUserOverride) *model.AppError { + if channelID != "" { + if !model.IsValidId(channelID) { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "channel_id"}, "", http.StatusBadRequest) + } + for _, u := range users { + if u.UserID == "" || !model.IsValidId(u.UserID) { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "user_id"}, "", http.StatusBadRequest) + } + if _, err := a.Srv().Store().Channel().GetMember(rctx, channelID, u.UserID); err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.access_control_policy.simulate.users_out_of_scope.app_error", nil, "user_id="+u.UserID, http.StatusForbidden) + } + return model.NewAppError("ValidatePolicySimulationUsersInScope", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + return nil + } + if teamID != "" { + if !model.IsValidId(teamID) { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "team_id"}, "", http.StatusBadRequest) + } + for _, u := range users { + if u.UserID == "" || !model.IsValidId(u.UserID) { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.context.invalid_param.app_error", map[string]any{"Name": "user_id"}, "", http.StatusBadRequest) + } + if _, appErr := a.GetTeamMember(rctx, teamID, u.UserID); appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + return model.NewAppError("ValidatePolicySimulationUsersInScope", "api.access_control_policy.simulate.users_out_of_scope.app_error", nil, "user_id="+u.UserID, http.StatusForbidden) + } + return appErr + } + } + } + return nil +} + +// isThisRuleScope returns true when the simulator should run in +// "this rule only" mode. The empty string is included as a defensive +// belt-and-braces fallback for callers that bypass the api4 handler's +// normalisation (it forces "" → this_rule per the model docstring +// default). Direct App.SimulateAccessControlPolicyForUsers callers +// in tests / future RPC entry points may still hit this helper with +// a raw empty string; we treat it consistently with the documented +// model default rather than letting it silently fall through to +// "all" semantics. +func isThisRuleScope(scope string) bool { + return scope == "" || scope == model.PolicyEvaluationScopeThisRule +} + +// userAttributesPathPrefix is the canonical CEL prefix the simulator +// records on leaf evaluation-tree nodes for user-attribute references +// (e.g. `user.attributes.Clearance`). The CPA field name is the +// suffix; we strip the prefix to match against the protected set +// indexed by field name. +const userAttributesPathPrefix = "user.attributes." + +// RedactSimulationAttributesForCaller strips attribute values from a +// PolicySimulationResponse on every surface the picker exposes +// (top-level user/session Attributes maps AND the per-leaf +// ActualValue inside same-scope blame evaluation trees) when the +// caller is not a system admin. +// +// A field is treated as protected — and therefore redacted — when +// any of the following applies (channel and team admins are never a +// CPA field's source plugin, so the access_mode branches collapse +// to "not public" for these callers): +// +// - `visibility == "hidden"`: the field is hidden on the user +// profile page; the simulate UI must not be a side channel. +// +// - `access_mode == "source_only"`: the CPA value is reserved for +// the source plugin. Channel/team admins are never plugin +// callers, so the value is always inaccessible to them. +// +// - `access_mode == "shared_only"`: the underlying property +// service computes an intersection of the caller's and target's +// values on read. The simulator does NOT call the property +// service (it reads from AttributeView directly), so we +// conservatively redact these values rather than ship them +// unfiltered. +// +// System admins (passed via callerIsSystemAdmin=true) bypass the +// filter entirely; they always see every attribute the simulator +// recorded. +// +// On failure to look up the CPA fields we *strip every attribute map* +// and clear every evaluation tree's ActualValue, rather than leaking +// a value through a transient error — the fail-closed default mirrors +// how `BuildAccessControlSubject` treats a missing channel-role +// lookup. +func (a *App) RedactSimulationAttributesForCaller(rctx request.CTX, resp *model.PolicySimulationResponse, callerIsSystemAdmin bool) { + if resp == nil || callerIsSystemAdmin { + return + } + + // Cheap-out when no result row carries any of the redactable + // surfaces (top-level Attributes maps or blame evaluation trees) — + // saves the CPA fetch on the common "deny chip only, no Decision + // Details panel" UX. + if !simulationHasRedactableAttributeData(resp) { + return + } + + protected, err := a.protectedCPAFieldNamesForCaller(rctx) + if err != nil { + rctx.Logger().Warn( + "RedactSimulationAttributesForCaller: failed to load CPA fields; redacting every simulation attribute surface as a fail-closed default", + mlog.Err(err), + ) + // Fail closed: drop every attribute snapshot AND every leaf + // `actual_value` rather than leak a protected field through a + // transient lookup failure. + clearAllSimulationAttributes(resp) + clearAllEvaluationTreeActualValues(resp) + return + } + if len(protected) == 0 { + return + } + + stripProtectedAttributes(resp, protected) + redactProtectedEvaluationTreeActualValues(resp, protected) +} + +// protectedCPAFieldNamesForCaller returns the set of CPA field names +// whose contents must be hidden from a non-system-admin caller. The +// set includes both `visibility: hidden` fields and any field whose +// `access_mode` is not public (source_only / shared_only). The +// simulator's AttributeView populates its per-user map keyed by +// `pf.Name` (see db/migrations/postgres/000137_update_attribute_view.up.sql), +// and the evaluation-tree walker likewise records `user.attributes.` +// on each leaf — so matching by name is correct for both. +func (a *App) protectedCPAFieldNamesForCaller(rctx request.CTX) (map[string]struct{}, error) { + group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName) + if appErr != nil { + return nil, appErr + } + + propertyFields, appErr := a.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{ + PerPage: model.AccessControlGroupFieldLimit + 5, + }) + if appErr != nil { + return nil, appErr + } + + protected := map[string]struct{}{} + for _, pf := range propertyFields { + if pf == nil { + continue + } + f, err := model.NewCPAFieldFromPropertyField(pf) + if err != nil { + // Fail-closed: an unparseable field is treated as protected + // rather than leaked through the masking layer as public. + rctx.Logger().Warn("Failed to parse property field for CPA protection check; treating as protected", + mlog.String("field_name", pf.Name), + mlog.String("field_id", pf.ID), + mlog.Err(err), + ) + protected[pf.Name] = struct{}{} + continue + } + if cpaFieldIsProtectedForChannelAdmin(f) { + protected[f.Name] = struct{}{} + } + } + return protected, nil +} + +// cpaFieldIsProtectedForChannelAdmin reports whether a CPA field's +// value must be hidden from a non-system-admin caller. Pure helper +// so the protected-set construction and the per-leaf tree walker can +// share the same predicate. +func cpaFieldIsProtectedForChannelAdmin(f *model.CPAField) bool { + if f == nil { + return false + } + if f.Attrs.Visibility == model.CustomProfileAttributesVisibilityHidden { + return true + } + // access_mode "" defaults to public — only non-public values are + // protected. Channel/team admins are never the source plugin so + // both source_only and shared_only collapse to "inaccessible". + if f.Attrs.AccessMode != "" && f.Attrs.AccessMode != model.PropertyAccessModePublic { + return true + } + return false +} + +// simulationHasRedactableAttributeData reports whether any result row +// carries a non-empty top-level `Attributes` map at the user OR +// session level, or any blame entry whose `EvaluationTree` (or +// per-rule subtree under MergedRules) might leak a leaf +// `ActualValue`. Used to short-circuit the redact pass when the +// response is purely "decision chips only" with no Decision Details +// data to redact. +func simulationHasRedactableAttributeData(resp *model.PolicySimulationResponse) bool { + if resp == nil { + return false + } + for i := range resp.Results { + r := &resp.Results[i] + if len(r.Attributes) > 0 { + return true + } + for j := range r.Decisions { + if decisionCarriesActualValue(r.Decisions[j]) { + return true + } + } + for j := range r.Sessions { + if len(r.Sessions[j].Attributes) > 0 { + return true + } + for k := range r.Sessions[j].Decisions { + if decisionCarriesActualValue(r.Sessions[j].Decisions[k]) { + return true + } + } + } + } + return false +} + +// decisionCarriesActualValue reports whether any blame entry on the +// decision has an evaluation tree (either at the top level or under a +// merged-rule entry) that could leak an `ActualValue`. +func decisionCarriesActualValue(dec model.PolicySimulationActionDecision) bool { + for i := range dec.Blame { + b := &dec.Blame[i] + if b.EvaluationTree != nil { + return true + } + for j := range b.MergedRules { + if b.MergedRules[j].EvaluationTree != nil { + return true + } + } + } + return false +} + +// stripProtectedAttributes deletes any key in `protected` from every +// result row's user-level and per-session top-level Attributes maps in +// `resp`. Mutates `resp` in place; safe to call when `protected` is +// empty (no-op). This handles the top-level snapshot the Decision +// Details panel renders as a User/Session attributes table. +func stripProtectedAttributes(resp *model.PolicySimulationResponse, protected map[string]struct{}) { + if resp == nil || len(protected) == 0 { + return + } + for i := range resp.Results { + r := &resp.Results[i] + for name := range protected { + delete(r.Attributes, name) + } + for j := range r.Sessions { + for name := range protected { + delete(r.Sessions[j].Attributes, name) + } + } + } +} + +// redactProtectedEvaluationTreeActualValues walks every blame entry's +// EvaluationTree (and the per-rule subtrees attached under +// MergedRules) on every result and session decision in `resp`. For +// each leaf node whose `Attribute` references a protected CPA field +// (path format `user.attributes.`), the leaf's `ActualValue` +// is blanked. +// +// Why ActualValue and nothing else: +// - `Attribute` is the path; it already appears in the rule's +// `Expression`, which the channel admin can see. +// - `ExpectedValue` is the literal from the rule (e.g. `"il5"`), +// not the user's data — also already in `Expression`. +// - `ActualValue` is the only field that records the target user's +// concrete attribute value. That's the one we must redact. +func redactProtectedEvaluationTreeActualValues(resp *model.PolicySimulationResponse, protected map[string]struct{}) { + if resp == nil || len(protected) == 0 { + return + } + for i := range resp.Results { + r := &resp.Results[i] + for action, dec := range r.Decisions { + redactProtectedActualValuesInDecision(&dec, protected) + r.Decisions[action] = dec + } + for j := range r.Sessions { + for action, dec := range r.Sessions[j].Decisions { + redactProtectedActualValuesInDecision(&dec, protected) + r.Sessions[j].Decisions[action] = dec + } + } + } +} + +func redactProtectedActualValuesInDecision(dec *model.PolicySimulationActionDecision, protected map[string]struct{}) { + for i := range dec.Blame { + b := &dec.Blame[i] + if b.EvaluationTree != nil { + redactProtectedActualValuesInTree(b.EvaluationTree, protected) + } + for j := range b.MergedRules { + if b.MergedRules[j].EvaluationTree != nil { + redactProtectedActualValuesInTree(b.MergedRules[j].EvaluationTree, protected) + } + } + } +} + +// redactProtectedActualValuesInTree recursively walks `node` and +// blanks the `ActualValue` on every leaf whose `Attribute` resolves +// to a CPA field in `protected`. Operates in place on the tree +// pointer the response shares with its parent blame entry. +func redactProtectedActualValuesInTree(node *model.PolicySimulationEvaluationNode, protected map[string]struct{}) { + if node == nil { + return + } + if isProtectedAttributePath(node.Attribute, protected) { + node.ActualValue = "" + } + for i := range node.Children { + redactProtectedActualValuesInTree(&node.Children[i], protected) + } +} + +// isProtectedAttributePath returns true when `path` is the canonical +// CEL form `user.attributes.` and `` is in `protected`. +// Returns false for empty paths and for any path that doesn't carry +// the user-attribute prefix (other shapes — function-call leaves, +// constant comparisons — are not user data). +func isProtectedAttributePath(path string, protected map[string]struct{}) bool { + if path == "" || len(protected) == 0 { + return false + } + name, ok := strings.CutPrefix(path, userAttributesPathPrefix) + if !ok || name == "" { + return false + } + _, found := protected[name] + return found +} + +// clearAllSimulationAttributes wipes every top-level user-level and +// per-session Attributes map in `resp`. Used as part of the fail- +// closed default when the CPA visibility lookup fails — a transient +// store error must not leak a hidden value to a channel admin via +// the simulator. +func clearAllSimulationAttributes(resp *model.PolicySimulationResponse) { + if resp == nil { + return + } + for i := range resp.Results { + r := &resp.Results[i] + r.Attributes = nil + for j := range r.Sessions { + r.Sessions[j].Attributes = nil + } + } +} + +// clearAllEvaluationTreeActualValues wipes the `ActualValue` field on +// every leaf in every evaluation tree the response carries (top-level +// and per-merged-rule). Companion to `clearAllSimulationAttributes` +// for the fail-closed path: we don't know which fields are protected +// because the CPA lookup failed, so we redact every leaf rather than +// take the risk. +func clearAllEvaluationTreeActualValues(resp *model.PolicySimulationResponse) { + if resp == nil { + return + } + for i := range resp.Results { + r := &resp.Results[i] + for action, dec := range r.Decisions { + clearActualValuesInDecision(&dec) + r.Decisions[action] = dec + } + for j := range r.Sessions { + for action, dec := range r.Sessions[j].Decisions { + clearActualValuesInDecision(&dec) + r.Sessions[j].Decisions[action] = dec + } + } + } +} + +func clearActualValuesInDecision(dec *model.PolicySimulationActionDecision) { + for i := range dec.Blame { + b := &dec.Blame[i] + if b.EvaluationTree != nil { + clearActualValuesInTree(b.EvaluationTree) + } + for j := range b.MergedRules { + if b.MergedRules[j].EvaluationTree != nil { + clearActualValuesInTree(b.MergedRules[j].EvaluationTree) + } + } + } +} + +func clearActualValuesInTree(node *model.PolicySimulationEvaluationNode) { + if node == nil { + return + } + node.ActualValue = "" + for i := range node.Children { + clearActualValuesInTree(&node.Children[i]) + } +} + +// enrichBlameForDraftScope walks the simulator response and: +// - copies the failing rule's expression into draft-side blame entries +// (this_rule / sibling_rule / sibling_saved) using params.Policy.Rules +// as the source — only if the simulator hasn't already populated it. +// - reclassifies system_permission blame entries whose blamed policy +// lives at the SAME scope as the draft (same Type and same Imports +// parent set) to peer_policy, populating the failing rule's +// expression in from the blamed policy's Rules when the simulator +// left it empty. acs.GetPolicy is consulted once per unique +// policy_id and cached for the request. +// - **defensively strips Expression and EvaluationTree from blame +// entries whose final source is truly upper-scoped** +// (system_permission, channel_policy). The simulator may attach +// these fields unconditionally for ergonomics; the privacy +// boundary is enforced here so the UI never receives the contents +// of a policy outside the editing scope. +func enrichBlameForDraftScope(rctx request.CTX, acs einterfaces.AccessControlServiceInterface, draft *model.AccessControlPolicy, resp *model.PolicySimulationResponse) { + if resp == nil || draft == nil { + return + } + draftRules := buildRulesIndex(draft) + cache := map[string]*model.AccessControlPolicy{} + + enrichDecisions := func(decisions map[string]model.PolicySimulationActionDecision) { + for action, dec := range decisions { + for j := range dec.Blame { + enrichBlameEntry(rctx, acs, draft, draftRules, cache, &dec.Blame[j]) + } + decisions[action] = dec + } + } + + for i := range resp.Results { + enrichDecisions(resp.Results[i].Decisions) + for k := range resp.Results[i].Sessions { + enrichDecisions(resp.Results[i].Sessions[k].Decisions) + } + } +} + +func enrichBlameEntry(rctx request.CTX, acs einterfaces.AccessControlServiceInterface, draft *model.AccessControlPolicy, draftRules map[string]string, cache map[string]*model.AccessControlPolicy, blame *model.PolicySimulationBlame) { + if blame == nil { + return + } + switch blame.Source { + case model.PolicySimulationBlameSourceThisRule, + model.PolicySimulationBlameSourceSiblingRule, + model.PolicySimulationBlameSourceSiblingSaved: + // Same-scope draft blame: backfill expression if the simulator + // didn't pre-populate it. + if blame.Expression == "" { + if expr, ok := draftRules[blame.RuleName]; ok { + blame.Expression = expr + } + } + case model.PolicySimulationBlameSourceSystemPermission: + // Peer-vs-upper distinction lives here: load the blamed + // policy, compare scope to the draft, and either reclassify + // (peer_policy with expression preserved/backfilled) or strip + // the leaked details. + if blame.PolicyID == "" { + stripUpperScopedFields(blame) + return + } + blamed, cached := cache[blame.PolicyID] + if !cached { + policy, appErr := acs.GetPolicy(rctx, blame.PolicyID) + if appErr != nil { + policy = nil + } + cache[blame.PolicyID] = policy + blamed = policy + } + if blamed == nil || !samePeerScope(draft, blamed) { + stripUpperScopedFields(blame) + return + } + blame.Source = model.PolicySimulationBlameSourcePeerPolicy + if blame.Expression == "" { + for _, r := range blamed.Rules { + if r.Name == blame.RuleName { + blame.Expression = r.Expression + break + } + } + } + case model.PolicySimulationBlameSourceChannelPolicy: + // channel_policy is always upper-scoped from a draft's view — + // the parent or an inherited resource policy. Strip + // expression / tree details so the UI keeps the chip opaque. + stripUpperScopedFields(blame) + } +} + +// stripUpperScopedFields clears the fields that would leak the contents +// of an out-of-scope policy if the simulator attached them. Called +// whenever a blame entry's final source is determined to live above +// the editing scope. +// +// MergedRules is stripped alongside Expression / EvaluationTree: +// the per-rule list lets the picker number sub-rules of the +// contributing policy, which would amount to enumerating that +// policy's authored rules — exactly what the privacy boundary is +// supposed to hide. The simulator may have attached MergedRules +// unconditionally for ergonomics; we drop it here once the source is +// known to live above the editing scope. +func stripUpperScopedFields(blame *model.PolicySimulationBlame) { + blame.Expression = "" + blame.EvaluationTree = nil + blame.MergedRules = nil +} + +// buildRulesIndex maps rule_name -> CEL expression for a policy. Rules +// without a name (legacy v0.3 membership rules) are skipped because the +// blame entries reference rules by name — anonymous rules would never +// match. +func buildRulesIndex(policy *model.AccessControlPolicy) map[string]string { + if policy == nil { + return nil + } + out := make(map[string]string, len(policy.Rules)) + for _, r := range policy.Rules { + if r.Name == "" { + continue + } + out[r.Name] = r.Expression + } + return out +} + +// samePeerScope reports whether two policies live at the same scope. +// Policies are peers when they share the same Type, the same Scope + +// ScopeID (so a team-scoped permission policy is never treated as a +// peer of a system-scoped one with the same imports), and the same +// parent imports set (order-insensitive). Two policies with no Imports +// (top-level system policies) count as peers of one another. A policy +// and its parent are NOT peers — the parent has a smaller / different +// imports set. +func samePeerScope(a, b *model.AccessControlPolicy) bool { + if a == nil || b == nil { + return false + } + if a.Type != b.Type { + return false + } + if a.Scope != b.Scope || a.ScopeID != b.ScopeID { + return false + } + return importsEqual(a.Imports, b.Imports) +} + +func importsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + aa := append([]string(nil), a...) + bb := append([]string(nil), b...) + slices.Sort(aa) + slices.Sort(bb) + for i := range aa { + if aa[i] != bb[i] { + return false + } + } + return true +} + +// filterResponseToEditingRuleScope is the defensive post-process for the +// "this rule only" evaluation scope. The simulator already restricts +// contributions to just the editing rule (no sibling rules, no system +// permission policies, no peer policies), so in practice this function +// only runs over an already-clean response. It exists to backstop +// any blame entry that leaked through, drop anything that isn't a +// draft-side entry on the editing rule, and flip orphaned denies back +// to allow. +// +// editingRuleName is the rule the author is currently simulating; when +// non-empty, only this_rule blame entries that explicitly target that +// rule survive. When empty (e.g. an unnamed draft rule) the filter +// drops everything except this_rule, sibling_saved, and +// no_applicable_policy regardless of the rule_name field — sibling +// rules in the same policy are never kept in this mode. +func filterResponseToEditingRuleScope(resp *model.PolicySimulationResponse, editingRuleName string) { + for i := range resp.Results { + // System admins are subject to ABAC the same as any other + // user, BUT they don't carry the channel-level role tokens + // (channel_user / channel_guest / channel_admin) the + // simulator pairs rules against — they inherit them + // implicitly. The simulator returns a bare {decision: true} + // for sysadmin candidates without a this_rule blame, which + // looks identical to the "rule doesn't apply (role + // mismatch)" vacuous allow the filter relies on. Without a + // sysadmin carve-out the marker would mislabel sysadmin + // rows as "this rule doesn't apply" when in fact the rule + // does apply via role fallback — the sysadmin is allowed + // by the same rule the picker is testing. We pass the flag + // down to filterDecisionsToEditingRuleScope so it can skip + // the no_applicable_rule injection for those rows. + callerIsSystemAdmin := false + if u := resp.Results[i].User; u != nil { + callerIsSystemAdmin = u.IsSystemAdmin() + } + resp.Results[i].Decisions = filterDecisionsToEditingRuleScope(resp.Results[i].Decisions, editingRuleName, callerIsSystemAdmin) + for j := range resp.Results[i].Sessions { + resp.Results[i].Sessions[j].Decisions = filterDecisionsToEditingRuleScope(resp.Results[i].Sessions[j].Decisions, editingRuleName, callerIsSystemAdmin) + } + } +} + +func filterDecisionsToEditingRuleScope(decisions map[string]model.PolicySimulationActionDecision, editingRuleName string, candidateIsSystemAdmin bool) map[string]model.PolicySimulationActionDecision { + if len(decisions) == 0 { + return decisions + } + for action, dec := range decisions { + filtered := filterBlameToEditingRuleScope(dec.Blame, editingRuleName) + + switch { + case !dec.Decision && len(filtered) == 0: + // DENY with no editing-rule contribution at all (only + // upper-scoped / peer / sibling-rule denies, all of which + // were just filtered out). The editing rule is silent on + // this user, so we surface "doesn't apply" rather than + // the old flip-to-plain-allow — that read as "this rule + // alone would have allowed this user" which isn't true + // for a permission rule whose filter didn't grant. + // + // Outcome is left empty (not OutcomeAllow) to match the + // existing no_applicable_policy convention: the chip's + // hasBlame helper filters informational outcome=allow + // entries out, so a vacuous-allow synthetic must NOT set + // outcome=allow or the chip will skip it. + dec.Decision = true + dec.Blame = []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceNoApplicableRule, + }} + case dec.Decision && !hasThisRuleAllow(filtered) && !hasNoApplicablePolicy(filtered) && !candidateIsSystemAdmin: + // ALLOW without the editing rule actively granting. This + // covers three real-world simulator outputs: + // + // 1. sibling_saved present — this rule denied, an + // OR-merged sibling allowed. + // 2. Bare {decision: true} with empty blame — the + // simulator emits a vacuous allow when the editing + // rule's role doesn't match the candidate's role + // (e.g. testing a channel_user rule against a guest + // user), or the rule's action set doesn't overlap. + // 3. Only upper-scoped allow blame survived the + // filter — same idea: the editing rule itself was + // silent on this user. + // + // In every case the editing rule didn't contribute a + // grant, so in "this rule only" view the chip should read + // "this rule doesn't apply". Append (don't replace) so + // any sibling_saved expression stays available for the + // Decision Details trace. + // + // Three carve-outs: + // - no_applicable_policy already attributes the verdict + // to the WHOLE policy being silent on this user; + // that's strictly more informative and we don't + // shadow it. + // - candidateIsSystemAdmin — sysadmins inherit every + // channel-level role implicitly, so a bare + // {decision: true} for a sysadmin candidate is a + // legitimate allow via role fallback, NOT a "rule + // doesn't apply" signal. The simulator just doesn't + // emit a this_rule blame entry for the fallback path. + // - this_rule allow + sibling_saved (handled by the + // hasThisRuleAllow guard above) — the rule did + // contribute, sibling is supplementary. + dec.Blame = append(filtered, model.PolicySimulationBlame{ + Source: model.PolicySimulationBlameSourceNoApplicableRule, + }) + default: + dec.Blame = filtered + } + + decisions[action] = dec + } + return decisions +} + +// hasThisRuleAllow reports whether any blame entry is an +// informational this_rule entry with outcome=allow — i.e. the +// editing rule itself granted the subject. When this is true we +// must NOT convert to no_applicable_rule: the rule did contribute, +// any sibling_saved entry alongside is just supplementary +// "another rule also allowed" context. +func hasThisRuleAllow(blames []model.PolicySimulationBlame) bool { + for _, b := range blames { + if b.Source == model.PolicySimulationBlameSourceThisRule && b.Outcome == model.PolicySimulationBlameOutcomeAllow { + return true + } + } + return false +} + +// hasNoApplicablePolicy reports whether the simulator already +// marked the response with a no_applicable_policy synthetic blame +// — the policy as a whole doesn't govern this user. We use the +// same "outcome != allow" gate the chip's hasBlame helper uses so +// our detection lines up with what the picker will actually +// render; this prevents us from shadowing a wider +// "policy doesn't apply" verdict with a narrower +// "this rule doesn't apply" pill. +func hasNoApplicablePolicy(blames []model.PolicySimulationBlame) bool { + for _, b := range blames { + if b.Source == model.PolicySimulationBlameSourceNoApplicablePolicy && b.Outcome != model.PolicySimulationBlameOutcomeAllow { + return true + } + } + return false +} + +// editingRuleBlameSources lists the blame sources that originate inside +// the editing rule itself (or are synthetic markers about how the rule +// applies). Anything else — peer_policy (same scope, different policy), +// system_permission, channel_policy, and even sibling_rule (same policy, +// different rule) — is dropped when the caller asks for "this rule only". +// +// no_applicable_rule is not listed here because it's emitted POST-filter +// by filterDecisionsToEditingRuleScope itself, not by the simulator. +// Listing it here would have no effect; the filter would never see one. +var editingRuleBlameSources = map[string]struct{}{ + model.PolicySimulationBlameSourceThisRule: {}, + model.PolicySimulationBlameSourceSiblingSaved: {}, + model.PolicySimulationBlameSourceNoApplicablePolicy: {}, +} + +func filterBlameToEditingRuleScope(blame []model.PolicySimulationBlame, editingRuleName string) []model.PolicySimulationBlame { + if len(blame) == 0 { + return nil + } + out := blame[:0:0] + for _, b := range blame { + if _, ok := editingRuleBlameSources[b.Source]; !ok { + continue + } + // Defensive: when the editing rule's name is known, only keep + // blame entries that explicitly target it. sibling_saved is the + // deliberate exception — by definition it names the rescuing + // sibling, never the editing rule. + if editingRuleName != "" && b.RuleName != "" && b.RuleName != editingRuleName && + b.Source != model.PolicySimulationBlameSourceSiblingSaved { + continue + } + out = append(out, b) + } + if len(out) == 0 { + return nil + } + return out +} + func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID string, channelIDs []string) ([]*model.AccessControlPolicy, *model.AppError) { acs := a.Srv().ch.AccessControl if acs == nil { @@ -792,6 +1730,76 @@ func (a *App) channelPolicyIDsWithImport(rctx request.CTX, importID string) []st return channelIDs } +// HydrateChannelPolicyActions populates ch.PolicyActions for a single channel +// when ch.PolicyEnforced is true, by looking up the per-action union from +// the AccessControlPolicies table. It's a no-op for channels without an +// attached policy, so the cost on the common no-policy path is zero — only +// the cheap PolicyEnforced=false branch is taken. +// +// Errors from the underlying store are returned as AppErrors; callers +// should treat them as the channel having no actions (fail-closed) for any +// membership-dependent gate. Hydration leaves PolicyEnforced untouched so +// the "any AC policy attached" semantic remains available for consumers +// that need it (admin UI, useChannelSystemPolicies). +func (a *App) HydrateChannelPolicyActions(rctx request.CTX, ch *model.Channel) *model.AppError { + if ch == nil || !ch.PolicyEnforced { + return nil + } + actions, err := a.Srv().Store().AccessControlPolicy().GetActionsForPolicy(rctx, ch.Id) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + // Policy was deleted between the channel read and this lookup; + // the channel row's PolicyEnforced flag will be reconciled on + // the next write. Treat as "no actions" rather than failing. + ch.PolicyActions = map[string]bool{} + return nil + } + return model.NewAppError("HydrateChannelPolicyActions", "app.pap.hydrate_actions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + ch.PolicyActions = actions + return nil +} + +// HydrateChannelsPolicyActions does the same for a slice of channels, but +// batches the underlying store call for the subset of channels with +// PolicyEnforced=true. Channels with PolicyEnforced=false are left +// untouched and never reach the AccessControlPolicies table. Used by +// list endpoints to avoid an N+1 against the policy store. +func (a *App) HydrateChannelsPolicyActions(rctx request.CTX, channels []*model.Channel) *model.AppError { + if len(channels) == 0 { + return nil + } + var ids []string + for _, ch := range channels { + if ch == nil || !ch.PolicyEnforced { + continue + } + ids = append(ids, ch.Id) + } + if len(ids) == 0 { + return nil + } + actionsByID, err := a.Srv().Store().AccessControlPolicy().GetActionsForPolicies(rctx, ids) + if err != nil { + return model.NewAppError("HydrateChannelsPolicyActions", "app.pap.hydrate_actions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + for _, ch := range channels { + if ch == nil || !ch.PolicyEnforced { + continue + } + if actions, ok := actionsByID[ch.Id]; ok { + ch.PolicyActions = actions + } else { + // Policy row missing for an enforced channel — same semantics + // as the single-channel ErrNotFound path: treat as empty rather + // than fail the whole batch. + ch.PolicyActions = map[string]bool{} + } + } + return nil +} + // publishChannelPolicyEnforcedUpdate invalidates the channel cache for the // given channel ID and broadcasts a channel_access_control_updated websocket // event so that connected clients can refresh their view of the channel's @@ -812,6 +1820,19 @@ func (a *App) publishChannelPolicyEnforcedUpdate(rctx request.CTX, channelID str return } + // Ensure the broadcasted payload carries the freshly-hydrated action + // map so clients can react to action-set changes without an extra + // round-trip. GetChannel above already hydrates on cache miss, but + // re-hydrating here keeps the behavior consistent if a cache hit + // returned a channel without PolicyActions populated (e.g. a Phase 1 + // rollout where caches predate the hydration seam). + if appErr := a.HydrateChannelPolicyActions(rctx, channel); appErr != nil { + rctx.Logger().Warn("Failed to hydrate policy actions before broadcast; clients will see policy_actions=nil", + mlog.String("channel_id", channelID), + mlog.Err(appErr), + ) + } + channelJSON, jsonErr := json.Marshal(channel) if jsonErr != nil { rctx.Logger().Warn("Failed to marshal channel after access control policy change", @@ -1026,10 +2047,15 @@ func (a *App) ValidateExpressionAgainstRequester(rctx request.CTX, expression st return false, nil } -// BuildAccessControlSubject creates a fully populated Subject with user attributes and system role -// for use in AccessEvaluation calls. It also ensures the materialized attribute view is -// refreshed periodically (at most once per attributeViewRefreshInterval). -func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles string) (*model.Subject, *model.AppError) { +// BuildAccessControlSubject creates a fully populated Subject with user attributes and +// scoped roles for use in AccessEvaluation calls. It also ensures the materialized +// attribute view is refreshed periodically (at most once per attributeViewRefreshInterval). +// +// channelID is optional: when non-empty, the channel-scoped role for the user is resolved +// from ChannelMember and appended to Subject.ScopedRoles so v0.4 channel resource policy +// permission rules can match (channel_guest / channel_user / channel_admin). When empty, +// only the system-scoped role is populated. +func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles string, channelID string) (*model.Subject, *model.AppError) { a.refreshAttributeViewIfStale(rctx) group, err := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName) @@ -1041,26 +2067,161 @@ func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles s if storeErr != nil { var nfErr *store.ErrNotFound if errors.As(storeErr, &nfErr) { - return &model.Subject{ + subject = &model.Subject{ ID: userID, Type: "user", - Role: roles, Attributes: map[string]any{}, - }, nil + } + } else { + rctx.Logger().Warn("Failed to get subject for access control subject", + mlog.String("user_id", userID), + mlog.String("roles", roles), + mlog.Err(storeErr), + ) + return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.get_subject.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) } - - rctx.Logger().Warn("Failed to get subject for access control subject", - mlog.String("user_id", userID), - mlog.String("roles", roles), - mlog.Err(storeErr), - ) - return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.get_subject.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) } subject.Role = roles + subject.SetScopedRole(model.AccessControlSubjectScopeSystem, ResolveSystemRole(roles)) + if channelID != "" { + channelRole, appErr := a.GetSubjectChannelRole(rctx, userID, channelID) + if appErr != nil { + // Fail closed: a transient channel-member lookup failure must + // not silently produce a subject without a channel-scoped + // role — the resource lane evaluator would then evaluate + // against an empty role and let the user through any + // channel-role-targeted rules. Propagate the error so the + // caller treats the build as a denial. + rctx.Logger().Warn("Failed to resolve channel-scoped role for ABAC subject; aborting subject build", + mlog.String("user_id", userID), + mlog.String("channel_id", channelID), + mlog.Err(appErr), + ) + return nil, appErr + } + if channelRole != "" { + subject.SetScopedRole(model.AccessControlSubjectScopeChannel, channelRole) + } + } + return subject, nil } +// GetSubjectChannelRole returns the channel-scoped role identifier +// (channel_admin / channel_user / channel_guest) for the given user in +// the given channel. +// +// Resolution order: +// 1. Look up ChannelMember; map SchemeAdmin → channel_admin, SchemeUser → channel_user, +// SchemeGuest → channel_guest. +// 2. Inspect the Roles tokens on the channel member for the channel role names. +// +// Returns ("", nil) when no channel role can be determined — either +// because the user is not a member of the channel, or because the +// ChannelMember row exists but is in an inconsistent shape (no scheme +// flag set and no recognised channel-role token in Roles). Callers +// (e.g. attachChannelScopedRole, BuildAccessControlSubject) gate on the +// empty string and skip the channel scope rather than evaluating against +// a fabricated role. Inconsistent-row cases are logged at WARN with the +// row's flags and Roles for operator triage. +func (a *App) GetSubjectChannelRole(rctx request.CTX, userID, channelID string) (string, *model.AppError) { + cm, err := a.Srv().Store().Channel().GetMember(rctx, channelID, userID) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + // Not a member: return an empty role and let the caller + // decide what "no resource role" means for them. We used + // to fabricate a role from the user's system roles here, + // but that synthesised channel-scope information from + // data the user has no actual channel membership behind — + // callers (e.g. attachChannelScopedRole in file.go) now + // gate on the empty string and skip the channel scope + // rather than evaluating against a guess. + return "", nil + } + return "", model.NewAppError("GetSubjectChannelRole", "app.access_control.get_channel_role.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + switch { + case cm.SchemeAdmin: + return model.ChannelAdminRoleId, nil + case cm.SchemeGuest: + return model.ChannelGuestRoleId, nil + case cm.SchemeUser: + return model.ChannelUserRoleId, nil + } + + // Inspect the Roles tokens deterministically rather than returning + // whichever recognised token appears first in the space-separated + // string. The legacy first-match-wins behaviour silently downgraded + // a channel admin whose Roles happened to list + // `channel_user channel_admin` (in either order, depending on how + // the row was migrated). + // + // channel_admin is checked first because admin and user tokens + // STACK on legacy rows — a promoted member carries both. Picking + // admin when present matches the stacked-token reality. + // + // channel_guest is a separate lane: it represents an external + // guest account, NOT a lower rung of the admin/user hierarchy. + // In healthy data it never co-occurs with channel_user / + // channel_admin (the SchemeGuest switch case above handles the + // modern path), so checking it after the stacked-pair tokens is + // purely defensive — only reached when SchemeGuest wasn't set + // and `channel_guest` is the sole recognised token in the row. + tokens := strings.Fields(cm.Roles) + if slices.Contains(tokens, model.ChannelAdminRoleId) { + return model.ChannelAdminRoleId, nil + } + if slices.Contains(tokens, model.ChannelUserRoleId) { + return model.ChannelUserRoleId, nil + } + if slices.Contains(tokens, model.ChannelGuestRoleId) { + return model.ChannelGuestRoleId, nil + } + + // ChannelMember row exists but neither the scheme flags nor the + // Roles tokens identify a recognised channel role. This shouldn't + // happen on healthy data — schemes set SchemeUser by default, and + // pre-scheme rows still carry channel_user / channel_admin / + // channel_guest tokens. We used to fall back to guessing from the + // user's system roles here, but that fabricated channel-scope + // information from system-scope data and silently masked the + // underlying inconsistency. Returning "" makes the caller skip the + // channel scope (same as the not-a-member path) and the WARN log + // surfaces the row state so operators can investigate. + rctx.Logger().Warn( + "Channel member exists but channel role could not be resolved; treating as no channel scope", + mlog.String("user_id", userID), + mlog.String("channel_id", channelID), + mlog.String("roles", cm.Roles), + mlog.Bool("scheme_admin", cm.SchemeAdmin), + mlog.Bool("scheme_user", cm.SchemeUser), + mlog.Bool("scheme_guest", cm.SchemeGuest), + ) + return "", nil +} + +// ResolveSystemRole returns the highest-precedence base system role token +// from a space-separated roles string. The check order is deterministic: +// system_admin > system_guest > system_user. Custom/admin-managed roles +// without a recognised base default to system_user so the permission-policy +// lane is never silently skipped. +func ResolveSystemRole(roles string) string { + tokens := strings.Fields(roles) + if slices.Contains(tokens, model.SystemAdminRoleId) { + return model.SystemAdminRoleId + } + if slices.Contains(tokens, model.SystemGuestRoleId) { + return model.SystemGuestRoleId + } + if slices.Contains(tokens, model.SystemUserRoleId) { + return model.SystemUserRoleId + } + return model.SystemUserRoleId +} + // refreshAttributeViewIfStale refreshes the materialized AttributeView if the last // refresh was more than attributeViewRefreshInterval ago. The refresh is non-blocking: // if another goroutine is already refreshing, this call returns immediately. diff --git a/server/channels/app/access_control_masking.go b/server/channels/app/access_control_masking.go index 2faca231918..50b057fc857 100644 --- a/server/channels/app/access_control_masking.go +++ b/server/channels/app/access_control_masking.go @@ -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 " ". 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) { diff --git a/server/channels/app/access_control_masking_test.go b/server/channels/app/access_control_masking_test.go index e75b411b86e..fd7c2608162 100644 --- a/server/channels/app/access_control_masking_test.go +++ b/server/channels/app/access_control_masking_test.go @@ -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) +} diff --git a/server/channels/app/access_control_merge_test.go b/server/channels/app/access_control_merge_test.go index 28cc6ae161b..27abf12f365 100644 --- a/server/channels/app/access_control_merge_test.go +++ b/server/channels/app/access_control_merge_test.go @@ -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"}} diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go index 151d107fe1c..79027150cf4 100644 --- a/server/channels/app/access_control_test.go +++ b/server/channels/app/access_control_test.go @@ -4,6 +4,7 @@ package app import ( + "errors" "net/http" "testing" @@ -13,10 +14,16 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost/server/v8/channels/app/properties" + "github.com/mattermost/mattermost/server/v8/channels/store" storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" ) +func celSafeName() string { + return "f_" + model.NewId() +} + func TestCreateOrUpdateAccessControlPolicy(t *testing.T) { th := Setup(t).InitBasic(t) @@ -2119,6 +2126,85 @@ func TestHasPermissionToFileAction(t *testing.T) { }) } +func TestResolveSystemRole(t *testing.T) { + t.Run("system_admin highest precedence", func(t *testing.T) { + assert.Equal(t, model.SystemAdminRoleId, ResolveSystemRole("system_user system_admin")) + }) + t.Run("system_guest before system_user", func(t *testing.T) { + assert.Equal(t, model.SystemGuestRoleId, ResolveSystemRole("system_user system_guest")) + }) + t.Run("system_user", func(t *testing.T) { + assert.Equal(t, model.SystemUserRoleId, ResolveSystemRole("system_user")) + }) + t.Run("falls back to system_user when no recognised base role", func(t *testing.T) { + assert.Equal(t, model.SystemUserRoleId, ResolveSystemRole("custom_role")) + }) + t.Run("empty string defaults to system_user", func(t *testing.T) { + assert.Equal(t, model.SystemUserRoleId, ResolveSystemRole("")) + }) +} + +func TestGetSubjectChannelRole(t *testing.T) { + th := Setup(t).InitBasic(t) + + t.Run("returns channel_admin for channel creator (SchemeAdmin)", func(t *testing.T) { + // BasicUser is the creator of BasicChannel and is auto-promoted to + // channel admin via SchemeAdmin. + role, appErr := th.App.GetSubjectChannelRole(th.Context, th.BasicUser.Id, th.BasicChannel.Id) + require.Nil(t, appErr) + assert.Equal(t, model.ChannelAdminRoleId, role) + }) + + // Non-members have no channel-scoped role to report. The function's + // contract — documented in the docstring — is to return ("", nil) + // and let the caller decide; previously it synthesised a guess from + // the caller-supplied systemRoles (channel_user for system_user, + // channel_guest for system_guest), which leaked channel-scope data + // from the user's system membership. Callers (attachChannelScopedRole, + // simulator subject builders) now gate on the empty string and skip + // the channel scope. + t.Run("returns empty role for non-member", func(t *testing.T) { + // Cover the real existence path (not the unknown-user path): + // create an actual user who is deliberately NOT added to + // BasicChannel so the store lookup hits ErrNotFound on the + // ChannelMember row rather than ErrNotFound on the User row. + // GetSubjectChannelRole must report no channel-scoped role + // for them — never fabricate one from system roles. + nonMember := th.CreateUser(t) + role, appErr := th.App.GetSubjectChannelRole(th.Context, nonMember.Id, th.BasicChannel.Id) + require.Nil(t, appErr) + assert.Equal(t, "", role) + }) +} + +func TestBuildAccessControlSubjectScopedRoles(t *testing.T) { + th := Setup(t).InitBasic(t) + + t.Run("populates system scope only when channelID empty", func(t *testing.T) { + subject, appErr := th.App.BuildAccessControlSubject(th.Context, th.BasicUser.Id, th.BasicUser.Roles, "") + require.Nil(t, appErr) + require.NotNil(t, subject) + require.Len(t, subject.ScopedRoles, 1) + assert.Equal(t, model.AccessControlSubjectScopeSystem, subject.ScopedRoles[0].Scope) + assert.Equal(t, model.SystemUserRoleId, subject.ScopedRoles[0].Role) + // Legacy field retained for backward compat + assert.Equal(t, th.BasicUser.Roles, subject.Role) + }) + + t.Run("populates both scopes when channelID provided", func(t *testing.T) { + subject, appErr := th.App.BuildAccessControlSubject(th.Context, th.BasicUser.Id, th.BasicUser.Roles, th.BasicChannel.Id) + require.Nil(t, appErr) + require.NotNil(t, subject) + + systemRole := subject.RoleForScope(model.AccessControlSubjectScopeSystem) + channelRole := subject.RoleForScope(model.AccessControlSubjectScopeChannel) + + assert.Equal(t, model.SystemUserRoleId, systemRole) + // BasicUser is the channel creator → channel_admin via SchemeAdmin. + assert.Equal(t, model.ChannelAdminRoleId, channelRole) + }) +} + func TestGetRecommendedPublicChannelsForUser(t *testing.T) { th := Setup(t).InitBasic(t) @@ -2233,6 +2319,1932 @@ func TestGetRecommendedPublicChannelsForUser(t *testing.T) { }) } +// TestFilterResponseToEditingRuleScope locks down the post-processing +// that turns a full-stack simulator response into a "this rule only" +// view. Upper-scoped blame entries (system_permission, peer_policy, +// inherited channel_policy) and sibling_rule entries are dropped; +// denies that have no remaining editing-rule-side blame surface as a +// neutral no_applicable_rule chip — the older flip-to-plain-allow +// behavior read as "this rule alone would have allowed this user" +// which is wrong for a permission rule whose filter didn't grant. +// The simulator already restricts contributions, so this filter is +// the defensive backstop. +func TestFilterResponseToEditingRuleScope(t *testing.T) { + t.Run("deny attributed only to upper-scoped policy converts to no_applicable_rule", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u1"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSystemPermission, PolicyName: "Org IL5"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision, "deny solely from upper-scoped blame must normalize to a vacuous allow") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, dec.Blame[0].Source, + "the editing rule is silent on this user — must surface as no_applicable_rule, not a plain allow") + // Outcome stays empty (matches the no_applicable_policy + // convention) so the chip's hasBlame() helper — which filters + // informational outcome=allow entries — picks this marker up. + assert.Empty(t, dec.Blame[0].Outcome) + }) + + t.Run("deny with both this_rule and upper-scoped blame stays a deny but loses the upper entry", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u2"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "download_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "rule1"}, + {Source: model.PolicySimulationBlameSourceSystemPermission, PolicyName: "Org IL5"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["download_file_attachment"] + assert.False(t, dec.Decision, "deny that the draft itself produces must remain a deny") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceThisRule, dec.Blame[0].Source) + }) + + t.Run("allow with sibling_saved alone gains a no_applicable_rule marker so the chip reads 'doesn't apply'", func(t *testing.T) { + // At the "this rule only" scope, the sibling that saved the + // user is by definition out of scope, so "Allowed · another + // rule" is misleading — the chip should read "this rule + // doesn't apply" instead. The sibling_saved entry stays in + // the blame list so the Decision Details modal can still + // build a trace from any expression attached to it. + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u3"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSiblingSaved, RuleName: "rule1"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision) + require.Len(t, dec.Blame, 2, "the synthetic marker is appended; sibling_saved stays for trace rendering") + + sources := []string{dec.Blame[0].Source, dec.Blame[1].Source} + assert.Contains(t, sources, model.PolicySimulationBlameSourceSiblingSaved) + assert.Contains(t, sources, model.PolicySimulationBlameSourceNoApplicableRule) + }) + + t.Run("allow with this_rule allow + sibling_saved keeps the chip allowed (no marker injected)", func(t *testing.T) { + // When the editing rule itself granted the user (this_rule + // outcome=allow), a sibling_saved entry alongside is just + // supplementary "another rule also allowed" context. The + // rule DID contribute, so we must NOT inject the + // no_applicable_rule marker — the chip stays a plain + // "Allowed". + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u3a"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "rule1", Outcome: model.PolicySimulationBlameOutcomeAllow}, + {Source: model.PolicySimulationBlameSourceSiblingSaved, RuleName: "rule1"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision) + require.Len(t, dec.Blame, 2) + for _, b := range dec.Blame { + assert.NotEqual(t, model.PolicySimulationBlameSourceNoApplicableRule, b.Source, + "this_rule allow means the rule did apply — must not inject no_applicable_rule") + } + }) + + t.Run("bare allow with empty blame (role mismatch) gains no_applicable_rule marker", func(t *testing.T) { + // The user-reported regression: when the editing rule + // targets channel_user and the picker drops in a guest + // (channel_guest), the simulator returns + // `{decision: true}` with NO blame at all — it's a vacuous + // allow because the rule doesn't apply to the candidate's + // role. The old default branch left this untouched and the + // chip rendered a misleading plain "Allowed". The filter + // must inject the no_applicable_rule marker so the picker + // shows "this rule doesn't apply" instead. + // + // User.Roles is set to a non-sysadmin role to lock down + // that the sysadmin carve-out introduced in a sibling test + // doesn't accidentally widen and skip the marker for + // regular users. + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u3b", Roles: model.SystemGuestRoleId}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision, "vacuous allow stays an allow — the chip handles the 'doesn't apply' rendering") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, dec.Blame[0].Source) + }) + + t.Run("system admin allow with empty blame stays a plain allow (no marker injected via role fallback)", func(t *testing.T) { + // Sysadmins inherit every channel-level role implicitly, so + // the simulator returns {decision: true} for them without a + // this_rule blame — same shape as the "role doesn't apply" + // vacuous allow used for guests. Without a sysadmin + // carve-out the picker would mis-label the sysadmin row as + // "this rule doesn't apply" when in fact the rule does + // apply via role fallback. Verifies the User.IsSystemAdmin + // check on the result row is wired correctly. + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "uadmin", Roles: model.SystemAdminRoleId + " " + model.SystemUserRoleId}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision) + assert.Empty(t, dec.Blame, "sysadmin candidates must not get the no_applicable_rule marker — the rule applies to them via role fallback") + }) + + t.Run("system admin allow with sibling_saved blame still skips the marker (role fallback wins)", func(t *testing.T) { + // Same reasoning as the bare-allow sysadmin case: even if + // the simulator surfaces a sibling_saved blame for a + // sysadmin (rare; sysadmins normally bypass the OR-bucket + // machinery), the marker must NOT be injected — the rule + // still applies via role fallback regardless of which + // sibling carried the verdict. + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "uadmin2", Roles: model.SystemAdminRoleId}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSiblingSaved, RuleName: "rule1"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision) + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceSiblingSaved, dec.Blame[0].Source, + "sibling_saved survives, but no_applicable_rule is NOT appended for sysadmins") + }) + + t.Run("allow already attributed to no_applicable_policy is NOT shadowed by no_applicable_rule", func(t *testing.T) { + // When the simulator already explained "the whole policy + // doesn't apply to this user" via no_applicable_policy, the + // rule-scoped marker is strictly less informative — we + // deliberately don't append it so the chip continues to + // render the wider "policy doesn't apply" label. + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u3c"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: true, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceNoApplicablePolicy}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision) + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicablePolicy, dec.Blame[0].Source, + "the wider policy-level marker must survive untouched; no_applicable_rule must not shadow it") + }) + + t.Run("inherited channel_policy blame converts to no_applicable_rule", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u4"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceChannelPolicy, PolicyName: "Parent"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision, "channel_policy blame is upper-scoped, so the deny must normalize to vacuous allow") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, dec.Blame[0].Source) + }) + + t.Run("per-session decisions are filtered alongside the user-level ones", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u5"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSystemPermission}, + }, + }, + }, + Sessions: []model.PolicySimulationSession{{ + ID: "s1", + Device: "Macbook", + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSystemPermission}, + }, + }, + }, + }, { + ID: "s2", + Device: "iPhone", + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "rule1"}, + {Source: model.PolicySimulationBlameSourceSystemPermission}, + }, + }, + }, + }}, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + userDec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, userDec.Decision, "user-level deny solely from upper-scoped normalizes to vacuous allow") + require.Len(t, userDec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, userDec.Blame[0].Source) + + sess1Dec := resp.Results[0].Sessions[0].Decisions["upload_file_attachment"] + assert.True(t, sess1Dec.Decision, "session-level deny solely from upper-scoped normalizes to vacuous allow") + require.Len(t, sess1Dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, sess1Dec.Blame[0].Source) + + sess2Dec := resp.Results[0].Sessions[1].Decisions["upload_file_attachment"] + assert.False(t, sess2Dec.Decision, "session-level deny with this_rule blame stays a deny") + require.Len(t, sess2Dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceThisRule, sess2Dec.Blame[0].Source) + }) + + t.Run("peer_policy blame is dropped in this_rule mode (peers are not the editing rule)", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u6"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourcePeerPolicy, PolicyName: "IL5 Block", RuleName: "r1", Expression: "user.attributes.clearance == \"il5\""}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision, "deny coming from a peer policy is irrelevant in this rule mode and must normalize to vacuous allow") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, dec.Blame[0].Source) + }) + + // This is the regression that motivated the toggle rename: when + // editing rule "channel_users" and the policy ALSO has a sibling + // "channel_admins" rule that allowed the candidate, the picker + // previously surfaced the sibling allow under "this policy only". + // In "this rule only" mode that sibling_rule blame must be dropped. + t.Run("sibling_rule blame is dropped in this_rule mode (only the editing rule counts)", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u7"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSiblingRule, RuleName: "channel_admins"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "channel_users") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.True(t, dec.Decision, "sibling-rule deny must normalize to vacuous allow when scoped to a specific editing rule") + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceNoApplicableRule, dec.Blame[0].Source) + }) + + // When two different rules both emit this_rule blame on the same + // decision (theoretically possible if the simulator's contribution + // restriction misfires) the filter keeps only the entry whose + // rule_name matches the editing rule. Belt-and-suspenders defence + // behind the simulator's contribution gate. + t.Run("this_rule blame is filtered to the editing rule by name", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u8"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "channel_admins"}, + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "channel_users"}, + }, + }, + }, + }}, + } + + filterResponseToEditingRuleScope(resp, "channel_users") + + dec := resp.Results[0].Decisions["upload_file_attachment"] + assert.False(t, dec.Decision, "deny from the editing rule survives") + require.Len(t, dec.Blame, 1) + assert.Equal(t, "channel_users", dec.Blame[0].RuleName, + "only the editing rule's blame is kept; the other this_rule entry is dropped") + }) +} + +// TestEnrichBlameForDraftScope locks down the post-processing that +// turns the simulator's raw response into the picker-friendly view: it +// (a) injects expression text on draft-side blame entries, (b) +// reclassifies system_permission blame whose blamed policy lives at +// the same scope as the draft (same Type + same Imports) into +// peer_policy and copies its expression in too, (c) leaves truly +// upper-scoped sources expression-less so the UI cannot leak them. +func TestEnrichBlameForDraftScope(t *testing.T) { + t.Helper() + + t.Run("draft-side blame (this_rule / sibling_rule / sibling_saved) gains the expression from params.Policy.Rules", func(t *testing.T) { + mockACS := &mocks.AccessControlServiceInterface{} + draft := &model.AccessControlPolicy{ + ID: "draft1", + Type: model.AccessControlPolicyTypePermission, + Rules: []model.AccessControlPolicyRule{ + {Name: "r1", Expression: "user.attributes.region == \"us\""}, + {Name: "r2", Expression: "user.attributes.department == \"engineering\""}, + }, + } + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u1"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceThisRule, RuleName: "r1"}, + }, + }, + "download_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{ + {Source: model.PolicySimulationBlameSourceSiblingRule, RuleName: "r2"}, + }, + }, + }, + }}, + } + + enrichBlameForDraftScope(request.EmptyContext(nil), mockACS, draft, resp) + + uploadBlame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0] + assert.Equal(t, "user.attributes.region == \"us\"", uploadBlame.Expression, "this_rule blame must receive the rule's expression") + + downloadBlame := resp.Results[0].Decisions["download_file_attachment"].Blame[0] + assert.Equal(t, "user.attributes.department == \"engineering\"", downloadBlame.Expression, "sibling_rule blame must receive the rule's expression") + + mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything) + }) + + t.Run("system_permission blame whose blamed policy shares scope with the draft is reclassified to peer_policy and gains its expression", func(t *testing.T) { + mockACS := &mocks.AccessControlServiceInterface{} + draft := &model.AccessControlPolicy{ + ID: "draft1", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Name: "rd", Expression: "true"}, + }, + } + peer := &model.AccessControlPolicy{ + ID: "peer1", + Name: "IL5 Block", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Name: "p1", Expression: "user.attributes.clearance == \"il5\""}, + }, + } + mockACS.On("GetPolicy", mock.Anything, "peer1").Return(peer, (*model.AppError)(nil)) + + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u2"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceSystemPermission, + PolicyID: "peer1", + PolicyName: "IL5 Block", + RuleName: "p1", + }}, + }, + }, + }}, + } + + enrichBlameForDraftScope(request.EmptyContext(nil), mockACS, draft, resp) + + dec := resp.Results[0].Decisions["upload_file_attachment"] + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourcePeerPolicy, dec.Blame[0].Source, "same-scope blame must be reclassified to peer_policy") + assert.Equal(t, "user.attributes.clearance == \"il5\"", dec.Blame[0].Expression, "the failing rule's expression must be injected from the peer policy") + assert.Equal(t, "IL5 Block", dec.Blame[0].PolicyName) + + mockACS.AssertExpectations(t) + }) + + t.Run("system_permission blame whose blamed policy lives at a different scope stays opaque and gets no expression", func(t *testing.T) { + mockACS := &mocks.AccessControlServiceInterface{} + draft := &model.AccessControlPolicy{ + ID: "draft1", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{}, // top-level (system console) draft. + } + upperScoped := &model.AccessControlPolicy{ + ID: "upper1", + Name: "Org Wide Lockdown", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{"some-parent-id"}, // a child of some other parent — different scope. + Rules: []model.AccessControlPolicyRule{ + {Name: "u1", Expression: "user.attributes.region == \"sandbox\""}, + }, + } + mockACS.On("GetPolicy", mock.Anything, "upper1").Return(upperScoped, (*model.AppError)(nil)) + + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u3"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceSystemPermission, + PolicyID: "upper1", + PolicyName: "Org Wide Lockdown", + RuleName: "u1", + }}, + }, + }, + }}, + } + + enrichBlameForDraftScope(request.EmptyContext(nil), mockACS, draft, resp) + + dec := resp.Results[0].Decisions["upload_file_attachment"] + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceSystemPermission, dec.Blame[0].Source, "different-scope blame must stay system_permission") + assert.Empty(t, dec.Blame[0].Expression, "upper-scoped blame must NEVER carry the expression — that would leak content of a policy outside the editing scope") + }) + + t.Run("channel_policy blame is never reclassified or enriched", func(t *testing.T) { + mockACS := &mocks.AccessControlServiceInterface{} + draft := &model.AccessControlPolicy{ + ID: "draft1", + Type: model.AccessControlPolicyTypePermission, + } + + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u4"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceChannelPolicy, + PolicyID: "channel-policy-1", + PolicyName: "Parent", + RuleName: "r1", + }}, + }, + }, + }}, + } + + enrichBlameForDraftScope(request.EmptyContext(nil), mockACS, draft, resp) + + dec := resp.Results[0].Decisions["upload_file_attachment"] + require.Len(t, dec.Blame, 1) + assert.Equal(t, model.PolicySimulationBlameSourceChannelPolicy, dec.Blame[0].Source, "channel_policy blame must never be reclassified") + assert.Empty(t, dec.Blame[0].Expression) + mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything) + }) + + t.Run("session-level decisions are enriched alongside the user-level ones, and GetPolicy is cached per policy_id", func(t *testing.T) { + mockACS := &mocks.AccessControlServiceInterface{} + draft := &model.AccessControlPolicy{ + ID: "draft1", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{}, + } + peer := &model.AccessControlPolicy{ + ID: "peer1", + Name: "IL5 Block", + Type: model.AccessControlPolicyTypePermission, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Name: "p1", Expression: "user.attributes.clearance == \"il5\""}, + }, + } + + // Set up GetPolicy with .Once() so the assertion below proves + // caching: even though peer1 appears in three blame entries + // across the response, the helper must only resolve it once + // for the request. + mockACS.On("GetPolicy", mock.Anything, "peer1").Return(peer, (*model.AppError)(nil)).Once() + + makeBlame := func() []model.PolicySimulationBlame { + return []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceSystemPermission, + PolicyID: "peer1", + PolicyName: "IL5 Block", + RuleName: "p1", + }} + } + + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u5"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": {Decision: false, Blame: makeBlame()}, + }, + Sessions: []model.PolicySimulationSession{ + {ID: "s1", Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": {Decision: false, Blame: makeBlame()}, + }}, + {ID: "s2", Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": {Decision: false, Blame: makeBlame()}, + }}, + }, + }}, + } + + enrichBlameForDraftScope(request.EmptyContext(nil), mockACS, draft, resp) + + assert.Equal(t, model.PolicySimulationBlameSourcePeerPolicy, resp.Results[0].Decisions["upload_file_attachment"].Blame[0].Source) + assert.Equal(t, model.PolicySimulationBlameSourcePeerPolicy, resp.Results[0].Sessions[0].Decisions["upload_file_attachment"].Blame[0].Source) + assert.Equal(t, model.PolicySimulationBlameSourcePeerPolicy, resp.Results[0].Sessions[1].Decisions["upload_file_attachment"].Blame[0].Source) + mockACS.AssertExpectations(t) + }) +} + +// TestRedactSimulationAttributesForCaller covers the CPA-visibility +// + access-mode post-processor that strips attribute values from a +// simulator response for non-system-admin callers. The simulator +// surfaces per-user (and per-session) attribute snapshots so the +// Decision Details panel can read a deny like an evaluation trace — +// channel and team admins must not see values for fields configured +// as `visibility: hidden`, source_only, or shared_only because each +// of those tiers is hidden from them on the user profile page +// itself. The redactor also walks every blame entry's evaluation +// tree and blanks `ActualValue` on every leaf whose `Attribute` +// references a protected field; the top-level Attributes snapshot +// is not the only leak surface. +func TestRedactSimulationAttributesForCaller(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + rctx := th.emptyContextWithCallerID(anonymousCallerId) + + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + require.True(t, ok, "SetLicense should return true") + defer th.App.Srv().SetLicense(nil) + + cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName) + require.Nil(t, gErr) + + // Two CPA fields: one hidden (the realistic non-plugin path) and + // one visible. Source_only and shared_only access modes are + // covered by TestCPAFieldIsProtectedForChannelAdmin below because + // they require `protected: true` (and therefore a plugin caller) + // to create through the normal app path. + createdHidden, hAppErr := th.App.CreatePropertyField(rctx, &model.PropertyField{ + GroupID: cpaGroup.ID, + Name: celSafeName(), + Type: model.PropertyFieldTypeText, + ObjectType: model.PropertyFieldObjectTypeUser, + TargetType: string(model.PropertyFieldTargetLevelSystem), + Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden}, + }, false, "") + require.Nil(t, hAppErr) + + createdVisible, vAppErr := th.App.CreatePropertyField(rctx, &model.PropertyField{ + GroupID: cpaGroup.ID, + Name: celSafeName(), + Type: model.PropertyFieldTypeText, + ObjectType: model.PropertyFieldObjectTypeUser, + TargetType: string(model.PropertyFieldTargetLevelSystem), + Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityWhenSet}, + }, false, "") + require.Nil(t, vAppErr) + + hiddenName := createdHidden.Name + visibleName := createdVisible.Name + + // makeResp builds a fresh response that exercises every leak + // surface in one shot: top-level user attributes, top-level + // session attributes, the deny blame's evaluation tree (root + + // per-attribute leaf), and a per-merged-rule evaluation tree. + // Each tier carries a value for BOTH CPA fields so the test can + // assert "protected: blanked" and "visible: preserved" on every + // surface in the same pass. + mkLeaf := func(name, value string) model.PolicySimulationEvaluationNode { + return model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: userAttributesPathPrefix + name, + ActualValue: value, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + } + } + mkResp := func() *model.PolicySimulationResponse { + topLevelTree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindAnd, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + Children: []model.PolicySimulationEvaluationNode{ + mkLeaf(hiddenName, "il5"), + mkLeaf(visibleName, "us"), + }, + } + mergedRuleTree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: userAttributesPathPrefix + hiddenName, + ActualValue: "il5", + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + } + return &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: model.NewId()}, + Attributes: map[string]string{ + hiddenName: "il5", + visibleName: "us", + }, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceThisRule, + RuleName: "rule1", + EvaluationTree: topLevelTree, + MergedRules: []model.PolicySimulationMergedRule{{ + Name: "rule1", + EvaluationTree: mergedRuleTree, + }}, + }}, + }, + }, + Sessions: []model.PolicySimulationSession{{ + ID: "s1", + Attributes: map[string]string{ + hiddenName: "il5", + visibleName: "us", + }, + }}, + }}, + } + } + + t.Run("system admins see every attribute value on every surface", func(t *testing.T) { + resp := mkResp() + th.App.RedactSimulationAttributesForCaller(rctx, resp, true) + + // Top-level snapshot (user + session): every field passes through. + for _, name := range []string{hiddenName, visibleName} { + assert.NotEmpty(t, resp.Results[0].Attributes[name], "system admin must see %q in user-level attributes", name) + assert.NotEmpty(t, resp.Results[0].Sessions[0].Attributes[name], "system admin must see %q in session attributes", name) + } + + // Evaluation tree leaves keep their ActualValue. + blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0] + for _, child := range blame.EvaluationTree.Children { + assert.NotEmpty(t, child.ActualValue, "system admin must see ActualValue on every leaf, including %q", child.Attribute) + } + assert.NotEmpty(t, blame.MergedRules[0].EvaluationTree.ActualValue, "merged-rule tree ActualValue preserved for system admin") + }) + + t.Run("non-system-admin callers do not see hidden values on any surface", func(t *testing.T) { + resp := mkResp() + th.App.RedactSimulationAttributesForCaller(rctx, resp, false) + + // Top-level snapshot redactions: hidden field removed from the + // user-level and session Attributes maps; the visible field + // passes through. + _, presentUser := resp.Results[0].Attributes[hiddenName] + _, presentSession := resp.Results[0].Sessions[0].Attributes[hiddenName] + assert.False(t, presentUser, "hidden user attribute must be stripped for non-system-admin caller") + assert.False(t, presentSession, "hidden session attribute must be stripped for non-system-admin caller") + assert.Equal(t, "us", resp.Results[0].Attributes[visibleName]) + assert.Equal(t, "us", resp.Results[0].Sessions[0].Attributes[visibleName]) + + // Evaluation tree redactions: leaf whose Attribute references + // the hidden field has ActualValue blanked; the visible + // field's leaf keeps its value — that's the value the channel + // admin would see on the user profile page itself. + blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0] + require.Len(t, blame.EvaluationTree.Children, 2) + leafByAttribute := map[string]model.PolicySimulationEvaluationNode{} + for _, child := range blame.EvaluationTree.Children { + leafByAttribute[child.Attribute] = child + } + assert.Empty(t, leafByAttribute[userAttributesPathPrefix+hiddenName].ActualValue, + "hidden leaf must have ActualValue blanked") + assert.Equal(t, "us", leafByAttribute[userAttributesPathPrefix+visibleName].ActualValue, + "visible leaf must keep ActualValue") + + // Merged-rule subtree gets the same treatment — that's the + // per-rule view the picker renders alongside the merged tree. + assert.Empty(t, blame.MergedRules[0].EvaluationTree.ActualValue, + "merged-rule leaf for the hidden field must have ActualValue blanked") + }) + + t.Run("nil response is a safe no-op", func(t *testing.T) { + require.NotPanics(t, func() { + th.App.RedactSimulationAttributesForCaller(rctx, nil, false) + }) + }) + + t.Run("response with no attribute surfaces short-circuits before CPA lookup", func(t *testing.T) { + // Most common shape: a deny chip alone, no Decision Details + // panel ever opened. Both the top-level Attributes map and + // every blame's evaluation tree are nil. The redactor must + // return immediately without paying for SearchPropertyFields. + 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", + }}, + }, + }, + }}, + } + require.NotPanics(t, func() { + th.App.RedactSimulationAttributesForCaller(rctx, resp, false) + }) + assert.Nil(t, resp.Results[0].Attributes) + }) +} + +// TestCPAFieldIsProtectedForChannelAdmin covers the per-field +// predicate used to build the protected-name set. Source_only and +// shared_only access modes require `protected: true` and a +// source_plugin_id, which only a plugin caller can set through the +// app — so this is a pure unit test against directly-constructed +// CPAField values rather than going through the app's create path. +func TestCPAFieldIsProtectedForChannelAdmin(t *testing.T) { + mainHelper.Parallel(t) + + tests := []struct { + name string + field *model.CPAField + want bool + }{ + { + name: "visibility=hidden is protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{Visibility: model.CustomProfileAttributesVisibilityHidden}, + }, + want: true, + }, + { + name: "access_mode=source_only is protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityWhenSet, + AccessMode: model.PropertyAccessModeSourceOnly, + }, + }, + want: true, + }, + { + name: "access_mode=shared_only is protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityWhenSet, + AccessMode: model.PropertyAccessModeSharedOnly, + }, + }, + want: true, + }, + { + name: "visibility=when_set + public access mode is NOT protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityWhenSet, + AccessMode: model.PropertyAccessModePublic, + }, + }, + want: false, + }, + { + name: "visibility=always + public access mode is NOT protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityAlways, + AccessMode: model.PropertyAccessModePublic, + }, + }, + want: false, + }, + { + name: "empty access mode defaults to public and is NOT protected", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityWhenSet, + AccessMode: "", + }, + }, + want: false, + }, + { + name: "visibility=hidden wins over public access mode (still protected)", + field: &model.CPAField{ + Attrs: model.CPAAttrs{ + Visibility: model.CustomProfileAttributesVisibilityHidden, + AccessMode: model.PropertyAccessModePublic, + }, + }, + want: true, + }, + { + name: "nil field is not protected (caller short-circuits but the predicate is defensive)", + field: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cpaFieldIsProtectedForChannelAdmin(tt.field) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestRedactProtectedActualValuesInTree is a focused unit test for +// the tree walker. Exercises: +// - protected leaves at the root level get ActualValue blanked +// - protected leaves nested under compound nodes get ActualValue +// blanked +// - unprotected leaves are untouched +// - non-user-attribute leaves (e.g. function call results, raw +// expressions) are untouched +// - nil node is a safe no-op +func TestRedactProtectedActualValuesInTree(t *testing.T) { + mainHelper.Parallel(t) + + protected := map[string]struct{}{ + "Clearance": {}, + "NetworkZone": {}, + } + + t.Run("redacts ActualValue on protected leaves at every depth", func(t *testing.T) { + tree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindAnd, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + Children: []model.PolicySimulationEvaluationNode{ + { + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: "user.attributes.Clearance", + ActualValue: "il5", + }, + { + Kind: model.PolicySimulationEvaluationKindOr, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + Children: []model.PolicySimulationEvaluationNode{ + { + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: "user.attributes.NetworkZone", + ActualValue: "vpn", + }, + { + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: "user.attributes.Region", + ActualValue: "us", + }, + }, + }, + { + Kind: model.PolicySimulationEvaluationKindFunction, + + // Function leaf with no attribute path (e.g. a + // constant comparison or receiver-style call + // where we couldn't infer the attribute) must + // be left alone — there's no protected user + // data to leak. + Attribute: "", + ActualValue: "some-internal-value", + }, + }, + } + + redactProtectedActualValuesInTree(tree, protected) + + // Root-level Clearance leaf: blanked. + assert.Empty(t, tree.Children[0].ActualValue, "Clearance leaf must be blanked") + + // Nested NetworkZone (protected) blanked; nested Region + // (public) preserved. + assert.Empty(t, tree.Children[1].Children[0].ActualValue, "NetworkZone leaf must be blanked") + assert.Equal(t, "us", tree.Children[1].Children[1].ActualValue, "Region leaf must be preserved") + + // Function leaf with no attribute path is left alone. + assert.Equal(t, "some-internal-value", tree.Children[2].ActualValue, "non-user-attribute leaf must be preserved") + }) + + t.Run("nil node is a safe no-op", func(t *testing.T) { + require.NotPanics(t, func() { + redactProtectedActualValuesInTree(nil, protected) + }) + }) + + t.Run("empty protected set is a safe no-op", func(t *testing.T) { + tree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: "user.attributes.Clearance", + ActualValue: "il5", + } + redactProtectedActualValuesInTree(tree, nil) + + // Helper itself is unconditional but the public entry point + // short-circuits before calling it with an empty set — + // either way, an empty set must not zap anything. + assert.Equal(t, "il5", tree.ActualValue) + }) +} + +// TestIsProtectedAttributePath pins the path-prefix matcher used by +// the tree walker. Covers the canonical CEL prefix, mis-prefixed +// paths, empty paths, and empty protected sets. +func TestIsProtectedAttributePath(t *testing.T) { + mainHelper.Parallel(t) + protected := map[string]struct{}{"Clearance": {}} + + t.Run("returns true for the canonical user.attributes. form", func(t *testing.T) { + assert.True(t, isProtectedAttributePath("user.attributes.Clearance", protected)) + }) + + t.Run("returns false for non-user-attribute paths", func(t *testing.T) { + // Resource / session / channel paths must not collide with + // the user-attributes namespace — only `user.attributes.*` + // is in scope for the CPA visibility filter. + assert.False(t, isProtectedAttributePath("session.network_status", protected)) + assert.False(t, isProtectedAttributePath("resource.id", protected)) + assert.False(t, isProtectedAttributePath("channel.member_count", protected)) + }) + + t.Run("returns false for paths whose suffix is not in the protected set", func(t *testing.T) { + assert.False(t, isProtectedAttributePath("user.attributes.Region", protected)) + }) + + t.Run("returns false for empty inputs", func(t *testing.T) { + assert.False(t, isProtectedAttributePath("", protected)) + assert.False(t, isProtectedAttributePath("user.attributes.Clearance", nil)) + assert.False(t, isProtectedAttributePath("user.attributes.", protected), + "empty suffix must not match — that's a malformed path, not a protected reference") + }) +} + +// TestStripProtectedAttributes is a focused unit test for the +// top-level attribute-map pruner. Exercises both vertical levels +// (user + session) and the no-op edge cases (empty protected set, +// nil response). +func TestStripProtectedAttributes(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("removes protected keys from user and session attribute maps", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u1"}, + Attributes: map[string]string{ + "Clearance": "il5", + "Region": "us", + }, + Sessions: []model.PolicySimulationSession{{ + Attributes: map[string]string{ + "Clearance": "il5", + "NetworkZone": "vpn", + }, + }}, + }}, + } + stripProtectedAttributes(resp, map[string]struct{}{ + "Clearance": {}, "NetworkZone": {}, + }) + + _, c1 := resp.Results[0].Attributes["Clearance"] + assert.False(t, c1, "Clearance must be stripped from user-level attributes") + assert.Equal(t, "us", resp.Results[0].Attributes["Region"], "Region must survive") + + _, c2 := resp.Results[0].Sessions[0].Attributes["Clearance"] + assert.False(t, c2, "Clearance must be stripped from session attributes") + _, n := resp.Results[0].Sessions[0].Attributes["NetworkZone"] + assert.False(t, n, "NetworkZone must be stripped from session attributes") + }) + + t.Run("empty protected set is a no-op", func(t *testing.T) { + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + Attributes: map[string]string{"Region": "us"}, + }}, + } + stripProtectedAttributes(resp, nil) + assert.Equal(t, "us", resp.Results[0].Attributes["Region"]) + }) + + t.Run("nil response is a safe no-op", func(t *testing.T) { + require.NotPanics(t, func() { + stripProtectedAttributes(nil, map[string]struct{}{"Anything": {}}) + }) + }) +} + +// TestClearAllSimulationAttributesAndTrees pins the fail-closed +// default used by RedactSimulationAttributesForCaller when the CPA +// lookup itself errors. Every attribute map (user + session) AND +// every evaluation tree's ActualValue (top-level + per-merged-rule) +// must be wiped so a transient store failure cannot leak protected +// values through the simulator. +func TestClearAllSimulationAttributesAndTrees(t *testing.T) { + mainHelper.Parallel(t) + + resp := &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: "u1"}, + Attributes: map[string]string{"Region": "us", "Clearance": "il5"}, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceThisRule, + EvaluationTree: &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindAnd, + Children: []model.PolicySimulationEvaluationNode{{ + Attribute: "user.attributes.Clearance", + ActualValue: "il5", + }, { + Attribute: "user.attributes.Region", + ActualValue: "us", + }}, + }, + MergedRules: []model.PolicySimulationMergedRule{{ + Name: "rule1", + EvaluationTree: &model.PolicySimulationEvaluationNode{ + Attribute: "user.attributes.Clearance", + ActualValue: "il5", + }, + }}, + }}, + }, + }, + Sessions: []model.PolicySimulationSession{{ + Attributes: map[string]string{"NetworkZone": "vpn"}, + }}, + }, { + User: &model.User{Id: "u2"}, + Attributes: map[string]string{"Region": "eu"}, + }}, + } + + clearAllSimulationAttributes(resp) + clearAllEvaluationTreeActualValues(resp) + + // Every Attributes map cleared (user + session) on both rows. + for _, r := range resp.Results { + assert.Nil(t, r.Attributes, "user-level attributes must be cleared") + for _, s := range r.Sessions { + assert.Nil(t, s.Attributes, "session-level attributes must be cleared") + } + } + + // Every tree leaf's ActualValue cleared — including nested + // children and the merged-rule subtree. + blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0] + for _, child := range blame.EvaluationTree.Children { + assert.Empty(t, child.ActualValue, "leaf %q ActualValue must be cleared", child.Attribute) + } + assert.Empty(t, blame.MergedRules[0].EvaluationTree.ActualValue, "merged-rule leaf ActualValue must be cleared") +} + +// makeSimulationResponseForRedactionTest builds a simulator response +// shaped like the real picker output: top-level user/session +// attribute snapshots AND a deny blame whose evaluation tree carries +// per-leaf `ActualValue`s (including a per-merged-rule subtree). One +// leaf references `protected` and one references `public`; callers +// can vary which CPA field names are protected to drive the +// assertions in each redaction scenario. +func makeSimulationResponseForRedactionTest(protectedName, publicName, protectedValue, publicValue string) *model.PolicySimulationResponse { + mkLeaf := func(name, value string) model.PolicySimulationEvaluationNode { + return model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: userAttributesPathPrefix + name, + ActualValue: value, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + } + } + topLevelTree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindAnd, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + Children: []model.PolicySimulationEvaluationNode{ + mkLeaf(protectedName, protectedValue), + mkLeaf(publicName, publicValue), + }, + } + mergedRuleTree := &model.PolicySimulationEvaluationNode{ + Kind: model.PolicySimulationEvaluationKindCompare, + Attribute: userAttributesPathPrefix + protectedName, + ActualValue: protectedValue, + Outcome: model.PolicySimulationEvaluationOutcomeFalse, + } + return &model.PolicySimulationResponse{ + Results: []model.PolicySimulationUserResult{{ + User: &model.User{Id: model.NewId()}, + Attributes: map[string]string{ + protectedName: protectedValue, + publicName: publicValue, + }, + Decisions: map[string]model.PolicySimulationActionDecision{ + "upload_file_attachment": { + Decision: false, + Blame: []model.PolicySimulationBlame{{ + Source: model.PolicySimulationBlameSourceThisRule, + RuleName: "rule1", + EvaluationTree: topLevelTree, + MergedRules: []model.PolicySimulationMergedRule{{ + Name: "rule1", + EvaluationTree: mergedRuleTree, + }}, + }}, + }, + }, + Sessions: []model.PolicySimulationSession{{ + ID: "s1", + Attributes: map[string]string{ + protectedName: protectedValue, + publicName: publicValue, + }, + }}, + }}, + } +} + +// TestRedactSimulationAttributesForCallerAccessModes exercises the +// non-public access-mode branches of cpaFieldIsProtectedForChannelAdmin +// end to end through RedactSimulationAttributesForCaller. Source_only +// and shared_only fields require `protected: true` (and a source +// plugin ID), so we bypass the App-level CreatePropertyField path — +// which would reject a non-plugin caller — and insert the fields +// directly into the store. This proves the full pipeline (predicate + +// protected-set + top-level pruner + tree walker) treats these +// access modes the same as `visibility: hidden`. +func TestRedactSimulationAttributesForCallerAccessModes(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + rctx := th.emptyContextWithCallerID(anonymousCallerId) + + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + require.True(t, ok, "SetLicense should return true") + defer th.App.Srv().SetLicense(nil) + + cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName) + require.Nil(t, gErr) + + createProtectedField := func(t *testing.T, accessMode string) *model.PropertyField { + t.Helper() + field := &model.PropertyField{ + GroupID: cpaGroup.ID, + Name: celSafeName(), + Type: model.PropertyFieldTypeText, + ObjectType: model.PropertyFieldObjectTypeUser, + TargetType: string(model.PropertyFieldTargetLevelSystem), + Attrs: model.StringInterface{ + model.PropertyAttrsProtected: true, + model.PropertyAttrsAccessMode: accessMode, + model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin", + }, + } + created, err := th.Store.PropertyField().Create(field) + require.NoError(t, err, + "protected %s fields must be insertable directly via the store (the app's CreatePropertyField hook rejects non-plugin callers, which is unrelated to what this test exercises)", + accessMode) + return created + } + + publicField := &model.PropertyField{ + GroupID: cpaGroup.ID, + Name: celSafeName(), + Type: model.PropertyFieldTypeText, + ObjectType: model.PropertyFieldObjectTypeUser, + TargetType: string(model.PropertyFieldTargetLevelSystem), + Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityWhenSet}, + } + createdPublic, vAppErr := th.App.CreatePropertyField(rctx, publicField, false, "") + require.Nil(t, vAppErr) + publicName := createdPublic.Name + + assertRedactedAgainst := func(t *testing.T, protectedName string) { + t.Helper() + resp := makeSimulationResponseForRedactionTest(protectedName, publicName, "il5", "us") + th.App.RedactSimulationAttributesForCaller(rctx, resp, false) + + // Top-level user + session snapshots: protected field removed, + // public field preserved on both surfaces. + _, presentUser := resp.Results[0].Attributes[protectedName] + assert.False(t, presentUser, "protected user attribute must be stripped for channel admin") + assert.Equal(t, "us", resp.Results[0].Attributes[publicName], "public user attribute must be preserved") + + _, presentSession := resp.Results[0].Sessions[0].Attributes[protectedName] + assert.False(t, presentSession, "protected session attribute must be stripped for channel admin") + assert.Equal(t, "us", resp.Results[0].Sessions[0].Attributes[publicName], "public session attribute must be preserved") + + // Top-level evaluation tree: protected leaf has ActualValue + // blanked, public leaf preserved. Iterate by attribute to + // avoid relying on child ordering. + blame := resp.Results[0].Decisions["upload_file_attachment"].Blame[0] + require.Len(t, blame.EvaluationTree.Children, 2) + leafByAttribute := map[string]model.PolicySimulationEvaluationNode{} + for _, child := range blame.EvaluationTree.Children { + leafByAttribute[child.Attribute] = child + } + assert.Empty(t, leafByAttribute[userAttributesPathPrefix+protectedName].ActualValue, + "protected leaf must have ActualValue blanked") + assert.Equal(t, "us", leafByAttribute[userAttributesPathPrefix+publicName].ActualValue, + "public leaf must keep ActualValue") + + // Per-merged-rule subtree must receive the same treatment as + // the top-level tree — the picker renders the merged-rule + // tree alongside it, so a leak on either path is equally bad. + assert.Empty(t, blame.MergedRules[0].EvaluationTree.ActualValue, + "merged-rule leaf for the protected field must have ActualValue blanked") + } + + t.Run("source_only access mode is redacted on every surface", func(t *testing.T) { + field := createProtectedField(t, model.PropertyAccessModeSourceOnly) + assertRedactedAgainst(t, field.Name) + }) + + t.Run("shared_only access mode is redacted on every surface", func(t *testing.T) { + field := createProtectedField(t, model.PropertyAccessModeSharedOnly) + assertRedactedAgainst(t, field.Name) + }) +} + +// TestRedactSimulationAttributesForCallerFailClosed exercises the +// branch that runs when protectedCPAFieldNamesForCaller returns an +// error (a transient property-store failure during the CPA lookup). +// The contract is "fail closed": every attribute snapshot AND every +// evaluation-tree leaf's ActualValue must be wiped so the channel +// admin can't see a single protected value just because the CPA +// lookup happened to fail mid-request. We force the error by +// swapping the server's propertyService with one whose +// PropertyGroupStore is mocked to return a synthetic store failure +// for the access-control group lookup. +func TestRedactSimulationAttributesForCallerFailClosed(t *testing.T) { + mainHelper.Parallel(t) + thMock := SetupWithStoreMock(t) + rctx := thMock.emptyContextWithCallerID(anonymousCallerId) + + // Build a fresh property service wired to mocked stores: the + // group store fails on the AccessControl group lookup, which is + // the very first call protectedCPAFieldNamesForCaller makes. + // PropertyField / PropertyValue stores stay attached but never + // fire because we error before getting that far. + mockGroupStore := &storemocks.PropertyGroupStore{} + mockFieldStore := &storemocks.PropertyFieldStore{} + mockValueStore := &storemocks.PropertyValueStore{} + mockGroupStore. + On("Get", model.AccessControlPropertyGroupName). + Return((*model.PropertyGroup)(nil), errors.New("simulated store failure")) + + ps, err := properties.New(properties.ServiceConfig{ + PropertyGroupStore: mockGroupStore, + PropertyFieldStore: mockFieldStore, + PropertyValueStore: mockValueStore, + CallerIDExtractor: func(rctx request.CTX) string { return "" }, + }) + require.NoError(t, err) + + originalPS := thMock.App.Srv().propertyService + thMock.App.Srv().propertyService = ps + defer func() { thMock.App.Srv().propertyService = originalPS }() + + resp := makeSimulationResponseForRedactionTest("Clearance", "Region", "il5", "us") + thMock.App.RedactSimulationAttributesForCaller(rctx, resp, false) + + // Every Attributes map (user + session) cleared — we can't tell + // which fields are protected, so we redact unconditionally. + r := resp.Results[0] + assert.Nil(t, r.Attributes, "fail-closed: user-level attributes must be cleared") + require.Len(t, r.Sessions, 1) + assert.Nil(t, r.Sessions[0].Attributes, "fail-closed: session attributes must be cleared") + + // Every evaluation-tree leaf — top-level + per-merged-rule — + // has ActualValue cleared. The public field's leaf is no + // exception in the fail-closed path: we don't know which fields + // are protected, so we wipe them all. + blame := r.Decisions["upload_file_attachment"].Blame[0] + require.NotNil(t, blame.EvaluationTree) + for _, child := range blame.EvaluationTree.Children { + assert.Empty(t, child.ActualValue, "fail-closed: leaf %q ActualValue must be cleared", child.Attribute) + } + require.Len(t, blame.MergedRules, 1) + assert.Empty(t, blame.MergedRules[0].EvaluationTree.ActualValue, + "fail-closed: merged-rule leaf ActualValue must be cleared") + + mockGroupStore.AssertExpectations(t) +} + +// TestRedactSimulationAttributesForCallerSystemAdminBypass pins the +// privacy-escape hatch for system admins: they always see every +// attribute the simulator recorded, regardless of CPA visibility or +// access_mode. The function must early-return BEFORE talking to the +// property service so a broken store/property service can't degrade +// the sysadmin's view. We assert that by mocking the property +// service with no expectations — any call to it would crash the +// test. +func TestRedactSimulationAttributesForCallerSystemAdminBypass(t *testing.T) { + mainHelper.Parallel(t) + thMock := SetupWithStoreMock(t) + rctx := thMock.emptyContextWithCallerID(anonymousCallerId) + + // Property service is wired to mocks with NO expectations — if + // the sysadmin bypass leaks into the CPA lookup path, the mock + // will panic with "no return value specified" and fail the test + // with a clear signal. + mockGroupStore := &storemocks.PropertyGroupStore{} + mockFieldStore := &storemocks.PropertyFieldStore{} + mockValueStore := &storemocks.PropertyValueStore{} + ps, err := properties.New(properties.ServiceConfig{ + PropertyGroupStore: mockGroupStore, + PropertyFieldStore: mockFieldStore, + PropertyValueStore: mockValueStore, + CallerIDExtractor: func(rctx request.CTX) string { return "" }, + }) + require.NoError(t, err) + + originalPS := thMock.App.Srv().propertyService + thMock.App.Srv().propertyService = ps + defer func() { thMock.App.Srv().propertyService = originalPS }() + + resp := makeSimulationResponseForRedactionTest("Clearance", "Region", "il5", "us") + thMock.App.RedactSimulationAttributesForCaller(rctx, resp, true) + + // Top-level snapshots preserved verbatim. + r := resp.Results[0] + assert.Equal(t, "il5", r.Attributes["Clearance"], "system admin must see protected user attribute") + assert.Equal(t, "us", r.Attributes["Region"], "system admin must see public user attribute") + require.Len(t, r.Sessions, 1) + assert.Equal(t, "il5", r.Sessions[0].Attributes["Clearance"], "system admin must see protected session attribute") + assert.Equal(t, "us", r.Sessions[0].Attributes["Region"], "system admin must see public session attribute") + + // Every leaf's ActualValue preserved on every tree. + blame := r.Decisions["upload_file_attachment"].Blame[0] + require.NotNil(t, blame.EvaluationTree) + leafByAttribute := map[string]model.PolicySimulationEvaluationNode{} + for _, child := range blame.EvaluationTree.Children { + leafByAttribute[child.Attribute] = child + } + assert.Equal(t, "il5", leafByAttribute[userAttributesPathPrefix+"Clearance"].ActualValue, + "sysadmin must see ActualValue on protected leaf in evaluation tree") + assert.Equal(t, "us", leafByAttribute[userAttributesPathPrefix+"Region"].ActualValue, + "sysadmin must see ActualValue on public leaf in evaluation tree") + require.Len(t, blame.MergedRules, 1) + assert.Equal(t, "il5", blame.MergedRules[0].EvaluationTree.ActualValue, + "sysadmin must see ActualValue on merged-rule leaf in evaluation tree") + + // Sanity check: the property service must not have been called. + mockGroupStore.AssertNotCalled(t, "Get", mock.Anything) + mockFieldStore.AssertExpectations(t) + mockValueStore.AssertExpectations(t) +} + +// TestValidatePolicySimulationUsersInScopeChannel covers the channel- +// scope branch of the delegated-simulate input validator. The +// channel-scope branch is reached when a non-system-admin author +// runs the simulator from the channel-settings policy editor; the +// validator must refuse to look outside that channel. We pin: +// - non-member user → 403 users_out_of_scope (the deny-by-default +// bound the api4 handler relies on to short-circuit before the +// simulator ever runs) +// - empty / malformed user_id → 400 invalid_param so the picker +// surfaces a usable validation error +// - invalid channel_id → 400 invalid_param (mismatched ID type) +// - a channel member passes through (negative control for the 403 path) +func TestValidatePolicySimulationUsersInScopeChannel(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + rctx := th.Context + + // BasicChannel is created in InitBasic but BasicUser/BasicUser2 + // are NOT auto-joined to it; add BasicUser explicitly so we have + // a "member" baseline. + th.AddUserToChannel(t, th.BasicUser, th.BasicChannel) + + // outsider is added to the team (so the team-membership path + // doesn't accidentally trip) but never added to BasicChannel. + outsider := th.CreateUser(t) + th.LinkUserToTeam(t, outsider, th.BasicTeam) + + t.Run("channel member passes the check", func(t *testing.T) { + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", th.BasicChannel.Id, []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}}) + require.Nil(t, err, "channel member must pass the scope check") + }) + + t.Run("user not a member of the channel returns 403 users_out_of_scope", func(t *testing.T) { + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", th.BasicChannel.Id, []model.PolicySimulationUserOverride{{UserID: outsider.Id}}) + require.NotNil(t, err, "outsider must be rejected") + assert.Equal(t, http.StatusForbidden, err.StatusCode, + "the contract with the api4 handler is a 403 so the delegated path can short-circuit before invoking the simulator") + assert.Equal(t, "api.access_control_policy.simulate.users_out_of_scope.app_error", err.Id) + }) + + t.Run("empty user_id returns 400 invalid_param", func(t *testing.T) { + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", th.BasicChannel.Id, []model.PolicySimulationUserOverride{{UserID: ""}}) + require.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.Equal(t, "api.context.invalid_param.app_error", err.Id) + }) + + t.Run("malformed user_id returns 400 invalid_param", func(t *testing.T) { + // 25 hex chars is not a valid 26-char model ID; the + // model.IsValidId pre-check must reject before the store + // would be hit (which would otherwise raise a 500). + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", th.BasicChannel.Id, []model.PolicySimulationUserOverride{{UserID: "not-a-valid-id"}}) + require.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.Equal(t, "api.context.invalid_param.app_error", err.Id) + }) + + t.Run("malformed channel_id returns 400 invalid_param", func(t *testing.T) { + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", "not-a-valid-id", []model.PolicySimulationUserOverride{{UserID: th.BasicUser.Id}}) + require.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.Equal(t, "api.context.invalid_param.app_error", err.Id) + }) + + t.Run("first failure short-circuits the rest of the user list", func(t *testing.T) { + // Mixed list: outsider first, member second. The validator + // is a strict gate — one bad apple makes the whole call + // fail. Pins the early-exit ordering the api4 handler + // depends on for the audit trail. + err := th.App.ValidatePolicySimulationUsersInScope(rctx, "", th.BasicChannel.Id, []model.PolicySimulationUserOverride{ + {UserID: outsider.Id}, + {UserID: th.BasicUser.Id}, + }) + require.NotNil(t, err) + assert.Equal(t, http.StatusForbidden, err.StatusCode) + }) +} + +func TestHydrateChannelPolicyActions(t *testing.T) { + t.Run("Channel without an enforced policy is a no-op (no store call, PolicyActions stays nil)", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + // We register the AccessControlPolicy() accessor in case any other + // path touches it, but `GetActionsForPolicy` MUST NOT be called + // when PolicyEnforced is false — that's the whole point of the + // lazy-fetch design. + mockStore.On("AccessControlPolicy").Return(&mockACPStore).Maybe() + + ch := &model.Channel{Id: model.NewId(), PolicyEnforced: false} + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, ch) + require.Nil(t, appErr) + require.Nil(t, ch.PolicyActions, "non-enforced channels must not have an empty map injected") + mockACPStore.AssertNotCalled(t, "GetActionsForPolicy", mock.Anything, mock.Anything) + }) + + t.Run("Nil channel pointer is a defensive no-op", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, nil) + require.Nil(t, appErr) + }) + + t.Run("Membership-only policy hydrates PolicyActions with membership key", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + channelID := model.NewId() + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID). + Return(map[string]bool{model.AccessControlPolicyActionMembership: true}, nil).Once() + + ch := &model.Channel{Id: channelID, PolicyEnforced: true} + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, ch) + require.Nil(t, appErr) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionMembership: true}, ch.PolicyActions) + require.True(t, ch.HasMembershipPolicyAction(), "convenience helper must agree with the map") + mockACPStore.AssertExpectations(t) + }) + + t.Run("Permission-only policy hydrates with the permission key only (no membership)", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + channelID := model.NewId() + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID). + Return(map[string]bool{model.AccessControlPolicyActionUploadFileAttachment: true}, nil).Once() + + ch := &model.Channel{Id: channelID, PolicyEnforced: true} + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, ch) + require.Nil(t, appErr) + require.False(t, ch.HasMembershipPolicyAction(), "permission-only policy must NOT report membership — this is the core bug fix invariant") + require.True(t, ch.HasPolicyAction(model.AccessControlPolicyActionUploadFileAttachment)) + }) + + t.Run("Policy missing in store (deleted between reads) returns nil and sets empty map", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + channelID := model.NewId() + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID). + Return(nil, store.NewErrNotFound("AccessControlPolicy", channelID)).Once() + + ch := &model.Channel{Id: channelID, PolicyEnforced: true} + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, ch) + require.Nil(t, appErr, "ErrNotFound from store must be swallowed — channel row will reconcile on next write") + require.NotNil(t, ch.PolicyActions, "ErrNotFound path must set an empty map so HasPolicyAction returns false") + require.Empty(t, ch.PolicyActions) + }) + + t.Run("Unexpected store error is surfaced and PolicyActions stays nil", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + channelID := model.NewId() + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID). + Return(nil, errors.New("boom")).Once() + + ch := &model.Channel{Id: channelID, PolicyEnforced: true} + appErr := thMock.App.HydrateChannelPolicyActions(thMock.Context, ch) + require.NotNil(t, appErr, "non-not-found store errors must propagate so callers can fail-closed") + require.Equal(t, "app.pap.hydrate_actions.app_error", appErr.Id) + require.Nil(t, ch.PolicyActions, "error path must leave PolicyActions untouched (caller decides fallback)") + }) +} + +func TestHydrateChannelsPolicyActions(t *testing.T) { + t.Run("Empty slice is a no-op", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore).Maybe() + + appErr := thMock.App.HydrateChannelsPolicyActions(thMock.Context, nil) + require.Nil(t, appErr) + appErr = thMock.App.HydrateChannelsPolicyActions(thMock.Context, []*model.Channel{}) + require.Nil(t, appErr) + mockACPStore.AssertNotCalled(t, "GetActionsForPolicies", mock.Anything, mock.Anything) + }) + + t.Run("Slice with only non-enforced channels skips the store entirely", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore).Maybe() + + channels := []*model.Channel{ + {Id: model.NewId(), PolicyEnforced: false}, + {Id: model.NewId(), PolicyEnforced: false}, + } + appErr := thMock.App.HydrateChannelsPolicyActions(thMock.Context, channels) + require.Nil(t, appErr) + for _, ch := range channels { + require.Nil(t, ch.PolicyActions) + } + mockACPStore.AssertNotCalled(t, "GetActionsForPolicies", mock.Anything, mock.Anything) + }) + + t.Run("Mixed slice issues a single batched call for enforced channels only", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + enforced1 := model.NewId() + enforced2 := model.NewId() + channels := []*model.Channel{ + {Id: enforced1, PolicyEnforced: true}, + {Id: model.NewId(), PolicyEnforced: false}, + {Id: enforced2, PolicyEnforced: true}, + } + + mockACPStore.On("GetActionsForPolicies", thMock.Context, mock.MatchedBy(func(ids []string) bool { + // We don't depend on slice order — order is incidental — but + // the contents must be exactly the two enforced IDs and never + // the non-enforced one. + if len(ids) != 2 { + return false + } + have := map[string]bool{} + for _, id := range ids { + have[id] = true + } + return have[enforced1] && have[enforced2] + })).Return(map[string]map[string]bool{ + enforced1: {model.AccessControlPolicyActionMembership: true}, + enforced2: {model.AccessControlPolicyActionUploadFileAttachment: true}, + }, nil).Once() + + appErr := thMock.App.HydrateChannelsPolicyActions(thMock.Context, channels) + require.Nil(t, appErr) + require.True(t, channels[0].HasMembershipPolicyAction()) + require.Nil(t, channels[1].PolicyActions, "non-enforced channels must remain untouched") + require.False(t, channels[2].HasMembershipPolicyAction(), "permission-only channel must NOT report membership") + require.True(t, channels[2].HasPolicyAction(model.AccessControlPolicyActionUploadFileAttachment)) + mockACPStore.AssertExpectations(t) + }) + + t.Run("Enforced channel missing from batch result gets an empty map (fail-closed for membership)", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + enforced := model.NewId() + channels := []*model.Channel{ + {Id: enforced, PolicyEnforced: true}, + } + // Simulate the policy row being deleted between channel read and + // batch fetch — the result map is empty, but the call succeeded. + mockACPStore.On("GetActionsForPolicies", thMock.Context, []string{enforced}). + Return(map[string]map[string]bool{}, nil).Once() + + appErr := thMock.App.HydrateChannelsPolicyActions(thMock.Context, channels) + require.Nil(t, appErr) + require.NotNil(t, channels[0].PolicyActions, "missing-from-batch must default to empty map, not nil") + require.Empty(t, channels[0].PolicyActions) + require.False(t, channels[0].HasMembershipPolicyAction()) + }) + + t.Run("Underlying batch error is surfaced and channels are left untouched", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + + channels := []*model.Channel{{Id: model.NewId(), PolicyEnforced: true}} + mockACPStore.On("GetActionsForPolicies", thMock.Context, mock.Anything). + Return(nil, errors.New("boom")).Once() + + appErr := thMock.App.HydrateChannelsPolicyActions(thMock.Context, channels) + require.NotNil(t, appErr) + require.Equal(t, "app.pap.hydrate_actions.app_error", appErr.Id) + require.Nil(t, channels[0].PolicyActions, "error path must leave the slice untouched") + }) +} + +func TestGetChannelHydratesPolicyActions(t *testing.T) { + // App.GetChannel is the canonical single-channel read seam. After + // Phase 1 it must transparently hydrate PolicyActions so consumers + // (Phase 2 server gates and frontend) can rely on the field being + // present whenever PolicyEnforced is true. + t.Run("Returned channel carries PolicyActions when policy_enforced is true", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + + channelID := model.NewId() + mockChannelStore := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannelStore) + mockChannelStore.On("Get", channelID, true). + Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate, PolicyEnforced: true}, nil).Once() + + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID). + Return(map[string]bool{model.AccessControlPolicyActionMembership: true}, nil).Once() + + channel, appErr := thMock.App.GetChannel(thMock.Context, channelID) + require.Nil(t, appErr) + require.NotNil(t, channel) + require.True(t, channel.HasMembershipPolicyAction(), "GetChannel must hydrate the action map so downstream gates see the membership bit") + mockACPStore.AssertExpectations(t) + }) + + t.Run("No-policy channel returns without touching AccessControlPolicies", func(t *testing.T) { + thMock := SetupWithStoreMock(t) + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + + channelID := model.NewId() + mockChannelStore := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannelStore) + mockChannelStore.On("Get", channelID, true). + Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate, PolicyEnforced: false}, nil).Once() + + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore).Maybe() + + channel, appErr := thMock.App.GetChannel(thMock.Context, channelID) + require.Nil(t, appErr) + require.NotNil(t, channel) + require.Nil(t, channel.PolicyActions) + mockACPStore.AssertNotCalled(t, "GetActionsForPolicy", mock.Anything, mock.Anything) + }) +} + +func TestChannelAccessControlled(t *testing.T) { + th := Setup(t).InitBasic(t) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true + }) + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok) + defer th.App.Srv().SetLicense(nil) + + savePolicy := func(t *testing.T, channelID string, actions ...string) { + t.Helper() + policy := &model.AccessControlPolicy{ + ID: channelID, + Type: model.AccessControlPolicyTypeChannel, + Name: "policy-" + channelID, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: actions, Expression: "true"}, + }, + } + _, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy) + require.NoError(t, err) + t.Cleanup(func() { + _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channelID) + th.App.Srv().Store().Channel().InvalidateChannel(channelID) + }) + th.App.Srv().Store().Channel().InvalidateChannel(channelID) + } + + t.Run("channel with no policy is not controlled", func(t *testing.T) { + channel := th.CreatePrivateChannel(t, th.BasicTeam) + controlled, appErr := th.App.ChannelAccessControlled(th.Context, channel.Id) + require.Nil(t, appErr) + require.False(t, controlled) + }) + + t.Run("channel with a membership policy is controlled", func(t *testing.T) { + channel := th.CreatePrivateChannel(t, th.BasicTeam) + savePolicy(t, channel.Id, model.AccessControlPolicyActionMembership) + + controlled, appErr := th.App.ChannelAccessControlled(th.Context, channel.Id) + require.Nil(t, appErr) + require.True(t, controlled, "membership policy must make ChannelAccessControlled return true") + }) + + t.Run("channel with ONLY a permission policy is NOT controlled (bug fix)", func(t *testing.T) { + // Bug-fix regression: HasPermissionToChannel and other callers + // must not treat permission-only channels (e.g. file upload + // restriction) as ABAC-membership-controlled. Before the + // PolicyActions[membership] migration this returned true. + channel := th.CreatePrivateChannel(t, th.BasicTeam) + savePolicy(t, channel.Id, model.AccessControlPolicyActionUploadFileAttachment) + + controlled, appErr := th.App.ChannelAccessControlled(th.Context, channel.Id) + require.Nil(t, appErr) + require.False(t, controlled, "permission-only policy must NOT make ChannelAccessControlled return true") + }) + + t.Run("non-existent channel returns false without error (existing contract)", func(t *testing.T) { + controlled, appErr := th.App.ChannelAccessControlled(th.Context, model.NewId()) + require.Nil(t, appErr) + require.False(t, controlled) + }) +} + +func TestPublishChannelPolicyEnforcedUpdateHydratesBroadcastPayload(t *testing.T) { + // publishChannelPolicyEnforcedUpdate must include PolicyActions in the + // broadcast payload so connected clients can react to action-set + // changes without a follow-up REST round-trip. The hydration happens + // after GetChannel reloads the (now-policy-enforced) channel post-save. + thMock := SetupWithStoreMock(t) + + channelID := model.NewId() + channelPolicy := &model.AccessControlPolicy{ + ID: channelID, + Type: model.AccessControlPolicyTypeChannel, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"}, + }, + } + + mockStore := thMock.App.Srv().Store().(*storemocks.Store) + mockChannelStore := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannelStore) + mockChannelStore.On("InvalidateChannel", channelID).Once() + // Channel().Get is called twice on a save flow — once by eligibility + // validation pre-save, once by publishChannelPolicyEnforcedUpdate + // post-save. Both calls return a PolicyEnforced=true channel so the + // hydrator fires on the second call. + mockChannelStore.On("Get", channelID, true). + Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate, PolicyEnforced: true}, nil).Twice() + + mockACPStore := storemocks.AccessControlPolicyStore{} + mockStore.On("AccessControlPolicy").Return(&mockACPStore) + // Permission-only policy: hydrator must return an action set WITHOUT + // the membership key. This is the bug-fix invariant the broadcast + // must carry to the client. + expectedActions := map[string]bool{model.AccessControlPolicyActionUploadFileAttachment: true} + mockACPStore.On("GetActionsForPolicy", thMock.Context, channelID).Return(expectedActions, nil) + + mockAccessControl := &mocks.AccessControlServiceInterface{} + thMock.App.Srv().ch.AccessControl = mockAccessControl + mockAccessControl.On("SavePolicy", thMock.Context, mock.Anything).Return(channelPolicy, nil).Once() + + result, err := thMock.App.CreateOrUpdateAccessControlPolicy(thMock.Context, channelPolicy) + require.Nil(t, err) + require.NotNil(t, result) + + mockChannelStore.AssertCalled(t, "InvalidateChannel", channelID) + // The critical assertion: the hydrator was invoked with the right + // channel ID, meaning the WS payload that follows includes the + // non-membership action set. + mockACPStore.AssertCalled(t, "GetActionsForPolicy", thMock.Context, channelID) + mockAccessControl.AssertExpectations(t) +} + // TestGetAccessControlPolicyAttributes_MaskedFieldsFiltered verifies that // source_only and shared_only attribute fields are stripped from the response // of GetAccessControlPolicyAttributes so their values are never exposed to @@ -2338,6 +4350,14 @@ func TestGetAccessControlPolicyAttributes_PublicFieldsPassThrough(t *testing.T) // The attack: submit a PUT with the same masked expression but a different // action type — the merge would restore the hidden CEL value while silently // accepting the caller's action, removing the original access restriction. +// +// The submitted and stored rules are paired by Name (v0.4 permission rules +// always carry a unique Name) so the test models the realistic attack +// surface — a caller editing an existing Named rule swaps its Actions +// while leaving the masked Expression alone. Pair-by-Name is what makes +// the action-locking guard reachable in this scenario; an attacker who +// drops the Name (or changes it) instead falls into the masked-rule- +// deleted 403 path, which is exercised by the merge tests above. func TestMergeStoredPolicyExpressions_ActionsLocked(t *testing.T) { th := Setup(t).InitBasic(t) th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) @@ -2369,24 +4389,39 @@ func TestMergeStoredPolicyExpressions_ActionsLocked(t *testing.T) { callerID := model.NewId() policyID := model.NewId() + ruleName := "rule_" + model.NewId()[:8] storedExpr := `user.attributes.` + fieldName + ` == "TopSecret"` maskedExpr := `user.attributes.` + fieldName + ` == "--------"` storedPolicy := &model.AccessControlPolicy{ ID: policyID, - Type: model.AccessControlPolicyTypeParent, + Type: model.AccessControlPolicyTypeChannel, Rules: []model.AccessControlPolicyRule{ - {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: storedExpr}, + { + Name: ruleName, + Role: model.ChannelUserRoleId, + Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, + Expression: storedExpr, + }, }, } - // Attacker submits the masked expression unchanged but swaps the action. + // Attacker keeps the rule's Name (so the editor still considers it + // "the same rule") and submits the masked expression unchanged, but + // swaps Actions from upload → download. Without the action-locking + // guard the merge would re-inject the hidden literal and silently + // re-purpose the gate. submittedPolicy := &model.AccessControlPolicy{ ID: policyID, - Type: model.AccessControlPolicyTypeParent, + Type: model.AccessControlPolicyTypeChannel, Rules: []model.AccessControlPolicyRule{ - {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: maskedExpr}, + { + Name: ruleName, + Role: model.ChannelUserRoleId, + Actions: []string{model.AccessControlPolicyActionDownloadFileAttachment}, + Expression: maskedExpr, + }, }, } @@ -2412,7 +4447,7 @@ func TestMergeStoredPolicyExpressions_ActionsLocked(t *testing.T) { // Expression must be restored to the real stored value. assert.Equal(t, storedExpr, submittedPolicy.Rules[0].Expression) // Actions must be locked to the stored value, not the attacker's. - assert.Equal(t, []string{model.AccessControlPolicyActionMembership}, submittedPolicy.Rules[0].Actions) + assert.Equal(t, []string{model.AccessControlPolicyActionUploadFileAttachment}, submittedPolicy.Rules[0].Actions) mockACS.AssertExpectations(t) } diff --git a/server/channels/app/authorization.go b/server/channels/app/authorization.go index 240b23e94b8..0f9c68755bf 100644 --- a/server/channels/app/authorization.go +++ b/server/channels/app/authorization.go @@ -505,9 +505,12 @@ func (a *App) SessionHasPermissionToEditPropertyField(rctx request.CTX, session return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionField) } -// SessionHasPermissionToSetPropertyFieldValues checks if the session has permission to set values on objects. +// SessionHasPermissionToSetPropertyFieldValues checks if the session has +// permission to set the given value on the field. The valueTargetID is the +// specific object the value is attached to (the channel/post/user/team ID +// for that ObjectType); admin and member levels are evaluated against it. // Returns false if the field is nil or if PermissionValues is nil (legacy fields). -func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, session model.Session, field *model.PropertyField) bool { +func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, session model.Session, field *model.PropertyField, valueTargetID string) bool { if field == nil { return false } @@ -517,7 +520,7 @@ func (a *App) SessionHasPermissionToSetPropertyFieldValues(rctx request.CTX, ses if session.IsUnrestricted() { return true } - return a.hasPropertyFieldPermissionLevel(rctx, session.UserId, field, *field.PermissionValues) + return a.hasPropertyFieldValuePermissionLevel(rctx, session.UserId, field, valueTargetID, *field.PermissionValues) } // SessionHasPermissionToManagePropertyFieldOptions checks if the session has permission to manage field options. @@ -550,16 +553,19 @@ func (a *App) HasPermissionToEditPropertyField(rctx request.CTX, userID string, return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionField) } -// HasPermissionToSetPropertyFieldValues checks if the user has permission to set values on objects. +// HasPermissionToSetPropertyFieldValues checks if the user has permission to +// set the given value on the field. The valueTargetID is the specific object +// the value is attached to (the channel/post/user/team ID for that +// ObjectType); admin and member levels are evaluated against it. // Returns false if the field is nil, userID is empty, or if PermissionValues is nil (legacy fields). -func (a *App) HasPermissionToSetPropertyFieldValues(rctx request.CTX, userID string, field *model.PropertyField) bool { +func (a *App) HasPermissionToSetPropertyFieldValues(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool { if field == nil || userID == "" { return false } if field.PermissionValues == nil { return false } - return a.hasPropertyFieldPermissionLevel(rctx, userID, field, *field.PermissionValues) + return a.hasPropertyFieldValuePermissionLevel(rctx, userID, field, valueTargetID, *field.PermissionValues) } // HasPermissionToManagePropertyFieldOptions checks if the user has permission to manage field options. @@ -575,6 +581,12 @@ func (a *App) HasPermissionToManagePropertyFieldOptions(rctx request.CTX, userID } // hasPropertyFieldPermissionLevel checks if the user has the specified permission level for the field. +// "admin" resolves against the field's target: manage_system on system targets, +// manage_team on team targets, manage_channel_roles on channel targets — i.e. +// the permission that the corresponding built-in admin role grants. Note this +// is a stricter check than hasTargetAccess (which uses manage_*_channel_properties +// for channel writes): hasTargetAccess is the outer "may write anything here" +// gate, and PermissionLevelAdmin is the inner "is a channel admin" tier above it. func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, level model.PermissionLevel) bool { switch level { case model.PermissionLevelNone: @@ -583,44 +595,119 @@ func (a *App) hasPropertyFieldPermissionLevel(rctx request.CTX, userID string, f return a.HasPermissionTo(userID, model.PermissionManageSystem) case model.PermissionLevelMember: return a.hasPropertyFieldScopeAccess(rctx, userID, field) + case model.PermissionLevelAdmin: + switch field.TargetType { + case string(model.PropertyFieldTargetLevelSystem): + return a.HasPermissionTo(userID, model.PermissionManageSystem) + case string(model.PropertyFieldTargetLevelTeam): + return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionManageTeam) + case string(model.PropertyFieldTargetLevelChannel): + hasPermission, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionManageChannelRoles) + return hasPermission + } } return false } -// hasPropertyFieldScopeAccess checks if the user has access to the property field's scope. -// For system-level properties, any authenticated user has access. -// For channel-level properties, the user must be a member of the channel. +// hasPropertyFieldValuePermissionLevel evaluates a permission level against +// the value's specific target rather than the field's target. The "admin" +// and "member" levels dispatch on field.ObjectType against valueTargetID — +// so a value on a channel-object field is gated by the user's role on that +// channel, regardless of how the field itself is scoped. "sysadmin" and +// "none" behave identically to the field-level dispatch. +func (a *App) hasPropertyFieldValuePermissionLevel(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string, level model.PermissionLevel) bool { + switch level { + case model.PermissionLevelSysadmin: + return a.HasPermissionTo(userID, model.PermissionManageSystem) + case model.PermissionLevelAdmin: + return a.hasPropertyFieldValueAdmin(rctx, userID, field, valueTargetID) + case model.PermissionLevelMember: + return a.hasPropertyFieldValueScopeAccess(rctx, userID, field, valueTargetID) + case model.PermissionLevelNone: + return false + } + return false +} + +// hasPropertyFieldValueAdmin reports whether the user administers the +// value's target. For channel/post-object fields, this is channel admin +// (manage_channel_roles) on the value's channel (or the post's channel). +// For user/system/template fields the value's target has no admin concept, +// so the check defers to the field's TargetType (sysadmin / team admin / +// channel admin) via the field-level dispatch. +func (a *App) hasPropertyFieldValueAdmin(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool { + switch field.ObjectType { + case model.PropertyFieldObjectTypeChannel: + ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionManageChannelRoles) + return ok + case model.PropertyFieldObjectTypePost: + post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false) + if err != nil { + rctx.Logger().Warn("Failed to look up post for property value admin check", + mlog.String("post_id", valueTargetID), + mlog.String("user_id", userID), + mlog.String("field_id", field.ID), + mlog.Err(err), + ) + return false + } + ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionManageChannelRoles) + return ok + case model.PropertyFieldObjectTypeUser, + model.PropertyFieldObjectTypeSystem, + model.PropertyFieldObjectTypeTemplate: + return a.hasPropertyFieldPermissionLevel(rctx, userID, field, model.PermissionLevelAdmin) + } + return false +} + +// hasPropertyFieldValueScopeAccess reports whether the user can write the +// value's target as a regular member. For channel-object fields this is +// membership in the value's channel. For post-object fields this is +// membership in the post's channel — any channel member can set values on +// any post in that channel. Both are checked via HasPermissionToChannel so +// sysadmins and team admins cascade through. User/system/template fields +// have no per-object membership and defer to the field's TargetType-based scope. +func (a *App) hasPropertyFieldValueScopeAccess(rctx request.CTX, userID string, field *model.PropertyField, valueTargetID string) bool { + switch field.ObjectType { + case model.PropertyFieldObjectTypeChannel: + ok, _ := a.HasPermissionToChannel(rctx, userID, valueTargetID, model.PermissionReadChannel) + return ok + case model.PropertyFieldObjectTypePost: + post, err := a.Srv().Store().Post().GetSingle(rctx, valueTargetID, false) + if err != nil { + rctx.Logger().Warn("Failed to look up post for property value scope check", + mlog.String("post_id", valueTargetID), + mlog.String("user_id", userID), + mlog.String("field_id", field.ID), + mlog.Err(err), + ) + return false + } + ok, _ := a.HasPermissionToChannel(rctx, userID, post.ChannelId, model.PermissionReadChannel) + return ok + case model.PropertyFieldObjectTypeUser, + model.PropertyFieldObjectTypeSystem, + model.PropertyFieldObjectTypeTemplate: + return a.hasPropertyFieldScopeAccess(rctx, userID, field) + } + return false +} + +// hasPropertyFieldScopeAccess checks if the user has access to the property +// field's scope. System-level properties are open to any authenticated user. +// Team- and channel-level properties go through HasPermissionToTeam / +// HasPermissionToChannel so sysadmins (and team-admins for channel scopes) +// cascade through — matching the value-level scope check. func (a *App) hasPropertyFieldScopeAccess(rctx request.CTX, userID string, field *model.PropertyField) bool { switch field.TargetType { case string(model.PropertyFieldTargetLevelSystem): - // System-level property: any authenticated user return true case string(model.PropertyFieldTargetLevelTeam): - // Team-level property: must be team member - member, err := a.Srv().Store().Team().GetMember(rctx, field.TargetID, userID) - if err != nil { - rctx.Logger().Warn("Failed to get team member for property field scope check", - mlog.String("team_id", field.TargetID), - mlog.String("user_id", userID), - mlog.String("field_id", field.ID), - mlog.Err(err), - ) - return false - } - return member != nil + return a.HasPermissionToTeam(rctx, userID, field.TargetID, model.PermissionViewTeam) case string(model.PropertyFieldTargetLevelChannel): - // Channel-level property: must be channel member - member, err := a.Srv().Store().Channel().GetMember(rctx, field.TargetID, userID) - if err != nil { - rctx.Logger().Warn("Failed to get channel member for property field scope check", - mlog.String("channel_id", field.TargetID), - mlog.String("user_id", userID), - mlog.String("field_id", field.ID), - mlog.Err(err), - ) - return false - } - return member != nil + ok, _ := a.HasPermissionToChannel(rctx, userID, field.TargetID, model.PermissionReadChannel) + return ok } return false } @@ -643,7 +730,7 @@ func (a *App) HasPermissionToFileAction(rctx request.CTX, userID string, roles s return true } - subject, appErr := a.BuildAccessControlSubject(rctx, userID, roles) + subject, appErr := a.BuildAccessControlSubject(rctx, userID, roles, channelID) if appErr != nil { rctx.Logger().Info("Failed to build ABAC subject for file action evaluation", mlog.String("user_id", userID), diff --git a/server/channels/app/authorization_test.go b/server/channels/app/authorization_test.go index 4dc921a22e6..8c927ebb65f 100644 --- a/server/channels/app/authorization_test.go +++ b/server/channels/app/authorization_test.go @@ -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)) +} diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 535de6a5b78..10bc89acb81 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -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 } diff --git a/server/channels/app/channel_discoverable_visibility.go b/server/channels/app/channel_discoverable_visibility.go new file mode 100644 index 00000000000..95cf7a2c012 --- /dev/null +++ b/server/channels/app/channel_discoverable_visibility.go @@ -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 +} diff --git a/server/channels/app/channel_discoverable_visibility_test.go b/server/channels/app/channel_discoverable_visibility_test.go new file mode 100644 index 00000000000..ada36a94d7f --- /dev/null +++ b/server/channels/app/channel_discoverable_visibility_test.go @@ -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") + } +} diff --git a/server/channels/app/channel_guards.go b/server/channels/app/channel_guards.go new file mode 100644 index 00000000000..0bb8e4dc0ef --- /dev/null +++ b/server/channels/app/channel_guards.go @@ -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 + } +} diff --git a/server/channels/app/channel_guards_test.go b/server/channels/app/channel_guards_test.go new file mode 100644 index 00000000000..cee183000e9 --- /dev/null +++ b/server/channels/app/channel_guards_test.go @@ -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) +} diff --git a/server/channels/app/channel_join_request.go b/server/channels/app/channel_join_request.go new file mode 100644 index 00000000000..05b8c98e503 --- /dev/null +++ b/server/channels/app/channel_join_request.go @@ -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) +} diff --git a/server/channels/app/channel_join_request_test.go b/server/channels/app/channel_join_request_test.go new file mode 100644 index 00000000000..6cda6adb46e --- /dev/null +++ b/server/channels/app/channel_join_request_test.go @@ -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 +} diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index d4a9e1f6a65..dce2f6e298c 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -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) diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index eaa21d4ccd3..07754530bd4 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -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) +} diff --git a/server/channels/app/cluster_handlers.go b/server/channels/app/cluster_handlers.go index 720ce9ea078..5a4a17faeea 100644 --- a/server/channels/app/cluster_handlers.go +++ b/server/channels/app/cluster_handlers.go @@ -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() } diff --git a/server/channels/app/draft.go b/server/channels/app/draft.go index 275a718f544..05d76184efd 100644 --- a/server/channels/app/draft.go +++ b/server/channels/app/draft.go @@ -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) diff --git a/server/channels/app/email/mocks/ServiceInterface.go b/server/channels/app/email/mocks/ServiceInterface.go index 14517938d31..0e790ce309c 100644 --- a/server/channels/app/email/mocks/ServiceInterface.go +++ b/server/channels/app/email/mocks/ServiceInterface.go @@ -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 diff --git a/server/channels/app/file.go b/server/channels/app/file.go index f1d3a91e87d..2188b2488b3 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -1528,7 +1528,14 @@ func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model. } } - abacSubject := a.buildFileDownloadSubject(rctx, userID) + abacSubject, abacSubjectErr := a.buildFileDownloadSubject(rctx, userID) + if abacSubjectErr != nil { + // Fail closed: a transient subject-build failure must not silently + // allow files through. Surface the error to the caller — the + // search returns 5xx instead of leaking files past a policy that + // would have denied them. + return false, abacSubjectErr + } channelPermission := make(map[string]bool) filteredFiles := make(map[string]*model.FileInfo) @@ -1567,18 +1574,28 @@ func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model. return allFilesHaveMembership, nil } -// buildFileDownloadSubject returns a fully populated ABAC Subject for the user -// when ABAC is active, or nil when ABAC is not configured / not enabled. -func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) *model.Subject { +// buildFileDownloadSubject returns a fully populated ABAC Subject for the +// user when ABAC is active. The error return distinguishes the two +// failure modes that used to share `nil`: +// - (nil, nil): ABAC isn't configured/enabled; the file download path +// is allowed without further checks. +// - (subject, nil): ABAC is active; caller should evaluate. +// - (nil, err): a transient lookup failure (GetUser / +// BuildAccessControlSubject). The caller MUST treat this as a +// denial; the previous behaviour returned `nil` here too which +// `hasFileDownloadPermission` interpreted as "ABAC disabled, +// allow" — i.e. a transient DB blip silently bypassed +// download_file_attachment policies. +func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) (*model.Subject, *model.AppError) { acs := a.Srv().Channels().AccessControl if acs == nil { - return nil + return nil, nil } if !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl { - return nil + return nil, nil } if !a.Config().FeatureFlags.PermissionPolicies { - return nil + return nil, nil } user, err := a.GetUser(userID) @@ -1587,18 +1604,52 @@ func (a *App) buildFileDownloadSubject(rctx request.CTX, userID string) *model.S mlog.String("user_id", userID), mlog.Err(err), ) - return nil + return nil, err } - subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles) + // channelID is intentionally empty here: the subject is reused across many + // channels in the file-search loop. hasFileDownloadPermission attaches the + // channel-scoped role per-evaluation via attachChannelScopedRole. + subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles, "") if appErr != nil { rctx.Logger().Warn("Failed to build ABAC subject for file search filtering", mlog.String("user_id", userID), mlog.Err(appErr), ) - return nil + return nil, appErr } - return subject + return subject, nil +} + +// attachChannelScopedRole returns a copy of the subject with the channel-scoped +// ScopedRole entry replaced for the given channelID. It's used in hot paths +// where the same per-user Subject is reused across many channels — Subject +// is taken by value and SetScopedRole always allocates a fresh ScopedRoles +// backing array, so the caller's cached Subject is not mutated. +// +// Errors from GetSubjectChannelRole (e.g. transient channel-member store +// failures) are propagated as an AppError. Callers MUST treat the error as +// a denial — a transient DB blip is distinguishable from "no channel role" +// (legitimate non-member), and conflating the two could let infra hiccups +// silently degrade ABAC enforcement even with the downstream +// PolicyGovernsAction fail-secure in place. Defense in depth: both layers +// should fail closed independently. Callers should NOT stamp an empty +// channel role in the error path — Subject is returned unchanged so the +// caller can use it for logging without leaking a partially populated +// scope onto downstream evaluators. +func (a *App) attachChannelScopedRole(rctx request.CTX, subject model.Subject, userID, channelID string) (model.Subject, *model.AppError) { + channelRole, appErr := a.GetSubjectChannelRole(rctx, userID, channelID) + if appErr != nil { + rctx.Logger().Warn( + "Failed to resolve channel-scoped role for ABAC subject; treating as denial (transient lookup failure must not silently bypass ABAC)", + mlog.String("user_id", userID), + mlog.String("channel_id", channelID), + mlog.Err(appErr), + ) + return subject, appErr + } + subject.SetScopedRole(model.AccessControlSubjectScopeChannel, channelRole) + return subject, nil } // hasFileDownloadPermission evaluates the ABAC download_file_attachment policy @@ -1614,8 +1665,16 @@ func (a *App) hasFileDownloadPermission(rctx request.CTX, userID string, channel return true } + subjectForChannel, attachErr := a.attachChannelScopedRole(rctx, *subject, userID, channelID) + if attachErr != nil { + // Channel-role lookup failed (e.g. transient ChannelMember store + // error). Fail-secure: refuse access rather than evaluating against + // a subject missing its channel scope. The warn log was already + // emitted by attachChannelScopedRole. + return false + } decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{ - Subject: *subject, + Subject: subjectForChannel, Resource: model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: channelID}, Action: model.AccessControlPolicyActionDownloadFileAttachment, }) diff --git a/server/channels/app/guarded_hooks.go b/server/channels/app/guarded_hooks.go new file mode 100644 index 00000000000..05b51a2f9e7 --- /dev/null +++ b/server/channels/app/guarded_hooks.go @@ -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 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[] 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 +} diff --git a/server/channels/app/guarded_hooks_test.go b/server/channels/app/guarded_hooks_test.go new file mode 100644 index 00000000000..0e5a84189cb --- /dev/null +++ b/server/channels/app/guarded_hooks_test.go @@ -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) +} diff --git a/server/channels/app/integration_action.go b/server/channels/app/integration_action.go index 5b1ea52a61f..f6c8f935646 100644 --- a/server/channels/app/integration_action.go +++ b/server/channels/app/integration_action.go @@ -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 } } diff --git a/server/channels/app/integration_action_test.go b/server/channels/app/integration_action_test.go index 1389b24b18b..68aa21fd51b 100644 --- a/server/channels/app/integration_action_test.go +++ b/server/channels/app/integration_action_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -67,7 +68,7 @@ func TestPostActionInvalidURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.ErrorContains(t, err, "missing protocol scheme") } @@ -119,7 +120,7 @@ func TestPostActionEmptyResponse(t *testing.T) { attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) require.True(t, ok) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) }) @@ -167,7 +168,7 @@ func TestPostActionEmptyResponse(t *testing.T) { cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = new(int64(1)) }) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.ErrorContains(t, err, "context deadline exceeded") }) @@ -236,7 +237,7 @@ func TestPostActionResponseSizeLimit(t *testing.T) { // Should return error due to truncated JSON, but NOT crash or OOM _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, - attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) // Truncated JSON causes unmarshal error assert.Equal(t, "api.post.do_action.action_integration.app_error", err.Id) @@ -279,7 +280,7 @@ func TestPostActionResponseSizeLimit(t *testing.T) { // Should return error due to invalid JSON, but NOT crash or OOM _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, - attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.Equal(t, "api.post.do_action.action_integration.app_error", err.Id) }) @@ -425,16 +426,16 @@ func TestPostAction(t *testing.T) { require.NotEmpty(t, attachments2[0].Actions) require.NotEmpty(t, attachments2[0].Actions[0].Id) - clientTriggerID, err := th.App.DoPostActionWithCookie(th.Context, post.Id, "notavalidid", th.BasicUser.Id, "", nil) + clientTriggerID, err := th.App.DoPostActionWithCookie(th.Context, post.Id, "notavalidid", th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.Equal(t, http.StatusNotFound, err.StatusCode) assert.Len(t, clientTriggerID, 0) - clientTriggerID, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + clientTriggerID, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) assert.Len(t, clientTriggerID, 26) - clientTriggerID, err = th.App.DoPostActionWithCookie(th.Context, post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected", nil) + clientTriggerID, err = th.App.DoPostActionWithCookie(th.Context, post2.Id, attachments2[0].Actions[0].Id, th.BasicUser.Id, "selected", nil, nil) require.Nil(t, err) assert.Len(t, clientTriggerID, 26) @@ -442,7 +443,7 @@ func TestPostAction(t *testing.T) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "" }) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.ErrorContains(t, err, "address forbidden") @@ -480,14 +481,14 @@ func TestPostAction(t *testing.T) { attachmentsPlugin, ok := postplugin.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) require.True(t, ok) - _, err = th.App.DoPostActionWithCookie(th.Context, postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Equal(t, "api.post.do_action.action_integration.app_error", err.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" }) - _, err = th.App.DoPostActionWithCookie(th.Context, postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, postplugin.Id, attachmentsPlugin[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) th.App.UpdateConfig(func(cfg *model.Config) { @@ -528,7 +529,7 @@ func TestPostAction(t *testing.T) { attachmentsSiteURL, ok := postSiteURL.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) require.True(t, ok) - _, err = th.App.DoPostActionWithCookie(th.Context, postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, postSiteURL.Id, attachmentsSiteURL[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) assert.ErrorContains(t, err, "connection refused") @@ -570,7 +571,7 @@ func TestPostAction(t *testing.T) { attachmentsSubpath, ok := postSubpath.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) require.True(t, ok) - _, err = th.App.DoPostActionWithCookie(th.Context, postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, postSubpath.Id, attachmentsSubpath[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) }) } @@ -644,7 +645,7 @@ func TestPostActionProps(t *testing.T) { attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) require.True(t, ok) - clientTriggerId, err := th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + clientTriggerId, err := th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) assert.Len(t, clientTriggerId, 26) @@ -830,7 +831,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) @@ -870,7 +871,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) @@ -910,7 +911,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) @@ -950,7 +951,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) @@ -990,7 +991,7 @@ func TestPostActionRelativeURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) } @@ -1067,7 +1068,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.NotNil(t, err) }) @@ -1107,7 +1108,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) }) @@ -1147,7 +1148,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) }) @@ -1187,7 +1188,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { require.NotEmpty(t, attachments[0].Actions) require.NotEmpty(t, attachments[0].Actions[0].Id) - _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil) + _, err = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) require.Nil(t, err) }) } @@ -1757,7 +1758,7 @@ func TestDoPostActionWithCookieEdgeCases(t *testing.T) { }, } - _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", th.BasicUser.Id, "", cookie) + _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", th.BasicUser.Id, "", cookie, nil) require.Nil(t, err) }) @@ -1771,7 +1772,7 @@ func TestDoPostActionWithCookieEdgeCases(t *testing.T) { }, } - _, err := th.App.DoPostActionWithCookie(th.Context, "actual_post_id", "action_id", th.BasicUser.Id, "", cookie) + _, err := th.App.DoPostActionWithCookie(th.Context, "actual_post_id", "action_id", th.BasicUser.Id, "", cookie, nil) require.NotNil(t, err) assert.Contains(t, err.Error(), "postId doesn't match") }) @@ -1784,7 +1785,7 @@ func TestDoPostActionWithCookieEdgeCases(t *testing.T) { Integration: nil, } - _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", th.BasicUser.Id, "", cookie) + _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", th.BasicUser.Id, "", cookie, nil) require.NotNil(t, err) assert.Contains(t, err.Error(), "no Integration in action cookie") }) @@ -1805,10 +1806,129 @@ func TestDoPostActionWithCookieEdgeCases(t *testing.T) { }, } - _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", "nonexistent_user_id", "", cookie) + _, err := th.App.DoPostActionWithCookie(th.Context, "nonexistent_post_id", "action_id", "nonexistent_user_id", "", cookie, nil) require.NotNil(t, err) assert.Contains(t, err.Error(), "Unable to find the user.") }) + + t.Run("rejects oversized query at the App boundary (independent of API handler)", func(t *testing.T) { + // ValidateActionQuery is called at the top of DoPostActionWithCookie, + // not just in the API handler. Direct App-layer callers (plugins, + // tests, internal triggers) get the same enforcement as REST clients. + oversized := make(map[string]string, model.MaxActionQueryEntries+1) + for i := range model.MaxActionQueryEntries + 1 { + oversized["k"+strconv.Itoa(i)] = "v" + } + + _, err := th.App.DoPostActionWithCookie(th.Context, "any_post", "any_action", th.BasicUser.Id, "", nil, oversized) + require.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.StatusCode) + assert.Equal(t, "api.post.do_action.query.app_error", err.Id) + }) +} + +// TestCloneMmBlocksActionsProp guards the deep-clone semantics used when +// restoring an original spec after a plugin update response is rejected. +// A shallow clone would alias the nested per-action map back into post.Props, +// so a later mutation through response.Update could reach into the live post. +func TestCloneMmBlocksActionsProp(t *testing.T) { + t.Run("nil and non-map values are returned unchanged", func(t *testing.T) { + assert.Nil(t, cloneMmBlocksActionsProp(nil)) + assert.Equal(t, "string", cloneMmBlocksActionsProp("string")) + }) + + t.Run("top-level and nested mutations on the clone do not leak", func(t *testing.T) { + original := map[string]any{ + "btn1": map[string]any{ + "type": "external", + "url": "http://example.com/hook", + }, + } + + cloned, ok := cloneMmBlocksActionsProp(original).(map[string]any) + require.True(t, ok) + + // Mutating the top-level map on the clone (adding a key) must not + // reach the original. + cloned["btn2"] = map[string]any{"type": "external", "url": "http://example.com/other"} + assert.NotContains(t, original, "btn2") + + // Mutating a nested per-action map on the clone (changing the URL) + // must not reach the original — this is the case the shallow-clone + // bug actually exposed. + clonedEntry, ok := cloned["btn1"].(map[string]any) + require.True(t, ok) + clonedEntry["url"] = "http://attacker.example/" + + originalEntry, ok := original["btn1"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "http://example.com/hook", originalEntry["url"]) + }) + + t.Run("deeply nested context and array mutations on the clone do not leak", func(t *testing.T) { + // Per-action specs can carry nested context maps and arrays. A + // shallow per-entry clone would still alias these structures back + // to the live post's props. + original := map[string]any{ + "btn1": map[string]any{ + "type": "external", + "url": "http://example.com/hook", + "context": map[string]any{"team": "alpha", "tags": []any{"a", "b"}}, + }, + } + + cloned, ok := cloneMmBlocksActionsProp(original).(map[string]any) + require.True(t, ok) + + clonedEntry := cloned["btn1"].(map[string]any) + clonedContext := clonedEntry["context"].(map[string]any) + + // Mutate the nested context map on the clone. + clonedContext["team"] = "tampered" + clonedContext["new"] = "added" + + // Mutate the nested array on the clone. + clonedTags := clonedContext["tags"].([]any) + clonedTags[0] = "tampered" + + // Original must be untouched at every level. + originalEntry := original["btn1"].(map[string]any) + originalContext := originalEntry["context"].(map[string]any) + assert.Equal(t, "alpha", originalContext["team"]) + assert.NotContains(t, originalContext, "new") + assert.Equal(t, []any{"a", "b"}, originalContext["tags"]) + }) + + t.Run("pathologically nested input is truncated past maxMmBlocksActionsCloneDepth", func(t *testing.T) { + // ValidateMmBlocksActions doesn't bound nesting depth inside + // spec.Context — defense-in-depth against stack exhaustion if a + // bot/plugin author crafts deeply nested input. + var leaf any = "leaf" + const tooDeep = maxMmBlocksActionsCloneDepth + 100 + for range tooDeep { + leaf = map[string]any{"n": leaf} + } + + // Must not stack-overflow / panic. + var cloned any + require.NotPanics(t, func() { + cloned = cloneMmBlocksActionsProp(leaf) + }) + + // Walk the clone; should hit nil before reaching the leaf string. + current := cloned + for i := range tooDeep { + m, ok := current.(map[string]any) + if !ok { + assert.Greater(t, i, maxMmBlocksActionsCloneDepth-2, + "truncation should kick in at or near maxMmBlocksActionsCloneDepth") + assert.Nil(t, current, "subtree past depth cap must be nil, not aliased to source") + return + } + current = m["n"] + } + t.Fatalf("clone walked %d levels without hitting truncation", tooDeep) + }) } func TestDoPluginRequest(t *testing.T) { @@ -2002,3 +2122,859 @@ func TestDoPluginRequest(t *testing.T) { } }) } + +// buildMmBlocksActionsProp returns a mm_blocks_actions map (an "external"-type +// action) suitable for use as a post prop in tests. +func buildMmBlocksActionsProp(id, url string, context map[string]any) map[string]any { + entry := map[string]any{ + "type": model.MmBlocksActionTypeExternal, + "url": url, + } + if context != nil { + entry["context"] = context + } + return map[string]any{id: entry} +} + +// setupBotInChannel creates a bot, joins it to the team and channel, and +// returns the resolved *model.User for the bot. +func setupBotInChannel(t *testing.T, th *TestHelper) *model.User { + t.Helper() + bot := th.CreateBot(t) + botUser, appErr := th.App.GetUser(bot.UserId) + require.Nil(t, appErr) + _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, botUser.Id, "") + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, botUser, th.BasicChannel, false) + require.Nil(t, appErr) + return botUser +} + +func TestMmBlocksActionsStrippedOnCreate(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + post := &model.Post{ + Message: "hello with inline actions", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "actionone", + "http://127.0.0.1/plugins/myplugin/doit", + map[string]any{"operation": "STORM"}, + ), + }, + } + + created, _, err := th.App.CreatePostAsUser(th.Context, post, "", true) + require.Nil(t, err) + assert.Nil(t, created.GetProp(model.PostPropsMmBlocksActions), "non-bot, non-integration user should have mm_blocks_actions stripped") + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + assert.Nil(t, stored.GetProp(model.PostPropsMmBlocksActions), "stored post should not carry mm_blocks_actions") +} + +func TestMmBlocksActionsKeptForBotIntegration(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + + // IsOAuth=true makes Session.IsIntegration() return true without needing + // a full bot-token session. + intSession := &model.Session{UserId: botUser.Id, IsOAuth: true} + intCtx := th.Context.WithSession(intSession) + + post := &model.Post{ + Message: "hello from a bot", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "actiontwo", + "http://127.0.0.1/plugins/myplugin/doit", + map[string]any{"operation": "STORM"}, + ), + }, + } + + created, _, err := th.App.CreatePostAsUser(intCtx, post, "", true) + require.Nil(t, err) + require.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions), "bot post via integration session should preserve mm_blocks_actions") + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + require.NotNil(t, stored.GetProp(model.PostPropsMmBlocksActions), "stored bot post should carry mm_blocks_actions") + + spec := stored.GetMmBlocksActionSpec("actiontwo") + require.NotNil(t, spec) + assert.Equal(t, "http://127.0.0.1/plugins/myplugin/doit", spec.URL) +} + +// TestPluginAPICreatePostKeepsMmBlocksActions locks the contract that a +// plugin creating a post via PluginAPI.CreatePost retains mm_blocks_actions. +// Plugins are server-trusted code, but their static activation-time rctx +// has an unmarked session — without pluginIntegrationCtx the strip in +// CreatePost would delete the prop and clicks would 404 with +// "invalid action id". +func TestPluginAPICreatePostKeepsMmBlocksActions(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + botUser := setupBotInChannel(t, th) + + manifest := &model.Manifest{Id: "com.mattermost.test-plugin"} + api := NewPluginAPI(th.App, th.Context, manifest) + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + UserId: botUser.Id, + Message: "issue tracker post", + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "triage", + "/plugins/com.mattermost.test-plugin/inline_action/triage", + map[string]any{"project": "Demo Project"}, + ), + }, + } + + created, appErr := api.CreatePost(post) + require.Nil(t, appErr) + require.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions), + "plugin-created post must preserve mm_blocks_actions; the strip in CreatePost should not fire because PluginAPI marks the session as integration") + + // Re-read from the store to confirm persistence (not just in-memory). + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + spec := stored.GetMmBlocksActionSpec("triage") + require.NotNil(t, spec, "stored plugin post must resolve the action spec at click time") + assert.Equal(t, "/plugins/com.mattermost.test-plugin/inline_action/triage", spec.URL) +} + +// TestMmBlocksActionsKeptForWebhookImpersonation verifies that an integration +// session is sufficient on its own — the post's author does not need to be a +// bot. This is the webhook-impersonation flow: a webhook posts as a regular +// user with from_webhook=true, and we must not strip the prop just because +// user.IsBot is false. +func TestMmBlocksActionsKeptForWebhookImpersonation(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + // Integration session for a regular (non-bot) user. + intSession := &model.Session{UserId: th.BasicUser.Id, IsOAuth: true} + intCtx := th.Context.WithSession(intSession) + + post := &model.Post{ + Message: "post from impersonating webhook", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "webhook1", + "http://127.0.0.1/plugins/myplugin/wh", + nil, + ), + }, + } + + created, _, err := th.App.CreatePostAsUser(intCtx, post, "", true) + require.Nil(t, err) + require.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions), + "non-bot author via integration session must preserve mm_blocks_actions (webhook flow)") + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + require.NotNil(t, stored.GetProp(model.PostPropsMmBlocksActions)) +} + +// TestMmBlocksActionsStripGate locks the create-time strip policy: keep +// when the post is bot-authored OR the session is an integration; strip +// when neither signal is present. The bot-author signal covers +// PluginAPI.CreatePost (whose static rctx is unmarked) where the post is +// authored by the plugin's bot user; the integration-session signal +// covers REST callers using bot tokens, PATs, or OAuth apps. +func TestMmBlocksActionsStripGate(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + + inline := buildMmBlocksActionsProp( + "mx", + "http://127.0.0.1/plugins/myplugin/mx", + nil, + ) + + t.Run("bot author via non-integration session is kept", func(t *testing.T) { + // Models the PluginAPI.CreatePost path: post.UserId is the plugin's + // bot user but rctx.Session() is the unmarked plugin context. The + // bot-author signal alone must be sufficient to keep the prop. + post := &model.Post{ + Message: "hello", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{model.PostPropsMmBlocksActions: inline}, + } + created, _, err := th.App.CreatePostAsUser(th.Context, post, "", true) + require.Nil(t, err) + assert.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions), + "bot-authored post must keep mm_blocks_actions even without an integration session") + }) + + t.Run("regular user via non-integration session is stripped", func(t *testing.T) { + // Neither signal present: the prop must be removed. Catches the + // baseline user-content case. + post := &model.Post{ + Message: "hello", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: th.BasicUser.Id, + Props: model.StringInterface{model.PostPropsMmBlocksActions: inline}, + } + created, _, err := th.App.CreatePostAsUser(th.Context, post, "", true) + require.Nil(t, err) + assert.Nil(t, created.GetProp(model.PostPropsMmBlocksActions), + "regular-user post via non-integration session must strip mm_blocks_actions") + }) +} + +func TestUpdatePostMmBlocksActionsGuard(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + + // Bot posts with mm_blocks_actions must be CREATED via an integration + // session — see the matching create-time strip in CreatePostAsUser. + intSeedSession := &model.Session{UserId: botUser.Id, IsOAuth: true} + intSeedCtx := th.Context.WithSession(intSeedSession) + + // originalInline is the mm_blocks_actions value we expect the bot post to + // keep after non-integration edits. + originalInline := buildMmBlocksActionsProp( + "keep", + "http://127.0.0.1/plugins/myplugin/original", + map[string]any{"k": "orig"}, + ) + + t.Run("non-integration edit of bot post reverts mm_blocks_actions", func(t *testing.T) { + botPost := &model.Post{ + Message: "bot post with inline actions", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: originalInline, + }, + } + created, _, cErr := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, cErr) + require.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions)) + + // A non-integration session tries to swap mm_blocks_actions wholesale. + newInline := buildMmBlocksActionsProp( + "swap", + "http://127.0.0.1/plugins/myplugin/swapped", + map[string]any{"k": "attacker"}, + ) + edit := created.Clone() + edit.Message = "edited message" + edit.AddProp(model.PostPropsMmBlocksActions, newInline) + + // th.Context has an empty/zero session — not an integration. + updated, _, uErr := th.App.UpdatePost(th.Context, edit, &model.UpdatePostOptions{SafeUpdate: false}) + require.Nil(t, uErr) + + // mm_blocks_actions should revert to the original value. + got := updated.GetMmBlocksActionSpec("keep") + require.NotNil(t, got, "original inline action should still be reachable") + assert.Equal(t, "http://127.0.0.1/plugins/myplugin/original", got.URL) + + // The attacker's swapped action should not be present. + assert.Nil(t, updated.GetMmBlocksActionSpec("swap")) + + // Message change should still be applied. + assert.Equal(t, "edited message", updated.Message) + }) + + t.Run("non-integration edit cannot add mm_blocks_actions when original had none", func(t *testing.T) { + plainBotPost := &model.Post{ + Message: "bot post without inline actions", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + } + created, _, cErr := th.App.CreatePostAsUser(intSeedCtx, plainBotPost, "", true) + require.Nil(t, cErr) + require.Nil(t, created.GetProp(model.PostPropsMmBlocksActions)) + + newInline := buildMmBlocksActionsProp( + "added", + "http://127.0.0.1/plugins/myplugin/added", + nil, + ) + edit := created.Clone() + edit.AddProp(model.PostPropsMmBlocksActions, newInline) + + updated, _, uErr := th.App.UpdatePost(th.Context, edit, &model.UpdatePostOptions{SafeUpdate: false}) + require.Nil(t, uErr) + assert.Nil(t, updated.GetProp(model.PostPropsMmBlocksActions), "non-integration update must not introduce mm_blocks_actions") + }) + + t.Run("integration session alone cannot modify mm_blocks_actions", func(t *testing.T) { + // Even with an integration session (PAT / OAuth / bot-token), the + // UpdatePost path requires AllowMmBlocksActionsUpdate to modify + // mm_blocks_actions. A PAT-holding user could otherwise inject + // mm_blocks_actions on any post they can edit. + botPost := &model.Post{ + Message: "bot post for integration edit", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: originalInline, + }, + } + created, _, cErr := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, cErr) + + intSession := &model.Session{UserId: th.BasicUser.Id, IsOAuth: true} + intCtx := th.Context.WithSession(intSession) + require.True(t, intCtx.Session().IsIntegration()) + + newInline := buildMmBlocksActionsProp( + "replaced", + "http://127.0.0.1/plugins/myplugin/new", + map[string]any{"k": "integration"}, + ) + edit := created.Clone() + edit.AddProp(model.PostPropsMmBlocksActions, newInline) + + updated, _, uErr := th.App.UpdatePost(intCtx, edit, &model.UpdatePostOptions{SafeUpdate: false}) + require.Nil(t, uErr) + + // The attacker's "replaced" entry must not land; the original stays. + assert.Nil(t, updated.GetMmBlocksActionSpec("replaced"), "integration session alone must not overwrite mm_blocks_actions") + keep := updated.GetMmBlocksActionSpec("keep") + require.NotNil(t, keep, "original inline action must be preserved") + assert.Equal(t, "http://127.0.0.1/plugins/myplugin/original", keep.URL) + }) + + t.Run("AllowMmBlocksActionsUpdate option accepts new mm_blocks_actions", func(t *testing.T) { + botPost := &model.Post{ + Message: "bot post for plugin-path edit", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: originalInline, + }, + } + created, _, cErr := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, cErr) + + newInline := buildMmBlocksActionsProp( + "plugin", + "http://127.0.0.1/plugins/myplugin/plugin", + map[string]any{"k": "plugin"}, + ) + edit := created.Clone() + edit.AddProp(model.PostPropsMmBlocksActions, newInline) + + // Non-integration session, but AllowMmBlocksActionsUpdate grants write. + updated, _, uErr := th.App.UpdatePost(th.Context, edit, &model.UpdatePostOptions{SafeUpdate: false, AllowMmBlocksActionsUpdate: true}) + require.Nil(t, uErr) + + assert.Nil(t, updated.GetMmBlocksActionSpec("keep")) + integration := updated.GetMmBlocksActionSpec("plugin") + require.NotNil(t, integration) + assert.Equal(t, "http://127.0.0.1/plugins/myplugin/plugin", integration.URL) + }) +} + +// TestCreateWebhookPostStripsMmBlocksActions locks the contract that an +// incoming webhook cannot persist mm_blocks_actions even if the payload +// includes the prop in its `props` map. CreateWebhookPost's prop iteration +// has no explicit blocklist entry for mm_blocks_actions; it falls through +// to AddProp and would land on the post object. The strip in CreatePost +// (post.go) then fires because the webhook flow has no integration session +// (incomingWebhook is registered with RequireSession: false). If a future +// refactor changes the webhook session model, this test catches it. +func TestCreateWebhookPostStripsMmBlocksActions(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableIncomingWebhooks = true }) + + hook, hookErr := th.App.CreateIncomingWebhookForChannel(th.BasicUser.Id, th.BasicChannel, &model.IncomingWebhook{ChannelId: th.BasicChannel.Id}) + require.Nil(t, hookErr) + defer func() { + _ = th.App.DeleteIncomingWebhook(hook.Id) + }() + + inline := buildMmBlocksActionsProp( + "actx", + "http://127.0.0.1/plugins/myplugin/x", + nil, + ) + + post, appErr := th.App.CreateWebhookPost(th.Context, hook.UserId, th.BasicChannel, "hello", "user", "http://iconurl", "", + model.StringInterface{ + model.PostPropsMmBlocksActions: inline, + }, + "", "", nil) + require.Nil(t, appErr) + + assert.Nil(t, post.GetProp(model.PostPropsMmBlocksActions), + "incoming webhook payload must not be able to persist mm_blocks_actions; the strip in CreatePost should fire because the webhook session has IsIntegration()==false") + + // Belt and suspenders: read back from the DB to confirm the prop is + // not persisted either. + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false) + require.NoError(t, nErr) + assert.Nil(t, stored.GetProp(model.PostPropsMmBlocksActions), + "stored webhook post must not carry mm_blocks_actions") +} + +func TestSendEphemeralPostStripsMmBlocksActions(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + ephemeral := &model.Post{ + ChannelId: th.BasicChannel.Id, + UserId: th.BasicUser.Id, + Message: "ephemeral with inline actions", + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "eph", + "http://127.0.0.1/plugins/myplugin/eph", + map[string]any{"k": "v"}, + ), + }, + } + + result, _ := th.App.SendEphemeralPost(th.Context, th.BasicUser.Id, ephemeral) + require.NotNil(t, result) + assert.Nil(t, result.GetProp(model.PostPropsMmBlocksActions), "SendEphemeralPost must drop mm_blocks_actions") + + // UpdateEphemeralPost path + ephemeral2 := &model.Post{ + Id: result.Id, + ChannelId: th.BasicChannel.Id, + UserId: th.BasicUser.Id, + Message: "updated ephemeral with inline actions", + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: buildMmBlocksActionsProp( + "eph2", + "http://127.0.0.1/plugins/myplugin/eph2", + nil, + ), + }, + } + updated, _ := th.App.UpdateEphemeralPost(th.Context, th.BasicUser.Id, ephemeral2) + require.NotNil(t, updated) + assert.Nil(t, updated.GetProp(model.PostPropsMmBlocksActions), "UpdateEphemeralPost must drop mm_blocks_actions") +} + +func TestDoPostActionQueryMergedIntoURL(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + intSeedCtx := th.Context.WithSession(&model.Session{UserId: botUser.Id, IsOAuth: true}) + + // Capture both the upstream integration request body and the URL the + // server saw, so we can assert that per-click query lands in the URL + // (mm_blocks transport) and not in the upstream Context map. + var ( + capturedReq model.PostActionIntegrationRequest + capturedRawQuery string + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawQuery = r.URL.RawQuery + body, readErr := io.ReadAll(r.Body) + require.NoError(t, readErr) + require.NoError(t, json.Unmarshal(body, &capturedReq)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + inlineActions := buildMmBlocksActionsProp( + "inline1", + ts.URL, + map[string]any{"operation": "STORM"}, + ) + botPost := &model.Post{ + Message: "mm_blocks action post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: inlineActions, + }, + } + created, _, err := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, err) + require.NotNil(t, created.GetProp(model.PostPropsMmBlocksActions)) + + query := map[string]string{"tail": "214"} + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, "inline1", th.BasicUser.Id, "", nil, query) + require.Nil(t, err) + + // Query was appended to the upstream URL. + parsedQuery, qErr := url.ParseQuery(capturedRawQuery) + require.NoError(t, qErr) + assert.Equal(t, "214", parsedQuery.Get("tail"), "per-click query should land in the upstream URL") + + // Original action Context is forwarded as the upstream request's + // Context, untouched by the query merge. + assert.Equal(t, "STORM", capturedReq.Context["operation"]) + _, leakedInlineParams := capturedReq.Context["inline_params"] + assert.False(t, leakedInlineParams, "query must not be injected into upstream Context") +} + +func TestDoPostActionStaticQueryMergedWithPerClickQuery(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + intSeedCtx := th.Context.WithSession(&model.Session{UserId: botUser.Id, IsOAuth: true}) + + var capturedRawQuery string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + // Spec carries a static query (source=fleet) AND a key (tail=999) that + // the per-click query will override. Per-click should win. + botPost := &model.Post{ + Message: "mm_blocks action post with static query", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: map[string]any{ + "inline1": map[string]any{ + "type": model.MmBlocksActionTypeExternal, + "url": ts.URL, + "query": map[string]any{"source": "fleet", "tail": "999"}, + }, + }, + }, + } + created, _, err := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, err) + + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, "inline1", th.BasicUser.Id, "", nil, map[string]string{"tail": "214"}) + require.Nil(t, err) + + parsedQuery, qErr := url.ParseQuery(capturedRawQuery) + require.NoError(t, qErr) + assert.Equal(t, "fleet", parsedQuery.Get("source"), "spec static query should land in the upstream URL") + assert.Equal(t, "214", parsedQuery.Get("tail"), "per-click query should override spec static query on overlapping keys") +} + +func TestDoPostActionContextMapNotMutated(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + intSeedCtx := th.Context.WithSession(&model.Session{UserId: botUser.Id, IsOAuth: true}) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer ts.Close() + + originalContext := map[string]any{"operation": "STORM"} + inlineActions := buildMmBlocksActionsProp("inline1", ts.URL, originalContext) + botPost := &model.Post{ + Message: "mm_blocks action post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsMmBlocksActions: inlineActions, + }, + } + created, _, err := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, err) + + // First click: carries one set of per-click query values. + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, "inline1", th.BasicUser.Id, "", nil, map[string]string{"tail": "214"}) + require.Nil(t, err) + + // Post's stored mm_blocks_actions Context must not be mutated by the click. + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + spec := stored.GetMmBlocksActionSpec("inline1") + require.NotNil(t, spec) + assert.Equal(t, "STORM", spec.Context["operation"]) + assert.Equal(t, ts.URL, spec.URL, "stored URL must not absorb per-click query") + + // Second click with a different per-click query. + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, "inline1", th.BasicUser.Id, "", nil, map[string]string{"tail": "999"}) + require.Nil(t, err) + + stored, nErr = th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + spec = stored.GetMmBlocksActionSpec("inline1") + require.NotNil(t, spec) + assert.Equal(t, "STORM", spec.Context["operation"]) + assert.Equal(t, ts.URL, spec.URL, "stored URL must not absorb per-click query") +} + +func TestDoPostActionPluginResponseMmBlocksActionsDropped(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + + // Plugin returns an update that tries to add mm_blocks_actions, even + // though the original post had none. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := `{ + "update": { + "message": "updated message", + "props": { + "mm_blocks_actions": { + "sneaky": {"type": "external", "url": "http://127.0.0.1/plugins/myplugin/sneak"} + } + } + } + }` + _, _ = w.Write([]byte(resp)) + })) + defer ts.Close() + + // Bot post has an ATTACHMENT action (not an mm_blocks action), and no + // mm_blocks_actions prop. The plugin's response to clicking the + // attachment should not be able to introduce mm_blocks_actions. + botPost := &model.Post{ + Message: "attachment-only bot post", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsAttachments: []*model.MessageAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Type: model.PostActionTypeButton, + Name: "click", + Integration: &model.PostActionIntegration{ + URL: ts.URL, + }, + }, + }, + }, + }, + }, + } + created, _, err := th.App.CreatePostAsUser(th.Context, botPost, "", true) + require.Nil(t, err) + attachments, ok := created.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + require.Nil(t, created.GetProp(model.PostPropsMmBlocksActions)) + + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) + require.Nil(t, err) + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + assert.Nil(t, stored.GetProp(model.PostPropsMmBlocksActions), "plugin response must not be able to add mm_blocks_actions where none existed") + assert.Equal(t, "updated message", stored.Message) +} + +func TestDoPostActionPluginResponseInvalidMmBlocksActionsRestored(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + botUser := setupBotInChannel(t, th) + intSeedCtx := th.Context.WithSession(&model.Session{UserId: botUser.Id, IsOAuth: true}) + + // Plugin returns an update where mm_blocks_actions contains an entry + // with an empty URL — invalid; the original prop should be restored + // with a warning, while the message update still succeeds. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := `{ + "update": { + "message": "updated via plugin", + "props": { + "mm_blocks_actions": { + "broken": {"type": "external", "url": ""} + } + } + } + }` + _, _ = w.Write([]byte(resp)) + })) + defer ts.Close() + + // The original post has VALID mm_blocks_actions, so the "drop because + // original had none" branch is bypassed and we exercise the validation + // branch. + originalInline := buildMmBlocksActionsProp( + "orig", + "http://127.0.0.1/plugins/myplugin/orig", + nil, + ) + botPost := &model.Post{ + Message: "bot post with valid inline actions", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + UserId: botUser.Id, + Props: model.StringInterface{ + model.PostPropsAttachments: []*model.MessageAttachment{ + { + Text: "hello", + Actions: []*model.PostAction{ + { + Type: model.PostActionTypeButton, + Name: "click", + Integration: &model.PostActionIntegration{ + URL: ts.URL, + }, + }, + }, + }, + }, + model.PostPropsMmBlocksActions: originalInline, + }, + } + created, _, err := th.App.CreatePostAsUser(intSeedCtx, botPost, "", true) + require.Nil(t, err) + attachments, ok := created.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) + require.True(t, ok) + require.NotEmpty(t, attachments[0].Actions) + require.NotEmpty(t, attachments[0].Actions[0].Id) + + _, err = th.App.DoPostActionWithCookie(th.Context, created.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) + require.Nil(t, err) + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, created.Id, false) + require.NoError(t, nErr) + // Message update still applied — the invalid mm_blocks_actions were + // restored to the original value with a warning, so the rest of the + // response.Update is persisted. + assert.Equal(t, "updated via plugin", stored.Message) + // The broken action from the plugin response must never be stored. + assert.Nil(t, stored.GetMmBlocksActionSpec("broken"), "invalid mm_blocks action from plugin response must not be persisted") + // The original valid mm_blocks_actions must survive — an invalid plugin + // response must never wipe a post's existing buttons. + require.NotNil(t, stored.GetMmBlocksActionSpec("orig"), "original valid mm_blocks action must be preserved when plugin response is invalid") + assert.Equal(t, "http://127.0.0.1/plugins/myplugin/orig", stored.GetMmBlocksActionSpec("orig").URL) +} + +// TestPostActionRetainsFromBotAndFromPlugin verifies that from_bot and +// from_plugin props are retained across a plugin-returned post update even +// when the plugin's response.Props omits them. This matters because the +// webapp's allowInlineActions gate is derived from these markers; losing +// them on first update would hide every inline button on subsequent renders. +func TestPostActionRetainsFromBotAndFromPlugin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + // Plugin response deliberately omits from_bot / from_plugin from props. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"update": {"message": "updated", "props": {"A": "AA"}}}`) + })) + defer ts.Close() + + interactivePost := model.Post{ + Message: "interactive", + ChannelId: th.BasicChannel.Id, + PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()), + 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: ts.URL, + }, + }}, + }}, + model.PostPropsFromBot: "true", + model.PostPropsFromPlugin: "true", + }, + } + + post, _, appErr := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + require.Nil(t, appErr) + attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.MessageAttachment) + require.True(t, ok) + + _, appErr = th.App.DoPostActionWithCookie(th.Context, post.Id, attachments[0].Actions[0].Id, th.BasicUser.Id, "", nil, nil) + require.Nil(t, appErr) + + stored, nErr := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false) + require.NoError(t, nErr) + + assert.Equal(t, "true", stored.GetProp(model.PostPropsFromBot), "from_bot must be retained across plugin update response") + assert.Equal(t, "true", stored.GetProp(model.PostPropsFromPlugin), "from_plugin must be retained across plugin update response") + assert.Equal(t, "AA", stored.GetProp("A"), "plugin-supplied prop applied") +} diff --git a/server/channels/app/job.go b/server/channels/app/job.go index f5df4cb2ab5..63c7e2cf311 100644 --- a/server/channels/app/job.go +++ b/server/channels/app/job.go @@ -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 diff --git a/server/channels/app/platform/enterprise.go b/server/channels/app/platform/enterprise.go index b601d908c9e..31d617d8655 100644 --- a/server/channels/app/platform/enterprise.go +++ b/server/channels/app/platform/enterprise.go @@ -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) { diff --git a/server/channels/app/platform/service.go b/server/channels/app/platform/service.go index 10587436453..302ae623946 100644 --- a/server/channels/app/platform/service.go +++ b/server/channels/app/platform/service.go @@ -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() diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go index e76e82b2b44..7d645c5a2d0 100644 --- a/server/channels/app/platform/support_packet.go +++ b/server/channels/app/platform/support_packet.go @@ -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) } diff --git a/server/channels/app/platform/support_packet_test.go b/server/channels/app/platform/support_packet_test.go index 56100cedd76..c8f290494cd 100644 --- a/server/channels/app/platform/support_packet_test.go +++ b/server/channels/app/platform/support_packet_test.go @@ -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) { diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index fa6dad2bec4..e3689b57bb2 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -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() } diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index 82562d734df..df483b555e1 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -6,12 +6,15 @@ package app import ( "bytes" _ "embed" + "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "testing" @@ -1846,6 +1849,171 @@ func TestHookMessagesWillBeConsumed(t *testing.T) { }) } +func TestUpdatePostFiresConsumeHook(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.ConsumePostHook = true + }).InitBasic(t) + + var mockAPI plugintest.API + mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil) + mockAPI.On("LogDebug", mock.Anything).Return(nil) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{` + package main + + import ( + "strings" + + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + for _, post := range posts { + post.Message = strings.ToUpper(post.Message) + } + return posts + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + t.Cleanup(tearDown) + + wsMessages, closeWS := connectFakeWebSocket(t, th, th.BasicUser.Id, "", []model.WebsocketEventType{ + model.WebsocketEventPosted, + model.WebsocketEventPostEdited, + }) + defer closeWS() + + basePost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original body", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: false}) + require.Nil(t, err) + + drainTimeout := time.After(500 * time.Millisecond) +drainLoop: + for { + select { + case <-wsMessages: + case <-drainTimeout: + break drainLoop + } + } + + editedMessage := "edited body" + patchedPost, _, err := th.App.PatchPost(th.Context, basePost.Id, &model.PostPatch{ + Message: &editedMessage, + }, nil) + require.Nil(t, err) + + require.Equal(t, "EDITED BODY", patchedPost.Message) + + timeout := time.After(5 * time.Second) + for { + select { + case ev := <-wsMessages: + if ev.EventType() != model.WebsocketEventPostEdited { + continue + } + postJSON, ok := ev.GetData()["post"].(string) + require.True(t, ok, "post field in websocket event should be a JSON string") + var wsPost model.Post + require.NoError(t, json.Unmarshal([]byte(postJSON), &wsPost)) + assert.Equal(t, "EDITED BODY", wsPost.Message) + return + case <-timeout: + require.Fail(t, "timed out waiting for post_edited websocket event") + } + } +} + +func TestUpdatePostNoConsumeHookWhenFlagDisabled(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.ConsumePostHook = false + }).InitBasic(t) + + var mockAPI plugintest.API + mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil) + mockAPI.On("LogDebug", mock.Anything).Return(nil) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{` + package main + + import ( + "strings" + + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessagesWillBeConsumed(posts []*model.Post) []*model.Post { + for _, post := range posts { + post.Message = strings.ToUpper(post.Message) + } + return posts + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + t.Cleanup(tearDown) + + basePost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original body", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: false}) + require.Nil(t, err) + + editedMessage := "edited body" + patchedPost, _, err := th.App.PatchPost(th.Context, basePost.Id, &model.PostPatch{ + Message: &editedMessage, + }, nil) + require.Nil(t, err) + + assert.Equal(t, "edited body", patchedPost.Message) +} + +func TestUpdatePostNoOpWhenNoPlugin(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.ConsumePostHook = true + }).InitBasic(t) + + basePost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original body", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: false}) + require.Nil(t, err) + + editedMessage := "edited body" + patchedPost, _, err := th.App.PatchPost(th.Context, basePost.Id, &model.PostPatch{ + Message: &editedMessage, + }, nil) + require.Nil(t, err) + + assert.Equal(t, "edited body", patchedPost.Message) +} + func TestHookPreferencesHaveChanged(t *testing.T) { mainHelper.Parallel(t) t.Run("should be called when preferences are changed by non-plugin code", func(t *testing.T) { @@ -2584,6 +2752,24 @@ func TestHookServeMetrics(t *testing.T) { }) } +func assertHookPostExists(t *testing.T, th *TestHelper, channelID, expectedMessage string) { + t.Helper() + + assert.Eventually(t, func() bool { + posts, appErr := th.App.GetPosts(th.Context, channelID, 0, 30) + require.Nil(t, appErr) + + for _, postID := range posts.Order { + post := posts.Posts[postID] + if post.Message == expectedMessage { + return true + } + } + + return false + }, 10*time.Second, 100*time.Millisecond) +} + func TestUserHasJoinedChannel(t *testing.T) { mainHelper.Parallel(t) getPluginCode := func(th *TestHelper) string { @@ -2649,29 +2835,15 @@ func TestUserHasJoinedChannel(t *testing.T) { // Setup plugin after creating the channel setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context) + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID), "plugin %q failed to activate", pluginID) _, appErr = th.App.AddChannelMember(th.Context, user2.Id, channel, ChannelMemberOpts{ UserRequestorID: user2.Id, }) require.Nil(t, appErr) - assert.EventuallyWithT(t, func(t *assert.CollectT) { - posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 30) - - require.Nil(t, appErr) - assert.True(t, len(posts.Order) > 0) - - found := false - for _, post := range posts.Posts { - if post.Message == fmt.Sprintf("Test: User %s joined %s", user2.Id, channel.Id) { - found = true - } - } - - if !found { - assert.Fail(t, "Couldn't find user joined channel hook message post") - } - }, 5*time.Second, 100*time.Millisecond) + expectedMessage := fmt.Sprintf("Test: User %s joined %s", user2.Id, channel.Id) + assertHookPostExists(t, th, channel.Id, expectedMessage) }) t.Run("should call hook when a user is added to an existing channel", func(t *testing.T) { @@ -2694,6 +2866,7 @@ func TestUserHasJoinedChannel(t *testing.T) { // Setup plugin after creating the channel setupPluginAPITest(t, getPluginCode(th), pluginManifest, pluginID, th.App, th.Context) + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID), "plugin %q failed to activate", pluginID) _, appErr = th.App.AddChannelMember(th.Context, user2.Id, channel, ChannelMemberOpts{ UserRequestorID: user1.Id, @@ -2701,22 +2874,7 @@ func TestUserHasJoinedChannel(t *testing.T) { require.Nil(t, appErr) expectedMessage := fmt.Sprintf("Test: User %s added to %s by %s", user2.Id, channel.Id, user1.Id) - assert.Eventually(t, func() bool { - // Typically, the post we're looking for will be the latest, but there's a race between the plugin and - // "User has joined the channel" post which means the plugin post may not the the latest one - posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 10) - require.Nil(t, appErr) - - for _, postId := range posts.Order { - post := posts.Posts[postId] - - if post.Message == expectedMessage { - return true - } - } - - return false - }, 5*time.Second, 100*time.Millisecond) + assertHookPostExists(t, th, channel.Id, expectedMessage) }) t.Run("should not call hook when a regular channel is created", func(t *testing.T) { @@ -3240,3 +3398,3003 @@ func TestHookChannelWillBeArchived(t *testing.T) { assert.NotEqual(t, int64(0), ch.DeleteAt) }) } + +func TestHookRPCChannelWillBeUpdated(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + return nil, "rpc test rejected" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + newCh := &model.Channel{Id: model.NewId(), TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, DisplayName: "new"} + oldCh := &model.Channel{Id: newCh.Id, TeamId: th.BasicTeam.Id, Type: model.ChannelTypeOpen, DisplayName: "old"} + replacement, reason := hooks.ChannelWillBeUpdated(&plugin.Context{}, newCh, oldCh) + require.Equal(t, "rpc test rejected", reason) + require.Nil(t, replacement) + }) + + t.Run("modify", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + newChannel.DisplayName = "modified-by-plugin" + return newChannel, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + newCh := &model.Channel{Id: model.NewId(), TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, DisplayName: "new"} + oldCh := &model.Channel{Id: newCh.Id, TeamId: th.BasicTeam.Id, Type: model.ChannelTypeOpen, DisplayName: "old"} + replacement, reason := hooks.ChannelWillBeUpdated(&plugin.Context{}, newCh, oldCh) + require.Equal(t, "", reason) + require.NotNil(t, replacement) + require.Equal(t, "modified-by-plugin", replacement.DisplayName) + }) +} + +func TestHookRPCChannelWillBeRestored(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "rpc test rejected" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + ch := &model.Channel{Id: model.NewId(), TeamId: th.BasicTeam.Id, Type: model.ChannelTypePrivate, DisplayName: "restore"} + reason := hooks.ChannelWillBeRestored(&plugin.Context{}, ch) + require.Equal(t, "rpc test rejected", reason) +} + +func TestHookRPCScheduledPostWillBeCreated(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + return nil, "rpc test rejected" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + sp := &model.ScheduledPost{ + Draft: model.Draft{ + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "scheduled hi", + }, + Id: model.NewId(), + ScheduledAt: 1234567890, + } + replacement, reason := hooks.ScheduledPostWillBeCreated(&plugin.Context{}, sp) + require.Equal(t, "rpc test rejected", reason) + require.Nil(t, replacement) + }) + + t.Run("modify", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + scheduledPost.Message = "modified-by-plugin" + return scheduledPost, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + sp := &model.ScheduledPost{ + Draft: model.Draft{ + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "original", + }, + Id: model.NewId(), + ScheduledAt: 1234567890, + } + replacement, reason := hooks.ScheduledPostWillBeCreated(&plugin.Context{}, sp) + require.Equal(t, "", reason) + require.NotNil(t, replacement) + require.Equal(t, "modified-by-plugin", replacement.Message) + }) +} + +func TestHookRPCDraftWillBeUpserted(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + return nil, "rpc test rejected" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + draft := &model.Draft{ + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "draft hi", + } + replacement, reason := hooks.DraftWillBeUpserted(&plugin.Context{}, draft) + require.Equal(t, "rpc test rejected", reason) + require.Nil(t, replacement) + }) + + t.Run("modify", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + draft.Message = "modified-by-plugin" + return draft, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + hooks, err := th.App.GetPluginsEnvironment().HooksForPlugin(pluginIDs[0]) + require.NoError(t, err) + + draft := &model.Draft{ + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "original", + } + replacement, reason := hooks.DraftWillBeUpserted(&plugin.Context{}, draft) + require.Equal(t, "", reason) + require.NotNil(t, replacement) + require.Equal(t, "modified-by-plugin", replacement.Message) + }) +} + +func TestRegisterChannelGuardIdempotent(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + channelID := th.BasicChannel.Id + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) OnActivate() error { + channelID := "` + channelID + `" + if appErr := p.API.RegisterChannelGuard(channelID); appErr != nil { + return appErr + } + // Second call must be idempotent. + return p.API.RegisterChannelGuard(channelID) + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + + rctx := request.EmptyContext(th.App.Srv().Log()) + guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, guards, 1, "second Register call must be a no-op (DO NOTHING)") + + cached := th.App.Channels().getGuardsForChannel(channelID) + require.Len(t, cached, 1, "cache should match the store") + assert.Equal(t, strings.ToLower(pluginIDs[0]), cached[0].PluginId) +} + +func TestRegisterChannelGuardMultiClaim(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + channelID := th.BasicChannel.Id + + pluginCode := func() string { + return ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) OnActivate() error { + return p.API.RegisterChannelGuard("` + channelID + `") + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + ` + } + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + pluginCode(), + pluginCode(), + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 2) + + rctx := request.EmptyContext(th.App.Srv().Log()) + guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, guards, 2, "two distinct plugins must produce two rows") + + pluginAID := strings.ToLower(pluginIDs[0]) + pluginBID := strings.ToLower(pluginIDs[1]) + + cached := th.App.Channels().getGuardsForChannel(channelID) + require.Len(t, cached, 2) + cachedIDs := []string{cached[0].PluginId, cached[1].PluginId} + assert.Contains(t, cachedIDs, pluginAID) + assert.Contains(t, cachedIDs, pluginBID) + + // Unregister plugin A's claim via the App-level method; B's claim must remain. + require.Nil(t, th.App.UnregisterChannelGuard(rctx, channelID, pluginAID)) + + guards, err = th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, guards, 1) + assert.Equal(t, pluginBID, guards[0].PluginId) + + cached = th.App.Channels().getGuardsForChannel(channelID) + require.Len(t, cached, 1) + assert.Equal(t, pluginBID, cached[0].PluginId) +} + +func TestChannelGuardSurvivesArchive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + channelID := th.BasicChannel.Id + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) OnActivate() error { + return p.API.RegisterChannelGuard("` + channelID + `") + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + + // Archive the channel. + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + + // Guard row must persist (no FK, no cascade). + rctx := request.EmptyContext(th.App.Srv().Log()) + guards, err := th.App.Srv().Store().ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, guards, 1) + assert.Equal(t, strings.ToLower(pluginIDs[0]), guards[0].PluginId) + + cached := th.App.Channels().getGuardsForChannel(channelID) + require.Len(t, cached, 1) +} + +func TestHookChannelWillBeUpdated(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + return nil, "update not permitted" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + original := th.BasicChannel.DisplayName + updated := th.BasicChannel.DeepCopy() + updated.DisplayName = "Should Not Persist" + + _, appErr := th.App.UpdateChannel(th.Context, updated) + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + + fetched, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + assert.Equal(t, original, fetched.DisplayName) + }) + + t.Run("modified", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "strings" + + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + newChannel.DisplayName = strings.ToUpper(newChannel.DisplayName) + return newChannel, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + updated := th.BasicChannel.DeepCopy() + updated.DisplayName = "lowercase name" + + _, appErr := th.App.UpdateChannel(th.Context, updated) + require.Nil(t, appErr) + + fetched, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + assert.Equal(t, "LOWERCASE NAME", fetched.DisplayName) + }) + + t.Run("old vs new diff", func(t *testing.T) { + // Plugin rejects only when the DisplayName changed — proving that oldChannel carries the + // stored value, not a copy of newChannel. + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + if oldChannel.DisplayName != newChannel.DisplayName { + return nil, "display name changed" + } + return nil, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + // Call with a changed DisplayName — plugin sees old != new and rejects. + changed := th.BasicChannel.DeepCopy() + changed.DisplayName = "Renamed Channel" + _, appErr := th.App.UpdateChannel(th.Context, changed) + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + + // Call with the same DisplayName — plugin sees old == new and allows. + same := th.BasicChannel.DeepCopy() + _, appErr = th.App.UpdateChannel(th.Context, same) + require.Nil(t, appErr) + }) + + t.Run("idempotent across repeat calls", func(t *testing.T) { + // UpdateChannelPrivacy may invoke UpdateChannel twice on the postChannelPrivacyMessage + // failure path (forward + revert). This test approximates that double-fire by calling + // UpdateChannel twice with the same plugin loaded — the hook must tolerate repeat invocations. + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + return nil, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + first := th.BasicChannel.DeepCopy() + first.DisplayName = "First" + _, appErr := th.App.UpdateChannel(th.Context, first) + require.Nil(t, appErr) + + second := first.DeepCopy() + second.DisplayName = "Second" + _, appErr = th.App.UpdateChannel(th.Context, second) + require.Nil(t, appErr) + + fetched, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + assert.Equal(t, "Second", fetched.DisplayName) + }) +} + +func TestHookChannelWillBeRestored(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // First archive the channel so RestoreChannel has something to do. + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "restore not permitted" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + archived, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + + _, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + + fetched, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + assert.NotEqual(t, int64(0), fetched.DeleteAt) + }) + + t.Run("allowed", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + archived, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + + _, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.Nil(t, appErr) + + fetched, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + assert.Equal(t, int64(0), fetched.DeleteAt) + }) +} + +func TestHookScheduledPostWillBeCreated(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("save rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + return nil, "scheduled post not permitted" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + sp := &model.ScheduledPost{ + Draft: model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "scheduled hi", + }, + ScheduledAt: model.GetMillis() + 60_000, + } + _, appErr := th.App.SaveScheduledPost(th.Context, sp, "") + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + }) + + t.Run("save modified", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + scheduledPost.Message = "modified-by-plugin" + return scheduledPost, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + sp := &model.ScheduledPost{ + Draft: model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original", + }, + ScheduledAt: model.GetMillis() + 60_000, + } + saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "") + require.Nil(t, appErr) + require.NotNil(t, saved) + assert.Equal(t, "modified-by-plugin", saved.Message) + }) + + t.Run("update rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // First save (no plugin loaded yet so the hook is a no-op). + sp := &model.ScheduledPost{ + Draft: model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original", + }, + ScheduledAt: model.GetMillis() + 60_000, + } + saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "") + require.Nil(t, appErr) + require.NotNil(t, saved) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + return nil, "update not permitted" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + saved.Message = "edited" + _, appErr = th.App.UpdateScheduledPost(th.Context, th.BasicUser.Id, saved, "") + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + }) +} + +func TestHookDraftWillBeUpserted(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("rejected", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + return nil, "draft not permitted" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + draft := &model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "draft hi", + } + _, appErr := th.App.UpsertDraft(th.Context, draft, "") + require.NotNil(t, appErr) + assert.Contains(t, appErr.Id, "rejected_by_plugin") + + drafts, getErr := th.App.GetDraftsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id) + require.Nil(t, getErr) + assert.Empty(t, drafts) + }) + + t.Run("modified", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + draft.Message = "modified-by-plugin" + return draft, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + draft := &model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original", + } + saved, appErr := th.App.UpsertDraft(th.Context, draft, "") + require.Nil(t, appErr) + require.NotNil(t, saved) + assert.Equal(t, "modified-by-plugin", saved.Message) + }) + + t.Run("delete-empty does not fire hook", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + // Plugin rejects everything; if it fires on the delete path we will see an AppError. + tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + return nil, "should not be called for empty-message delete" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + empty := &model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "", + } + _, appErr := th.App.UpsertDraft(th.Context, empty, "") + require.Nil(t, appErr) + }) +} + +func TestHooksNoOpWhenNoPlugin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // No plugin loaded — all hooks must be no-ops and the affected app calls must succeed + // (or fail for unrelated reasons). This guards against accidentally turning a no-op + // RunMultiHook into a hard requirement. + + updated := th.BasicChannel.DeepCopy() + updated.DisplayName = "renamed" + _, appErr := th.App.UpdateChannel(th.Context, updated) + require.Nil(t, appErr) + + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + archived, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + _, appErr = th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.Nil(t, appErr) + + // UpsertDraft exercises the DraftWillBeUpserted hook path with no plugin loaded. + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + draft := &model.Draft{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "no-op draft", + } + _, appErr = th.App.UpsertDraft(th.Context, draft, "") + require.Nil(t, appErr) +} + +func TestChannelGuardBlocksPostWhenPluginInactive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that implements MessageWillBePosted (allow all posts). + // The guard row is registered directly from the test using App.RegisterChannelGuard so + // the test is not coupled to a particular OnActivate implementation. + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Register a channel guard for BasicChannel under this plugin's ID. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + // Subtest (a): plugin active — CreatePost must succeed. + t.Run("plugin active allows post", func(t *testing.T) { + post := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "should be allowed", + } + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + require.NotNil(t, createdPost) + }) + + // Subtest (b): plugin deactivated — CreatePost must return 503 inactive_guard error + // and the post must not be persisted. + t.Run("plugin inactive rejects post", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) + require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + post := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "should be rejected", + } + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr, "expected error when guard plugin is inactive") + require.Nil(t, createdPost) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Verify the post was not persisted by fetching recent posts for the channel. + postList, storeErr := th.App.Srv().Store().Post().GetPosts(th.Context, model.GetPostsOptions{ + ChannelId: th.BasicChannel.Id, + Page: 0, + PerPage: 10, + }, false, nil) + require.NoError(t, storeErr) + for _, p := range postList.Posts { + assert.NotEqual(t, "should be rejected", p.Message, "rejected post must not be in the store") + } + }) +} + +func TestChannelGuardBlocksPostUpdateWhenPluginInactive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that implements MessageWillBeUpdated (allow all updates). + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) { + return newPost, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Register a channel guard for BasicChannel under this plugin's ID. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + // Create the initial post that will be updated. + post := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original message", + } + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + require.NotNil(t, createdPost) + + // Subtest (a): plugin active — UpdatePost must succeed. + t.Run("plugin active allows update", func(t *testing.T) { + updatedPost := createdPost.Clone() + updatedPost.Message = "updated message allowed" + result, _, appErr := th.App.UpdatePost(th.Context, updatedPost, nil) + require.Nil(t, appErr) + require.NotNil(t, result) + }) + + // Subtest (b): plugin deactivated — UpdatePost must return 503 inactive_guard error + // and the post must remain unchanged in the store. + t.Run("plugin inactive rejects update", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) + require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + updatedPost := createdPost.Clone() + updatedPost.Message = "should be rejected" + result, _, appErr := th.App.UpdatePost(th.Context, updatedPost, nil) + require.NotNil(t, appErr, "expected error when guard plugin is inactive") + require.Nil(t, result) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Verify the post was not updated by fetching it from the store. + fetchedPost, storeErr := th.App.GetSinglePost(th.Context, createdPost.Id, false) + require.Nil(t, storeErr) + assert.NotEqual(t, "should be rejected", fetchedPost.Message, "rejected update must not be persisted") + }) +} + +// TestChannelGuardPostUpdateRejectionReasonPreserved locks in the legacy rejection-reason +// shape for UpdatePost. A plugin returning (nil, "blocked-by-policy") must surface as +// AppError with Id "Post rejected by plugin. blocked-by-policy". The unguarded path +// exercises the legacy AppError shape that existing tooling may grep for. +func TestChannelGuardPostUpdateRejectionReasonPreserved(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) { + return nil, "blocked-by-policy" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginIDs[0])) + + // Create the initial post that will be updated. + post := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original message", + } + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + require.NotNil(t, createdPost) + + // Unguarded path — no guard registered. The plugin returns (nil, "blocked-by-policy") and the + // rejection error must include the reason verbatim. + updatedPost := createdPost.Clone() + updatedPost.Message = "unguarded rejection" + result, _, appErr := th.App.UpdatePost(th.Context, updatedPost, nil) + require.NotNil(t, appErr, "expected rejection from plugin") + require.Nil(t, result) + assert.Equal(t, "Post rejected by plugin. blocked-by-policy", appErr.Id) +} + +func TestChannelGuardBlocksMemberAddWhenPluginInactive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that implements ChannelMemberWillBeAdded (allow all). + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelMemberWillBeAdded(c *plugin.Context, member *model.ChannelMember) (*model.ChannelMember, string) { + return member, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Create a private channel to test member addition. + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + + // Register a channel guard for this channel under this plugin's ID. + appErr := th.App.RegisterChannelGuard(th.Context, privateChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + // Subtest (a): plugin active — AddUserToChannel must succeed. + t.Run("plugin active allows member add", func(t *testing.T) { + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, privateChannel, false) + // May already be a member from setup; either success or "already a member" is OK. + if appErr != nil { + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id, "must not be a guard error when plugin is active") + } + }) + + // Subtest (b): plugin deactivated — AddUserToChannel must return 503 inactive_guard error. + t.Run("plugin inactive rejects member add", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) + require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Use a new user who is definitely not yet a member; add them to the team first. + newUser := th.CreateUser(t) + _, _, teamErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, newUser.Id, "") + require.Nil(t, teamErr) + _, appErr := th.App.AddUserToChannel(th.Context, newUser, privateChannel, false) + require.NotNil(t, appErr, "expected error when guard plugin is inactive") + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Verify the user was not added. + _, memberErr := th.App.GetChannelMember(th.Context, privateChannel.Id, newUser.Id) + require.NotNil(t, memberErr, "user must not be a member of the channel") + }) +} + +func TestChannelGuardBlocksChannelUpdateWhenPluginInactive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that implements ChannelWillBeUpdated (allow all updates). + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + return newChannel, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Register a channel guard for BasicChannel under this plugin's ID. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + // Subtest (a): plugin active — UpdateChannel must succeed. + t.Run("plugin active allows update", func(t *testing.T) { + channelToUpdate := th.BasicChannel.DeepCopy() + channelToUpdate.DisplayName = "Updated Name Allowed" + result, appErr := th.App.UpdateChannel(th.Context, channelToUpdate) + require.Nil(t, appErr) + require.NotNil(t, result) + }) + + // Subtest (b): plugin deactivated — UpdateChannel must return 503 inactive_guard error + // and the channel must remain unchanged in the store. + t.Run("plugin inactive rejects update", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) + require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + channelToUpdate := th.BasicChannel.DeepCopy() + channelToUpdate.DisplayName = "Should Be Rejected" + result, appErr := th.App.UpdateChannel(th.Context, channelToUpdate) + require.NotNil(t, appErr, "expected error when guard plugin is inactive") + require.Nil(t, result) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Verify the channel was not updated. + fetched, storeErr := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, storeErr) + assert.NotEqual(t, "Should Be Rejected", fetched.DisplayName, "rejected update must not be persisted") + }) +} + +func TestChannelGuardRejectsTypeMutationFromPlugin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that flips the channel Type in its replacement. + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + mutated := newChannel.DeepCopy() + // Flip Open <-> Private. + if mutated.Type == model.ChannelTypeOpen { + mutated.Type = model.ChannelTypePrivate + } else { + mutated.Type = model.ChannelTypeOpen + } + return mutated, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Register a channel guard so this goes through the guarded path. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + originalType := th.BasicChannel.Type + + channelToUpdate := th.BasicChannel.DeepCopy() + channelToUpdate.DisplayName = "Type Mutation Attempt" + result, appErr := th.App.UpdateChannel(th.Context, channelToUpdate) + require.NotNil(t, appErr, "expected type-mutation error") + require.Nil(t, result) + assert.Equal(t, "app.channel.update_channel.plugin_type_mutation.app_error", appErr.Id) + assert.Equal(t, 400, appErr.StatusCode) + // The error string must include the offending plugin ID (from the i18n template). + assert.Contains(t, appErr.Error(), pluginID) + + // Verify the channel type was not changed. + fetched, storeErr := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, storeErr) + assert.Equal(t, originalType, fetched.Type, "type must not be mutated by plugin replacement") +} + +func TestChannelGuardAllowsNonTypeMutationFromPlugin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that modifies DisplayName but not Type. + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + modified := newChannel.DeepCopy() + modified.DisplayName = "plugin-modified-name" + return modified, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Register a channel guard so this goes through the guarded path. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + channelToUpdate := th.BasicChannel.DeepCopy() + channelToUpdate.DisplayName = "Original Caller Name" + result, appErr := th.App.UpdateChannel(th.Context, channelToUpdate) + require.Nil(t, appErr, "non-type-mutation replacement must succeed") + require.NotNil(t, result) + + // Verify the DB has the plugin-modified DisplayName. + fetched, storeErr := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, storeErr) + assert.Equal(t, "plugin-modified-name", fetched.DisplayName, "plugin DisplayName replacement must be persisted") +} + +// Guard blocks RestoreChannel when the guard plugin is inactive. +func TestChannelGuardBlocksRestoreWhenPluginInactive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Compile and activate a plugin that implements ChannelWillBeRestored (allow all). + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // Archive BasicChannel so RestoreChannel has something to do. + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + + // Register a channel guard for this channel under this plugin's ID. + appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID) + require.Nil(t, appErr, "RegisterChannelGuard must succeed") + + // Subtest (a): plugin active — RestoreChannel must succeed. + t.Run("plugin active allows restore", func(t *testing.T) { + archived, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + require.NotEqual(t, int64(0), archived.DeleteAt, "channel must be archived before restore") + + _, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.Nil(t, appErr, "expected no error when guard plugin is active") + + // Re-archive for the next subtest. + require.Nil(t, th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id)) + }) + + // Subtest (b): plugin deactivated — RestoreChannel must return 503 inactive_guard error + // and the channel must remain archived. + t.Run("plugin inactive rejects restore", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID)) + require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + archived, err := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, err) + require.NotEqual(t, int64(0), archived.DeleteAt, "channel must be archived for this subtest") + + result, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.NotNil(t, appErr, "expected error when guard plugin is inactive") + require.Nil(t, result) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Verify the channel was not restored (still archived). + fetched, storeErr := th.App.GetChannel(th.Context, th.BasicChannel.Id) + require.Nil(t, storeErr) + assert.NotEqual(t, int64(0), fetched.DeleteAt, "rejected restore must not change DeleteAt") + }) +} + +// --------------------------------------------------------------------------- +// Cross-cutting e2e tests for channel-guard dispatch +// --------------------------------------------------------------------------- + +// TestChannelGuardWrapperRejectsOnHookRPCError verifies that when a guard plugin's hook +// implementation panics (which net/rpc recovers and returns as a non-nil error from +// client.Call), the guarded site returns 503 app.plugin.guard_hook_failed.app_error. +// +// The first sub-test is a panic-discovery smoke test that proves the mechanism works before +// relying on it for all five sites. The remaining sub-tests cover each guarded site. +// +// Each sub-test also verifies that an unguarded channel with the same panicking plugin still +// succeeds (existing fail-open RunMultiHook swallows RPC errors per long-standing contract). +func TestChannelGuardWrapperRejectsOnHookRPCError(t *testing.T) { + mainHelper.Parallel(t) + + // panicAllPlugin is a single compiled plugin that panics in all five guarded hooks. + // One plugin, one compile — reused across every sub-test. + const panicAllPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type PanicPlugin struct { + plugin.MattermostPlugin +} + +func (p *PanicPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + panic("forced RPC error") +} + +func (p *PanicPlugin) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) { + panic("forced RPC error") +} + +func (p *PanicPlugin) ChannelMemberWillBeAdded(c *plugin.Context, member *model.ChannelMember) (*model.ChannelMember, string) { + panic("forced RPC error") +} + +func (p *PanicPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + panic("forced RPC error") +} + +func (p *PanicPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + panic("forced RPC error") +} + +func main() { + plugin.ClientMain(&PanicPlugin{}) +} +` + + // One sub-test per guarded site. Each registers the panicking guard plugin on a + // channel and asserts the guard wrapper returns 503 (Phase B fail-closed). Each also + // verifies the unguarded path with the same plugin returns no error (fail-open + // preservation for non-guarded callers). + + t.Run("MessageWillBePosted", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{panicAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + guardedCh := th.CreateChannel(t, th.BasicTeam) + appErr := th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID) + require.Nil(t, appErr) + + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: guardedCh.Id, + Message: "msg", + }, guardedCh, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Unguarded: fail-open. + unguardedCh := th.CreateChannel(t, th.BasicTeam) + _, _, appErr2 := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: unguardedCh.Id, + Message: "unguarded", + }, unguardedCh, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr2) + }) + + t.Run("MessageWillBeUpdated", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{panicAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + guardedCh := th.CreateChannel(t, th.BasicTeam) + appErr := th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID) + require.Nil(t, appErr) + + // Create a post to update (without the panicking plugin active on this channel yet). + // Create the initial post on BasicChannel (no guard) to avoid the guard. + initialPost := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: guardedCh.Id, + Message: "original", + } + // To create the initial post we need to temporarily bypass the guard. + // Remove guard, create post, re-add guard. + require.Nil(t, th.App.UnregisterChannelGuard(th.Context, guardedCh.Id, pluginID)) + created, _, err := th.App.CreatePost(th.Context, initialPost, guardedCh, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID)) + + updated := created.Clone() + updated.Message = "updated" + _, _, appErr = th.App.UpdatePost(th.Context, updated, nil) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Unguarded: fail-open. + unguardedCh := th.CreateChannel(t, th.BasicTeam) + initial2 := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: unguardedCh.Id, + Message: "initial2", + } + created2, _, err2 := th.App.CreatePost(th.Context, initial2, unguardedCh, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err2) + updated2 := created2.Clone() + updated2.Message = "updated2" + _, _, appErr2 := th.App.UpdatePost(th.Context, updated2, nil) + require.Nil(t, appErr2) + }) + + t.Run("ChannelMemberWillBeAdded", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{panicAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + guardedCh := th.CreatePrivateChannel(t, th.BasicTeam) + appErr := th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID) + require.Nil(t, appErr) + + newUser := th.CreateUser(t) + _, _, teamErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, newUser.Id, "") + require.Nil(t, teamErr) + + _, appErr = th.App.AddUserToChannel(th.Context, newUser, guardedCh, false) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Unguarded: fail-open. + unguardedCh := th.CreatePrivateChannel(t, th.BasicTeam) + newUser2 := th.CreateUser(t) + _, _, teamErr2 := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, newUser2.Id, "") + require.Nil(t, teamErr2) + _, appErr2 := th.App.AddUserToChannel(th.Context, newUser2, unguardedCh, false) + require.Nil(t, appErr2) + }) + + t.Run("ChannelWillBeUpdated", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{panicAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + guardedCh := th.CreateChannel(t, th.BasicTeam) + appErr := th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID) + require.Nil(t, appErr) + + ch := guardedCh.DeepCopy() + ch.DisplayName = "Panic Test" + _, appErr = th.App.UpdateChannel(th.Context, ch) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Unguarded: fail-open. + unguardedCh := th.CreateChannel(t, th.BasicTeam) + ch2 := unguardedCh.DeepCopy() + ch2.DisplayName = "Unguarded Update" + _, appErr2 := th.App.UpdateChannel(th.Context, ch2) + require.Nil(t, appErr2) + }) + + t.Run("ChannelWillBeRestored", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{panicAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + guardedCh := th.CreateChannel(t, th.BasicTeam) + require.Nil(t, th.App.DeleteChannel(th.Context, guardedCh, th.BasicUser.Id)) + appErr := th.App.RegisterChannelGuard(th.Context, guardedCh.Id, pluginID) + require.Nil(t, appErr) + + archived, err := th.App.GetChannel(th.Context, guardedCh.Id) + require.Nil(t, err) + _, appErr = th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Unguarded: fail-open. + unguardedCh := th.CreateChannel(t, th.BasicTeam) + require.Nil(t, th.App.DeleteChannel(th.Context, unguardedCh, th.BasicUser.Id)) + archived2, err2 := th.App.GetChannel(th.Context, unguardedCh.Id) + require.Nil(t, err2) + _, appErr2 := th.App.RestoreChannel(th.Context, archived2, th.BasicUser.Id) + require.Nil(t, appErr2) + }) +} + +// TestChannelGuardAllowsAllOpsWhenPluginActiveNoRejection registers a guard whose plugin +// allows every hook and exercises all five guarded sites to confirm no regression. +func TestChannelGuardAllowsAllOpsWhenPluginActiveNoRejection(t *testing.T) { + mainHelper.Parallel(t) + + th := Setup(t).InitBasic(t) + + const allowAllPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type AllowPlugin struct { + plugin.MattermostPlugin +} + +func (p *AllowPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "" +} + +func (p *AllowPlugin) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) { + return newPost, "" +} + +func (p *AllowPlugin) ChannelMemberWillBeAdded(c *plugin.Context, member *model.ChannelMember) (*model.ChannelMember, string) { + return member, "" +} + +func (p *AllowPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + return newChannel, "" +} + +func (p *AllowPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "" +} + +func main() { + plugin.ClientMain(&AllowPlugin{}) +} +` + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{allowAllPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + // All five sites share the same channel so one guard covers all. + ch := th.BasicChannel + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, pluginID)) + + // Site 1: MessageWillBePosted (CreatePost). + t.Run("MessageWillBePosted", func(t *testing.T) { + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "allow all test", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + }) + + // Site 2: MessageWillBeUpdated (UpdatePost). Create a post first on BasicChannel (no guard + // conflict — guard already registered, plugin allows). + var createdPost *model.Post + t.Run("MessageWillBeUpdated_setup", func(t *testing.T) { + var appErr *model.AppError + createdPost, _, appErr = th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "original for update", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + }) + + t.Run("MessageWillBeUpdated", func(t *testing.T) { + require.NotNil(t, createdPost) + up := createdPost.Clone() + up.Message = "updated by allow-all guard" + _, _, appErr := th.App.UpdatePost(th.Context, up, nil) + require.Nil(t, appErr) + }) + + // Site 3: ChannelMemberWillBeAdded. Use a fresh user to guarantee AddUserToChannel + // reaches the hook (existing-membership early-return would silently skip it). + t.Run("ChannelMemberWillBeAdded", func(t *testing.T) { + newUser := th.CreateUser(t) + _, _, teamErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, newUser.Id, "") + require.Nil(t, teamErr) + _, appErr := th.App.AddUserToChannel(th.Context, newUser, ch, false) + require.Nil(t, appErr) + }) + + // Site 4: ChannelWillBeUpdated. + t.Run("ChannelWillBeUpdated", func(t *testing.T) { + update := ch.DeepCopy() + update.DisplayName = "Allow-All Guard Test" + result, appErr := th.App.UpdateChannel(th.Context, update) + require.Nil(t, appErr) + require.NotNil(t, result) + }) + + // Site 5: ChannelWillBeRestored. Archive then restore. + t.Run("ChannelWillBeRestored", func(t *testing.T) { + restoreCh := th.CreateChannel(t, th.BasicTeam) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, restoreCh.Id, pluginID)) + require.Nil(t, th.App.DeleteChannel(th.Context, restoreCh, th.BasicUser.Id)) + archived, err := th.App.GetChannel(th.Context, restoreCh.Id) + require.Nil(t, err) + _, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.Nil(t, appErr) + }) +} + +// TestChannelGuardFiresHookWhenPluginActive confirms that for each of the five guarded sites, +// when a guard plugin's hook returns a rejection, the rejection comes from the hook (not from +// the guard inactive pre-check). The error reason matches the plugin-returned string. +func TestChannelGuardFiresHookWhenPluginActive(t *testing.T) { + mainHelper.Parallel(t) + + const rejectPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type RejectPlugin struct { + plugin.MattermostPlugin +} + +func (p *RejectPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "guard-rejected-post" +} + +func (p *RejectPlugin) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) { + return nil, "guard-rejected-update" +} + +func (p *RejectPlugin) ChannelMemberWillBeAdded(c *plugin.Context, member *model.ChannelMember) (*model.ChannelMember, string) { + return nil, "guard-rejected-member" +} + +func (p *RejectPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + return nil, "guard-rejected-channel-update" +} + +func (p *RejectPlugin) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + return "guard-rejected-restore" +} + +func main() { + plugin.ClientMain(&RejectPlugin{}) +} +` + + t.Run("MessageWillBePosted", func(t *testing.T) { + th := Setup(t).InitBasic(t) + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{rejectPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + ch := th.BasicChannel + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, pluginID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "msg", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr, "plugin rejection must return error") + // The error comes from the hook (plugin active) — Id must contain the rejection reason. + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id, "must not be inactive-guard error") + assert.Contains(t, appErr.Id, "guard-rejected-post") + }) + + t.Run("MessageWillBeUpdated", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + // Create a post BEFORE activating the reject plugin (the plugin also rejects + // MessageWillBePosted, so CreatePost would fail if the plugin were active). + initialPost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, err) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{rejectPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)) + + updated := initialPost.Clone() + updated.Message = "attempt" + _, _, appErr := th.App.UpdatePost(th.Context, updated, nil) + require.NotNil(t, appErr) + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Contains(t, appErr.Id, "guard-rejected-update") + }) + + t.Run("ChannelMemberWillBeAdded", func(t *testing.T) { + th := Setup(t).InitBasic(t) + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{rejectPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + ch := th.CreatePrivateChannel(t, th.BasicTeam) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, pluginID)) + + newUser := th.CreateUser(t) + _, _, teamErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, newUser.Id, "") + require.Nil(t, teamErr) + + _, appErr := th.App.AddUserToChannel(th.Context, newUser, ch, false) + require.NotNil(t, appErr) + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id) + // ChannelMemberWillBeAdded rejection wraps the reason via app.channel.add_user.to.channel.rejected_by_plugin + assert.Equal(t, "app.channel.add_user.to.channel.rejected_by_plugin", appErr.Id) + }) + + t.Run("ChannelWillBeUpdated", func(t *testing.T) { + th := Setup(t).InitBasic(t) + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{rejectPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + ch := th.BasicChannel + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, pluginID)) + + update := ch.DeepCopy() + update.DisplayName = "Rejected" + _, appErr := th.App.UpdateChannel(th.Context, update) + require.NotNil(t, appErr) + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, "app.channel.update_channel.rejected_by_plugin", appErr.Id) + }) + + t.Run("ChannelWillBeRestored", func(t *testing.T) { + th := Setup(t).InitBasic(t) + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{rejectPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + ch := th.CreateChannel(t, th.BasicTeam) + require.Nil(t, th.App.DeleteChannel(th.Context, ch, th.BasicUser.Id)) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, pluginID)) + + archived, err := th.App.GetChannel(th.Context, ch.Id) + require.Nil(t, err) + _, appErr := th.App.RestoreChannel(th.Context, archived, th.BasicUser.Id) + require.NotNil(t, appErr) + assert.NotEqual(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, "app.channel.restore_channel.rejected_by_plugin", appErr.Id) + }) +} + +// TestChannelGuardTwoPhaseDispatchOrdering installs two plugins: a guard plugin G and a +// non-guard plugin N. N uppercases the message in Phase A; G sees the uppercased message in +// Phase B. When N rejects, Phase B is not invoked. +func TestChannelGuardTwoPhaseDispatchOrdering(t *testing.T) { + mainHelper.Parallel(t) + + // Guard plugin G: allow everything; records the message it received. + // The destination file path is baked into the source at compile time so the + // plugin doesn't need to read it from the environment — process-global env + // mutation is incompatible with t.Parallel(). + makeGuardSrc := func(receivedFile string) string { + return fmt.Sprintf(` +package main + +import ( + "os" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type GuardPlugin struct { + plugin.MattermostPlugin +} + +func (p *GuardPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + _ = os.WriteFile(%q, []byte(post.Message), 0644) + return nil, "" +} + +func main() { + plugin.ClientMain(&GuardPlugin{}) +} +`, receivedFile) + } + + // Non-guard plugin N: uppercases the message. + const srcN = ` +package main + +import ( + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type NPlugin struct { + plugin.MattermostPlugin +} + +func (p *NPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + modified := post.Clone() + modified.Message = strings.ToUpper(post.Message) + return modified, "" +} + +func main() { + plugin.ClientMain(&NPlugin{}) +} +` + + // Non-guard plugin N_reject: rejects all posts. + const srcNReject = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type NRejectPlugin struct { + plugin.MattermostPlugin +} + +func (p *NRejectPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "n-rejected" +} + +func main() { + plugin.ClientMain(&NRejectPlugin{}) +} +` + + // Sub-test (a): N uppercases, G receives the uppercased message. + t.Run("Phase_A_composes_into_Phase_B_input", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + // Temp file for the guard plugin to write the received message. + receivedFile, err := os.CreateTemp("", "guard_received_*.txt") + require.NoError(t, err) + receivedFile.Close() + defer os.Remove(receivedFile.Name()) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{makeGuardSrc(receivedFile.Name()), srcN}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 2) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + + // Determine which ID belongs to G vs N based on position. + gID := pluginIDs[0] + nID := pluginIDs[1] + _ = nID // N is not registered as a guard. + + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, gID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "hello", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + // Read the message that the guard plugin received; it must be uppercased. + received, readErr := os.ReadFile(receivedFile.Name()) + require.NoError(t, readErr) + assert.Equal(t, "HELLO", string(received), "Phase B guard must see Phase A's output (uppercased)") + }) + + // Sub-test (b): N rejects → Phase B (guard) is not invoked. + t.Run("Phase_A_rejection_skips_Phase_B", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + receivedFile, err := os.CreateTemp("", "guard_received_*.txt") + require.NoError(t, err) + receivedFile.Close() + defer os.Remove(receivedFile.Name()) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{makeGuardSrc(receivedFile.Name()), srcNReject}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 2) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + + gID := pluginIDs[0] + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, gID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "msg", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr, "N_reject must reject") + assert.Contains(t, appErr.Id, "n-rejected") + + // Guard plugin must NOT have been called (file stays empty). + received, readErr := os.ReadFile(receivedFile.Name()) + require.NoError(t, readErr) + assert.Empty(t, string(received), "Phase B guard must not be invoked when Phase A rejects") + }) +} + +// TestChannelGuardMultiClaimAllMustBeActive installs two guard plugins G1 and G2 on the +// same channel. Both active → CreatePost succeeds. Deactivate either → 503. Re-activate → +// success. The plugin ID is logged server-side (operator attribution) but intentionally +// omitted from the user-facing AppError, so this test only asserts the generic 503 shape. +func TestChannelGuardMultiClaimAllMustBeActive(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + const allowPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type AllowPlugin struct { + plugin.MattermostPlugin +} + +func (p *AllowPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "" +} + +func main() { + plugin.ClientMain(&AllowPlugin{}) +} +` + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{allowPlugin, allowPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 2) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + + g1ID := pluginIDs[0] + g2ID := pluginIDs[1] + + ch := th.BasicChannel + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, g1ID)) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, ch.Id, g2ID)) + + // Both active: must succeed. + t.Run("both_active_succeeds", func(t *testing.T) { + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "both active", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + }) + + // Deactivate G1: must get the generic 503 (plugin ID is in the server log, not the AppError). + t.Run("g1_inactive_returns_503", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(g1ID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "g1 inactive", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Re-activate G1. + _, _, activateErr := th.App.GetPluginsEnvironment().Activate(g1ID) + require.NoError(t, activateErr) + }) + + // Deactivate G2: must get 503. + t.Run("g2_inactive_returns_503", func(t *testing.T) { + require.True(t, th.App.GetPluginsEnvironment().Deactivate(g2ID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "g2 inactive", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr) + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) + + // Re-activate G2. + _, _, activateErr := th.App.GetPluginsEnvironment().Activate(g2ID) + require.NoError(t, activateErr) + }) + + // Both re-activated: must succeed again. + t.Run("both_reactivated_succeeds", func(t *testing.T) { + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: ch.Id, + Message: "both reactivated", + }, ch, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + }) +} + +// TestChannelGuardMultiClaimPhaseBSequence verifies Phase B composition and sequencing with two +// guard plugins G1 and G2. Plugin IDs are random UUIDs at test time, so the test does not pin which +// guard sorts first; it asserts properties that hold regardless of order. +// +// a) Both allow: each prepends its tag to the message → final message contains both tags in +// PluginId-sorted-call order, proving Phase B composes left-to-right. +// +// b) Whichever guard runs first rejects → the second guard is NOT invoked (test reads +// either possible counter file and asserts at least one is empty, allowing 0 or 1 +// invocations of the second to satisfy the short-circuit contract). +// +// c) Phase A's RunMultiHookExcluding skips both guards: a third non-guard plugin N runs +// exactly once per CreatePost, while G1/G2's counters do not increment during Phase A. +func TestChannelGuardMultiClaimPhaseBSequence(t *testing.T) { + mainHelper.Parallel(t) + + // Each plugin source is built per-subtest with its counter file path baked + // in as a Go literal. Reading the path from the environment instead would + // require t.Setenv, which panics under t.Parallel. + + // G1: prepends "G1:" to the message; writes its call count to a file. + makeG1PrependSrc := func(countFile string) string { + return fmt.Sprintf(` +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type G1Plugin struct { + plugin.MattermostPlugin +} + +func (p *G1Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + countFile := %q + count := 0 + if data, err := os.ReadFile(countFile); err == nil { + count, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + count++ + _ = os.WriteFile(countFile, []byte(fmt.Sprintf("%%d", count)), 0644) + + modified := post.Clone() + modified.Message = "G1:" + post.Message + return modified, "" +} + +func main() { + plugin.ClientMain(&G1Plugin{}) +} +`, countFile) + } + + // G1 that rejects. + makeG1RejectSrc := func(countFile string) string { + return fmt.Sprintf(` +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type G1RejectPlugin struct { + plugin.MattermostPlugin +} + +func (p *G1RejectPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + countFile := %q + count := 0 + if data, err := os.ReadFile(countFile); err == nil { + count, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + count++ + _ = os.WriteFile(countFile, []byte(fmt.Sprintf("%%d", count)), 0644) + return nil, "g1-rejected" +} + +func main() { + plugin.ClientMain(&G1RejectPlugin{}) +} +`, countFile) + } + + // G2: prepends "G2:" to the message; writes its call count to a file. + makeG2Src := func(countFile string) string { + return fmt.Sprintf(` +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type G2Plugin struct { + plugin.MattermostPlugin +} + +func (p *G2Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + countFile := %q + count := 0 + if data, err := os.ReadFile(countFile); err == nil { + count, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + count++ + _ = os.WriteFile(countFile, []byte(fmt.Sprintf("%%d", count)), 0644) + + modified := post.Clone() + modified.Message = "G2:" + post.Message + return modified, "" +} + +func main() { + plugin.ClientMain(&G2Plugin{}) +} +`, countFile) + } + + // G3: counts in a temp file but never rejects (used as the third guard in phase-b tests). + makeG3Src := func(countFile string) string { + return fmt.Sprintf(` +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type G3Plugin struct { + plugin.MattermostPlugin +} + +func (p *G3Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + countFile := %q + count := 0 + if data, err := os.ReadFile(countFile); err == nil { + count, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + count++ + _ = os.WriteFile(countFile, []byte(fmt.Sprintf("%%d", count)), 0644) + return nil, "" +} + +func main() { + plugin.ClientMain(&G3Plugin{}) +} +`, countFile) + } + + // Non-guard plugin N: writes its call count to a file. + makeNSrc := func(countFile string) string { + return fmt.Sprintf(` +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type NPlugin struct { + plugin.MattermostPlugin +} + +func (p *NPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + countFile := %q + count := 0 + if data, err := os.ReadFile(countFile); err == nil { + count, _ = strconv.Atoi(strings.TrimSpace(string(data))) + } + count++ + _ = os.WriteFile(countFile, []byte(fmt.Sprintf("%%d", count)), 0644) + return nil, "" +} + +func main() { + plugin.ClientMain(&NPlugin{}) +} +`, countFile) + } + + // Helper to read a counter file. + readCount := func(t *testing.T, path string) int { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + s := strings.TrimSpace(string(data)) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + require.NoError(t, err) + return n + } + + // Sub-test (a): both allow, modifications compose left-to-right. + // G1 prepends "G1:", G2 prepends "G2:" → "G2:G1:". + // Phase B order is determined by PluginId alphabetical order (resolveGuards sorts). + t.Run("composition_left_to_right", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + g1CountFile, _ := os.CreateTemp("", "g1_count_*.txt") + g1CountFile.Close() + defer os.Remove(g1CountFile.Name()) + g2CountFile, _ := os.CreateTemp("", "g2_count_*.txt") + g2CountFile.Close() + defer os.Remove(g2CountFile.Name()) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{makeG1PrependSrc(g1CountFile.Name()), makeG2Src(g2CountFile.Name())}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 2) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + + // pluginIDs[0] → G1Prepend (prepends "G1:"), pluginIDs[1] → G2 (prepends "G2:"). + // resolveGuards fires Phase B in PluginId alphabetical order. Walk the sorted IDs to + // predict the expected final message and assert exact equality. + id0, id1 := pluginIDs[0], pluginIDs[1] + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, id0)) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, id1)) + + sortedIDs := []string{id0, id1} + sort.Strings(sortedIDs) + // Each plugin prepends its tag to whatever message it receives. Walking in + // sorted order: the first plugin sees "original" and produces "G?:original"; + // the second plugin sees that and prepends its own tag. Build the expected + // result by walking backwards through the sorted list (each plugin wraps the prior). + pluginTag := map[string]string{id0: "G1:", id1: "G2:"} + expected := "original" + for _, id := range sortedIDs { + expected = pluginTag[id] + expected + } + + created, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "original", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + require.NotNil(t, created) + + // Exact equality: confirms both that both plugins ran AND that they ran in + // PluginId-sorted order. Contains would accept the wrong order. + require.Equal(t, expected, created.Message) + }) + + // Sub-test (b): a guard's rejection propagates and stops Phase B iteration. + // + // Three guard plugins are used so that the rejecter can be in the middle of the + // sorted order (two plugins cannot detect a missing short-circuit: the loop ends + // naturally after two iterations regardless). The rejecter is G1Reject (pluginIDs[0]); + // G2 (pluginIDs[1]) and G3 (pluginIDs[2]) are plain counters. After sorting the + // three plugin IDs, any plugin whose sorted position is after the rejecter MUST have a + // count of 0 (Phase B short-circuited). Any plugin before the rejecter must have count 1. + // The rejecter itself must have count 1. + t.Run("guard_rejection_stops_phase_b", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + g1CountFile, _ := os.CreateTemp("", "g1_count_*.txt") + g1CountFile.Close() + defer os.Remove(g1CountFile.Name()) + g2CountFile, _ := os.CreateTemp("", "g2_count_*.txt") + g2CountFile.Close() + defer os.Remove(g2CountFile.Name()) + g3CountFile, _ := os.CreateTemp("", "g3_count_*.txt") + g3CountFile.Close() + defer os.Remove(g3CountFile.Name()) + + // pluginIDs[0] → G1Reject (rejecter, writes to g1CountFile) + // pluginIDs[1] → G2 (counter, writes to g2CountFile) + // pluginIDs[2] → G3 (counter, writes to g3CountFile) + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{makeG1RejectSrc(g1CountFile.Name()), makeG2Src(g2CountFile.Name()), makeG3Src(g3CountFile.Name())}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 3) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + require.NoError(t, errs[2]) + + rejecterID := pluginIDs[0] + for _, id := range pluginIDs { + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, id)) + } + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "msg", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr, "rejection from a guard in Phase B must propagate") + assert.Contains(t, appErr.Id, "g1-rejected", "the reject plugin must be the source of the error") + + // Map each plugin ID to its count file so we can check by sorted position. + countFile := map[string]string{ + pluginIDs[0]: g1CountFile.Name(), + pluginIDs[1]: g2CountFile.Name(), + pluginIDs[2]: g3CountFile.Name(), + } + sortedIDs := []string{pluginIDs[0], pluginIDs[1], pluginIDs[2]} + sort.Strings(sortedIDs) + + // Find rejecter's index in the sorted order. + rejecterIdx := -1 + for i, id := range sortedIDs { + if id == rejecterID { + rejecterIdx = i + break + } + } + require.NotEqual(t, -1, rejecterIdx) + + // Rejecter must have run exactly once. + rejecterCount := readCount(t, countFile[rejecterID]) + assert.Equal(t, 1, rejecterCount, "rejecter plugin must have been invoked exactly once") + + // Plugins sorted before the rejecter: each must have run exactly once. + for _, id := range sortedIDs[:rejecterIdx] { + c := readCount(t, countFile[id]) + assert.Equal(t, 1, c, "plugin sorted before rejecter must have run once") + } + + // Plugins sorted after the rejecter: Phase B must have short-circuited; count must be 0. + for _, id := range sortedIDs[rejecterIdx+1:] { + c := readCount(t, countFile[id]) + assert.Equal(t, 0, c, "plugin sorted after rejecter must not have been invoked (short-circuit)") + } + }) + + // Sub-test (c): Phase A's RunMultiHookExcluding skips guards; non-guard N runs once. + t.Run("phase_a_excludes_guards", func(t *testing.T) { + th := Setup(t).InitBasic(t) + + g1CountFile, _ := os.CreateTemp("", "g1_count_*.txt") + g1CountFile.Close() + defer os.Remove(g1CountFile.Name()) + g2CountFile, _ := os.CreateTemp("", "g2_count_*.txt") + g2CountFile.Close() + defer os.Remove(g2CountFile.Name()) + nCountFile, _ := os.CreateTemp("", "n_count_*.txt") + nCountFile.Close() + defer os.Remove(nCountFile.Name()) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{makeG1PrependSrc(g1CountFile.Name()), makeG2Src(g2CountFile.Name()), makeNSrc(nCountFile.Name())}, th.App, th.NewPluginAPI) + defer tearDown() + require.Len(t, errs, 3) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + require.NoError(t, errs[2]) + + g1RegID := pluginIDs[0] + g2RegID := pluginIDs[1] + // pluginIDs[2] is N — not registered as a guard. + + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, g1RegID)) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, g2RegID)) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "phase-a-test", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + // N (non-guard) runs exactly once during Phase A. + nCount := readCount(t, nCountFile.Name()) + assert.Equal(t, 1, nCount, "non-guard plugin N must run once in Phase A") + + // G1 and G2 each run exactly once during Phase B (not in Phase A). + g1Count := readCount(t, g1CountFile.Name()) + g2Count := readCount(t, g2CountFile.Name()) + assert.Equal(t, 1, g1Count, "G1 must run once in Phase B only") + assert.Equal(t, 1, g2Count, "G2 must run once in Phase B only") + }) +} + +// TestChannelGuardNoCheckWhenNoRow confirms that channels with no guard registered +// proceed normally and no guard-related error IDs fire. +func TestChannelGuardNoCheckWhenNoRow(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // No plugin installed. Channels have no guard rows. + // CreatePost must succeed without any guard-related error. + post := &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "no guard test", + } + created, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr, "CreatePost on unguarded channel must succeed") + require.NotNil(t, created) + assert.NotEqual(t, "", created.Id, "created post must have an ID") + + // UpdatePost must also succeed. + updated := created.Clone() + updated.Message = "updated no guard" + result, _, appErr2 := th.App.UpdatePost(th.Context, updated, nil) + require.Nil(t, appErr2, "UpdatePost on unguarded channel must succeed") + require.NotNil(t, result) + + // UpdateChannel must succeed. + ch := th.BasicChannel.DeepCopy() + ch.DisplayName = "No Guard Channel Update" + updatedCh, appErr3 := th.App.UpdateChannel(th.Context, ch) + require.Nil(t, appErr3, "UpdateChannel on unguarded channel must succeed") + require.NotNil(t, updatedCh) +} + +// TestChannelGuardFailsClosedWhenPluginsDisabled covers the resolveGuards branch where the +// plugin system is off (PluginSettings.Enable == false) but a guard row still exists for the +// channel. The user-facing AppError shape is the same generic 503 used for inactive guards +// (the distinguishing operator-facing error_id lives in the server log via +// logAndErrPluginsDisabled), so this test verifies fail-closed enforcement, not log content. +func TestChannelGuardFailsClosedWhenPluginsDisabled(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { + return nil, "" + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `, + }, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)) + + // Disable the plugin system globally. resolveGuards now sees env == nil while + // guards remain in the cache, taking the logAndErrPluginsDisabled branch. + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "plugins disabled", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.NotNil(t, appErr, "guarded channel must fail-closed when plugin system is disabled") + assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id) + assert.Equal(t, 503, appErr.StatusCode) +} + +// TestChannelGuardAllowByDefaultForUnimplementedHook covers the contract documented in +// guarded_hooks.go: 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[] gate skips the RPC entirely and returns zero values with a nil +// error — which the helper treats as "no opinion" rather than rejection. The op succeeds. +func TestChannelGuardAllowByDefaultForUnimplementedHook(t *testing.T) { + mainHelper.Parallel(t) + + // partialPlugin implements ChannelMemberWillBeAdded only; all other guarded-hook + // companions return "not implemented" (zero values, nil error), which the helpers + // treat as allow-by-default. + const partialPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type PartialPlugin struct { + plugin.MattermostPlugin +} + +func (p *PartialPlugin) ChannelMemberWillBeAdded(c *plugin.Context, member *model.ChannelMember) (*model.ChannelMember, string) { + return nil, "" +} + +func main() { + plugin.ClientMain(&PartialPlugin{}) +} +` + + th := Setup(t).InitBasic(t) + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{partialPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 1) + require.NoError(t, errs[0]) + pluginID := pluginIDs[0] + + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)) + + // CreatePost: plugin does not implement MessageWillBePosted → allow-by-default. + created, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "allow by default", + }, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr, "CreatePost must succeed when guard plugin doesn't implement MessageWillBePosted") + require.NotNil(t, created) + + // UpdatePost: plugin does not implement MessageWillBeUpdated → allow-by-default. + updated := created.Clone() + updated.Message = "allow by default updated" + result, _, appErr2 := th.App.UpdatePost(th.Context, updated, nil) + require.Nil(t, appErr2, "UpdatePost must succeed when guard plugin doesn't implement MessageWillBeUpdated") + require.NotNil(t, result) + + // UpdateChannel: plugin does not implement ChannelWillBeUpdated → allow-by-default. + chCopy := th.BasicChannel.DeepCopy() + chCopy.DisplayName = "Allow by Default Update" + updatedCh, appErr3 := th.App.UpdateChannel(th.Context, chCopy) + require.Nil(t, appErr3, "UpdateChannel must succeed when guard plugin doesn't implement ChannelWillBeUpdated") + require.NotNil(t, updatedCh) + + // RestoreChannel: plugin does not implement ChannelWillBeRestored → allow-by-default. + t.Run("RestoreChannel", func(t *testing.T) { + th2 := Setup(t).InitBasic(t) + tearDown2, pluginIDs2, errs2 := SetAppEnvironmentWithPlugins(t, []string{partialPlugin}, th2.App, th2.NewPluginAPI) + defer tearDown2() + require.Len(t, errs2, 1) + require.NoError(t, errs2[0]) + pluginID2 := pluginIDs2[0] + + restoreCh := th2.CreateChannel(t, th2.BasicTeam) + require.Nil(t, th2.App.RegisterChannelGuard(th2.Context, restoreCh.Id, pluginID2)) + require.Nil(t, th2.App.DeleteChannel(th2.Context, restoreCh, th2.BasicUser.Id)) + + archived, err := th2.App.GetChannel(th2.Context, restoreCh.Id) + require.Nil(t, err) + _, appErr := th2.App.RestoreChannel(th2.Context, archived, th2.BasicUser.Id) + require.Nil(t, appErr, "RestoreChannel must succeed when guard plugin doesn't implement ChannelWillBeRestored") + }) +} + +// TestChannelGuardRejectsTypeMutationFromPhaseAPlugin covers the type-mutation guard at +// guarded_hooks.go line ~339: when one or more guards exist for a channel, a non-guard +// (Phase A) plugin that mutates Channel.Type must be rejected. This is the Phase A branch +// of the type-mutation check, distinct from the Phase B branch covered by +// TestChannelGuardRejectsTypeMutationFromPlugin. +func TestChannelGuardRejectsTypeMutationFromPhaseAPlugin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Plugin G: passive guard (allows everything). Phase B has nothing to do. + const guardPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type GuardPlugin struct { + plugin.MattermostPlugin +} + +func (p *GuardPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + return nil, "" +} + +func main() { + plugin.ClientMain(&GuardPlugin{}) +} +` + + // Plugin N: non-guard plugin that mutates Channel.Type in ChannelWillBeUpdated. + // On a guarded channel, this must be rejected with the type-mutation AppError. + const mutatorPlugin = ` +package main + +import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" +) + +type MutatorPlugin struct { + plugin.MattermostPlugin +} + +func (p *MutatorPlugin) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + mutated := newChannel + mutated.Type = model.ChannelTypePrivate + return mutated, "" +} + +func main() { + plugin.ClientMain(&MutatorPlugin{}) +} +` + + tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t, []string{guardPlugin, mutatorPlugin}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, errs, 2) + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + guardID := pluginIDs[0] + // Mutator plugin (pluginIDs[1]) is intentionally NOT registered as a guard. + + // Use a public channel so type mutation Public → Private is observable. + require.Equal(t, model.ChannelTypeOpen, th.BasicChannel.Type) + require.Nil(t, th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, guardID)) + + chCopy := th.BasicChannel.DeepCopy() + chCopy.DisplayName = "Phase A type mutation" + _, appErr := th.App.UpdateChannel(th.Context, chCopy) + require.NotNil(t, appErr, "Phase A plugin mutating Channel.Type on a guarded channel must be rejected") + assert.Equal(t, "app.channel.update_channel.plugin_type_mutation.app_error", appErr.Id) + assert.Equal(t, 400, appErr.StatusCode) +} diff --git a/server/channels/app/post.go b/server/channels/app/post.go index ff61bd923c3..2b8e05506f7 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -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) diff --git a/server/channels/app/post_metadata_test.go b/server/channels/app/post_metadata_test.go index eda92d74326..073319d9201 100644 --- a/server/channels/app/post_metadata_test.go +++ b/server/channels/app/post_metadata_test.go @@ -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") }) diff --git a/server/channels/app/properties/access_control_attribute_validation.go b/server/channels/app/properties/access_control_attribute_validation.go index 054715ee171..c645e54eb9c 100644 --- a/server/channels/app/properties/access_control_attribute_validation.go +++ b/server/channels/app/properties/access_control_attribute_validation.go @@ -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 } diff --git a/server/channels/app/properties/access_control_attribute_validation_test.go b/server/channels/app/properties/access_control_attribute_validation_test.go index 01b80ab63b9..90a93e57d46 100644 --- a/server/channels/app/properties/access_control_attribute_validation_test.go +++ b/server/channels/app/properties/access_control_attribute_validation_test.go @@ -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) { diff --git a/server/channels/app/properties/property_field.go b/server/channels/app/properties/property_field.go index 83e70179dc6..f4649600cfe 100644 --- a/server/channels/app/properties/property_field.go +++ b/server/channels/app/properties/property_field.go @@ -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 diff --git a/server/channels/app/properties/property_field_test.go b/server/channels/app/properties/property_field_test.go index fbebec23a40..b419679ccdf 100644 --- a/server/channels/app/properties/property_field_test.go +++ b/server/channels/app/properties/property_field_test.go @@ -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)) + }) } diff --git a/server/channels/app/scheduled_post.go b/server/channels/app/scheduled_post.go index 3e23c43780e..872c6c7229a 100644 --- a/server/channels/app/scheduled_post.go +++ b/server/channels/app/scheduled_post.go @@ -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) } diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 518fe6930a1..4bdd3e5e173 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -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), diff --git a/server/channels/app/session.go b/server/channels/app/session.go index af6c46c3bcb..0068cd3e49e 100644 --- a/server/channels/app/session.go +++ b/server/channels/app/session.go @@ -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 diff --git a/server/channels/app/support_packet_test.go b/server/channels/app/support_packet_test.go index c017c9d24ff..c9480638464 100644 --- a/server/channels/app/support_packet_test.go +++ b/server/channels/app/support_packet_test.go @@ -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) diff --git a/server/channels/app/team.go b/server/channels/app/team.go index dfdbe2f2cfd..43f45183d16 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -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 } diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go index c495f5d131c..2957e430e2a 100644 --- a/server/channels/app/team_test.go +++ b/server/channels/app/team_test.go @@ -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) { diff --git a/server/channels/app/web_broadcast_hooks.go b/server/channels/app/web_broadcast_hooks.go index 3db2fa7fcbd..f8ba6713a48 100644 --- a/server/channels/app/web_broadcast_hooks.go +++ b/server/channels/app/web_broadcast_hooks.go @@ -25,6 +25,7 @@ const ( broadcastBurnOnRead = "burn_on_read" broadcastBurnOnReadReaction = "burn_on_read_reaction" broadcastAbacFiles = "abac_files" + broadcastOnlyChannelAdmins = "only_channel_admins" ) func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook { @@ -37,6 +38,7 @@ func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook { broadcastBurnOnRead: &burnOnReadBroadcastHook{}, broadcastBurnOnReadReaction: &burnOnReadReactionBroadcastHook{}, broadcastAbacFiles: &abacFilesBroadcastHook{}, + broadcastOnlyChannelAdmins: &onlyChannelAdminsBroadcastHook{}, } } @@ -505,6 +507,34 @@ func (h *abacFilesBroadcastHook) stripFilesFromMessage(msg *platform.HookedWebSo return nil } +// onlyChannelAdminsBroadcastHook narrows a channel-scoped broadcast to the +// channel-admin subset of the channel's members. The hook arg +// `channel_admin_user_ids` is the precomputed list of admin user ids at publish +// time; recipients not in that set have the event rejected. +// +// Pair with `Broadcast{ChannelId: channelId}` so the platform's existing +// channel-member fan-out is the outer bound and this hook simply filters +// non-admin members out. +type onlyChannelAdminsBroadcastHook struct{} + +func useOnlyChannelAdminsHook(message *model.WebSocketEvent, channelAdminUserIds []string) { + message.GetBroadcast().AddHook(broadcastOnlyChannelAdmins, map[string]any{ + "channel_admin_user_ids": model.StringArray(channelAdminUserIds), + }) +} + +func (h *onlyChannelAdminsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error { + adminUserIDs, err := getTypedArg[model.StringArray](args, "channel_admin_user_ids") + if err != nil { + return errors.Wrap(err, "Invalid channel_admin_user_ids value passed to onlyChannelAdminsBroadcastHook") + } + + if !slices.Contains(adminUserIDs, webConn.UserId) { + msg.Event().Reject() + } + return nil +} + func incrementWebsocketCounter(wc *platform.WebConn) { if wc.Platform.Metrics() == nil { return diff --git a/server/channels/app/webhook.go b/server/channels/app/webhook.go index 92775e70d50..68f5655c04e 100644 --- a/server/channels/app/webhook.go +++ b/server/channels/app/webhook.go @@ -367,6 +367,12 @@ func (a *App) CreateWebhookPost(rctx request.CTX, userID string, channel *model. model.PostPropsOverrideUsername, model.PostPropsFromWebhook: // Do nothing + case model.PostPropsMmBlocksActions: + // Webhook payloads are user-controlled even when the + // webhook is bound to a bot user, so the bot-author + // signal in CreatePost's strip rule cannot distinguish + // them. Drop here so mm_blocks_actions never reaches + // the post object. default: post.AddProp(key, val) } diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 73a7ed10fd1..4b54f05bbd0 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -365,3 +365,13 @@ channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_ channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql +channels/db/migrations/postgres/000185_create_channel_guards.down.sql +channels/db/migrations/postgres/000185_create_channel_guards.up.sql +channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.down.sql +channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.up.sql +channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.down.sql +channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.up.sql +channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.down.sql +channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.up.sql +channels/db/migrations/postgres/000189_add_admin_to_permission_level.down.sql +channels/db/migrations/postgres/000189_add_admin_to_permission_level.up.sql diff --git a/server/channels/db/migrations/postgres/000185_create_channel_guards.down.sql b/server/channels/db/migrations/postgres/000185_create_channel_guards.down.sql new file mode 100644 index 00000000000..0a327247b93 --- /dev/null +++ b/server/channels/db/migrations/postgres/000185_create_channel_guards.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ChannelGuards; diff --git a/server/channels/db/migrations/postgres/000185_create_channel_guards.up.sql b/server/channels/db/migrations/postgres/000185_create_channel_guards.up.sql new file mode 100644 index 00000000000..141f1d3f5bc --- /dev/null +++ b/server/channels/db/migrations/postgres/000185_create_channel_guards.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS ChannelGuards ( + ChannelId varchar(26) NOT NULL, + PluginId varchar(190) NOT NULL, + CreatedAt bigint NOT NULL, + PRIMARY KEY (ChannelId, PluginId) +); diff --git a/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.down.sql b/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.down.sql new file mode 100644 index 00000000000..e523ed0283a --- /dev/null +++ b/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_channel_guards_plugin_id; diff --git a/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.up.sql b/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.up.sql new file mode 100644 index 00000000000..2b196e9e1eb --- /dev/null +++ b/server/channels/db/migrations/postgres/000186_create_channel_guards_plugin_id_index.up.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_channel_guards_plugin_id ON ChannelGuards(PluginId); diff --git a/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.down.sql b/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.down.sql new file mode 100644 index 00000000000..b0d5ff83f6c --- /dev/null +++ b/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.down.sql @@ -0,0 +1 @@ +ALTER TABLE useraccesstokens DROP COLUMN IF EXISTS expiresat; diff --git a/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.up.sql b/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.up.sql new file mode 100644 index 00000000000..311cd6adb4d --- /dev/null +++ b/server/channels/db/migrations/postgres/000187_add_expiresat_to_user_access_tokens.up.sql @@ -0,0 +1 @@ +ALTER TABLE useraccesstokens ADD COLUMN IF NOT EXISTS expiresat bigint NOT NULL DEFAULT 0; diff --git a/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.down.sql b/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.down.sql new file mode 100644 index 00000000000..2534cd3ad1a --- /dev/null +++ b/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.down.sql @@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_useraccesstokens_expiresat; diff --git a/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.up.sql b/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.up.sql new file mode 100644 index 00000000000..a023f331725 --- /dev/null +++ b/server/channels/db/migrations/postgres/000188_add_expiresat_index_to_user_access_tokens.up.sql @@ -0,0 +1,4 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_useraccesstokens_expiresat + ON useraccesstokens (expiresat) + WHERE expiresat > 0; diff --git a/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.down.sql b/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.down.sql new file mode 100644 index 00000000000..35b5fc9e14e --- /dev/null +++ b/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.down.sql @@ -0,0 +1,18 @@ +-- Postgres cannot remove a value from an existing enum in place, so rebuild +-- the type without 'admin'. Any rows currently holding 'admin' are coerced to +-- NULL first so the recreated enum can accept them. + +UPDATE PropertyFields SET PermissionField = NULL WHERE PermissionField = 'admin'; +UPDATE PropertyFields SET PermissionValues = NULL WHERE PermissionValues = 'admin'; +UPDATE PropertyFields SET PermissionOptions = NULL WHERE PermissionOptions = 'admin'; + +ALTER TYPE permission_level RENAME TO permission_level_old; + +CREATE TYPE permission_level AS ENUM ('none', 'sysadmin', 'member'); + +ALTER TABLE PropertyFields + ALTER COLUMN PermissionField TYPE permission_level USING PermissionField::text::permission_level, + ALTER COLUMN PermissionValues TYPE permission_level USING PermissionValues::text::permission_level, + ALTER COLUMN PermissionOptions TYPE permission_level USING PermissionOptions::text::permission_level; + +DROP TYPE permission_level_old; diff --git a/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.up.sql b/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.up.sql new file mode 100644 index 00000000000..ad63b1beba3 --- /dev/null +++ b/server/channels/db/migrations/postgres/000189_add_admin_to_permission_level.up.sql @@ -0,0 +1 @@ +ALTER TYPE permission_level ADD VALUE IF NOT EXISTS 'admin'; diff --git a/server/channels/jobs/cleanup_expired_access_tokens/scheduler.go b/server/channels/jobs/cleanup_expired_access_tokens/scheduler.go new file mode 100644 index 00000000000..8f0107c3f21 --- /dev/null +++ b/server/channels/jobs/cleanup_expired_access_tokens/scheduler.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package cleanup_expired_access_tokens + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/jobs" +) + +const schedFreq = 1 * time.Hour + +func MakeScheduler(jobServer *jobs.JobServer) *jobs.PeriodicScheduler { + isEnabled := func(cfg *model.Config) bool { + return *cfg.ServiceSettings.EnableUserAccessTokens + } + return jobs.NewPeriodicScheduler(jobServer, model.JobTypeCleanupExpiredAccessTokens, schedFreq, isEnabled) +} diff --git a/server/channels/jobs/cleanup_expired_access_tokens/worker.go b/server/channels/jobs/cleanup_expired_access_tokens/worker.go new file mode 100644 index 00000000000..71fa4f0f988 --- /dev/null +++ b/server/channels/jobs/cleanup_expired_access_tokens/worker.go @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package cleanup_expired_access_tokens + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/jobs" +) + +const ( + workerName = "CleanupExpiredAccessTokens" + // batchLimit bounds both the number of rows fetched by GetExpiredBefore + // and the corresponding DeleteByIds call, keeping the transaction + // footprint bounded even when a large number of tokens expire at once. + batchLimit = 1000 + // maxBatches caps the number of iterations per job execution so that very + // large expiry backlogs are drained across multiple scheduled runs rather + // than a single unbounded loop. + maxBatches = 1000 +) + +// expiredTokenStore is the subset of UserAccessTokenStore used by the worker. +// Defined here rather than depending on the full store interface so the +// orchestration logic can be unit-tested with a small fake. +type expiredTokenStore interface { + GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) + DeleteByIds(tokenIDs []string) (int64, error) +} + +// MakeWorker creates a worker that periodically deletes personal access tokens +// whose ExpiresAt has passed, along with any sessions created from them. +// The work is done in batches to keep the transaction footprint bounded. +// +// clearSessionCache is called for each affected user after their tokens are +// deleted so that in-memory session caches don't serve stale sessions. +func MakeWorker(jobServer *jobs.JobServer, clearSessionCache func(userID string)) *jobs.SimpleWorker { + isEnabled := func(cfg *model.Config) bool { + return *cfg.ServiceSettings.EnableUserAccessTokens + } + + execute := func(logger mlog.LoggerIFace, job *model.Job) error { + defer jobServer.HandleJobPanic(logger, job) + return cleanupExpired( + logger, + jobServer.Store.UserAccessToken(), + clearSessionCache, + model.GetMillis(), + batchLimit, + maxBatches, + ) + } + + return jobs.NewSimpleWorker(workerName, jobServer, execute, isEnabled) +} + +// cleanupExpired drains expired personal access tokens in batches up to +// maxBatches iterations. It is extracted from MakeWorker so that the batching +// and error-propagation logic can be exercised by unit tests with a fake store. +// +// clearSessionCache is called for each unique user whose tokens were deleted so +// that in-memory session caches don't continue serving the removed sessions. +func cleanupExpired( + logger mlog.LoggerIFace, + store expiredTokenStore, + clearSessionCache func(userID string), + cutoff int64, + limit int, + maxIter int, +) error { + var totalDeleted int64 + + for range maxIter { + expired, err := store.GetExpiredBefore(cutoff, limit) + if err != nil { + return err + } + if len(expired) == 0 { + break + } + + ids := make([]string, len(expired)) + userIDs := make(map[string]struct{}, len(expired)) + for i, token := range expired { + ids[i] = token.Id + userIDs[token.UserId] = struct{}{} + } + + deleted, err := store.DeleteByIds(ids) + if err != nil { + return err + } + totalDeleted += deleted + + for userID := range userIDs { + clearSessionCache(userID) + } + + if len(expired) < limit { + break + } + } + + logger.Info( + "Cleaned up expired personal access tokens", + mlog.Int("deleted", int(totalDeleted)), + mlog.Int("cutoff", int(cutoff)), + ) + + return nil +} diff --git a/server/channels/jobs/cleanup_expired_access_tokens/worker_test.go b/server/channels/jobs/cleanup_expired_access_tokens/worker_test.go new file mode 100644 index 00000000000..f497a807e3c --- /dev/null +++ b/server/channels/jobs/cleanup_expired_access_tokens/worker_test.go @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package cleanup_expired_access_tokens + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +// fakeStore implements expiredTokenStore. Each call to GetExpiredBefore pops +// the next pre-programmed batch off batches, then returns the configured error +// (which can be nil). DeleteByIds returns deleteCount/deleteErr and records +// the ids it was called with. +type fakeStore struct { + batches [][]*model.UserAccessToken + getCalls int + getErrAt int // 1-based call index that returns getErr; 0 == no error + getErr error + deleteCnt int64 + deleteErr error + deletedIDs [][]string +} + +func (f *fakeStore) GetExpiredBefore(_ int64, _ int) ([]*model.UserAccessToken, error) { + f.getCalls++ + if f.getErrAt != 0 && f.getCalls == f.getErrAt { + return nil, f.getErr + } + if len(f.batches) == 0 { + return nil, nil + } + next := f.batches[0] + f.batches = f.batches[1:] + return next, nil +} + +func (f *fakeStore) DeleteByIds(ids []string) (int64, error) { + f.deletedIDs = append(f.deletedIDs, ids) + if f.deleteErr != nil { + return 0, f.deleteErr + } + if f.deleteCnt != 0 { + return f.deleteCnt, nil + } + return int64(len(ids)), nil +} + +func makeTokens(n int, base int64) []*model.UserAccessToken { + out := make([]*model.UserAccessToken, n) + for i := range n { + out[i] = &model.UserAccessToken{ + Id: model.NewId(), + UserId: model.NewId(), + ExpiresAt: base + int64(i), + IsActive: true, + } + } + return out +} + +func nopClearSession(_ string) {} + +func TestCleanupExpired(t *testing.T) { + logger := mlog.CreateConsoleTestLogger(t) + + t.Run("happy path single batch", func(t *testing.T) { + tokens := makeTokens(3, 1000) + store := &fakeStore{batches: [][]*model.UserAccessToken{tokens}} + + err := cleanupExpired(logger, store, nopClearSession, 9999, 1000, 10) + require.NoError(t, err) + + // Exactly one DeleteByIds call with the three token ids. A partial first + // batch (len < limit) must short-circuit the loop, so GetExpiredBefore is + // called exactly once. + require.Len(t, store.deletedIDs, 1) + require.Len(t, store.deletedIDs[0], 3) + require.Equal(t, 1, store.getCalls) + }) + + t.Run("empty result is no-op", func(t *testing.T) { + store := &fakeStore{} // no batches, no errors + + err := cleanupExpired(logger, store, nopClearSession, 9999, 1000, 10) + require.NoError(t, err) + + require.Equal(t, 1, store.getCalls) + require.Empty(t, store.deletedIDs) + }) + + t.Run("full batch triggers next iteration", func(t *testing.T) { + const limit = 5 + first := makeTokens(limit, 1000) // full batch -> loop continues + second := makeTokens(2, 2000) // partial batch -> loop stops + store := &fakeStore{batches: [][]*model.UserAccessToken{first, second}} + + err := cleanupExpired(logger, store, nopClearSession, 9999, limit, 10) + require.NoError(t, err) + + require.Equal(t, 2, store.getCalls) + require.Len(t, store.deletedIDs, 2) + require.Len(t, store.deletedIDs[0], limit) + require.Len(t, store.deletedIDs[1], 2) + }) + + t.Run("max iter cap", func(t *testing.T) { + const limit = 3 + const maxIter = 2 + store := &fakeStore{batches: [][]*model.UserAccessToken{ + makeTokens(limit, 1000), + makeTokens(limit, 2000), + makeTokens(limit, 3000), // never reached + }} + + err := cleanupExpired(logger, store, nopClearSession, 9999, limit, maxIter) + require.NoError(t, err) + + require.Equal(t, maxIter, store.getCalls, "loop must cap at maxIter") + require.Len(t, store.deletedIDs, maxIter) + }) + + t.Run("get error propagates", func(t *testing.T) { + wantErr := errors.New("get failed") + store := &fakeStore{ + batches: [][]*model.UserAccessToken{makeTokens(2, 1000)}, + getErrAt: 1, + getErr: wantErr, + } + + err := cleanupExpired(logger, store, nopClearSession, 9999, 1000, 10) + require.ErrorIs(t, err, wantErr) + require.Empty(t, store.deletedIDs, "delete must not run when get fails") + }) + + t.Run("delete error propagates", func(t *testing.T) { + wantErr := errors.New("delete failed") + store := &fakeStore{ + batches: [][]*model.UserAccessToken{makeTokens(2, 1000)}, + deleteErr: wantErr, + } + + err := cleanupExpired(logger, store, nopClearSession, 9999, 1000, 10) + require.ErrorIs(t, err, wantErr) + require.Len(t, store.deletedIDs, 1, "DeleteByIds was called once before failing") + }) + + t.Run("session cache cleared for each unique user after delete", func(t *testing.T) { + sharedUserID := model.NewId() + tokens := []*model.UserAccessToken{ + {Id: model.NewId(), UserId: sharedUserID, ExpiresAt: 1000, IsActive: true}, + {Id: model.NewId(), UserId: sharedUserID, ExpiresAt: 1001, IsActive: true}, + {Id: model.NewId(), UserId: model.NewId(), ExpiresAt: 1002, IsActive: true}, + } + store := &fakeStore{batches: [][]*model.UserAccessToken{tokens}} + + cleared := map[string]int{} + err := cleanupExpired(logger, store, func(userID string) { cleared[userID]++ }, 9999, 1000, 10) + require.NoError(t, err) + + require.Len(t, cleared, 2, "cache must be cleared for each unique user") + require.Equal(t, 1, cleared[sharedUserID], "each user cleared exactly once per batch") + }) +} diff --git a/server/channels/store/diagnostics.go b/server/channels/store/diagnostics.go new file mode 100644 index 00000000000..eaa74e7e363 --- /dev/null +++ b/server/channels/store/diagnostics.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package store + +import "time" + +// DatabaseDiagnostics is a snapshot of database health and pool state. +// Pointer fields are nil when the underlying metric is unavailable +// (non-Postgres driver, query failure, or no matching row). +type DatabaseDiagnostics struct { + MasterConnectionsInUse int + MasterConnectionsIdle int + MasterPoolWaitCount int64 + MasterPoolWaitDurationMs int64 + MasterConnectionsClosedMaxIdle int64 + MasterConnectionsClosedMaxLifetime int64 + ReplicaConnectionsInUse int + ReplicaConnectionsIdle int + ReplicaPoolWaitCount int64 + ReplicaPoolWaitDurationMs int64 + ReplicaConnectionsClosedMaxIdle int64 + ReplicaConnectionsClosedMaxLifetime int64 + CacheHitRatio *float64 + Deadlocks *int64 + TempFiles *int64 + TempBytesMB *float64 + Rollbacks *int64 + IdleInTransactionCount *int64 + LongestQueryDurationSeconds *float64 + WaitingForLockCount *int64 + PostsDeadTuples *int64 + PostsLastAutovacuum *time.Time +} diff --git a/server/channels/store/layer_generators/main.go b/server/channels/store/layer_generators/main.go index 9c244cef93e..796b4152891 100644 --- a/server/channels/store/layer_generators/main.go +++ b/server/channels/store/layer_generators/main.go @@ -184,6 +184,8 @@ func generateLayer(name, templateFile string) ([]byte, error) { switch result { case "*PostReminderMetadata": returns = append(returns, fmt.Sprintf("*store.%s", strings.TrimPrefix(result, "*"))) + case "[]*ChannelGuard": + returns = append(returns, fmt.Sprintf("[]*store.%s", strings.TrimPrefix(result, "[]*"))) default: returns = append(returns, result) } @@ -243,7 +245,7 @@ func generateLayer(name, templateFile string) ([]byte, error) { switch param.Type { case "ChannelSearchOpts", "UserGetByIdsOpts", "ThreadMembershipOpts", "GetPolicyOptions": paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type)) - case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts": + case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts", "*ChannelGuard": paramsWithType = append(paramsWithType, fmt.Sprintf("%s *store.%s", param.Name, strings.TrimPrefix(param.Type, "*"))) default: paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type)) @@ -257,7 +259,7 @@ func generateLayer(name, templateFile string) ([]byte, error) { switch param.Type { case "ChannelSearchOpts", "UserGetByIdsOpts", "ThreadMembershipOpts", "GetPolicyOptions": paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type)) - case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts": + case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts", "*ChannelGuard": paramsWithType = append(paramsWithType, fmt.Sprintf("%s *store.%s", param.Name, strings.TrimPrefix(param.Type, "*"))) default: paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type)) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 18e3ca33dc4..23c5c02b429 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -27,6 +27,7 @@ type RetryLayer struct { BotStore store.BotStore ChannelStore store.ChannelStore ChannelBookmarkStore store.ChannelBookmarkStore + ChannelGuardStore store.ChannelGuardStore ChannelJoinRequestStore store.ChannelJoinRequestStore ChannelMemberHistoryStore store.ChannelMemberHistoryStore ClusterDiscoveryStore store.ClusterDiscoveryStore @@ -108,6 +109,10 @@ func (s *RetryLayer) ChannelBookmark() store.ChannelBookmarkStore { return s.ChannelBookmarkStore } +func (s *RetryLayer) ChannelGuard() store.ChannelGuardStore { + return s.ChannelGuardStore +} + func (s *RetryLayer) ChannelJoinRequest() store.ChannelJoinRequestStore { return s.ChannelJoinRequestStore } @@ -347,6 +352,11 @@ type RetryLayerChannelBookmarkStore struct { Root *RetryLayer } +type RetryLayerChannelGuardStore struct { + store.ChannelGuardStore + Root *RetryLayer +} + type RetryLayerChannelJoinRequestStore struct { store.ChannelJoinRequestStore Root *RetryLayer @@ -655,6 +665,48 @@ func (s *RetryLayerAccessControlPolicyStore) Get(rctx request.CTX, id string) (* } +func (s *RetryLayerAccessControlPolicyStore) GetActionsForPolicies(rctx request.CTX, policyIDs []string) (map[string]map[string]bool, error) { + + tries := 0 + for { + result, err := s.AccessControlPolicyStore.GetActionsForPolicies(rctx, policyIDs) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerAccessControlPolicyStore) GetActionsForPolicy(rctx request.CTX, policyID string) (map[string]bool, error) { + + tries := 0 + for { + result, err := s.AccessControlPolicyStore.GetActionsForPolicy(rctx, policyID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerAccessControlPolicyStore) GetPoliciesByFieldID(rctx request.CTX, fieldID string) ([]*model.AccessControlPolicy, error) { tries := 0 @@ -2245,6 +2297,27 @@ func (s *RetryLayerChannelStore) GetDeletedByName(teamID string, name string) (* } +func (s *RetryLayerChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + + tries := 0 + for { + result, resultVar1, resultVar2, err := s.ChannelStore.GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + if err == nil { + return result, resultVar1, resultVar2, nil + } + if !isRepeatableError(err) { + return result, resultVar1, resultVar2, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, resultVar2, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) GetFileCount(channelID string) (int64, error) { tries := 0 @@ -2818,6 +2891,27 @@ func (s *RetryLayerChannelStore) GetTeamChannels(teamID string) (model.ChannelLi } +func (s *RetryLayerChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + + tries := 0 + for { + result, resultVar1, resultVar2, err := s.ChannelStore.GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + if err == nil { + return result, resultVar1, resultVar2, nil + } + if !isRepeatableError(err) { + return result, resultVar1, resultVar2, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, resultVar2, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) GetTeamForChannel(channelID string) (*model.Team, error) { tries := 0 @@ -3868,6 +3962,90 @@ func (s *RetryLayerChannelBookmarkStore) UpdateSortOrder(bookmarkID string, chan } +func (s *RetryLayerChannelGuardStore) Delete(rctx request.CTX, channelID string, pluginID string) (int64, error) { + + tries := 0 + for { + result, err := s.ChannelGuardStore.Delete(rctx, channelID, pluginID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) { + + tries := 0 + for { + result, err := s.ChannelGuardStore.GetAll(rctx) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelGuardStore) GetForChannel(rctx request.CTX, channelID string) ([]*store.ChannelGuard, error) { + + tries := 0 + for { + result, err := s.ChannelGuardStore.GetForChannel(rctx, channelID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelGuardStore) Save(rctx request.CTX, guard *store.ChannelGuard) error { + + tries := 0 + for { + err := s.ChannelGuardStore.Save(rctx, guard) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelJoinRequestStore) CountPending(channelId string) (int64, error) { tries := 0 @@ -10153,6 +10331,27 @@ func (s *RetryLayerPropertyFieldStore) CountForGroup(groupID string, includeDele } +func (s *RetryLayerPropertyFieldStore) CountForGroupObjectType(groupID string, objectType string, includeDeleted bool) (int64, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.CountForGroupObjectType(groupID, objectType, includeDeleted) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPropertyFieldStore) CountForTarget(groupID string, targetType string, targetID string, includeDeleted bool) (int64, error) { tries := 0 @@ -16096,6 +16295,27 @@ func (s *RetryLayerUserStore) DeactivateMagicLinkGuests() ([]string, error) { } +func (s *RetryLayerUserStore) DecrementFailedPasswordAttempts(userID string) error { + + tries := 0 + for { + err := s.UserStore.DecrementFailedPasswordAttempts(userID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) DemoteUserToGuest(userID string) (*model.User, error) { tries := 0 @@ -17350,6 +17570,27 @@ func (s *RetryLayerUserStore) StoreMfaUsedTimestamps(userID string, ts []int) er } +func (s *RetryLayerUserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { + + tries := 0 + for { + result, err := s.UserStore.TryIncrementFailedPasswordAttempts(userID, maxAttempts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) Update(rctx request.CTX, user *model.User, allowRoleUpdate bool) (*model.UserUpdate, error) { tries := 0 @@ -17413,48 +17654,6 @@ func (s *RetryLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp } -func (s *RetryLayerUserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { - - tries := 0 - for { - result, err := s.UserStore.TryIncrementFailedPasswordAttempts(userID, maxAttempts) - if err == nil { - return result, nil - } - if !isRepeatableError(err) { - return result, err - } - tries++ - if tries >= 3 { - err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") - return result, err - } - timepkg.Sleep(100 * timepkg.Millisecond) - } - -} - -func (s *RetryLayerUserStore) DecrementFailedPasswordAttempts(userID string) error { - - tries := 0 - for { - err := s.UserStore.DecrementFailedPasswordAttempts(userID) - if err == nil { - return nil - } - if !isRepeatableError(err) { - return err - } - tries++ - if tries >= 3 { - err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") - return err - } - timepkg.Sleep(100 * timepkg.Millisecond) - } - -} - func (s *RetryLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error { tries := 0 @@ -17644,6 +17843,48 @@ func (s *RetryLayerUserAccessTokenStore) Delete(tokenID string) error { } +func (s *RetryLayerUserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, error) { + + tries := 0 + for { + result, err := s.UserAccessTokenStore.DeleteByIds(tokenIDs) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) { + + tries := 0 + for { + result, err := s.UserAccessTokenStore.GetExpiredBefore(cutoff, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserAccessTokenStore) DeleteAllForUser(userID string) error { tries := 0 @@ -18608,6 +18849,10 @@ func (s *RetryLayer) TotalSearchDbConnections() int { return s.Store.TotalSearchDbConnections() } +func (s *RetryLayer) GetDiagnostics(ctx context.Context) (*store.DatabaseDiagnostics, error) { + return s.Store.GetDiagnostics(ctx) +} + func (s *RetryLayer) UnlockFromMaster() { s.Store.UnlockFromMaster() } @@ -18624,6 +18869,7 @@ func New(childStore store.Store) *RetryLayer { newStore.BotStore = &RetryLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &RetryLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} newStore.ChannelBookmarkStore = &RetryLayerChannelBookmarkStore{ChannelBookmarkStore: childStore.ChannelBookmark(), Root: &newStore} + newStore.ChannelGuardStore = &RetryLayerChannelGuardStore{ChannelGuardStore: childStore.ChannelGuard(), Root: &newStore} newStore.ChannelJoinRequestStore = &RetryLayerChannelJoinRequestStore{ChannelJoinRequestStore: childStore.ChannelJoinRequest(), Root: &newStore} newStore.ChannelMemberHistoryStore = &RetryLayerChannelMemberHistoryStore{ChannelMemberHistoryStore: childStore.ChannelMemberHistory(), Root: &newStore} newStore.ClusterDiscoveryStore = &RetryLayerClusterDiscoveryStore{ClusterDiscoveryStore: childStore.ClusterDiscovery(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 7cb965e53b3..0010f1d3a2b 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -19,6 +19,7 @@ func genStore() *mocks.Store { mock.On("Audit").Return(&mocks.AuditStore{}) mock.On("Bot").Return(&mocks.BotStore{}) mock.On("Channel").Return(&mocks.ChannelStore{}) + mock.On("ChannelGuard").Return(&mocks.ChannelGuardStore{}) mock.On("ChannelMemberHistory").Return(&mocks.ChannelMemberHistoryStore{}) mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{}) mock.On("ClusterDiscovery").Return(&mocks.ClusterDiscoveryStore{}) diff --git a/server/channels/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go index d2324f6f1ea..4a08f96dafc 100644 --- a/server/channels/store/sqlstore/access_control_policy_store.go +++ b/server/channels/store/sqlstore/access_control_policy_store.go @@ -781,6 +781,122 @@ func (s *SqlAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts mode return policies, total, nil } +// actionsAggregationSQL is the per-policy action-union expression embedded in +// both GetActionsForPolicy and GetActionsForPolicies. It produces a JSONB +// object whose keys are the distinct action strings declared by the policy's +// own rules and the rules of any policies it imports. The expression is +// table-qualified with `p` so it can be reused as a correlated subquery +// against either a single row or a set of rows. +const actionsAggregationSQL = `COALESCE( + ( + SELECT jsonb_object_agg(action, true) + FROM ( + SELECT DISTINCT jsonb_array_elements_text(rule->'actions') AS action + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(p.Data->'rules') = 'array' + THEN p.Data->'rules' ELSE '[]'::jsonb END + ) AS rule + UNION + SELECT DISTINCT jsonb_array_elements_text(rule->'actions') AS action + FROM AccessControlPolicies parent + JOIN jsonb_array_elements_text( + CASE WHEN jsonb_typeof(p.Data->'imports') = 'array' + THEN p.Data->'imports' ELSE '[]'::jsonb END + ) AS imp(id) ON parent.ID = imp.id + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(parent.Data->'rules') = 'array' + THEN parent.Data->'rules' ELSE '[]'::jsonb END + ) AS rule + ) AS unioned_actions + ), + '{}'::jsonb +)` + +// GetActionsForPolicy returns the union of action keys declared by the policy's +// own rules and the rules of any policies it imports. The result is always a +// non-nil map (empty when the policy exists but declares no rules). Returns +// store.ErrNotFound when no AccessControlPolicies row exists for policyID. +// +// The query is bounded by a single row's expansion plus its (small) imports +// fan-out. Channels with no attached policy never reach this method because +// the App-layer hydrator short-circuits on PolicyEnforced=false. +func (s *SqlAccessControlPolicyStore) GetActionsForPolicy(_ request.CTX, policyID string) (map[string]bool, error) { + if !model.IsValidId(policyID) { + return nil, store.NewErrInvalidInput("AccessControlPolicy", "policyID", policyID) + } + + var raw []byte + query := fmt.Sprintf(`SELECT %s AS actions FROM AccessControlPolicies p WHERE p.ID = $1`, actionsAggregationSQL) + err := s.GetReplica().Get(&raw, query, policyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, store.NewErrNotFound("AccessControlPolicy", policyID) + } + return nil, errors.Wrapf(err, "failed to load policy actions for id=%s", policyID) + } + + actions := map[string]bool{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &actions); err != nil { + return nil, errors.Wrapf(err, "failed to decode policy actions for id=%s", policyID) + } + } + return actions, nil +} + +// GetActionsForPolicies returns the per-policy action union for each ID in +// policyIDs. Missing IDs are absent from the result map (callers can detect +// "policy not found" via `_, ok := result[id]`). An empty input slice returns +// an empty map and fires no SQL. Used by batched hydration on channel-list +// reads to avoid an N+1 against AccessControlPolicies. +func (s *SqlAccessControlPolicyStore) GetActionsForPolicies(_ request.CTX, policyIDs []string) (map[string]map[string]bool, error) { + if len(policyIDs) == 0 { + return map[string]map[string]bool{}, nil + } + + for _, id := range policyIDs { + if !model.IsValidId(id) { + return nil, store.NewErrInvalidInput("AccessControlPolicy", "policyID", id) + } + } + + query, args, err := s.getQueryBuilder(). + Select("p.ID", fmt.Sprintf("%s AS actions", actionsAggregationSQL)). + From("AccessControlPolicies p"). + Where(sq.Eq{"p.ID": policyIDs}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "failed to build batched policy actions query") + } + + rows, err := s.GetReplica().Query(query, args...) + if err != nil { + return nil, errors.Wrap(err, "failed to load policy actions batch") + } + defer rows.Close() + + result := make(map[string]map[string]bool, len(policyIDs)) + for rows.Next() { + var id string + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + return nil, errors.Wrap(err, "failed to scan policy actions row") + } + actions := map[string]bool{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &actions); err != nil { + return nil, errors.Wrapf(err, "failed to decode policy actions for id=%s", id) + } + } + result[id] = actions + } + if err := rows.Err(); err != nil { + return nil, errors.Wrap(err, "policy actions row iteration failed") + } + + return result, nil +} + // GetPoliciesByFieldID finds all policies whose CEL rule expressions reference the // given property field ID. It performs a text search on the serialized JSONB Data // column for the pattern "id_", which is how property fields are referenced diff --git a/server/channels/store/sqlstore/channel_guard_store.go b/server/channels/store/sqlstore/channel_guard_store.go new file mode 100644 index 00000000000..abbc9fbdea5 --- /dev/null +++ b/server/channels/store/sqlstore/channel_guard_store.go @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +type SqlChannelGuardStore struct { + *SqlStore + + channelGuardSelectQuery sq.SelectBuilder +} + +func newSqlChannelGuardStore(sqlStore *SqlStore) store.ChannelGuardStore { + s := &SqlChannelGuardStore{SqlStore: sqlStore} + + s.channelGuardSelectQuery = s.getQueryBuilder(). + Select("ChannelId", "PluginId", "CreatedAt"). + From("ChannelGuards") + + return s +} + +func (s *SqlChannelGuardStore) Save(rctx request.CTX, guard *store.ChannelGuard) error { + builder := s.getQueryBuilder(). + Insert("ChannelGuards"). + Columns("ChannelId", "PluginId", "CreatedAt"). + Values(guard.ChannelId, guard.PluginId, guard.CreatedAt). + SuffixExpr(sq.Expr("ON CONFLICT (ChannelId, PluginId) DO NOTHING")) + + if _, err := s.GetMaster().ExecBuilder(builder); err != nil { + return errors.Wrapf(err, "failed to save channel guard for channel=%s plugin=%s", guard.ChannelId, guard.PluginId) + } + + return nil +} + +func (s *SqlChannelGuardStore) Delete(rctx request.CTX, channelID, pluginID string) (int64, error) { + builder := s.getQueryBuilder(). + Delete("ChannelGuards"). + Where(sq.Eq{ + "ChannelId": channelID, + "PluginId": pluginID, + }) + + result, err := s.GetMaster().ExecBuilder(builder) + if err != nil { + return 0, errors.Wrapf(err, "failed to delete channel guard for channel=%s plugin=%s", channelID, pluginID) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return 0, errors.Wrapf(err, "failed to get rows affected for channel guard delete channel=%s plugin=%s", channelID, pluginID) + } + + return rowsAffected, nil +} + +func (s *SqlChannelGuardStore) GetForChannel(rctx request.CTX, channelID string) ([]*store.ChannelGuard, error) { + query := s.channelGuardSelectQuery.Where(sq.Eq{"ChannelId": channelID}) + + guards := []*store.ChannelGuard{} + if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&guards, query); err != nil { + return nil, errors.Wrapf(err, "failed to get channel guards for channel=%s", channelID) + } + + return guards, nil +} + +func (s *SqlChannelGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) { + guards := []*store.ChannelGuard{} + if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&guards, s.channelGuardSelectQuery); err != nil { + return nil, errors.Wrap(err, "failed to get all channel guards") + } + + return guards, nil +} diff --git a/server/channels/store/sqlstore/channel_guard_store_test.go b/server/channels/store/sqlstore/channel_guard_store_test.go new file mode 100644 index 00000000000..25377156050 --- /dev/null +++ b/server/channels/store/sqlstore/channel_guard_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" +) + +func TestChannelGuardStore(t *testing.T) { + StoreTest(t, storetest.TestChannelGuardStore) +} diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index d98f215dacb..9b4cbf7805c 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -3260,6 +3260,9 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc From("ChannelMembers"). Where(sq.Eq{"UserId": userID}))) } else { + // Non-guests see public channels, private channels they're a member of, and + // discoverable private channels (subject to a post-query ABAC visibility filter + // applied at the app layer for policy-enforced channels). query = query.Where(sq.Or{ sq.NotEq{"c.Type": model.ChannelTypePrivate}, sq.And{ @@ -3268,6 +3271,10 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc From("ChannelMembers"). Where(sq.Eq{"UserId": userID})), }, + sq.And{ + sq.Eq{"c.Type": model.ChannelTypePrivate}, + sq.Eq{"c.Discoverable": true}, + }, }) } @@ -3311,12 +3318,19 @@ func (s SqlChannelStore) buildAutocompleteInTeamQuery(teamID, userID, term strin if isGuest { query = query.Where(sq.Expr("c.Id IN (?)", memberSubQuery)) } else { + // Non-guests see public channels, private channels they're a member of, and + // discoverable private channels (subject to a post-query ABAC visibility filter + // applied at the app layer for policy-enforced channels). query = query.Where(sq.Or{ sq.NotEq{"c.Type": model.ChannelTypePrivate}, sq.And{ sq.Eq{"c.Type": model.ChannelTypePrivate}, sq.Expr("c.Id IN (?)", memberSubQuery), }, + sq.And{ + sq.Eq{"c.Type": model.ChannelTypePrivate}, + sq.Eq{"c.Discoverable": true}, + }, }) } diff --git a/server/channels/store/sqlstore/diagnostics.go b/server/channels/store/sqlstore/diagnostics.go new file mode 100644 index 00000000000..e6ef19d0242 --- /dev/null +++ b/server/channels/store/sqlstore/diagnostics.go @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "context" + "database/sql" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +const pgDiagnosticsQueryTimeout = 10 * time.Second + +func (ss *SqlStore) GetDiagnostics(ctx context.Context) (*store.DatabaseDiagnostics, error) { + diagnostics := &store.DatabaseDiagnostics{} + applyDBPoolStats(diagnostics, ss.MasterDBStats(), ss.ReplicaDBStats()) + + if ss.DriverName() != model.DatabaseDriverPostgres { + return diagnostics, nil + } + + if err := collectPostgresDatabaseDiagnostics(ctx, ss.GetMaster().DB(), diagnostics); err != nil { + return diagnostics, err + } + + return diagnostics, nil +} + +func collectPostgresDatabaseDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error { + if db == nil { + return errors.New("postgres diagnostics query failed: no master database connection") + } + + var rErr *multierror.Error + + if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error { + return collectPGStatDatabaseDiagnostics(ctx, db, diagnostics) + }); err != nil { + rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_database")) + } + + if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error { + return collectPGStatActivityDiagnostics(ctx, db, diagnostics) + }); err != nil { + rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_activity")) + } + + if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error { + return collectPGStatUserTablesDiagnostics(ctx, db, diagnostics) + }); err != nil { + rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_user_tables")) + } + + return rErr.ErrorOrNil() +} + +func withDiagnosticsTimeout(ctx context.Context, fn func(context.Context) error) error { + ctx, cancel := context.WithTimeout(ctx, pgDiagnosticsQueryTimeout) + defer cancel() + return fn(ctx) +} + +func applyDBPoolStats(diagnostics *store.DatabaseDiagnostics, masterDBStats, replicaDBStats sql.DBStats) { + diagnostics.MasterConnectionsInUse = masterDBStats.InUse + diagnostics.MasterConnectionsIdle = masterDBStats.Idle + diagnostics.MasterPoolWaitCount = masterDBStats.WaitCount + diagnostics.MasterPoolWaitDurationMs = masterDBStats.WaitDuration.Milliseconds() + diagnostics.MasterConnectionsClosedMaxIdle = masterDBStats.MaxIdleClosed + diagnostics.MasterConnectionsClosedMaxLifetime = masterDBStats.MaxLifetimeClosed + + diagnostics.ReplicaConnectionsInUse = replicaDBStats.InUse + diagnostics.ReplicaConnectionsIdle = replicaDBStats.Idle + diagnostics.ReplicaPoolWaitCount = replicaDBStats.WaitCount + diagnostics.ReplicaPoolWaitDurationMs = replicaDBStats.WaitDuration.Milliseconds() + diagnostics.ReplicaConnectionsClosedMaxIdle = replicaDBStats.MaxIdleClosed + diagnostics.ReplicaConnectionsClosedMaxLifetime = replicaDBStats.MaxLifetimeClosed +} + +func collectPGStatDatabaseDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error { + var row struct { + CacheHitRatio float64 `db:"cache_hit_ratio"` + Deadlocks int64 `db:"deadlocks"` + TempFiles int64 `db:"temp_files"` + TempBytes int64 `db:"temp_bytes"` + XactRollback int64 `db:"xact_rollback"` + } + + const query = ` +SELECT + COALESCE(blks_hit::double precision / NULLIF(blks_hit + blks_read, 0), 0) AS cache_hit_ratio, + deadlocks, + temp_files, + temp_bytes, + xact_rollback +FROM pg_stat_database +WHERE datname = current_database()` + + if err := db.GetContext(ctx, &row, query); err != nil { + return err + } + + tempBytesMB := float64(row.TempBytes) / (1024 * 1024) + diagnostics.CacheHitRatio = &row.CacheHitRatio + diagnostics.Deadlocks = &row.Deadlocks + diagnostics.TempFiles = &row.TempFiles + diagnostics.TempBytesMB = &tempBytesMB + diagnostics.Rollbacks = &row.XactRollback + + return nil +} + +func collectPGStatActivityDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error { + var row struct { + IdleInTransactionCount int64 `db:"idle_in_transaction_count"` + LongestQueryDurationSeconds float64 `db:"longest_query_duration_seconds"` + WaitingForLockCount int64 `db:"waiting_for_lock_count"` + } + + const query = ` +SELECT + COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction_count, + EXTRACT(EPOCH FROM COALESCE( + MAX(clock_timestamp() - query_start) FILTER (WHERE state = 'active' AND query_start IS NOT NULL), + interval '0 second' + )) AS longest_query_duration_seconds, + COUNT(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_for_lock_count +FROM pg_stat_activity +WHERE datname = current_database()` + + if err := db.GetContext(ctx, &row, query); err != nil { + return err + } + + diagnostics.IdleInTransactionCount = &row.IdleInTransactionCount + diagnostics.LongestQueryDurationSeconds = &row.LongestQueryDurationSeconds + diagnostics.WaitingForLockCount = &row.WaitingForLockCount + + return nil +} + +func collectPGStatUserTablesDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error { + var row struct { + NDeadTup int64 `db:"n_dead_tup"` + LastAutovacuum sql.NullTime `db:"last_autovacuum"` + } + + const query = ` +SELECT + n_dead_tup, + last_autovacuum +FROM pg_stat_user_tables +WHERE lower(relname) = 'posts' + AND schemaname = current_schema() +LIMIT 1` + + if err := db.GetContext(ctx, &row, query); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + + diagnostics.PostsDeadTuples = &row.NDeadTup + if row.LastAutovacuum.Valid { + ts := row.LastAutovacuum.Time.UTC() + diagnostics.PostsLastAutovacuum = &ts + } + + return nil +} diff --git a/server/channels/store/sqlstore/diagnostics_test.go b/server/channels/store/sqlstore/diagnostics_test.go new file mode 100644 index 00000000000..e66781f6611 --- /dev/null +++ b/server/channels/store/sqlstore/diagnostics_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "context" + "database/sql" + "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" +) + +func TestGetDiagnostics(t *testing.T) { + StoreTest(t, func(t *testing.T, _ request.CTX, ss store.Store) { + sqlStore := ss.(*SqlStore) + + diagnostics, err := sqlStore.GetDiagnostics(context.Background()) + require.NoError(t, err) + require.NotNil(t, diagnostics) + + // Pool stats are populated for every supported driver. + assert.GreaterOrEqual(t, diagnostics.MasterConnectionsInUse, 0) + assert.GreaterOrEqual(t, diagnostics.MasterConnectionsIdle, 0) + + if sqlStore.DriverName() != model.DatabaseDriverPostgres { + assert.Nil(t, diagnostics.CacheHitRatio) + return + } + + require.NotNil(t, diagnostics.CacheHitRatio) + assert.GreaterOrEqual(t, *diagnostics.CacheHitRatio, 0.0) + assert.LessOrEqual(t, *diagnostics.CacheHitRatio, 1.0) + require.NotNil(t, diagnostics.Deadlocks) + require.NotNil(t, diagnostics.TempFiles) + require.NotNil(t, diagnostics.TempBytesMB) + require.NotNil(t, diagnostics.Rollbacks) + require.NotNil(t, diagnostics.IdleInTransactionCount) + require.NotNil(t, diagnostics.LongestQueryDurationSeconds) + require.NotNil(t, diagnostics.WaitingForLockCount) + }) +} + +func TestApplyDBPoolStats(t *testing.T) { + diagnostics := &store.DatabaseDiagnostics{} + applyDBPoolStats( + diagnostics, + sql.DBStats{ + InUse: 3, + Idle: 7, + WaitCount: 11, + WaitDuration: 2*time.Second + 25*time.Millisecond, + MaxIdleClosed: 13, + MaxLifetimeClosed: 17, + }, + sql.DBStats{ + InUse: 5, + Idle: 9, + WaitCount: 19, + WaitDuration: 4*time.Second + 90*time.Millisecond, + MaxIdleClosed: 23, + MaxLifetimeClosed: 29, + }, + ) + + assert.Equal(t, 3, diagnostics.MasterConnectionsInUse) + assert.Equal(t, 7, diagnostics.MasterConnectionsIdle) + assert.Equal(t, int64(11), diagnostics.MasterPoolWaitCount) + assert.Equal(t, int64(2025), diagnostics.MasterPoolWaitDurationMs) + assert.Equal(t, int64(13), diagnostics.MasterConnectionsClosedMaxIdle) + assert.Equal(t, int64(17), diagnostics.MasterConnectionsClosedMaxLifetime) + assert.Equal(t, 5, diagnostics.ReplicaConnectionsInUse) + assert.Equal(t, 9, diagnostics.ReplicaConnectionsIdle) + assert.Equal(t, int64(19), diagnostics.ReplicaPoolWaitCount) + assert.Equal(t, int64(4090), diagnostics.ReplicaPoolWaitDurationMs) + assert.Equal(t, int64(23), diagnostics.ReplicaConnectionsClosedMaxIdle) + assert.Equal(t, int64(29), diagnostics.ReplicaConnectionsClosedMaxLifetime) +} diff --git a/server/channels/store/sqlstore/migration_000172_test.go b/server/channels/store/sqlstore/migration_000185_test.go similarity index 98% rename from server/channels/store/sqlstore/migration_000172_test.go rename to server/channels/store/sqlstore/migration_000185_test.go index 7dba1969f71..bb6a37fa990 100644 --- a/server/channels/store/sqlstore/migration_000172_test.go +++ b/server/channels/store/sqlstore/migration_000185_test.go @@ -23,7 +23,7 @@ func readMigrationSQL(t *testing.T, filename string) string { return string(data) } -func TestMigration000172(t *testing.T) { +func TestMigration000185(t *testing.T) { logger := mlog.CreateTestLogger(t) settings, err := makeSqlSettings(model.DatabaseDriverPostgres) @@ -206,7 +206,7 @@ func TestMigration000172(t *testing.T) { assert.Equal(t, groupID, val.GroupID, "value GroupID should remain unchanged after down") } -func TestMigration000172DownPreservesNonUserFields(t *testing.T) { +func TestMigration000185DownPreservesNonUserFields(t *testing.T) { logger := mlog.CreateTestLogger(t) settings, err := makeSqlSettings(model.DatabaseDriverPostgres) @@ -299,7 +299,7 @@ func TestMigration000172DownPreservesNonUserFields(t *testing.T) { assert.Equal(t, "sysadmin", channelField.PermissionOptions.String) } -func TestMigration000172NoOpOnFreshDB(t *testing.T) { +func TestMigration000185NoOpOnFreshDB(t *testing.T) { logger := mlog.CreateTestLogger(t) settings, err := makeSqlSettings(model.DatabaseDriverPostgres) diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index 0bf3b4a3200..f252c26ff93 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -105,6 +105,7 @@ type SqlStoreStores struct { postPersistentNotification store.PostPersistentNotificationStore desktopTokens store.DesktopTokensStore channelBookmarks store.ChannelBookmarkStore + channelGuard store.ChannelGuardStore scheduledPost store.ScheduledPostStore view store.ViewStore propertyGroup store.PropertyGroupStore @@ -292,6 +293,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store) store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics) store.stores.channelBookmarks = newSqlChannelBookmarkStore(store) + store.stores.channelGuard = newSqlChannelGuardStore(store) store.stores.scheduledPost = newScheduledPostStore(store) store.stores.view = newSqlViewStore(store) store.stores.propertyGroup = newPropertyGroupStore(store) @@ -547,6 +549,10 @@ func (ss *SqlStore) TotalMasterDbConnections() int { return ss.GetMaster().Stats().OpenConnections } +func (ss *SqlStore) MasterDBStats() sql.DBStats { + return ss.GetMaster().Stats() +} + // ReplicaLagAbs queries all the replica databases to get the absolute replica lag value // and updates the Prometheus metric with it. func (ss *SqlStore) ReplicaLagAbs() error { @@ -601,6 +607,27 @@ func (ss *SqlStore) TotalReadDbConnections() int { return count } +func (ss *SqlStore) ReplicaDBStats() sql.DBStats { + var stats sql.DBStats + for _, db := range ss.ReplicaXs { + if !db.Load().Online() { + continue + } + + dbStats := db.Load().Stats() + stats.OpenConnections += dbStats.OpenConnections + stats.InUse += dbStats.InUse + stats.Idle += dbStats.Idle + stats.WaitCount += dbStats.WaitCount + stats.WaitDuration += dbStats.WaitDuration + stats.MaxIdleClosed += dbStats.MaxIdleClosed + stats.MaxIdleTimeClosed += dbStats.MaxIdleTimeClosed + stats.MaxLifetimeClosed += dbStats.MaxLifetimeClosed + } + + return stats +} + func (ss *SqlStore) TotalSearchDbConnections() int { if len(ss.settings.DataSourceSearchReplicas) == 0 { return 0 @@ -888,6 +915,10 @@ func (ss *SqlStore) ChannelBookmark() store.ChannelBookmarkStore { return ss.stores.channelBookmarks } +func (ss *SqlStore) ChannelGuard() store.ChannelGuardStore { + return ss.stores.channelGuard +} + func (ss *SqlStore) View() store.ViewStore { return ss.stores.view } diff --git a/server/channels/store/sqlstore/user_access_token_store.go b/server/channels/store/sqlstore/user_access_token_store.go index 7a93d389ede..ba33898c35c 100644 --- a/server/channels/store/sqlstore/user_access_token_store.go +++ b/server/channels/store/sqlstore/user_access_token_store.go @@ -32,6 +32,7 @@ func newSqlUserAccessTokenStore(sqlStore *SqlStore) store.UserAccessTokenStore { "UserAccessTokens.UserId", "UserAccessTokens.Description", "UserAccessTokens.IsActive", + "UserAccessTokens.ExpiresAt", ). From("UserAccessTokens") @@ -46,8 +47,8 @@ func (s SqlUserAccessTokenStore) Save(token *model.UserAccessToken) (*model.User } query, args, err := s.getQueryBuilder().Insert("UserAccessTokens"). - Columns("Id", "Token", "UserId", "Description", "IsActive"). - Values(token.Id, token.Token, token.UserId, token.Description, token.IsActive). + Columns("Id", "Token", "UserId", "Description", "IsActive", "ExpiresAt"). + Values(token.Id, token.Token, token.UserId, token.Description, token.IsActive, token.ExpiresAt). ToSql() if err != nil { return nil, errors.Wrap(err, "UserAccessToken_tosql") @@ -231,6 +232,103 @@ func (s SqlUserAccessTokenStore) UpdateTokenDisable(tokenId string) (err error) return nil } +// GetExpiredBefore returns active tokens whose non-zero ExpiresAt is less than +// or equal to the provided cutoff (Unix milliseconds), up to the given limit. +// The secret Token column is intentionally NOT selected — callers use the +// returned rows for metadata (audit logging, deletion) only. +// +// A non-positive limit returns an empty slice without hitting the DB rather +// than relying on the int -> uint64 cast (which would otherwise wrap a +// negative value into an enormous unsigned limit and effectively disable the +// bound). +func (s SqlUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) { + tokens := []*model.UserAccessToken{} + + if limit <= 0 { + return tokens, nil + } + + query := s.getQueryBuilder(). + Select( + "UserAccessTokens.Id", + "UserAccessTokens.UserId", + "UserAccessTokens.Description", + "UserAccessTokens.IsActive", + "UserAccessTokens.ExpiresAt", + ). + From("UserAccessTokens"). + Where(sq.Gt{"UserAccessTokens.ExpiresAt": 0}). + Where(sq.LtOrEq{"UserAccessTokens.ExpiresAt": cutoff}). + Where(sq.Eq{"UserAccessTokens.IsActive": true}). + OrderBy("UserAccessTokens.ExpiresAt ASC"). + Limit(uint64(limit)) + + if err := s.GetReplica().SelectBuilder(&tokens, query); err != nil { + return nil, errors.Wrap(err, "failed to find expired UserAccessTokens") + } + + return tokens, nil +} + +// DeleteByIds deletes the tokens identified by tokenIDs along with any sessions +// minted from those tokens, all within a single transaction. It returns the +// number of UserAccessTokens rows actually deleted. +func (s SqlUserAccessTokenStore) DeleteByIds(tokenIDs []string) (deleted int64, err error) { + if len(tokenIDs) == 0 { + return 0, nil + } + + transaction, beginErr := s.GetMaster().Begin() + if beginErr != nil { + err = errors.Wrap(beginErr, "begin_transaction") + return + } + defer finalizeTransactionX(transaction, &err) + + // Delete sessions whose Token matches any of the PAT tokens via subquery. + subSQL, subArgs, sqErr := s.getQueryBuilder(). + Select("Token"). + From("UserAccessTokens"). + Where(sq.Eq{"Id": tokenIDs}). + ToSql() + if sqErr != nil { + err = errors.Wrap(sqErr, "UserAccessToken_tosql") + return + } + if _, sErr := transaction.Exec("DELETE FROM Sessions WHERE Token IN ("+subSQL+")", subArgs...); sErr != nil { + err = errors.Wrap(sErr, "failed to delete Sessions for UserAccessTokens") + return + } + + tokenSQL, tokenArgs, sqErr := s.getQueryBuilder(). + Delete("UserAccessTokens"). + Where(sq.Eq{"Id": tokenIDs}). + ToSql() + if sqErr != nil { + err = errors.Wrap(sqErr, "UserAccessToken_tosql") + return + } + res, execErr := transaction.Exec(tokenSQL, tokenArgs...) + if execErr != nil { + err = errors.Wrap(execErr, "failed to delete UserAccessTokens") + return + } + + rowCount, rErr := res.RowsAffected() + if rErr != nil { + err = errors.Wrap(rErr, "failed to read RowsAffected for UserAccessTokens delete") + return + } + + if cErr := transaction.Commit(); cErr != nil { + err = errors.Wrap(cErr, "commit_transaction") + return + } + + deleted = rowCount + return +} + func (s SqlUserAccessTokenStore) deleteSessionsAndDisableToken(transaction *sqlxTxWrapper, tokenId string) error { query := "DELETE FROM Sessions s USING UserAccessTokens o WHERE o.Token = s.Token AND o.Id = ?" diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 04715debd3d..0fda03addb7 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -62,6 +62,7 @@ type Store interface { LinkMetadata() LinkMetadataStore SharedChannel() SharedChannelStore Draft() DraftStore + ChannelGuard() ChannelGuardStore MarkSystemRanUnitTests() Close() LockToMaster() @@ -79,6 +80,7 @@ type Store interface { TotalMasterDbConnections() int TotalReadDbConnections() int TotalSearchDbConnections() int + GetDiagnostics(ctx context.Context) (*DatabaseDiagnostics, error) ReplicaLagTime() error ReplicaLagAbs() error CheckIntegrity() <-chan model.IntegrityCheckResult @@ -842,10 +844,12 @@ type UserAccessTokenStore interface { Save(token *model.UserAccessToken) (*model.UserAccessToken, error) DeleteAllForUser(userID string) error Delete(tokenID string) error + DeleteByIds(tokenIDs []string) (int64, error) Get(tokenID string) (*model.UserAccessToken, error) GetAll(offset int, limit int) ([]*model.UserAccessToken, error) GetByToken(tokenString string) (*model.UserAccessToken, error) GetByUser(userID string, page, perPage int) ([]*model.UserAccessToken, error) + GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) Search(term string) ([]*model.UserAccessToken, error) UpdateTokenEnable(tokenID string) error UpdateTokenDisable(tokenID string) error @@ -1075,6 +1079,21 @@ type PostPriorityStore interface { Delete(postID string) error } +// ChannelGuard is a single claim row asserting that a plugin has registered as a guard for a given +// channel. Plugins may co-claim a channel; one row per (ChannelId, PluginId) pair. +type ChannelGuard struct { + ChannelId string + PluginId string + CreatedAt int64 +} + +type ChannelGuardStore interface { + Save(rctx request.CTX, guard *ChannelGuard) error + Delete(rctx request.CTX, channelID, pluginID string) (rowsAffected int64, err error) + GetForChannel(rctx request.CTX, channelID string) ([]*ChannelGuard, error) + GetAll(rctx request.CTX) ([]*ChannelGuard, error) +} + type DraftStore interface { Upsert(d *model.Draft) (*model.Draft, error) Get(userID, channelID, rootID string, includeDeleted bool) (*model.Draft, error) @@ -1184,6 +1203,20 @@ type AccessControlPolicyStore interface { Get(rctx request.CTX, id string) (*model.AccessControlPolicy, error) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) GetPoliciesByFieldID(rctx request.CTX, fieldID string) ([]*model.AccessControlPolicy, error) + + // GetActionsForPolicy returns the union of action keys declared by the + // policy's own rules and the rules of any policies it imports. Returns + // an empty (non-nil) map when the policy exists but declares no rules. + // Returns ErrNotFound when no policy row exists for policyID. Used by + // the App-layer hydrator to lazily populate Channel.PolicyActions. + GetActionsForPolicy(rctx request.CTX, policyID string) (map[string]bool, error) + + // GetActionsForPolicies returns the per-policy action union for each ID + // in policyIDs. Missing policy IDs are absent from the returned map + // (not nil-valued). Single round-trip; used by batched hydration on + // channel-list reads to avoid an N+1 against AccessControlPolicies. + // Empty input returns an empty map and fires no SQL. + GetActionsForPolicies(rctx request.CTX, policyIDs []string) (map[string]map[string]bool, error) } type AttributesStore interface { diff --git a/server/channels/store/storetest/access_control_policy_store.go b/server/channels/store/storetest/access_control_policy_store.go index 16aab507384..89e08d30a1a 100644 --- a/server/channels/store/storetest/access_control_policy_store.go +++ b/server/channels/store/storetest/access_control_policy_store.go @@ -4,6 +4,7 @@ package storetest import ( + "errors" "fmt" "testing" @@ -25,6 +26,8 @@ func TestAccessControlPolicyStore(t *testing.T, rctx request.CTX, ss store.Store t.Run("GetPoliciesByFieldID", func(t *testing.T) { testAccessControlPolicyStoreGetPoliciesByFieldID(t, rctx, ss) }) t.Run("ScopeRoundtrip", func(t *testing.T) { testAccessControlPolicyStoreScopeRoundtrip(t, rctx, ss) }) t.Run("SearchByTeamIDWithScope", func(t *testing.T) { testAccessControlPolicyStoreSearchByTeamIDWithScope(t, rctx, ss) }) + t.Run("GetActionsForPolicy", func(t *testing.T) { testAccessControlPolicyStoreGetActionsForPolicy(t, rctx, ss) }) + t.Run("GetActionsForPolicies", func(t *testing.T) { testAccessControlPolicyStoreGetActionsForPolicies(t, rctx, ss) }) } func testAccessControlPolicyStoreSaveAndGet(t *testing.T, rctx request.CTX, ss store.Store) { @@ -1360,3 +1363,289 @@ func testAccessControlPolicyStoreSearchByTeamIDWithScope(t *testing.T, rctx requ require.Empty(t, results, "cross-team policy should not match single-team search") }) } + +func testAccessControlPolicyStoreGetActionsForPolicy(t *testing.T, rctx request.CTX, ss store.Store) { + savePolicy := func(t *testing.T, policy *model.AccessControlPolicy) *model.AccessControlPolicy { + t.Helper() + saved, err := ss.AccessControlPolicy().Save(rctx, policy) + require.NoError(t, err) + t.Cleanup(func() { + _ = ss.AccessControlPolicy().Delete(rctx, saved.ID) + }) + return saved + } + + t.Run("membership-only policy returns membership action", func(t *testing.T) { + policy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Membership Only " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{model.AccessControlPolicyActionMembership}, + Expression: "user.attributes.program == \"engineering\"", + }, + }, + }) + + actions, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, policy.ID) + require.NoError(t, err) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionMembership: true}, actions) + }) + + t.Run("permission-only policy returns the non-membership action", func(t *testing.T) { + policy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Permission Only " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, + Expression: "user.attributes.program == \"engineering\"", + }, + }, + }) + + actions, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, policy.ID) + require.NoError(t, err) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionUploadFileAttachment: true}, actions) + require.False(t, actions[model.AccessControlPolicyActionMembership], "membership must not leak in") + }) + + t.Run("mixed actions are deduplicated across rules", func(t *testing.T) { + policy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Mixed " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{model.AccessControlPolicyActionMembership, model.AccessControlPolicyActionUploadFileAttachment}, + Expression: "user.attributes.program == \"engineering\"", + }, + { + Actions: []string{model.AccessControlPolicyActionMembership}, + Expression: "user.attributes.region == \"emea\"", + }, + }, + }) + + actions, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, policy.ID) + require.NoError(t, err) + require.Equal(t, map[string]bool{ + model.AccessControlPolicyActionMembership: true, + model.AccessControlPolicyActionUploadFileAttachment: true, + }, actions) + }) + + t.Run("child policy with no rules inherits parent's actions via imports", func(t *testing.T) { + parent := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Parent For Inherit " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{model.AccessControlPolicyActionMembership}, + Expression: "user.attributes.program == \"engineering\"", + }, + }, + }) + + child := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Child " + model.NewId(), + Type: model.AccessControlPolicyTypeChannel, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{parent.ID}, + Rules: []model.AccessControlPolicyRule{}, + }) + + actions, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, child.ID) + require.NoError(t, err) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionMembership: true}, actions, "child must inherit parent's action union") + }) + + t.Run("child policy unions own rules with parent imports", func(t *testing.T) { + parent := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Parent Membership " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"}, + }, + }) + + child := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Child Upload " + model.NewId(), + Type: model.AccessControlPolicyTypeChannel, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{parent.ID}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"}, + }, + }) + + actions, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, child.ID) + require.NoError(t, err) + require.Equal(t, map[string]bool{ + model.AccessControlPolicyActionMembership: true, + model.AccessControlPolicyActionUploadFileAttachment: true, + }, actions) + }) + + t.Run("non-existent policy returns ErrNotFound", func(t *testing.T) { + _, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, model.NewId()) + require.Error(t, err) + var nfErr *store.ErrNotFound + require.True(t, errors.As(err, &nfErr), "expected ErrNotFound, got %T: %v", err, err) + }) + + t.Run("invalid policy id returns ErrInvalidInput", func(t *testing.T) { + _, err := ss.AccessControlPolicy().GetActionsForPolicy(rctx, "not-a-valid-id") + require.Error(t, err) + var invErr *store.ErrInvalidInput + require.True(t, errors.As(err, &invErr), "expected ErrInvalidInput, got %T: %v", err, err) + }) +} + +func testAccessControlPolicyStoreGetActionsForPolicies(t *testing.T, rctx request.CTX, ss store.Store) { + savePolicy := func(t *testing.T, policy *model.AccessControlPolicy) *model.AccessControlPolicy { + t.Helper() + saved, err := ss.AccessControlPolicy().Save(rctx, policy) + require.NoError(t, err) + t.Cleanup(func() { + _ = ss.AccessControlPolicy().Delete(rctx, saved.ID) + }) + return saved + } + + t.Run("empty input returns empty map", func(t *testing.T) { + result, err := ss.AccessControlPolicy().GetActionsForPolicies(rctx, []string{}) + require.NoError(t, err) + require.Empty(t, result) + require.NotNil(t, result, "empty result must not be nil so callers can range safely") + }) + + t.Run("returns per-policy action union for each ID", func(t *testing.T) { + membershipPolicy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Batch Membership " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"}, + }, + }) + + permissionPolicy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Batch Permission " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"}, + }, + }) + + result, err := ss.AccessControlPolicy().GetActionsForPolicies(rctx, []string{membershipPolicy.ID, permissionPolicy.ID}) + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionMembership: true}, result[membershipPolicy.ID]) + require.Equal(t, map[string]bool{model.AccessControlPolicyActionUploadFileAttachment: true}, result[permissionPolicy.ID]) + }) + + t.Run("missing IDs are absent from the result map (not nil-valued)", func(t *testing.T) { + policy := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Batch Existing " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"}, + }, + }) + + missing := model.NewId() + result, err := ss.AccessControlPolicy().GetActionsForPolicies(rctx, []string{policy.ID, missing}) + require.NoError(t, err) + require.Len(t, result, 1, "missing ID should be absent (not nil-valued)") + require.Contains(t, result, policy.ID) + require.NotContains(t, result, missing) + }) + + t.Run("imports are unioned for batch reads", func(t *testing.T) { + parent := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Batch Parent " + model.NewId(), + Type: model.AccessControlPolicyTypeParent, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"}, + }, + }) + + child := savePolicy(t, &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Batch Child " + model.NewId(), + Type: model.AccessControlPolicyTypeChannel, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_2, + Imports: []string{parent.ID}, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: "true"}, + }, + }) + + result, err := ss.AccessControlPolicy().GetActionsForPolicies(rctx, []string{child.ID}) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, map[string]bool{ + model.AccessControlPolicyActionMembership: true, + model.AccessControlPolicyActionUploadFileAttachment: true, + }, result[child.ID]) + }) + + t.Run("invalid ID in batch surfaces ErrInvalidInput", func(t *testing.T) { + _, err := ss.AccessControlPolicy().GetActionsForPolicies(rctx, []string{"not-a-valid-id"}) + require.Error(t, err) + var invErr *store.ErrInvalidInput + require.True(t, errors.As(err, &invErr), "expected ErrInvalidInput, got %T: %v", err, err) + }) +} diff --git a/server/channels/store/storetest/channel_guard_store.go b/server/channels/store/storetest/channel_guard_store.go new file mode 100644 index 00000000000..5b961eb4da0 --- /dev/null +++ b/server/channels/store/storetest/channel_guard_store.go @@ -0,0 +1,141 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChannelGuardStore(t *testing.T, rctx request.CTX, ss store.Store) { + t.Run("SaveAndGetForChannel", func(t *testing.T) { testChannelGuardSaveAndGetForChannel(t, rctx, ss) }) + t.Run("SaveIdempotentSamePlugin", func(t *testing.T) { testChannelGuardSaveIdempotentSamePlugin(t, rctx, ss) }) + t.Run("SaveTwoPluginsSameChannel", func(t *testing.T) { testChannelGuardSaveTwoPluginsSameChannel(t, rctx, ss) }) + t.Run("Delete", func(t *testing.T) { testChannelGuardDelete(t, rctx, ss) }) + t.Run("DeleteRowsAffected", func(t *testing.T) { testChannelGuardDeleteRowsAffected(t, rctx, ss) }) + t.Run("GetAll", func(t *testing.T) { testChannelGuardGetAll(t, rctx, ss) }) +} + +func testChannelGuardSaveAndGetForChannel(t *testing.T, rctx request.CTX, ss store.Store) { + channelID := model.NewId() + pluginID := "com.example.plugin-a" + + guard := &store.ChannelGuard{ + ChannelId: channelID, + PluginId: pluginID, + CreatedAt: 1000, + } + + err := ss.ChannelGuard().Save(rctx, guard) + require.NoError(t, err) + + got, err := ss.ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, channelID, got[0].ChannelId) + assert.Equal(t, pluginID, got[0].PluginId) + assert.Equal(t, int64(1000), got[0].CreatedAt) +} + +func testChannelGuardSaveIdempotentSamePlugin(t *testing.T, rctx request.CTX, ss store.Store) { + channelID := model.NewId() + pluginID := "com.example.plugin-a" + + first := &store.ChannelGuard{ChannelId: channelID, PluginId: pluginID, CreatedAt: 1000} + require.NoError(t, ss.ChannelGuard().Save(rctx, first)) + + second := &store.ChannelGuard{ChannelId: channelID, PluginId: pluginID, CreatedAt: 2000} + require.NoError(t, ss.ChannelGuard().Save(rctx, second)) + + got, err := ss.ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, got, 1, "second save should be a no-op (DO NOTHING)") + assert.Equal(t, int64(1000), got[0].CreatedAt, "original CreatedAt should be preserved") +} + +func testChannelGuardSaveTwoPluginsSameChannel(t *testing.T, rctx request.CTX, ss store.Store) { + channelID := model.NewId() + pluginA := "com.example.plugin-a" + pluginB := "com.example.plugin-b" + + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelID, PluginId: pluginA, CreatedAt: 1000})) + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelID, PluginId: pluginB, CreatedAt: 2000})) + + got, err := ss.ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, got, 2) + + pluginIDs := []string{got[0].PluginId, got[1].PluginId} + assert.Contains(t, pluginIDs, pluginA) + assert.Contains(t, pluginIDs, pluginB) +} + +func testChannelGuardDelete(t *testing.T, rctx request.CTX, ss store.Store) { + channelID := model.NewId() + pluginA := "com.example.plugin-a" + pluginB := "com.example.plugin-b" + + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelID, PluginId: pluginA, CreatedAt: 1000})) + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelID, PluginId: pluginB, CreatedAt: 2000})) + + n, err := ss.ChannelGuard().Delete(rctx, channelID, pluginA) + require.NoError(t, err) + assert.Equal(t, int64(1), n, "expected 1 row deleted") + + got, err := ss.ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, got, 1, "only plugin-A's row should be deleted") + assert.Equal(t, pluginB, got[0].PluginId) + + // Deleting an already-removed (channel, plugin) pair is a no-op, not an error. + n, err = ss.ChannelGuard().Delete(rctx, channelID, pluginA) + require.NoError(t, err) + assert.Equal(t, int64(0), n, "expected 0 rows deleted for already-removed row") +} + +func testChannelGuardDeleteRowsAffected(t *testing.T, rctx request.CTX, ss store.Store) { + channelID := model.NewId() + pluginA := "com.example.plugin-a" + pluginB := "com.example.plugin-b" + + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelID, PluginId: pluginA, CreatedAt: 1000})) + + // Cross-plugin delete: pluginB has no claim on the channel; returns (0, nil). + n, err := ss.ChannelGuard().Delete(rctx, channelID, pluginB) + require.NoError(t, err) + assert.Equal(t, int64(0), n, "cross-plugin delete must return 0 rows affected") + + // pluginA's row must be untouched. + got, err := ss.ChannelGuard().GetForChannel(rctx, channelID) + require.NoError(t, err) + require.Len(t, got, 1, "pluginA row must remain after cross-plugin delete") + assert.Equal(t, pluginA, got[0].PluginId) +} + +func testChannelGuardGetAll(t *testing.T, rctx request.CTX, ss store.Store) { + channelA := model.NewId() + channelB := model.NewId() + pluginA := "com.example.plugin-a-" + model.NewId() + pluginB := "com.example.plugin-b-" + model.NewId() + + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginA, CreatedAt: 1000})) + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelA, PluginId: pluginB, CreatedAt: 1100})) + require.NoError(t, ss.ChannelGuard().Save(rctx, &store.ChannelGuard{ChannelId: channelB, PluginId: pluginA, CreatedAt: 1200})) + + all, err := ss.ChannelGuard().GetAll(rctx) + require.NoError(t, err) + + count := 0 + for _, g := range all { + if g.PluginId == pluginA || g.PluginId == pluginB { + count++ + } + } + assert.Equal(t, 3, count, "expected 3 rows from this test fixture") +} diff --git a/server/channels/store/storetest/mocks/AccessControlPolicyStore.go b/server/channels/store/storetest/mocks/AccessControlPolicyStore.go index 775aca660ef..c36c109c1ea 100644 --- a/server/channels/store/storetest/mocks/AccessControlPolicyStore.go +++ b/server/channels/store/storetest/mocks/AccessControlPolicyStore.go @@ -63,6 +63,66 @@ func (_m *AccessControlPolicyStore) Get(rctx request.CTX, id string) (*model.Acc return r0, r1 } +// GetActionsForPolicies provides a mock function with given fields: rctx, policyIDs +func (_m *AccessControlPolicyStore) GetActionsForPolicies(rctx request.CTX, policyIDs []string) (map[string]map[string]bool, error) { + ret := _m.Called(rctx, policyIDs) + + if len(ret) == 0 { + panic("no return value specified for GetActionsForPolicies") + } + + var r0 map[string]map[string]bool + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, []string) (map[string]map[string]bool, error)); ok { + return rf(rctx, policyIDs) + } + if rf, ok := ret.Get(0).(func(request.CTX, []string) map[string]map[string]bool); ok { + r0 = rf(rctx, policyIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]map[string]bool) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, []string) error); ok { + r1 = rf(rctx, policyIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetActionsForPolicy provides a mock function with given fields: rctx, policyID +func (_m *AccessControlPolicyStore) GetActionsForPolicy(rctx request.CTX, policyID string) (map[string]bool, error) { + ret := _m.Called(rctx, policyID) + + if len(ret) == 0 { + panic("no return value specified for GetActionsForPolicy") + } + + var r0 map[string]bool + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string) (map[string]bool, error)); ok { + return rf(rctx, policyID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) map[string]bool); ok { + r0 = rf(rctx, policyID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]bool) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok { + r1 = rf(rctx, policyID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPoliciesByFieldID provides a mock function with given fields: rctx, fieldID func (_m *AccessControlPolicyStore) GetPoliciesByFieldID(rctx request.CTX, fieldID string) ([]*model.AccessControlPolicy, error) { ret := _m.Called(rctx, fieldID) diff --git a/server/channels/store/storetest/mocks/ChannelGuardStore.go b/server/channels/store/storetest/mocks/ChannelGuardStore.go new file mode 100644 index 00000000000..7606ebaf885 --- /dev/null +++ b/server/channels/store/storetest/mocks/ChannelGuardStore.go @@ -0,0 +1,136 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + request "github.com/mattermost/mattermost/server/public/shared/request" + store "github.com/mattermost/mattermost/server/v8/channels/store" + mock "github.com/stretchr/testify/mock" +) + +// ChannelGuardStore is an autogenerated mock type for the ChannelGuardStore type +type ChannelGuardStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: rctx, channelID, pluginID +func (_m *ChannelGuardStore) Delete(rctx request.CTX, channelID string, pluginID string) (int64, error) { + ret := _m.Called(rctx, channelID, pluginID) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string, string) (int64, error)); ok { + return rf(rctx, channelID, pluginID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string) int64); ok { + r0 = rf(rctx, channelID, pluginID) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok { + r1 = rf(rctx, channelID, pluginID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: rctx +func (_m *ChannelGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) { + ret := _m.Called(rctx) + + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + + var r0 []*store.ChannelGuard + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX) ([]*store.ChannelGuard, error)); ok { + return rf(rctx) + } + if rf, ok := ret.Get(0).(func(request.CTX) []*store.ChannelGuard); ok { + r0 = rf(rctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*store.ChannelGuard) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX) error); ok { + r1 = rf(rctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForChannel provides a mock function with given fields: rctx, channelID +func (_m *ChannelGuardStore) GetForChannel(rctx request.CTX, channelID string) ([]*store.ChannelGuard, error) { + ret := _m.Called(rctx, channelID) + + if len(ret) == 0 { + panic("no return value specified for GetForChannel") + } + + var r0 []*store.ChannelGuard + var r1 error + if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*store.ChannelGuard, error)); ok { + return rf(rctx, channelID) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) []*store.ChannelGuard); ok { + r0 = rf(rctx, channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*store.ChannelGuard) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok { + r1 = rf(rctx, channelID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: rctx, guard +func (_m *ChannelGuardStore) Save(rctx request.CTX, guard *store.ChannelGuard) error { + ret := _m.Called(rctx, guard) + + if len(ret) == 0 { + panic("no return value specified for Save") + } + + var r0 error + if rf, ok := ret.Get(0).(func(request.CTX, *store.ChannelGuard) error); ok { + r0 = rf(rctx, guard) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewChannelGuardStore creates a new instance of ChannelGuardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewChannelGuardStore(t interface { + mock.TestingT + Cleanup(func()) +}) *ChannelGuardStore { + mock := &ChannelGuardStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index c9c0a8c4958..7e63eb319e2 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -7,9 +7,8 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" - mock "github.com/stretchr/testify/mock" - store "github.com/mattermost/mattermost/server/v8/channels/store" + mock "github.com/stretchr/testify/mock" ) // ChannelStore is an autogenerated mock type for the ChannelStore type @@ -1327,6 +1326,54 @@ func (_m *ChannelStore) GetDeletedByName(teamID string, name string) (*model.Cha return r0, r1 } +// GetDirectMessagesWithUnreadAndMentions provides a mock function with given fields: rctx, userID, userNotifyProps +func (_m *ChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(rctx, userID, userNotifyProps) + + if len(ret) == 0 { + panic("no return value specified for GetDirectMessagesWithUnreadAndMentions") + } + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(rctx, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) []string); ok { + r0 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.StringMap) []string); ok { + r1 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, model.StringMap) map[string]int64); ok { + r2 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(request.CTX, string, model.StringMap) error); ok { + r3 = rf(rctx, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetFileCount provides a mock function with given fields: channelID func (_m *ChannelStore) GetFileCount(channelID string) (int64, error) { ret := _m.Called(channelID) @@ -1817,54 +1864,6 @@ func (_m *ChannelStore) GetMembersInfoByChannelIds(channelIDs []string) (map[str return r0, r1 } -// GetDirectMessagesWithUnreadAndMentions provides a mock function with given fields: rctx, userID, userNotifyProps -func (_m *ChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { - ret := _m.Called(rctx, userID, userNotifyProps) - - if len(ret) == 0 { - panic("no return value specified for GetDirectMessagesWithUnreadAndMentions") - } - - var r0 []string - var r1 []string - var r2 map[string]int64 - var r3 error - if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { - return rf(rctx, userID, userNotifyProps) - } - if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) []string); ok { - r0 = rf(rctx, userID, userNotifyProps) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]string) - } - } - - if rf, ok := ret.Get(1).(func(request.CTX, string, model.StringMap) []string); ok { - r1 = rf(rctx, userID, userNotifyProps) - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).([]string) - } - } - - if rf, ok := ret.Get(2).(func(request.CTX, string, model.StringMap) map[string]int64); ok { - r2 = rf(rctx, userID, userNotifyProps) - } else { - if ret.Get(2) != nil { - r2 = ret.Get(2).(map[string]int64) - } - } - - if rf, ok := ret.Get(3).(func(request.CTX, string, model.StringMap) error); ok { - r3 = rf(rctx, userID, userNotifyProps) - } else { - r3 = ret.Error(3) - } - - return r0, r1, r2, r3 -} - // GetMoreChannels provides a mock function with given fields: teamID, userID, offset, limit func (_m *ChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { ret := _m.Called(teamID, userID, offset, limit) diff --git a/server/channels/store/storetest/mocks/PostStore.go b/server/channels/store/storetest/mocks/PostStore.go index 2574526c4b3..4a1c022b9a6 100644 --- a/server/channels/store/storetest/mocks/PostStore.go +++ b/server/channels/store/storetest/mocks/PostStore.go @@ -7,9 +7,8 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" - mock "github.com/stretchr/testify/mock" - store "github.com/mattermost/mattermost/server/v8/channels/store" + mock "github.com/stretchr/testify/mock" ) // PostStore is an autogenerated mock type for the PostStore type diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index 2a9565d9fbd..16ed34d912f 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -5,6 +5,7 @@ package mocks import ( + context "context" sql "database/sql" time "time" @@ -159,6 +160,26 @@ func (_m *Store) ChannelBookmark() store.ChannelBookmarkStore { return r0 } +// ChannelGuard provides a mock function with no fields +func (_m *Store) ChannelGuard() store.ChannelGuardStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ChannelGuard") + } + + var r0 store.ChannelGuardStore + if rf, ok := ret.Get(0).(func() store.ChannelGuardStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ChannelGuardStore) + } + } + + return r0 +} + // ChannelJoinRequest provides a mock function with no fields func (_m *Store) ChannelJoinRequest() store.ChannelJoinRequestStore { ret := _m.Called() @@ -593,6 +614,36 @@ func (_m *Store) GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, erro return r0, r1 } +// GetDiagnostics provides a mock function with given fields: ctx +func (_m *Store) GetDiagnostics(ctx context.Context) (*store.DatabaseDiagnostics, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetDiagnostics") + } + + var r0 *store.DatabaseDiagnostics + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*store.DatabaseDiagnostics, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *store.DatabaseDiagnostics); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.DatabaseDiagnostics) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Group provides a mock function with no fields func (_m *Store) Group() store.GroupStore { ret := _m.Called() diff --git a/server/channels/store/storetest/mocks/ThreadStore.go b/server/channels/store/storetest/mocks/ThreadStore.go index e2d222da345..4a3aa04edb0 100644 --- a/server/channels/store/storetest/mocks/ThreadStore.go +++ b/server/channels/store/storetest/mocks/ThreadStore.go @@ -7,9 +7,8 @@ package mocks import ( model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" - mock "github.com/stretchr/testify/mock" - store "github.com/mattermost/mattermost/server/v8/channels/store" + mock "github.com/stretchr/testify/mock" ) // ThreadStore is an autogenerated mock type for the ThreadStore type diff --git a/server/channels/store/storetest/mocks/UserAccessTokenStore.go b/server/channels/store/storetest/mocks/UserAccessTokenStore.go index c4e23f6fc4f..62fc003a65f 100644 --- a/server/channels/store/storetest/mocks/UserAccessTokenStore.go +++ b/server/channels/store/storetest/mocks/UserAccessTokenStore.go @@ -50,6 +50,64 @@ func (_m *UserAccessTokenStore) DeleteAllForUser(userID string) error { return r0 } +// DeleteByIds provides a mock function with given fields: tokenIDs +func (_m *UserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, error) { + ret := _m.Called(tokenIDs) + + if len(ret) == 0 { + panic("no return value specified for DeleteByIds") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func([]string) (int64, error)); ok { + return rf(tokenIDs) + } + if rf, ok := ret.Get(0).(func([]string) int64); ok { + r0 = rf(tokenIDs) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(tokenIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetExpiredBefore provides a mock function with given fields: cutoff, limit +func (_m *UserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) { + ret := _m.Called(cutoff, limit) + + if len(ret) == 0 { + panic("no return value specified for GetExpiredBefore") + } + + var r0 []*model.UserAccessToken + var r1 error + if rf, ok := ret.Get(0).(func(int64, int) ([]*model.UserAccessToken, error)); ok { + return rf(cutoff, limit) + } + if rf, ok := ret.Get(0).(func(int64, int) []*model.UserAccessToken); ok { + r0 = rf(cutoff, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.UserAccessToken) + } + } + + if rf, ok := ret.Get(1).(func(int64, int) error); ok { + r1 = rf(cutoff, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Get provides a mock function with given fields: tokenID func (_m *UserAccessTokenStore) Get(tokenID string) (*model.UserAccessToken, error) { ret := _m.Called(tokenID) diff --git a/server/channels/store/storetest/mocks/UserStore.go b/server/channels/store/storetest/mocks/UserStore.go index dda8cf0571b..312e0a9b88b 100644 --- a/server/channels/store/storetest/mocks/UserStore.go +++ b/server/channels/store/storetest/mocks/UserStore.go @@ -8,11 +8,9 @@ import ( context "context" model "github.com/mattermost/mattermost/server/public/model" - mock "github.com/stretchr/testify/mock" - request "github.com/mattermost/mattermost/server/public/shared/request" - store "github.com/mattermost/mattermost/server/v8/channels/store" + mock "github.com/stretchr/testify/mock" ) // UserStore is an autogenerated mock type for the UserStore type @@ -357,6 +355,24 @@ func (_m *UserStore) DeactivateMagicLinkGuests() ([]string, error) { return r0, r1 } +// DecrementFailedPasswordAttempts provides a mock function with given fields: userID +func (_m *UserStore) DecrementFailedPasswordAttempts(userID string) error { + ret := _m.Called(userID) + + if len(ret) == 0 { + panic("no return value specified for DecrementFailedPasswordAttempts") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DemoteUserToGuest provides a mock function with given fields: userID func (_m *UserStore) DemoteUserToGuest(userID string) (*model.User, error) { ret := _m.Called(userID) @@ -2080,6 +2096,34 @@ func (_m *UserStore) StoreMfaUsedTimestamps(userID string, ts []int) error { return r0 } +// TryIncrementFailedPasswordAttempts provides a mock function with given fields: userID, maxAttempts +func (_m *UserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { + ret := _m.Called(userID, maxAttempts) + + if len(ret) == 0 { + panic("no return value specified for TryIncrementFailedPasswordAttempts") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string, int) (bool, error)); ok { + return rf(userID, maxAttempts) + } + if rf, ok := ret.Get(0).(func(string, int) bool); ok { + r0 = rf(userID, maxAttempts) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string, int) error); ok { + r1 = rf(userID, maxAttempts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: rctx, user, allowRoleUpdate func (_m *UserStore) Update(rctx request.CTX, user *model.User, allowRoleUpdate bool) (*model.UserUpdate, error) { ret := _m.Called(rctx, user, allowRoleUpdate) @@ -2156,52 +2200,6 @@ func (_m *UserStore) UpdateFailedPasswordAttempts(userID string, attempts int) e return r0 } -// TryIncrementFailedPasswordAttempts provides a mock function with given fields: userID, maxAttempts -func (_m *UserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { - ret := _m.Called(userID, maxAttempts) - - if len(ret) == 0 { - panic("no return value specified for TryIncrementFailedPasswordAttempts") - } - - var r0 bool - var r1 error - if rf, ok := ret.Get(0).(func(string, int) (bool, error)); ok { - return rf(userID, maxAttempts) - } - if rf, ok := ret.Get(0).(func(string, int) bool); ok { - r0 = rf(userID, maxAttempts) - } else { - r0 = ret.Get(0).(bool) - } - - if rf, ok := ret.Get(1).(func(string, int) error); ok { - r1 = rf(userID, maxAttempts) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// DecrementFailedPasswordAttempts provides a mock function with given fields: userID -func (_m *UserStore) DecrementFailedPasswordAttempts(userID string) error { - ret := _m.Called(userID) - - if len(ret) == 0 { - panic("no return value specified for DecrementFailedPasswordAttempts") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(userID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // UpdateLastLogin provides a mock function with given fields: userID, lastLogin func (_m *UserStore) UpdateLastLogin(userID string, lastLogin int64) error { ret := _m.Called(userID, lastLogin) diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 5c54760f6d7..999754b46b8 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -4,6 +4,7 @@ package storetest import ( + "context" "database/sql" "time" @@ -63,6 +64,7 @@ type Store struct { PostPersistentNotificationStore mocks.PostPersistentNotificationStore DesktopTokensStore mocks.DesktopTokensStore ChannelBookmarkStore mocks.ChannelBookmarkStore + ChannelGuardStore mocks.ChannelGuardStore ScheduledPostStore mocks.ScheduledPostStore PropertyGroupStore mocks.PropertyGroupStore PropertyFieldStore mocks.PropertyFieldStore @@ -120,6 +122,7 @@ func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } func (s *Store) ChannelBookmark() store.ChannelBookmarkStore { return &s.ChannelBookmarkStore } +func (s *Store) ChannelGuard() store.ChannelGuardStore { return &s.ChannelGuardStore } func (s *Store) DesktopTokens() store.DesktopTokensStore { return &s.DesktopTokensStore } func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore } func (s *Store) Group() store.GroupStore { return &s.GroupStore } @@ -193,6 +196,10 @@ func (s *Store) GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error }, nil } +func (s *Store) GetDiagnostics(_ context.Context) (*store.DatabaseDiagnostics, error) { + return &store.DatabaseDiagnostics{}, nil +} + func (s *Store) AssertExpectations(t mock.TestingT) bool { return mock.AssertExpectationsForObjects(t, &s.TeamStore, @@ -234,6 +241,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.PostPersistentNotificationStore, &s.DesktopTokensStore, &s.ChannelBookmarkStore, + &s.ChannelGuardStore, &s.ScheduledPostStore, &s.AccessControlPolicyStore, &s.AttributesStore, diff --git a/server/channels/store/storetest/user_access_token_store.go b/server/channels/store/storetest/user_access_token_store.go index 61eb8459378..6f972384483 100644 --- a/server/channels/store/storetest/user_access_token_store.go +++ b/server/channels/store/storetest/user_access_token_store.go @@ -18,6 +18,7 @@ func TestUserAccessTokenStore(t *testing.T, rctx request.CTX, ss store.Store) { t.Run("UserAccessTokenDisableEnable", func(t *testing.T) { testUserAccessTokenDisableEnable(t, rctx, ss) }) t.Run("UserAccessTokenSearch", func(t *testing.T) { testUserAccessTokenSearch(t, rctx, ss) }) t.Run("UserAccessTokenPagination", func(t *testing.T) { testUserAccessTokenPagination(t, rctx, ss) }) + t.Run("UserAccessTokenExpiry", func(t *testing.T) { testUserAccessTokenExpiry(t, rctx, ss) }) } func testUserAccessTokenSaveGetDelete(t *testing.T, rctx request.CTX, ss store.Store) { @@ -245,3 +246,113 @@ func testUserAccessTokenPagination(t *testing.T, rctx request.CTX, ss store.Stor require.NoError(t, nErr) require.Len(t, result, 0, "Should return 0 tokens for non-existent user") } + +func testUserAccessTokenExpiry(t *testing.T, rctx request.CTX, ss store.Store) { + now := model.GetMillis() + + // Non-expiring token (ExpiresAt == 0) + nonExpiring := &model.UserAccessToken{ + Token: model.NewId(), + UserId: model.NewId(), + Description: "non-expiring", + } + _, err := ss.UserAccessToken().Save(nonExpiring) + require.NoError(t, err) + + // Token already expired + expired := &model.UserAccessToken{ + Token: model.NewId(), + UserId: model.NewId(), + Description: "expired", + ExpiresAt: now - 60*1000, + } + expiredSession := &model.Session{UserId: expired.UserId, Token: expired.Token} + _, sErr := ss.Session().Save(rctx, expiredSession) + require.NoError(t, sErr) + _, err = ss.UserAccessToken().Save(expired) + require.NoError(t, err) + + // Token expiring in the future + future := &model.UserAccessToken{ + Token: model.NewId(), + UserId: model.NewId(), + Description: "future", + ExpiresAt: now + 60*60*1000, + } + _, err = ss.UserAccessToken().Save(future) + require.NoError(t, err) + + t.Cleanup(func() { + // Delete all three fixtures (expired included) so the test stays + // isolated even on early exit before DeleteByIds runs. + _ = ss.UserAccessToken().Delete(nonExpiring.Id) + _ = ss.UserAccessToken().Delete(future.Id) + _ = ss.UserAccessToken().Delete(expired.Id) + }) + + // The stored value should be persisted and returned + stored, err := ss.UserAccessToken().Get(expired.Id) + require.NoError(t, err) + require.Equal(t, expired.ExpiresAt, stored.ExpiresAt) + + storedNonExpiring, err := ss.UserAccessToken().Get(nonExpiring.Id) + require.NoError(t, err) + require.Equal(t, int64(0), storedNonExpiring.ExpiresAt) + + // GetExpiredBefore should only return the expired token and must not leak + // the secret token value (the Token column is intentionally not selected). + expiredRows, err := ss.UserAccessToken().GetExpiredBefore(now, 100) + require.NoError(t, err) + found := false + for _, row := range expiredRows { + // The Token column is never selected by GetExpiredBefore, so no row — + // not just the matched expired one — should ever carry the secret. + require.Empty(t, row.Token, "GetExpiredBefore must never return the secret Token value") + if row.Id == expired.Id { + require.Equal(t, expired.ExpiresAt, row.ExpiresAt) + found = true + } + require.NotEqual(t, nonExpiring.Id, row.Id, "non-expiring token must not be returned") + require.NotEqual(t, future.Id, row.Id, "future token must not be returned") + } + require.True(t, found, "expired token should be present in GetExpiredBefore results") + + // Negative or zero limits short-circuit and return an empty slice without + // hitting the DB; verify the contract holds. + zeroLimit, err := ss.UserAccessToken().GetExpiredBefore(now, 0) + require.NoError(t, err) + require.Empty(t, zeroLimit) + negativeLimit, err := ss.UserAccessToken().GetExpiredBefore(now, -5) + require.NoError(t, err) + require.Empty(t, negativeLimit) + + // DeleteByIds on the expired token removes it and its session but leaves + // the other two tokens alone. + deleted, err := ss.UserAccessToken().DeleteByIds([]string{expired.Id}) + require.NoError(t, err) + require.Equal(t, int64(1), deleted) + + _, err = ss.UserAccessToken().Get(expired.Id) + require.Error(t, err, "expired token should be deleted") + + _, err = ss.Session().Get(rctx, expiredSession.Token) + require.Error(t, err, "session for expired token should be deleted") + + stillThere, err := ss.UserAccessToken().Get(nonExpiring.Id) + require.NoError(t, err) + require.Equal(t, nonExpiring.Id, stillThere.Id) + + stillThere, err = ss.UserAccessToken().Get(future.Id) + require.NoError(t, err) + require.Equal(t, future.Id, stillThere.Id) + + // DeleteByIds with an empty slice is a no-op, and with a non-matching id + // returns 0 without error. + deleted, err = ss.UserAccessToken().DeleteByIds(nil) + require.NoError(t, err) + require.Equal(t, int64(0), deleted) + + deleted, err = ss.UserAccessToken().DeleteByIds([]string{model.NewId()}) + require.NoError(t, err) + require.Equal(t, int64(0), deleted) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 6fa6f706031..202bc98a7ce 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -26,6 +26,7 @@ type TimerLayer struct { BotStore store.BotStore ChannelStore store.ChannelStore ChannelBookmarkStore store.ChannelBookmarkStore + ChannelGuardStore store.ChannelGuardStore ChannelJoinRequestStore store.ChannelJoinRequestStore ChannelMemberHistoryStore store.ChannelMemberHistoryStore ClusterDiscoveryStore store.ClusterDiscoveryStore @@ -107,6 +108,10 @@ func (s *TimerLayer) ChannelBookmark() store.ChannelBookmarkStore { return s.ChannelBookmarkStore } +func (s *TimerLayer) ChannelGuard() store.ChannelGuardStore { + return s.ChannelGuardStore +} + func (s *TimerLayer) ChannelJoinRequest() store.ChannelJoinRequestStore { return s.ChannelJoinRequestStore } @@ -346,6 +351,11 @@ type TimerLayerChannelBookmarkStore struct { Root *TimerLayer } +type TimerLayerChannelGuardStore struct { + store.ChannelGuardStore + Root *TimerLayer +} + type TimerLayerChannelJoinRequestStore struct { store.ChannelJoinRequestStore Root *TimerLayer @@ -633,6 +643,38 @@ func (s *TimerLayerAccessControlPolicyStore) Get(rctx request.CTX, id string) (* return result, err } +func (s *TimerLayerAccessControlPolicyStore) GetActionsForPolicies(rctx request.CTX, policyIDs []string) (map[string]map[string]bool, error) { + start := time.Now() + + result, err := s.AccessControlPolicyStore.GetActionsForPolicies(rctx, policyIDs) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AccessControlPolicyStore.GetActionsForPolicies", success, elapsed) + } + return result, err +} + +func (s *TimerLayerAccessControlPolicyStore) GetActionsForPolicy(rctx request.CTX, policyID string) (map[string]bool, error) { + start := time.Now() + + result, err := s.AccessControlPolicyStore.GetActionsForPolicy(rctx, policyID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("AccessControlPolicyStore.GetActionsForPolicy", success, elapsed) + } + return result, err +} + func (s *TimerLayerAccessControlPolicyStore) GetPoliciesByFieldID(rctx request.CTX, fieldID string) ([]*model.AccessControlPolicy, error) { start := time.Now() @@ -1907,6 +1949,22 @@ func (s *TimerLayerChannelStore) GetDeletedByName(teamID string, name string) (* return result, err } +func (s *TimerLayerChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + start := time.Now() + + result, resultVar1, resultVar2, err := s.ChannelStore.GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetDirectMessagesWithUnreadAndMentions", success, elapsed) + } + return result, resultVar1, resultVar2, err +} + func (s *TimerLayerChannelStore) GetFileCount(channelID string) (int64, error) { start := time.Now() @@ -2355,6 +2413,22 @@ func (s *TimerLayerChannelStore) GetTeamChannels(teamID string) (model.ChannelLi return result, err } +func (s *TimerLayerChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + start := time.Now() + + result, resultVar1, resultVar2, err := s.ChannelStore.GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetTeamChannelsWithUnreadAndMentions", success, elapsed) + } + return result, resultVar1, resultVar2, err +} + func (s *TimerLayerChannelStore) GetTeamForChannel(channelID string) (*model.Team, error) { start := time.Now() @@ -3228,6 +3302,70 @@ func (s *TimerLayerChannelBookmarkStore) UpdateSortOrder(bookmarkID string, chan return result, err } +func (s *TimerLayerChannelGuardStore) Delete(rctx request.CTX, channelID string, pluginID string) (int64, error) { + start := time.Now() + + result, err := s.ChannelGuardStore.Delete(rctx, channelID, pluginID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelGuardStore.Delete", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) { + start := time.Now() + + result, err := s.ChannelGuardStore.GetAll(rctx) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelGuardStore.GetAll", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelGuardStore) GetForChannel(rctx request.CTX, channelID string) ([]*store.ChannelGuard, error) { + start := time.Now() + + result, err := s.ChannelGuardStore.GetForChannel(rctx, channelID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelGuardStore.GetForChannel", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelGuardStore) Save(rctx request.CTX, guard *store.ChannelGuard) error { + start := time.Now() + + err := s.ChannelGuardStore.Save(rctx, guard) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelGuardStore.Save", success, elapsed) + } + return err +} + func (s *TimerLayerChannelJoinRequestStore) CountPending(channelId string) (int64, error) { start := time.Now() @@ -8102,6 +8240,22 @@ func (s *TimerLayerPropertyFieldStore) CountForGroup(groupID string, includeDele return result, err } +func (s *TimerLayerPropertyFieldStore) CountForGroupObjectType(groupID string, objectType string, includeDeleted bool) (int64, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.CountForGroupObjectType(groupID, objectType, includeDeleted) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.CountForGroupObjectType", success, elapsed) + } + return result, err +} + func (s *TimerLayerPropertyFieldStore) CountForTarget(groupID string, targetType string, targetID string, includeDeleted bool) (int64, error) { start := time.Now() @@ -12704,6 +12858,22 @@ func (s *TimerLayerUserStore) DeactivateMagicLinkGuests() ([]string, error) { return result, err } +func (s *TimerLayerUserStore) DecrementFailedPasswordAttempts(userID string) error { + start := time.Now() + + err := s.UserStore.DecrementFailedPasswordAttempts(userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.DecrementFailedPasswordAttempts", success, elapsed) + } + return err +} + func (s *TimerLayerUserStore) DemoteUserToGuest(userID string) (*model.User, error) { start := time.Now() @@ -13725,6 +13895,22 @@ func (s *TimerLayerUserStore) StoreMfaUsedTimestamps(userID string, ts []int) er return err } +func (s *TimerLayerUserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { + start := time.Now() + + result, err := s.UserStore.TryIncrementFailedPasswordAttempts(userID, maxAttempts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.TryIncrementFailedPasswordAttempts", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserStore) Update(rctx request.CTX, user *model.User, allowRoleUpdate bool) (*model.UserUpdate, error) { start := time.Now() @@ -13773,38 +13959,6 @@ func (s *TimerLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp return err } -func (s *TimerLayerUserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) { - start := time.Now() - - result, err := s.UserStore.TryIncrementFailedPasswordAttempts(userID, maxAttempts) - - elapsed := float64(time.Since(start)) / float64(time.Second) - if s.Root.Metrics != nil { - success := "false" - if err == nil { - success = "true" - } - s.Root.Metrics.ObserveStoreMethodDuration("UserStore.TryIncrementFailedPasswordAttempts", success, elapsed) - } - return result, err -} - -func (s *TimerLayerUserStore) DecrementFailedPasswordAttempts(userID string) error { - start := time.Now() - - err := s.UserStore.DecrementFailedPasswordAttempts(userID) - - elapsed := float64(time.Since(start)) / float64(time.Second) - if s.Root.Metrics != nil { - success := "false" - if err == nil { - success = "true" - } - s.Root.Metrics.ObserveStoreMethodDuration("UserStore.DecrementFailedPasswordAttempts", success, elapsed) - } - return err -} - func (s *TimerLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error { start := time.Now() @@ -13965,6 +14119,38 @@ func (s *TimerLayerUserAccessTokenStore) DeleteAllForUser(userID string) error { return err } +func (s *TimerLayerUserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, error) { + start := time.Now() + + result, err := s.UserAccessTokenStore.DeleteByIds(tokenIDs) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserAccessTokenStore.DeleteByIds", success, elapsed) + } + return result, err +} + +func (s *TimerLayerUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) { + start := time.Now() + + result, err := s.UserAccessTokenStore.GetExpiredBefore(cutoff, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserAccessTokenStore.GetExpiredBefore", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserAccessTokenStore) Get(tokenID string) (*model.UserAccessToken, error) { start := time.Now() @@ -14711,6 +14897,10 @@ func (s *TimerLayer) TotalSearchDbConnections() int { return s.Store.TotalSearchDbConnections() } +func (s *TimerLayer) GetDiagnostics(ctx context.Context) (*store.DatabaseDiagnostics, error) { + return s.Store.GetDiagnostics(ctx) +} + func (s *TimerLayer) UnlockFromMaster() { s.Store.UnlockFromMaster() } @@ -14728,6 +14918,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.BotStore = &TimerLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &TimerLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} newStore.ChannelBookmarkStore = &TimerLayerChannelBookmarkStore{ChannelBookmarkStore: childStore.ChannelBookmark(), Root: &newStore} + newStore.ChannelGuardStore = &TimerLayerChannelGuardStore{ChannelGuardStore: childStore.ChannelGuard(), Root: &newStore} newStore.ChannelJoinRequestStore = &TimerLayerChannelJoinRequestStore{ChannelJoinRequestStore: childStore.ChannelJoinRequest(), Root: &newStore} newStore.ChannelMemberHistoryStore = &TimerLayerChannelMemberHistoryStore{ChannelMemberHistoryStore: childStore.ChannelMemberHistory(), Root: &newStore} newStore.ClusterDiscoveryStore = &TimerLayerClusterDiscoveryStore{ClusterDiscoveryStore: childStore.ClusterDiscovery(), Root: &newStore} diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index 5d01e25c6ab..c8fe7f14957 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -145,6 +145,9 @@ func GetMockStoreForSetupFunctions() *mocks.Store { propertyFieldStore := mocks.PropertyFieldStore{} propertyValueStore := mocks.PropertyValueStore{} + channelGuardStore := mocks.ChannelGuardStore{} + channelGuardStore.On("GetAll", mock.Anything).Return([]*store.ChannelGuard{}, nil) + groupsByName := map[string]*model.PropertyGroup{} accessControlGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2} @@ -218,6 +221,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { mockStore.On("PropertyGroup").Return(&propertyGroupStore) mockStore.On("PropertyField").Return(&propertyFieldStore) mockStore.On("PropertyValue").Return(&propertyValueStore) + mockStore.On("ChannelGuard").Return(&channelGuardStore) return &mockStore } diff --git a/server/channels/web/params.go b/server/channels/web/params.go index b57cbf6a2a3..1bd741bde3d 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -129,6 +129,9 @@ type Params struct { GroupName string ObjectType string TargetId string + + // Channel join requests + RequestId string } var getChannelMembersForUserRegex = regexp.MustCompile("/api/v4/users/[A-Za-z0-9]{26}/channel_members") @@ -205,6 +208,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.GroupName = props["group_name"] params.ObjectType = props["object_type"] params.TargetId = props["target_id"] + params.RequestId = props["request_id"] params.Scope = query.Get("scope") if val, err := strconv.Atoi(query.Get("page")); err != nil || (val < 0 && params.UserId == "" && !getChannelMembersForUserRegex.MatchString(r.URL.Path)) { diff --git a/server/cmd/mattermost/commands/db_ping.go b/server/cmd/mattermost/commands/db_ping.go new file mode 100644 index 00000000000..80729a24e40 --- /dev/null +++ b/server/cmd/mattermost/commands/db_ping.go @@ -0,0 +1,181 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "context" + dbsql "database/sql" + stdErrors "errors" + "net/url" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/config" +) + +const ( + dbPingDefaultTimeout = 5 * time.Minute + dbPingDefaultRetryInterval = 2 * time.Second + // dbPingAttemptTimeout caps a single PingContext call so a hung connection + // doesn't block the whole timeout budget on one attempt. + dbPingAttemptTimeout = 10 * time.Second +) + +var DBPingCmd = &cobra.Command{ + Use: "ping", + Short: "Wait for the database to become reachable", + Long: `Pings the configured Mattermost database, retrying until --timeout expires. +Exits 0 once the database accepts a ping. Exits non-zero on timeout or fatal error. + +Intended for use as a readiness probe (e.g. a Kubernetes init container). +Resolves the DSN exactly like 'mattermost db migrate' / 'mattermost db init': +the --config flag, then MM_CONFIG, then config.json (which is then loaded as +a config store and SqlSettings.DataSource is used).`, + Example: ` # Database DSN passed via --config (preferred for readiness probes) + $ mattermost db ping --config postgres://mmuser:mostest@localhost/mattermost --timeout 2m + + # Or via MM_CONFIG + $ MM_CONFIG=postgres://localhost/mattermost mattermost db ping`, + Args: cobra.NoArgs, + RunE: dbPingCmdF, +} + +func init() { + DBPingCmd.Flags().Duration("timeout", dbPingDefaultTimeout, + "Maximum total time to wait for the DB to become reachable.") + DBPingCmd.Flags().Duration("retry-interval", dbPingDefaultRetryInterval, + "Sleep between ping attempts.") + DbCmd.AddCommand(DBPingCmd) +} + +func dbPingCmdF(command *cobra.Command, _ []string) error { + logger := mlog.CreateConsoleLogger() + defer func() { + _ = logger.Shutdown() + }() + + timeout, _ := command.Flags().GetDuration("timeout") + retryInterval, _ := command.Flags().GetDuration("retry-interval") + if timeout <= 0 { + return errors.New("--timeout must be > 0") + } + if retryInterval <= 0 { + return errors.New("--retry-interval must be > 0") + } + + dsn, err := resolvePingDataSource(command) + if err != nil { + return err + } + + sanitized, err := sanitizePingDataSource(dsn) + if err != nil { + return err + } + + db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn) + if err != nil { + return errors.Wrap(err, "failed to open SQL connection") + } + defer db.Close() + + // Minimal pool — this is a one-shot readiness probe. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + ctx, cancel := context.WithTimeout(command.Context(), timeout) + defer cancel() + + return pingWithRetry(ctx, db, retryInterval, logger.With( + mlog.String("dataSource", sanitized), + )) +} + +func sanitizePingDataSource(dsn string) (string, error) { + sanitized, err := model.SanitizeDataSource(model.DatabaseDriverPostgres, dsn) + if err != nil { + return "", safeDataSourceSanitizationError(err) + } + + return sanitized, nil +} + +func safeDataSourceSanitizationError(err error) error { + var urlErr *url.Error + if stdErrors.As(err, &urlErr) { + if urlErr.Err != nil { + return errors.Errorf("invalid database DSN: %v", urlErr.Err) + } + return errors.New("invalid database DSN") + } + + return errors.New("invalid database DSN") +} + +// resolvePingDataSource returns a postgres DSN to ping. +// +// If the configured DSN is a postgres:// / postgresql:// URL it is returned as-is +// (fast path: no config store load required). Otherwise it is treated as a file +// path: a config.Store is loaded read-only (createFileIfNotExist=false so the +// command never has a side effect of creating a config file) and +// SqlSettings.DataSource is returned. +func resolvePingDataSource(command *cobra.Command) (string, error) { + cfgDSN := getConfigDSN(command, config.GetEnvironment()) + + if config.IsDatabaseDSN(cfgDSN) { + return cfgDSN, nil + } + + cfgStore, err := config.NewStoreFromDSN(cfgDSN, true /*readOnly*/, nil /*customDefaults*/, false /*createFileIfNotExist*/) + if err != nil { + return "", errors.Wrapf(err, "failed to load configuration from %q", cfgDSN) + } + defer cfgStore.Close() + + sqlSettings := cfgStore.Get().SqlSettings + if sqlSettings.DataSource == nil || *sqlSettings.DataSource == "" { + return "", errors.New("no database DSN configured: set --config or MM_CONFIG to a postgres:// URL, or ensure SqlSettings.DataSource is set in your configuration") + } + if !config.IsDatabaseDSN(*sqlSettings.DataSource) { + // Defensive: the loaded config has a non-postgres DataSource. Mattermost is postgres-only. + return "", errors.New("configured SqlSettings.DataSource is not a postgres DSN") + } + return *sqlSettings.DataSource, nil +} + +// pingWithRetry pings db every retryInterval until it succeeds or ctx is done. +// Each individual PingContext call is capped at dbPingAttemptTimeout so a hung +// network connection cannot consume the entire timeout budget on a single try. +func pingWithRetry(ctx context.Context, db *dbsql.DB, retryInterval time.Duration, logger mlog.LoggerIFace) error { + attempt := 0 + for { + attempt++ + attemptCtx, cancel := context.WithTimeout(ctx, dbPingAttemptTimeout) + err := db.PingContext(attemptCtx) + cancel() + if err == nil { + logger.Info("Database is reachable", mlog.Int("attempt", attempt)) + return nil + } + + // Surface progress on every attempt so operators can see the probe is alive. + // Intentionally omit the raw error: lib/pq error strings can echo DSN fragments. + logger.Info("Waiting for database", + mlog.Int("attempt", attempt), + mlog.Duration("retry_interval", retryInterval), + mlog.String("status", "ping_failed"), + ) + + // Wait retryInterval, but bail early if ctx is done. + select { + case <-ctx.Done(): + return errors.Wrapf(ctx.Err(), "timed out waiting for database after %d attempts", attempt) + case <-time.After(retryInterval): + } + } +} diff --git a/server/cmd/mattermost/commands/db_ping_test.go b/server/cmd/mattermost/commands/db_ping_test.go new file mode 100644 index 00000000000..005dfc343d2 --- /dev/null +++ b/server/cmd/mattermost/commands/db_ping_test.go @@ -0,0 +1,322 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package commands + +import ( + "context" + dbsql "database/sql" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +// dsnFromHelper builds a postgres:// DSN from the test main helper's SqlSettings. +// The helper itself stores the live test postgres DSN. +func dsnFromHelper(t *testing.T) string { + t.Helper() + require.NotNil(t, mainHelper, "mainHelper must be initialized; do not run with -short") + settings := mainHelper.GetSQLSettings() + require.NotNil(t, settings.DataSource) + require.NotEmpty(t, *settings.DataSource) + return *settings.DataSource +} + +// --- subprocess (CLI integration) tests --- + +func TestDBPingHappyPath(t *testing.T) { + if testing.Short() { + t.Skip("requires live test database") + } + + th := SetupWithStoreMock(t) + output := th.CheckCommand(t, "db", "ping", "--timeout", "30s") + require.Contains(t, output, "Database is reachable", + "expected success log line in command output, got: %s", output) +} + +func TestDBPingDirectDSN(t *testing.T) { + if testing.Short() { + t.Skip("requires live test database") + } + + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + dsn := dsnFromHelper(t) + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", "--timeout", "30s") + require.NoError(t, err, "command should succeed when DSN is direct postgres URL; output: %s", output) + require.Contains(t, output, "Database is reachable") +} + +func TestDBPingTimeoutOnUnreachableDB(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + // Loopback to a port nothing listens on; connect_timeout=1 keeps each + // attempt short. We allow 2s total with 500ms between attempts so we get + // multiple "Waiting for database" lines. + dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1" + + start := time.Now() + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "2s", "--retry-interval", "500ms") + elapsed := time.Since(start) + + require.Error(t, err, "command should fail on unreachable DB; output: %s", output) + require.Contains(t, output, "timed out waiting for database", + "expected timeout message in output, got: %s", output) + require.LessOrEqual(t, elapsed, 30*time.Second, + "command should not exceed a generous upper bound; took %s", elapsed) +} + +func TestDBPingInvalidDSN(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + // Passes IsDatabaseDSN (postgres:// prefix) so it takes the direct path. + dsn := "postgres://leakyuser:supersecret@[invalid" + + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "2s", "--retry-interval", "500ms") + require.Error(t, err, "command should fail on malformed DSN; output: %s", output) + require.Contains(t, output, "invalid database DSN", + "expected sanitized DSN parse error; got: %s", output) + require.Contains(t, output, "missing ']' in host", + "expected malformed DSN reason; got: %s", output) + require.NotContains(t, output, "supersecret", + "malformed DSN errors must not leak credentials; got: %s", output) + require.NotContains(t, output, "leakyuser", + "malformed DSN errors must not leak credentials; got: %s", output) +} + +func TestDBPingMissingConfigFile(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + // Point --config at a path that does not exist; createFileIfNotExist=false + // inside resolvePingDataSource means NewStoreFromDSN will return an error. + missing := th.TemporaryDirectory() + "/does-not-exist.json" + + output, err := th.RunCommandWithOutput(t, "--config", missing, "db", "ping", + "--timeout", "2s", "--retry-interval", "500ms") + require.Error(t, err, "command should fail when --config file does not exist; output: %s", output) + require.Contains(t, output, "failed to load configuration", + "expected config-load error message; got: %s", output) +} + +func TestDBPingFlagValidation(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + dsn := "postgres://localhost:1/mattermost?sslmode=disable&connect_timeout=1" + + t.Run("zero timeout", func(t *testing.T) { + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "0s") + require.Error(t, err) + require.Contains(t, output, "--timeout must be > 0", + "expected timeout validation error; got: %s", output) + }) + + t.Run("zero retry interval", func(t *testing.T) { + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "1s", "--retry-interval", "0s") + require.Error(t, err) + require.Contains(t, output, "--retry-interval must be > 0", + "expected retry-interval validation error; got: %s", output) + }) + + t.Run("negative timeout", func(t *testing.T) { + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "-1s") + require.Error(t, err) + require.Contains(t, output, "--timeout must be > 0", + "expected timeout validation error for negative value; got: %s", output) + }) + + t.Run("garbage timeout value", func(t *testing.T) { + // cobra refuses to parse "garbage" as a duration; subcommand never runs. + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "garbage") + require.Error(t, err) + // Don't pin to exact text — cobra owns the error string here. Just + // confirm the subcommand's success log is absent. + require.NotContains(t, output, "Database is reachable", + "command should not have run successfully; got: %s", output) + }) +} + +func TestDBPingRetryIntervalHonored(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1" + + start := time.Now() + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "2s", "--retry-interval", "500ms") + elapsed := time.Since(start) + + require.Error(t, err) + // Loose lower bound: at 500ms intervals we expect at least 2 retries + // (~1s wall clock minimum) before the 2s timeout strikes. + require.GreaterOrEqual(t, elapsed, 1*time.Second, + "expected retries to span at least 1s; got %s", elapsed) + // Loose upper bound: don't exceed several multiples of the configured + // timeout — accommodates CI variance. + require.LessOrEqual(t, elapsed, 30*time.Second, + "expected command to bail close to --timeout; took %s", elapsed) + + waitingCount := strings.Count(output, "Waiting for database") + require.GreaterOrEqual(t, waitingCount, 2, + "expected at least 2 'Waiting for database' lines; got %d in output:\n%s", + waitingCount, output) +} + +// TestDBPingShortRetryIntervalProducesMoreAttempts verifies that the +// --retry-interval flag actually controls the cadence (not just the timeout). +func TestDBPingShortRetryIntervalProducesMoreAttempts(t *testing.T) { + th := SetupWithStoreMock(t) + th.SetAutoConfig(false) + + dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1" + + output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", + "--timeout", "3s", "--retry-interval", "200ms") + require.Error(t, err) + + waitingCount := strings.Count(output, "Waiting for database") + // At 200ms intervals over 3s we expect well more than 3 attempts even + // accounting for per-attempt connection overhead. + require.GreaterOrEqual(t, waitingCount, 3, + "expected several retries with short interval; got %d in output:\n%s", + waitingCount, output) +} + +// TestDBPingCmdRegistered confirms the new subcommand is wired into the +// existing DbCmd group, so users actually get `mattermost db ping`. +func TestDBPingCmdRegistered(t *testing.T) { + require.Contains(t, DbCmd.Commands(), DBPingCmd, + "DBPingCmd should be registered as a subcommand of DbCmd") + require.Equal(t, "ping", DBPingCmd.Use) + + // Flags exist with sensible defaults. + timeoutFlag := DBPingCmd.Flags().Lookup("timeout") + require.NotNil(t, timeoutFlag) + require.Equal(t, dbPingDefaultTimeout.String(), timeoutFlag.DefValue) + + intervalFlag := DBPingCmd.Flags().Lookup("retry-interval") + require.NotNil(t, intervalFlag) + require.Equal(t, dbPingDefaultRetryInterval.String(), intervalFlag.DefValue) +} + +// --- in-process tests of pingWithRetry / resolvePingDataSource --- + +func TestPingWithRetry_SuccessOnFirstAttempt(t *testing.T) { + if testing.Short() { + t.Skip("requires live test database") + } + + dsn := dsnFromHelper(t) + db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn) + require.NoError(t, err) + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + logger := mlog.CreateConsoleTestLogger(t) + err = pingWithRetry(ctx, db, 100*time.Millisecond, logger) + require.NoError(t, err) +} + +func TestPingWithRetry_TimeoutAgainstUnreachable(t *testing.T) { + dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1" + db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn) + require.NoError(t, err) + defer db.Close() + + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + + logger := mlog.CreateConsoleTestLogger(t) + err = pingWithRetry(ctx, db, 200*time.Millisecond, logger) + elapsed := time.Since(start) + + require.Error(t, err) + require.True(t, + errors.Is(err, context.DeadlineExceeded) || + strings.Contains(err.Error(), "timed out waiting for database"), + "expected deadline-exceeded or timeout error; got %v", err) + // Must have honored the timeout, not just returned immediately. + require.LessOrEqual(t, elapsed, 30*time.Second, + "expected reasonable upper bound; took %s", elapsed) +} + +func TestPingWithRetry_ContextCancelImmediately(t *testing.T) { + dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1" + db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn) + require.NoError(t, err) + defer db.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before we even start + + logger := mlog.CreateConsoleTestLogger(t) + err = pingWithRetry(ctx, db, 1*time.Second, logger) + require.Error(t, err, "cancelled context should produce an error") +} + +// In-process tests of resolvePingDataSource. We drive DSN selection via the +// MM_CONFIG environment variable rather than the --config persistent flag +// because the persistent flag is only merged into a subcommand's local +// flagset during cobra's Execute() pipeline; calling resolvePingDataSource +// directly outside Execute means the flag would not be visible. +// MM_CONFIG is consumed by getConfigDSN as the second-precedence source. + +func TestResolvePingDataSource_DirectDSN(t *testing.T) { + wanted := "postgres://user:pw@example.invalid:5432/mm?sslmode=disable" + t.Setenv("MM_CONFIG", wanted) + + got, err := resolvePingDataSource(DBPingCmd) + require.NoError(t, err) + require.Equal(t, wanted, got) +} + +func TestResolvePingDataSource_DirectDSN_PostgresqlScheme(t *testing.T) { + wanted := "postgresql://user:pw@example.invalid:5432/mm?sslmode=disable" + t.Setenv("MM_CONFIG", wanted) + + got, err := resolvePingDataSource(DBPingCmd) + require.NoError(t, err) + require.Equal(t, wanted, got) +} + +func TestResolvePingDataSource_MissingFile(t *testing.T) { + missing := t.TempDir() + "/no-such-config.json" + t.Setenv("MM_CONFIG", missing) + + _, err := resolvePingDataSource(DBPingCmd) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to load configuration") +} + +// TestResolvePingDataSource_PointsAtDirectory verifies that pointing --config +// at a directory (not a JSON file) surfaces a clear, wrapped error. Catches +// regressions where we silently fall through instead of returning the load error. +func TestResolvePingDataSource_PointsAtDirectory(t *testing.T) { + dir := t.TempDir() + t.Setenv("MM_CONFIG", dir) + + _, err := resolvePingDataSource(DBPingCmd) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to load configuration", + "expected wrapped error; got %v", err) +} diff --git a/server/einterfaces/mocks/AccessControlServiceInterface.go b/server/einterfaces/mocks/AccessControlServiceInterface.go index a0fc3f92654..ce2f8488dbb 100644 --- a/server/einterfaces/mocks/AccessControlServiceInterface.go +++ b/server/einterfaces/mocks/AccessControlServiceInterface.go @@ -419,6 +419,38 @@ func (_m *AccessControlServiceInterface) SavePolicy(rctx request.CTX, policy *mo return r0, r1 } +// SimulatePolicyForUsers provides a mock function with given fields: rctx, params +func (_m *AccessControlServiceInterface) SimulatePolicyForUsers(rctx request.CTX, params model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError) { + ret := _m.Called(rctx, params) + + if len(ret) == 0 { + panic("no return value specified for SimulatePolicyForUsers") + } + + var r0 *model.PolicySimulationResponse + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError)); ok { + return rf(rctx, params) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.PolicySimulationByUsersParams) *model.PolicySimulationResponse); ok { + r0 = rf(rctx, params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PolicySimulationResponse) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.PolicySimulationByUsersParams) *model.AppError); ok { + r1 = rf(rctx, params) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // NewAccessControlServiceInterface creates a new instance of AccessControlServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewAccessControlServiceInterface(t interface { diff --git a/server/einterfaces/mocks/AccessControlSyncJobInterface.go b/server/einterfaces/mocks/AccessControlSyncJobInterface.go index e4b82fc3f56..799df2fe44c 100644 --- a/server/einterfaces/mocks/AccessControlSyncJobInterface.go +++ b/server/einterfaces/mocks/AccessControlSyncJobInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // AccessControlSyncJobInterface is an autogenerated mock type for the AccessControlSyncJobInterface type diff --git a/server/einterfaces/mocks/AutoTranslationInterface.go b/server/einterfaces/mocks/AutoTranslationInterface.go index 7cedb088561..e1c7087dabf 100644 --- a/server/einterfaces/mocks/AutoTranslationInterface.go +++ b/server/einterfaces/mocks/AutoTranslationInterface.go @@ -7,11 +7,9 @@ package mocks import ( context "context" - jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" - - mock "github.com/stretchr/testify/mock" - model "github.com/mattermost/mattermost/server/public/model" + jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" + mock "github.com/stretchr/testify/mock" ) // AutoTranslationInterface is an autogenerated mock type for the AutoTranslationInterface type diff --git a/server/einterfaces/mocks/CloudJobInterface.go b/server/einterfaces/mocks/CloudJobInterface.go index ebe5a5efc7e..d0c35f15aba 100644 --- a/server/einterfaces/mocks/CloudJobInterface.go +++ b/server/einterfaces/mocks/CloudJobInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // CloudJobInterface is an autogenerated mock type for the CloudJobInterface type diff --git a/server/einterfaces/mocks/ClusterInterface.go b/server/einterfaces/mocks/ClusterInterface.go index 3a0ef3df5f2..f8aa96d40a1 100644 --- a/server/einterfaces/mocks/ClusterInterface.go +++ b/server/einterfaces/mocks/ClusterInterface.go @@ -5,12 +5,10 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" - - request "github.com/mattermost/mattermost/server/public/shared/request" ) // ClusterInterface is an autogenerated mock type for the ClusterInterface type diff --git a/server/einterfaces/mocks/DataRetentionJobInterface.go b/server/einterfaces/mocks/DataRetentionJobInterface.go index 0876690eaa4..962a7bfbb09 100644 --- a/server/einterfaces/mocks/DataRetentionJobInterface.go +++ b/server/einterfaces/mocks/DataRetentionJobInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // DataRetentionJobInterface is an autogenerated mock type for the DataRetentionJobInterface type diff --git a/server/einterfaces/mocks/ElasticsearchAggregatorInterface.go b/server/einterfaces/mocks/ElasticsearchAggregatorInterface.go index b33cef03908..dd8774b6e85 100644 --- a/server/einterfaces/mocks/ElasticsearchAggregatorInterface.go +++ b/server/einterfaces/mocks/ElasticsearchAggregatorInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // ElasticsearchAggregatorInterface is an autogenerated mock type for the ElasticsearchAggregatorInterface type diff --git a/server/einterfaces/mocks/LdapSyncInterface.go b/server/einterfaces/mocks/LdapSyncInterface.go index 7582f6050db..ad2537af01d 100644 --- a/server/einterfaces/mocks/LdapSyncInterface.go +++ b/server/einterfaces/mocks/LdapSyncInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // LdapSyncInterface is an autogenerated mock type for the LdapSyncInterface type diff --git a/server/einterfaces/mocks/MessageExportJobInterface.go b/server/einterfaces/mocks/MessageExportJobInterface.go index 7c52631fca2..d12b733292c 100644 --- a/server/einterfaces/mocks/MessageExportJobInterface.go +++ b/server/einterfaces/mocks/MessageExportJobInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // MessageExportJobInterface is an autogenerated mock type for the MessageExportJobInterface type diff --git a/server/einterfaces/mocks/MetricsInterface.go b/server/einterfaces/mocks/MetricsInterface.go index 610883b1161..fb6bbda4080 100644 --- a/server/einterfaces/mocks/MetricsInterface.go +++ b/server/einterfaces/mocks/MetricsInterface.go @@ -5,12 +5,11 @@ package mocks import ( - logr "github.com/mattermost/logr/v2" - mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" - sql "database/sql" + + logr "github.com/mattermost/logr/v2" + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" ) // MetricsInterface is an autogenerated mock type for the MetricsInterface type diff --git a/server/einterfaces/mocks/OAuthProvider.go b/server/einterfaces/mocks/OAuthProvider.go index 869a703de89..4c6766adac2 100644 --- a/server/einterfaces/mocks/OAuthProvider.go +++ b/server/einterfaces/mocks/OAuthProvider.go @@ -8,9 +8,8 @@ import ( io "io" model "github.com/mattermost/mattermost/server/public/model" - mock "github.com/stretchr/testify/mock" - request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" ) // OAuthProvider is an autogenerated mock type for the OAuthProvider type diff --git a/server/einterfaces/mocks/PolicyAdministrationPointInterface.go b/server/einterfaces/mocks/PolicyAdministrationPointInterface.go index 7f018aa28b8..6fc0869cc94 100644 --- a/server/einterfaces/mocks/PolicyAdministrationPointInterface.go +++ b/server/einterfaces/mocks/PolicyAdministrationPointInterface.go @@ -389,6 +389,38 @@ func (_m *PolicyAdministrationPointInterface) SavePolicy(rctx request.CTX, polic return r0, r1 } +// SimulatePolicyForUsers provides a mock function with given fields: rctx, params +func (_m *PolicyAdministrationPointInterface) SimulatePolicyForUsers(rctx request.CTX, params model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError) { + ret := _m.Called(rctx, params) + + if len(ret) == 0 { + panic("no return value specified for SimulatePolicyForUsers") + } + + var r0 *model.PolicySimulationResponse + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError)); ok { + return rf(rctx, params) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.PolicySimulationByUsersParams) *model.PolicySimulationResponse); ok { + r0 = rf(rctx, params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PolicySimulationResponse) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.PolicySimulationByUsersParams) *model.AppError); ok { + r1 = rf(rctx, params) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // NewPolicyAdministrationPointInterface creates a new instance of PolicyAdministrationPointInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewPolicyAdministrationPointInterface(t interface { diff --git a/server/einterfaces/mocks/PushProxyInterface.go b/server/einterfaces/mocks/PushProxyInterface.go index d35fe540313..09660c51ac9 100644 --- a/server/einterfaces/mocks/PushProxyInterface.go +++ b/server/einterfaces/mocks/PushProxyInterface.go @@ -5,10 +5,9 @@ package mocks import ( + model "github.com/mattermost/mattermost/server/public/model" jobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" ) // PushProxyInterface is an autogenerated mock type for the PushProxyInterface type diff --git a/server/einterfaces/mocks/SamlDiagnosticInterface.go b/server/einterfaces/mocks/SamlDiagnosticInterface.go new file mode 100644 index 00000000000..2e150ae2b1a --- /dev/null +++ b/server/einterfaces/mocks/SamlDiagnosticInterface.go @@ -0,0 +1,48 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// SamlDiagnosticInterface is an autogenerated mock type for the SamlDiagnosticInterface type +type SamlDiagnosticInterface struct { + mock.Mock +} + +// RunSupportPacketTest provides a mock function with given fields: rctx, settings +func (_m *SamlDiagnosticInterface) RunSupportPacketTest(rctx request.CTX, settings model.SamlSettings) error { + ret := _m.Called(rctx, settings) + + if len(ret) == 0 { + panic("no return value specified for RunSupportPacketTest") + } + + var r0 error + if rf, ok := ret.Get(0).(func(request.CTX, model.SamlSettings) error); ok { + r0 = rf(rctx, settings) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewSamlDiagnosticInterface creates a new instance of SamlDiagnosticInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSamlDiagnosticInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *SamlDiagnosticInterface { + mock := &SamlDiagnosticInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/SamlInterface.go b/server/einterfaces/mocks/SamlInterface.go index 6066bc3cc6f..bedec7f46ce 100644 --- a/server/einterfaces/mocks/SamlInterface.go +++ b/server/einterfaces/mocks/SamlInterface.go @@ -5,11 +5,10 @@ package mocks import ( + saml2 "github.com/mattermost/gosaml2" model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" - - saml2 "github.com/mattermost/gosaml2" ) // SamlInterface is an autogenerated mock type for the SamlInterface type diff --git a/server/einterfaces/mocks/Scheduler.go b/server/einterfaces/mocks/Scheduler.go index 88024464dae..f459db138ca 100644 --- a/server/einterfaces/mocks/Scheduler.go +++ b/server/einterfaces/mocks/Scheduler.go @@ -5,11 +5,11 @@ package mocks import ( + time "time" + model "github.com/mattermost/mattermost/server/public/model" request "github.com/mattermost/mattermost/server/public/shared/request" mock "github.com/stretchr/testify/mock" - - time "time" ) // Scheduler is an autogenerated mock type for the Scheduler type diff --git a/server/einterfaces/pap.go b/server/einterfaces/pap.go index f0af4673081..024ae15ccc9 100644 --- a/server/einterfaces/pap.go +++ b/server/einterfaces/pap.go @@ -42,4 +42,11 @@ type PolicyAdministrationPointInterface interface { // GetPoliciesForFieldIDs returns the policies that reference any of the given // property field IDs in their CEL rule expressions. GetPoliciesForFieldIDs(rctx request.CTX, fieldIDs []string) ([]*model.AccessControlPolicy, *model.AppError) + // SimulatePolicyForUsers evaluates a DRAFT policy against an explicit + // user list (with optional per-user session attribute overrides) and + // returns per-user, per-action ALLOW/DENY decisions plus blame + // attribution. The draft is compiled in-memory only; nothing is + // persisted. Backs the picker-based "Simulate access" UX in the + // System Console and Channel Settings. + SimulatePolicyForUsers(rctx request.CTX, params model.PolicySimulationByUsersParams) (*model.PolicySimulationResponse, *model.AppError) } diff --git a/server/einterfaces/saml_diagnostic.go b/server/einterfaces/saml_diagnostic.go new file mode 100644 index 00000000000..735a8cbc1a7 --- /dev/null +++ b/server/einterfaces/saml_diagnostic.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +type SamlDiagnosticInterface interface { + RunSupportPacketTest(rctx request.CTX, settings model.SamlSettings) error +} diff --git a/server/enterprise/elasticsearch/opensearch/opensearch.go b/server/enterprise/elasticsearch/opensearch/opensearch.go index 8932244657e..cad70b036c9 100644 --- a/server/enterprise/elasticsearch/opensearch/opensearch.go +++ b/server/enterprise/elasticsearch/opensearch/opensearch.go @@ -34,7 +34,7 @@ import ( "github.com/opensearch-project/opensearch-go/v4/opensearchapi" ) -const opensearchMaxVersion = 2 +const opensearchMaxVersion = 3 var ( purgeIndexListAllowedIndexes = []string{common.IndexBaseChannels} diff --git a/server/go.mod b/server/go.mod index c9a998b1530..8e99d8f4073 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,27 +1,27 @@ module github.com/mattermost/mattermost/server/v8 -go 1.26.2 +go 1.26.3 require ( code.sajari.com/docconv/v2 v2.0.0-pre.4 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc - github.com/aws/aws-sdk-go-v2 v1.41.5 - github.com/aws/aws-sdk-go-v2/config v1.32.13 - github.com/aws/aws-sdk-go-v2/credentials v1.19.13 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 - github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.3 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 + github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.5 github.com/bep/imagemeta v0.12.0 github.com/blang/semver/v4 v4.0.0 github.com/boxes-ltd/imaging v1.7.5 github.com/cespare/xxhash/v2 v2.3.0 github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a - github.com/elastic/go-elasticsearch/v8 v8.19.3 + github.com/elastic/go-elasticsearch/v8 v8.19.6 github.com/fatih/color v1.19.0 - github.com/getsentry/sentry-go v0.44.1 + github.com/getsentry/sentry-go v0.46.2 github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -39,27 +39,27 @@ require ( github.com/icrowley/fake v0.0.0-20240710202011-f797eb4a99c0 github.com/isacikgoz/prompt v0.1.0 github.com/jmoiron/sqlx v1.4.0 - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.6 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 - github.com/lib/pq v1.12.0 + github.com/lib/pq v1.12.3 github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 github.com/mattermost/gosaml2 v0.10.0 github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 github.com/mattermost/logr/v2 v2.0.22 github.com/mattermost/mattermost-plugin-ai v1.14.0 - github.com/mattermost/mattermost/server/public v0.3.0 + github.com/mattermost/mattermost/server/public v0.4.0 github.com/mattermost/morph v1.1.0 github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 github.com/mattermost/squirrel v0.5.0 github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/minio/minio-go/v7 v7.0.99 + github.com/minio/minio-go/v7 v7.1.0 github.com/opensearch-project/opensearch-go/v4 v4.6.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 - github.com/redis/rueidis v1.0.73 + github.com/redis/rueidis v1.0.75 github.com/reflog/dateconstraints v0.2.1 github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.4 @@ -68,19 +68,19 @@ require ( github.com/splitio/go-client/v6 v6.10.0 github.com/stretchr/testify v1.11.1 github.com/throttled/throttled/v2 v2.15.0 - github.com/tinylib/msgp v1.6.3 + github.com/tinylib/msgp v1.6.4 github.com/tylerb/graceful v1.2.15 github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/wiggin77/merror v1.0.5 github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c github.com/yuin/goldmark v1.8.2 - golang.org/x/crypto v0.50.0 - golang.org/x/image v0.38.0 - golang.org/x/net v0.53.0 + golang.org/x/crypto v0.51.0 + golang.org/x/image v0.40.0 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 + golang.org/x/sys v0.44.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 gopkg.in/mail.v2 v2.3.1 ) @@ -91,61 +91,60 @@ require ( github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/advancedlogic/GoOse v0.0.0-20231203033844-ae6b36caf275 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/armon/go-metrics v0.4.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beevik/etree v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bloom/v3 v3.7.1 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.6.1 // indirect + github.com/bodgit/sevenzip v1.6.2 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/corpix/uarand v0.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect + github.com/elastic/elastic-transport-go/v8 v8.11.0 // indirect github.com/fatih/set v0.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/francoispqt/gojay v1.2.13 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect - github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -162,31 +161,31 @@ require ( github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/miekg/dns v1.1.72 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minlz v1.1.0 // indirect + github.com/minio/minlz v1.1.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/oklog/run v1.2.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/errors v1.3.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/otiai10/gosseract/v2 v2.4.1 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect @@ -207,28 +206,29 @@ require ( github.com/ulikunitz/xz v0.5.15 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/srslog v1.0.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/grpc v1.81.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.70.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.48.0 // indirect + modernc.org/sqlite v1.50.1 // indirect ) // See MM-66167, MM-68222 for more details. diff --git a/server/go.sum b/server/go.sum index 117aa7c1369..1393f6f20b2 100644 --- a/server/go.sum +++ b/server/go.sum @@ -28,8 +28,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052 h1:8T2zMbhLBbH9514PIQVHdsGhypMrsB4CxwbldKA9sBA= github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052/go.mod h1:0SURuH1rsE8aVWvutuMZghRNrNrYEUzibzJfhEYR8L0= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/PuerkitoBio/goquery v1.4.1/go.mod h1:T9ezsOHcCrDCgA8aF1Cqr3sSYbO/xgdy8/R/XiIMAhA= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= @@ -42,8 +42,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= @@ -55,36 +55,36 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc h1:LwSuf3dfZvA9GdPSWa3XlDG6lHGBoqlyChxH9INKu2o= github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc/go.mod h1:s+GCtuP4kZNxh1WGoqdWI1+PbluBcycrMMWuKQ9e5Nk= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= -github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.3 h1:8O2Bq20DZxHXks7fhg1oz28ZS7bo5CBFJfwubPpjk2w= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.3/go.mod h1:DCUaBLgkipwFmf1qzzQBs9fWdBc8e3nNlGR3DK5M628= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.5 h1:8wYfwgBKiuI3Bul4IGvWLIg1UzaWZyF53V2Itgdk28o= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.36.5/go.mod h1:61Jn5ZnR34wW70jQ4mtXlonyeJ+CcZv3piGjfFbIBBI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= @@ -105,8 +105,8 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= -github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= +github.com/bodgit/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34= +github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boxes-ltd/imaging v1.7.5 h1:k4kYxJEhysoGhEEN1IEeKoSbnG8/8snjj7M48Ok0fnk= @@ -142,8 +142,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 h1:AqeKSZIG/NIC75MNQlPy/LM3LxfpLwahICJBHwSMFNc= github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -152,10 +150,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= -github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs= -github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc= -github.com/elastic/go-elasticsearch/v8 v8.19.3 h1:5LDg0hfGJXBa9Y+2QlUgRTsNJ/7rm7oNidydtFAq0LI= -github.com/elastic/go-elasticsearch/v8 v8.19.3/go.mod h1:tHJQdInFa6abmDbDCEH2LJja07l/SIpaGpJcm13nt7s= +github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc= +github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8= +github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk= +github.com/elastic/go-elasticsearch/v8 v8.19.6/go.mod h1:jeWebApE1oFEW/hKZqx/IRYmP/aa2+WMJkOfk+AduSI= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -170,10 +168,10 @@ github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiD github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= +github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 h1:u8AQ9bPa9oC+8/A/jlWouakhIvkFfuxgIIRjiy8av7I= github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573/go.mod h1:eBvb3i++NHDH4Ugo9qCvMw8t0mTSctaEa5blJbWcNxs= @@ -200,8 +198,8 @@ github.com/go-resty/resty/v2 v2.0.0/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbR github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= @@ -251,12 +249,12 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -294,8 +292,8 @@ github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcX github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= -github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= @@ -341,8 +339,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -373,8 +371,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 h1:W7p+m/AECTL3s/YR5RpQ4hz5SjNeKzZBl1q36ws12s0= github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5/go.mod h1:QMe2wuKJ0o7zIVE8AqiT8rd8epmm6WDIZ2wyuBqYPzM= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= -github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= @@ -387,8 +385,8 @@ github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= github.com/mattermost/mattermost-plugin-ai v1.14.0 h1:Fba2ORHaMe0Dl0TfVtdC1eAQOkvgRzVz3v/B9V21d9w= github.com/mattermost/mattermost-plugin-ai v1.14.0/go.mod h1:L/I/IpdWNGbxRfUduCstCYbhyX59OEftxcpDHtCT4EI= -github.com/mattermost/mattermost/server/public v0.3.0 h1:AtzCjypbLcvSVQZMg0vKWL57vVfLSCC46j1nsOof2Ko= -github.com/mattermost/mattermost/server/public v0.3.0/go.mod h1:QnF/1Evlh7e3G8ifwut7Q5Joy/t4oHYNcDoyBTYuXho= +github.com/mattermost/mattermost/server/public v0.4.0 h1:DtD89e3zvuWXdAPr3MbyEk6+EA59FySGbnHQ8o0S92Q= +github.com/mattermost/mattermost/server/public v0.4.0/go.mod h1:VWtRZ9s/69vsoDCJ+4AHqB5VuaLk6gjS08NIuQgCAsk= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815 h1:uOi89NvrFmDngqMKjlLDxi+MNzJQLA3TqcU2p8czv34= @@ -406,12 +404,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -429,10 +427,10 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE= -github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= -github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ= -github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= +github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM= +github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -451,15 +449,15 @@ github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= -github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= +github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v0.0.0-20180506121414-d4647c9c7a84/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opensearch-project/opensearch-go/v4 v4.6.0 h1:Ac8aLtDSmLEyOmv0r1qhQLw3b4vcUhE42NE9k+Z4cRc= github.com/opensearch-project/opensearch-go/v4 v4.6.0/go.mod h1:3iZtb4SNt3IzaxavKq0dURh1AmtVgYW71E4XqmYnIiQ= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= @@ -474,8 +472,8 @@ github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtP github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= -github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= @@ -520,10 +518,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/redis/rueidis v1.0.73 h1:0Enrg0VuMdaYyNDDj0lLIheWY0uybCeQOh+jTp2GG3M= -github.com/redis/rueidis v1.0.73/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/rueidis v1.0.75 h1:zPlBDvjeMHaNCJT36U9ZKJNddMDXSti7OL2H7v2KYmo= +github.com/redis/rueidis v1.0.75/go.mod h1:UsfHPSbomB6QAVMk4iiFkzRy0nh9o7scDGa+SitvBY4= github.com/reflog/dateconstraints v0.2.1 h1:Hz1n2Q1vEm0Rj5gciDQcCN1iPBwfFjxUJy32NknGP/s= github.com/reflog/dateconstraints v0.2.1/go.mod h1:Ax8AxTBcJc3E/oVS2hd2j7RDM/5MDtuPwuR7lIHtPLo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -636,8 +634,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= @@ -664,21 +662,23 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -703,13 +703,13 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -719,8 +719,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -747,8 +747,8 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -799,15 +799,14 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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= @@ -818,8 +817,8 @@ golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -831,8 +830,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -848,14 +847,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -868,14 +867,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= 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= @@ -915,10 +914,10 @@ grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJd honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -927,18 +926,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= -modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/server/i18n/be.json b/server/i18n/be.json index aa932490fac..e3b06481a60 100644 --- a/server/i18n/be.json +++ b/server/i18n/be.json @@ -6311,10 +6311,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Немагчыма атрымаць каналы." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Немагчыма атрымаць колькасць каналаў." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Немагчыма атрымаць каналы для дадзенай схемы." @@ -6455,14 +6451,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "Немагчыма загрузіць файл. Няправільны ідэнтыфікатар канала: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Пераканайцеся, што ваш бакет Amazon S3 даступны, і праверце дазволы на доступ да яго." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Немагчыма падключыцца да S3. Праверце параметры аўтарызацыі і налады аўтэнтыфікацыі для падключэння да Amazon S3." - }, { "id": "api.file.test_connection.app_error", "translation": "Немагчыма атрымаць доступ да сховішча файлаў." @@ -9991,38 +9979,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Не атрымалася стварыць каталог для выяваў нестандартных эмодзі" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Немагчыма зарэгістраваць групу ўласцівасцей \"Атрыбуты карыстальніцкага профілю\"" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Немагчыма атрымаць поле \"Атрыбут карыстальніцкага профілю\"" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Дасягнуты ліміт поля \"Атрыбуты карыстальніцкага профілю\"" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Немагчыма атрымаць значэнні атрыбутаў карыстальніцкага профілю" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Немагчыма выдаліць поле \"Атрыбут карыстальніцкага профілю\"" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Поле \"Атрыбут карыстальніцкага профілю\" не знойдзена" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Немагчыма абнавіць поле \"Атрыбут карыстальніцкага профілю\"" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Немагчыма шукаць палі \"Атрыбуты карыстальніцкага профілю\"" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Не атрымалася выдаліць запытаныя файлы з базы дадзеных" @@ -10083,10 +10039,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Няправільнае значэнне ўласцівасці: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Ваша ліцэнзія не падтрымлівае атрыбуты карыстальніцкага профілю." - }, { "id": "api.command.execute_command.deleted.error", "translation": "Немагчыма выканаць каманду ў выдаленым канале." @@ -10119,14 +10071,6 @@ "id": "api.context.get_session.app_error", "translation": "Сеанс не знойдзены." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Немагчыма падлічыць колькасць палёў для групы атрыбутаў карыстальніцкага профілю" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Немагчыма дадаць/абнавіць палі атрыбутаў карыстальніцкага профілю" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Няправільны ідэнтыфікатар карыстальніка на баку кліента: {{.Id}}" @@ -10195,10 +10139,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Немагчыма пераўтварыць поле ўласцівасці ў поле атрыбута карыстальніцкага профілю" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Няправільныя атрыбуты значэнняў уласцівасцей: {{.AttributeName}} ({{.Reason}})." - }, { "id": "model.access_policy.is_valid.id.app_error", "translation": "Няправільны ідэнтыфікатар палітыкі." @@ -10243,14 +10183,6 @@ "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} павінен быць прэфіксам IndexPrefix {{.IndexPrefix}}." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Немагчыма выдаліць значэнні атрыбутаў карыстальніцкага профілю для карыстальніка" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Не атрымалася праверыць значэнне ўласцівасці" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Не атрымалася атрымаць палі CPA" @@ -10339,10 +10271,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "Адрас электроннай пошты для паведамлення пра праблему абавязковы." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Немагчыма абнавіць значэнне для сінхранізаванага поля атрыбута карыстальніцкага профілю" - }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "Ліміт на колькасць атрыманых каналаў няправільны." @@ -10647,10 +10575,6 @@ "id": "app.pap.access_control.insufficient_permissions", "translation": "У вас няма дазволу кіраваць гэтай палітыкай кантролю доступу." }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Немагчыма абнавіць значэнне для поля карыстальніцкага атрыбута профілю, кіраванага адміністратарам" - }, { "id": "model.config.is_valid.client_side_cert_enable.app_error", "translation": "Аўтэнтыфікацыя на аснове сертыфікатаў была выдалена. Каб працягнуць, адключыце ClientSideCertEnable." @@ -10839,10 +10763,6 @@ "id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error", "translation": "Карыстальнік, створаны ШІ, павінен быць стваральнікам паведамлення або ботам." }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Немагчыма абнавіць поле карыстальніцкага атрыбута профілю" - }, { "id": "app.post.rewrite.agent_call_failed", "translation": "Не атрымалася выклікаць ШІ-агента." diff --git a/server/i18n/bg.json b/server/i18n/bg.json index 469c20600aa..cd19ad3dff2 100644 --- a/server/i18n/bg.json +++ b/server/i18n/bg.json @@ -4233,10 +4233,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Каналите не могат да се получат." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Броят на каналите не може да бъде получен." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Каналите за предоставената схема не могат да се получат." @@ -5470,7 +5466,10 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} е вече в канала." + "translation": { + "one": "{{.User}} е вече в канала.", + "other": "" + } }, { "id": "api.command_invite.success", @@ -7735,14 +7734,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "Типовете задачи в задачата, която се опитвате да извлечете не съдържат права" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Уверете се, че Amazon S3 bucket е налично и проверете Вашите права в него." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Невъзможно свързване с S3. Проверете параметрите за удостоверяване на връзката с Amazon S3 и настройките за удостоверяване." - }, { "id": "api.error_set_first_admin_visit_marketplace_status", "translation": "Грешка при опит за запис в хранилището статуса първо администраторско посещение на пазара ." diff --git a/server/i18n/ca.json b/server/i18n/ca.json index df974e85d46..ed05100cb92 100644 --- a/server/i18n/ca.json +++ b/server/i18n/ca.json @@ -3597,7 +3597,11 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} ja està al canal." + "translation": { + "many": "", + "one": "{{.User}} ja està al canal.", + "other": "" + } }, { "id": "api.command_invite.success", diff --git a/server/i18n/cs.json b/server/i18n/cs.json index c3eab47fddf..9a6f2b00481 100644 --- a/server/i18n/cs.json +++ b/server/i18n/cs.json @@ -6359,10 +6359,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Nepodařilo se získat kanály pro daná id." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Nepodařilo se získat počet kanálů." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Nepodařilo se získat kanály pro dané schéma." @@ -7715,10 +7711,6 @@ "id": "api.drafts.disabled.app_error", "translation": "Funkce konceptů je zakázána." }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Nelze se připojit k S3. Zkontrolujte vaše autorizační parametry a nastavení autentifikace pro připojení k Amazon S3." - }, { "id": "api.file.test_connection_s3_settings_nil.app_error", "translation": "Nastavení úložiště souborů obsahuje nevyplněné hodnoty." @@ -7995,10 +7987,6 @@ "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", "translation": "Nepodařilo se získat přihlašovací údaje s uvedenou konfigurací připojení." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Ujistěte se, že váš Amazon S3 bucket je dostupný, a ověřte oprávnění k vašemu bucketu." - }, { "id": "api.custom_groups.license_error", "translation": "není licencováno pro vlastní skupiny" @@ -10015,38 +10003,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Nelze vytvořit adresář pro obrázky vlastních emoji" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Nepodařilo se obnovit přílohy souborů k příspěvku" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Nepodařilo se získat pole vlastního atributu profilu" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Byl dosažen limit polí vlastních atributů profilu" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Nepodařilo se získat hodnoty vlastních atributů profilu" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Nepodařilo se smazat pole vlastního atributu profilu" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Pole vlastního atributu profilu nebylo nalezeno" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Nepodařilo se aktualizovat pole vlastního atributu profilu" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Nepodařilo se vyhledat pole vlastních atributů profilu" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Nepodařilo se odstranit požadované soubory z databáze" @@ -10107,10 +10063,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Neplatná hodnota vlastnosti: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Vaše licence nepodporuje vlastní atributy profilu." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Nelze získat čtečku souborů ZIP." @@ -10147,14 +10099,6 @@ "id": "api.context.get_session.app_error", "translation": "Relace nebyla nalezena." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Nelze spočítat počet polí pro skupinu vlastního atributu profilu" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Nelze provést vložení nebo aktualizaci polí vlastního atributu profilu" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Neplatné ID uživatele na straně klienta: {{.Id}}" @@ -10223,22 +10167,10 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Nelze převést pole vlastnosti na pole vlastního atributu profilu" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Neplatné atributy hodnoty vlastnosti: {{.AttributeName}} ({{.Reason}})." - }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Nelze smazat hodnoty atributu vlastního profilu pro uživatele" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Nepodařilo se načíst pole CPA" }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Nepodařilo se ověřit hodnotu vlastnosti" - }, { "id": "ent.ldap.update_cpa.empty_attribute", "translation": "Prázdná hodnota atributu LDAP" @@ -10367,10 +10299,6 @@ "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} by měl být předponou IndexPrefix {{.IndexPrefix}}." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Nelze aktualizovat hodnotu pro synchronizované pole vlastního atributu profilu" - }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "Limit pro získání kanálů není platný." @@ -10463,10 +10391,6 @@ "id": "app.cloud.preview_modal_data_parse_error", "translation": "Nepodařilo se zpracovat data pro náhledové okno" }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Nelze upravit hodnotu pole Custom Profile spravovaného administrátorem" - }, { "id": "app.group.create_syncable_memberships.error", "translation": "Nelze vytvořit synchronizovaná členství." diff --git a/server/i18n/da.json b/server/i18n/da.json index e14f0c1c3cd..4aab70bb14d 100644 --- a/server/i18n/da.json +++ b/server/i18n/da.json @@ -1098,9 +1098,5 @@ { "id": "api.custom_status.set_custom_statuses.update.app_error", "translation": "Det lykkedes ikke at opdatere den brugerdefinerede status. Tilføj enten emoji eller brugerdefineret tekststatus eller begge dele." - }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Din licens understøtter ikke brugerdefinerede profilattributter." } ] diff --git a/server/i18n/de.json b/server/i18n/de.json index 49219dca284..bb4e360c507 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -6286,10 +6286,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Konnte die Kanäle nicht nach IDs abrufen." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Konnte die Anzahl der Kanäle nicht laden." - }, { "id": "app.team.update.updating.app_error", "translation": "Es trat ein Fehler beim Aktualisieren des Teams auf." @@ -6846,14 +6842,6 @@ "id": "api.getThreadsForUser.bad_params", "translation": "Bevor- und Danach-Parameter für getThreadsForUser schließen sich gegenseitig aus" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Stelle sicher, dass dein Amazon S3 Bucket verfügbar ist und prüfe deine Bucket Berechtigungen." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Kann nicht zu S3 verbinden. Prüfe deine Amazon S3 Autorisierungsparameter und Authentifizierungseinstellungen." - }, { "id": "api.file.file_reader.app_error", "translation": "Kann keinen Dateileser bekommen." @@ -10011,38 +9999,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Verzeichnis für benutzerdefinierte Emoji-Bilder kann nicht erstellt werden" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Die Eigenschaftsgruppe der benutzerdefinierten Profilattribute kann nicht abgerufen werden." - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Benutzerdefiniertes Profilattributfeld kann nicht abgerufen werden" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Feldgrenze für benutzerdefinierte Profilattribute erreicht" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Benutzerdefinierte Profilattributwerte können nicht abgerufen werden" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Benutzerdefiniertes Profilattributfeld kann nicht gelöscht werden" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Benutzerdefiniertes Profilattributfeld nicht gefunden" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Benutzerdefiniertes Profilattributfeld kann nicht aktualisiert werden" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Benutzerdefinierte Profilattributfelder können nicht durchsucht werden" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Die angeforderten Dateien konnten nicht aus der Datenbank entfernt werden" @@ -10103,10 +10059,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Ungültiger Eigenschaftswert: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Deine Lizenz unterstützt keine benutzerdefinierten Profilattribute." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Es ist nicht möglich, einen Zip-Datei-Leser zu bekommen." @@ -10143,14 +10095,6 @@ "id": "api.context.get_session.app_error", "translation": "Sitzung nicht gefunden." }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Benutzerdefinierte Profilattributfelder können nicht hochgeladen werden" - }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Die Anzahl der Felder für die Attributgruppe des benutzerdefinierten Profils kann nicht gezählt werden" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Ungültige Client Side User ID: {{.Id}}" @@ -10219,10 +10163,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Das Eigenschaftsfeld kann nicht in ein benutzerdefiniertes Profilattributfeld umgewandelt werden" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Ungültige Eigenschaftswertattribute : {{.AttributeName}} ({{.Reason}})." - }, { "id": "model.access_policy.is_valid.name.app_error", "translation": "Ungültiger Name für die Richtlinie." @@ -10267,18 +10207,10 @@ "id": "model.config.is_valid.elastic_search.empty_index_prefix.app_error", "translation": "IndexPrefix kann nicht leer sein, wenn GlobalSearchPrefix gesetzt ist." }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Validierung des Eigenschaftswertes fehlgeschlagen" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "CPA-Felder konnten nicht abgerufen werden" }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Benutzerdefinierte Profilattributwerte für den Benutzer können nicht gelöscht werden" - }, { "id": "ent.ldap.update_cpa.empty_attribute", "translation": "Leerer LDAP-Attributwert" @@ -10363,10 +10295,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "\"Melde ein Problem\"-Mail ist erforderlich." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Wert für ein synchronisiertes benutzerdefiniertes Profilattributfeld kann nicht aktualisiert werden" - }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "Das Get Channels Limit ist nicht gültig." @@ -10679,10 +10607,6 @@ "id": "model.config.is_valid.experimental_view_archived_channels.app_error", "translation": "Das Verstecken von archivierten Kanälen wird nicht mehr unterstützt. Mache diese Kanäle stattdessen privat und entferne Mitglieder." }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Wert für ein vom Administrator verwaltetes benutzerdefiniertes Profilattributfeld kann nicht aktualisiert werden" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "Bei der Dekodierung der JSON-Antwort von der interaktiven Dialogsuche ist ein Fehler aufgetreten." @@ -10871,10 +10795,6 @@ "id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error", "translation": "Der KI-generierte Nutzer muss entweder der Ersteller des Beitrags oder ein Bot sein." }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Benutzerdefiniertes Profilattributfeld kann nicht gepatcht werden" - }, { "id": "app.post.rewrite.agent_call_failed", "translation": "Der KI-Agent konnte nicht angerufen werden." diff --git a/server/i18n/el.json b/server/i18n/el.json index 26cf46ab5b5..a01790a0eff 100644 --- a/server/i18n/el.json +++ b/server/i18n/el.json @@ -669,7 +669,10 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "Ο χρήστης {{.User}} είναι ήδη στο κανάλι." + "translation": { + "one": "Ο χρήστης {{.User}} είναι ήδη στο κανάλι.", + "other": "" + } }, { "id": "api.command_invite.success", @@ -2296,14 +2299,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "Δεν είναι δυνατή η μεταφόρτωση του αρχείου. Εσφαλμένο αναγνωριστικό καναλιού: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Βεβαιωθείτε ότι ο Amazon S3 bucket είναι διαθέσιμος και επαληθεύστε τα δικαιώματα του." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Δεν είναι δυνατή η σύνδεση στο S3. Επαληθεύστε τις παραμέτρους εξουσιοδότησης σύνδεσης και τις ρυθμίσεις ελέγχου ταυτότητας για το Amazon S3." - }, { "id": "api.file.read_file.reading_local.app_error", "translation": "Παρουσιάστηκε σφάλμα ανάγνωσης από τον τοπικό χώρο αποθήκευσης αρχείων του διακομιστή." diff --git a/server/i18n/en-AU.json b/server/i18n/en-AU.json index 8bfb8691064..31853929ad6 100644 --- a/server/i18n/en-AU.json +++ b/server/i18n/en-AU.json @@ -4123,10 +4123,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Unable to get the channels." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Unable to get the channel counts." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Unable to get the channels for the provided scheme." @@ -5311,14 +5307,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "Unable to upload the file. Incorrect channel ID: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Ensure your Amazon S3 bucket is available, and verify your bucket permissions." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Unable to connect to S3. Verify your Amazon S3 connection authorisation parameters and authentication settings." - }, { "id": "api.file.test_connection.app_error", "translation": "Unable to access the file storage." @@ -10015,38 +10003,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Unable to create a directory for custom emoji images" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Cannot register Custom Profile Attributes property group" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Unable to get Custom Profile Attribute field" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Custom Profile Attributes field limit reached" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Unable to get custom profile attribute values" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Unable to delete Custom Profile Attribute field" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Custom Profile Attribute field not found" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Unable to update Custom Profile Attribute field" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Unable to search Custom Profile Attribute fields" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Failed to remove the requested files from database" @@ -10099,10 +10055,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Invalid property value: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Your licence does not support Custom Profile Attributes." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Unable to get a zip file reader." @@ -10143,10 +10095,6 @@ "id": "api.context.get_session.app_error", "translation": "Session not found." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Unable to count the number of fields for the custom profile attribute group" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Invalid client side user ID: {{.Id}}" @@ -10155,10 +10103,6 @@ "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", "translation": "Number of elements in ClientSideUserIds {{.CurrentLength}} is higher than maximum limit of {{.MaxLength}}." }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Unable to upsert Custom Profile Attribute fields" - }, { "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", "translation": "Channel banner can only be configured on Public and Private channels." @@ -10215,10 +10159,6 @@ "id": "model.config.is_valid.ldap_max_login_attempts.app_error", "translation": "Invalid maximum login attempts for LDAP settings. Must be a positive number." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Unable to delete Custom Profile Attribute Values for user" - }, { "id": "api.admin.add_certificate.app_error", "translation": "Failed to add certificate." @@ -10263,10 +10203,6 @@ "id": "web.incoming_webhook.parse_multipart.app_error", "translation": "Failed to parse multipart form for webhook {{.hook_id}}." }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Failed to validate property value" - }, { "id": "app.import.profile_image.open.app_error", "translation": "Failed to open the profile image file: {{.FileName}}" @@ -10295,10 +10231,6 @@ "id": "ent.ldap_groups.invalid_ldap_id", "translation": "Invalid AD/LDAP ID" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Invalid property value attributes : {{.AttributeName}} ({{.Reason}})." - }, { "id": "model.access_policy.is_valid.name.app_error", "translation": "Invalid name for the policy." @@ -10395,10 +10327,6 @@ "id": "model.config.is_valid.report_a_problem_mail.invalid.app_error", "translation": "Invalid report a problem mail. Must be a valid email address." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Cannot update value for a synced Custom Profile Attribute field" - }, { "id": "api.user.create_user.license_user_limits.exceeded", "translation": "Can't create user. Server exceeds maximum licensed users. Contact your administrator with: ERROR_LICENSED_USERS_LIMIT_EXCEEDED." @@ -10723,10 +10651,6 @@ "id": "app.channel.get_common_teams.app_error", "translation": "Error get common teams for channel" }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Cannot update value for an admin-managed Custom Profile Attribute field" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "Encountered an error decoding JSON response from interactive dialog lookup." @@ -11019,10 +10943,6 @@ "id": "app.burn_post.read_receipt.update.error", "translation": "An error occurred while updating the read receipt." }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Unable to patch Custom Profile Attribute field" - }, { "id": "app.pap.update_access_control_policies_active.app_error", "translation": "Could not update active status of access control policies." diff --git a/server/i18n/en.json b/server/i18n/en.json index 1f58855805c..0631cff88e9 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -47,6 +47,10 @@ "id": "September", "translation": "September" }, + { + "id": "api.access_control_policy.channel_permission_policies.feature_disabled", + "translation": "Channel-level permission policies feature is not enabled." + }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "Get channels limit is not valid." @@ -59,6 +63,14 @@ "id": "api.access_control_policy.permission_policies.feature_disabled", "translation": "Permission policies feature is not enabled." }, + { + "id": "api.access_control_policy.policy_simulation.feature_disabled", + "translation": "Policy simulation feature is not enabled." + }, + { + "id": "api.access_control_policy.simulate.users_out_of_scope.app_error", + "translation": "All users in the simulation must be members of the team or channel in the request." + }, { "id": "api.acknowledgement.delete.archived_channel.app_error", "translation": "You cannot remove an acknowledgment in an archived channel." @@ -415,6 +427,54 @@ "id": "api.channel.delete_channel.type.invalid", "translation": "Unable to delete direct or group message channels" }, + { + "id": "api.channel.discoverable_join_request.already_member.app_error", + "translation": "You are already a member of this channel." + }, + { + "id": "api.channel.discoverable_join_request.archived.app_error", + "translation": "Cannot request to join an archived channel." + }, + { + "id": "api.channel.discoverable_join_request.discoverable_requires_approval.app_error", + "translation": "This channel requires admin approval to join. Please send a request from the Browse Channels modal." + }, + { + "id": "api.channel.discoverable_join_request.duplicate.app_error", + "translation": "You already have a pending request to join this channel." + }, + { + "id": "api.channel.discoverable_join_request.feature_disabled.app_error", + "translation": "Discoverable channels are not enabled on this server." + }, + { + "id": "api.channel.discoverable_join_request.guest.app_error", + "translation": "Guests cannot request to join discoverable private channels." + }, + { + "id": "api.channel.discoverable_join_request.invalid_patch.app_error", + "translation": "Invalid update for the channel join request." + }, + { + "id": "api.channel.discoverable_join_request.not_discoverable.app_error", + "translation": "This channel is not discoverable." + }, + { + "id": "api.channel.discoverable_join_request.not_pending.app_error", + "translation": "The join request is no longer pending." + }, + { + "id": "api.channel.discoverable_join_request.not_private.app_error", + "translation": "Only private channels accept join requests." + }, + { + "id": "api.channel.discoverable_join_request.policy_denied.app_error", + "translation": "You do not satisfy the access rules required to join this channel." + }, + { + "id": "api.channel.discoverable_join_request.shared.app_error", + "translation": "Shared channels do not accept discoverable join requests." + }, { "id": "api.channel.get_channel.flagged_post_mismatch.app_error", "translation": "Channel ID does not match the channel ID of the flagged post." @@ -2940,6 +3000,14 @@ "id": "api.post.do_action.action_integration.app_error", "translation": "Action integration error." }, + { + "id": "api.post.do_action.merge_query.app_error", + "translation": "Failed to merge query into action URL." + }, + { + "id": "api.post.do_action.query.app_error", + "translation": "Invalid action query." + }, { "id": "api.post.error_get_post_id.pending", "translation": "Unable to get the pending post." @@ -5182,6 +5250,10 @@ "id": "app.access_control.build_subject.group_id.app_error", "translation": "Failed to retrieve the access control attribute group." }, + { + "id": "app.access_control.get_channel_role.app_error", + "translation": "Unable to get channel role for the user. Please try again." + }, { "id": "app.access_control.insufficient_permissions", "translation": "You do not have permission to manage this access control policy." @@ -5610,6 +5682,22 @@ "id": "app.channel.group_message_conversion.post_message.error", "translation": "Failed to create group message to channel conversion post" }, + { + "id": "app.channel.join_request.get.app_error", + "translation": "Failed to load channel join request." + }, + { + "id": "app.channel.join_request.not_found.app_error", + "translation": "Channel join request not found." + }, + { + "id": "app.channel.join_request.save.app_error", + "translation": "Failed to save channel join request." + }, + { + "id": "app.channel.join_request.update.app_error", + "translation": "Failed to update channel join request." + }, { "id": "app.channel.migrate_channel_members.select.app_error", "translation": "Failed to select the batch of channel members." @@ -5674,6 +5762,10 @@ "id": "app.channel.restore.app_error", "translation": "Unable to restore the channel." }, + { + "id": "app.channel.restore_channel.rejected_by_plugin", + "translation": "Channel restore rejected by plugin: {{.Reason}}" + }, { "id": "app.channel.save_member.app_error", "translation": "Unable to save channel member." @@ -5718,6 +5810,14 @@ "id": "app.channel.update_channel.internal_error", "translation": "Unable to update channel." }, + { + "id": "app.channel.update_channel.plugin_type_mutation.app_error", + "translation": "Plugin {{.PluginID}} attempted to mutate channel type via ChannelWillBeUpdated; type changes must go through the dedicated type-change path" + }, + { + "id": "app.channel.update_channel.rejected_by_plugin", + "translation": "Channel update rejected by plugin: {{.Reason}}" + }, { "id": "app.channel.update_last_viewed_at.app_error", "translation": "Unable to update the last viewed at time." @@ -5738,6 +5838,26 @@ "id": "app.channel.user_belongs_to_channels.app_error", "translation": "Unable to determine if the user belongs to a list of channels." }, + { + "id": "app.channel_guard.invalid_channel.app_error", + "translation": "Channel ID is not a valid channel identifier." + }, + { + "id": "app.channel_guard.register.app_error", + "translation": "Unable to register the channel guard." + }, + { + "id": "app.channel_guard.register.empty_channel.app_error", + "translation": "Channel ID is required to register a channel guard." + }, + { + "id": "app.channel_guard.unregister.app_error", + "translation": "Unable to unregister the channel guard." + }, + { + "id": "app.channel_guard.unregister.empty_channel.app_error", + "translation": "Channel ID is required to unregister a channel guard." + }, { "id": "app.channel_member_history.log_join_event.internal_error", "translation": "Failed to record channel member history." @@ -6288,6 +6408,10 @@ "id": "app.draft.save.app_error", "translation": "Unable to save the Draft." }, + { + "id": "app.draft.upsert.rejected_by_plugin", + "translation": "Draft rejected by plugin: {{.Reason}}" + }, { "id": "app.drafts.permanent_delete_by_user.app_error", "translation": "Unable to delete drafts for user." @@ -7536,6 +7660,10 @@ "id": "app.pap.get_policy_attributes.app_error", "translation": "Could not get attributes for policy." }, + { + "id": "app.pap.hydrate_actions.app_error", + "translation": "Could not load the action set declared by the channel's access control policy." + }, { "id": "app.pap.init.app_error", "translation": "Unable to initialize access control service." @@ -7584,14 +7712,66 @@ "id": "app.pap.save_policy.name_exists.app_error", "translation": "A policy with this name already exists. Please choose a different name." }, + { + "id": "app.pap.save_policy.rule_name_unique.app_error", + "translation": "Permission rule names must be unique within the policy." + }, { "id": "app.pap.save_policy.self_exclusion", "translation": "You do not satisfy one or more conditions in this policy." }, + { + "id": "app.pap.save_policy.user_session_unsupported.app_error", + "translation": "Session attributes are not supported for policy simulation." + }, { "id": "app.pap.search_access_control_policies.app_error", "translation": "Could not search access control policies." }, + { + "id": "app.pap.simulate.attribute_refresh", + "translation": "Failed to refresh attributes for the simulation." + }, + { + "id": "app.pap.simulate.compile_failed", + "translation": "Failed to compile the policy for simulation." + }, + { + "id": "app.pap.simulate.feature_disabled", + "translation": "The permission policies feature is currently disabled." + }, + { + "id": "app.pap.simulate.invalid_policy", + "translation": "The policy is not valid." + }, + { + "id": "app.pap.simulate.missing_actions", + "translation": "At least one action is required for simulation." + }, + { + "id": "app.pap.simulate.missing_channel_id", + "translation": "A channel is required to simulate a channel-scoped policy." + }, + { + "id": "app.pap.simulate.missing_policy", + "translation": "A policy is required for simulation." + }, + { + "id": "app.pap.simulate.missing_users", + "translation": "At least one user is required for simulation." + }, + { + "id": "app.pap.simulate.too_many_users", + "translation": "Too many users requested. Maximum allowed is {{.Max}}." + }, + { + "id": "app.pap.simulate.unavailable", + "translation": "Policy Administration Point is not initialized." + }, + { + "id": "app.pap.simulate.unsupported_type", + "translation": "Policy type \"{{.Type}}\" is not supported by the simulator." + }, { "id": "app.pap.unassign_access_control_policy_from_channels.app_error", "translation": "Could not unassign access control policy from channels." @@ -7656,6 +7836,14 @@ "id": "app.plugin.get_statuses.app_error", "translation": "Unable to get plugin statuses." }, + { + "id": "app.plugin.guard_hook_failed.app_error", + "translation": "Operation rejected: claiming plugin {{.PluginID}} hook call failed" + }, + { + "id": "app.plugin.inactive_guard.app_error", + "translation": "Operation rejected: a required plugin is not active" + }, { "id": "app.plugin.install.app_error", "translation": "Unable to install plugin." @@ -8622,10 +8810,18 @@ "id": "app.scheduled_post.private_channel", "translation": "Private channel" }, + { + "id": "app.scheduled_post.save.rejected_by_plugin", + "translation": "Scheduled post rejected by plugin: {{.Reason}}" + }, { "id": "app.scheduled_post.unknown_channel", "translation": "Unknown Channel" }, + { + "id": "app.scheduled_post.update.rejected_by_plugin", + "translation": "Scheduled post update rejected by plugin: {{.Reason}}" + }, { "id": "app.scheme.delete.app_error", "translation": "Unable to delete this scheme." @@ -9326,6 +9522,10 @@ "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." }, + { + "id": "app.user_access_token.expired", + "translation": "The personal access token has expired." + }, { "id": "app.user_access_token.get_all.app_error", "translation": "Unable to get all personal access tokens." @@ -10558,6 +10758,10 @@ "id": "model.access_policy.inherit.already_imported.app_error", "translation": "The parent is already imported." }, + { + "id": "model.access_policy.inherit.parent_type.app_error", + "translation": "Imports must target a membership parent policy." + }, { "id": "model.access_policy.inherit.permission.app_error", "translation": "Permission policies cannot inherit from other policies." @@ -10570,6 +10774,14 @@ "id": "model.access_policy.is_valid.actions.app_error", "translation": "Action(s) is not valid." }, + { + "id": "model.access_policy.is_valid.actions.membership_combined.app_error", + "translation": "Membership cannot be combined with other actions in the same rule." + }, + { + "id": "model.access_policy.is_valid.actions.permission_type.app_error", + "translation": "Permission action rules are only allowed on channel policies." + }, { "id": "model.access_policy.is_valid.id.app_error", "translation": "Invalid policy id." @@ -10590,6 +10802,18 @@ "id": "model.access_policy.is_valid.roles.app_error", "translation": "Permission policies must be applied to exactly one role." }, + { + "id": "model.access_policy.is_valid.rule_name.app_error", + "translation": "Permission rules require a non-empty name within the policy max length." + }, + { + "id": "model.access_policy.is_valid.rule_name_unique.app_error", + "translation": "Permission rule names must be unique within the policy." + }, + { + "id": "model.access_policy.is_valid.rule_role.app_error", + "translation": "Invalid role for the rule." + }, { "id": "model.access_policy.is_valid.rules.app_error", "translation": "Rule(s) is not valid." @@ -12766,6 +12990,10 @@ "id": "model.user_access_token.is_valid.description.app_error", "translation": "Invalid description, must be 255 or less characters." }, + { + "id": "model.user_access_token.is_valid.expires_at.app_error", + "translation": "Invalid expires_at, must be zero or a positive Unix timestamp in milliseconds." + }, { "id": "model.user_access_token.is_valid.id.app_error", "translation": "Invalid value for id." @@ -12866,6 +13094,10 @@ "id": "plugin.api.get_users_in_channel", "translation": "Unable to get the users, invalid sorting criteria." }, + { + "id": "plugin.api.update_post.mm_blocks_actions.app_error", + "translation": "Invalid mm_blocks_actions in plugin post update." + }, { "id": "plugin.api.update_user_status.bad_status", "translation": "Unable to set the user status. Unknown user status." diff --git a/server/i18n/es.json b/server/i18n/es.json index 5f37b376b90..730741465ec 100644 --- a/server/i18n/es.json +++ b/server/i18n/es.json @@ -6467,10 +6467,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "No se pudo obtener los canales por ids." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "No se puede obtener el número de canales." - }, { "id": "app.channel.count_posts_since.app_error", "translation": "No se pueden contar los mensajes desde la fecha proporcionada." @@ -7427,14 +7423,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "Los tipos de trabajo que está intentando recuperar no contienen permisos" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Asegúrese de que su cubo de Amazon S3 está disponible y verifique los permisos de su cubo." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "No se puede conectar a S3. Verifique los parámetros de autorización de su conexión a Amazon S3 y la configuración de autenticación." - }, { "id": "api.error_set_first_admin_visit_marketplace_status", "translation": "Error al intentar guardar el estado de la primera visita del administrador al marketplace." @@ -8723,10 +8711,6 @@ "id": "api.file.zip_file_reader.app_error", "translation": "No se pudo obtener un lector de archivos zip." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "La licencia actual no permite Atributos de Perfil Personalizados." - }, { "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", "translation": "Error: no se ha indicado el nombre de la organización para la puesta en marcha en servidor propio." diff --git a/server/i18n/fa.json b/server/i18n/fa.json index 71d19338bae..b01b9c55a19 100644 --- a/server/i18n/fa.json +++ b/server/i18n/fa.json @@ -5007,10 +5007,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "دریافت کانال ها امکان پذیر نیست." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "شمارش کانال امکان پذیر نیست." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "گرفتن کانالهای طرح ارائه شده امکان پذیر نیست." @@ -6305,14 +6301,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "بارگذاری پرونده امکان پذیر نیست. شناسه کانال نادرست است: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "اطمینان حاصل کنید که bucket آمازون S3 شما در دسترس است و مجوزهای bucket خود را تأیید کنید." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "امکان اتصال به S3 وجود ندارد. پارامترهای مجوز اتصال آمازون S3 و تنظیمات احراز هویت را تأیید کنید." - }, { "id": "api.file.test_connection.app_error", "translation": "دسترسی به حافظه فایل امکان پذیر نیست." @@ -7042,7 +7030,10 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} از قبل در کانال است." + "translation": { + "one": "{{.User}} از قبل در کانال است.", + "other": "" + } }, { "id": "api.command_invite.success", diff --git a/server/i18n/fr.json b/server/i18n/fr.json index b9c5799e4c2..bb986f5190f 100644 --- a/server/i18n/fr.json +++ b/server/i18n/fr.json @@ -6283,10 +6283,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Impossible de récupérer les canaux par leur identifiant." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Impossible de récupérer le nombre de canaux." - }, { "id": "app.team.update.updating.app_error", "translation": "Une erreur s'est produite lors de la modification de l'équipe." @@ -6847,14 +6843,6 @@ "id": "api.license.request-trial.can-start-trial.error", "translation": "Impossible de vérifier si un essai peut être commencé" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Vérifiez que votre bucket Amazon S3 est disponible, et vérifiez-en les permissions." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Impossible de se connecter à S3. Vérifiez les paramètres d'autorisation et d'authentification à la connexion Amazon S3." - }, { "id": "app.user.get.app_error", "translation": "Une erreur s'est produite lors de la recherche du compte." diff --git a/server/i18n/hi.json b/server/i18n/hi.json index 453fed249dc..4d767f446ed 100644 --- a/server/i18n/hi.json +++ b/server/i18n/hi.json @@ -3395,14 +3395,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "फ़ाइल अपलोड करने में असमर्थ. गलत चैनल आईडी: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "सुनिश्चित करें कि आपका Amazon S3 बकेट उपलब्ध है, और अपनी बकेट अनुमतियों को सत्यापित करें।" - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "S3 से कनेक्ट करने में असमर्थ। अपने Amazon S3 कनेक्शन प्राधिकरण मापदंडों और प्रमाणीकरण सेटिंग्स को सत्यापित करें।" - }, { "id": "api.email_batching.send_batched_email_notification.subject", "translation": { @@ -3799,7 +3791,10 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} पहले से ही चैनल में है।" + "translation": { + "one": "{{.User}} पहले से ही चैनल में है।", + "other": "" + } }, { "id": "api.command_invite.success", @@ -5328,10 +5323,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "चैनल प्राप्त करने में असमर्थ।" }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "चैनल की गिनती प्राप्त करने में असमर्थ।" - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "प्रदान की गई योजना के लिए चैनल प्राप्त करने में असमर्थ।" diff --git a/server/i18n/hu.json b/server/i18n/hu.json index 1f1974bf6f3..3418876df9e 100644 --- a/server/i18n/hu.json +++ b/server/i18n/hu.json @@ -3927,10 +3927,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Nem sikerült lekérni a csatornákat." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Nem sikerült lekérni a csatornák mennyiségét." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Nem sikerült lekérni a csatornákat a megadott sémához." @@ -5655,14 +5651,6 @@ "id": "api.file.upload_file.incorrect_channelId.app_error", "translation": "Nem lehet feltölteni a fájlt. Érvénytelen csatorna ID: {{.channelId}}" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Győződjön meg arról, hogy elérhető-e az Amazon S3 bucket , és ellenőrizze a bucket engedélyeit." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Nem sikerült csatlakozni az S3-hoz. Ellenőrizze az Amazon S3 kapcsolat hitelesítési paramétereit és hitelesítési beállításait." - }, { "id": "api.file.test_connection.app_error", "translation": "Nem lehet hozzáférni a fájl tárolóhoz." diff --git a/server/i18n/it.json b/server/i18n/it.json index 812fda7ffdf..559c41c7014 100644 --- a/server/i18n/it.json +++ b/server/i18n/it.json @@ -673,7 +673,11 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} è già membro del canale." + "translation": { + "many": "", + "one": "{{.User}} è già membro del canale.", + "other": "" + } }, { "id": "api.command_invite_people.permission.app_error", @@ -6387,10 +6391,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Impossibile recuperare canali per id" }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Impossibile ottenere il numero dei canali" - }, { "id": "app.team.update.updating.app_error", "translation": "Riscontrato un errore nell'aggiornamento della squadra" @@ -6631,10 +6631,6 @@ "id": "app.export.marshal.app_error", "translation": " " }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": " " - }, { "id": "api.invalid_custom_url_scheme", "translation": " " @@ -7671,10 +7667,6 @@ "id": "api.command_remote.displayname.hint", "translation": " " }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": " " - }, { "id": "app.upload.upload_data.save.app_error", "translation": " " diff --git a/server/i18n/ja.json b/server/i18n/ja.json index f6fd1731d16..64a75e63583 100644 --- a/server/i18n/ja.json +++ b/server/i18n/ja.json @@ -6473,10 +6473,6 @@ "id": "app.channel.get_channels_by_ids.get.app_error", "translation": "チャンネルを取得できませんでした。" }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "チャンネル数を取得できませんでした。" - }, { "id": "api.user.update_password.user_and_hashed.app_error", "translation": "システム管理者のみがハッシュ化されたパスワードを設定できます。" @@ -7793,14 +7789,6 @@ "id": "api.cloud.cws_webhook_event_missing_error", "translation": "Webhookイベントが処理されませんでした。存在しないか、有効でないかのいずれかです。" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Amazon S3バケットが利用可能であることを確認し、バケットの権限を確認してください。" - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "S3へ接続できませんでした。Amazon S3接続の認証パラメーターと認証設定を確認してください。" - }, { "id": "ent.data_retention.policies.invalid_policy", "translation": "ポリシーが不正です。" @@ -8011,7 +7999,7 @@ }, { "id": "api.push_notification.title.collapsed_threads", - "translation": "{{.channelName}} へ返信する" + "translation": "{{.channelName}} への返信" }, { "id": "api.post.send_notification_and_forget.push_comment_on_crt_thread", @@ -8039,7 +8027,7 @@ }, { "id": "api.push_notification.title.collapsed_threads_dm", - "translation": "ダイレクトメッセージへ返信する" + "translation": "ダイレクトメッセージへの返信" }, { "id": "api.post.send_notification_and_forget.push_comment_on_crt_thread_dm", @@ -10001,38 +9989,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "カスタム絵文字の画像用のディレクトリを作成できませんでした" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "カスタムプロフィール属性のプロパティグループを登録できません" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "カスタムプロフィール属性の値を取得できませんでした" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "カスタムプロフィール属性のフィールドを削除できませんでした" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "カスタムプロフィール属性のフィールドを取得できませんでした" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "カスタムプロフィール属性のフィールドの上限に達しました" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "カスタムプロフィール属性のフィールドが見つかりません" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "カスタムプロフィール属性のフィールドを更新できませんでした" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "カスタムプロフィール属性のフィールドを検索できませんでした" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "要求されたファイルをデータベースから削除できませんでした" @@ -10097,10 +10053,6 @@ "id": "api.command.execute_command.deleted.error", "translation": "削除されたチャンネルではコマンドを実行できません。" }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "あなたのライセンスはユーザー属性をサポートしていません。" - }, { "id": "api.file.zip_file_reader.app_error", "translation": "ZIPファイルリーダーを取得できませんでした。" @@ -10133,14 +10085,6 @@ "id": "api.context.get_session.app_error", "translation": "セッションが見つかりません。" }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "カスタムプロフィール属性グループのフィールド数をカウントできませんでした" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "カスタムプロフィール属性フィールドをupsertできませんでした" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "不正なクライアントサイドユーザーID: {{.Id}}" @@ -10205,10 +10149,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "プロパティフィールドをカスタムプロフィール属性フィールドに変換できません" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "無効なプロパティ値属性 : {{.AttributeName}} ({{.Reason}})。" - }, { "id": "app.group.license_error", "translation": "LDAPライセンスが必要です。" @@ -10237,14 +10177,6 @@ "id": "model.access_policy.is_valid.rules_imports.app_error", "translation": "ポリシーはルールをインポートするか定義しなければなりません。" }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "ユーザーのカスタムプロフィール属性の値を削除できません" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "プロパティ値を検証できませんでした" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "CPAフィールドを取得できませんでした" @@ -10353,10 +10285,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "問題報告用メールアドレスは必須です。" }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "同期されたカスタムプロフィール属性のフィールドの値を更新できません" - }, { "id": "app.import.validate_attachment_import_data.invalid_path.error", "translation": "添付インポートデータを検証できませんでした。不正なパス: \"{{.Path}}\"" @@ -10645,10 +10573,6 @@ "id": "app.access_control.insufficient_permissions", "translation": "あなたにはこのアクセス制御ポリシーを管理する権限がありません。" }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "管理者が管理するカスタムプロフィール属性フィールドの値は更新できません" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "インタラクティブダイアログのlookupからJSONレスポンスをデコードする際にエラーが発生しました。" diff --git a/server/i18n/ka.json b/server/i18n/ka.json index 6f5b1e30753..e2fe6290d7c 100644 --- a/server/i18n/ka.json +++ b/server/i18n/ka.json @@ -161,7 +161,7 @@ }, { "id": "api.admin.add_certificate.no_file.app_error", - "translation": "არ არის ფაილი მოთხოვნაში 'certificate'" + "translation": "მოთხოვნაში 'certificate'-ში ფაილი აღმოჩენილი არაა." }, { "id": "api.admin.add_certificate.array.app_error", @@ -169,7 +169,7 @@ }, { "id": "web.incoming_webhook.user.app_error", - "translation": "მომხმარებელი არ არის ნაპოვნი." + "translation": "მომხმარებლის აღმოჩენა შეუძლებელია. {{.user}}" }, { "id": "api.channel.remove_member.removed", diff --git a/server/i18n/ko.json b/server/i18n/ko.json index c0a5ef19982..1bd836aa733 100644 --- a/server/i18n/ko.json +++ b/server/i18n/ko.json @@ -6309,10 +6309,6 @@ "id": "app.channel.analytics_type_count.app_error", "translation": "채널 유형의 갯수를 가져올 수 없습니다." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "채널 갯수를 가져올 수 없습니다." - }, { "id": "app.channel.get_member_count.app_error", "translation": "채널 구성원 수를 가져올 수 없습니다." @@ -7265,14 +7261,6 @@ "id": "api.license.request-trial.can-start-trial.not-allowed", "translation": "새로운 체험판 라이선스를 적용하는데 실패하였습니다. 이 Mattermost 인스턴스에는 이전에 적용된 체험판 라이센스를 유지합니다. 체험 기간을 연장하시려면 [저희 영업 팀에 연락해주세요](https://mattermost.com/contact-us/)." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "아마존 S3 버킷이 가용한 상태인지 확인하고, 버킷 권한을 확인해주세요." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "S3와 연결할 수 없습니다. 아마존 S3 연결을 위한 인가 매개변수와 인증 설정을 확인하세요." - }, { "id": "api.file.file_reader.app_error", "translation": "파일 뷰어가 없습니다." @@ -7945,10 +7933,6 @@ "id": "api.custom_profile_attributes.invalid_field_patch", "translation": "잘못된 사용자 지정 프로필 속성 필드 패치" }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "라이선스는 사용자 지정 프로필 속성을 지원하지 않습니다." - }, { "id": "api.custom_status.set_custom_statuses.emoji_not_found", "translation": "사용자 지정 상태를 업데이트하지 못했습니다. 지정된 이름의 이모티콘이 존재하지 않습니다." @@ -8669,70 +8653,10 @@ "id": "app.custom_group.unique_name", "translation": "그룹 이름이 고유하지 않음" }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "사용자 지정 프로필 속성 그룹의 필드 수를 계산할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "사용자 지정 프로필 속성 그룹을 등록할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "사용자에 대한 사용자 지정 프로필 속성 값을 삭제할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "사용자 지정 프로필 속성 필드를 가져올 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "사용자 지정 프로필 속성 필드 제한에 도달했습니다." - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "사용자 지정 프로필 속성 값을 가져올 수 없습니다." - }, { "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "속성 필드를 사용자 지정 프로필 속성 필드로 변환할 수 없습니다." }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "사용자 지정 프로필 속성 필드를 삭제할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "관리자가 관리하는 사용자 지정 프로필 속성 필드의 값을 업데이트할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "동기화된 사용자 지정 프로필 속성 필드의 값을 업데이트할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "사용자 지정 프로필 속성 필드를 찾을 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "사용자 지정 프로필 속성 필드를 업데이트할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "사용자 지정 프로필 속성 필드를 업데이트할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "잘못된 속성 값입니다: {{.AttributeName}} ({{.Reason}})." - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "사용자 지정 프로필 속성 필드를 검색할 수 없습니다." - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "속성 값 유효성 검사 실패" - }, { "id": "app.delete_scheduled_post.delete_error", "translation": "데이터베이스에서 예약된 글을 삭제하지 못했습니다." diff --git a/server/i18n/lo.json b/server/i18n/lo.json index b14f187dc6e..59d903b06f0 100644 --- a/server/i18n/lo.json +++ b/server/i18n/lo.json @@ -1,6 +1,6 @@ [ - { - "id": "api.command.invite_people.name", - "translation": "invite_people" - } + { + "id": "api.command.invite_people.name", + "translation": "invite_people" + } ] diff --git a/server/i18n/mk.json b/server/i18n/mk.json index b4a022c1d6f..3a2080dc9f2 100644 --- a/server/i18n/mk.json +++ b/server/i18n/mk.json @@ -703,10 +703,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Неможе да се превземат каналите." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Не може да се превземат бројките на каналот." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Не може да се превземат каналите за наведената шема." diff --git a/server/i18n/ml.json b/server/i18n/ml.json index 1b67877eabd..b08a8cfe22f 100644 --- a/server/i18n/ml.json +++ b/server/i18n/ml.json @@ -713,7 +713,10 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} ഇതിനകം ചാനലിലുണ്ട്." + "translation": { + "one": "{{.User}} ഇതിനകം ചാനലിലുണ്ട്.", + "other": "" + } }, { "id": "api.command_invite.success", diff --git a/server/i18n/mn.json b/server/i18n/mn.json index b86508fefc2..ab9b9950ebb 100644 --- a/server/i18n/mn.json +++ b/server/i18n/mn.json @@ -1,86 +1,86 @@ [ - { - "id": "api.team.get_all_teams.insufficient_permissions", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_remove.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_msg.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_channel_rename.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_channel_purpose.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_channel_purpose.desc", - "translation": "Группыг зохистой ашиглах тухай мэдээлэл өөрчлөх" - }, - { - "id": "api.command_channel_header.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" - }, - { - "id": "api.command_channel_header.desc", - "translation": "Группын товч тайлбар/уриаг өөрчлөх" - }, - { - "id": "api.command.invite_people.name", - "translation": "invite_people" - }, - { - "id": "September", - "translation": "9-р сар" - }, - { - "id": "October", - "translation": "10-р сар" - }, - { - "id": "November", - "translation": "11-р сар" - }, - { - "id": "May", - "translation": "5-р сар" - }, - { - "id": "March", - "translation": "3-р сар" - }, - { - "id": "June", - "translation": "6-р сар" - }, - { - "id": "July", - "translation": "7-р сар" - }, - { - "id": "January", - "translation": "1-р сар" - }, - { - "id": "February", - "translation": "2-р сар" - }, - { - "id": "December", - "translation": "12-р сар" - }, - { - "id": "August", - "translation": "8-р сар" - }, - { - "id": "April", - "translation": "4-р сар" - } + { + "id": "api.team.get_all_teams.insufficient_permissions", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_remove.permission.app_error", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_msg.permission.app_error", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_channel_rename.permission.app_error", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_channel_purpose.permission.app_error", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_channel_purpose.desc", + "translation": "Группыг зохистой ашиглах тухай мэдээлэл өөрчлөх" + }, + { + "id": "api.command_channel_header.permission.app_error", + "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + }, + { + "id": "api.command_channel_header.desc", + "translation": "Группын товч тайлбар/уриаг өөрчлөх" + }, + { + "id": "api.command.invite_people.name", + "translation": "invite_people" + }, + { + "id": "September", + "translation": "9-р сар" + }, + { + "id": "October", + "translation": "10-р сар" + }, + { + "id": "November", + "translation": "11-р сар" + }, + { + "id": "May", + "translation": "5-р сар" + }, + { + "id": "March", + "translation": "3-р сар" + }, + { + "id": "June", + "translation": "6-р сар" + }, + { + "id": "July", + "translation": "7-р сар" + }, + { + "id": "January", + "translation": "1-р сар" + }, + { + "id": "February", + "translation": "2-р сар" + }, + { + "id": "December", + "translation": "12-р сар" + }, + { + "id": "August", + "translation": "8-р сар" + }, + { + "id": "April", + "translation": "4-р сар" + } ] diff --git a/server/i18n/nl.json b/server/i18n/nl.json index dea04bbaa5e..def5b814c19 100644 --- a/server/i18n/nl.json +++ b/server/i18n/nl.json @@ -6310,10 +6310,6 @@ "id": "api.user.login_cws.license.error", "translation": "CWS login is verboden." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Kan het aantal kanalen niet tellen." - }, { "id": "app.channel.analytics_type_count.app_error", "translation": "We kunnen de kanalen typen niet tellen." @@ -7798,14 +7794,6 @@ "id": "api.job.unable_to_create_job.incorrect_job_type", "translation": "Het functietype van de job die je probeert aan te maken is ongeldig" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Zorg ervoor dat jouw Amazon S3 bucket beschikbaar is, en controleer jouw bucketpermissies." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Kan geen verbinding maken met S3. Controleer je Amazon S3 verbindingsautorisatieparameters en authenticatie-instellingen." - }, { "id": "api.admin.saml.failure_reset_authdata_to_email.app_error", "translation": "Fout bij het resetten van AuthData veld naar e-mail." @@ -10023,38 +10011,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Kan geen map maken voor aangepaste emoji-afbeeldingen" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Kan de eigenschapgroep Gebruikersattributen niet ophalen." - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Kan gebruikersattribuutveld niet krijgen" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Gebruikersattributen veldlimiet bereikt" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Kan gebruikersattribuutveld niet verwijderen" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Kan de waarden van gebruikersattributen niet ophalen" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Gebruikersattribuutveld niet gevonden" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Kan gebruikersattribuutveld niet bijwerken" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Niet in velden met gebruikersattributen kunnen zoeken" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Het is niet gelukt de gevraagde bestanden uit de database te verwijderen" @@ -10123,10 +10079,6 @@ "id": "api.file.zip_file_reader.app_error", "translation": "Kon geen zip-bestandslezer vinden." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Je licentie ondersteunt geen gebruikersattributen." - }, { "id": "api.channel.bookmark.create_channel_bookmark.deleted_channel.forbidden.app_error", "translation": "Het aanmaken van de kanaalbladwijzer is mislukt." @@ -10155,14 +10107,6 @@ "id": "api.context.get_session.app_error", "translation": "Sessie niet gevonden." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Kan het aantal velden voor de groep Gebruikersattributen niet tellen" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Kan gebruikersattribuutvelden niet upsertten" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Ongeldige gebruikers-id aan clientzijde: {{.Id}}" @@ -10231,10 +10175,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Kan het eigenschapveld niet converteren naar een gebruikersattribuutveld" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Ongeldige eigenschap waarde attributen : {{.AttributeName}} ({{.Reason}})." - }, { "id": "api.custom_profile_attributes.invalid_field_patch", "translation": "ongeldige patch van gebruikersattribuutveld" @@ -10279,14 +10219,6 @@ "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} moet een prefix zijn van IndexPrefix {{.IndexPrefix}}." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Kan waarden van gebruikersattributen voor gebruiker niet verwijderen" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Fout bij het valideren van de waarde van de eigenschap" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Fout bij het ophalen van CPA-velden" @@ -10375,10 +10307,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "Een probleem melden mail is vereist." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Kan waarde voor een gesynchroniseerd veld van gebruikersattributen niet bijwerken" - }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "Limiet kanalen ophalen is ongeldig." @@ -10691,10 +10619,6 @@ "id": "model.config.is_valid.experimental_view_archived_channels.app_error", "translation": "Het verbergen van gearchiveerde kanalen wordt niet langer ondersteund. Maak deze kanalen privé en verwijder in plaats daarvan leden." }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Kan de waarde van een door de beheerder beheerd veld met gebruikersattributen niet bijwerken" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "Er is een fout opgetreden bij het decoderen van de JSON respons van het interactief dialoogvenster voor opzoeken." @@ -10883,10 +10807,6 @@ "id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error", "translation": "De AI-gegenereerde gebruiker moet de maker van het bericht of een bot zijn." }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Kan gebruikersattribuutveld niet patchen" - }, { "id": "app.post.rewrite.agent_call_failed", "translation": "Fout bij het aanspreken van AI-agent." @@ -12043,10 +11963,6 @@ "id": "api.property_field.delete.no_permission.app_error", "translation": "Je hebt geen toestemming om dit eigenschapveld te verwijderen." }, - { - "id": "api.property_field.delete.protected_via_api.app_error", - "translation": "Kan een beschermd eigenschapveld niet verwijderen via API." - }, { "id": "api.property_field.get.invalid_target_type.app_error", "translation": "Een geldig target_type (systeem, team of kanaal) is vereist." @@ -12075,10 +11991,6 @@ "id": "api.property_field.update.no_options_permission.app_error", "translation": "Je hebt geen rechten om opties voor dit eigenschapveld te beheren." }, - { - "id": "api.property_field.update.protected_via_api.app_error", - "translation": "Kan een beschermd eigenschapveld niet bijwerken via API." - }, { "id": "api.property_value.invalid_object_type.app_error", "translation": "Het opgegeven objecttype is ongeldig." @@ -12103,10 +12015,6 @@ "id": "api.property_value.patch.too_many_items.request_error", "translation": "Zoveel waarden van eigenschappen kunnen niet worden bijgewerkt. Alleen {{.Max}} waarden van eigenschappen kunnen in één keer worden bijgewerkt." }, - { - "id": "api.property_value.target_user.forbidden.app_error", - "translation": "Je hebt geen toegang tot de waarden van eigenschappen van een andere gebruiker." - }, { "id": "api.templates.license_need_help.info", "translation": "Praat met een Mattermost expert voor hulp met plannen en verlengingsopties." @@ -12187,10 +12095,6 @@ "id": "app.channel.delete_channel.rejected_by_plugin", "translation": "Kanaalarchief afgekeurd door plugin: {{.Reason}}" }, - { - "id": "app.custom_profile_attributes.get_property_value.app_error", - "translation": "Kan waarde van gebruikersattribuut niet krijgen" - }, { "id": "app.pap.save_policy.name_exists.app_error", "translation": "Er bestaat al een beleid met deze naam. Kies een andere naam." @@ -12247,10 +12151,6 @@ "id": "app.property_field.get_many.app_error", "translation": "Kan eigenschap veld niet krijgen." }, - { - "id": "app.property_field.get_many.fields_not_found.app_error", - "translation": "Een of meer veld-ID's van de eigenschap zijn niet gevonden in de opgegeven groep." - }, { "id": "app.property_field.invalid_input.app_error", "translation": "Ongeldige invoer verstrekt." @@ -12547,10 +12447,6 @@ "id": "api.file.upload_file.abac_denied.app_error", "translation": "Je hebt niet de vereiste toegang om bestanden te uploaden naar dit kanaal." }, - { - "id": "api.managed_category.feature_not_available.app_error", - "translation": "Beheerde kanaalcategorieën zijn niet beschikbaar." - }, { "id": "api.shared_channel.attachment.creator_id_required.app_error", "translation": "FileInfo.CreatorId is vereist." diff --git a/server/i18n/pl.json b/server/i18n/pl.json index 3cba5837741..0f8acf1ce02 100644 --- a/server/i18n/pl.json +++ b/server/i18n/pl.json @@ -6291,10 +6291,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Nie można uzyskać kanałów według identyfikatorów." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Nie można uzyskać liczby kanałów." - }, { "id": "app.team.update.updating.app_error", "translation": "Napotkaliśmy błąd aktualizując zespół." @@ -6943,14 +6939,6 @@ "id": "api.file.write_file.app_error", "translation": "Nie można zapisać pliku." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Upewnij się, że twoje Amazon S3 jest dostępne oraz sprawdź uprawnienia do niego." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Nie można połączyć się z S3. Sprawdź parametry autoryzacji połączenia Amazon S3 i ustawienia uwierzytelniania." - }, { "id": "api.file.test_connection.app_error", "translation": "Nie można uzyskać dostępu do magazynu plików." @@ -10015,38 +10003,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Nie można utworzyć katalogu dla niestandardowych obrazów emoji" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Nie można pobrać grupy właściwości Atrybuty użytkownika." - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Osiągnięto limit pola Atrybuty użytkownika" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Nie można uzyskać pola Atrybut użytkownika" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Nie można uzyskać wartości Atrybutu użytkownika" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Nie można usunąć pola Atrybut użytkownika" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Nie można zaktualizować pola Atrybut użytkownika" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Nie znaleziono pola Atrybut użytkownika" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Nie można szukać pól Atrybutów użytkownika" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Nie udało się usunąć żądanych plików z bazy danych" @@ -10107,10 +10063,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Nieprawidłowa wartość właściwości: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Twoja licencja nie obsługuje Atrybutów użytkownika." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Nie można pobrać czytnika plików zip." @@ -10147,14 +10099,6 @@ "id": "api.context.get_session.app_error", "translation": "Nie znaleziono sesji." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Nie można policzyć liczby pól dla grupy Atrybuty użytkownika" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Nie można wstawić pól Atrybutów użytkownika" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Nieprawidłowy identyfikator użytkownika po stronie klienta: {{.Id}}" @@ -10223,10 +10167,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Nie można przekonwertować pola właściwości na pole atrybutu użytkownika" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Nieprawidłowe atrybuty wartości właściwości: {{.AttributeName}} ({{.Reason}})." - }, { "id": "api.custom_profile_attributes.invalid_field_patch", "translation": "Nieprawidłowa poprawka pola Atrybut użytkownika" @@ -10271,14 +10211,6 @@ "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} powinien być prefiksem IndexPrefix {{.IndexPrefix}}." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Nie można usunąć wartości Atrybutu użytkownika dla użytkownika" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Nie udało się zweryfikować wartości właściwości" - }, { "id": "ent.ldap.update_cpa.empty_attribute", "translation": "Pusta wartość atrybutu LDAP" @@ -10367,10 +10299,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "Wymagane jest zgłoszenie problemu pocztą." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Nie można zaktualizować wartości dla zsynchronizowanego pola Atrybut użytkownika" - }, { "id": "app.import.validate_attachment_import_data.invalid_path.error", "translation": "Nie udało się zweryfikować danych importu załącznika. Nieprawidłowa ścieżka: \"{{.Path}}\"" @@ -10683,10 +10611,6 @@ "id": "model.config.is_valid.experimental_view_archived_channels.app_error", "translation": "Ukrywanie archiwizowanych kanałów nie jest już obsługiwane. Zamiast tego ustaw te kanały jako prywatne i usuń ich członków." }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Nie można zaktualizować wartości pola Atrybut użytkownika zarządzanego przez administratora" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "Napotkano błąd dekodowania odpowiedzi JSON z interaktywnego wyszukiwania okna dialogowego." @@ -10939,10 +10863,6 @@ "id": "api.user.send_password_reset.guest_magic_link.app_error", "translation": "Nie można zresetować hasła dla kont gości magic link." }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Nie można załatać pola Atrybut użytkownika" - }, { "id": "app.pap.update_access_control_policies_active.app_error", "translation": "Nie można zaktualizować aktywnego statusu polityk kontroli dostępu." @@ -12035,10 +11955,6 @@ "id": "api.property_field.delete.no_permission.app_error", "translation": "Nie masz uprawnień do usunięcia tego pola właściwości." }, - { - "id": "api.property_field.delete.protected_via_api.app_error", - "translation": "Nie można usunąć chronionego pola właściwości za pośrednictwem interfejsu API." - }, { "id": "api.property_field.get.invalid_target_type.app_error", "translation": "Wymagany jest prawidłowy target_type (system, zespół lub kanał)." @@ -12067,10 +11983,6 @@ "id": "api.property_field.update.no_options_permission.app_error", "translation": "Nie masz uprawnień do zarządzania opcjami dla tego pola właściwości." }, - { - "id": "api.property_field.update.protected_via_api.app_error", - "translation": "Nie można zaktualizować chronionego pola właściwości za pośrednictwem interfejsu API." - }, { "id": "api.property_value.invalid_object_type.app_error", "translation": "Podany typ obiektu jest nieprawidłowy." @@ -12095,10 +12007,6 @@ "id": "api.property_value.patch.too_many_items.request_error", "translation": "Nie można zaktualizować tak wielu wartości właściwości. Tylko {{.Max}} wartości właściwości mogą być aktualizowane jednocześnie." }, - { - "id": "api.property_value.target_user.forbidden.app_error", - "translation": "Nie masz uprawnień dostępu do wartości właściwości innego użytkownika." - }, { "id": "api.templates.license_need_help.info", "translation": "Porozmawiaj z ekspertem Mattermost, aby uzyskać pomoc dotyczącą planów i opcji odnowienia." @@ -12179,10 +12087,6 @@ "id": "app.channel.delete_channel.rejected_by_plugin", "translation": "Archiwum Kanałów odrzucone przez Wtyczkę: {{.Reason}}" }, - { - "id": "app.custom_profile_attributes.get_property_value.app_error", - "translation": "Nie można uzyskać wartości Atrybutu użytkownika" - }, { "id": "app.pap.save_policy.name_exists.app_error", "translation": "Polityka o tej Nazwie już istnieje. Wybierz inną nazwę." @@ -12239,10 +12143,6 @@ "id": "app.property_field.get_many.app_error", "translation": "Nie można uzyskać pola właściwości." }, - { - "id": "app.property_field.get_many.fields_not_found.app_error", - "translation": "Co najmniej jeden identyfikator pola właściwości nie został znaleziony w określonej grupie." - }, { "id": "app.property_field.invalid_input.app_error", "translation": "Podano nieprawidłowe dane wejściowe." @@ -12539,10 +12439,6 @@ "id": "api.file.upload_file.abac_denied.app_error", "translation": "Nie masz wymaganego dostępu do przesyłania plików na ten kanał." }, - { - "id": "api.managed_category.feature_not_available.app_error", - "translation": "Zarządzane kategorie kanałów nie są dostępne." - }, { "id": "api.shared_channel.attachment.creator_id_required.app_error", "translation": "FileInfo.CreatorId jest wymagany." @@ -12667,22 +12563,6 @@ "id": "api.property.v2_group_not_found.app_error", "translation": "Określona grupa właściwości nie została znaleziona." }, - { - "id": "api.property_field.patch.cannot_link_existing.app_error", - "translation": "Nie można ustawić linked_field_id na istniejącym polu. Można go ustawić tylko w czasie tworzenia." - }, - { - "id": "api.property_field.patch.linked_field_change.app_error", - "translation": "Nie można zmienić celu linku. Najpierw usuń link, a następnie utwórz nowe połączone pole." - }, - { - "id": "api.property_field.patch.linked_options_change.app_error", - "translation": "Nie można modyfikować opcji pola Link. Opcje są dziedziczone ze źródła." - }, - { - "id": "api.property_field.patch.linked_type_change.app_error", - "translation": "Nie można zmodyfikować typu pola Link. Typ jest dziedziczony ze źródła." - }, { "id": "api.property_value.template_no_values.app_error", "translation": "Pola szablonu nie mogą mieć wartości." @@ -12786,5 +12666,9 @@ { "id": "model.property_group.is_valid.app_error", "translation": "Nieprawidłowa grupa właściwości: {{.FieldName}} ({{.Reason}})." + }, + { + "id": "shared_channel.system_message.no_longer_shared_unknown", + "translation": "Kanał ten nie jest już udostępniany innej przestrzeni roboczej." } ] diff --git a/server/i18n/pt-BR.json b/server/i18n/pt-BR.json index 9c6208e5b29..a778266c479 100644 --- a/server/i18n/pt-BR.json +++ b/server/i18n/pt-BR.json @@ -6471,10 +6471,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Não é possível obter canais por ids." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Não é possível obter as contagens de canais." - }, { "id": "app.team.update.updating.app_error", "translation": "Encontramos um erro ao atualizar a equipe." @@ -8023,10 +8019,6 @@ "id": "api.email_batching.send_batched_email_notification.subTitle", "translation": "Confira abaixo um resumo de suas novas mensagens." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Certifique-se de que seu bucket do Amazon S3 está disponível e verifique as permissões do bucket." - }, { "id": "api.get_site_url_error", "translation": "Não foi possível obter a URL do site da instância" @@ -8179,10 +8171,6 @@ "id": "api.email_batching.send_batched_email_notification.time", "translation": "{{.Hour}}:{{.Minute}} {{.TimeZone}}" }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Não foi possível conectar-se ao S3. Verifique os parâmetros de autorização de conexão do Amazon S3 e as configurações de autenticação." - }, { "id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error", "translation": "O preenchimento automático nos canais não pode ser ativado porque o esquema do índice de canais está desatualizado. Recomenda-se que você gere novamente o índice do canal. Consulte o registro de alterações do Mattermost para obter mais informações" @@ -9787,42 +9775,14 @@ "id": "api.command.execute_command.deleted.error", "translation": "Não é possível executar comandos em um canal deletado." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Não é possível deletar valores de atributos de perfil personalizados para o usuário" - }, { "id": "api.context.get_session.app_error", "translation": "Sessão não encontrada." }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Não é possível registrar o grupo de propriedades Custom Profile Attributes" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Não é possível obter o campo Atributo de perfil personalizado" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "O limite do campo atributos de perfil personalizado foi atingido" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Não é possível obter valores de atributos de perfil personalizados" - }, { "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", "translation": "O banner do canal só pode ser configurado em canais Públicos ou Privados." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Não é possível contar o número de campos para o grupo de atributos do perfil personalizado" - }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Sua licença não oferece suporte a Atributos de Perfil Personalizados." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Não foi possível obter um leitor de arquivo zip." diff --git a/server/i18n/ro.json b/server/i18n/ro.json index 74d41dd2deb..e63132eae2c 100644 --- a/server/i18n/ro.json +++ b/server/i18n/ro.json @@ -673,7 +673,11 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} este deja în canal." + "translation": { + "few": "", + "one": "{{.User}} este deja în canal.", + "other": "" + } }, { "id": "api.command_invite_people.permission.app_error", @@ -6423,10 +6427,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Canalele nu pot fi obținute prin ID-uri." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Imposibil de obținut numărul de canale." - }, { "id": "app.channel.count_posts_since.app_error", "translation": "Imposibil de numărat mesaje de la data dată." @@ -7799,14 +7799,6 @@ "id": "api.command_remote.permission_required", "translation": "Aveți nevoie de permisiunea `{{.Permission}}` pentru a gestiona clusterele la distanță." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Asigurați-vă că bucket-ul Amazon S3 este disponibil și verificați permisiunile bucket-ului." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Nu se poate conecta la S3. Verificați parametrii de autorizare a conexiunii Amazon S3 și setările de autentificare." - }, { "id": "api.command_remote.displayname.hint", "translation": "Un nume de afișare pentru clusterul la distanță" diff --git a/server/i18n/ru.json b/server/i18n/ru.json index 75068259446..3c738352e8e 100644 --- a/server/i18n/ru.json +++ b/server/i18n/ru.json @@ -6555,10 +6555,6 @@ "id": "app.channel.get_channels_by_ids.get.app_error", "translation": "Невозможно получить каналы." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Невозможно получить количество каналов." - }, { "id": "app.team.update.updating.app_error", "translation": "Возникла ошибка обновления команды." @@ -7803,14 +7799,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "Типы заданий, которые вы пытаетесь получить, не содержат разрешений" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Убедитесь в доступности козины Amazon S3 и проверьте разрешения на доступ." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Невозможно подключиться к S3. Проверьте параметры авторизации и настройки аутентификации подключения Amazon S3." - }, { "id": "ent.data_retention.policies.invalid_policy", "translation": "Политика некорректна." @@ -9763,26 +9751,6 @@ "id": "api.shared_channel.uninvite_remote_to_channel_error", "translation": "Не удалось отменить приглашение дистанционного пользователя на канал" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Невозможно зарегистрировать группу свойств \"Атрибуты пользовательского профиля\"" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Невозможно получить поле \"Атрибут пользовательского профиля\"" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Достигнут лимит поля \"Атрибуты пользовательского профиля\"" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Невозможно получить значения пользовательских атрибутов профиля" - }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Ваша лицензия не поддерживает пользовательские атрибуты профиля." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Невозможно получить программу для чтения zip-файлов." @@ -9815,10 +9783,6 @@ "id": "api.context.get_session.app_error", "translation": "Сеанс не найден." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Невозможно подсчитать количество полей для группы атрибутов пользовательского профиля" - }, { "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", "translation": "Баннер канала можно настроить только на публичных и приватных каналах." @@ -9983,22 +9947,6 @@ "id": "app.delete_scheduled_post.existing_scheduled_post.not_exist", "translation": "Запланированное сообщение не существует." }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Невозможно вставить пользовательские поля атрибутов профиля" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Невозможно удалить поле Атрибут пользовательского профиля" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Поле Атрибут пользовательского профиля не найдено" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Невозможно выполнить поиск по полям пользовательских атрибутов профиля" - }, { "id": "api.user.reset_password_failed_attempts.ldap_and_email_only.app_error", "translation": "Служба аутентификации пользователей должна быть LDAP или Email." @@ -10007,14 +9955,6 @@ "id": "api.user.reset_password_failed_attempts.permissions.app_error", "translation": "У вас нет разрешения на обновление этого ресурса." }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Невозможно обновить поле Атрибут пользовательского профиля" - }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Неверное значение свойства Атрибут : {{.AttributeName}} ({{.Reason}})." - }, { "id": "api.admin.add_certificate.multiple_files.app_error", "translation": "Слишком много файлов в разделе 'certificate' в запросе." @@ -10575,26 +10515,6 @@ "id": "app.command.validatecommandtriggeruniqueness.internal_error", "translation": "Указанное ключевое слово триггера уже существует." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Невозможно удалить пользовательские значения атрибутов профиля для пользователя" - }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "Невозможно исправить поле \"Атрибут пользовательского профиля\"" - }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Невозможно обновить значение для поля Атрибут пользовательского профиля, управляемого администратором" - }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Невозможно обновить значение для синхронизированного поля Атрибут пользовательского профиля" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Не удалось проверить значение свойства" - }, { "id": "app.data_spillage.assign_reviewer.no_reviewer_field.app_error", "translation": "Не найдено поле свойств идентификатора рецензента." diff --git a/server/i18n/sl.json b/server/i18n/sl.json index 40506f51ed5..5fb5967285e 100644 --- a/server/i18n/sl.json +++ b/server/i18n/sl.json @@ -3696,7 +3696,12 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} je že v kanalu." + "translation": { + "few": "", + "one": "{{.User}} je že v kanalu.", + "other": "", + "two": "" + } }, { "id": "api.command_invite.success", diff --git a/server/i18n/sv.json b/server/i18n/sv.json index e5543a7c43c..0394c5ece9e 100644 --- a/server/i18n/sv.json +++ b/server/i18n/sv.json @@ -3767,10 +3767,6 @@ "id": "app.channel.get_channels.get.app_error", "translation": "Kunde inte hämta kanalerna." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Kunde inte hämta antalet kanaler." - }, { "id": "app.channel.get_by_scheme.app_error", "translation": "Kunde inte få fram kanaler för angivet schema." @@ -7702,14 +7698,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "Jobbtyperna för ett jobb som du försöker hämta innehåller inga behörigheter" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Säkerställ att din Amazon S3 bucket är tillgänglig och kontrollera rättigheterna." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Kunde inte ansluta till S3. Kontrollera auktorisations och autentiseringsparametrar för Amazon S3." - }, { "id": "api.context.remote_id_missing.app_error", "translation": "Id för säker anslutning saknas." @@ -10011,22 +9999,6 @@ "id": "web.incoming_webhook.decode.app_error", "translation": "Misslyckades med att tolka datat av mediatyp {{.media_type}} för inkommande webhook {{.hook_id}}." }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Det går inte att registrera en egenskapsgrupp för anpassade profilattribut" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Det går inte att hämta värden för anpassade profilattribut" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Det går inte att ta bort det anpassade profilattributfältet" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Det går inte att söka efter fälten för anpassade profilattribut" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Misslyckades med att ta bort de begärda filerna från databasen" @@ -10075,38 +10047,18 @@ "id": "model.property_value.is_valid.app_error", "translation": "Ogiltigt egenskapsvärde: {{.FieldName}} ({{.Reason}})." }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Det går inte att hämta fälten för anpassade profilattribut" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Det går inte att uppdatera anpassade profilattribut-fältet" - }, { "id": "app.file_info.undelete_for_post_ids.app_error", "translation": "Misslyckades med att återställa bilagor till postfiler." }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Gränsen för fältet för anpassade profilattribut har nåtts" - }, { "id": "app.post.restore_post_version.not_valid_post_history_item.app_error", "translation": "Det angivna meddelande-ID:t för inläggshistorik motsvarar inte något historiskt meddelandet." }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Anpassade profilattribut-fältet hittades inte" - }, { "id": "app.file_info.get_by_ids.app_error", "translation": "Det går inte att få filinformationen genom id för postredigeringshistorik." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Din licens tillåter inte anpassade profil-attribut." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Kunde inte använda en zip-filsläsare." @@ -10139,14 +10091,6 @@ "id": "api.context.get_session.app_error", "translation": "Sessionen hittades inte." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Det går inte att räkna antalet fält i den anpassade attributprofilgruppen" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Det går inte att lägga till fält i den anpassade attributprofilen" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Ogiltigt användar-ID på klientsidan: {{.Id}}" @@ -10215,10 +10159,6 @@ "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Det går inte att konvertera egenskapsfältet till ett attributfält för anpassad profil" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Ogiltiga attribut på egenskap : {{.AttributeName}} ({{.Reason}})." - }, { "id": "app.group.license_error", "translation": "LDAP-licens krävs." @@ -10267,14 +10207,6 @@ "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} ska vara ett prefix från IndexPrefix {{.IndexPrefix}}." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Kunde inte ta bort användarens värden på anpassade profilattribut" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Misslyckades att validera egenskapens värde" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Misslyckades att hämta CPA-fält" @@ -10363,10 +10295,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "Mejladress för att rapportera ett problem krävs." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Det går inte att uppdatera värdet i ett synkroniserat fält i en anpassad profil" - }, { "id": "api.channel.add_user.to.channel.rejected", "translation": "Användaren har inte de attribut som krävs för att ansluta till kanalen." @@ -10679,10 +10607,6 @@ "id": "model.config.is_valid.experimental_view_archived_channels.app_error", "translation": "Att dölja arkiverade kanaler stöds inte längre. Gör dessa kanaler privata och ta bort medlemmar istället." }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Det går inte att uppdatera värdet i ett administratörshanterat attributfält för anpassad profil" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "Ett fel uppstod vid avkodning av JSON-svar från en interaktiv dialog." diff --git a/server/i18n/tr.json b/server/i18n/tr.json index 2c657fbd7c4..c6dbf27fc6c 100644 --- a/server/i18n/tr.json +++ b/server/i18n/tr.json @@ -6238,10 +6238,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Kodlara göre kanallar alınamadı." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Kanal sayıları alınamadı." - }, { "id": "app.channel.count_posts_since.app_error", "translation": "Belirtilen tarihten sonraki ileti sayıları belirlenemedi." @@ -7702,14 +7698,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "Almaya çalıştığınız bir görevin görev türlerinde izinler bulunmuyor" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Amazon S3 klasörünüzün kullanılabilir olduğundan emin olduktan sonra klasör izinlerinizi denetleyin." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "S3 bağlantısı kurulamadı. Amazon S3 bağlantı kimlik doğrulama parametrelerinizi ve kimlik doğrulama ayarlarınızı denetleyin." - }, { "id": "api.context.remote_id_missing.app_error", "translation": "Güvenli bağlantı kimliği eksik." @@ -10011,38 +9999,6 @@ "id": "app.export.export_custom_emoji.mkdir.error", "translation": "Özel ifade görselleri için bir klasörler oluşturulamadı" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Özel profil öznitelikleri özellik grubu kaydedilemedi." - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Kullanıcı özniteliği değerleri alınamadı" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Kullanızı özniteliği alanı alınamadı" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Kullanıcı özniteliği alanı sayısı sınırına ulaşıldı" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Kullanıcı özniteliği alanı güncellenemedi" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Kullanıcı özniteliği alanı silinemedi" - }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Kullanıcı özniteliği alanı bulunamadı" - }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Lisansınızda kullanıcı öznitelikleri özelliği yok." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Bir zip dosyası okuyucusu alınamadı." @@ -10087,10 +10043,6 @@ "id": "model.property_field.is_valid.app_error", "translation": "Özellik alanı geçersiz: {{.FieldName}} ({{.Reason}})." }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Kullanıcı özniteliği alanları aranamaz" - }, { "id": "app.post.restore_post_version.not_an_history_item.app_error", "translation": "Belirtilen ileti geçmişi kimliği, belirtilen ileti için herhangi bir geçmiş ögesine karşılık gelmiyor." @@ -10143,18 +10095,10 @@ "id": "api.context.get_session.app_error", "translation": "Oturum bulunamadı." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Kullanıcı öznitelikleri grubu için alan sayısı belirlenemedi" - }, { "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", "translation": "ClientSideUserIds {{.CurrentLength}} içindeki öge sayısı olabilecek en fazla {{.MaxLength}} değerinden büyük." }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Özel profil özniteliği alanları güncellenemedi veya eklenemedi" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "İstemci tarafı kullanıcı kimliği geçersiz: {{.Id}}" @@ -10179,10 +10123,6 @@ "id": "model.channel.is_valid.banner_info.text.invalid_length.app_error", "translation": "Kanal duyurusu bilgi yazısı çok uzun. En fazla {{.maxLength}} karakter uzunluğunda olabilir." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Kullanıcının kullanıcı özniteliği değerleri silinemedi" - }, { "id": "api.admin.add_certificate.app_error", "translation": "Sertifika eklenemedi." @@ -10199,10 +10139,6 @@ "id": "api.license.load_metric.app_error", "translation": "Aylık etkin kullanıcı sayısı hesaplanamadı." }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "Eşitlenmiş bir kullanıcı özniteliği alanının değeri güncellenemedi" - }, { "id": "api.user.check_user_login_attempts.too_many_ldap.app_error", "translation": "Çok fazla başarısız olan parola denendiği için hesabınız kilitlendi. Lütfen sistem yöneticinizle görüşün." @@ -10223,10 +10159,6 @@ "id": "api.custom_profile_attributes.invalid_field_patch", "translation": "Kullanıcı özniteliği alanı yaması geçersiz" }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Özellik değeri doğrulanamadı" - }, { "id": "ent.saml.cpa_field_mapping.list_error", "translation": "CPA alanları alınamadı" @@ -10347,10 +10279,6 @@ "id": "model.access_policy.is_valid.rules_imports.app_error", "translation": "İlke kuralları içe aktarmalı ya da tanımlamalı." }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Özellik değeri önitelikleri geçersiz: {{.AttributeName}} ({{.Reason}})." - }, { "id": "model.access_policy.is_valid.type.app_error", "translation": "İlke türü geçersiz." @@ -10459,10 +10387,6 @@ "id": "app.cloud.preview_modal_data_parse_error", "translation": "Ön izleme penceresi verileri ayrıştırılamadı" }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "Yönetici tarafından yönetilen bir kullanıcı özniteliği alanının değeri güncellenemedi" - }, { "id": "app.group.create_syncable_memberships.error", "translation": "Grup eşitlenebilir üyelikleri oluşturulamadı." diff --git a/server/i18n/uk.json b/server/i18n/uk.json index 188d6b7e210..44fcfe04a5c 100644 --- a/server/i18n/uk.json +++ b/server/i18n/uk.json @@ -6275,10 +6275,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "Не вдається отримати канали за ідентифікаторами." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Не вдається отримати кількість каналів." - }, { "id": "app.team.update.updating.app_error", "translation": "Ми зіткнулися з помилкою при оновленні команди." @@ -7279,14 +7275,6 @@ "id": "api.file.read_file.app_error", "translation": "Не вдалося прочитати файл." }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Переконайтеся, що ваш контейнер Amazon S3 доступний та перевірте права доступу до нього." - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Не вдається підключитися до S3. Перевірте параметри авторизації підключення до Amazon S3 та налаштування автентифікації." - }, { "id": "api.file.file_reader.app_error", "translation": "Не вдалось отримати програму для читання файлів." @@ -10015,14 +10003,6 @@ "id": "api.filter_config_error", "translation": "Не вдалося відфільтрувати конфігурацію." }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "Не вдається видалити поле користувацького атрибуту профілю" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "Не вдається отримати значення користувацьких атрибутів профілю" - }, { "id": "app.role.delete.app_error", "translation": "Не вдалося видалити роль." @@ -10043,10 +10023,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "Неправильне значення властивості: {{.FieldName}} ({{.Reason}})." }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "Ваша ліцензія не підтримує користувацькі атрибути профілю." - }, { "id": "api.file.zip_file_reader.app_error", "translation": "Неможливо отримати програму для читання zip-файлів." @@ -10055,30 +10031,10 @@ "id": "ent.message_export.actiance_export.calculate_channel_exports.activity_message", "translation": "Підрахунок активності каналів: {{.NumCompleted}}/{{.NumChannels}} каналів завершено." }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "Не вдалось зареєструвати групу властивостей \"Атрибути користувацького профілю\"" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "Неможливо отримати поле \"Атрибут профілю користувача\"" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "Досягнуто ліміт поля \"Атрибути профілю користувача\"" - }, { "id": "api.command.execute_command.deleted.error", "translation": "Неможливо виконати команду у видаленому каналі." }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "Користувацьке поле атрибуту профілю не знайдено" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "Не вдається оновити поле атрибуту користувацького профілю" - }, { "id": "app.file_info.get_count.app_error", "translation": "Не вдалося порахувати всі файли." @@ -10103,14 +10059,6 @@ "id": "api.context.get_session.app_error", "translation": "Сеанс не знайдено." }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "Не вдалося підрахувати кількість полів для групи атрибутів користувацького профілю" - }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "Не вдалося додати поля користувацьких атрибутів профілю" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "Невірний ідентифікатор користувача на стороні клієнта: {{.Id}}" @@ -10123,10 +10071,6 @@ "id": "app.file_info.delete_for_post_ids.app_error", "translation": "Не вдалося видалити запитувані файли з бази даних" }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "Не вдалося виконати пошук полів атрибутів користувацького профілю" - }, { "id": "app.post.restore_post_version.not_an_history_item.app_error", "translation": "Наданий ідентифікатор історії допису не відповідає жодному елементу історії для вказаного допису." @@ -10219,10 +10163,6 @@ "id": "license_error.feature_unavailable", "translation": "Функція недоступна для поточної ліцензії" }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "Некоректні атрибути значень властивостей : {{.AttributeName}} ({{.Reason}})." - }, { "id": "app.custom_profile_attributes.property_field_conversion.app_error", "translation": "Не вдалося перетворити поле властивості на поле атрибута кастомного профілю" @@ -10287,18 +10227,10 @@ "id": "app.submit_interactive_dialog.decode_json_error", "translation": "Виникла помилка при декодуванні JSON-відповіді з інтерактивного діалогу." }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "Не вдалося видалити значення атрибутів спеціального профілю для користувача" - }, { "id": "ent.ldap.update_cpa.empty_attribute", "translation": "Порожнє значення атрибута LDAP" }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "Не вдалося перевірити значення властивості" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "Не вдалося отримати поля CPA" diff --git a/server/i18n/vi.json b/server/i18n/vi.json index 0f7ad5330ce..7170658ce18 100644 --- a/server/i18n/vi.json +++ b/server/i18n/vi.json @@ -7009,14 +7009,6 @@ "id": "api.file.test_connection_email_settings_nil.app_error", "translation": "Cài đặt email có giá trị chưa được đặt." }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Không thể kết nối với S3. Xác minh các tham số ủy quyền kết nối Amazon S3 và cài đặt xác thực của bạn." - }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Đảm bảo bộ chứa Amazon S3 của bạn có sẵn và xác minh quyền của bộ chứa." - }, { "id": "api.file.test_connection_s3_settings_nil.app_error", "translation": "Cài đặt lưu trữ tệp có giá trị chưa được đặt." @@ -8417,10 +8409,6 @@ "id": "app.channel.count_urgent_posts_since.app_error", "translation": "Không thể đếm các bài viết khẩn cấp kể từ ngày nhất định." }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "Không thể lấy được số lượng kênh." - }, { "id": "app.channel.get_channels_by_ids.app_error", "translation": "Không thể lấy kênh theo id." diff --git a/server/i18n/zh-CN.json b/server/i18n/zh-CN.json index 996eb6d3df8..ec7be264967 100644 --- a/server/i18n/zh-CN.json +++ b/server/i18n/zh-CN.json @@ -6449,10 +6449,6 @@ "id": "app.channel.get_channels_by_ids.app_error", "translation": "无法以 id 获得频道。" }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "无法获取频道数。" - }, { "id": "app.channel.count_posts_since.app_error", "translation": "无法以指定的日期计算消息数。" @@ -7737,14 +7733,6 @@ "id": "api.job.retrieve.nopermissions", "translation": "您尝试检索的作业类型不包含权限" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "确保您的 Amazon S3 存储桶可用,并验证您的存储桶权限。" - }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "无法连接到 S3。请检查您的 Amazon S3 连接授权参数和认证设置。" - }, { "id": "api.email_batching.send_batched_email_notification.title", "translation": "您有新消息" @@ -10001,34 +9989,6 @@ "id": "api.filter_config_error", "translation": "无法过滤配置。" }, - { - "id": "app.custom_profile_attributes.cpa_group_id.app_error", - "translation": "无法获取用户属性属性组。" - }, - { - "id": "app.custom_profile_attributes.get_property_field.app_error", - "translation": "无法获取用户属性字段" - }, - { - "id": "app.custom_profile_attributes.limit_reached.app_error", - "translation": "已达到用户属性字段数量上限" - }, - { - "id": "app.custom_profile_attributes.list_property_values.app_error", - "translation": "无法获取用户属性值" - }, - { - "id": "app.custom_profile_attributes.property_field_delete.app_error", - "translation": "无法删除用户属性字段" - }, - { - "id": "app.custom_profile_attributes.property_field_update.app_error", - "translation": "无法更新用户属性字段" - }, - { - "id": "app.custom_profile_attributes.search_property_fields.app_error", - "translation": "无法搜索用户属性字段" - }, { "id": "app.file_info.delete_for_post_ids.app_error", "translation": "无法从数据库中删除请求的文件" @@ -10081,10 +10041,6 @@ "id": "model.property_value.is_valid.app_error", "translation": "属性值:{{.FieldName}}({{.Reason}}) 无效。" }, - { - "id": "app.custom_profile_attributes.property_field_not_found.app_error", - "translation": "未找到用户属性字段" - }, { "id": "ent.message_export.calculate_channel_exports.app_error", "translation": "无法计算频道导出数据。" @@ -10097,10 +10053,6 @@ "id": "api.command.execute_command.deleted.error", "translation": "不能在已删除的频道中执行命令。" }, - { - "id": "api.custom_profile_attributes.license_error", - "translation": "您的许可证不支持用户属性。" - }, { "id": "api.file.zip_file_reader.app_error", "translation": "无法获取 zip 文件读取器。" @@ -10133,10 +10085,6 @@ "id": "api.context.get_session.app_error", "translation": "会话未找到。" }, - { - "id": "app.custom_profile_attributes.count_property_fields.app_error", - "translation": "无法统计用户属性组的字段数量" - }, { "id": "model.config.is_valid.metrics_client_side_user_id.app_error", "translation": "客户端侧用户 ID:{{.Id}} 无效" @@ -10145,10 +10093,6 @@ "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", "translation": "ClientSideUserIds 中的元素数量 {{.CurrentLength}} 大于上限 {{.MaxLength}}。" }, - { - "id": "app.custom_profile_attributes.property_value_upsert.app_error", - "translation": "无法插入或更新用户属性字段" - }, { "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", "translation": "频道横幅只能在公共频道和私有频道上设置。" @@ -10209,18 +10153,6 @@ "id": "api.admin.add_certificate.app_error", "translation": "添加证书失败。" }, - { - "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", - "translation": "无法删除该用户的用户属性值" - }, - { - "id": "app.custom_profile_attributes.validate_value.app_error", - "translation": "验证属性值失败" - }, - { - "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", - "translation": "属性值属性:{{.AttributeName}} ({{.Reason}})无效。" - }, { "id": "ent.ldap.cpa_field_mapping.list_error", "translation": "获取 CPA 字段失败" @@ -10353,10 +10285,6 @@ "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", "translation": "报告问题邮箱为必填项。" }, - { - "id": "app.custom_profile_attributes.property_field_is_synced.app_error", - "translation": "无法更新已同步用户属性字段值" - }, { "id": "api.access_control_policy.get_channels.limit.app_error", "translation": "获取频道限制无效。" @@ -10645,10 +10573,6 @@ "id": "app.access_control.insufficient_permissions", "translation": "您没有权限管理此访问控制策略。" }, - { - "id": "app.custom_profile_attributes.property_field_is_managed.app_error", - "translation": "无法更新由管理员管理的用户属性字段值" - }, { "id": "app.lookup_interactive_dialog.decode_json_error", "translation": "解码来自交互式对话框查找的 JSON 响应时发生错误。" @@ -11009,10 +10933,6 @@ "id": "app.burn_post.read_receipt.update.error", "translation": "更新已读回执时发生错误。" }, - { - "id": "app.custom_profile_attributes.patch_field.app_error", - "translation": "无法修补用户属性字段" - }, { "id": "app.pap.update_access_control_policies_active.app_error", "translation": "无法更新访问控制策略的激活状态。" @@ -11789,10 +11709,6 @@ "id": "api.file.upload_file.abac_denied.app_error", "translation": "您没有向此频道上传文件所需的访问权限。" }, - { - "id": "api.managed_category.feature_not_available.app_error", - "translation": "托管频道分类不可用。" - }, { "id": "api.oauth.get_access_token.client_id_mismatch.app_error", "translation": "invalid_grant:令牌授权并非发放给此客户端。" @@ -11833,10 +11749,6 @@ "id": "api.property_field.delete.no_permission.app_error", "translation": "您没有删除此属性字段的权限。" }, - { - "id": "api.property_field.delete.protected_via_api.app_error", - "translation": "无法通过 API 删除受保护的属性字段。" - }, { "id": "api.property_field.get.invalid_target_type.app_error", "translation": "需要有效的 target_type(system、team 或 channel)。" @@ -11865,10 +11777,6 @@ "id": "api.property_field.update.no_options_permission.app_error", "translation": "您没有管理此属性字段选项的权限。" }, - { - "id": "api.property_field.update.protected_via_api.app_error", - "translation": "无法通过 API 更新受保护的属性字段。" - }, { "id": "api.property_value.invalid_object_type.app_error", "translation": "提供的对象类型无效。" @@ -11893,10 +11801,6 @@ "id": "api.property_value.patch.too_many_items.request_error", "translation": "无法更新这么多属性值。一次最多只能更新 {{.Max}} 个属性值。" }, - { - "id": "api.property_value.target_user.forbidden.app_error", - "translation": "您没有访问其他用户属性值的权限。" - }, { "id": "api.shared_channel.attachment.creator_id_required.app_error", "translation": "FileInfo.CreatorId 为必填项。" @@ -12029,10 +11933,6 @@ "id": "app.command.validatecommandtriggeruniqueness.internal_error", "translation": "指定的触发关键字已存在。" }, - { - "id": "app.custom_profile_attributes.get_property_value.app_error", - "translation": "无法获取用户属性值" - }, { "id": "app.data_spillage.assign_reviewer.no_reviewer_field.app_error", "translation": "未找到审核员 ID 属性字段。" @@ -12281,10 +12181,6 @@ "id": "app.property_field.get_many.app_error", "translation": "无法获取属性字段列表。" }, - { - "id": "app.property_field.get_many.fields_not_found.app_error", - "translation": "指定组中未找到一个或多个属性字段 ID。" - }, { "id": "app.property_field.invalid_input.app_error", "translation": "提供的输入无效。" @@ -12653,22 +12549,6 @@ "id": "api.property.v2_group_not_found.app_error", "translation": "未找到指定的属性组。" }, - { - "id": "api.property_field.patch.cannot_link_existing.app_error", - "translation": "无法在已存在的字段上设置 linked_field_id。只能在创建时设置。" - }, - { - "id": "api.property_field.patch.linked_field_change.app_error", - "translation": "无法更改链接目标。请先取消链接,再创建一个新的链接字段。" - }, - { - "id": "api.property_field.patch.linked_options_change.app_error", - "translation": "无法修改链接字段的选项。选项继承自来源。" - }, - { - "id": "api.property_field.patch.linked_type_change.app_error", - "translation": "无法修改链接字段的类型。类型继承自来源。" - }, { "id": "api.property_value.template_no_values.app_error", "translation": "模板字段不能有值。" @@ -12772,5 +12652,209 @@ { "id": "model.property_group.is_valid.app_error", "translation": "属性组无效:{{.FieldName}}({{.Reason}})。" + }, + { + "id": "shared_channel.system_message.now_shared", + "translation": "此频道现已与 {{.WorkspaceName}} 共享。" + }, + { + "id": "app.data_spillage.report.cleared", + "translation": "已清除" + }, + { + "id": "app.data_spillage.report.column.detail", + "translation": "详情" + }, + { + "id": "app.data_spillage.report.generated", + "translation": "生成时间:" + }, + { + "id": "app.data_spillage.report.status.failed", + "translation": "失败" + }, + { + "id": "app.data_spillage.report.step.acknowledgements", + "translation": "已读确认" + }, + { + "id": "app.data_spillage.report.step.fileinfo_rows", + "translation": "文件信息记录" + }, + { + "id": "app.data_spillage.report.step.thread_data", + "translation": "话题、回复和表情回应" + }, + { + "id": "app.file_info.not_found", + "translation": "文件存储中未找到路径对应的文件。" + }, + { + "id": "model.cluster.is_valid.site_url.app_error", + "translation": "必须设置 SiteURL。" + }, + { + "id": "shared_channel.system_message.no_longer_shared", + "translation": "此频道不再与 {{.WorkspaceName}} 共享。" + }, + { + "id": "shared_channel.system_message.no_longer_shared_unknown", + "translation": "此频道不再与另一个工作区共享。" + }, + { + "id": "app.data_spillage.report.column.step", + "translation": "步骤" + }, + { + "id": "app.pap.access_control.channel_default", + "translation": "成员资格策略不能应用于团队默认频道。" + }, + { + "id": "app.pap.access_control.channel_type_not_supported", + "translation": "访问控制策略只能应用于公共频道或私有频道。" + }, + { + "id": "model.cpa_field.name.invalid_charset.app_error", + "translation": "无效的 CPA 字段名称“{{.Name}}”:必须匹配 ^[A-Za-z_][A-Za-z0-9_]*$(CEL 标识符规则)。" + }, + { + "id": "app.data_spillage.report.detail.deleted", + "translation": "已删除。" + }, + { + "id": "app.data_spillage.report.detail.failed_retrieve_edit_history", + "translation": "无法获取编辑历史。" + }, + { + "id": "app.data_spillage.report.detail.file_attachments_info_ids", + "translation": "**文件信息 ID:** {{.FileInfoIDs}}" + }, + { + "id": "app.data_spillage.report.error_log", + "translation": "错误日志" + }, + { + "id": "app.data_spillage.report.step.reminders", + "translation": "提醒" + }, + { + "id": "app.data_spillage.report.total_steps", + "translation": "总步骤:" + }, + { + "id": "app.data_spillage.report.status.not_applicable", + "translation": "不适用" + }, + { + "id": "app.data_spillage.report.status.partial", + "translation": "部分完成" + }, + { + "id": "app.data_spillage.report.status.removed", + "translation": "已移除" + }, + { + "id": "app.data_spillage.report.status.unknown", + "translation": "未知" + }, + { + "id": "app.data_spillage.report.step.edit_histories", + "translation": "编辑历史" + }, + { + "id": "app.data_spillage.report.step.file_attachments", + "translation": "文件附件" + }, + { + "id": "app.data_spillage.report.step.persistent_notifications", + "translation": "持久通知" + }, + { + "id": "app.data_spillage.report.step.post_itself", + "translation": "消息记录" + }, + { + "id": "app.data_spillage.report.step.priority_data", + "translation": "优先级元数据" + }, + { + "id": "app.data_spillage.report.summary", + "translation": "摘要" + }, + { + "id": "app.data_spillage.report.title", + "translation": "消息删除报告" + }, + { + "id": "app.file_info.remove_file.app_error", + "translation": "无法从文件存储中移除文件。" + }, + { + "id": "model.cpa_field.name.reserved_word.app_error", + "translation": "无效的 CPA 字段名称“{{.Name}}”:这是 CEL 保留字,不能用作字段标识符。" + }, + { + "id": "app.recap.mark_viewed.app_error", + "translation": "无法将回顾标记为已查看。" + }, + { + "id": "api.channel.update_channel.policy_enforced_type_conversion.app_error", + "translation": "此频道已应用基于属性的成员资格策略。请先移除该策略,再在公共频道和私有频道之间转换。" + }, + { + "id": "api.property_value.system_use_dedicated_route.app_error", + "translation": "系统值必须使用专用的系统值端点。" + }, + { + "id": "app.data_spillage.report.column.status", + "translation": "状态" + }, + { + "id": "app.data_spillage.report.detail.file_names", + "translation": { + "other": "已从磁盘中移除 {{.Count}} 个文件。" + } + }, + { + "id": "app.data_spillage.report.detail.no_data_found", + "translation": "未找到数据。" + }, + { + "id": "app.data_spillage.report.detail.no_files", + "translation": "未找到文件。" + }, + { + "id": "app.data_spillage.report.detail.no_rows_to_delete", + "translation": "没有可删除的行。" + }, + { + "id": "app.data_spillage.report.detail.post_scrubbed_deleted", + "translation": "消息已清理并删除。" + }, + { + "id": "app.data_spillage.report.detail.revisions_cleared", + "translation": { + "other": "已清除 {{.Count}} 个修订,共 {{.Total}} 个。" + } + }, + { + "id": "app.data_spillage.report.detail.thread_data_deleted", + "translation": "话题、表情回应及相关数据已删除。" + }, + { + "id": "app.data_spillage.report.incomplete_warning", + "translation": "消息删除未完成。请查看错误日志,并联系系统管理员进行手动修复。" + }, + { + "id": "app.data_spillage.report.post_id", + "translation": "消息 ID:" + }, + { + "id": "app.data_spillage.report.revision", + "translation": "修订" + }, + { + "id": "app.data_spillage.report.revisions_found", + "translation": "发现的修订:" } ] diff --git a/server/i18n/zh-TW.json b/server/i18n/zh-TW.json index cc3263e2e0c..fbfd1389879 100644 --- a/server/i18n/zh-TW.json +++ b/server/i18n/zh-TW.json @@ -671,7 +671,9 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} 已在頻道當中。" + "translation": { + "other": "{{.User}} 已在頻道當中。" + } }, { "id": "api.command_invite_people.permission.app_error", @@ -6447,10 +6449,6 @@ "id": "app.channel.permanent_delete_members_by_user.app_error", "translation": "無法移除頻道成員。" }, - { - "id": "app.channel.get_channel_counts.get.app_error", - "translation": "無法取得頻道數量。" - }, { "id": "app.channel.analytics_type_count.app_error", "translation": "無法取得頻道類型數量。" @@ -7579,10 +7577,6 @@ "id": "api.command_share.remote_id.help", "translation": "既有安全連線的 ID。請參見 `secure-connection`命令以新增一個安全連線。" }, - { - "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "請確認您的 Amazon S3 儲存貯體可供使用,並檢查您的儲存貯體權限。" - }, { "id": "api.file.test_connection_s3_settings_nil.app_error", "translation": "檔案儲存空間設定有未設的選項。" @@ -7923,10 +7917,6 @@ "id": "api.file.test_connection_email_settings_nil.app_error", "translation": "電子郵件設定有未設的值。" }, - { - "id": "api.file.test_connection_s3_auth.app_error", - "translation": "無法連線至 S3。請確認您的 Amazon S3 授權參數及認證設定。" - }, { "id": "api.file.write_file.app_error", "translation": "無法寫入檔案。" diff --git a/server/platform/services/cache/mocks/Provider.go b/server/platform/services/cache/mocks/Provider.go index 3c53f2562e3..596754e049d 100644 --- a/server/platform/services/cache/mocks/Provider.go +++ b/server/platform/services/cache/mocks/Provider.go @@ -7,7 +7,6 @@ package mocks import ( einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces" cache "github.com/mattermost/mattermost/server/v8/platform/services/cache" - mock "github.com/stretchr/testify/mock" ) diff --git a/server/platform/services/searchengine/mocks/SearchEngineInterface.go b/server/platform/services/searchengine/mocks/SearchEngineInterface.go index beba45ebfa1..d8d047b18f2 100644 --- a/server/platform/services/searchengine/mocks/SearchEngineInterface.go +++ b/server/platform/services/searchengine/mocks/SearchEngineInterface.go @@ -8,9 +8,8 @@ import ( context "context" model "github.com/mattermost/mattermost/server/public/model" - mock "github.com/stretchr/testify/mock" - request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" time "time" ) diff --git a/server/platform/services/sharedchannel/mock_AppIface_test.go b/server/platform/services/sharedchannel/mock_AppIface_test.go index 250d5808a7d..763d9c9ec35 100644 --- a/server/platform/services/sharedchannel/mock_AppIface_test.go +++ b/server/platform/services/sharedchannel/mock_AppIface_test.go @@ -5,12 +5,10 @@ package sharedchannel import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" filestore "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" - - request "github.com/mattermost/mattermost/server/public/shared/request" ) // MockAppIface is an autogenerated mock type for the AppIface type diff --git a/server/platform/services/sharedchannel/mock_ServerIface_test.go b/server/platform/services/sharedchannel/mock_ServerIface_test.go index d223f1df076..5930e079592 100644 --- a/server/platform/services/sharedchannel/mock_ServerIface_test.go +++ b/server/platform/services/sharedchannel/mock_ServerIface_test.go @@ -5,16 +5,12 @@ package sharedchannel import ( - mlog "github.com/mattermost/mattermost/server/public/shared/mlog" - einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces" - - mock "github.com/stretchr/testify/mock" - model "github.com/mattermost/mattermost/server/public/model" - - remotecluster "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster" - + mlog "github.com/mattermost/mattermost/server/public/shared/mlog" store "github.com/mattermost/mattermost/server/v8/channels/store" + einterfaces "github.com/mattermost/mattermost/server/v8/einterfaces" + remotecluster "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster" + mock "github.com/stretchr/testify/mock" ) // MockServerIface is an autogenerated mock type for the ServerIface type diff --git a/server/platform/shared/filestore/mocks/FileBackend.go b/server/platform/shared/filestore/mocks/FileBackend.go index 46bfefae050..823ac7d26b7 100644 --- a/server/platform/shared/filestore/mocks/FileBackend.go +++ b/server/platform/shared/filestore/mocks/FileBackend.go @@ -6,12 +6,10 @@ package mocks import ( io "io" + time "time" filestore "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" - mock "github.com/stretchr/testify/mock" - - time "time" ) // FileBackend is an autogenerated mock type for the FileBackend type diff --git a/server/public/go.mod b/server/public/go.mod index c93244d8c7e..a1db0e3b197 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -1,9 +1,9 @@ module github.com/mattermost/mattermost/server/public -go 1.26.2 +go 1.26.3 require ( - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a github.com/francoispqt/gojay v1.2.13 github.com/goccy/go-yaml v1.19.2 @@ -12,8 +12,8 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/hashicorp/go-plugin v1.7.0 - github.com/lib/pq v1.12.0 + github.com/hashicorp/go-plugin v1.8.0 + github.com/lib/pq v1.12.3 github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 github.com/mattermost/gosaml2 v0.10.0 github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 @@ -23,14 +23,14 @@ require ( github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 - github.com/tinylib/msgp v1.6.3 + github.com/tinylib/msgp v1.6.4 github.com/vmihailenco/msgpack/v5 v5.4.1 - golang.org/x/crypto v0.49.0 - golang.org/x/mod v0.34.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.51.0 + golang.org/x/mod v0.36.0 + golang.org/x/net v0.54.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.35.0 - golang.org/x/tools v0.43.0 + golang.org/x/text v0.37.0 + golang.org/x/tools v0.45.0 ) require ( @@ -46,7 +46,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/oklog/run v1.2.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -58,9 +58,9 @@ require ( github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/sys v0.44.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/grpc v1.81.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/server/public/go.sum b/server/public/go.sum index 2fd53876ca4..dd55cbe23ee 100644 --- a/server/public/go.sum +++ b/server/public/go.sum @@ -10,8 +10,8 @@ git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGy github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= @@ -89,8 +89,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= -github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= @@ -112,8 +112,8 @@ github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= -github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= @@ -132,8 +132,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -208,8 +208,8 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -224,16 +224,16 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= @@ -242,15 +242,15 @@ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -263,8 +263,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -296,17 +296,16 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -316,13 +315,13 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -335,14 +334,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/public/model/access_policy.go b/server/public/model/access_policy.go index ee6e408bd7d..b0e5d618f78 100644 --- a/server/public/model/access_policy.go +++ b/server/public/model/access_policy.go @@ -22,6 +22,7 @@ const ( AccessControlPolicyVersionV0_1 = "v0.1" AccessControlPolicyVersionV0_2 = "v0.2" AccessControlPolicyVersionV0_3 = "v0.3" + AccessControlPolicyVersionV0_4 = "v0.4" AccessControlPolicyActionMembership = "membership" AccessControlPolicyActionUploadFileAttachment = "upload_file_attachment" @@ -36,6 +37,47 @@ var allowedActionsV0_3 = map[string]bool{ AccessControlPolicyActionDownloadFileAttachment: true, } +// allowedChannelRolesV0_4 is the set of channel-scoped roles that may appear +// on a v0.4 channel resource policy rule. +var allowedChannelRolesV0_4 = map[string]bool{ + ChannelGuestRoleId: true, + ChannelUserRoleId: true, + ChannelAdminRoleId: true, +} + +// allowedPermissionActionsV0_4 is the set of non-membership actions that may +// appear on a v0.4 channel resource policy rule. These rules govern per-action +// behavior (file upload/download) and must carry a channel-scoped role. +var allowedPermissionActionsV0_4 = map[string]bool{ + AccessControlPolicyActionUploadFileAttachment: true, + AccessControlPolicyActionDownloadFileAttachment: true, +} + +// IsPermissionAction reports whether the given action is a non-membership +// permission action governed by a v0.4 channel rule. +func IsPermissionAction(action string) bool { + return allowedPermissionActionsV0_4[action] +} + +// HasPermissionRuleAction reports whether ANY rule on this policy +// carries a non-membership permission action (file upload/download). +// Used by the API4 layer to gate channel-scope policies behind the +// ChannelPermissionPolicies feature flag: if a channel policy +// includes a permission rule and the flag is off, the request is +// rejected before reaching the PAP. Returns false for a nil/empty +// policy so callers can use it as a guard without nil checks. +func (p *AccessControlPolicy) HasPermissionRuleAction() bool { + if p == nil { + return false + } + for i := range p.Rules { + if slices.ContainsFunc(p.Rules[i].Actions, IsPermissionAction) { + return true + } + } + return false +} + // AccessControlAttribute represents a user attribute with its name and possible values type AccessControlAttribute struct { Attribute PropertyField `json:"attribute"` @@ -101,6 +143,13 @@ type AccessControlPolicy struct { type AccessControlPolicyRule struct { Actions []string `json:"actions"` Expression string `json:"expression"` + // Name is an admin-facing label for the rule. Required for v0.4 permission + // rules and must be unique within the same policy. + Name string `json:"name,omitempty"` + // Role is the channel-scoped role this rule applies to (channel_guest, + // channel_user, channel_admin) for v0.4 permission rules. Membership rules + // must leave this empty. + Role string `json:"role,omitempty"` } type CELExpressionError struct { @@ -156,6 +205,8 @@ func (p *AccessControlPolicy) IsValid() *AppError { return p.accessPolicyVersionV0_2() case AccessControlPolicyVersionV0_3: return p.accessPolicyVersionV0_3() + case AccessControlPolicyVersionV0_4: + return p.accessPolicyVersionV0_4() default: return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) } @@ -332,6 +383,126 @@ func (p *AccessControlPolicy) accessPolicyVersionV0_3() *AppError { return nil } +// accessPolicyVersionV0_4 validates a v0.4 policy. v0.4 extends v0.3 by +// allowing channel resource policies to carry channel-role-scoped permission +// rules (upload/download file attachments) alongside membership rules. +// +// Constraints layered on top of v0.3: +// - Permission action rules MUST carry a non-empty Name (unique within the +// policy) and a Role in {channel_guest, channel_user, channel_admin}. +// - Membership rules MUST NOT carry a Role and MUST be alone in their rule +// entry (cannot be combined with permission actions). +// - Permission action rules are only allowed on `channel` policy types. +// `parent` and system `permission` policy types remain membership-only at +// v0.4 (multi-action support there is a follow-up iteration). +func (p *AccessControlPolicy) accessPolicyVersionV0_4() *AppError { + if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission}, p.Type) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400) + } + + if !IsValidId(p.ID) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400) + } + + if (p.Type == AccessControlPolicyTypeParent || p.Type == AccessControlPolicyTypePermission) && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400) + } + + if p.Revision < 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400) + } + + if !semver.IsValid(p.Version) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) + } + + switch p.Type { + case AccessControlPolicyTypeParent: + if len(p.Rules) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400) + } + if len(p.Imports) > 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400) + } + case AccessControlPolicyTypeChannel: + if len(p.Rules) == 0 && len(p.Imports) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400) + } + case AccessControlPolicyTypePermission: + if len(p.Rules) == 0 && len(p.Imports) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400) + } + if len(p.Roles) != 1 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400) + } + for _, role := range p.Roles { + if strings.TrimSpace(role) == "" { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400) + } + } + if len(p.Imports) > 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400) + } + } + + seenNames := make(map[string]struct{}) + for _, rule := range p.Rules { + if len(rule.Actions) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400) + } + + hasMembership := false + hasPermission := false + for _, action := range rule.Actions { + if !allowedActionsV0_3[action] { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("unrecognized action: %s", action), 400) + } + if action == AccessControlPolicyActionMembership { + hasMembership = true + } + if allowedPermissionActionsV0_4[action] { + hasPermission = true + } + } + + // Membership cannot be combined with permission actions in the same rule. + if hasMembership && hasPermission { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.membership_combined.app_error", nil, "membership cannot be combined with other actions in the same rule", 400) + } + + // Permission rules are only allowed on channel-type policies in v0.4. + if hasPermission && p.Type != AccessControlPolicyTypeChannel { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.permission_type.app_error", nil, "permission action rules are only allowed on channel policies", 400) + } + + // Permission rules require a Name (unique within policy) and a Role. + // Normalise once: TrimSpace lets the empty-/length-/uniqueness + // checks share the same view of the name, so authoring errors + // like "Uploads" vs "Uploads " are caught as duplicates instead + // of slipping through and forming two visually identical rules. + if hasPermission { + n := strings.TrimSpace(rule.Name) + if n == "" || len(n) > MaxPolicyNameLength { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "permission rules require a non-empty name within the policy max length", 400) + } + if !allowedChannelRolesV0_4[rule.Role] { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, fmt.Sprintf("invalid channel role: %q", rule.Role), 400) + } + if _, exists := seenNames[n]; exists { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name_unique.app_error", nil, fmt.Sprintf("duplicate rule name: %q", n), 400) + } + seenNames[n] = struct{}{} + } + + // Membership rules must not carry a role. + if hasMembership && rule.Role != "" { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "membership rules must not have a role", 400) + } + } + + return nil +} + func (p *AccessControlPolicy) Inherit(parent *AccessControlPolicy) *AppError { rules := make([]AccessControlPolicyRule, len(p.Rules)) @@ -362,6 +533,38 @@ func (p *AccessControlPolicy) Inherit(parent *AccessControlPolicy) *AppError { return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400) } p.Imports = append(p.Imports, parent.ID) + case AccessControlPolicyVersionV0_4: + if p.Type == AccessControlPolicyTypePermission || parent.Type == AccessControlPolicyTypePermission { + return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.permission.app_error", nil, "", 400) + } + // v0.4 channel policies may import v0.3 or v0.4 parent policies. + // Parents themselves remain membership-only at v0.4 (validator enforces). + if parent.Version != AccessControlPolicyVersionV0_3 && parent.Version != AccessControlPolicyVersionV0_4 { + return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400) + } + // v0.4 inherit is strictly child-channel → parent-membership. + // A channel→channel or permission→channel import has no + // well-defined semantics in the v0.4 model (parents are the + // only carriers of reusable membership rules), so reject the + // import rather than silently appending a peer policy's ID + // into Imports where the loader would later treat it as a + // membership parent. + if parent.Type != AccessControlPolicyTypeParent { + return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.parent_type.app_error", nil, "v0.4 imports must target a membership parent policy", 400) + } + if slices.Contains(p.Imports, parent.ID) { + return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.already_imported.app_error", nil, "", 400) + } + // Stage Imports on a probe copy so a post-merge IsValid failure + // leaves the receiver untouched (transactional contract). + newImports := append(slices.Clone(p.Imports), parent.ID) + probe := *p + probe.Imports = newImports + if appErr := probe.IsValid(); appErr != nil { + return appErr + } + p.Imports = newImports + return nil default: return NewAppError("AccessControlPolicy.Inherit", "model.access_policy.inherit.version.app_error", nil, "", 400) } diff --git a/server/public/model/access_policy_test.go b/server/public/model/access_policy_test.go index 05d71b01fc7..e9208ee206c 100644 --- a/server/public/model/access_policy_test.go +++ b/server/public/model/access_policy_test.go @@ -496,6 +496,455 @@ func TestAccessPolicyVersionV0_3(t *testing.T) { }) } +func TestAccessPolicyVersionV0_4(t *testing.T) { + validMembership := AccessControlPolicyRule{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "user.attributes.dept == \"eng\"", + } + validPermission := func(name, role, action string) AccessControlPolicyRule { + return AccessControlPolicyRule{ + Name: name, + Role: role, + Actions: []string{action}, + Expression: "user.attributes.dept == \"eng\"", + } + } + + t.Run("valid channel policy with membership and permission rules", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{ + validMembership, + validPermission("Block external uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), + validPermission("Admin overrides", ChannelAdminRoleId, AccessControlPolicyActionDownloadFileAttachment), + }, + } + require.Nil(t, policy.accessPolicyVersionV0_4()) + }) + + t.Run("permission rule missing role rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Name: "Block external uploads", + Actions: []string{AccessControlPolicyActionUploadFileAttachment}, + Expression: "true", + }}, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) + }) + + t.Run("permission rule with invalid role rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Name: "Block external uploads", + Role: SystemUserRoleId, // wrong scope: must be channel role + Actions: []string{AccessControlPolicyActionUploadFileAttachment}, + Expression: "true", + }}, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) + }) + + t.Run("permission rule missing name rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Role: ChannelUserRoleId, + Actions: []string{AccessControlPolicyActionUploadFileAttachment}, + Expression: "true", + }}, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.rule_name.app_error", err.Id) + }) + + t.Run("duplicate permission rule names rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{ + validPermission("Block uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), + validPermission("Block uploads", ChannelAdminRoleId, AccessControlPolicyActionDownloadFileAttachment), + }, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.rule_name_unique.app_error", err.Id) + }) + + t.Run("membership combined with permission action rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Name: "Combined", + Role: ChannelUserRoleId, + Actions: []string{AccessControlPolicyActionMembership, AccessControlPolicyActionUploadFileAttachment}, + Expression: "true", + }}, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.actions.membership_combined.app_error", err.Id) + }) + + t.Run("membership rule with role rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Role: ChannelUserRoleId, + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) + }) + + t.Run("permission rule on parent policy rejected", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Parent", + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{ + validPermission("Block uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), + }, + } + err := policy.accessPolicyVersionV0_4() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.actions.permission_type.app_error", err.Id) + }) +} + +func TestInheritV0_4(t *testing.T) { + t.Run("v0.4 child can import v0.4 parent", func(t *testing.T) { + // Same-version happy path: a v0.4 channel policy importing + // another v0.4 parent should be accepted (Inherit only blocks + // v0.4 children importing pre-v0.3 parents). + parentID := NewId() + parent := &AccessControlPolicy{ + ID: parentID, + Type: AccessControlPolicyTypeParent, + Name: "Parent V04", + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + + err := child.Inherit(parent) + require.Nil(t, err) + require.Contains(t, child.Imports, parentID) + }) + + t.Run("v0.4 child can import v0.3 parent", func(t *testing.T) { + parentID := NewId() + parent := &AccessControlPolicy{ + ID: parentID, + Type: AccessControlPolicyTypeParent, + Name: "Parent", + Revision: 0, + Version: AccessControlPolicyVersionV0_3, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + + err := child.Inherit(parent) + require.Nil(t, err) + require.Contains(t, child.Imports, parentID) + }) + + t.Run("v0.4 child cannot import v0.1 parent", func(t *testing.T) { + parent := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "V01 Parent", + Revision: 0, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{ + Actions: []string{"read"}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + + err := child.Inherit(parent) + require.NotNil(t, err) + require.Equal(t, "model.access_policy.inherit.version.app_error", err.Id) + }) + + t.Run("v0.4 child rejects permission-type parent", func(t *testing.T) { + parent := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypePermission, + Name: "Permission", + Revision: 0, + Version: AccessControlPolicyVersionV0_3, + Roles: []string{"system_admin"}, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + + err := child.Inherit(parent) + require.NotNil(t, err) + require.Equal(t, "model.access_policy.inherit.permission.app_error", err.Id) + }) + + // v0.4 imports are strictly child-channel → parent-membership. + // A channel→channel import would write a peer channel policy's ID + // into Imports where the loader expects a membership parent — the + // resulting evaluation would silently misroute. Reject up front. + t.Run("v0.4 child rejects channel-type parent", func(t *testing.T) { + parent := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Revision: 0, + Version: AccessControlPolicyVersionV0_4, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + + err := child.Inherit(parent) + require.NotNil(t, err) + require.Equal(t, "model.access_policy.inherit.parent_type.app_error", err.Id) + require.Empty(t, child.Imports, "rejected imports must not leak into the child's Imports slice") + }) +} + +func TestSubjectRoleForScope(t *testing.T) { + t.Run("scoped roles take precedence", func(t *testing.T) { + s := &Subject{ + Role: SystemUserRoleId, // legacy field + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, + }, + } + require.Equal(t, SystemAdminRoleId, s.RoleForScope(AccessControlSubjectScopeSystem)) + require.Equal(t, ChannelAdminRoleId, s.RoleForScope(AccessControlSubjectScopeChannel)) + }) + + t.Run("falls back to legacy Role for system scope when ScopedRoles empty", func(t *testing.T) { + s := &Subject{Role: SystemAdminRoleId} + require.Equal(t, SystemAdminRoleId, s.RoleForScope(AccessControlSubjectScopeSystem)) + require.Equal(t, "", s.RoleForScope(AccessControlSubjectScopeChannel)) + }) + + t.Run("returns empty for unknown scope", func(t *testing.T) { + s := &Subject{} + require.Equal(t, "", s.RoleForScope("unknown")) + }) +} + +func TestSubjectRolesForScope(t *testing.T) { + t.Run("returns every entry matching the scope in order", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, + {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, + }, + } + require.Equal(t, []string{SystemUserRoleId, SystemAdminRoleId}, s.RolesForScope(AccessControlSubjectScopeSystem)) + require.Equal(t, []string{ChannelAdminRoleId}, s.RolesForScope(AccessControlSubjectScopeChannel)) + }) + + t.Run("returns nil when no entry matches", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, + } + require.Nil(t, s.RolesForScope(AccessControlSubjectScopeChannel)) + }) + + t.Run("does NOT fall back to legacy Role for system scope", func(t *testing.T) { + s := &Subject{Role: SystemAdminRoleId} + require.Nil(t, s.RolesForScope(AccessControlSubjectScopeSystem)) + }) +} + +func TestSubjectSetScopedRole(t *testing.T) { + t.Run("appends when scope is absent", func(t *testing.T) { + s := &Subject{} + s.SetScopedRole(AccessControlSubjectScopeSystem, SystemUserRoleId) + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, s.ScopedRoles) + }) + + t.Run("replaces in place when scope already exists", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + }, + } + s.SetScopedRole(AccessControlSubjectScopeSystem, SystemAdminRoleId) + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + }, s.ScopedRoles) + }) + + t.Run("collapses duplicate scope entries to one", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + {Scope: AccessControlSubjectScopeSystem, Role: SystemGuestRoleId}, + }, + } + s.SetScopedRole(AccessControlSubjectScopeSystem, SystemAdminRoleId) + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + }, s.ScopedRoles) + }) + + t.Run("empty role removes every entry for the scope", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + {Scope: AccessControlSubjectScopeSystem, Role: SystemGuestRoleId}, + }, + } + s.SetScopedRole(AccessControlSubjectScopeSystem, "") + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, + }, s.ScopedRoles) + }) + + t.Run("empty role on absent scope is a no-op", func(t *testing.T) { + s := &Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, + } + s.SetScopedRole(AccessControlSubjectScopeChannel, "") + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, s.ScopedRoles) + }) + + t.Run("empty scope is a no-op", func(t *testing.T) { + original := []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + } + s := &Subject{ScopedRoles: original} + s.SetScopedRole("", SystemAdminRoleId) + require.Equal(t, original, s.ScopedRoles) + }) + + t.Run("does not mutate aliased backing array", func(t *testing.T) { + // Mirrors the attachChannelScopedRole hot path: a cached Subject is + // passed by value, its ScopedRoles slice header is copied but the + // backing array is shared. SetScopedRole must allocate a fresh array + // so the cached Subject's ScopedRoles is not corrupted. + cached := Subject{ + ScopedRoles: []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, + } + copyOfCached := cached + copyOfCached.SetScopedRole(AccessControlSubjectScopeChannel, ChannelAdminRoleId) + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + }, cached.ScopedRoles, "cached Subject's ScopedRoles must not be mutated") + require.Equal(t, []ScopedRole{ + {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, + {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, + }, copyOfCached.ScopedRoles) + }) +} + func TestInheritV0_3(t *testing.T) { t.Run("successful inherit", func(t *testing.T) { parentID := NewId() diff --git a/server/public/model/access_request.go b/server/public/model/access_request.go index 75861e68c3d..0ec29a75470 100644 --- a/server/public/model/access_request.go +++ b/server/public/model/access_request.go @@ -3,6 +3,24 @@ package model +// AccessControlSubjectScope* enumerates the supported scopes for ScopedRole. +const ( + AccessControlSubjectScopeSystem = "system" + AccessControlSubjectScopeChannel = "channel" +) + +// ScopedRole pairs a role identifier with the scope it applies to. A subject +// may carry multiple ScopedRoles (e.g. one for the system, one for a channel) +// so the PDP can select the appropriate role when matching against a v0.4 +// channel resource policy rule whose Role field is a channel-scoped role. +type ScopedRole struct { + // Scope is one of AccessControlSubjectScope* constants. + Scope string `json:"scope"` + // Role is the role identifier within that scope (e.g. "system_user", + // "channel_admin"). + Role string `json:"role"` +} + // Subject represents the user or a virtual entity for which the Authorization // API is called. type Subject struct { @@ -13,12 +31,126 @@ type Subject struct { Type string `json:"type"` // Role is the system role of the subject (e.g. "system_user", "system_guest", "system_admin"). // This is separate from custom profile attributes since it's a first-class system concept. + // + // Deprecated: prefer ScopedRoles which can express both system and + // channel-scoped roles. Role is still populated for backward + // compatibility and acts as the system-scope fallback inside + // RoleForScope: a system-scope lookup returns Role whenever + // ScopedRoles has no entry whose Scope is system — including + // when the slice is empty AND when it contains only + // channel-scoped entries. Populating ScopedRoles with non-system + // entries does NOT suppress this fallback. Role string `json:"role"` + // ScopedRoles carries roles paired with the scope they apply to (system + // or channel). The PDP uses this slice to match a rule's scoped Role + // (e.g. v0.4 channel resource policy rules) against the subject. + ScopedRoles []ScopedRole `json:"scoped_roles,omitempty"` // Attributes are the key-value pairs assicuated with the subject. // An attribute may be single-valued or multi-valued and can be a primitive type // (string, boolean, number) or a complex type like a JSON object or array. Attributes map[string]any `json:"attributes"` - Session map[string]any `json:"session"` + // Session carries environmental / per-session attributes that policy + // authors reference as `user.session.` (e.g. user.session.network_status, + // user.session.client_type, user.session.device_managed, user.session.ip_range, + // user.session.platform, user.session.device_id). + // + // Session lives under the Subject — not as a sibling top-level CEL + // variable — because every value here is keyed to the requesting + // principal: the network the user is currently on, the client they're + // using, whether their device is MDM-managed, etc. Modeling it as part + // of the Subject keeps the Subject the single source of truth for + // "everything we know about the requester at decision time" and + // matches OpenID AuthZen's subject.properties / subject.session shape. + // + // The simulator populates this map from the picker's session-attribute + // overrides and the requesting admin's active-session snapshot. The + // live PDP populates it from rctx.Session() once the production wiring + // for environmental telemetry lands; until then SavePolicy rejects + // rules that reference user.session.* (see access_control.administration + // in the enterprise repo) so authors cannot ship a control whose + // production behaviour silently diverges from the simulator preview. + Session map[string]any `json:"session,omitempty"` +} + +// RoleForScope returns the role assigned to this subject within the given +// scope. It first walks ScopedRoles for a matching Scope; for the system +// scope it falls back to the legacy Role field whenever no system-scoped +// entry exists in ScopedRoles (including when the slice is empty or +// contains only channel-scoped entries). +func (s *Subject) RoleForScope(scope string) string { + for _, sr := range s.ScopedRoles { + if sr.Scope == scope { + return sr.Role + } + } + if scope == AccessControlSubjectScopeSystem { + return s.Role + } + return "" +} + +// RolesForScope returns every role assigned to this subject within the +// given scope, preserving the order they appear in ScopedRoles. Unlike +// RoleForScope it does NOT fall back to the legacy Role field — callers +// that need legacy single-role fallback should keep using RoleForScope. +// +// The current PDP only ever populates one entry per scope, so this +// helper returns at most a single-element slice today. It exists to +// give multi-role-per-scope consumers (a future capability — Mattermost +// users can carry multiple system roles like "system_user system_admin") +// a stable accessor that won't change shape when the underlying +// invariant is relaxed. +// +// Returns nil when no entry matches the scope. +func (s *Subject) RolesForScope(scope string) []string { + var roles []string + for _, sr := range s.ScopedRoles { + if sr.Scope == scope { + roles = append(roles, sr.Role) + } + } + return roles +} + +// SetScopedRole upserts a single role for the given scope, preserving +// the per-scope uniqueness invariant the PDP currently relies on. If an +// entry for the scope already exists, its role is replaced (keeping its +// position in ScopedRoles); any later duplicates with the same scope +// are removed. If no entry exists, a new one is appended. +// +// Passing an empty role removes every entry for the scope. This mirrors +// the convention used by the channel-scope hot path in +// attachChannelScopedRole, where an empty channel role lookup means "no +// channel role applies — drop any stale entry from the cached subject." +// +// Passing an empty scope is a no-op (defensive — the PDP never +// constructs scope="" entries). +// +// SetScopedRole always allocates a fresh ScopedRoles backing array, so +// it is safe to call on a Subject whose ScopedRoles slice is aliased +// with another Subject (e.g. the per-user cached Subject reused across +// many channels in attachChannelScopedRole). +func (s *Subject) SetScopedRole(scope, role string) { + if scope == "" { + return + } + updated := false + out := make([]ScopedRole, 0, len(s.ScopedRoles)+1) + for _, sr := range s.ScopedRoles { + if sr.Scope != scope { + out = append(out, sr) + continue + } + if role == "" || updated { + continue + } + out = append(out, ScopedRole{Scope: scope, Role: role}) + updated = true + } + if !updated && role != "" { + out = append(out, ScopedRole{Scope: scope, Role: role}) + } + s.ScopedRoles = out } type SubjectSearchOptions struct { @@ -78,3 +210,366 @@ type QueryExpressionParams struct { ChannelId string `json:"channelId,omitempty"` TeamId string `json:"teamId,omitempty"` } + +// PolicySimulationBlameSource enumerates where a deny originated when running +// the test (simulate) workflow against a draft policy. +const ( + // PolicySimulationBlameSourceThisRule means the deny came from the rule + // that the author is currently editing. + PolicySimulationBlameSourceThisRule = "this_rule" + // PolicySimulationBlameSourceSiblingRule means the deny came from another + // rule inside the same draft policy (same channel, different role/action + // or different rule on the same role/action that resolves to deny). + PolicySimulationBlameSourceSiblingRule = "sibling_rule" + // PolicySimulationBlameSourceChannelPolicy means the deny came from a + // resource-policy rule that is not the one being edited but contributes + // to the same effective decision (e.g. an inherited parent policy). + PolicySimulationBlameSourceChannelPolicy = "channel_policy" + // PolicySimulationBlameSourceSystemPermission means the deny came from a + // truly higher-scoped, persisted permission policy. Distinct from + // PolicySimulationBlameSourcePeerPolicy (same-scope) — the simulator + // emits both as system_permission, but the public-server reclassifies + // peer-scope blame entries before the response leaves the server. The + // expression of an upper-scoped policy is intentionally not exposed + // to the simulate UI to preserve scope privacy. + PolicySimulationBlameSourceSystemPermission = "system_permission" + // PolicySimulationBlameSourcePeerPolicy means the deny came from another + // persisted policy at the SAME scope as the draft (same Type and same + // ParentID). It's carved out of system_permission by the public-server + // post-processing so the picker can show the peer's name + the failing + // rule's CEL expression instead of an opaque "upper-scoped policy" + // chip — at the editing scope, peers are visible to the author. + PolicySimulationBlameSourcePeerPolicy = "peer_policy" + // PolicySimulationBlameSourceNoApplicablePolicy is a synthetic blame + // source emitted by the simulator when the draft policy does not apply + // to a candidate user (e.g. a system_user user is added to test a + // system_admin policy). The decision is recorded as ALLOW (vacuously, + // because the policy is silent on this user) and the picker renders a + // "Policy doesn't apply" pill from this entry. Never produced by + // production evaluation — simulation-only. + PolicySimulationBlameSourceNoApplicablePolicy = "no_applicable_policy" + // PolicySimulationBlameSourceSiblingSaved is attached to an ALLOW + // decision when the rule the author is editing alone would have DENIED + // the subject, but a sibling rule (same role + action, OR-combined at + // compile time) flipped the bucket back to ALLOW. Useful so the + // picker can surface "this rule alone wouldn't have allowed them — a + // sibling did". Simulation-only. + PolicySimulationBlameSourceSiblingSaved = "sibling_saved" + // PolicySimulationBlameSourceNoApplicableRule is the synthetic blame + // source the "this rule only" post-process emits when the rule the + // author is editing is silent on the subject — either a sibling + // rule's OR-bucket saved an otherwise-denied user, or the deny + // originated entirely outside the editing rule (upper-scoped policy, + // peer policy, etc.). The decision is normalized to a vacuous ALLOW + // like no_applicable_policy and the picker renders a neutral + // "This rule doesn't apply" pill from this entry instead of the + // misleading "Allowed · another rule" / plain "Allowed" chips that + // the sibling_saved / orphaned-deny branches used to surface. + // Simulation-only and only emitted under the "this_rule" + // EvaluationScope (the "All policies" view keeps the original + // sibling_saved chip because at that scope the other rule IS + // relevant context for the verdict). + PolicySimulationBlameSourceNoApplicableRule = "no_applicable_rule" +) + +// PolicySimulationBlameOutcome enumerates the per-blame verdict the +// simulator records for a contributing policy. Most blame entries +// carry the deny that produced the overall decision (PolicySimulationBlameOutcomeDeny); +// the simulator additionally emits informational entries with PolicySimulationBlameOutcomeAllow +// so the picker can show "your draft policy allowed this user" in +// multi-policy contexts where a peer policy is the denier. +const ( + PolicySimulationBlameOutcomeDeny = "deny" + PolicySimulationBlameOutcomeAllow = "allow" +) + +// PolicySimulationBlame attributes a deny decision back to the rule or policy +// that caused it. Some entries are informational (Outcome="allow") rather +// than deniers — those exist so the picker can surface the editing draft's +// evaluation alongside any peer policies' deny attribution; consumers that +// only care about deny attribution should filter to Outcome=="" or +// Outcome==PolicySimulationBlameOutcomeDeny (empty Outcome is treated as +// deny for backward compatibility with simulator builds that pre-date the +// field). +type PolicySimulationBlame struct { + // Source is one of the PolicySimulationBlameSource* constants. + Source string `json:"source"` + // Outcome is one of the PolicySimulationBlameOutcome* constants. + // Defaults to "deny" semantically when empty (backward compat with + // older simulators) — every blame entry shipped before this field + // existed was a denier. The picker uses Outcome to differentiate + // the editing draft's "I allowed" informational entry from the + // peer policies that actually caused the deny so each can render + // with the right indicator. + Outcome string `json:"outcome,omitempty"` + // PolicyID is the ID of the contributing policy (for system permission + // or channel policy sources). Empty when the deny originated from the + // draft itself (no persisted ID exists yet). + PolicyID string `json:"policy_id,omitempty"` + // PolicyName is the human-readable name of the contributing policy. + PolicyName string `json:"policy_name,omitempty"` + // RuleName is the name of the contributing rule (v0.4 permission rules + // always carry a unique name within their policy). + RuleName string `json:"rule_name,omitempty"` + // Role is the scoped role (system_* or channel_*) of the contributing + // rule or policy. Useful for explaining hierarchy fallbacks. + Role string `json:"role,omitempty"` + // Expression is the 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). Truly upper-scoped sources + // (system_permission, channel_policy) deliberately omit this field so + // the simulate UI can't leak the expression of a policy outside the + // editing scope. + Expression string `json:"expression,omitempty"` + // EvaluationTree is the per-node evaluation breakdown of the + // contributing rule, mirroring the boolean shape of the CEL + // expression's AST. Same scope-privacy rule as Expression: only + // populated for draft-side / peer-policy blame; truly upper-scoped + // sources omit it. The simulate UI renders it as a structured + // AND/OR/NOT tree showing exactly which sub-expression(s) produced + // the deny. + EvaluationTree *PolicySimulationEvaluationNode `json:"evaluation_tree,omitempty"` + // MergedRules lists every authored rule that was OR-folded into + // `Expression` for this contribution (see engine.JoinExpressions). + // Populated only when the contributing scope has more than one + // rule sharing the same (role, action) — single-rule + // contributions leave this empty so the simulate UI can keep the + // simpler "Rule: " header. Order mirrors the policy's rule + // order, which is also the order JoinExpressions used when + // constructing the merged expression — so a UI can number rules + // consistently with the merged tree's branches. + // + // Same scope-privacy rule as Expression: populated only for + // same-scope blame (this_rule / sibling_rule / sibling_saved / + // peer_policy). Truly upper-scoped sources never carry this so + // the picker can't enumerate the rules of an out-of-scope policy. + MergedRules []PolicySimulationMergedRule `json:"merged_rules,omitempty"` +} + +// PolicySimulationMergedRule is one entry in a blame's MergedRules: +// the name + expression + standalone evaluation tree of a single rule +// that was OR-folded into the blame's merged expression. A standalone +// tree (computed against the same activation as the merged tree) lets +// the UI render a per-rule breakdown numbered 1..N alongside the +// merged tree, so authors can map specific branches back to the rule +// they came from. The standalone tree carries the same scope-privacy +// rule as the surrounding blame's Expression; truly upper-scoped +// blame never carries MergedRules at all. +type PolicySimulationMergedRule struct { + // Name of the contributing rule (matches AccessControlPolicy.Rules[i].Name). + Name string `json:"name"` + // Expression is the rule's CEL text, before JoinExpressions wraps + // it in parens for the OR-fold. Useful when the UI wants to show + // the contributing rule on its own without reparsing. + Expression string `json:"expression,omitempty"` + // EvaluationTree is the standalone per-node evaluation breakdown + // of just this rule's expression (not the merged whole). The + // outcome on the root reflects whether THIS rule alone matched + // for the subject, which is what the picker needs to render + // "rule 1: TRUE / rule 2: FALSE" per-rule chips above each tree. + EvaluationTree *PolicySimulationEvaluationNode `json:"evaluation_tree,omitempty"` +} + +// Kind values for PolicySimulationEvaluationNode.Kind. Compound kinds +// carry children; leaf kinds carry attribute / actual / expected +// metadata. PolicySimulationEvaluationKindOther is the catch-all for +// shapes the simulator doesn't decompose (bare attribute reference, +// ternary, unknown call). +const ( + PolicySimulationEvaluationKindAnd = "and" + PolicySimulationEvaluationKindOr = "or" + PolicySimulationEvaluationKindNot = "not" + PolicySimulationEvaluationKindCompare = "compare" + PolicySimulationEvaluationKindFunction = "function" + PolicySimulationEvaluationKindOther = "other" +) + +// Outcome values for PolicySimulationEvaluationNode.Outcome. Mirrors +// the three-way truth result of CEL evaluation — a clean true/false, +// or an error condition (missing attribute, type mismatch). +const ( + PolicySimulationEvaluationOutcomeTrue = "true" + PolicySimulationEvaluationOutcomeFalse = "false" + PolicySimulationEvaluationOutcomeError = "error" +) + +// PolicySimulationEvaluationNode is a single node in the evaluation +// tree returned by the simulate-by-users endpoint when the simulator +// is asked to explain a deny. The tree mirrors the boolean shape of +// the failing rule's CEL expression — short-circuit branches are +// walked regardless of their parent's outcome so the consumer can +// render the state of every clause, not just the first one that +// decided the verdict. +type PolicySimulationEvaluationNode struct { + // Kind classifies the node (compound vs leaf vs other). One of the + // PolicySimulationEvaluationKind* constants above. + Kind string `json:"kind"` + // Expression is the textual form of THIS subtree, suitable for the + // UI to render a snippet without rebuilding text from the AST. + Expression string `json:"expression"` + // Outcome is the per-node verdict. One of the + // PolicySimulationEvaluationOutcome* constants. + Outcome string `json:"outcome"` + // Error is a human-readable description of an evaluation-time + // failure. Populated only when Outcome == "error". + Error string `json:"error,omitempty"` + // Operator names the leaf operation: "==", "!=", "<", ">", ">=", + // "<=", "in", "startsWith", "endsWith", "contains". Empty for + // compound and other nodes. + Operator string `json:"operator,omitempty"` + // Attribute is the user-attribute path the leaf references when + // it could be unambiguously identified + // (e.g. user.attributes.region). Empty when the leaf does not + // reference an attribute or when both sides are non-attribute + // expressions. + Attribute string `json:"attribute,omitempty"` + // ActualValue is a display-formatted rendering of the user's + // value for Attribute. Empty when the attribute is missing — a + // missing attribute is also reflected in Outcome="error". + ActualValue string `json:"actual_value,omitempty"` + // ExpectedValue is a display-formatted rendering of the literal + // (or list of literals) the leaf compared against. Empty when the + // other side is itself an attribute reference. + ExpectedValue string `json:"expected_value,omitempty"` + // Children are the operands of a compound node, walked in + // expression order. Empty for leaf and other nodes. + Children []PolicySimulationEvaluationNode `json:"children,omitempty"` +} + +// PolicySimulationActionDecision is the per-action verdict for a single user. +type PolicySimulationActionDecision struct { + Decision bool `json:"decision"` + Blame []PolicySimulationBlame `json:"blame,omitempty"` +} + +// PolicySimulationSession is the per-session breakdown entry for the +// simulate-by-users response. Populated when the caller requests per-session +// evaluation (typically a system admin: their active sessions are individually +// evaluated so the picker can show why two sessions of the same user come +// back with different verdicts). Channel admins receive at most a single +// synthetic session populated with default values that they can override +// through the per-row session-attribute editor. +type PolicySimulationSession struct { + // ID is the persistent session identifier. Empty for synthetic sessions. + ID string `json:"id,omitempty"` + // Device is a human-readable device/client label (e.g. "MacBook Pro"). + Device string `json:"device,omitempty"` + // Network classifies the connection (e.g. "WiFi", "VPN", "Mobile"). + Network string `json:"network,omitempty"` + // LastActiveAt is the last-active timestamp in milliseconds since epoch. + LastActiveAt int64 `json:"last_active_at,omitempty"` + // Decisions maps action name → verdict for THIS session specifically, + // using the session's own session.* attributes (the user's profile + // attributes are constant across sessions). + Decisions map[string]PolicySimulationActionDecision `json:"decisions,omitempty"` + // Attributes is the session-attribute snapshot the simulator used when + // evaluating this session (network_status, device_managed, ip_range, + // etc.). Surfaced to the picker's "Decision details" view so the + // author can read the deny like an evaluation trace. Optional — omitted + // when the simulator hasn't populated it. + Attributes map[string]string `json:"attributes,omitempty"` +} + +// PolicySimulationUserResult is one row in the simulation response. +type PolicySimulationUserResult struct { + User *User `json:"user"` + // Decisions maps action name → verdict. Always populated when the + // simulation request had non-empty Actions; nil when ExpressionOnly is + // true (fallback mode). 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. + Decisions map[string]PolicySimulationActionDecision `json:"decisions,omitempty"` + // Sessions is the optional per-session breakdown. Empty/nil falls back + // to the user-level Decisions only. + Sessions []PolicySimulationSession `json:"sessions,omitempty"` + // Attributes is the user profile attribute snapshot the simulator used + // when evaluating this user (department, region, clearance, etc.). + // Surfaced to the picker's "Decision details" view so the author can + // read the deny as an evaluation trace. Optional — omitted when the + // simulator hasn't populated it. + Attributes map[string]string `json:"attributes,omitempty"` +} + +// PolicySimulationResponse is the body returned by cel/simulate_users. +type PolicySimulationResponse struct { + Results []PolicySimulationUserResult `json:"results"` + Total int64 `json:"total"` +} + +// PolicySimulationUserOverride captures the per-user inputs the picker UI +// sends to /access_control_policies/cel/simulate_users. The simulator +// resolves each user's profile attributes from CPA storage and then layers +// session context on top: first the active-session snapshot (when +// UseActiveSession is set), then the explicit SessionOverrides map. +type PolicySimulationUserOverride struct { + // UserID identifies the user to simulate against. + UserID string `json:"user_id"` + // UseActiveSession injects the requesting admin's session.* attributes + // (network_status, client_type, device_managed, ip_range, platform, + // device_id) into this user's evaluation context. When the live PDP + // does not yet populate session.* on the request context this is a + // no-op; the API surface is forward-compatible. + UseActiveSession bool `json:"use_active_session,omitempty"` + // SessionOverrides replaces individual session.* attributes for this + // user only. Applied on top of the active-session snapshot when both + // are set, so a future "configure" panel can shadow specific values + // without discarding the rest of the active session. + // + // Mirrors the shape of Subject.Session (map[string]any) so the picker + // can carry mixed-typed session attributes (e.g. boolean + // device_managed alongside string network_status) without coercing + // everything through string. Nested maps / slices flow through to the + // CEL evaluator unchanged. + SessionOverrides map[string]any `json:"session_overrides,omitempty"` +} + +// PolicyEvaluationScope* constants enumerate the supported evaluation +// scopes for /cel/simulate_users. +const ( + // PolicyEvaluationScopeThisRule evaluates ONLY the rule the author is + // editing — sibling rules in the same policy, system permission + // policies, imported parent policies, and any other peer policies are + // excluded. This is the authoring-time "what does this rule alone do?" + // view: useful for iterating on a single rule's expression without + // other rules shadowing or compensating for it. Default when the + // request omits EvaluationScope. + PolicyEvaluationScopeThisRule = "this_rule" + // PolicyEvaluationScopeAll co-evaluates every contributing program — + // the entire draft policy (all rules), persisted system permission + // policies, parent policies — exactly as the live PDP would at + // request time. This is the "what verdict will the user actually + // experience?" view. + PolicyEvaluationScopeAll = "all" +) + +// PolicySimulationByUsersParams is the request body for +// /access_control_policies/cel/simulate_users. +// +// The picker-based "Simulate access" UX hand-selects users to dry-run a +// draft policy against. Each user is run through the same dual-lane PDP +// path the live request would take and the response carries per-user, +// per-action ALLOW/DENY decisions plus blame attribution. +type PolicySimulationByUsersParams struct { + // Policy is the draft policy as it currently sits in the editor. Not + // persisted; compiled in-memory only. + Policy *AccessControlPolicy `json:"policy"` + // Actions is the set of permission actions to simulate. Required — + // a picker UX only makes sense once an action is in scope. + Actions []string `json:"actions"` + // RuleName identifies which rule in Policy.Rules the author is + // editing (used for blame attribution). Optional. When set, denies + // originating from this rule are tagged source=this_rule; other + // denies in the same draft are tagged source=sibling_rule. + RuleName string `json:"rule_name,omitempty"` + // ChannelID and TeamID provide context for delegated admin auth and + // channel-scope evaluation. + ChannelID string `json:"channel_id,omitempty"` + TeamID string `json:"team_id,omitempty"` + // Users is the explicit set of users to evaluate, with per-user + // session-attribute overrides. + Users []PolicySimulationUserOverride `json:"users"` + // EvaluationScope 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. + EvaluationScope string `json:"evaluation_scope,omitempty"` +} diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index a82ec6b7d83..16447f76cec 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -84,6 +84,9 @@ const ( AuditEventAddChannelMember = "addChannelMember" // add member to channel AuditEventConvertGroupMessageToChannel = "convertGroupMessageToChannel" // convert group message to private channel AuditEventCreateChannel = "createChannel" // create public or private channel + AuditEventCreateChannelJoinRequest = "createChannelJoinRequest" // request to join a discoverable private channel + AuditEventUpdateChannelJoinRequest = "updateChannelJoinRequest" // approve or deny a channel join request + AuditEventWithdrawChannelJoinRequest = "withdrawChannelJoinRequest" // requester cancels their channel join request AuditEventCreateDirectChannel = "createDirectChannel" // create direct message channel between two users AuditEventCreateGroupChannel = "createGroupChannel" // create group message channel with multiple users AuditEventDeleteChannel = "deleteChannel" // delete channel @@ -474,6 +477,7 @@ const ( AuditEventRevokeAllSessionsAllUsers = "revokeAllSessionsAllUsers" // revoke all active sessions for all users AuditEventRevokeAllSessionsForUser = "revokeAllSessionsForUser" // revoke all active sessions for specific user AuditEventRevokeSession = "revokeSession" // revoke specific user session + AuditEventRejectExpiredUserAccessToken = "rejectExpiredUserAccessToken" // rejected an API request because the personal access token has expired AuditEventRevokeUserAccessToken = "revokeUserAccessToken" // revoke user personal access token AuditEventSendPasswordReset = "sendPasswordReset" // send password reset email to user AuditEventSendVerificationEmail = "sendVerificationEmail" // send email verification link to user diff --git a/server/public/model/channel.go b/server/public/model/channel.go index ef73d651fe8..0fa96cf1a29 100644 --- a/server/public/model/channel.go +++ b/server/public/model/channel.go @@ -81,34 +81,62 @@ func (c ChannelBannerInfo) Value() (driver.Value, error) { } type Channel struct { - Id string `json:"id"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - TeamId string `json:"team_id"` - Type ChannelType `json:"type"` - DisplayName string `json:"display_name"` - Name string `json:"name"` - Header string `json:"header"` - Purpose string `json:"purpose"` - LastPostAt int64 `json:"last_post_at"` - TotalMsgCount int64 `json:"total_msg_count"` - ExtraUpdateAt int64 `json:"extra_update_at"` - CreatorId string `json:"creator_id"` - SchemeId *string `json:"scheme_id"` - Props map[string]any `json:"props"` - GroupConstrained *bool `json:"group_constrained"` - AutoTranslation bool `json:"autotranslation"` - Shared *bool `json:"shared"` - TotalMsgCountRoot int64 `json:"total_msg_count_root"` - PolicyID *string `json:"policy_id"` - LastRootPostAt int64 `json:"last_root_post_at"` - BannerInfo *ChannelBannerInfo `json:"banner_info"` - PolicyEnforced bool `json:"policy_enforced"` - PolicyIsActive bool `json:"policy_is_active"` - DefaultCategoryName string `json:"default_category_name"` - ManagedCategoryName string `json:"managed_category_name"` - Discoverable bool `json:"discoverable"` + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + TeamId string `json:"team_id"` + Type ChannelType `json:"type"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Header string `json:"header"` + Purpose string `json:"purpose"` + LastPostAt int64 `json:"last_post_at"` + TotalMsgCount int64 `json:"total_msg_count"` + ExtraUpdateAt int64 `json:"extra_update_at"` + CreatorId string `json:"creator_id"` + SchemeId *string `json:"scheme_id"` + Props map[string]any `json:"props"` + GroupConstrained *bool `json:"group_constrained"` + AutoTranslation bool `json:"autotranslation"` + Shared *bool `json:"shared"` + TotalMsgCountRoot int64 `json:"total_msg_count_root"` + PolicyID *string `json:"policy_id"` + LastRootPostAt int64 `json:"last_root_post_at"` + BannerInfo *ChannelBannerInfo `json:"banner_info"` + PolicyEnforced bool `json:"policy_enforced"` + // PolicyActions maps each action key declared by the channel's access + // control policy (and any imported parent policies) to true. It is + // populated lazily by App-layer hydrators and is therefore unset on + // channel reads that don't pass through one of those seams. Consumers + // that care about a specific action (e.g. "membership") should check + // PolicyActions[action] and fall back to PolicyEnforced only when the + // stronger meaning is acceptable. Empty/nil means either no policy or + // no hydration was performed. + PolicyActions map[string]bool `json:"policy_actions,omitempty"` + PolicyIsActive bool `json:"policy_is_active"` + DefaultCategoryName string `json:"default_category_name"` + ManagedCategoryName string `json:"managed_category_name"` + Discoverable bool `json:"discoverable"` +} + +// HasPolicyAction reports whether the channel's policy declares the given +// action. Safe to call on a Channel whose PolicyActions map is nil +// (returns false in that case). Use this in preference to direct map +// indexing so consumers don't have to defend against nil maps. +func (o *Channel) HasPolicyAction(action string) bool { + if o == nil || len(o.PolicyActions) == 0 { + return false + } + return o.PolicyActions[action] +} + +// HasMembershipPolicyAction is a convenience for the most common consumer +// pattern: "is this channel's membership controlled by ABAC?". Used by +// the invite picker, channel settings, members RHS, and the server-side +// gates (setChannelMembers, guest-invite, ChannelAccessControlled). +func (o *Channel) HasMembershipPolicyAction() bool { + return o.HasPolicyAction(AccessControlPolicyActionMembership) } func (o *Channel) Auditable() map[string]any { @@ -130,6 +158,7 @@ func (o *Channel) Auditable() map[string]any { "type": o.Type, "update_at": o.UpdateAt, "policy_enforced": o.PolicyEnforced, + "policy_actions": o.PolicyActions, // hydrated lazily; only populated on selected read paths "autotranslation": o.AutoTranslation, "policy_is_active": o.PolicyIsActive, // this field is only for logging purposes "discoverable": o.Discoverable, diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 3b0e698d168..b01067aab02 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -75,12 +75,31 @@ type FeatureFlags struct { // Enable permission policies (file upload/download ABAC policies). // Requires AttributeBasedAccessControl to also be enabled. + // + // This is the umbrella flag: when off, both ChannelPermissionPolicies + // and PolicySimulation are also off regardless of their individual + // settings. Use the IsChannelPermissionPoliciesEnabled() and + // IsPolicySimulationEnabled() helpers below rather than checking + // PermissionPolicies + the sub-flag manually at every call site — + // they encapsulate the dependency so a future renaming / + // consolidation only has to update one place. PermissionPolicies bool - ContentFlagging bool + // Enable permission-rule actions (upload_file_attachment, + // download_file_attachment) on channel-scope policies — and, on the + // frontend, the Channel Settings → Permissions Policy tab that lets + // channel admins configure them. Requires PermissionPolicies. Read + // via FeatureFlags.IsChannelPermissionPoliciesEnabled() so the + // PermissionPolicies dependency is enforced at every call site. + ChannelPermissionPolicies bool - // Enable AppsForm for Interactive Dialogs instead of legacy dialog implementation - InteractiveDialogAppsForm bool + // Enable the "Simulate access" preview UX and its backing + // /cel/simulate_users endpoint. Requires PermissionPolicies. Read + // via FeatureFlags.IsPolicySimulationEnabled() so the + // PermissionPolicies dependency is enforced at every call site. + PolicySimulation bool + + ContentFlagging bool EnableMattermostEntry bool @@ -158,8 +177,9 @@ func (f *FeatureFlags) SetDefaults() { f.AttributeBasedAccessControl = true f.AttributeValueMasking = false f.PermissionPolicies = false + f.ChannelPermissionPolicies = false + f.PolicySimulation = false f.ContentFlagging = true - f.InteractiveDialogAppsForm = true f.EnableMattermostEntry = true // DEPRECATED: Disabled by default - mobile clients use direct SSO callback flow @@ -190,6 +210,27 @@ func (f *FeatureFlags) SetDefaults() { f.MobileEphemeralMode = false } +// IsChannelPermissionPoliciesEnabled reports whether channel-scope +// policies may carry permission-rule actions (file upload/download) +// and whether the Channel Settings → Permissions Policy tab should +// be exposed. Both the sub-flag AND the PermissionPolicies umbrella +// must be on — turning the umbrella off implicitly disables the +// sub-feature even if its own flag is on. Centralizing the +// dependency check here keeps every call site honest. +func (f *FeatureFlags) IsChannelPermissionPoliciesEnabled() bool { + return f.PermissionPolicies && f.ChannelPermissionPolicies +} + +// IsPolicySimulationEnabled reports whether the "Simulate access" +// preview UX and its backing /cel/simulate_users endpoint are +// available. Both the sub-flag AND the PermissionPolicies umbrella +// must be on — turning the umbrella off implicitly disables the +// sub-feature even if its own flag is on. Centralizing the +// dependency check here keeps every call site honest. +func (f *FeatureFlags) IsPolicySimulationEnabled() bool { + return f.PermissionPolicies && f.PolicySimulation +} + // ToMap returns the feature flags as a map[string]string // Supports boolean and string feature flags. func (f *FeatureFlags) ToMap() map[string]string { diff --git a/server/public/model/feature_flags_test.go b/server/public/model/feature_flags_test.go index 6e5e0a473cb..f9c865f28a7 100644 --- a/server/public/model/feature_flags_test.go +++ b/server/public/model/feature_flags_test.go @@ -59,6 +59,59 @@ func TestFeatureFlagsSetDefaults_AttributeValueMasking(t *testing.T) { require.Equal(t, "false", flags.ToMap()["AttributeValueMasking"]) } +// TestFeatureFlagsPermissionPoliciesDependencies pins down the +// "sub-flag is gated by the umbrella PermissionPolicies flag" +// contract for both ChannelPermissionPolicies and PolicySimulation. +// Centralizing this in helper methods means future changes to the +// dependency (additional gates, new sub-flags) only have to update +// one place and existing call sites stay correct. +func TestFeatureFlagsPermissionPoliciesDependencies(t *testing.T) { + t.Run("both helpers are off when defaults are applied", func(t *testing.T) { + var f FeatureFlags + f.SetDefaults() + + require.False(t, f.IsChannelPermissionPoliciesEnabled()) + require.False(t, f.IsPolicySimulationEnabled()) + }) + + t.Run("sub-flag alone is not enough — the umbrella must be on too", func(t *testing.T) { + f := FeatureFlags{ + PermissionPolicies: false, + ChannelPermissionPolicies: true, + PolicySimulation: true, + } + require.False(t, f.IsChannelPermissionPoliciesEnabled(), + "ChannelPermissionPolicies sub-flag must be ignored when the PermissionPolicies umbrella is off") + require.False(t, f.IsPolicySimulationEnabled(), + "PolicySimulation sub-flag must be ignored when the PermissionPolicies umbrella is off") + }) + + t.Run("umbrella alone is not enough — the sub-flag must be on too", func(t *testing.T) { + f := FeatureFlags{ + PermissionPolicies: true, + ChannelPermissionPolicies: false, + PolicySimulation: false, + } + require.False(t, f.IsChannelPermissionPoliciesEnabled()) + require.False(t, f.IsPolicySimulationEnabled()) + }) + + t.Run("both flags on enables each sub-feature independently", func(t *testing.T) { + f := FeatureFlags{ + PermissionPolicies: true, + ChannelPermissionPolicies: true, + PolicySimulation: false, + } + require.True(t, f.IsChannelPermissionPoliciesEnabled()) + require.False(t, f.IsPolicySimulationEnabled(), "sub-flags are independent — enabling one must not enable the other") + + f.ChannelPermissionPolicies = false + f.PolicySimulation = true + require.False(t, f.IsChannelPermissionPoliciesEnabled()) + require.True(t, f.IsPolicySimulationEnabled()) + }) +} + func TestFeatureFlagsToMapBool(t *testing.T) { for name, tc := range map[string]struct { Flags FeatureFlags diff --git a/server/public/model/integration_action.go b/server/public/model/integration_action.go index a8e00646442..03e8875cb8b 100644 --- a/server/public/model/integration_action.go +++ b/server/public/model/integration_action.go @@ -16,7 +16,9 @@ import ( "io" "math/big" "net/http" + "net/url" "reflect" + "regexp" "slices" "strconv" "strings" @@ -55,16 +57,30 @@ var commonDateTimeFormats = []string{ ISODateTimeNoSecondsFormat, // ISO datetime without seconds } -var PostActionRetainPropKeys = []string{PostPropsFromWebhook, PostPropsOverrideUsername, PostPropsOverrideIconURL} +var PostActionRetainPropKeys = []string{ + PostPropsFromWebhook, + PostPropsFromBot, + PostPropsFromPlugin, + PostPropsOverrideUsername, + PostPropsOverrideIconURL, +} type DoPostActionRequest struct { - SelectedOption string `json:"selected_option,omitempty"` - Cookie string `json:"cookie,omitempty"` + SelectedOption string `json:"selected_option,omitempty"` + Cookie string `json:"cookie,omitempty"` + Query map[string]string `json:"query,omitempty"` } const ( PostActionDataSourceUsers = "users" PostActionDataSourceChannels = "channels" + + MaxMmBlocksActionsPerPost = 50 + MaxMmBlocksActionKeyLength = 64 + + MaxActionQueryEntries = 50 + MaxActionQueryKeyLength = 128 + MaxActionQueryValueLength = 2048 ) type PostAction struct { @@ -873,6 +889,7 @@ func (o *Post) StripActionIntegrations() { action.Integration = nil } } + o.StripMmBlocksActionSecrets() } func (o *Post) GetAction(id string) *PostAction { @@ -883,6 +900,137 @@ func (o *Post) GetAction(id string) *PostAction { } } } + if spec := o.GetMmBlocksActionSpec(id); spec != nil && spec.Type == MmBlocksActionTypeExternal && spec.URL != "" { + // Synthesize a PostAction so the existing click pipeline can + // dispatch without branching on action source. Pre-merge the + // spec's static per-action query into the URL here; per-click + // query (from DoPostActionRequest.Query) is merged on top by the + // caller via MergeQueryIntoURL, with per-click overriding static + // values on overlapping keys. + url := spec.URL + if len(spec.Query) > 0 { + merged, err := MergeQueryIntoURL(spec.URL, spec.Query) + if err != nil { + // Spec URL is malformed. ValidateMmBlocksActions + // should have rejected it at save time, so this is a + // belt-and-suspenders guard. Returning nil routes the + // caller through the standard "action not found" + // 404 path rather than firing a request to a URL + // that's missing the static query params. + return nil + } + url = merged + } + return &PostAction{ + Id: id, + Type: PostActionTypeButton, + Integration: &PostActionIntegration{ + URL: url, + Context: spec.Context, + }, + } + } + return nil +} + +var mmBlocksActionIDRegex = regexp.MustCompile(`^[A-Za-z0-9]+$`) + +// ValidateMmBlocksActions verifies the post's mm_blocks_actions prop has the +// expected shape and bounds. Each entry must coerce to a valid spec via +// mmBlocksEntryMapToSpec. +func ValidateMmBlocksActions(o *Post) error { + raw := o.GetProp(PostPropsMmBlocksActions) + if raw == nil { + return nil + } + actions, ok := coerceToStringAnyMap(raw) + if !ok { + return fmt.Errorf("mm_blocks_actions must be a map") + } + if len(actions) > MaxMmBlocksActionsPerPost { + return fmt.Errorf("mm_blocks_actions exceeds maximum of %d entries", MaxMmBlocksActionsPerPost) + } + for key, entry := range actions { + if len(key) > MaxMmBlocksActionKeyLength { + return fmt.Errorf("mm_blocks_actions key exceeds %d chars", MaxMmBlocksActionKeyLength) + } + if !mmBlocksActionIDRegex.MatchString(key) { + return fmt.Errorf("mm_blocks_actions key %q must be alphanumeric", key) + } + entryMap, ok := coerceToStringAnyMap(entry) + if !ok { + return fmt.Errorf("mm_blocks_actions entry %q must be an object", key) + } + spec := mmBlocksEntryMapToSpec(entryMap) + if spec == nil { + return fmt.Errorf("mm_blocks_actions entry %q has invalid type or shape", key) + } + if spec.Type == MmBlocksActionTypeExternal { + if err := validateIntegrationURL(spec.URL); err != nil { + return fmt.Errorf("mm_blocks_actions entry %q: %w", key, err) + } + // Bound the per-spec static query so a bot cannot stash + // unbounded data in the post that gets merged into the + // outgoing URL on every click. + if err := ValidateActionQuery(spec.Query); err != nil { + return fmt.Errorf("mm_blocks_actions entry %q static query: %w", key, err) + } + // Bound entry count and key length on the static context. + // Values are arbitrary JSON, so size is constrained by the + // outer post-size limit; we cap entries to prevent crafted + // posts from inflating GetAction's clone cost. + if len(spec.Context) > MaxActionQueryEntries { + return fmt.Errorf("mm_blocks_actions entry %q context exceeds maximum of %d entries", key, MaxActionQueryEntries) + } + for k := range spec.Context { + if len(k) > MaxActionQueryKeyLength { + return fmt.Errorf("mm_blocks_actions entry %q context key exceeds %d chars", key, MaxActionQueryKeyLength) + } + } + } + } + return nil +} + +// ValidateActionQuery bounds the size of user-supplied per-click query +// parameters so a crafted post cannot trigger unbounded memory use in the +// plugin-request path. +func ValidateActionQuery(q map[string]string) error { + if len(q) > MaxActionQueryEntries { + return fmt.Errorf("query exceeds maximum of %d entries", MaxActionQueryEntries) + } + for key, value := range q { + if len(key) > MaxActionQueryKeyLength { + return fmt.Errorf("query key exceeds %d chars", MaxActionQueryKeyLength) + } + if len(value) > MaxActionQueryValueLength { + return fmt.Errorf("query value for %q exceeds %d chars", key, MaxActionQueryValueLength) + } + } + return nil +} + +func validateIntegrationURL(rawURL string) error { + if rawURL == "" { + return fmt.Errorf("must have a non-empty URL") + } + if !(strings.HasPrefix(rawURL, "/plugins/") || strings.HasPrefix(rawURL, "plugins/") || IsValidHTTPURL(rawURL)) { + return fmt.Errorf("must have a valid integration URL") + } + // Reject path-traversal segments. /plugins/ URLs are routed by the + // local server, so a `..` segment can escape the plugin namespace and + // hit unrelated server routes. url.Parse decodes percent-encoded path + // bytes into u.Path, which is the same single decode pass that + // doPluginRequest performs at dispatch — so encoded forms like + // %2e%2e%2f are caught here symmetrically with how the router would + // resolve them. + u, parseErr := url.Parse(rawURL) + if parseErr != nil { + return fmt.Errorf("must have a valid integration URL: %w", parseErr) + } + if strings.Contains(u.Path, "/../") || strings.HasSuffix(u.Path, "/..") { + return fmt.Errorf("integration URL must not contain path traversal segments") + } return nil } diff --git a/server/public/model/integration_action_test.go b/server/public/model/integration_action_test.go index baefc9f968b..69a751982d2 100644 --- a/server/public/model/integration_action_test.go +++ b/server/public/model/integration_action_test.go @@ -12,6 +12,7 @@ import ( "encoding/json" "io" "math/big" + "strconv" "strings" "testing" "time" @@ -1676,3 +1677,742 @@ func TestDialogElementDateTimeValidation(t *testing.T) { assert.True(t, effective.ManualTimeEntry, "deprecated field alone should enable manual entry after EffectiveDateTimeConfig") }) } + +func TestValidateActionQuery(t *testing.T) { + t.Run("nil map is valid", func(t *testing.T) { + assert.NoError(t, ValidateActionQuery(nil)) + }) + + t.Run("empty map is valid", func(t *testing.T) { + assert.NoError(t, ValidateActionQuery(map[string]string{})) + }) + + t.Run("within bounds is valid", func(t *testing.T) { + ctx := map[string]string{ + "alpha": "one", + "beta": "two", + } + assert.NoError(t, ValidateActionQuery(ctx)) + }) + + t.Run("exceeds MaxActionQueryEntries", func(t *testing.T) { + ctx := make(map[string]string, MaxActionQueryEntries+1) + for i := range MaxActionQueryEntries + 1 { + ctx[strconv.Itoa(i)] = "v" + } + err := ValidateActionQuery(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + }) + + t.Run("key length exactly MaxActionQueryKeyLength is allowed", func(t *testing.T) { + ctx := map[string]string{ + strings.Repeat("k", MaxActionQueryKeyLength): "value", + } + assert.NoError(t, ValidateActionQuery(ctx)) + }) + + t.Run("key length MaxActionQueryKeyLength+1 is rejected", func(t *testing.T) { + ctx := map[string]string{ + strings.Repeat("k", MaxActionQueryKeyLength+1): "value", + } + err := ValidateActionQuery(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "key exceeds") + }) + + t.Run("value length exactly MaxActionQueryValueLength is allowed", func(t *testing.T) { + ctx := map[string]string{ + "key": strings.Repeat("v", MaxActionQueryValueLength), + } + assert.NoError(t, ValidateActionQuery(ctx)) + }) + + t.Run("value length MaxActionQueryValueLength+1 is rejected", func(t *testing.T) { + ctx := map[string]string{ + "key": strings.Repeat("v", MaxActionQueryValueLength+1), + } + err := ValidateActionQuery(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "value for") + }) + + t.Run("multiple violations triggers an error", func(t *testing.T) { + // Too many entries AND every value is over-length. First detected + // violation wins; only assert that an error is returned. + ctx := make(map[string]string, MaxActionQueryEntries+1) + for i := range MaxActionQueryEntries + 1 { + ctx[strconv.Itoa(i)] = strings.Repeat("v", MaxActionQueryValueLength+1) + } + err := ValidateActionQuery(ctx) + require.Error(t, err) + }) +} + +func mmBlocksExternalEntry(url string, context map[string]any) map[string]any { + entry := map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": url, + } + if context != nil { + entry["context"] = context + } + return entry +} + +func TestGetMmBlocksActionSpec(t *testing.T) { + t.Run("prop absent returns nil", func(t *testing.T) { + p := &Post{} + assert.Nil(t, p.GetMmBlocksActionSpec("btn1")) + }) + + t.Run("empty action id returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + assert.Nil(t, p.GetMmBlocksActionSpec("")) + }) + + t.Run("id not found returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + assert.Nil(t, p.GetMmBlocksActionSpec("missing")) + }) + + t.Run("external entry returns spec with url and context", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", map[string]any{"k": "v"}), + }) + got := p.GetMmBlocksActionSpec("btn1") + require.NotNil(t, got) + assert.Equal(t, MmBlocksActionTypeExternal, got.Type) + assert.Equal(t, "http://example.com/hook", got.URL) + assert.Equal(t, "v", got.Context["k"]) + }) + + t.Run("entry missing type returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{"url": "http://example.com/hook"}, + }) + assert.Nil(t, p.GetMmBlocksActionSpec("btn1")) + }) + + t.Run("entry with unknown type returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": "bogus", + "url": "http://example.com/hook", + }, + }) + assert.Nil(t, p.GetMmBlocksActionSpec("btn1")) + }) + + t.Run("wrong-shape prop returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, "not-a-map") + assert.Nil(t, p.GetMmBlocksActionSpec("btn1")) + }) + + t.Run("entry value not an object returns nil", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": "not-an-object", + }) + assert.Nil(t, p.GetMmBlocksActionSpec("btn1")) + }) +} + +func TestValidateMmBlocksActions(t *testing.T) { + t.Run("absent prop returns no error", func(t *testing.T) { + p := &Post{} + assert.NoError(t, ValidateMmBlocksActions(p)) + }) + + t.Run("string prop is rejected (cookie transport not yet supported)", func(t *testing.T) { + // The cookie-transport PR will add proper validation for + // encrypted-string payloads. Until then, any string value is + // rejected so an integration session cannot bypass the + // alphanumeric-key, URL, and bounds checks by simply storing a + // raw string at the prop key. + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, "encrypted-cookie-blob") + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a map") + }) + + t.Run("valid external entries return no error", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + "btn2": mmBlocksExternalEntry("/plugins/myplugin/action", nil), + "btn3": mmBlocksExternalEntry("plugins/myplugin/action", nil), + }) + assert.NoError(t, ValidateMmBlocksActions(p)) + }) + + t.Run("exceeding MaxMmBlocksActionsPerPost returns error", func(t *testing.T) { + actions := make(map[string]any, MaxMmBlocksActionsPerPost+1) + for i := range MaxMmBlocksActionsPerPost + 1 { + actions["btn"+strconv.Itoa(i)] = mmBlocksExternalEntry("http://example.com/hook", nil) + } + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, actions) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum") + }) + + t.Run("action id with hyphen is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "foo-bar": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be alphanumeric") + }) + + t.Run("action id at MaxMmBlocksActionKeyLength is allowed", func(t *testing.T) { + key := strings.Repeat("a", MaxMmBlocksActionKeyLength) + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + key: mmBlocksExternalEntry("http://example.com/hook", nil), + }) + assert.NoError(t, ValidateMmBlocksActions(p)) + }) + + t.Run("action id over MaxMmBlocksActionKeyLength is rejected", func(t *testing.T) { + key := strings.Repeat("a", MaxMmBlocksActionKeyLength+1) + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + key: mmBlocksExternalEntry("http://example.com/hook", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds") + }) + + t.Run("action id with underscore is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "foo_bar": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be alphanumeric") + }) + + t.Run("action id with space is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "FOO bar": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be alphanumeric") + }) + + t.Run("empty URL is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-empty URL") + }) + + t.Run("path traversal in /plugins/ URL is rejected", func(t *testing.T) { + // Defense-in-depth: a `..` segment in a /plugins/ URL can escape the + // plugin namespace at request time. Bot-authored mm_blocks specs are + // the origin point so we reject at save. + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("/plugins/../../../etc/passwd", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") + }) + + t.Run("trailing /.. in /plugins/ URL is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("/plugins/myplugin/..", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") + }) + + t.Run("percent-encoded traversal in /plugins/ URL is rejected", func(t *testing.T) { + // doPluginRequest decodes the path via url.Parse before path.Clean, + // so an encoded "%2e%2e%2f" would otherwise route to a different + // plugin than the validator thinks it's protecting. Validator must + // decode symmetrically to catch this at save time. + for _, encoded := range []string{ + "/plugins/innocent/%2e%2e%2f/target/handler", + "/plugins/innocent/%2E%2E%2F/target/handler", + "/plugins/innocent/..%2f/target/handler", + "/plugins/innocent/%2e%2e/", + "/plugins/innocent/%2e%2e", + } { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry(encoded, nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err, "url=%q must be rejected", encoded) + assert.Contains(t, err.Error(), "path traversal", "url=%q", encoded) + } + }) + + t.Run("entry missing type is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{"url": "http://example.com/hook"}, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid type or shape") + }) + + t.Run("entry with unknown type is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": "bogus", + "url": "http://example.com/hook", + }, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid type or shape") + }) + + t.Run("entry value not an object is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": "not-an-object", + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be an object") + }) + + t.Run("javascript URL is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("javascript://alert(1)", nil), + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "valid integration URL") + }) + + t.Run("http URL is accepted", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://legit.com", nil), + }) + assert.NoError(t, ValidateMmBlocksActions(p)) + }) + + t.Run("/plugins/ URL is accepted", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("/plugins/foo", nil), + }) + assert.NoError(t, ValidateMmBlocksActions(p)) + }) + + t.Run("wrong-shape raw prop is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, []string{"not-a-map"}) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a map") + }) + + t.Run("static query exceeding entry cap is rejected", func(t *testing.T) { + query := make(map[string]any, MaxActionQueryEntries+1) + for i := range MaxActionQueryEntries + 1 { + query["k"+strconv.Itoa(i)] = "v" + } + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + "query": query, + }, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "static query") + }) + + t.Run("static query value exceeding length cap is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + "query": map[string]any{"k": strings.Repeat("a", MaxActionQueryValueLength+1)}, + }, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "static query") + }) + + t.Run("static context exceeding entry cap is rejected", func(t *testing.T) { + ctx := make(map[string]any, MaxActionQueryEntries+1) + for i := range MaxActionQueryEntries + 1 { + ctx["k"+strconv.Itoa(i)] = "v" + } + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + "context": ctx, + }, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "context exceeds maximum") + }) + + t.Run("static context key exceeding length cap is rejected", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + "context": map[string]any{strings.Repeat("a", MaxActionQueryKeyLength+1): "v"}, + }, + }) + err := ValidateMmBlocksActions(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "context key exceeds") + }) +} + +func TestStripActionIntegrations_MmBlocksActions(t *testing.T) { + t.Run("strips mm_blocks_actions prop", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + p.StripActionIntegrations() + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + }) + + t.Run("post without mm_blocks_actions prop does not panic", func(t *testing.T) { + p := &Post{} + assert.NotPanics(t, func() { + p.StripActionIntegrations() + }) + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + }) + + t.Run("post with both attachments and mm_blocks_actions cleans both", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsAttachments, []*MessageAttachment{ + { + Actions: []*PostAction{ + { + Id: "a1", + Name: "Button", + Type: PostActionTypeButton, + Integration: &PostActionIntegration{URL: "http://example.com/hook"}, + }, + }, + }, + }) + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + + p.StripActionIntegrations() + + // mm_blocks_actions prop should be removed entirely. + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + + // Attachment actions should remain but with nil Integration. + attachments := p.Attachments() + require.Len(t, attachments, 1) + require.Len(t, attachments[0].Actions, 1) + assert.Nil(t, attachments[0].Actions[0].Integration) + }) +} + +func TestGetAction_MmBlocksFallback(t *testing.T) { + t.Run("returns attachment action when present", func(t *testing.T) { + attachmentAction := &PostAction{ + Id: "a1", + Name: "Attach Button", + Type: PostActionTypeButton, + Integration: &PostActionIntegration{URL: "http://example.com/attach"}, + } + p := &Post{} + p.AddProp(PostPropsAttachments, []*MessageAttachment{ + {Actions: []*PostAction{attachmentAction}}, + }) + + got := p.GetAction("a1") + require.NotNil(t, got) + assert.Same(t, attachmentAction, got) + }) + + t.Run("synthesizes PostAction from mm_blocks_actions when no attachment match", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", map[string]any{"k": "v"}), + }) + + got := p.GetAction("btn1") + require.NotNil(t, got) + assert.Equal(t, "btn1", got.Id) + assert.Equal(t, PostActionTypeButton, got.Type) + require.NotNil(t, got.Integration) + assert.Equal(t, "http://example.com/hook", got.Integration.URL) + assert.Equal(t, "v", got.Integration.Context["k"]) + }) + + t.Run("synthesized URL pre-merges spec static query", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + "query": map[string]any{"source": "fleet-status"}, + }, + }) + + got := p.GetAction("btn1") + require.NotNil(t, got) + require.NotNil(t, got.Integration) + assert.Equal(t, "http://example.com/hook?source=fleet-status", got.Integration.URL) + }) + + t.Run("synthesized URL preserves existing query and adds spec static query", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook?team=alpha", + "query": map[string]any{"source": "fleet-status"}, + }, + }) + + got := p.GetAction("btn1") + require.NotNil(t, got) + require.NotNil(t, got.Integration) + // url.Values.Encode() sorts keys alphabetically. + assert.Contains(t, got.Integration.URL, "source=fleet-status") + assert.Contains(t, got.Integration.URL, "team=alpha") + }) + + t.Run("attachment wins when id matches both attachment and mm_blocks action", func(t *testing.T) { + attachmentAction := &PostAction{ + Id: "btn1", + Name: "Attach Button", + Type: PostActionTypeButton, + Integration: &PostActionIntegration{URL: "http://example.com/attach"}, + } + p := &Post{} + p.AddProp(PostPropsAttachments, []*MessageAttachment{ + {Actions: []*PostAction{attachmentAction}}, + }) + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/inline", nil), + }) + + got := p.GetAction("btn1") + require.NotNil(t, got) + assert.Same(t, attachmentAction, got) + assert.Equal(t, "http://example.com/attach", got.Integration.URL) + }) + + t.Run("returns nil when id matches neither", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsAttachments, []*MessageAttachment{ + {Actions: []*PostAction{{Id: "other", Name: "X", Type: PostActionTypeButton, Integration: &PostActionIntegration{URL: "http://example.com"}}}}, + }) + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "something": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + + assert.Nil(t, p.GetAction("missing")) + }) + + t.Run("returns nil when spec URL is unparseable and static query merge fails", func(t *testing.T) { + // Defense-in-depth: ValidateMmBlocksActions should reject this at + // save time, but if a malformed URL slips through, GetAction must + // not silently fire the bare URL with the static query dropped. + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/%%%bad", + "query": map[string]any{"source": "fleet"}, + }, + }) + + assert.Nil(t, p.GetAction("btn1")) + }) +} + +func TestMergeQueryIntoURL(t *testing.T) { + t.Run("empty query returns rawURL unchanged", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook", nil) + require.NoError(t, err) + assert.Equal(t, "http://example.com/hook", got) + + got, err = MergeQueryIntoURL("http://example.com/hook", map[string]string{}) + require.NoError(t, err) + assert.Equal(t, "http://example.com/hook", got) + }) + + t.Run("URL without existing query gets query appended", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook", map[string]string{"a": "1"}) + require.NoError(t, err) + assert.Equal(t, "http://example.com/hook?a=1", got) + }) + + t.Run("URL with existing query merges non-overlapping keys", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook?team=alpha", map[string]string{"source": "fleet"}) + require.NoError(t, err) + // Encode() sorts keys alphabetically. + assert.Contains(t, got, "team=alpha") + assert.Contains(t, got, "source=fleet") + }) + + t.Run("query map overrides existing key on overlap", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook?tail=999", map[string]string{"tail": "214"}) + require.NoError(t, err) + assert.Equal(t, "http://example.com/hook?tail=214", got) + }) + + t.Run("URL fragment is preserved", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook#anchor", map[string]string{"a": "1"}) + require.NoError(t, err) + assert.Equal(t, "http://example.com/hook?a=1#anchor", got) + }) + + t.Run("special characters in values are URL-encoded", func(t *testing.T) { + got, err := MergeQueryIntoURL("http://example.com/hook", map[string]string{"q": "a b&c=d"}) + require.NoError(t, err) + // space → +, & and = → %26 / %3D + assert.Contains(t, got, "q=a+b%26c%3Dd") + }) + + t.Run("relative URL with empty path accepts query merge", func(t *testing.T) { + got, err := MergeQueryIntoURL("/plugins/myplugin/action", map[string]string{"a": "1"}) + require.NoError(t, err) + assert.Equal(t, "/plugins/myplugin/action?a=1", got) + }) + + t.Run("malformed URL returns parse error", func(t *testing.T) { + _, err := MergeQueryIntoURL("://not-a-url", map[string]string{"a": "1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse url") + }) +} + +func TestMmBlocksContextMap(t *testing.T) { + t.Run("empty string returns nil", func(t *testing.T) { + assert.Nil(t, MmBlocksContextMap("")) + }) + + t.Run("valid JSON object string is parsed into a map", func(t *testing.T) { + got := MmBlocksContextMap(`{"k":"v","n":1}`) + require.NotNil(t, got) + assert.Equal(t, "v", got["k"]) + // JSON numbers decode to float64. + assert.Equal(t, float64(1), got["n"]) + }) + + t.Run("non-JSON string is wrapped under context key", func(t *testing.T) { + got := MmBlocksContextMap("hello world") + require.NotNil(t, got) + assert.Equal(t, "hello world", got["context"]) + }) + + t.Run("JSON null falls back to wrap (m is nil after unmarshal)", func(t *testing.T) { + got := MmBlocksContextMap("null") + require.NotNil(t, got) + assert.Equal(t, "null", got["context"]) + }) + + t.Run("JSON array falls back to wrap (target type mismatch)", func(t *testing.T) { + got := MmBlocksContextMap("[1,2,3]") + require.NotNil(t, got) + assert.Equal(t, "[1,2,3]", got["context"]) + }) + + t.Run("JSON number falls back to wrap (target type mismatch)", func(t *testing.T) { + got := MmBlocksContextMap("42") + require.NotNil(t, got) + assert.Equal(t, "42", got["context"]) + }) + + t.Run("malformed JSON falls back to wrap", func(t *testing.T) { + got := MmBlocksContextMap(`{"unclosed":`) + require.NotNil(t, got) + assert.Equal(t, `{"unclosed":`, got["context"]) + }) +} + +func TestStripMmBlocksActionSecrets(t *testing.T) { + t.Run("absent prop is a no-op", func(t *testing.T) { + p := &Post{} + assert.NotPanics(t, func() { + p.StripMmBlocksActionSecrets() + }) + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + }) + + t.Run("map-form prop is deleted", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + p.StripMmBlocksActionSecrets() + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + }) + + t.Run("string-form prop is deleted (cookie transport not yet supported)", func(t *testing.T) { + // Until the cookie-transport PR ships proper handling, any string + // value is treated as opaque garbage and stripped wholesale — + // matches the validator's reject-strings policy. + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, "encrypted-cookie-blob") + p.StripMmBlocksActionSecrets() + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + }) + + t.Run("other props on the post are not touched", func(t *testing.T) { + p := &Post{} + p.AddProp(PostPropsMmBlocksActions, map[string]any{ + "btn1": mmBlocksExternalEntry("http://example.com/hook", nil), + }) + p.AddProp(PostPropsAttachments, []*MessageAttachment{{Text: "keep me"}}) + p.AddProp(PostPropsFromBot, "true") + + p.StripMmBlocksActionSecrets() + + assert.Nil(t, p.GetProp(PostPropsMmBlocksActions)) + assert.NotNil(t, p.GetProp(PostPropsAttachments)) + assert.Equal(t, "true", p.GetProp(PostPropsFromBot)) + }) +} diff --git a/server/public/model/job.go b/server/public/model/job.go index 500a05e459e..77bfd7bfb1a 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -48,6 +48,7 @@ const ( JobTypeRecap = "recap" JobTypeDeleteExpiredPosts = "delete_expired_posts" JobTypeAutoTranslationRecovery = "autotranslation_recovery" + JobTypeCleanupExpiredAccessTokens = "cleanup_expired_access_tokens" JobStatusPending = "pending" JobStatusInProgress = "in_progress" @@ -78,6 +79,7 @@ var AllJobTypes = [...]string{ JobTypeLastAccessiblePost, JobTypeLastAccessibleFile, JobTypeCleanupDesktopTokens, + JobTypeCleanupExpiredAccessTokens, JobTypeRefreshMaterializedViews, JobTypeMobileSessionMetadata, } diff --git a/server/public/model/mm_blocks_actions.go b/server/public/model/mm_blocks_actions.go new file mode 100644 index 00000000000..dbea4c869aa --- /dev/null +++ b/server/public/model/mm_blocks_actions.go @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Server-side definitions for the post.props.mm_blocks_actions registry that +// underpins the markdown-actions feature. Mirrors the canonical model +// landing in the broader mm_blocks framework PR; cookie transport +// (MmBlocksActionCookie, AddMmBlocksActionCookies, ParseDecryptedActionCookiePayload) +// is intentionally omitted here and will be filled in by that PR. Until then, +// mm_blocks_actions is resolved on click via DB lookup +// (GetMmBlocksActionSpec) and stripped from ephemeral broadcasts so dead +// buttons don't render. + +package model + +import ( + "encoding/json" + "fmt" + "maps" + "net/url" +) + +const ( + MmBlocksActionTypeExternal = "external" +) + +// MmBlocksActionSpec is the server-side definition for one entry in props.mm_blocks_actions. +type MmBlocksActionSpec struct { + Type string + URL string + Query map[string]string + Context map[string]any +} + +// GetMmBlocksActionSpec returns the action definition for actionID from props.mm_blocks_actions, if present. +func (o *Post) GetMmBlocksActionSpec(actionID string) *MmBlocksActionSpec { + raw := o.GetProp(PostPropsMmBlocksActions) + if raw == nil || actionID == "" { + return nil + } + actionsTop, ok := coerceToStringAnyMap(raw) + if !ok { + return nil + } + entry, ok := actionsTop[actionID] + if !ok || entry == nil { + return nil + } + entryMap, ok := coerceToStringAnyMap(entry) + if !ok { + return nil + } + return mmBlocksEntryMapToSpec(entryMap) +} + +// mmBlocksEntryMapToSpec maps one props.mm_blocks_actions[actionID] object to MmBlocksActionSpec. +func mmBlocksEntryMapToSpec(entryMap map[string]any) *MmBlocksActionSpec { + typ, _ := entryMap["type"].(string) + if typ == "" { + return nil + } + if typ != MmBlocksActionTypeExternal { + return nil + } + spec := &MmBlocksActionSpec{Type: typ} + spec.URL, _ = entryMap["url"].(string) + spec.Context = contextMapFromProp(entryMap["context"]) + spec.Query = stringMapFromPropValue(entryMap["query"]) + return spec +} + +// MmBlocksContextMap parses a context JSON string or treats a non-JSON string as a single context value. +func MmBlocksContextMap(contextString string) map[string]any { + if contextString == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(contextString), &m); err == nil && m != nil { + return m + } + return map[string]any{"context": contextString} +} + +// MergeQueryIntoURL merges q into rawURL's query string; existing keys are overwritten by q. +func MergeQueryIntoURL(rawURL string, q map[string]string) (string, error) { + if len(q) == 0 { + return rawURL, nil + } + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse url: %w", err) + } + values := u.Query() + for k, v := range q { + values.Set(k, v) + } + u.RawQuery = values.Encode() + return u.String(), nil +} + +// StripMmBlocksActionSecrets removes server-only fields from +// props.mm_blocks_actions for wire serialization. The current +// implementation deletes the prop wholesale; the cookie-transport PR will +// extend this to preserve encrypted-string cookie payloads in place. +func (o *Post) StripMmBlocksActionSecrets() { + if o.GetProp(PostPropsMmBlocksActions) == nil { + return + } + o.DelProp(PostPropsMmBlocksActions) +} + +// contextMapFromProp normalizes props.mm_blocks_actions[*].context to map[string]any (JSON object or string). +func contextMapFromProp(v any) map[string]any { + if v == nil { + return nil + } + if s, ok := v.(string); ok { + return MmBlocksContextMap(s) + } + if m, ok := coerceToStringAnyMap(v); ok { + // Clone so callers cannot mutate the live post.Props map. A + // nested mutation through the returned map would otherwise race + // with concurrent post.Props readers. + return maps.Clone(m) + } + return nil +} + +func stringMapFromPropValue(v any) map[string]string { + m, ok := coerceToStringAnyMap(v) + if !ok || len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, val := range m { + if s, ok := val.(string); ok { + out[k] = s + } + } + if len(out) == 0 { + return nil + } + return out +} + +func coerceToStringAnyMap(v any) (map[string]any, bool) { + if v == nil { + return nil, false + } + m, ok := v.(map[string]any) + if ok { + return m, true + } + return nil, false +} diff --git a/server/public/model/post.go b/server/public/model/post.go index 3c1d52cb9ae..50f41586673 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -93,6 +93,7 @@ const ( PostPropsFromOAuthApp = "from_oauth_app" PostPropsWebhookDisplayName = "webhook_display_name" PostPropsAttachments = "attachments" + PostPropsMmBlocksActions = "mm_blocks_actions" PostPropsFromPlugin = "from_plugin" PostPropsMentionHighlightDisabled = "mentionHighlightDisabled" PostPropsGroupHighlightDisabled = "disable_group_highlight" @@ -619,6 +620,7 @@ func ContainsIntegrationsReservedProps(props StringInterface) []string { PostPropsWebhookDisplayName, PostPropsOverrideIconURL, PostPropsOverrideIconEmoji, + PostPropsMmBlocksActions, } for _, key := range reservedProps { @@ -843,6 +845,12 @@ func (o *Post) propsIsValid() error { } } + if props[PostPropsMmBlocksActions] != nil { + if err := ValidateMmBlocksActions(o); err != nil { + multiErr = multierror.Append(multiErr, fmt.Errorf("invalid mm_blocks_actions: %w", err)) + } + } + for i, a := range o.Attachments() { if err := a.IsValid(); err != nil { multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("message attachtment at index %d is invalid:", i))) @@ -1197,6 +1205,14 @@ func (o *Post) CleanPost() *Post { type UpdatePostOptions struct { SafeUpdate bool IsRestorePost bool + + // AllowMmBlocksActionsUpdate grants the caller permission to add, + // remove, or modify the mm_blocks_actions prop. Without it, + // non-integration sessions cannot change mm_blocks_actions and the + // prop is reset to its prior value. Set only from trusted paths (e.g. + // the post-action integration response handler which has already + // validated the incoming value). + AllowMmBlocksActionsUpdate bool } func DefaultUpdatePostOptions() *UpdatePostOptions { diff --git a/server/public/model/post_test.go b/server/public/model/post_test.go index 1d0b17b2d73..62739b184b9 100644 --- a/server/public/model/post_test.go +++ b/server/public/model/post_test.go @@ -171,10 +171,17 @@ func TestPost_ContainsIntegrationsReservedProps(t *testing.T) { PostPropsOverrideUsername: "overridden_username", PostPropsOverrideIconURL: "a-custom-url", PostPropsOverrideIconEmoji: ":custom_emoji_name:", + PostPropsMmBlocksActions: map[string]any{ + "btn1": map[string]any{ + "type": MmBlocksActionTypeExternal, + "url": "http://example.com/hook", + }, + }, }, } keys2 := post2.ContainsIntegrationsReservedProps() - require.Len(t, keys2, 5) + require.Len(t, keys2, 6) + require.Contains(t, keys2, PostPropsMmBlocksActions) } func TestPostPatch_ContainsIntegrationsReservedProps(t *testing.T) { diff --git a/server/public/model/property_field.go b/server/public/model/property_field.go index 73c64e445f1..70e6f9e7707 100644 --- a/server/public/model/property_field.go +++ b/server/public/model/property_field.go @@ -41,6 +41,11 @@ const ( PermissionLevelNone PermissionLevel = "none" PermissionLevelSysadmin PermissionLevel = "sysadmin" PermissionLevelMember PermissionLevel = "member" + // PermissionLevelAdmin resolves to the admin of the field's target: sysadmin + // for system targets, team admin for team targets, channel admin for + // channel targets. The specific permission checked per scope is documented + // at hasPropertyFieldPermissionLevel in the app package. + PermissionLevelAdmin PermissionLevel = "admin" PropertyFieldObjectTypePost = "post" PropertyFieldObjectTypeChannel = "channel" @@ -48,13 +53,15 @@ const ( PropertyFieldObjectTypeTemplate = "template" PropertyFieldObjectTypeSystem = "system" - - // NOTE: Temporarily using this until CPA is migrated to v2 - ClassificationMarkingsPropertyGroupName = "classification_markings" ) // validPermissionLevels contains all valid PermissionLevel values. -var validPermissionLevels = []PermissionLevel{PermissionLevelNone, PermissionLevelSysadmin, PermissionLevelMember} +var validPermissionLevels = []PermissionLevel{ + PermissionLevelNone, + PermissionLevelSysadmin, + PermissionLevelMember, + PermissionLevelAdmin, +} // validPSAv2TargetTypes contains all valid TargetType values for PSAv2 properties. var validPSAv2TargetTypes = []string{ diff --git a/server/public/model/property_field_test.go b/server/public/model/property_field_test.go index 15efb8b7a79..0a43768b557 100644 --- a/server/public/model/property_field_test.go +++ b/server/public/model/property_field_test.go @@ -723,8 +723,8 @@ func TestPropertyField_IsValid(t *testing.T) { require.NoError(t, pf.IsValid()) }) - t.Run("non-protected field with admin or member field permission is valid", func(t *testing.T) { - for _, level := range []PermissionLevel{PermissionLevelSysadmin, PermissionLevelMember} { + t.Run("non-protected field with non-none field permission is valid", func(t *testing.T) { + for _, level := range []PermissionLevel{PermissionLevelSysadmin, PermissionLevelMember, PermissionLevelAdmin} { pf := baseField() pf.Protected = false pf.PermissionField = new(level) @@ -759,22 +759,15 @@ func TestPropertyField_IsValid(t *testing.T) { require.Error(t, pf.IsValid()) }) - t.Run("protected field with field=admin is invalid", func(t *testing.T) { - pf := baseField() - pf.Protected = true - pf.PermissionField = new(PermissionLevelSysadmin) - pf.PermissionValues = new(PermissionLevelMember) - pf.PermissionOptions = new(PermissionLevelMember) - require.Error(t, pf.IsValid()) - }) - - t.Run("protected field with field=member is invalid", func(t *testing.T) { - pf := baseField() - pf.Protected = true - pf.PermissionField = new(PermissionLevelMember) - pf.PermissionValues = new(PermissionLevelMember) - pf.PermissionOptions = new(PermissionLevelMember) - require.Error(t, pf.IsValid()) + t.Run("protected field with non-none field permission is invalid", func(t *testing.T) { + for _, level := range []PermissionLevel{PermissionLevelSysadmin, PermissionLevelMember, PermissionLevelAdmin} { + pf := baseField() + pf.Protected = true + pf.PermissionField = new(level) + pf.PermissionValues = new(PermissionLevelMember) + pf.PermissionOptions = new(PermissionLevelMember) + require.Error(t, pf.IsValid(), "should be invalid with field permission %s", level) + } }) t.Run("invalid permission_field value is rejected", func(t *testing.T) { @@ -798,6 +791,46 @@ func TestPropertyField_IsValid(t *testing.T) { require.Error(t, pf.IsValid()) }) }) + + t.Run("admin permission level", func(t *testing.T) { + baseField := func(target PropertyFieldTargetLevel) *PropertyField { + pf := &PropertyField{ + ID: NewId(), + GroupID: NewId(), + Name: "test field", + Type: PropertyFieldTypeText, + ObjectType: PropertyFieldObjectTypePost, + TargetType: string(target), + CreateAt: GetMillis(), + UpdateAt: GetMillis(), + } + if target != PropertyFieldTargetLevelSystem { + pf.TargetID = NewId() + } + return pf + } + + for _, target := range []PropertyFieldTargetLevel{ + PropertyFieldTargetLevelSystem, + PropertyFieldTargetLevelTeam, + PropertyFieldTargetLevelChannel, + } { + t.Run("admin is valid on "+string(target)+" target", func(t *testing.T) { + pf := baseField(target) + pf.PermissionField = new(PermissionLevelAdmin) + pf.PermissionValues = new(PermissionLevelAdmin) + pf.PermissionOptions = new(PermissionLevelAdmin) + require.NoError(t, pf.IsValid()) + }) + } + + t.Run("PSAv1 field rejects admin permission level", func(t *testing.T) { + pf := baseField(PropertyFieldTargetLevelChannel) + pf.ObjectType = "" + pf.PermissionField = new(PermissionLevelAdmin) + require.Error(t, pf.IsValid()) + }) + }) } func TestPropertyFieldPatch_IsValid(t *testing.T) { diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 7a2d85a893c..b35acd2eb76 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -49,12 +49,34 @@ type SupportPacketDiagnostics struct { } `yaml:"config"` Database struct { - Type string `yaml:"type"` - Version string `yaml:"version"` - SchemaVersion string `yaml:"schema_version"` - MasterConnectios int `yaml:"master_connections"` - ReplicaConnectios int `yaml:"replica_connections"` - SearchConnections int `yaml:"search_connections"` + Type string `yaml:"type"` + Version string `yaml:"version"` + SchemaVersion string `yaml:"schema_version"` + MasterConnections int `yaml:"master_connections"` + ReplicaConnections int `yaml:"replica_connections"` + SearchConnections int `yaml:"search_connections"` + MasterConnectionsInUse int `yaml:"master_connections_in_use"` + MasterConnectionsIdle int `yaml:"master_connections_idle"` + MasterPoolWaitCount int64 `yaml:"master_pool_wait_count"` + MasterPoolWaitDurationMs int64 `yaml:"master_pool_wait_duration_ms"` + MasterConnectionsClosedMaxIdle int64 `yaml:"master_connections_closed_max_idle"` + MasterConnectionsClosedMaxLifetime int64 `yaml:"master_connections_closed_max_lifetime"` + ReplicaConnectionsInUse int `yaml:"replica_connections_in_use"` + ReplicaConnectionsIdle int `yaml:"replica_connections_idle"` + ReplicaPoolWaitCount int64 `yaml:"replica_pool_wait_count"` + ReplicaPoolWaitDurationMs int64 `yaml:"replica_pool_wait_duration_ms"` + ReplicaConnectionsClosedMaxIdle int64 `yaml:"replica_connections_closed_max_idle"` + ReplicaConnectionsClosedMaxLifetime int64 `yaml:"replica_connections_closed_max_lifetime"` + CacheHitRatio *float64 `yaml:"cache_hit_ratio,omitempty"` + Deadlocks *int64 `yaml:"deadlocks,omitempty"` + TempFiles *int64 `yaml:"temp_files,omitempty"` + TempBytesMB *float64 `yaml:"temp_bytes_mb,omitempty"` + Rollbacks *int64 `yaml:"rollbacks,omitempty"` + IdleInTransactionCount *int64 `yaml:"idle_in_transaction_count,omitempty"` + LongestQueryDurationSeconds *float64 `yaml:"longest_query_duration_seconds,omitempty"` + WaitingForLockCount *int64 `yaml:"waiting_for_lock_count,omitempty"` + PostsDeadTuples *int64 `yaml:"posts_dead_tuples,omitempty"` + PostsLastAutovacuum *time.Time `yaml:"posts_last_autovacuum,omitempty"` } `yaml:"database"` FileStore struct { @@ -95,6 +117,8 @@ type SupportPacketDiagnostics struct { SAML struct { ProviderType string `yaml:"provider_type,omitempty"` + Status string `yaml:"status,omitempty"` + Error string `yaml:"error,omitempty"` } `yaml:"saml"` ElasticSearch struct { @@ -104,6 +128,22 @@ type SupportPacketDiagnostics struct { ServerPlugins []string `yaml:"server_plugins,omitempty"` Error string `yaml:"error,omitempty"` } `yaml:"elastic"` + + OAuthProviders OAuthProviders `yaml:"oauth_providers,omitempty"` +} + +// OAuthProviderStatus reports the connectivity status of a single OAuth2/OpenID Connect provider. +type OAuthProviderStatus struct { + Status string `yaml:"status,omitempty"` // ok / fail / disabled + Error string `yaml:"error,omitempty"` +} + +// OAuthProviders aggregates the connectivity status for the configured OAuth2/OpenID Connect providers. +type OAuthProviders struct { + GitLab OAuthProviderStatus `yaml:"gitlab,omitempty"` + Google OAuthProviderStatus `yaml:"google,omitempty"` + Office365 OAuthProviderStatus `yaml:"office365,omitempty"` + OpenID OAuthProviderStatus `yaml:"openid,omitempty"` } type SupportPacketStats struct { diff --git a/server/public/model/user_access_token.go b/server/public/model/user_access_token.go index dee31f1837a..9acccbfeab9 100644 --- a/server/public/model/user_access_token.go +++ b/server/public/model/user_access_token.go @@ -13,6 +13,11 @@ type UserAccessToken struct { UserId string `json:"user_id"` Description string `json:"description"` IsActive bool `json:"is_active"` + // ExpiresAt is the Unix timestamp in milliseconds at which the token + // expires. A value of 0 means the token does not expire. Tokens whose + // ExpiresAt is non-zero and in the past are considered expired and + // MUST be rejected at validation time. + ExpiresAt int64 `json:"expires_at"` } func (t *UserAccessToken) IsValid() *AppError { @@ -32,6 +37,10 @@ func (t *UserAccessToken) IsValid() *AppError { return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.description.app_error", nil, "", http.StatusBadRequest) } + if t.ExpiresAt < 0 { + return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.expires_at.app_error", nil, "", http.StatusBadRequest) + } + return nil } @@ -39,3 +48,13 @@ func (t *UserAccessToken) PreSave() { t.Id = NewId() t.IsActive = true } + +// IsExpired reports whether the token has a non-zero ExpiresAt in the past. +// Tokens with ExpiresAt == 0 are treated as non-expiring for backwards +// compatibility with tokens that existed before expiry was introduced. +func (t *UserAccessToken) IsExpired() bool { + if t.ExpiresAt <= 0 { + return false + } + return GetMillis() >= t.ExpiresAt +} diff --git a/server/public/model/user_access_token_test.go b/server/public/model/user_access_token_test.go index 7060430b474..762b08aa1bd 100644 --- a/server/public/model/user_access_token_test.go +++ b/server/public/model/user_access_token_test.go @@ -29,4 +29,37 @@ func TestUserAccessTokenIsValid(t *testing.T) { ad.Description = NewRandomString(256) appErr = ad.IsValid() require.False(t, appErr == nil || appErr.Id != "model.user_access_token.is_valid.description.app_error") + + ad.Description = NewRandomString(100) + ad.ExpiresAt = -1 + appErr = ad.IsValid() + require.NotNil(t, appErr) + require.Equal(t, "model.user_access_token.is_valid.expires_at.app_error", appErr.Id) + + ad.ExpiresAt = GetMillis() + 1000 + require.Nil(t, ad.IsValid()) +} + +func TestUserAccessTokenIsExpired(t *testing.T) { + now := GetMillis() + + t.Run("zero never expires", func(t *testing.T) { + tok := &UserAccessToken{ExpiresAt: 0} + require.False(t, tok.IsExpired()) + }) + + t.Run("negative never expires", func(t *testing.T) { + tok := &UserAccessToken{ExpiresAt: -1} + require.False(t, tok.IsExpired()) + }) + + t.Run("future not expired", func(t *testing.T) { + tok := &UserAccessToken{ExpiresAt: now + 60*1000} + require.False(t, tok.IsExpired()) + }) + + t.Run("past is expired", func(t *testing.T) { + tok := &UserAccessToken{ExpiresAt: now - 60*1000} + require.True(t, tok.IsExpired()) + }) } diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index 97b06dfd84a..23eabd0bed8 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -510,6 +510,26 @@ type API interface { // Minimum server version: 5.2 UpdateChannel(channel *model.Channel) (*model.Channel, *model.AppError) + // RegisterChannelGuard claims the channel for this plugin, signaling to the server that the + // channel has plugin-managed semantics and that the server's default behaviors are unsafe + // without plugin involvement. + // + // The calling plugin's ID is implicit. Multiple plugins may co-guard the same channel; each + // claim is an independent row. Subsequent calls from the same plugin are idempotent; calls from + // a different plugin add a new claim. + // + // @tag Channel + // Minimum server version: 11.8 + RegisterChannelGuard(channelID string) *model.AppError + + // UnregisterChannelGuard releases this plugin's claim on the channel. Only the registering + // plugin can unregister its own claim; other plugins' claims on the same channel are + // unaffected. + // + // @tag Channel + // Minimum server version: 11.8 + UnregisterChannelGuard(channelID string) *model.AppError + // SearchChannels returns the channels on a team matching the provided search term. // // @tag Channel diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index 35818b5f6ac..c4301202d4e 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -560,6 +560,20 @@ func (api *apiTimerLayer) UpdateChannel(channel *model.Channel) (*model.Channel, return _returnsA, _returnsB } +func (api *apiTimerLayer) RegisterChannelGuard(channelID string) *model.AppError { + startTime := timePkg.Now() + _returnsA := api.apiImpl.RegisterChannelGuard(channelID) + api.recordTime(startTime, "RegisterChannelGuard", _returnsA == nil) + return _returnsA +} + +func (api *apiTimerLayer) UnregisterChannelGuard(channelID string) *model.AppError { + startTime := timePkg.Now() + _returnsA := api.apiImpl.UnregisterChannelGuard(channelID) + api.recordTime(startTime, "UnregisterChannelGuard", _returnsA == nil) + return _returnsA +} + func (api *apiTimerLayer) SearchChannels(teamID string, term string) ([]*model.Channel, *model.AppError) { startTime := timePkg.Now() _returnsA, _returnsB := api.apiImpl.SearchChannels(teamID, term) diff --git a/server/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index 196b43ed2b3..76ce6c8825d 100644 --- a/server/public/plugin/client_rpc.go +++ b/server/public/plugin/client_rpc.go @@ -1460,6 +1460,83 @@ func (s *hooksRPCServer) ChannelMemberWillBeAdded(args *Z_ChannelMemberWillBeAdd return nil } +// MessageWillBePostedWithRPCErr returns the same values as MessageWillBePosted, with an additional +// trailing error for the RPC transport — always the LAST return slot. This hand-written companion +// exists because MessageWillBePosted is in excludedPluginHooks and therefore absent from the +// auto-generated HooksWithRPCErrGenerated interface in client_rpc_generated.go. +func (g *hooksRPCClient) MessageWillBePostedWithRPCErr(c *Context, post *model.Post) (*model.Post, string, error) { + _args := &Z_MessageWillBePostedArgs{c, post} + _returns := &Z_MessageWillBePostedReturns{} + var _err error + if g.implemented[MessageWillBePostedID] { + _err = g.client.Call("Plugin.MessageWillBePosted", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_MessageWillBePostedReturns{} + g.log.Debug("RPC call MessageWillBePosted to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +// MessageWillBeUpdatedWithRPCErr returns the same values as MessageWillBeUpdated, with an additional +// trailing error for the RPC transport — always the LAST return slot. This hand-written companion +// exists because MessageWillBeUpdated is in excludedPluginHooks and therefore absent from the +// auto-generated HooksWithRPCErrGenerated interface in client_rpc_generated.go. +func (g *hooksRPCClient) MessageWillBeUpdatedWithRPCErr(c *Context, newPost, oldPost *model.Post) (*model.Post, string, error) { + _args := &Z_MessageWillBeUpdatedArgs{c, newPost, oldPost} + _returns := &Z_MessageWillBeUpdatedReturns{} + var _err error + if g.implemented[MessageWillBeUpdatedID] { + _err = g.client.Call("Plugin.MessageWillBeUpdated", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_MessageWillBeUpdatedReturns{} + g.log.Debug("RPC call MessageWillBeUpdated to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +// ChannelMemberWillBeAddedWithRPCErr returns the same values as ChannelMemberWillBeAdded, with an +// additional trailing error for the RPC transport — always the LAST return slot. This hand-written +// companion exists because ChannelMemberWillBeAdded is in excludedPluginHooks and therefore absent +// from the auto-generated HooksWithRPCErrGenerated interface in client_rpc_generated.go. +func (g *hooksRPCClient) ChannelMemberWillBeAddedWithRPCErr(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) { + _args := &Z_ChannelMemberWillBeAddedArgs{c, channelMember} + _returns := &Z_ChannelMemberWillBeAddedReturns{} + var _err error + if g.implemented[ChannelMemberWillBeAddedID] { + _err = g.client.Call("Plugin.ChannelMemberWillBeAdded", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_ChannelMemberWillBeAddedReturns{} + g.log.Debug("RPC call ChannelMemberWillBeAdded to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +// HooksWithRPCErr extends HooksWithRPCErrGenerated with *WithRPCErr companions for the three hooks whose +// base stubs are hand-written in this file. The auto-generated HooksWithRPCErrGenerated in +// client_rpc_generated.go cannot include these because the generator skips excluded hooks. +// Returned by Environment.HooksForPluginWithRPCErr so callers can invoke any *WithRPCErr method +// without a type assertion. +type HooksWithRPCErr interface { + HooksWithRPCErrGenerated + MessageWillBePostedWithRPCErr(c *Context, post *model.Post) (*model.Post, string, error) + MessageWillBeUpdatedWithRPCErr(c *Context, newPost, oldPost *model.Post) (*model.Post, string, error) + ChannelMemberWillBeAddedWithRPCErr(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) +} + +var ( + _ HooksWithRPCErr = (*hooksRPCClient)(nil) + _ HooksWithRPCErr = (*hooksTimerLayer)(nil) +) + // TeamMemberWillBeAdded is hand-written to preserve the original TeamMember as the default // return value, avoiding unintentional field removal by older plugins. func init() { diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 5cf2fd52206..412646599d8 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -47,7 +47,7 @@ func (g *hooksRPCClient) OnDeactivateWithRPCErr() (error, error) { _err = g.client.Call("Plugin.OnDeactivate", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnDeactivateReturns{} g.log.Debug("RPC call OnDeactivate to plugin failed.", mlog.Err(_err)) } @@ -99,7 +99,7 @@ func (g *hooksRPCClient) OnConfigurationChangeWithRPCErr() (error, error) { _err = g.client.Call("Plugin.OnConfigurationChange", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnConfigurationChangeReturns{} g.log.Debug("RPC call OnConfigurationChange to plugin failed.", mlog.Err(_err)) } @@ -154,7 +154,7 @@ func (g *hooksRPCClient) ExecuteCommandWithRPCErr(c *Context, args *model.Comman _err = g.client.Call("Plugin.ExecuteCommand", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ExecuteCommandReturns{} g.log.Debug("RPC call ExecuteCommand to plugin failed.", mlog.Err(_err)) } @@ -206,7 +206,7 @@ func (g *hooksRPCClient) UserHasBeenCreatedWithRPCErr(c *Context, user *model.Us _err = g.client.Call("Plugin.UserHasBeenCreated", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasBeenCreatedReturns{} g.log.Debug("RPC call UserHasBeenCreated to plugin failed.", mlog.Err(_err)) } @@ -259,7 +259,7 @@ func (g *hooksRPCClient) UserWillLogInWithRPCErr(c *Context, user *model.User) ( _err = g.client.Call("Plugin.UserWillLogIn", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserWillLogInReturns{} g.log.Debug("RPC call UserWillLogIn to plugin failed.", mlog.Err(_err)) } @@ -311,7 +311,7 @@ func (g *hooksRPCClient) UserHasLoggedInWithRPCErr(c *Context, user *model.User) _err = g.client.Call("Plugin.UserHasLoggedIn", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasLoggedInReturns{} g.log.Debug("RPC call UserHasLoggedIn to plugin failed.", mlog.Err(_err)) } @@ -363,7 +363,7 @@ func (g *hooksRPCClient) MessageHasBeenPostedWithRPCErr(c *Context, post *model. _err = g.client.Call("Plugin.MessageHasBeenPosted", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_MessageHasBeenPostedReturns{} g.log.Debug("RPC call MessageHasBeenPosted to plugin failed.", mlog.Err(_err)) } @@ -416,7 +416,7 @@ func (g *hooksRPCClient) MessageHasBeenUpdatedWithRPCErr(c *Context, newPost, ol _err = g.client.Call("Plugin.MessageHasBeenUpdated", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_MessageHasBeenUpdatedReturns{} g.log.Debug("RPC call MessageHasBeenUpdated to plugin failed.", mlog.Err(_err)) } @@ -468,7 +468,7 @@ func (g *hooksRPCClient) MessageHasBeenDeletedWithRPCErr(c *Context, post *model _err = g.client.Call("Plugin.MessageHasBeenDeleted", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_MessageHasBeenDeletedReturns{} g.log.Debug("RPC call MessageHasBeenDeleted to plugin failed.", mlog.Err(_err)) } @@ -520,7 +520,7 @@ func (g *hooksRPCClient) ChannelHasBeenCreatedWithRPCErr(c *Context, channel *mo _err = g.client.Call("Plugin.ChannelHasBeenCreated", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ChannelHasBeenCreatedReturns{} g.log.Debug("RPC call ChannelHasBeenCreated to plugin failed.", mlog.Err(_err)) } @@ -573,7 +573,7 @@ func (g *hooksRPCClient) ChannelWillBeArchivedWithRPCErr(c *Context, channel *mo _err = g.client.Call("Plugin.ChannelWillBeArchived", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ChannelWillBeArchivedReturns{} g.log.Debug("RPC call ChannelWillBeArchived to plugin failed.", mlog.Err(_err)) } @@ -626,7 +626,7 @@ func (g *hooksRPCClient) UserHasJoinedChannelWithRPCErr(c *Context, channelMembe _err = g.client.Call("Plugin.UserHasJoinedChannel", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasJoinedChannelReturns{} g.log.Debug("RPC call UserHasJoinedChannel to plugin failed.", mlog.Err(_err)) } @@ -679,7 +679,7 @@ func (g *hooksRPCClient) UserHasLeftChannelWithRPCErr(c *Context, channelMember _err = g.client.Call("Plugin.UserHasLeftChannel", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasLeftChannelReturns{} g.log.Debug("RPC call UserHasLeftChannel to plugin failed.", mlog.Err(_err)) } @@ -732,7 +732,7 @@ func (g *hooksRPCClient) UserHasJoinedTeamWithRPCErr(c *Context, teamMember *mod _err = g.client.Call("Plugin.UserHasJoinedTeam", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasJoinedTeamReturns{} g.log.Debug("RPC call UserHasJoinedTeam to plugin failed.", mlog.Err(_err)) } @@ -785,7 +785,7 @@ func (g *hooksRPCClient) UserHasLeftTeamWithRPCErr(c *Context, teamMember *model _err = g.client.Call("Plugin.UserHasLeftTeam", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasLeftTeamReturns{} g.log.Debug("RPC call UserHasLeftTeam to plugin failed.", mlog.Err(_err)) } @@ -840,7 +840,7 @@ func (g *hooksRPCClient) FileWillBeDownloadedWithRPCErr(c *Context, fileInfo *mo _err = g.client.Call("Plugin.FileWillBeDownloaded", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_FileWillBeDownloadedReturns{} g.log.Debug("RPC call FileWillBeDownloaded to plugin failed.", mlog.Err(_err)) } @@ -892,7 +892,7 @@ func (g *hooksRPCClient) ReactionHasBeenAddedWithRPCErr(c *Context, reaction *mo _err = g.client.Call("Plugin.ReactionHasBeenAdded", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ReactionHasBeenAddedReturns{} g.log.Debug("RPC call ReactionHasBeenAdded to plugin failed.", mlog.Err(_err)) } @@ -944,7 +944,7 @@ func (g *hooksRPCClient) ReactionHasBeenRemovedWithRPCErr(c *Context, reaction * _err = g.client.Call("Plugin.ReactionHasBeenRemoved", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ReactionHasBeenRemovedReturns{} g.log.Debug("RPC call ReactionHasBeenRemoved to plugin failed.", mlog.Err(_err)) } @@ -996,7 +996,7 @@ func (g *hooksRPCClient) OnPluginClusterEventWithRPCErr(c *Context, ev model.Plu _err = g.client.Call("Plugin.OnPluginClusterEvent", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnPluginClusterEventReturns{} g.log.Debug("RPC call OnPluginClusterEvent to plugin failed.", mlog.Err(_err)) } @@ -1048,7 +1048,7 @@ func (g *hooksRPCClient) OnWebSocketConnectWithRPCErr(webConnID, userID string) _err = g.client.Call("Plugin.OnWebSocketConnect", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnWebSocketConnectReturns{} g.log.Debug("RPC call OnWebSocketConnect to plugin failed.", mlog.Err(_err)) } @@ -1100,7 +1100,7 @@ func (g *hooksRPCClient) OnWebSocketDisconnectWithRPCErr(webConnID, userID strin _err = g.client.Call("Plugin.OnWebSocketDisconnect", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnWebSocketDisconnectReturns{} g.log.Debug("RPC call OnWebSocketDisconnect to plugin failed.", mlog.Err(_err)) } @@ -1153,7 +1153,7 @@ func (g *hooksRPCClient) WebSocketMessageHasBeenPostedWithRPCErr(webConnID, user _err = g.client.Call("Plugin.WebSocketMessageHasBeenPosted", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_WebSocketMessageHasBeenPostedReturns{} g.log.Debug("RPC call WebSocketMessageHasBeenPosted to plugin failed.", mlog.Err(_err)) } @@ -1207,7 +1207,7 @@ func (g *hooksRPCClient) RunDataRetentionWithRPCErr(nowTime, batchSize int64) (i _err = g.client.Call("Plugin.RunDataRetention", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_RunDataRetentionReturns{} g.log.Debug("RPC call RunDataRetention to plugin failed.", mlog.Err(_err)) } @@ -1261,7 +1261,7 @@ func (g *hooksRPCClient) OnInstallWithRPCErr(c *Context, event model.OnInstallEv _err = g.client.Call("Plugin.OnInstall", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnInstallReturns{} g.log.Debug("RPC call OnInstall to plugin failed.", mlog.Err(_err)) } @@ -1312,7 +1312,7 @@ func (g *hooksRPCClient) OnSendDailyTelemetryWithRPCErr() error { _err = g.client.Call("Plugin.OnSendDailyTelemetry", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSendDailyTelemetryReturns{} g.log.Debug("RPC call OnSendDailyTelemetry to plugin failed.", mlog.Err(_err)) } @@ -1363,7 +1363,7 @@ func (g *hooksRPCClient) OnCloudLimitsUpdatedWithRPCErr(limits *model.ProductLim _err = g.client.Call("Plugin.OnCloudLimitsUpdated", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnCloudLimitsUpdatedReturns{} g.log.Debug("RPC call OnCloudLimitsUpdated to plugin failed.", mlog.Err(_err)) } @@ -1416,7 +1416,7 @@ func (g *hooksRPCClient) ConfigurationWillBeSavedWithRPCErr(newCfg *model.Config _err = g.client.Call("Plugin.ConfigurationWillBeSaved", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_ConfigurationWillBeSavedReturns{} g.log.Debug("RPC call ConfigurationWillBeSaved to plugin failed.", mlog.Err(_err)) } @@ -1470,7 +1470,7 @@ func (g *hooksRPCClient) EmailNotificationWillBeSentWithRPCErr(emailNotification _err = g.client.Call("Plugin.EmailNotificationWillBeSent", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_EmailNotificationWillBeSentReturns{} g.log.Debug("RPC call EmailNotificationWillBeSent to plugin failed.", mlog.Err(_err)) } @@ -1524,7 +1524,7 @@ func (g *hooksRPCClient) NotificationWillBePushedWithRPCErr(pushNotification *mo _err = g.client.Call("Plugin.NotificationWillBePushed", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_NotificationWillBePushedReturns{} g.log.Debug("RPC call NotificationWillBePushed to plugin failed.", mlog.Err(_err)) } @@ -1576,7 +1576,7 @@ func (g *hooksRPCClient) UserHasBeenDeactivatedWithRPCErr(c *Context, user *mode _err = g.client.Call("Plugin.UserHasBeenDeactivated", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_UserHasBeenDeactivatedReturns{} g.log.Debug("RPC call UserHasBeenDeactivated to plugin failed.", mlog.Err(_err)) } @@ -1630,7 +1630,7 @@ func (g *hooksRPCClient) OnSharedChannelsSyncMsgWithRPCErr(msg *model.SyncMsg, r _err = g.client.Call("Plugin.OnSharedChannelsSyncMsg", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSharedChannelsSyncMsgReturns{} g.log.Debug("RPC call OnSharedChannelsSyncMsg to plugin failed.", mlog.Err(_err)) } @@ -1683,7 +1683,7 @@ func (g *hooksRPCClient) OnSharedChannelsPingWithRPCErr(rc *model.RemoteCluster) _err = g.client.Call("Plugin.OnSharedChannelsPing", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSharedChannelsPingReturns{} g.log.Debug("RPC call OnSharedChannelsPing to plugin failed.", mlog.Err(_err)) } @@ -1735,7 +1735,7 @@ func (g *hooksRPCClient) PreferencesHaveChangedWithRPCErr(c *Context, preference _err = g.client.Call("Plugin.PreferencesHaveChanged", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_PreferencesHaveChangedReturns{} g.log.Debug("RPC call PreferencesHaveChanged to plugin failed.", mlog.Err(_err)) } @@ -1789,7 +1789,7 @@ func (g *hooksRPCClient) OnSharedChannelsAttachmentSyncMsgWithRPCErr(fi *model.F _err = g.client.Call("Plugin.OnSharedChannelsAttachmentSyncMsg", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSharedChannelsAttachmentSyncMsgReturns{} g.log.Debug("RPC call OnSharedChannelsAttachmentSyncMsg to plugin failed.", mlog.Err(_err)) } @@ -1843,7 +1843,7 @@ func (g *hooksRPCClient) OnSharedChannelsProfileImageSyncMsgWithRPCErr(user *mod _err = g.client.Call("Plugin.OnSharedChannelsProfileImageSyncMsg", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSharedChannelsProfileImageSyncMsgReturns{} g.log.Debug("RPC call OnSharedChannelsProfileImageSyncMsg to plugin failed.", mlog.Err(_err)) } @@ -1897,7 +1897,7 @@ func (g *hooksRPCClient) GenerateSupportDataWithRPCErr(c *Context) ([]*model.Fil _err = g.client.Call("Plugin.GenerateSupportData", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_GenerateSupportDataReturns{} g.log.Debug("RPC call GenerateSupportData to plugin failed.", mlog.Err(_err)) } @@ -1952,7 +1952,7 @@ func (g *hooksRPCClient) OnSAMLLoginWithRPCErr(c *Context, user *model.User, ass _err = g.client.Call("Plugin.OnSAMLLogin", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &Z_OnSAMLLoginReturns{} g.log.Debug("RPC call OnSAMLLogin to plugin failed.", mlog.Err(_err)) } @@ -1972,7 +1972,223 @@ func (s *hooksRPCServer) OnSAMLLogin(args *Z_OnSAMLLoginArgs, returns *Z_OnSAMLL return nil } -// HooksWithRPCErr provides a WithRPCErr variant for every generated hook. The last error return +func init() { + hookNameToId["ChannelWillBeUpdated"] = ChannelWillBeUpdatedID +} + +type Z_ChannelWillBeUpdatedArgs struct { + A *Context + B *model.Channel + C *model.Channel +} + +type Z_ChannelWillBeUpdatedReturns struct { + A *model.Channel + B string +} + +func (g *hooksRPCClient) ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + _args := &Z_ChannelWillBeUpdatedArgs{c, newChannel, oldChannel} + _returns := &Z_ChannelWillBeUpdatedReturns{} + if g.implemented[ChannelWillBeUpdatedID] { + if err := g.client.Call("Plugin.ChannelWillBeUpdated", _args, _returns); err != nil { + g.log.Error("RPC call ChannelWillBeUpdated to plugin failed.", mlog.Err(err)) + } + } + return _returns.A, _returns.B +} + +// ChannelWillBeUpdatedWithRPCErr returns the same values as ChannelWillBeUpdated, with an additional trailing error +// for the RPC transport — always the LAST return slot. +func (g *hooksRPCClient) ChannelWillBeUpdatedWithRPCErr(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string, error) { + _args := &Z_ChannelWillBeUpdatedArgs{c, newChannel, oldChannel} + _returns := &Z_ChannelWillBeUpdatedReturns{} + var _err error + if g.implemented[ChannelWillBeUpdatedID] { + _err = g.client.Call("Plugin.ChannelWillBeUpdated", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_ChannelWillBeUpdatedReturns{} + g.log.Debug("RPC call ChannelWillBeUpdated to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +func (s *hooksRPCServer) ChannelWillBeUpdated(args *Z_ChannelWillBeUpdatedArgs, returns *Z_ChannelWillBeUpdatedReturns) error { + if hook, ok := s.impl.(interface { + ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) + }); ok { + returns.A, returns.B = hook.ChannelWillBeUpdated(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("Hook ChannelWillBeUpdated called but not implemented.")) + } + return nil +} + +func init() { + hookNameToId["ChannelWillBeRestored"] = ChannelWillBeRestoredID +} + +type Z_ChannelWillBeRestoredArgs struct { + A *Context + B *model.Channel +} + +type Z_ChannelWillBeRestoredReturns struct { + A string +} + +func (g *hooksRPCClient) ChannelWillBeRestored(c *Context, channel *model.Channel) string { + _args := &Z_ChannelWillBeRestoredArgs{c, channel} + _returns := &Z_ChannelWillBeRestoredReturns{} + if g.implemented[ChannelWillBeRestoredID] { + if err := g.client.Call("Plugin.ChannelWillBeRestored", _args, _returns); err != nil { + g.log.Error("RPC call ChannelWillBeRestored to plugin failed.", mlog.Err(err)) + } + } + return _returns.A +} + +// ChannelWillBeRestoredWithRPCErr returns the same values as ChannelWillBeRestored, with an additional trailing error +// for the RPC transport — always the LAST return slot. +func (g *hooksRPCClient) ChannelWillBeRestoredWithRPCErr(c *Context, channel *model.Channel) (string, error) { + _args := &Z_ChannelWillBeRestoredArgs{c, channel} + _returns := &Z_ChannelWillBeRestoredReturns{} + var _err error + if g.implemented[ChannelWillBeRestoredID] { + _err = g.client.Call("Plugin.ChannelWillBeRestored", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_ChannelWillBeRestoredReturns{} + g.log.Debug("RPC call ChannelWillBeRestored to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _err +} + +func (s *hooksRPCServer) ChannelWillBeRestored(args *Z_ChannelWillBeRestoredArgs, returns *Z_ChannelWillBeRestoredReturns) error { + if hook, ok := s.impl.(interface { + ChannelWillBeRestored(c *Context, channel *model.Channel) string + }); ok { + returns.A = hook.ChannelWillBeRestored(args.A, args.B) + } else { + return encodableError(fmt.Errorf("Hook ChannelWillBeRestored called but not implemented.")) + } + return nil +} + +func init() { + hookNameToId["ScheduledPostWillBeCreated"] = ScheduledPostWillBeCreatedID +} + +type Z_ScheduledPostWillBeCreatedArgs struct { + A *Context + B *model.ScheduledPost +} + +type Z_ScheduledPostWillBeCreatedReturns struct { + A *model.ScheduledPost + B string +} + +func (g *hooksRPCClient) ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + _args := &Z_ScheduledPostWillBeCreatedArgs{c, scheduledPost} + _returns := &Z_ScheduledPostWillBeCreatedReturns{} + if g.implemented[ScheduledPostWillBeCreatedID] { + if err := g.client.Call("Plugin.ScheduledPostWillBeCreated", _args, _returns); err != nil { + g.log.Error("RPC call ScheduledPostWillBeCreated to plugin failed.", mlog.Err(err)) + } + } + return _returns.A, _returns.B +} + +// ScheduledPostWillBeCreatedWithRPCErr returns the same values as ScheduledPostWillBeCreated, with an additional trailing error +// for the RPC transport — always the LAST return slot. +func (g *hooksRPCClient) ScheduledPostWillBeCreatedWithRPCErr(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string, error) { + _args := &Z_ScheduledPostWillBeCreatedArgs{c, scheduledPost} + _returns := &Z_ScheduledPostWillBeCreatedReturns{} + var _err error + if g.implemented[ScheduledPostWillBeCreatedID] { + _err = g.client.Call("Plugin.ScheduledPostWillBeCreated", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_ScheduledPostWillBeCreatedReturns{} + g.log.Debug("RPC call ScheduledPostWillBeCreated to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +func (s *hooksRPCServer) ScheduledPostWillBeCreated(args *Z_ScheduledPostWillBeCreatedArgs, returns *Z_ScheduledPostWillBeCreatedReturns) error { + if hook, ok := s.impl.(interface { + ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) + }); ok { + returns.A, returns.B = hook.ScheduledPostWillBeCreated(args.A, args.B) + } else { + return encodableError(fmt.Errorf("Hook ScheduledPostWillBeCreated called but not implemented.")) + } + return nil +} + +func init() { + hookNameToId["DraftWillBeUpserted"] = DraftWillBeUpsertedID +} + +type Z_DraftWillBeUpsertedArgs struct { + A *Context + B *model.Draft +} + +type Z_DraftWillBeUpsertedReturns struct { + A *model.Draft + B string +} + +func (g *hooksRPCClient) DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string) { + _args := &Z_DraftWillBeUpsertedArgs{c, draft} + _returns := &Z_DraftWillBeUpsertedReturns{} + if g.implemented[DraftWillBeUpsertedID] { + if err := g.client.Call("Plugin.DraftWillBeUpserted", _args, _returns); err != nil { + g.log.Error("RPC call DraftWillBeUpserted to plugin failed.", mlog.Err(err)) + } + } + return _returns.A, _returns.B +} + +// DraftWillBeUpsertedWithRPCErr returns the same values as DraftWillBeUpserted, with an additional trailing error +// for the RPC transport — always the LAST return slot. +func (g *hooksRPCClient) DraftWillBeUpsertedWithRPCErr(c *Context, draft *model.Draft) (*model.Draft, string, error) { + _args := &Z_DraftWillBeUpsertedArgs{c, draft} + _returns := &Z_DraftWillBeUpsertedReturns{} + var _err error + if g.implemented[DraftWillBeUpsertedID] { + _err = g.client.Call("Plugin.DraftWillBeUpserted", _args, _returns) + if _err != nil { + // Reset _returns so partial gob decoding can't leak non-zero + // values past a transport failure (HooksWithRPCErrGenerated contract). + _returns = &Z_DraftWillBeUpsertedReturns{} + g.log.Debug("RPC call DraftWillBeUpserted to plugin failed.", mlog.Err(_err)) + } + } + return _returns.A, _returns.B, _err +} + +func (s *hooksRPCServer) DraftWillBeUpserted(args *Z_DraftWillBeUpsertedArgs, returns *Z_DraftWillBeUpsertedReturns) error { + if hook, ok := s.impl.(interface { + DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string) + }); ok { + returns.A, returns.B = hook.DraftWillBeUpserted(args.A, args.B) + } else { + return encodableError(fmt.Errorf("Hook DraftWillBeUpserted called but not implemented.")) + } + return nil +} + +// HooksWithRPCErrGenerated provides a WithRPCErr variant for every generated hook. The last error return // is always the RPC transport error — if non-nil, the plugin's other return values are zero. For // hooks whose base signature already returns error, the tuple is (originalReturns..., rpcErr) // where the final slot is always transport. @@ -1981,8 +2197,8 @@ func (s *hooksRPCServer) OnSAMLLogin(args *Z_OnSAMLLoginArgs, returns *Z_OnSAMLL // indistinguishable from a successful invocation that returned zeros. Callers MUST gate on // supervisor.Implements() (or use Environment.RunMultiPluginHookWithRPCErr, which gates // by the iteration's hook ID — note that any *WithRPCErr method called on the closure's -// HooksWithRPCErr is independently subject to its own implemented-gate). -type HooksWithRPCErr interface { +// HooksWithRPCErrGenerated is independently subject to its own implemented-gate). +type HooksWithRPCErrGenerated interface { OnDeactivateWithRPCErr() (error, error) OnConfigurationChangeWithRPCErr() (error, error) @@ -2056,6 +2272,14 @@ type HooksWithRPCErr interface { GenerateSupportDataWithRPCErr(c *Context) ([]*model.FileData, error, error) OnSAMLLoginWithRPCErr(c *Context, user *model.User, assertion *saml2.AssertionInfo) (error, error) + + ChannelWillBeUpdatedWithRPCErr(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string, error) + + ChannelWillBeRestoredWithRPCErr(c *Context, channel *model.Channel) (string, error) + + ScheduledPostWillBeCreatedWithRPCErr(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string, error) + + DraftWillBeUpsertedWithRPCErr(c *Context, draft *model.Draft) (*model.Draft, string, error) } type Z_RegisterCommandArgs struct { @@ -4240,6 +4464,62 @@ func (s *apiRPCServer) UpdateChannel(args *Z_UpdateChannelArgs, returns *Z_Updat return nil } +type Z_RegisterChannelGuardArgs struct { + A string +} + +type Z_RegisterChannelGuardReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) RegisterChannelGuard(channelID string) *model.AppError { + _args := &Z_RegisterChannelGuardArgs{channelID} + _returns := &Z_RegisterChannelGuardReturns{} + if err := g.client.Call("Plugin.RegisterChannelGuard", _args, _returns); err != nil { + log.Printf("RPC call to RegisterChannelGuard API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) RegisterChannelGuard(args *Z_RegisterChannelGuardArgs, returns *Z_RegisterChannelGuardReturns) error { + if hook, ok := s.impl.(interface { + RegisterChannelGuard(channelID string) *model.AppError + }); ok { + returns.A = hook.RegisterChannelGuard(args.A) + } else { + return encodableError(fmt.Errorf("API RegisterChannelGuard called but not implemented.")) + } + return nil +} + +type Z_UnregisterChannelGuardArgs struct { + A string +} + +type Z_UnregisterChannelGuardReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) UnregisterChannelGuard(channelID string) *model.AppError { + _args := &Z_UnregisterChannelGuardArgs{channelID} + _returns := &Z_UnregisterChannelGuardReturns{} + if err := g.client.Call("Plugin.UnregisterChannelGuard", _args, _returns); err != nil { + log.Printf("RPC call to UnregisterChannelGuard API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) UnregisterChannelGuard(args *Z_UnregisterChannelGuardArgs, returns *Z_UnregisterChannelGuardReturns) error { + if hook, ok := s.impl.(interface { + UnregisterChannelGuard(channelID string) *model.AppError + }); ok { + returns.A = hook.UnregisterChannelGuard(args.A) + } else { + return encodableError(fmt.Errorf("API UnregisterChannelGuard called but not implemented.")) + } + return nil +} + type Z_SearchChannelsArgs struct { A string B string diff --git a/server/public/plugin/environment.go b/server/public/plugin/environment.go index e37e50565f7..bdb135fefe6 100644 --- a/server/public/plugin/environment.go +++ b/server/public/plugin/environment.go @@ -8,6 +8,7 @@ import ( "hash/fnv" "os" "path/filepath" + "slices" "sync" "time" @@ -595,6 +596,19 @@ func (env *Environment) HooksForPlugin(id string) (Hooks, error) { return nil, fmt.Errorf("plugin not found: %v", id) } +// HooksForPluginWithRPCErr returns the full *WithRPCErr hook surface for the named plugin. +// Returns an error if the plugin is not found or not active. +func (env *Environment) HooksForPluginWithRPCErr(id string) (HooksWithRPCErr, error) { + if p, ok := env.registeredPlugins.Load(id); ok { + rp := p.(registeredPlugin) + if rp.supervisor != nil && env.IsActive(id) { + return rp.supervisor.HooksWithRPCErr(), nil + } + } + + return nil, fmt.Errorf("plugin not found: %v", id) +} + // RunMultiPluginHook invokes hookRunnerFunc for each active plugin that implements the given hookId. // // If hookRunnerFunc returns false, iteration will not continue. The iteration order among active @@ -626,9 +640,47 @@ func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks, mani } } +// RunMultiPluginHookExcluding is like RunMultiPluginHook but skips plugins whose IDs appear in +// excludePluginIDs, otherwise the semantics are the same as RunMultiPluginHook. The exclusion check +// is a linear scan. +func (env *Environment) RunMultiPluginHookExcluding( + excludePluginIDs []string, + hookRunnerFunc func(hooks Hooks, manifest *model.Manifest) bool, + hookId int, +) { + startTime := time.Now() + + env.registeredPlugins.Range(func(key, value any) bool { + rp := value.(registeredPlugin) + id := rp.BundleInfo.Manifest.Id + if slices.Contains(excludePluginIDs, id) { + return true + } + + if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(id) { + return true + } + + hookStartTime := time.Now() + cont := hookRunnerFunc(rp.supervisor.Hooks(), rp.BundleInfo.Manifest) + + if env.metrics != nil { + elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) + env.metrics.ObservePluginMultiHookIterationDuration(id, elapsedTime) + } + + return cont + }) + + if env.metrics != nil { + elapsedTime := float64(time.Since(startTime)) / float64(time.Second) + env.metrics.ObservePluginMultiHookDuration(elapsedTime) + } +} + // RunMultiPluginHookWithRPCErr is like RunMultiPluginHook but surfaces RPC transport errors. The -// closure receives a HooksWithRPCErr so it can call *WithRPCErr variants. Iteration stops on the first -// non-nil error returned by the closure. +// closure receives a HooksWithRPCErr so it can call any *WithRPCErr variant. Iteration stops on the +// first non-nil error returned by the closure. func (env *Environment) RunMultiPluginHookWithRPCErr(hookRunnerFunc func(hooks HooksWithRPCErr, manifest *model.Manifest) (bool, error), hookId int) error { startTime := time.Now() var retErr error diff --git a/server/public/plugin/environment_with_rpcerr_test.go b/server/public/plugin/environment_with_rpcerr_test.go index 05e200ec79e..aa4a3856bad 100644 --- a/server/public/plugin/environment_with_rpcerr_test.go +++ b/server/public/plugin/environment_with_rpcerr_test.go @@ -18,14 +18,6 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) -// Both the wire-level client and the metrics-wrapping layer returned by -// supervisor.Hooks() must implement HooksWithRPCErr — RunMultiPluginHookWithRPCErr's -// type assertion targets the latter. -var ( - _ HooksWithRPCErr = (*hooksRPCClient)(nil) - _ HooksWithRPCErr = (*hooksTimerLayer)(nil) -) - func TestRunMultiPluginHookWithRPCErr(t *testing.T) { pluginDir, err := os.MkdirTemp("", "mm-rpcerr-plugin") require.NoError(t, err) diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 58f77e5f559..2ea23301b6e 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -68,6 +68,10 @@ const ( ChannelMemberWillBeAddedID = 49 TeamMemberWillBeAddedID = 50 ChannelWillBeArchivedID = 51 + ChannelWillBeUpdatedID = 52 + ChannelWillBeRestoredID = 53 + ScheduledPostWillBeCreatedID = 54 + DraftWillBeUpsertedID = 55 TotalHooksID = iota ) @@ -465,4 +469,42 @@ type Hooks interface { // // Minimum server version: 10.7 OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error + + // ChannelWillBeUpdated is invoked before a channel update is committed, allowing plugins to + // modify the channel or reject the update. + // + // To reject the update, return a non-empty string describing why. To modify the channel, return + // the replacement *model.Channel and an empty string. To allow the update without modification, + // return nil and an empty string. + // + // Fires from the app-layer UpdateChannel and PatchChannel paths so REST, local API, plugin API, + // import, and bulk callers all hit it. + // + // Minimum server version: 11.8 + ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) + + // ChannelWillBeRestored is invoked before an archived channel is un-archived. Fires from + // app.RestoreChannel before the store's Channel().Restore call. Sibling of + // ChannelWillBeArchived for the inverse operation. + // + // To reject, return a non-empty string. Empty string allows the restore. + // + // Minimum server version: 11.8 + ChannelWillBeRestored(c *Context, channel *model.Channel) string + + // ScheduledPostWillBeCreated is invoked before a scheduled post is committed. Fires from the + // app-layer SaveScheduledPost and UpdateScheduledPost paths. + // + // Return value semantics match MessageWillBePosted. + // + // Minimum server version: 11.8 + ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) + + // DraftWillBeUpserted is invoked before a draft is committed. Fires from the app-layer + // UpsertDraft path. + // + // Return value semantics match MessageWillBePosted. + // + // Minimum server version: 11.8 + DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string) } diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index dee068b975f..a219828db2b 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -336,6 +336,34 @@ func (hooks *hooksTimerLayer) OnSAMLLogin(c *Context, user *model.User, assertio return _returnsA } +func (hooks *hooksTimerLayer) ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) { + startTime := timePkg.Now() + _returnsA, _returnsB := hooks.hooksImpl.ChannelWillBeUpdated(c, newChannel, oldChannel) + hooks.recordTime(startTime, "ChannelWillBeUpdated", true) + return _returnsA, _returnsB +} + +func (hooks *hooksTimerLayer) ChannelWillBeRestored(c *Context, channel *model.Channel) string { + startTime := timePkg.Now() + _returnsA := hooks.hooksImpl.ChannelWillBeRestored(c, channel) + hooks.recordTime(startTime, "ChannelWillBeRestored", true) + return _returnsA +} + +func (hooks *hooksTimerLayer) ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + startTime := timePkg.Now() + _returnsA, _returnsB := hooks.hooksImpl.ScheduledPostWillBeCreated(c, scheduledPost) + hooks.recordTime(startTime, "ScheduledPostWillBeCreated", true) + return _returnsA, _returnsB +} + +func (hooks *hooksTimerLayer) DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string) { + startTime := timePkg.Now() + _returnsA, _returnsB := hooks.hooksImpl.DraftWillBeUpserted(c, draft) + hooks.recordTime(startTime, "DraftWillBeUpserted", true) + return _returnsA, _returnsB +} + func (hooks *hooksTimerLayer) OnDeactivateWithRPCErr() (error, error) { startTime := timePkg.Now() _returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnDeactivateWithRPCErr() @@ -594,3 +622,31 @@ func (hooks *hooksTimerLayer) OnSAMLLoginWithRPCErr(c *Context, user *model.User hooks.recordTime(startTime, "OnSAMLLoginWithRPCErr", _returnsRPCErr == nil && _returnsA == nil) return _returnsA, _returnsRPCErr } + +func (hooks *hooksTimerLayer) ChannelWillBeUpdatedWithRPCErr(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelWillBeUpdatedWithRPCErr(c, newChannel, oldChannel) + hooks.recordTime(startTime, "ChannelWillBeUpdatedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} + +func (hooks *hooksTimerLayer) ChannelWillBeRestoredWithRPCErr(c *Context, channel *model.Channel) (string, error) { + startTime := timePkg.Now() + _returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelWillBeRestoredWithRPCErr(c, channel) + hooks.recordTime(startTime, "ChannelWillBeRestoredWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsRPCErr +} + +func (hooks *hooksTimerLayer) ScheduledPostWillBeCreatedWithRPCErr(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ScheduledPostWillBeCreatedWithRPCErr(c, scheduledPost) + hooks.recordTime(startTime, "ScheduledPostWillBeCreatedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} + +func (hooks *hooksTimerLayer) DraftWillBeUpsertedWithRPCErr(c *Context, draft *model.Draft) (*model.Draft, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.DraftWillBeUpsertedWithRPCErr(c, draft) + hooks.recordTime(startTime, "DraftWillBeUpsertedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} diff --git a/server/public/plugin/hooks_timer_layer_manual.go b/server/public/plugin/hooks_timer_layer_manual.go new file mode 100644 index 00000000000..4bfd4a6efa4 --- /dev/null +++ b/server/public/plugin/hooks_timer_layer_manual.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Hand-written timer-layer wrappers for the three hooks excluded from the code generator. +// The auto-generated hooks_timer_layer_generated.go ranges over HooksMethodsRPCErr which +// omits excluded hooks; these three fill that gap so hooksTimerLayer satisfies HooksWithRPCErr. + +package plugin + +import ( + timePkg "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +// MessageWillBePostedWithRPCErr wraps the underlying implementation's MessageWillBePostedWithRPCErr +// and records timing metrics. +func (hooks *hooksTimerLayer) MessageWillBePostedWithRPCErr(c *Context, post *model.Post) (*model.Post, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageWillBePostedWithRPCErr(c, post) + hooks.recordTime(startTime, "MessageWillBePostedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} + +// MessageWillBeUpdatedWithRPCErr wraps the underlying implementation's MessageWillBeUpdatedWithRPCErr +// and records timing metrics. +func (hooks *hooksTimerLayer) MessageWillBeUpdatedWithRPCErr(c *Context, newPost, oldPost *model.Post) (*model.Post, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageWillBeUpdatedWithRPCErr(c, newPost, oldPost) + hooks.recordTime(startTime, "MessageWillBeUpdatedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} + +// ChannelMemberWillBeAddedWithRPCErr wraps the underlying implementation's ChannelMemberWillBeAddedWithRPCErr +// and records timing metrics. +func (hooks *hooksTimerLayer) ChannelMemberWillBeAddedWithRPCErr(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) { + startTime := timePkg.Now() + _returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelMemberWillBeAddedWithRPCErr(c, channelMember) + hooks.recordTime(startTime, "ChannelMemberWillBeAddedWithRPCErr", _returnsRPCErr == nil) + return _returnsA, _returnsB, _returnsRPCErr +} diff --git a/server/public/plugin/interface_generator/main.go b/server/public/plugin/interface_generator/main.go index fe7773fdb77..2df8f73a5d5 100644 --- a/server/public/plugin/interface_generator/main.go +++ b/server/public/plugin/interface_generator/main.go @@ -391,7 +391,7 @@ func (g *hooksRPCClient) {{.Name}}WithRPCErr{{funcStyle .Params}} {{funcStyleApp _err = g.client.Call("Plugin.{{.Name}}", _args, _returns) if _err != nil { // Reset _returns so partial gob decoding can't leak non-zero - // values past a transport failure (HooksWithRPCErr contract). + // values past a transport failure (HooksWithRPCErrGenerated contract). _returns = &{{.Name | obscure}}Returns{} g.log.Debug("RPC call {{.Name}} to plugin failed.", mlog.Err(_err)) } @@ -412,7 +412,7 @@ func (s *hooksRPCServer) {{.Name}}(args *{{.Name | obscure}}Args, returns *{{.Na } {{end}} -// HooksWithRPCErr provides a WithRPCErr variant for every generated hook. The last error return +// HooksWithRPCErrGenerated provides a WithRPCErr variant for every generated hook. The last error return // is always the RPC transport error — if non-nil, the plugin's other return values are zero. For // hooks whose base signature already returns error, the tuple is (originalReturns..., rpcErr) // where the final slot is always transport. @@ -421,8 +421,8 @@ func (s *hooksRPCServer) {{.Name}}(args *{{.Name | obscure}}Args, returns *{{.Na // indistinguishable from a successful invocation that returned zeros. Callers MUST gate on // supervisor.Implements() (or use Environment.RunMultiPluginHookWithRPCErr, which gates // by the iteration's hook ID — note that any *WithRPCErr method called on the closure's -// HooksWithRPCErr is independently subject to its own implemented-gate). -type HooksWithRPCErr interface { +// HooksWithRPCErrGenerated is independently subject to its own implemented-gate). +type HooksWithRPCErrGenerated interface { {{range .HooksMethods}} {{.Name}}WithRPCErr{{funcStyle .Params}} {{funcStyleAppendErr .Return}} {{end}} @@ -646,7 +646,7 @@ func generatePluginTimerLayer(info *PluginInterfaceInfo) { // Prepare template params. The timer layer wraps the full Hooks interface, so // HooksMethods includes excluded hooks too. *WithRPCErr companions only exist - // for non-excluded hooks (see HooksWithRPCErr in client_rpc_generated.go), so the + // for non-excluded hooks (see HooksWithRPCErrGenerated in client_rpc_generated.go), so the // excluded subset is filtered into HooksMethodsRPCErr for that loop. excluded := func(name string) bool { return slices.Contains(excludedPluginHooks, name) } templateParams := HooksTemplateParams{} diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 338fef74f2d..e37102f60e9 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -9,10 +9,8 @@ import ( http "net/http" logr "github.com/mattermost/logr/v2" - - mock "github.com/stretchr/testify/mock" - model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" ) // API is an autogenerated mock type for the API type @@ -4675,6 +4673,26 @@ func (_m *API) ReceiveSharedChannelSyncMsg(remoteID string, msg *model.SyncMsg) return r0, r1 } +// RegisterChannelGuard provides a mock function with given fields: channelID +func (_m *API) RegisterChannelGuard(channelID string) *model.AppError { + ret := _m.Called(channelID) + + if len(ret) == 0 { + panic("no return value specified for RegisterChannelGuard") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string) *model.AppError); ok { + r0 = rf(channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // RegisterCollectionAndTopic provides a mock function with given fields: collectionType, topicType func (_m *API) RegisterCollectionAndTopic(collectionType string, topicType string) error { ret := _m.Called(collectionType, topicType) @@ -5457,6 +5475,26 @@ func (_m *API) UninviteRemoteFromChannel(channelID string, remoteID string) erro return r0 } +// UnregisterChannelGuard provides a mock function with given fields: channelID +func (_m *API) UnregisterChannelGuard(channelID string) *model.AppError { + ret := _m.Called(channelID) + + if len(ret) == 0 { + panic("no return value specified for UnregisterChannelGuard") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string) *model.AppError); ok { + r0 = rf(channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // UnregisterCommand provides a mock function with given fields: teamID, trigger func (_m *API) UnregisterCommand(teamID string, trigger string) error { ret := _m.Called(teamID, trigger) diff --git a/server/public/plugin/plugintest/driver.go b/server/public/plugin/plugintest/driver.go index 4c158856c39..db9288b9312 100644 --- a/server/public/plugin/plugintest/driver.go +++ b/server/public/plugin/plugintest/driver.go @@ -7,9 +7,8 @@ package plugintest import ( driver "database/sql/driver" - mock "github.com/stretchr/testify/mock" - plugin "github.com/mattermost/mattermost/server/public/plugin" + mock "github.com/stretchr/testify/mock" ) // Driver is an autogenerated mock type for the Driver type diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index 90206542a38..c33ee478943 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -8,13 +8,10 @@ import ( io "io" http "net/http" - mock "github.com/stretchr/testify/mock" - - model "github.com/mattermost/mattermost/server/public/model" - - plugin "github.com/mattermost/mattermost/server/public/plugin" - saml2 "github.com/mattermost/gosaml2" + model "github.com/mattermost/mattermost/server/public/model" + plugin "github.com/mattermost/mattermost/server/public/plugin" + mock "github.com/stretchr/testify/mock" ) // Hooks is an autogenerated mock type for the Hooks type @@ -75,6 +72,54 @@ func (_m *Hooks) ChannelWillBeArchived(c *plugin.Context, channel *model.Channel return r0 } +// ChannelWillBeRestored provides a mock function with given fields: c, channel +func (_m *Hooks) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string { + ret := _m.Called(c, channel) + + if len(ret) == 0 { + panic("no return value specified for ChannelWillBeRestored") + } + + var r0 string + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel) string); ok { + r0 = rf(c, channel) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// ChannelWillBeUpdated provides a mock function with given fields: c, newChannel, oldChannel +func (_m *Hooks) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) { + ret := _m.Called(c, newChannel, oldChannel) + + if len(ret) == 0 { + panic("no return value specified for ChannelWillBeUpdated") + } + + var r0 *model.Channel + var r1 string + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel, *model.Channel) (*model.Channel, string)); ok { + return rf(c, newChannel, oldChannel) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel, *model.Channel) *model.Channel); ok { + r0 = rf(c, newChannel, oldChannel) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Channel) + } + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Channel, *model.Channel) string); ok { + r1 = rf(c, newChannel, oldChannel) + } else { + r1 = ret.Get(1).(string) + } + + return r0, r1 +} + // ConfigurationWillBeSaved provides a mock function with given fields: newCfg func (_m *Hooks) ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error) { ret := _m.Called(newCfg) @@ -105,6 +150,36 @@ func (_m *Hooks) ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, return r0, r1 } +// DraftWillBeUpserted provides a mock function with given fields: c, draft +func (_m *Hooks) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) { + ret := _m.Called(c, draft) + + if len(ret) == 0 { + panic("no return value specified for DraftWillBeUpserted") + } + + var r0 *model.Draft + var r1 string + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Draft) (*model.Draft, string)); ok { + return rf(c, draft) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Draft) *model.Draft); ok { + r0 = rf(c, draft) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Draft) string); ok { + r1 = rf(c, draft) + } else { + r1 = ret.Get(1).(string) + } + + return r0, r1 +} + // EmailNotificationWillBeSent provides a mock function with given fields: emailNotification func (_m *Hooks) EmailNotificationWillBeSent(emailNotification *model.EmailNotification) (*model.EmailNotificationContent, string) { ret := _m.Called(emailNotification) @@ -640,6 +715,36 @@ func (_m *Hooks) RunDataRetention(nowTime int64, batchSize int64) (int64, error) return r0, r1 } +// ScheduledPostWillBeCreated provides a mock function with given fields: c, scheduledPost +func (_m *Hooks) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) { + ret := _m.Called(c, scheduledPost) + + if len(ret) == 0 { + panic("no return value specified for ScheduledPostWillBeCreated") + } + + var r0 *model.ScheduledPost + var r1 string + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ScheduledPost) (*model.ScheduledPost, string)); ok { + return rf(c, scheduledPost) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ScheduledPost) *model.ScheduledPost); ok { + r0 = rf(c, scheduledPost) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledPost) + } + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.ScheduledPost) string); ok { + r1 = rf(c, scheduledPost) + } else { + r1 = ret.Get(1).(string) + } + + return r0, r1 +} + // ServeHTTP provides a mock function with given fields: c, w, r func (_m *Hooks) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { _m.Called(c, w, r) diff --git a/server/public/plugin/plugintest/hooks_with_rpcerr.go b/server/public/plugin/plugintest/hooks_with_rpcerr.go new file mode 100644 index 00000000000..f89efe7f8cd --- /dev/null +++ b/server/public/plugin/plugintest/hooks_with_rpcerr.go @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Hand-written *WithRPCErr mock methods for the three hooks excluded from the code generator. +// The auto-generated hooks.go (regenerated by `make plugin-mocks`) covers the base Hooks interface; +// this file adds the extra *WithRPCErr companions so *Hooks satisfies plugin.HooksWithRPCErr. +// This file is not overwritten by `make plugin-mocks` because mockery writes only hooks.go for the +// Hooks interface (filename: "{{.InterfaceNameLower}}.go" in .mockery.yaml). + +package plugintest + +import ( + model "github.com/mattermost/mattermost/server/public/model" + plugin "github.com/mattermost/mattermost/server/public/plugin" +) + +// MessageWillBePostedWithRPCErr provides a mock function with given fields: c, post +func (_m *Hooks) MessageWillBePostedWithRPCErr(c *plugin.Context, post *model.Post) (*model.Post, string, error) { + ret := _m.Called(c, post) + + var r0 *model.Post + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) (*model.Post, string, error)); ok { + return rf(c, post) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) *model.Post); ok { + r0 = rf(c, post) + } else if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Post) + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post) string); ok { + r1 = rf(c, post) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(*plugin.Context, *model.Post) error); ok { + r2 = rf(c, post) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MessageWillBeUpdatedWithRPCErr provides a mock function with given fields: c, newPost, oldPost +func (_m *Hooks) MessageWillBeUpdatedWithRPCErr(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string, error) { + ret := _m.Called(c, newPost, oldPost) + + var r0 *model.Post + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) (*model.Post, string, error)); ok { + return rf(c, newPost, oldPost) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) *model.Post); ok { + r0 = rf(c, newPost, oldPost) + } else if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Post) + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post, *model.Post) string); ok { + r1 = rf(c, newPost, oldPost) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(*plugin.Context, *model.Post, *model.Post) error); ok { + r2 = rf(c, newPost, oldPost) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ChannelMemberWillBeAddedWithRPCErr provides a mock function with given fields: c, channelMember +func (_m *Hooks) ChannelMemberWillBeAddedWithRPCErr(c *plugin.Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) { + ret := _m.Called(c, channelMember) + + var r0 *model.ChannelMember + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) (*model.ChannelMember, string, error)); ok { + return rf(c, channelMember) + } + if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) *model.ChannelMember); ok { + r0 = rf(c, channelMember) + } else if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ChannelMember) + } + + if rf, ok := ret.Get(1).(func(*plugin.Context, *model.ChannelMember) string); ok { + r1 = rf(c, channelMember) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(*plugin.Context, *model.ChannelMember) error); ok { + r2 = rf(c, channelMember) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Note: plugintest.Hooks is a mock for the base Hooks interface only. The auto-generated +// hooks.go does not include *WithRPCErr methods, so *Hooks cannot satisfy HooksWithRPCErr +// in full. The production compile-time assertions for HooksWithRPCErr live in client_rpc.go +// (for *hooksRPCClient and *hooksTimerLayer). Tests that need a HooksWithRPCErr double +// should embed *Hooks and add the needed *WithRPCErr stubs directly. diff --git a/tools/mattermost-govet/go.mod b/tools/mattermost-govet/go.mod index 10e71b915ef..29980f9cf18 100644 --- a/tools/mattermost-govet/go.mod +++ b/tools/mattermost-govet/go.mod @@ -1,28 +1,25 @@ module github.com/mattermost/mattermost/tools/mattermost-govet -go 1.26.2 +go 1.26.3 require ( - github.com/getkin/kin-openapi v0.133.0 + github.com/pb33f/libopenapi v0.36.4 github.com/pkg/errors v0.9.1 github.com/sajari/fuzzy v1.0.0 - github.com/stretchr/testify v1.10.0 - golang.org/x/tools v0.40.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/tools v0.45.0 ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.9.1 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pb33f/jsonpath v0.8.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/sync v0.19.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tools/mattermost-govet/go.sum b/tools/mattermost-govet/go.sum index a4aadea12a3..ef6057aacac 100644 --- a/tools/mattermost-govet/go.sum +++ b/tools/mattermost-govet/go.sum @@ -1,48 +1,41 @@ +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.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/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/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= -github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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/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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/mattermost-govet/openApiSync/openApiSync.go b/tools/mattermost-govet/openApiSync/openApiSync.go index 7d4c8618d45..3e20a96997c 100644 --- a/tools/mattermost-govet/openApiSync/openApiSync.go +++ b/tools/mattermost-govet/openApiSync/openApiSync.go @@ -15,7 +15,8 @@ import ( "strconv" "strings" - "github.com/getkin/kin-openapi/openapi3" + "github.com/pb33f/libopenapi" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pkg/errors" "github.com/sajari/fuzzy" "golang.org/x/tools/go/analysis" @@ -30,7 +31,10 @@ var ( specFile string groupSplitRegexp = regexp.MustCompile(`{([a-z_]*):([a-z_]*)\|([a-z_]*)}`) - IgnoredCases = []string{"websocket:websocket", "api/v4/remotecluster"} + // Excludes | so group-alternation patterns like {type:a|b} are left intact for splitHandlerByGroup. + pathParamConstraintRegex = regexp.MustCompile(`\{([^:}]+):[^|}]+\}`) + openAPIParamRegex = regexp.MustCompile(`\{[^}]+\}`) + IgnoredCases = []string{"{websocket}", "api/v4/remotecluster"} ) func init() { @@ -57,9 +61,45 @@ func stringInSlice(str string, slice []string, partial bool) bool { return false } -// cleanRegexp removes parts of URL path regexp to be compatible with OpenAPI paths +// cleanRegexp strips regex constraints from Go router path parameters so they +// can be matched against OpenAPI path templates. +// e.g., "/users/{user_id:[0-9]+}" → "/users/{user_id}" func cleanRegexp(s string) string { - return strings.ReplaceAll(s, ":[A-Za-z0-9]+", "") + return pathParamConstraintRegex.ReplaceAllString(s, "{$1}") +} + +// matchesTemplate returns true if the OpenAPI specPath template matches handlerPath. +// Spec path parameters (e.g., {foo}) match any single non-slash segment, mirroring +// the behavior of kin-openapi's Paths.Find(). +func matchesTemplate(specPath, handlerPath string) bool { + specParts := strings.Split(strings.Trim(specPath, "/"), "/") + handlerParts := strings.Split(strings.Trim(handlerPath, "/"), "/") + if len(specParts) != len(handlerParts) { + return false + } + for i, specPart := range specParts { + if openAPIParamRegex.MatchString(specPart) { + continue + } + if specPart != handlerParts[i] { + return false + } + } + return true +} + +// findPathItem returns the path item matching handlerPath in the spec. +// It first tries exact key lookup, then falls back to template matching. +func findPathItem(paths *v3high.Paths, handlerPath string) *v3high.PathItem { + if item := paths.PathItems.GetOrZero(handlerPath); item != nil { + return item + } + for specPath, item := range paths.PathItems.FromOldest() { + if matchesTemplate(specPath, handlerPath) { + return item + } + } + return nil } // splitHandlerByGroup checks if URL path regexp contains named groups, and splits them in separate paths to be compatible with OpenAPI paths @@ -74,8 +114,31 @@ func splitHandlerByGroup(str string) []string { return []string{strings.Replace(str, group, part1, 1), strings.Replace(str, group, part2, 1)} } +// getOperation returns the operation for the given HTTP method on a path item, or nil if not defined. +func getOperation(pathItem *v3high.PathItem, method string) *v3high.Operation { + switch strings.ToUpper(method) { + case http.MethodGet: + return pathItem.Get + case http.MethodPost: + return pathItem.Post + case http.MethodPut: + return pathItem.Put + case http.MethodDelete: + return pathItem.Delete + case http.MethodPatch: + return pathItem.Patch + case http.MethodHead: + return pathItem.Head + case http.MethodOptions: + return pathItem.Options + case http.MethodTrace: + return pathItem.Trace + } + return nil +} + // processRouterInit checks that all Init functions defined in `names` are properly documented -func processRouterInit(pass *analysis.Pass, names []string, routerPrefixes map[string]string, swagger *openapi3.T, cm *fuzzy.Model) { +func processRouterInit(pass *analysis.Pass, names []string, routerPrefixes map[string]string, paths *v3high.Paths, cm *fuzzy.Model) { for _, file := range pass.Files { ast.Inspect(file, func(n ast.Node) bool { decl, ok := n.(*ast.FuncDecl) @@ -103,14 +166,14 @@ func processRouterInit(pass *analysis.Pass, names []string, routerPrefixes map[s handler = "/" + handler } for _, h := range splitHandlerByGroup(handler) { - if path := swagger.Paths.Find(h); path == nil { + pathItem := findPathItem(paths, h) + if pathItem == nil { suffix := "" if suggestions := cm.Suggestions(h, false); len(suggestions) > 0 { suffix = fmt.Sprintf(" (maybe you meant: %v)", suggestions) } pass.Reportf(aexpr.Pos(), "Cannot find %v method: %v in OpenAPI 3 spec.%s", h, method, suffix) - - } else if path.GetOperation(method) == nil { + } else if getOperation(pathItem, method) == nil { pass.Reportf(aexpr.Pos(), "Handler %v is defined with method %s, but it's not in the spec", h, method) } } @@ -224,21 +287,29 @@ func run(pass *analysis.Pass) (any, error) { if _, err := os.Stat(specFile); err != nil { return nil, errors.Wrapf(err, "spec file does not exist") } - swagger, err := openapi3.NewLoader().LoadFromFile(specFile) + data, err := os.ReadFile(specFile) + if err != nil { + return nil, errors.Wrapf(err, "Unable to read spec file") + } + doc, err := libopenapi.NewDocument(data) if err != nil { return nil, errors.Wrapf(err, "Unable to parse spec file. Expected OpenAPI3 format.") } + model, err := doc.BuildV3Model() + if err != nil { + return nil, errors.Wrapf(err, "Unable to build OpenAPI3 model") + } initFunctions, routerPrefixes := validateComments(pass) var swaggerPaths []string - for p := range swagger.Paths.Map() { + for p := range model.Model.Paths.PathItems.KeysFromOldest() { swaggerPaths = append(swaggerPaths, p) } - model := fuzzy.NewModel() - model.Train(swaggerPaths) + fuzzyModel := fuzzy.NewModel() + fuzzyModel.Train(swaggerPaths) - processRouterInit(pass, initFunctions, routerPrefixes, swagger, model) + processRouterInit(pass, initFunctions, routerPrefixes, model.Model.Paths, fuzzyModel) return nil, nil } diff --git a/tools/mattermost-govet/openApiSync/openApiSync_test.go b/tools/mattermost-govet/openApiSync/openApiSync_test.go index 3f004bca7e2..94be591fd1f 100644 --- a/tools/mattermost-govet/openApiSync/openApiSync_test.go +++ b/tools/mattermost-govet/openApiSync/openApiSync_test.go @@ -4,8 +4,10 @@ package openApiSync import ( + "net/http" "testing" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" "golang.org/x/tools/go/analysis/analysistest" ) @@ -14,3 +16,133 @@ func Test(t *testing.T) { specFile = analysistest.TestData() + "/spec.yaml" analysistest.Run(t, testdata, Analyzer, "api") } + +func TestCleanRegexp(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/api/v4/users/{user_id:[0-9]+}", "/api/v4/users/{user_id}"}, + {"/api/v4/users/{username:[A-Za-z0-9\\_\\-\\.]+}", "/api/v4/users/{username}"}, + {"/api/v4/jobs/type/{job_type:[A-Za-z0-9_-]+}", "/api/v4/jobs/type/{job_type}"}, + // Literal-value constraints (e.g., {websocket:websocket}) are stripped like any other. + {"/api/v4/{websocket:websocket}", "/api/v4/{websocket}"}, + // Group-alternation patterns must be left intact for splitHandlerByGroup. + {"/api/v4/groups/{group_id}/{syncable_type:teams|channels}/{syncable_id}/link", "/api/v4/groups/{group_id}/{syncable_type:teams|channels}/{syncable_id}/link"}, + // No constraint — unchanged. + {"/api/v4/users/{user_id}/posts", "/api/v4/users/{user_id}/posts"}, + // Multiple constrained params in one path. + {"/api/v4/users/{user_id:[0-9]+}/posts/{post_id:[0-9]+}", "/api/v4/users/{user_id}/posts/{post_id}"}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + if got := cleanRegexp(tc.input); got != tc.want { + t.Errorf("cleanRegexp(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestMatchesTemplate(t *testing.T) { + tests := []struct { + specPath string + handlerPath string + want bool + }{ + // Exact match. + { + "/api/v4/groups/{group_id}/teams/{syncable_id}/link", + "/api/v4/groups/{group_id}/teams/{syncable_id}/link", + true, + }, + // Spec param matches handler literal segment. + { + "/api/v4/groups/{group_id}/{syncable_type}/{syncable_id}/link", + "/api/v4/groups/{group_id}/channels/{syncable_id}/link", + true, + }, + // Spec literal does not match a different handler literal. + { + "/api/v4/groups/{group_id}/teams/{syncable_id}/link", + "/api/v4/groups/{group_id}/channels/{syncable_id}/link", + false, + }, + // Spec param matches handler param name. + { + "/api/v4/users/{user_id}", + "/api/v4/users/{user_id}", + true, + }, + // Spec param matches handler literal "me". + { + "/api/v4/users/{user_id}", + "/api/v4/users/me", + true, + }, + // Different path lengths. + { + "/api/v4/users/{user_id}/posts", + "/api/v4/users/{user_id}", + false, + }, + // Completely different paths. + { + "/api/v4/teams/{team_id}", + "/api/v4/users/{user_id}", + false, + }, + } + for _, tc := range tests { + t.Run(tc.specPath+"~"+tc.handlerPath, func(t *testing.T) { + if got := matchesTemplate(tc.specPath, tc.handlerPath); got != tc.want { + t.Errorf("matchesTemplate(%q, %q) = %v, want %v", tc.specPath, tc.handlerPath, got, tc.want) + } + }) + } +} + +func TestGetOperation(t *testing.T) { + get := &v3high.Operation{} + post := &v3high.Operation{} + put := &v3high.Operation{} + patch := &v3high.Operation{} + del := &v3high.Operation{} + head := &v3high.Operation{} + opts := &v3high.Operation{} + trace := &v3high.Operation{} + + pathItem := &v3high.PathItem{ + Get: get, + Post: post, + Put: put, + Patch: patch, + Delete: del, + Head: head, + Options: opts, + Trace: trace, + } + + tests := []struct { + method string + want *v3high.Operation + }{ + {http.MethodGet, get}, + {http.MethodPost, post}, + {http.MethodPut, put}, + {http.MethodPatch, patch}, + {http.MethodDelete, del}, + {http.MethodHead, head}, + {http.MethodOptions, opts}, + {http.MethodTrace, trace}, + {"get", get}, // case-insensitive + {"post", post}, // case-insensitive + {"UNKNOWN", nil}, + } + for _, tc := range tests { + t.Run(tc.method, func(t *testing.T) { + if got := getOperation(pathItem, tc.method); got != tc.want { + t.Errorf("getOperation(pathItem, %q) = %v, want %v", tc.method, got, tc.want) + } + }) + } +} diff --git a/tools/mmgotool/go.mod b/tools/mmgotool/go.mod index 91221c21d0b..f1735f87239 100644 --- a/tools/mmgotool/go.mod +++ b/tools/mmgotool/go.mod @@ -1,10 +1,10 @@ module github.com/mattermost/mattermost/tools/mmgotool -go 1.20 +go 1.26.3 -require github.com/spf13/cobra v1.7.0 +require github.com/spf13/cobra v1.10.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/tools/mmgotool/go.sum b/tools/mmgotool/go.sum index f3366a91aa3..ef5d78dd283 100644 --- a/tools/mmgotool/go.sum +++ b/tools/mmgotool/go.sum @@ -1,10 +1,11 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/sharedchannel-test/go.mod b/tools/sharedchannel-test/go.mod index d6a818b6cf9..2791befa3ae 100644 --- a/tools/sharedchannel-test/go.mod +++ b/tools/sharedchannel-test/go.mod @@ -1,11 +1,11 @@ module github.com/mattermost/mattermost/tools/sharedchannel-test -go 1.26.2 +go 1.26.3 -require github.com/mattermost/mattermost/server/public v0.1.12 +require github.com/mattermost/mattermost/server/public v0.4.0 require ( - github.com/blang/semver/v4 v4.0.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.19.0 // indirect @@ -18,14 +18,13 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect github.com/mattermost/logr/v2 v2.0.22 // indirect - github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/oklog/run v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect @@ -33,18 +32,18 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/tools/sharedchannel-test/go.sum b/tools/sharedchannel-test/go.sum index d5f75ecc6ed..0590ac65bc5 100644 --- a/tools/sharedchannel-test/go.sum +++ b/tools/sharedchannel-test/go.sum @@ -8,14 +8,16 @@ dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1 dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -26,8 +28,7 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= @@ -42,8 +43,7 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -79,8 +79,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= -github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= @@ -101,16 +101,14 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d h1:etRyN6FNd6fc7BGZ8X+XB2u/5Hb2HNz5/K53YZNvfrs= -github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d/go.mod h1:HILhsra+xY4SNEFhuPbobH3I8a0aeXJcTJ6RWPX85nI= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -170,9 +168,8 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= -github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= -github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -184,33 +181,31 @@ github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRi github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -220,9 +215,8 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -244,17 +238,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -262,8 +253,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -276,18 +267,15 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/webapp/channels/babel.config.js b/webapp/channels/babel.config.js index 9f5805a6737..641c0817c6e 100644 --- a/webapp/channels/babel.config.js +++ b/webapp/channels/babel.config.js @@ -11,7 +11,7 @@ const config = { chrome: 110, firefox: 102, edge: 110, - safari: '16.2', + safari: '16.4', }, corejs: corejsVersion, useBuiltIns: 'usage', diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js index 6c65afbf94e..4102848eab4 100644 --- a/webapp/channels/jest.config.js +++ b/webapp/channels/jest.config.js @@ -25,6 +25,7 @@ const config = { '^mattermost-redux/test/(.*)$': '/src/packages/mattermost-redux/test/$1', '^mattermost-redux/(.*)$': '/src/packages/mattermost-redux/src/$1', + '^pdfjs-dist/.*': '/src/tests/pdfjs_mock.ts', '^.+\\.(jpg|jpeg|png|apng|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/tests/image_url_mock.json', '^.+\\.(css|less|scss)$': 'identity-obj-proxy', diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 34293f3d97d..75ab58946d9 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -50,7 +50,7 @@ "katex": "0.16.21", "localforage": "1.10.0", "localforage-observable": "2.1.1", - "lodash": "4.17.23", + "lodash": "4.18.1", "luxon": "3.6.1", "mark.js": "8.11.1", "marked": "github:mattermost/marked#e4a8785014b26ba9f637c1fdab23e340961c6a03", @@ -59,7 +59,7 @@ "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", "p-queue": "7.3.0", - "pdfjs-dist": "4.4.168", + "pdfjs-dist": "4.10.38", "process": "0.11.10", "prop-types": "15.8.1", "react": "18.2.0", @@ -134,7 +134,7 @@ "@types/tinycolor2": "1.4.6", "@types/zen-observable": "0.8.7", "babel-plugin-styled-components": "2.1.4", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "14.0.0", "emoji-datasource": "6.1.1", "emoji-datasource-apple": "6.1.1", "emoji-datasource-google": "6.1.1", @@ -143,9 +143,9 @@ "html-loader": "5.1.0", "html-webpack-plugin": "5.5.0", "identity-obj-proxy": "3.0.0", - "image-webpack-loader": "8.1.0", - "imagemin-gifsicle": "7.0.0", - "imagemin-mozjpeg": "9.0.0", + "image-minimizer-webpack-plugin": "5.0.0", + "svgo": "2.8.2", + "sharp": "0.34.5", "jest": "30.1.3", "jest-canvas-mock": "2.5.0", "jest-cli": "30.1.3", diff --git a/webapp/channels/src/actions/websocket_actions.test.jsx b/webapp/channels/src/actions/websocket_actions.test.jsx index d9a2a886f0b..69c003bf838 100644 --- a/webapp/channels/src/actions/websocket_actions.test.jsx +++ b/webapp/channels/src/actions/websocket_actions.test.jsx @@ -14,6 +14,7 @@ import { getPostThreads, receivedNewPost, } from 'mattermost-redux/actions/posts'; +import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels'; import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling'; import {getUser} from 'mattermost-redux/actions/users'; import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general'; @@ -124,6 +125,14 @@ jest.mock('components/common/hooks/useAccessControlAttributes', () => ({ invalidateAccessControlAttributesCache: jest.fn(), })); +jest.mock('mattermost-redux/actions/shared_channels', () => ({ + fetchChannelRemotes: jest.fn((channelId, forceRefresh) => ({ + type: 'MOCK_FETCH_CHANNEL_REMOTES', + channelId, + forceRefresh, + })), +})); + let mockState = { entities: { apps: { @@ -1822,3 +1831,86 @@ describe('handleChannelConvertedEvent', () => { }); }); }); + +describe('handleSharedChannelRemoteUpdatedEvent', () => { + const channelId = 'shared-remote-channel'; + + beforeEach(() => { + store.dispatch.mockClear(); + fetchChannelRemotes.mockClear(); + mockState = { + ...mockState, + entities: { + ...mockState.entities, + channels: { + ...mockState.entities.channels, + channels: { + ...mockState.entities.channels.channels, + [channelId]: { + id: channelId, + team_id: 'currentTeamId', + type: Constants.OPEN_CHANNEL, + name: 'shared-channel', + shared: true, + }, + }, + }, + }, + }; + }); + + test('dispatches fetchChannelRemotes when local channel is shared', () => { + const msg = { + event: WebSocketEvents.SharedChannelRemoteUpdated, + data: {channel_id: channelId}, + broadcast: {channel_id: channelId}, + }; + + handleEvent(msg); + + expect(fetchChannelRemotes).toHaveBeenCalledWith(channelId, true); + expect(store.dispatch).toHaveBeenCalledWith({ + type: 'MOCK_FETCH_CHANNEL_REMOTES', + channelId, + forceRefresh: true, + }); + }); + + test('skips fetch when local channel is not shared (regression: MM-66162)', () => { + mockState.entities.channels.channels[channelId].shared = false; + + const msg = { + event: WebSocketEvents.SharedChannelRemoteUpdated, + data: {channel_id: channelId}, + broadcast: {channel_id: channelId}, + }; + + handleEvent(msg); + + expect(fetchChannelRemotes).not.toHaveBeenCalled(); + }); + + test('skips fetch when channel is absent from local state', () => { + const msg = { + event: WebSocketEvents.SharedChannelRemoteUpdated, + data: {channel_id: 'unknown-channel'}, + broadcast: {channel_id: 'unknown-channel'}, + }; + + handleEvent(msg); + + expect(fetchChannelRemotes).not.toHaveBeenCalled(); + }); + + test('skips fetch when channel_id is missing from both data and broadcast', () => { + const msg = { + event: WebSocketEvents.SharedChannelRemoteUpdated, + data: {}, + broadcast: {}, + }; + + handleEvent(msg); + + expect(fetchChannelRemotes).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index d9cb60c6155..343cb4f0b0c 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -779,11 +779,23 @@ export function handleEvent(msg: WebSocketMessage) { }); } +// The server publishes shared_channel_remote_updated from the onInvite callback after a remote +// (cluster or plugin) accepts a channel invitation. The channel is already shared at that point, +// and the channel_updated event that set shared=true ran earlier in the share flow, so the local +// channel should already reflect shared=true when this arrives. If it doesn't, the event isn't +// meaningful for us and we skip the fetch. function handleSharedChannelRemoteUpdatedEvent(msg: WebSocketMessages.SharedChannelRemoteUpdated) { const channelId = msg.data.channel_id || msg.broadcast.channel_id; - if (channelId) { - dispatch(fetchChannelRemotes(channelId, true)); + if (!channelId) { + return; } + + const channel = getChannel(getState(), channelId); + if (!channel?.shared) { + return; + } + + dispatch(fetchChannelRemotes(channelId, true)); } // handleChannelConvertedEvent handles updating of channel which is converted between public and private diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx index 9326d718682..7a13f324d1b 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx @@ -80,6 +80,21 @@ interface CELEditorProps { attribute: string; values: string[]; }>; + + /** + * When provided, the built-in expression-only TestResultsModal is + * suppressed and the test button forwards its click to the parent. + * The parent is responsible for rendering its own results modal — + * used by the permission-rule editor so its dual-lane simulation + * modal (SimulateAccessModal) can replace the legacy + * membership-only one without changing the button's layout. + */ + onTestClick?: () => void; + + /** Optional label override for the test button. Lets the + * permission-rule editor render "Simulate rules" instead of the + * default "Test access rule" copy. */ + testButtonLabel?: React.ReactNode; hasMaskedRows?: boolean; } @@ -95,6 +110,8 @@ function CELEditor({ teamId, disabled = false, userAttributes, + onTestClick, + testButtonLabel, hasMaskedRows = false, }: CELEditorProps): JSX.Element { const intl = useIntl(); @@ -408,7 +425,8 @@ function CELEditor({ setEditorState((prev) => ({...prev, showTestResults: true}))} + onClick={onTestClick ?? (() => setEditorState((prev) => ({...prev, showTestResults: true})))} + label={testButtonLabel} disabled={disabled || hasMaskedRows || !editorState.expression || !editorState.isValid || editorState.isValidating} disabledTooltip={ hasMaskedRows ? @@ -420,7 +438,11 @@ function CELEditor({ } /> - {editorState.showTestResults && ( + {/* Built-in expression-only modal. Suppressed when the + * parent provided an `onTestClick` override (used by the + * permission-rule editor, which renders its own dual-lane + * SimulateAccessModal). */} + {!onTestClick && editorState.showTestResults && ( setEditorState((prev) => ({...prev, showTestResults: false}))} isStacked={true} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx index 00d8e88d578..928abeea8fc 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx @@ -31,6 +31,24 @@ describe('TestButton', () => { expect(icon).toBeInTheDocument(); }); + test('should render the supplied label override instead of the default copy', () => { + renderWithContext( + , + {}, + ); + + // The default "Test access rule" copy must not appear when a + // label override is provided — used by the permission-rule + // editors to surface "Simulate rules" instead. + expect(screen.queryByRole('button', {name: /test access rule/i})).not.toBeInTheDocument(); + const button = screen.getByRole('button', {name: /simulate rules/i}); + expect(button).toBeInTheDocument(); + expect(button.querySelector('i.icon.icon-lock-outline')).toBeInTheDocument(); + }); + test('should be enabled and clickable when disabled is false', () => { renderWithContext(, {}); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx index d6442bb29b8..c8399bb55fe 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx @@ -123,6 +123,11 @@ interface TestButtonProps { onClick: () => void; disabled: boolean; disabledTooltip?: string; + + /** Override the default "Test access rule" label. Used by the + * permission-rule editors to surface "Simulate rules" instead, + * matching the dual-lane simulation modal they open. */ + label?: React.ReactNode; } interface AddAttributeButtonProps { @@ -135,7 +140,7 @@ interface HelpTextProps { onLearnMoreClick?: () => void; } -export function TestButton({onClick, disabled, disabledTooltip}: TestButtonProps): JSX.Element { +export function TestButton({onClick, disabled, disabledTooltip, label}: TestButtonProps): JSX.Element { const button = ( ); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx index f7de92128ea..852f55820cd 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx @@ -93,6 +93,27 @@ interface TableEditorProps { isSystemAdmin?: boolean; validateExpressionAgainstRequester?: (expression: string) => Promise>; + /** + * When provided, the built-in TestResultsModal is suppressed and the + * Test access rule button forwards its click to the parent. The parent + * is responsible for rendering its own results modal — used by the + * permission-rule editor so its dual-lane simulation modal can replace + * the legacy expression-only one without changing the button's layout. + */ + onTestClick?: () => void; + + /** Force the test button into the disabled state (overrides default). */ + testButtonDisabled?: boolean; + + /** Tooltip shown when the test button is disabled. Useful for explaining + * why simulation is unavailable (e.g. no attributes loaded). */ + testButtonTooltip?: string; + + /** Optional label override for the test button. Lets the + * permission-rule editor render "Simulate rules" instead of the + * default "Test access rule" copy. */ + testButtonLabel?: React.ReactNode; + // Callback to notify parent when masked state changes (for CEL editor integration) onMaskedStateChange?: (hasMasked: boolean) => void; } @@ -162,26 +183,6 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] = return tableRows; }; -function getTestButtonTooltip( - hasMaskedRows: boolean, - userWouldBeExcluded: boolean, - formatMessage: ReturnType['formatMessage'], -): string | undefined { - if (hasMaskedRows) { - return formatMessage({ - id: 'admin.access_control.table_editor.masked_values_tooltip', - defaultMessage: 'Test is unavailable because this policy contains restricted attribute values.', - }); - } - if (userWouldBeExcluded) { - return formatMessage({ - id: 'admin.access_control.table_editor.user_excluded_tooltip', - defaultMessage: 'You cannot test access rules that would exclude you from the channel', - }); - } - return undefined; -} - // TableEditor provides a user-friendly table interface for constructing and editing // CEL (Common Expression Language) expressions based on user attributes. // It parses incoming CEL expressions into rows and reconstructs the expression upon changes. @@ -200,6 +201,10 @@ function TableEditor({ actions, isSystemAdmin = false, validateExpressionAgainstRequester, + onTestClick, + testButtonDisabled, + testButtonTooltip, + testButtonLabel, onMaskedStateChange, }: TableEditorProps): JSX.Element { const {formatMessage} = useIntl(); @@ -520,13 +525,35 @@ function TableEditor({ })} /> setShowTestResults(true)} - disabled={disabled || !value || userWouldBeExcluded || hasMaskedRows} - disabledTooltip={getTestButtonTooltip(hasMaskedRows, userWouldBeExcluded, formatMessage)} + onClick={onTestClick ?? (() => setShowTestResults(true))} + disabled={(testButtonDisabled ?? false) || disabled || (!onTestClick && !value) || userWouldBeExcluded} + disabledTooltip={ + + // Precedence: an explicit parent-supplied + // tooltip paired with `testButtonDisabled` + // wins (the parent already chose what the + // user should see and why), then the + // user-excluded message, then any other + // testButtonTooltip the parent passed + // alongside other disable reasons. The + // earlier `userWouldBeExcluded ? … : tooltip` + // ternary silenced parent hints whenever the + // self-exclusion check happened to also + // be true. + (testButtonDisabled && testButtonTooltip) || + (userWouldBeExcluded ? formatMessage({ + id: 'admin.access_control.table_editor.user_excluded_tooltip', + defaultMessage: 'You cannot test access rules that would exclude you from the channel', + }) : testButtonTooltip) + } + label={testButtonLabel} /> - {showTestResults && ( + {/* Built-in expression-only modal. Suppressed when the parent + * provided an `onTestClick` override (used by the permission-rule + * editor, which renders its own dual-lane simulation modal). */} + {!onTestClick && showTestResults && ( setShowTestResults(false)} isStacked={true} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.test.ts b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.test.ts new file mode 100644 index 00000000000..4d6b4d24b4c --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {POLICY_SIMULATION_BLAME_SOURCES} from '@mattermost/types/access_control'; + +import {aggregateDecisions} from './decision_aggregate'; + +describe('aggregateDecisions', () => { + test('pending while pending flag is set', () => { + expect(aggregateDecisions(['a', 'b'], undefined, true)).toBe('pending'); + }); + + test('pending when decisions map is missing entirely', () => { + expect(aggregateDecisions(['a'], undefined, false)).toBe('pending'); + }); + + test('pending when one action has no verdict yet', () => { + expect(aggregateDecisions( + ['a', 'b'], + {a: {decision: true}}, + false, + )).toBe('pending'); + }); + + test('pending when actions array is empty', () => { + expect(aggregateDecisions([], {}, false)).toBe('pending'); + }); + + test('allowed when every action allows', () => { + expect(aggregateDecisions( + ['a', 'b'], + {a: {decision: true}, b: {decision: true}}, + false, + )).toBe('allowed'); + }); + + test('denied when every action denies', () => { + expect(aggregateDecisions( + ['a', 'b'], + {a: {decision: false}, b: {decision: false}}, + false, + )).toBe('denied'); + }); + + test('mixed when at least one allow + one deny', () => { + expect(aggregateDecisions( + ['a', 'b'], + {a: {decision: true}, b: {decision: false}}, + false, + )).toBe('mixed'); + }); + + test('not-applicable only when every action is no_applicable_policy', () => { + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: inapplicable}, + false, + )).toBe('not-applicable'); + }); + + test('inapplicable + real allow rolls up to allowed', () => { + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: {decision: true}}, + false, + )).toBe('allowed'); + }); + + test('inapplicable + real deny rolls up to denied', () => { + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: {decision: false}}, + false, + )).toBe('denied'); + }); + + test('single-action allow short-circuits to allowed', () => { + expect(aggregateDecisions( + ['a'], + {a: {decision: true}}, + false, + )).toBe('allowed'); + }); + + test('not-applicable when every action is no_applicable_rule', () => { + // In the "this rule only" view the server replaces an + // orphaned-deny or sibling_saved verdict with a synthetic + // no_applicable_rule blame. The aggregate must treat it the + // same as no_applicable_policy so the row chip reads + // "doesn't apply" instead of misleading "allowed". + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: inapplicable}, + false, + )).toBe('not-applicable'); + }); + + test('no_applicable_rule + real allow rolls up to allowed', () => { + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: {decision: true}}, + false, + )).toBe('allowed'); + }); + + test('no_applicable_rule + real deny rolls up to denied', () => { + const inapplicable = { + decision: true, + blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE}], + }; + expect(aggregateDecisions( + ['a', 'b'], + {a: inapplicable, b: {decision: false}}, + false, + )).toBe('denied'); + }); + + test('mixed no_applicable_rule + no_applicable_policy still rolls up to not-applicable', () => { + // The two synthetic markers can co-occur on different + // actions of the same row (e.g. one action falls outside + // the policy entirely, another falls outside this rule). + // Both count as inapplicable for the row-level rollup. + expect(aggregateDecisions( + ['a', 'b'], + { + a: {decision: true, blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY}]}, + b: {decision: true, blame: [{source: POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE}]}, + }, + false, + )).toBe('not-applicable'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.ts b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.ts new file mode 100644 index 00000000000..dc48e60032e --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_aggregate.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PolicySimulationActionDecision} from '@mattermost/types/access_control'; +import {POLICY_SIMULATION_BLAME_SOURCES} from '@mattermost/types/access_control'; + +/** + * AggregateDecisionState rolls up the per-action decisions for a single + * picker row into a single bucket the stacked chip renders against: + * + * - 'pending' — at least one action has no decision yet (debounce + * in flight or row freshly added). + * - 'not-applicable' — every decision carries `no_applicable_policy` or + * `no_applicable_rule` blame (the draft policy / + * editing rule doesn't govern this user). + * - 'allowed' — every action allows AND none are not-applicable. + * - 'denied' — every action denies. + * - 'mixed' — at least one allow + at least one deny in the same + * row, so a single chip would be misleading; the + * picker prompts the author to drill into the + * per-permission breakdown modal. + */ +/** + * Stable string keys for the rolled-up aggregate state. Re-export the + * literals as a frozen object so consumers (picker_row, stacked chip, + * tests) don't have to repeat the bare string in conditionals. + */ +export const AGGREGATE_DECISION_STATE = Object.freeze({ + PENDING: 'pending', + NOT_APPLICABLE: 'not-applicable', + ALLOWED: 'allowed', + DENIED: 'denied', + MIXED: 'mixed', +} as const); + +export type AggregateDecisionState = + (typeof AGGREGATE_DECISION_STATE)[keyof typeof AGGREGATE_DECISION_STATE]; + +/** + * Aggregates a row's per-action decisions into a single chip-friendly + * state. `decisions` is the map keyed by action name from the simulator + * response; `pending` is set by the picker while a debounced simulate + * dispatch is in flight. + * + * Counting rules: + * - actions missing from `decisions` count as pending (we haven't gotten + * a verdict yet). + * - decisions tagged with `no_applicable_policy` or `no_applicable_rule` + * blame are vacuous allows; they only roll up to 'not-applicable' when + * EVERY action is inapplicable. Mixed rows (one inapplicable + one real + * allow/deny) fold the inapplicable side into the real side so the + * chip reflects the actionable decisions. + */ +export function aggregateDecisions( + actions: string[], + decisions: Record | undefined, + pending: boolean, +): AggregateDecisionState { + if (pending || actions.length === 0) { + return AGGREGATE_DECISION_STATE.PENDING; + } + if (!decisions) { + return AGGREGATE_DECISION_STATE.PENDING; + } + + let allows = 0; + let denies = 0; + let inapplicable = 0; + let missing = 0; + + for (const action of actions) { + const dec = decisions[action]; + if (!dec) { + missing++; + continue; + } + if (!dec.decision) { + denies++; + continue; + } + if (dec.blame?.some((b) => + ( + b.source === POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY || + b.source === POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE + ) && + b.outcome !== 'allow', + )) { + inapplicable++; + continue; + } + allows++; + } + + if (missing > 0) { + return AGGREGATE_DECISION_STATE.PENDING; + } + if (inapplicable === actions.length) { + return AGGREGATE_DECISION_STATE.NOT_APPLICABLE; + } + if (allows > 0 && denies > 0) { + return AGGREGATE_DECISION_STATE.MIXED; + } + if (denies > 0) { + return AGGREGATE_DECISION_STATE.DENIED; + } + return AGGREGATE_DECISION_STATE.ALLOWED; +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.scss b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.scss new file mode 100644 index 00000000000..dd112905bc9 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.scss @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Styles for the decision chip family. The base `__rowChip` block is +// reused by the single-action DecisionChip, the multi-action +// StackedDecisionChip, and the per-session SessionStateChip — every +// callsite varies only the modifier (`--allow`, `--deny`, …). Loaded +// once via decision_chip.tsx; the other components ride on the same +// classes without importing this file directly. + +// Decision chip — matches the spec's pill design: filled-circle +// glyph, label, near-square radius. The earlier 12px radius read as a +// "tag", but the spec calls for a tighter 4px so the chip looks like +// a status badge. Padding lifts to 4px vertical so the 14px icon has +// room to breathe without the pill feeling top-heavy. +.SimulateAccessModal__rowChip { + display: inline-flex; + overflow: hidden; + max-width: 240px; + align-items: center; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + gap: 6px; + text-overflow: ellipsis; + white-space: nowrap; + + &--allow { + background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + color: var(--online-indicator, #06a77d); + } + + &--allow-saved { + background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.12); + color: var(--online-indicator, #06a77d); + } + + &--deny { + background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + color: var(--error-text); + } + + &--pending { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.56); + } + + &--not-applicable { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &--mixed { + background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.18); + color: var(--away-indicator, #b07700); + } + + // Multi-action rollup chip — same shape as the per-action pills, + // just rendered as a button with a count badge and chevron. + // Padding/font/radius come from the parent `__rowChip` rules so + // allow/mixed/deny stacked pills line up with their single-action + // counterparts. + &--stacked { + border: 0; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 600; + line-height: 16px; + + &:disabled { + cursor: default; + } + + &:hover:not(:disabled) { + filter: brightness(0.96); + } + + // Keyboard users need a visible focus indicator. Mirrors the + // pattern used by .SimulateAccessModal__rowChipButton below + // (outline 2px solid + 2px offset against the button-bg + // primary). The brightness filter is dropped here so the + // outline is the unambiguous focus signal — on a hover-then- + // focus the hover rule above still tints the chip. + &:focus-visible { + outline: 2px solid var(--button-bg); + outline-offset: 2px; + } + } +} + +// The leading status glyph at the start of every chip. Inherits +// currentColor so each pill modifier paints the icon to match its +// text colour automatically (allow → green, deny → red, etc). +.SimulateAccessModal__rowChipIcon { + flex-shrink: 0; + color: currentColor; +} + +.SimulateAccessModal__rowChipLabel { + overflow: hidden; + min-width: 0; + flex: 0 1 auto; + text-overflow: ellipsis; +} + +.SimulateAccessModal__rowChipCount { + display: inline-flex; + min-width: 18px; + height: 18px; + align-items: center; + justify-content: center; + padding: 0 5px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.55); + color: inherit; + font-size: 11px; + font-weight: 600; +} + +.SimulateAccessModal__rowChipChevron { + margin-right: -4px; + font-size: 14px; + opacity: 0.7; +} + +// The single-action chip is wrapped in a button when the row has +// something to drill into (a deny, or a sibling_saved allow). The +// wrapper is a transparent shell so the underlying chip still controls +// all the visuals; we just inherit pointer/focus affordances. +.SimulateAccessModal__rowChipButton { + display: inline-flex; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + font: inherit; + + &:hover .SimulateAccessModal__rowChip, + &:focus-visible .SimulateAccessModal__rowChip { + filter: brightness(0.96); + } + + &:focus-visible { + outline: 2px solid var(--button-bg); + outline-offset: 2px; + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.tsx b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.tsx new file mode 100644 index 00000000000..2be0dc3f4dc --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_chip.tsx @@ -0,0 +1,334 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessages, FormattedMessage, useIntl} from 'react-intl'; + +import {CheckCircleIcon, CloseCircleIcon, MinusCircleIcon, MinusCircleOutlineIcon} from '@mattermost/compass-icons/components'; +import type {PolicySimulationActionDecision, PolicySimulationBlame} from '@mattermost/types/access_control'; +import {POLICY_SIMULATION_BLAME_SOURCES} from '@mattermost/types/access_control'; + +import './decision_chip.scss'; + +// Compact leading icons that match the spec's pill design — a filled +// circle marker that telegraphs the verdict before the eye reads the +// label. Sized to 14px so the pill stays the same overall height as +// before; the icon's colour inherits via `currentColor` from the +// pill's modifier class so each state's tinted text colour applies +// uniformly to the glyph too. +const ICON_SIZE = 14; + +const blameSourceMessages = defineMessages({ + [POLICY_SIMULATION_BLAME_SOURCES.THIS_RULE]: { + id: 'admin.access_control.simulate_access.blame.this_rule', + defaultMessage: 'this rule', + }, + [POLICY_SIMULATION_BLAME_SOURCES.SIBLING_RULE]: { + id: 'admin.access_control.simulate_access.blame.sibling_rule', + defaultMessage: 'another rule', + }, + [POLICY_SIMULATION_BLAME_SOURCES.CHANNEL_POLICY]: { + id: 'admin.access_control.simulate_access.blame.channel_policy', + defaultMessage: 'parent policy', + }, + [POLICY_SIMULATION_BLAME_SOURCES.SYSTEM_PERMISSION]: { + id: 'admin.access_control.simulate_access.blame.system_permission', + defaultMessage: 'system policy', + }, + [POLICY_SIMULATION_BLAME_SOURCES.PEER_POLICY]: { + id: 'admin.access_control.simulate_access.blame.peer_policy', + defaultMessage: 'another policy', + }, + [POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY]: { + id: 'admin.access_control.simulate_access.blame.no_applicable_policy', + defaultMessage: "policy doesn't apply to this user", + }, + [POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE]: { + id: 'admin.access_control.simulate_access.blame.no_applicable_rule', + defaultMessage: "this rule doesn't apply to this user", + }, + [POLICY_SIMULATION_BLAME_SOURCES.SIBLING_SAVED]: { + id: 'admin.access_control.simulate_access.blame.sibling_saved', + defaultMessage: 'another rule', + }, +}); + +// Order of preference when more than one blame entry is present on a +// decision. We surface same-scope blame first because the chip has +// space for one source label, and "Denied · IL5 Block" is more useful +// than "Denied · system policy" when both contributors denied. +const DENY_BLAME_PRIORITY: string[] = [ + POLICY_SIMULATION_BLAME_SOURCES.THIS_RULE, + POLICY_SIMULATION_BLAME_SOURCES.SIBLING_RULE, + POLICY_SIMULATION_BLAME_SOURCES.PEER_POLICY, + POLICY_SIMULATION_BLAME_SOURCES.SYSTEM_PERMISSION, + POLICY_SIMULATION_BLAME_SOURCES.CHANNEL_POLICY, +]; + +type Props = { + decision: PolicySimulationActionDecision | undefined; + pending?: boolean; +}; + +/** + * Per-user, per-action decision chip. + * + * The chip renders the blame source inline (e.g. "Denied · this rule" + * rather than a tooltip-only hint) so authors can scan the list and tell + * at a glance whether a deny is coming from the rule they're editing, + * another rule in the same policy, or a system / parent policy + * outside the editing scope. + * + * States: + * - pending → grey "Evaluating…" while a simulate dispatch is in flight. + * - ALLOW with sibling_saved blame → green "Allowed · another rule" + * because the editing rule alone would have denied (OR-bucket saved + * them). + * - ALLOW with no_applicable_policy blame → neutral "Policy doesn't + * apply" so the row doesn't masquerade as a meaningful pass. + * - ALLOW (no blame) → green "Allowed". + * - DENY → red "Denied · {source}" where source maps to one of the + * blame-source labels above. + */ +export default function DecisionChip({decision, pending}: Props): JSX.Element { + const {formatMessage} = useIntl(); + + if (pending || !decision) { + return ( + + + + ); + } + + if (decision.decision) { + // "This rule doesn't apply to this user" — the post-process + // injects no_applicable_rule whenever the editing rule is + // silent on the subject in the "this rule only" view (either + // a sibling rule's OR-bucket saved the user, or the deny + // came entirely from outside the editing rule). Checked + // before no_applicable_policy because the rule-scoped marker + // is strictly narrower: the policy as a whole may still + // apply, just not this rule. + if (hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE)) { + return ( + + + + + ); + } + + // The simulator marks "policy doesn't apply to this user" rows + // with a synthetic vacuous ALLOW + a single no_applicable_policy + // blame entry. Render as a softer neutral chip so the author isn't + // misled into thinking the rule decided to allow them. + if (hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY)) { + return ( + + + + + ); + } + + // ALLOW with sibling_saved blame: the editing rule alone would + // have denied. Surface that inline so authors can spot + // "OR-saved" allows. Only reached when no_applicable_rule + // wasn't injected — i.e. we're in the "All policies" view + // where the sibling IS relevant context for the verdict. + if (hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.SIBLING_SAVED)) { + return ( + + + + + ); + } + + return ( + + + + + ); + } + + // In "all policies" mode, the fail-secure path produces + // Decision=false with a no_applicable_* blame when the action is + // governed but the subject's role doesn't match. Render the same + // neutral "doesn't apply" pill rather than a hard "Denied" chip so + // the UX stays consistent regardless of evaluation scope. + if (hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE) || + hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY)) { + const source = hasBlame(decision.blame, POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE) ? + POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_RULE : + POLICY_SIMULATION_BLAME_SOURCES.NO_APPLICABLE_POLICY; + return ( + + + + + ); + } + + const blame = pickPrimaryDenyBlame(decision.blame); + const blameLabel = blame ? blameSourceLabel(blame, formatMessage) : ''; + + // Only same-scope blame is allowed to expose the contributing + // rule/policy name in the tooltip. Upper-scoped sources + // (system_permission, channel_policy) deliberately leave the + // tooltip blank — the public-server's privacy boundary already + // strips their `expression` and `evaluation_tree`, and surfacing + // the rule/policy name here would leak metadata the author + // shouldn't see at this scope. + const blameTooltip = blame && SAME_SCOPE_BLAME_SOURCES.has(blame.source) ? + (blame.rule_name || blame.policy_name || '') : + ''; + + return ( + + + {blameLabel ? ( + + ) : ( + + )} + + ); +} + +// Returned alongside the export so a caller can reuse the same icon +// glyph mapping (e.g. the multi-action StackedDecisionChip). Keeps +// the icon-state mapping in one place — single source of truth for +// "what does an allow look like in the picker". +export const ChipIcon = {Allow: CheckCircleIcon, Deny: CloseCircleIcon, Mixed: MinusCircleIcon, NotApplicable: MinusCircleOutlineIcon}; + +// Blame sources that originate at (or below) the editing scope and are +// safe to expose detail about in the chip tooltip. Upper-scoped sources +// (system_permission, channel_policy) are deliberately omitted — the +// public-server's privacy boundary strips their expression/tree, and +// the chip-level rule/policy-name leak should follow the same rule. +const SAME_SCOPE_BLAME_SOURCES = new Set([ + POLICY_SIMULATION_BLAME_SOURCES.THIS_RULE, + POLICY_SIMULATION_BLAME_SOURCES.SIBLING_RULE, + POLICY_SIMULATION_BLAME_SOURCES.SIBLING_SAVED, + POLICY_SIMULATION_BLAME_SOURCES.PEER_POLICY, +]); + +function hasBlame(blame: PolicySimulationBlame[] | undefined, source: string): boolean { + if (!blame || blame.length === 0) { + return false; + } + + // Match deny-style entries only — informational allow entries + // don't contribute to "is this NO_APPLICABLE_POLICY / SIBLING_SAVED" + // reasoning the chip layer uses. (Same backward-compat handling + // for empty `outcome` as pickPrimaryDenyBlame.) + return blame.some((b) => b.source === source && b.outcome !== 'allow'); +} + +function pickPrimaryDenyBlame(blame: PolicySimulationBlame[] | undefined): PolicySimulationBlame | undefined { + if (!blame || blame.length === 0) { + return undefined; + } + + // Skip informational allow entries the simulator emits to surface + // the editing draft's evaluation when a peer policy is the actual + // denier — the chip is supposed to name the denier, not the + // "I allowed" companion entry. Empty `outcome` defaults to deny + // for backward compat with older simulators. + const denyOnly = blame.filter((b) => b.outcome !== 'allow'); + if (denyOnly.length === 0) { + return undefined; + } + let best: PolicySimulationBlame | undefined; + let bestRank = DENY_BLAME_PRIORITY.length; + for (const b of denyOnly) { + const rank = DENY_BLAME_PRIORITY.indexOf(b.source); + if (rank === -1 || rank >= bestRank) { + continue; + } + best = b; + bestRank = rank; + } + return best ?? denyOnly[0]; +} + +function blameSourceLabel( + blame: PolicySimulationBlame, + formatMessage: ReturnType['formatMessage'], +): string { + // Peer-policy blame surfaces the actual policy name on the chip + // (e.g. "Denied · IL5 Block") because at the editing scope peers + // are visible and naming them is useful. Falls back to the generic + // "peer policy" label only when the simulator didn't include a + // policy name. + if (blame.source === POLICY_SIMULATION_BLAME_SOURCES.PEER_POLICY && blame.policy_name) { + return blame.policy_name; + } + const sourceMsg = blameSourceMessages[blame.source as keyof typeof blameSourceMessages]; + if (!sourceMsg) { + return blame.source; + } + return formatMessage(sourceMsg); +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.scss b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.scss new file mode 100644 index 00000000000..939e71975aa --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.scss @@ -0,0 +1,669 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Styles for the stacked DecisionDetailsModal — the per-action +// breakdown shown when an author clicks a decision chip. Includes +// the modal's own flex chain (sibling to the picker modal because +// GenericModal portals to the document root), the user-attribute / +// session-attribute snapshot block, the per-action rule + expression +// + AST-trace details, and the trace tree itself. + +// DecisionDetailsModal lives at the root level (sibling to the +// SimulateAccessModal stacked modal) — it gets its own rules rather +// than nesting because GenericModal renders its DOM at the document +// portal root. Without this block long evaluation trees overflow the +// modal frame and scroll the page background instead of the modal +// body. +// Flex chain (from outermost in, all need to participate or the chain +// breaks): .modal-content (cap height) → .modal-body (Bootstrap layer, +// must be flex 1 column with hidden overflow so its child can grow) → +// .GenericModal__body (Mattermost wrapper, ditto) → modal content +// scrolls inside the GenericModal body. Skipping .modal-body — which +// is what the previous attempt did — leaves Bootstrap's default +// block-level body in place and the height constraint never reaches +// the inner content. +.SimulateAccessModal__details { + .modal-content { + display: flex; + max-height: 90vh; + flex-direction: column; + } + + .modal-body { + display: flex; + overflow: hidden; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + } + + .GenericModal__body { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + } +} + +.SimulateAccessModal__detailsHeader { + display: flex; + align-items: center; + margin-bottom: 16px; + gap: 12px; +} + +.SimulateAccessModal__detailsAvatar { + flex-shrink: 0; +} + +// Identity column needs `min-width: 0` so its ellipsizing children +// can actually shrink — without it a long display-name pushes the +// modal width past the configured max (each text span just expands +// and ignores the `text-overflow: ellipsis` directive). The `flex: 1` +// lets it claim the leftover width past the avatar. +.SimulateAccessModal__detailsIdentity { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + line-height: 18px; +} + +.SimulateAccessModal__detailsDisplayName { + overflow: hidden; + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.SimulateAccessModal__detailsUsername { + overflow: hidden; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Evaluation-trace block: the user/session attribute snapshot the +// simulator used. Two compact dl groups stack vertically; on wide +// viewports they could go side-by-side but the modal width is +// constrained so a vertical layout reads better. +.SimulateAccessModal__detailsAttributes { + display: flex; + flex-direction: column; + padding: 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 6px; + margin-bottom: 16px; + background: rgba(var(--center-channel-color-rgb), 0.03); + gap: 12px; +} + +.SimulateAccessModal__detailsAttributeGroup { + display: flex; + flex-direction: column; + gap: 4px; +} + +.SimulateAccessModal__detailsAttributeHeading { + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.SimulateAccessModal__detailsAttributeList { + display: grid; + padding: 0; + margin: 0; + column-gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 4px; + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } +} + +.SimulateAccessModal__detailsAttributeRow { + display: flex; + align-items: baseline; + gap: 6px; +} + +.SimulateAccessModal__detailsAttributeKey { + min-width: 0; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + font-weight: 600; + + // Attribute keys can be long (e.g. + // `network_classification_security_zone`) and ellipsizing them + // would hide which attribute is being shown. Wrap instead, and + // let the column shrink so the wrapped lines stay inside the + // grid cell. + overflow-wrap: anywhere; + word-break: break-word; +} + +.SimulateAccessModal__detailsAttributeValue { + overflow: hidden; + min-width: 0; + flex: 1 1 auto; + margin: 0; + color: var(--center-channel-color); + font-family: monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.SimulateAccessModal__detailsList { + display: flex; + flex-direction: column; + padding: 0 0 24px; + margin: 0; + gap: 6px; + list-style: none; +} + +.SimulateAccessModal__detailsItem { + display: flex; + flex-direction: column; + padding: 10px 12px; + border-radius: 6px; + background: rgba(var(--center-channel-color-rgb), 0.04); + gap: 8px; +} + +.SimulateAccessModal__detailsRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.SimulateAccessModal__detailsLabel { + overflow: hidden; + min-width: 0; + flex: 1 1 auto; + color: var(--center-channel-color); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Per-row disclosure toggle: the rule + tree details start collapsed +// behind this button so the modal opens with a tight summary view. +// Tertiary-button look matches the rest of the admin-console copy. +// Left padding is dropped to 0 so the "Show evaluation trace" text +// aligns with the permission label above it (both start at the card's +// 12px content gutter). The chevron sits at the end of the line, after +// the text, mirroring the standard expand affordance pattern. +.SimulateAccessModal__detailsToggle { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 4px 8px 4px 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--button-bg); + cursor: pointer; + font-size: 12px; + font-weight: 600; + gap: 4px; + + // No hover background or underline: the toggle reads as a + // brand-colored link inside a tinted card; a hover tint or + // underline both felt like clutter. Cursor change + brand color + // already make it discoverable as interactive. Keyboard-focus + // still gets the 2px outline ring so the focus state stays + // accessible. + &:focus-visible { + outline: 2px solid var(--button-bg); + outline-offset: 2px; + } + + i { + font-size: 14px; + } +} + +.SimulateAccessModal__detailsRule { + display: flex; + flex-direction: column; + padding: 8px 10px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 4px; + background: var(--center-channel-bg); + gap: 4px; +} + +// When the deny has more than one contributing same-scope policy +// (system console: peer policies + draft) the wrapper hosts numbered +// policy sections instead of a single rule block. Drop the outer +// padding/border so the per-policy cards inside own their own +// chrome — otherwise they look like nested rectangles within a +// rectangle. The allow / deny outcome is conveyed by the section's +// numbered badge color (green / red) and the outcome chip on the +// right; we deliberately do NOT also tint the section's left edge or +// background. Three concurrent outcome cues read as visual noise and +// made the multi-policy view look unrelated to the multi-rule view +// inside a single policy (which has none of that tinting). A single +// badge-color-coded affordance is enough. +.SimulateAccessModal__detailsRule--multiPolicy { + padding: 0; + border: 0; + background: transparent; +} + +.SimulateAccessModal__detailsPolicies { + display: flex; + flex-direction: column; + gap: 12px; +} + +.SimulateAccessModal__detailsPoliciesHead { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + line-height: 16px; +} + +// Per-policy section that appears either standalone (single +// contributing policy — keeps the original look) or as one of a +// numbered list of policies (multi-policy mode). In multi-policy +// mode the section adopts the same card chrome as a merged-rule +// section (`.SimulateAccessModal__detailsMergedRule`) so authors +// see a consistent visual treatment across "multiple rules in one +// policy" (channel settings) and "multiple policies on the same +// scope" (system console). Standalone (single-policy) mode keeps the +// flat layout the modal had before per-policy numbering was added. +.SimulateAccessModal__detailsPolicySection { + display: flex; + flex-direction: column; + gap: 4px; + + .SimulateAccessModal__detailsRule--multiPolicy & { + padding: 8px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.02); + gap: 6px; + } +} + +.SimulateAccessModal__detailsPolicyHeader { + display: flex; + align-items: center; + gap: 8px; +} + +// Per-section outcome chip (multi-policy mode only). Pushed to the +// far end of the header row via margin-left auto so the policy badge +// and name stay grouped on the left and the verdict reads on the +// right — same layout as the row-level decision chip elsewhere in +// the picker. The colored badge already conveys outcome, but we +// keep the chip for screen readers and to spell out "Your policy: +// Allowed" vs "Allowed" — the editing-draft / peer distinction is +// not encodable in badge color alone. +.SimulateAccessModal__detailsPolicyOutcome { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + margin-left: auto; + font-size: 11px; + font-weight: 600; + line-height: 16px; + white-space: nowrap; +} + +.SimulateAccessModal__detailsPolicyOutcome--allow { + background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + color: var(--online-indicator, #06a77d); +} + +.SimulateAccessModal__detailsPolicyOutcome--deny { + background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + color: var(--error-text, #d24b4e); +} + +// Numeric badge stamped on each policy section in multi-policy mode. +// Outline-only neutral style — no fill — matching the per-rule badge +// exactly so the two layers read as a single consistent affordance. +// Outcome at the policy layer is conveyed by the chip on the right +// (Your policy: Allowed / Denied) instead of badge color, which kept +// the channel-settings (rules) and system-console (policies) views +// looking like distinct UIs. Rules nested inside a multi-policy +// section render their badge with a "1.1", "1.2" label (see +// MergedRuleSection.policyIndex) so the +// inner numbering is visibly distinct from the outer one despite the +// shared style — color-coding the layers turned out to give +// inconsistent green / red / blue badges next to each other, which +// looked busy without communicating new information. +.SimulateAccessModal__detailsPolicyBadge { + display: inline-flex; + min-width: 20px; + height: 20px; + align-items: center; + justify-content: center; + padding: 0 6px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.32); + border-radius: 10px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.SimulateAccessModal__detailsPolicy, +.SimulateAccessModal__detailsRuleName { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + + // Policy and rule names are author-supplied text; they have no + // length cap on the server. `word-break: break-word` plus + // `overflow-wrap: anywhere` lets a name like + // "Restrict-File-Operations-To-Approved-Engineering-Teams-Only" + // wrap inside the trace block instead of forcing the modal to + // grow horizontally. + overflow-wrap: anywhere; + word-break: break-word; +} + +// Combined-evaluation header variant: the trace below merges multiple +// authored rules (engine.JoinExpressions OR-fold), so the line shows +// "Combined evaluation for role " inline with a help-icon button +// whose tooltip lists every contributing rule. Flex row instead of the +// default block layout so the icon button stays vertically centered +// next to the label. +.SimulateAccessModal__detailsRuleName--combined { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 4px; +} + +// Help-icon button next to "Combined evaluation for role ". Sized +// to the icon glyph itself (no rectangle padding) so it sits flush +// next to the label without inflating the header height. Inherits +// muted color from the surrounding label and brightens to body color +// on hover/focus so the affordance is discoverable. Reuses the same +// 2px outline pattern as __detailsToggle (above) for keyboard focus. +.SimulateAccessModal__detailsCombinedHelp { + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: help; + + i { + font-size: 14px; + } + + &:hover { + color: var(--center-channel-color); + } + + &:focus-visible { + outline: 2px solid var(--button-bg); + outline-offset: 1px; + } +} + +// Tooltip body for the help icon: a short explainer paragraph followed +// by a bulleted list of the merged rule names. Constrained width so a +// flat list of rules wraps inside a reasonable column instead of +// stretching to the viewport edge; gap controls the vertical rhythm +// between the explainer and the list. +.SimulateAccessModal__detailsCombinedTooltip { + display: flex; + max-width: 320px; + flex-direction: column; + gap: 6px; + text-align: left; +} + +.SimulateAccessModal__detailsCombinedTooltipHead { + font-size: 12px; + line-height: 16px; +} + +.SimulateAccessModal__detailsCombinedTooltipList { + padding-left: 18px; + margin: 0; + font-size: 12px; + line-height: 16px; + + li { + overflow-wrap: anywhere; + word-break: break-word; + } +} + +// Per-rule numbered sections rendered in place of the single merged +// evaluation tree when the server attached one tree per merged rule. +// Each section is a small card (background + border + padding) so the +// boundary between rules reads at a glance; the gap between sections +// is wider than within-section spacing so the visual rhythm matches +// "list of items" rather than "tightly nested children of one tree". +.SimulateAccessModal__detailsMergedRules { + display: flex; + flex-direction: column; + gap: 10px; +} + +.SimulateAccessModal__detailsMergedRule { + display: flex; + flex-direction: column; + padding: 8px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.02); + gap: 6px; +} + +.SimulateAccessModal__detailsMergedRuleHeader { + display: flex; + align-items: center; + gap: 8px; +} + +// Numeric badge that pairs each section with its tooltip-list entry +// above. Sized like a small chip — pill instead of circle so longer +// labels like "1.1", "1.2" (rules nested under a multi-policy +// section) still center cleanly. Outline-only neutral style matches +// the policy-section badge for visual consistency across the two +// views (rules within one policy / policies on the same scope); the +// difference between layers is carried by the label format +// ("1" vs "1.1"), not by color. +.SimulateAccessModal__detailsMergedRuleBadge { + display: inline-flex; + min-width: 20px; + height: 20px; + align-items: center; + justify-content: center; + padding: 0 6px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.32); + border-radius: 10px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.SimulateAccessModal__detailsMergedRuleName { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.SimulateAccessModal__detailsExpression { + padding: 6px 8px; + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.05); + color: var(--center-channel-color); + font-family: monospace; + font-size: 12px; + line-height: 16px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +// Evaluation-trace tree: a recursive, indented breakdown of the +// failing rule's CEL expression. Each node carries a small icon for +// outcome (check / x / warning), the subtree expression in monospace, +// and — for leaves — the user's actual value next to what the rule +// expected. Compound nodes carry a header line ("ALL of the following +// must hold (AND)") and indent their children one level via the +// `depth` prop translated to inline padding-left. +.SimulateAccessModal__detailsTree { + display: flex; + flex-direction: column; + padding: 6px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.02); + gap: 6px; +} + +// __traceNode is a pure structural container — colored band on the +// left + background tint, no inner padding. Spacing is owned by the +// children (header / values / nested traceChildren) so it's clear +// where each pixel of vertical space comes from. This avoids the +// "spacings compound" effect where a deeply-nested node accumulated +// padding from every wrapping container. +.SimulateAccessModal__traceNode { + display: flex; + flex-direction: column; + border-radius: 3px; + border-left: 3px solid transparent; + gap: 6px; + + &--true { + border-left-color: var(--online-indicator, #06a77d); + background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.06); + } + + &--false { + border-left-color: var(--error-text, #d24b4e); + background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.06); + } + + &--error { + border-left-color: var(--away-indicator, #ffbc42); + background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.08); + } +} + +.SimulateAccessModal__traceHeader { + display: flex; + align-items: center; + + // Header carries the visual padding now that the wrapping node is + // paddingless. 6/8 keeps the icon+label off the border band + // without crowding the row vertically. + padding: 6px 8px; + gap: 8px; +} + +.SimulateAccessModal__traceIcon { + flex-shrink: 0; + font-size: 16px; + + &--true { + color: var(--online-indicator, #06a77d); + } + + &--false { + // Hard-coded fallback matches the one used by traceNode--false + // (line ~301) so the icon stays red even when --error-text is + // undefined (e.g. preview embeds outside the channels theme). + color: var(--error-text, #d24b4e); + } + + &--error { + color: var(--away-indicator, #b07700); + } +} + +.SimulateAccessModal__traceCompoundLabel { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.SimulateAccessModal__traceExpression { + flex: 1 1 auto; + padding: 2px 6px; + border-radius: 3px; + background: rgba(var(--center-channel-color-rgb), 0.05); + color: var(--center-channel-color); + font-family: monospace; + font-size: 12px; + line-height: 16px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +// The error / values strips sit below the header inside a paddingless +// node. Padding-left 32px = 24px (icon column under the header) + +// 8px (matching the header's own 8px left padding) so the value text +// aligns visually under the leaf expression text, not under the +// icon. Padding-bottom gives the strip breathing room inside the +// colored band. +.SimulateAccessModal__traceError { + padding: 0 8px 6px 32px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 11px; + font-style: italic; + line-height: 14px; +} + +// Single short line under FALSE leaves: "Actual: ". TRUE +// leaves omit this strip entirely (the green tick + the expression +// already convey "matched"). The 32px left padding aligns the line +// under the leaf expression text (24px icon column + 8px header +// gutter), not under the icon. +.SimulateAccessModal__traceValues { + display: flex; + align-items: center; + padding: 0 8px 6px 32px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 11px; + line-height: 14px; +} + +.SimulateAccessModal__traceActual { + font-family: monospace; + overflow-wrap: anywhere; + word-break: break-word; +} + +// Children indent purely via margin-left here (no per-depth inline +// padding-left on the node); each nesting level adds 12px because the +// wrapping __traceChildren of every compound node applies the same +// offset. gap controls sibling vertical rhythm — same value as the +// parent __traceNode gap so a child's gap from its siblings matches +// its gap from the parent header. +.SimulateAccessModal__traceChildren { + display: flex; + flex-direction: column; + margin-left: 12px; + gap: 6px; +} diff --git a/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.tsx b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.tsx new file mode 100644 index 00000000000..b3ef22ef108 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/modals/simulate_access/decision_details_modal.tsx @@ -0,0 +1,1180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; +import {WithTooltip} from '@mattermost/shared/components/tooltip'; +import type { + AccessControlPolicy, + PolicySimulationActionDecision, + PolicySimulationBlame, + PolicySimulationEvaluationNode, +} from '@mattermost/types/access_control'; +import { + POLICY_SIMULATION_BLAME_SOURCES, + POLICY_SIMULATION_EVALUATION_NODE_KIND, + POLICY_SIMULATION_EVALUATION_OUTCOME, +} from '@mattermost/types/access_control'; +import type {UserProfile} from '@mattermost/types/users'; + +import {Client4} from 'mattermost-redux/client'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import ProfilePicture from 'components/profile_picture'; + +import DecisionChip from './decision_chip'; + +import './decision_details_modal.scss'; + +type Props = { + onExited: () => void; + user: UserProfile; + actions: string[]; + actionLabels?: Record; + decisions?: Record; + pending: boolean; + + /** Draft policy currently being edited. Used to fall back to a + * client-side expression lookup for `this_rule` / `sibling_rule` + * blame entries when the server didn't pre-populate + * `blame.expression`. We never look up peer policies here — those + * rely on server-side enrichment. */ + policy?: AccessControlPolicy; + + /** User profile attribute snapshot used for evaluation + * (department, region, etc.). Rendered as an evaluation-trace + * block above the per-action list. Hidden when undefined. */ + userAttributes?: Record; + + /** Session-attribute snapshot for the active session whose chip + * opened this modal. Rendered alongside userAttributes. Hidden + * when undefined. */ + sessionAttributes?: Record; +}; + +/** + * Stacked sub-modal that breaks a row's aggregate chip down to the + * per-permission decisions and surfaces an evaluation trace for each + * deny: rule name + CEL expression + the attribute values that fed the + * evaluation. Mounted with `isStacked={true}` so it sits above the main + * SimulateAccessModal without dismounting it. Read-only — decisions are + * passed in by the picker; the modal doesn't re-dispatch the simulator. + * + * Privacy boundary: the failing rule's expression is only rendered for + * blame entries at the draft's own scope (`this_rule`, `sibling_rule`, + * `sibling_saved`, `peer_policy`). Truly upper-scoped sources + * (`system_permission`, `channel_policy`) render the chip alone — the + * server intentionally omits their expression to avoid leaking the + * contents of an out-of-scope policy. + */ +export default function DecisionDetailsModal({ + onExited, + user, + actions, + actionLabels, + decisions, + pending, + policy, + userAttributes, + sessionAttributes, +}: Props): JSX.Element { + const {formatMessage} = useIntl(); + + // Local visibility state so the close click runs the exit + // transition before the parent unmounts us. Without this the X + // click fires `onExited` directly → parent flips its + // `showDetails` flag → we unmount instantly → React-Bootstrap + // never plays the modal's slide-out and the parent backdrop + // briefly flickers as the stacked-modal counter resets. With + // local state, `onHide` only flags us as hidden; the transition + // plays, then GenericModal calls the real `onExited` and the + // parent removes us cleanly. + const [show, setShow] = useState(true); + const handleHide = useCallback(() => setShow(false), []); + + // Narrow into a defined-or-undefined local so the JSX below doesn't + // need a non-null assertion — checking `userAttributes` indirectly + // through `hasUserAttrs` would lose the narrowing. + const userAttrs = userAttributes && Object.keys(userAttributes).length > 0 ? userAttributes : undefined; + const sessionAttrs = sessionAttributes && Object.keys(sessionAttributes).length > 0 ? sessionAttributes : undefined; + + return ( + + } + > +
+ {/* ProfilePicture with userId opens the standard profile + * popover on click — matches the affordance from the + * picker row so authors can drill into attribute/role + * details from this drilled-down view too. */} +
+ +
+
+ + {displayUsername(user, 'full_name') || user.username} + + {`@${user.username}`} +
+
+ + {(userAttrs || sessionAttrs) ? ( +
+ {userAttrs ? ( + + } + values={userAttrs} + testId='simulate-access-details-user-attributes' + /> + ) : null} + {sessionAttrs ? ( + + } + values={sessionAttrs} + testId='simulate-access-details-session-attributes' + /> + ) : null} +
+ ) : null} + +
    + {actions.map((action) => { + const dec = decisions?.[action]; + const traces = !pending && dec ? deriveTraces(dec, action, policy) : []; + return ( + + ); + })} +
+
+ ); +} + +type ActionDetailsRowProps = { + action: string; + label: string; + decision: PolicySimulationActionDecision | undefined; + pending: boolean; + traces: DenyTrace[]; +}; + +/** + * One row in the modal's per-action list: action label + decision + * chip on the always-visible header, with the rule + expression / + * evaluation-tree details collapsed behind a disclosure toggle. The + * details start hidden so the modal opens with a tight summary view + * and the author opts in to the deeper trace per-action; this avoids + * scrolling through long CEL expressions or deeply nested trees by + * default when a row is uninteresting (e.g. an allow). + */ +function ActionDetailsRow({action, label, decision, pending, traces}: ActionDetailsRowProps): JSX.Element { + const [showDetails, setShowDetails] = useState(false); + + return ( +
  • +
    + + {label} + + +
    + {traces.length > 0 ? ( + <> +