diff --git a/.cursor/README.md b/.cursor/README.md
index 93596cb5686..192cc60c907 100644
--- a/.cursor/README.md
+++ b/.cursor/README.md
@@ -17,7 +17,7 @@ The Docker build context is `.cursor/` only. The Dockerfile intentionally does n
## Runtime Hooks
- `cloud-agent-install.sh` runs after Cursor checks out the repo. It refreshes nvm, installs agent-browser browsers, verifies Cursor's multi-repo `mattermost/enterprise` checkout, runs `server` Go dependency hydration, installs webapp dependencies, and runs Playwright `npm ci`.
-- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, then starts Docker and waits until `docker info` and `docker compose version` succeed.
+- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, starts Docker, waits until `docker info` and `docker compose version` succeed, then logs in to Docker Hub when credentials are configured.
The environment declares `github.com/mattermost/enterprise` in `repositoryDependencies` so Cursor can provide it as part of the multi-repo workspace. Cursor currently clones the repositories as siblings, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, which matches `server/Makefile`'s default `../../enterprise` path. The install hook does not clone, pull, or symlink enterprise.
@@ -33,4 +33,7 @@ Set these environment variables to `true` to shorten startup for narrow tasks:
## Expected Secrets
-- AWS uploads use the standard AWS CLI environment variables provided to the Cloud Agent: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
+Configure these in the [Cursor Cloud Agents dashboard](https://cursor.com/dashboard/cloud-agents) as environment-scoped secrets for the Mattermost Cloud Agent environment.
+
+- AWS uploads use the standard AWS CLI environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
+- Docker Hub pulls use the same variable names as CI: `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. The start hook runs `docker login` after `dockerd` is ready. Mark `DOCKERHUB_TOKEN` as **redacted** in the dashboard. When both are set, agents can pull the full default `make start-docker` image set without hitting anonymous rate limits.
diff --git a/.cursor/cursor.md b/.cursor/cursor.md
index 895f1f0db19..e9fa3332185 100644
--- a/.cursor/cursor.md
+++ b/.cursor/cursor.md
@@ -58,7 +58,8 @@ The Mattermost server is expected at `http://localhost:8065`. The webapp dev ser
```
- When the server starts and `MM_LICENSE` is present in the environment, the server applies that license automatically. If `MM_LICENSE` is not set, starting the server automatically applies an Entry license, which provides nearly all functionality needed for development.
-- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. This is useful in Cloud when Docker Hub rate limits block the default `make start-docker` dependency set.
+- When `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured as Cloud Agent secrets, `cloud-agent-start.sh` logs in to Docker Hub and the full default `make start-docker` dependency set can be used without trimming services.
+- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. Use this fallback when Docker Hub credentials are unavailable and anonymous pulls hit rate limits.
- If the first-user signup UI is flaky but the server is already healthy, seed local state with `mmctl` and then log in through the browser:
```bash
diff --git a/.cursor/scripts/cloud-agent-start.sh b/.cursor/scripts/cloud-agent-start.sh
index 268888e5d85..f1478521bf0 100755
--- a/.cursor/scripts/cloud-agent-start.sh
+++ b/.cursor/scripts/cloud-agent-start.sh
@@ -34,6 +34,21 @@ ensure_docker_socket_access() {
fi
}
+docker_login_if_configured() {
+ if [ -z "${DOCKERHUB_USERNAME:-}" ] || [ -z "${DOCKERHUB_TOKEN:-}" ]; then
+ log "Docker Hub credentials not configured; anonymous pulls may hit rate limits."
+ return 0
+ fi
+
+ log "Logging in to Docker Hub as ${DOCKERHUB_USERNAME}."
+ if echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin >/tmp/docker-login.log 2>&1; then
+ log "Docker Hub login succeeded."
+ else
+ log "Docker Hub login failed; see /tmp/docker-login.log."
+ tail -n 20 /tmp/docker-login.log >&2 || true
+ fi
+}
+
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null 2>&1 || \
log "Could not relax AppArmor user namespace restriction; openldap-based tests may need a larger host profile."
@@ -44,6 +59,7 @@ ensure_docker_socket_access
if docker info >/dev/null 2>&1; then
log "Docker is already running."
docker compose version
+ docker_login_if_configured
exit 0
fi
@@ -64,6 +80,7 @@ for _ in {1..60}; do
log "Docker is ready."
docker version
docker compose version
+ docker_login_if_configured
exit 0
fi
diff --git a/.github/e2e-tests-workflows.md b/.github/e2e-tests-workflows.md
index b6c6a6577fe..95660924dfd 100644
--- a/.github/e2e-tests-workflows.md
+++ b/.github/e2e-tests-workflows.md
@@ -16,25 +16,119 @@ All pipelines follow the **smoke-then-full** pattern: smoke tests run first, ful
```
.github/workflows/
-├── e2e-tests-ci.yml # PR orchestrator
-├── e2e-tests-on-merge.yml # Merge orchestrator (master/release branches)
-├── e2e-tests-on-release.yml # Release cut orchestrator
-├── e2e-tests-cypress.yml # Shared wrapper: cypress smoke -> full
-├── e2e-tests-playwright.yml # Shared wrapper: playwright smoke -> full
-├── e2e-tests-cypress-template.yml # Template: actual cypress test execution
-└── e2e-tests-playwright-template.yml # Template: actual playwright test execution
+├── e2e-tests-ci.yml # PR orchestrator
+├── e2e-tests-on-merge.yml # Merge orchestrator (master/release branches)
+├── e2e-tests-on-release.yml # Release cut orchestrator
+├── e2e-tests-cypress.yml # Shared wrapper: routes to v1 or v2 template
+├── e2e-tests-playwright.yml # Shared wrapper: routes to v1 or v2 template
+├── e2e-tests-cypress-template-v2.yml # Active: cypress + test-system-io dispatch
+├── e2e-tests-playwright-template-v2.yml # Active: playwright + test-system-io dispatch
+├── e2e-tests-cypress-template.yml # Deprecated v1 (legacy in-job execution)
+└── e2e-tests-playwright-template.yml # Deprecated v1 (legacy in-job execution)
```
+> **v1 templates are deprecated.** They remain available behind a feature flag during cutover but receive no further changes. New work targets the v2 templates exclusively. The wrappers route by `vars.E2E_USE_TEST_IO_DISPATCH` — `'true'` selects v2, anything else falls back to v1.
+
### Call hierarchy
```
e2e-tests-ci.yml ─────────────────┐
-e2e-tests-on-merge.yml ───────────┤──► e2e-tests-cypress.yml ──► e2e-tests-cypress-template.yml
-e2e-tests-on-release.yml ─────────┘ e2e-tests-playwright.yml ──► e2e-tests-playwright-template.yml
+e2e-tests-on-merge.yml ───────────┤──► e2e-tests-cypress.yml ─────┐
+e2e-tests-on-release.yml ─────────┘ e2e-tests-playwright.yml ──┤
+ │
+ ┌──────────────────────────┘
+ │ routes on E2E_USE_TEST_IO_DISPATCH
+ ▼
+ v2 (active) ──► e2e-tests-{cypress,playwright}-template-v2.yml
+ v1 (legacy) ──► e2e-tests-{cypress,playwright}-template.yml
```
---
+## Workflow Architecture (v2)
+
+v2 splits the template into five jobs — `prepare-run`, `prep-deps`, `dispatch-begin`, `workers` (matrix), and `report` — and pushes spec-level execution to [Test System IO](https://github.com/mattermost/mattermost-test-system-io) so workers stay thin and identical.
+
+```
+┌──────────────────────────────────────────────────────────────────────────┐
+│ Template v2: e2e-tests-{cypress,playwright}-template-v2.yml │
+│ │
+│ ┌───────────────────┐ ┌──────────────────────────────┐ │
+│ │ prepare-run │ │ prep-deps │ │
+│ │ (1 runner) │ parallel │ (1 runner) │ │
+│ │ │ ◄────────────► │ │ │
+│ │ • build workers │ │ Cypress: │ │
+│ │ matrix [1..N] │ │ • cypress/node_modules │ │
+│ │ • compute commit │ │ • ~/.cache/Cypress (binary)│ │
+│ │ status context │ │ │ │
+│ │ • emit composite │ │ Playwright: │ │
+│ │ identity │ │ • webapp/platform/{client, │ │
+│ │ │ │ types}/{lib,node_mod} │ │
+│ │ │ │ • playwright/node_modules │ │
+│ │ │ │ • playwright/lib/dist │ │
+│ │ │ │ • ~/.cache/ms-playwright │ │
+│ │ │ │ (chromium only) │ │
+│ │ │ │ │ │
+│ │ │ │ → saved to actions/cache │ │
+│ └─────────┬─────────┘ └───────────────┬──────────────┘ │
+│ │ │ │
+│ │ ▼ │
+│ │ ┌──────────────────────────────┐ │
+│ │ │ dispatch-begin │ │
+│ │ │ • register run with │ │
+│ │ │ Test System IO │ │
+│ │ │ • runs immediately before │ │
+│ │ │ workers to minimise the │ │
+│ │ │ inactivity-timeout window │ │
+│ │ └───────────────┬──────────────┘ │
+│ │ │ │
+│ └────────────────────┬─────────────────────┘ │
+│ ▼ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ workers (matrix, fail-fast: false) │ │
+│ │ Cypress full: N=40 | Playwright full: N=10 │ │
+│ │ │ │
+│ │ each worker, in parallel: │ │
+│ │ 1. sparse-checkout actions + full checkout-repo │ │
+│ │ 2. setup-node │ │
+│ │ 3. restore caches ◄─── actions/cache (from prep-deps) │ │
+│ │ (fail-on-cache-miss: true) │ │
+│ │ 4. cloud-init + start-server (docker compose stack) │ │
+│ │ 5. prepare-cypress | prepare-playwright (run setup project) │ │
+│ │ 6. dispatch-run ──────────────────────────────────┐ │ │
+│ │ (pulls specs from Test System IO, runs locally, │ │ │
+│ │ posts result, loops until queue is empty) │ │ │
+│ │ 7. cloud-teardown │ │ │
+│ └────────────────────┬────────────────────────────────────┼───────┘ │
+│ │ │ │
+│ ▼ │ │
+│ ┌─────────────────────────────────────────────────┐ │ │
+│ │ report │ │ │
+│ │ • pull aggregated results from Test System IO │ ◄────┘ │
+│ │ • post commit status │ │
+│ │ • send webhook notification │ │
+│ └─────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────────────────┬─┘
+ │
+ ┌───────────────────────────▼───┐
+ │ Test System IO (external) │
+ │ • spec-level dispatch │
+ │ • result aggregation │
+ │ • retry orchestration │
+ └───────────────────────────────┘
+```
+
+### Key properties
+
+- **Spec-level vs. job-level parallelism.** The matrix sizes the runner pool; Test System IO does the spec assignment. Slow specs don't block a worker — fast workers keep pulling the next spec from the queue.
+- **Cache-only workers.** `prep-deps` installs once per workflow run and saves to `actions/cache`. Every worker restores with `fail-on-cache-miss: true` and runs zero `npm ci`. Eliminates the 40-way `EEXIST/ENOENT` race in npm's shared cacache writer.
+- **dispatch-begin runs late.** It depends on `prep-deps` so the gap between Test System IO run registration and the first worker calling `dispatch-run` is just per-worker setup (~3–5 min). Registering earlier risks the run timing out before any worker checks in, bulk-failing every spec.
+- **Playwright slim slice.** Playwright only consumes `@mattermost/client` and `@mattermost/types` from webapp, so prep-deps caches just those two packages' built `lib/` and `node_modules` (~10–30 MB) instead of the full `webapp/node_modules` tree (~1–2 GB).
+- **Browser/binary caches.** Cypress caches `~/.cache/Cypress` (cypress binary lives outside node_modules); playwright caches `~/.cache/ms-playwright` (chromium only). Both keyed on the framework's lockfile so they invalidate on version bumps.
+- **No retry plumbing in the template.** Test System IO handles per-spec retries; the workflow only sees aggregated results.
+
+---
+
## Pipeline 1: PR (`e2e-tests-ci.yml`)
Runs E2E tests for every PR commit after the enterprise docker image is built. Fails if the commit is not associated with an open PR.
diff --git a/.github/scripts/check_config_changes_ci.py b/.github/scripts/check_config_changes_ci.py
new file mode 100644
index 00000000000..ad3f3ce3e15
--- /dev/null
+++ b/.github/scripts/check_config_changes_ci.py
@@ -0,0 +1,609 @@
+#!/usr/bin/env python3
+"""
+.github/scripts/check_config_changes_ci.py
+
+CI script that detects notable changes across several Mattermost source files
+and appends structured release-note entries to the PR description.
+
+Checkers
+────────
+1. config.go — exported struct field additions/removals
+2. api4/ — API endpoint additions/removals (Handle() calls)
+3. audit_events.go — AuditEvent* constant additions/removals
+4. Dockerfile.buildenv — Go (base-image) version changes
+
+All inputs come from environment variables set by the GitHub Actions workflow:
+ GITHUB_TOKEN — built-in Actions token (pull-requests: write scope)
+ PR_NUMBER — pull request number
+ BASE_SHA — base commit SHA
+ HEAD_SHA — head commit SHA
+ REPO — owner/repo (e.g. mattermost/mattermost)
+"""
+
+import os
+import re
+import sys
+import subprocess
+import requests
+from dataclasses import dataclass, field
+from typing import Optional
+
+# ── Environment ────────────────────────────────────────────────────────────────
+
+GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
+PR_NUMBER = int(os.environ["PR_NUMBER"])
+BASE_SHA = os.environ["BASE_SHA"]
+HEAD_SHA = os.environ["HEAD_SHA"]
+REPO = os.environ.get("REPO", "mattermost/mattermost")
+
+BASE_URL = "https://api.github.com"
+HEADERS = {
+ "Authorization": f"token {GITHUB_TOKEN}",
+ "Accept": "application/vnd.github.v3+json",
+}
+
+# Timeout for all GitHub API requests: (connect seconds, read seconds).
+# Prevents the workflow from hanging indefinitely on a slow/unresponsive API.
+_TIMEOUT = (5, 30)
+
+# Paths watched by this script (must align with `paths:` in the workflow YAML)
+WATCHED_PATHS = [
+ "server/public/model/config.go",
+ "server/channels/api4/",
+ "server/public/model/audit_events.go",
+ "server/build/Dockerfile.buildenv",
+]
+
+
+# ── Data types ─────────────────────────────────────────────────────────────────
+
+@dataclass
+class CheckResult:
+ """Holds the findings from one checker."""
+ label: str # Section heading, e.g. "`config.json` Changes"
+ additions: list = field(default_factory=list)
+ removals: list = field(default_factory=list)
+ changes: list = field(default_factory=list) # for free-form entries (version bumps)
+
+ def has_findings(self) -> bool:
+ return bool(self.additions or self.removals or self.changes)
+
+ def to_markdown(self) -> str:
+ lines = [f"### {self.label}"]
+ if self.additions:
+ lines.append("**Added:** " + ", ".join(self.additions))
+ if self.removals:
+ lines.append("**Removed:** " + ", ".join(self.removals))
+ for change in self.changes:
+ lines.append(change)
+ return "\n".join(lines)
+
+
+# ── Diff helpers ───────────────────────────────────────────────────────────────
+
+def get_full_patch() -> str:
+ """Return unified diff for all watched paths between base and head."""
+ result = subprocess.run(
+ ["git", "diff", f"{BASE_SHA}...{HEAD_SHA}", "--"] + WATCHED_PATHS,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return result.stdout
+
+
+def split_patch_by_file(full_patch: str) -> dict[str, str]:
+ """
+ Split a multi-file unified diff into {filename: patch} mapping.
+ Filenames are the b-side (new) path, stripped of the 'b/' prefix.
+ """
+ patches: dict[str, str] = {}
+ current_file: Optional[str] = None
+ current_lines: list[str] = []
+
+ for line in full_patch.splitlines(keepends=True):
+ if line.startswith("diff --git "):
+ if current_file:
+ patches[current_file] = "".join(current_lines)
+ current_lines = [line]
+ # Extract filename from "diff --git a/foo b/foo"
+ m = re.search(r" b/(.+)$", line.rstrip())
+ current_file = m.group(1) if m else None
+ else:
+ current_lines.append(line)
+
+ if current_file:
+ patches[current_file] = "".join(current_lines)
+
+ return patches
+
+
+def file_at(ref: str, path: str) -> str:
+ """Return the full contents of `path` at git ref `ref`, or '' if absent."""
+ try:
+ return subprocess.run(
+ ["git", "show", f"{ref}:{path}"],
+ capture_output=True, text=True, check=True,
+ ).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 ──────────────────────────────────────────────────────
+
+_CONFIG_PATH = "server/public/model/config.go"
+_STRUCT_DECL_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{")
+_FIELD_LINE_RE = re.compile(r"^\t([A-Z][A-Za-z0-9_]*)\s+\S")
+
+
+def _scan_struct_fields(src: str) -> set[tuple[str, str]]:
+ """
+ Walk Go source and return {(StructName, FieldName)} for every exported
+ field in every struct.
+
+ Uses a brace-depth stack so nested anonymous structs, interface bodies,
+ and function literals don't corrupt the enclosing struct context.
+ Named type declarations cannot be nested in Go, so the struct_stack
+ never grows beyond one entry for named structs.
+ """
+ fields: set[tuple[str, str]] = set()
+ # Each entry: (struct_name, brace_depth_when_opened)
+ struct_stack: list[tuple[str, int]] = []
+ depth = 0
+
+ for line in src.splitlines():
+ sm = _STRUCT_DECL_RE.match(line)
+ if sm:
+ # Record depth *before* counting this line's braces
+ struct_stack.append((sm.group(1), depth))
+
+ depth += line.count("{") - line.count("}")
+
+ # Pop any structs whose closing brace has been passed
+ while struct_stack and depth <= struct_stack[-1][1]:
+ struct_stack.pop()
+
+ # Record fields only when we're directly inside exactly one named struct
+ if len(struct_stack) == 1:
+ fm = _FIELD_LINE_RE.match(line)
+ if fm:
+ fields.add((struct_stack[0][0], fm.group(1)))
+
+ return fields
+
+
+def check_config(patches: dict[str, str]) -> CheckResult:
+ """
+ Detect exported Go struct field additions/removals in config.go.
+
+ Compares full-file snapshots at BASE_SHA and HEAD_SHA so that fields
+ are always attributed to the correct struct regardless of which diff
+ hunks are present.
+ """
+ result = CheckResult(label="`config.json` Field Changes")
+ if _CONFIG_PATH not in patches:
+ return result
+
+ 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
+ removed = base_fields - head_fields
+
+ result.additions = sorted(f"`{s}.{f}`" for s, f in added)
+ result.removals = sorted(f"`{s}.{f}`" for s, f in removed)
+ return result
+
+
+# ── Checker 2 — api4/ ─────────────────────────────────────────────────────────
+
+# Matches Handle() route registrations after whitespace-collapsing the source.
+# Whitespace collapse makes multi-line declarations single-searchable.
+# Group 1: path Group 2: handler func Group 3: raw Methods(...) content
+#
+# The wrapper pattern uses [^)]* so it tolerates any middleware arguments
+# (e.g. r.APIHandler(...), r.ApiSessionRequired(..., isLocal=true), etc.)
+# without having to enumerate every possible wrapper signature.
+_HANDLE_RE = re.compile(
+ r'\.Handle\("([^"]*)"' # path
+ r',\s*[^)]*\((\w+)\)\)' # wrapper(...handlerFunc))
+ r'\.Methods\(([^)]+)\)', # .Methods(one or more methods)
+)
+
+_METHOD_RE = re.compile(r'(?:http\.Method)?(\w+)')
+_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
+
+
+def _parse_methods(raw: str) -> list[str]:
+ """Split raw Methods(...) content into individual uppercase HTTP verbs.
+
+ Filters against the set of known HTTP methods so that incidental
+ identifiers (handler names, constants, etc.) that happen to appear
+ inside Methods(...) don't produce spurious results.
+ """
+ return [
+ verb
+ for token in raw.split(",")
+ if (m := _METHOD_RE.search(token.strip()))
+ and (verb := m.group(1).upper()) in _HTTP_METHODS
+ ]
+
+
+def _format_endpoint(path: str, handler: str, method: str) -> str:
+ return f"`{method.upper()} {path or '/'}` (`{handler}`)"
+
+
+def _parse_endpoints(src: str) -> set[tuple[str, str, str]]:
+ """
+ Parse Handle() registrations from a Go source file.
+
+ Whitespace-collapses the entire file first so multi-line declarations
+ (e.g. the 18 in group.go) are matched as a single token sequence.
+ Returns {(path, handler, method)} tuples.
+ """
+ blob = " ".join(src.split())
+ endpoints: set[tuple[str, str, str]] = set()
+ for m in _HANDLE_RE.finditer(blob):
+ path, handler, methods_raw = m.group(1), m.group(2), m.group(3)
+ for method in _parse_methods(methods_raw):
+ endpoints.add((path or "/", handler, method))
+ return endpoints
+
+
+def check_api(patches: dict[str, str]) -> CheckResult:
+ """
+ Detect API endpoint additions/removals in the api4/ directory.
+
+ Compares full-file snapshots at BASE_SHA and HEAD_SHA via set arithmetic,
+ so multi-line and multi-method registrations are handled correctly.
+ """
+ result = CheckResult(label="API Changes (`api4`)")
+
+ api4_patches = {
+ fname: patch
+ for fname, patch in patches.items()
+ if fname.startswith("server/channels/api4/") and fname.endswith(".go")
+ }
+ if not api4_patches:
+ return result
+
+ added_eps: set[tuple[str, str, str]] = set()
+ removed_eps: set[tuple[str, str, str]] = set()
+
+ for fname, patch in api4_patches.items():
+ 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
+
+ # Anchor the check to avoid false positives from unrelated source text
+ if re.search(r"^new file mode \d+", patch, re.MULTILINE):
+ result.changes.append(f"🆕 New API file: `{fname.split('/')[-1]}`")
+ if re.search(r"^deleted file mode \d+", patch, re.MULTILINE):
+ result.changes.append(f"🗑️ Removed API file: `{fname.split('/')[-1]}`")
+
+ result.additions = sorted(_format_endpoint(p, h, m) for p, h, m in added_eps)
+ result.removals = sorted(_format_endpoint(p, h, m) for p, h, m in removed_eps)
+ return result
+
+
+# ── Checker 3 — audit_events.go ───────────────────────────────────────────────
+
+_AUDIT_EVENT_PATH = "server/public/model/audit_events.go"
+_AUDIT_CONST_RE = re.compile(r"^\t(AuditEvent\w+)\s*=")
+
+
+def _parse_audit_events(src: str) -> set[str]:
+ return {m.group(1) for line in src.splitlines() if (m := _AUDIT_CONST_RE.match(line))}
+
+
+def check_audit_events(patches: dict[str, str]) -> CheckResult:
+ """
+ Detect AuditEvent* constant additions/removals.
+
+ Uses full-file snapshots at BASE_SHA/HEAD_SHA so reorderings and
+ cross-constant name collisions don't produce false results.
+ """
+ result = CheckResult(label="Audit Log Event Changes")
+ if _AUDIT_EVENT_PATH not in patches:
+ return result
+
+ 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)
+ result.removals = sorted(f"`{e}`" for e in base_events - head_events)
+ return result
+
+
+# ── Checker 4 — Dockerfile.buildenv (Go version) ──────────────────────────────
+
+# The Go version lives in the base image tag, e.g.:
+# FROM mattermost/golang-bullseye:1.25.8@sha256:...
+_DOCKERFILE_PATH = "server/build/Dockerfile.buildenv"
+_IMAGE_VER_RE = re.compile(r"^FROM \S+:([0-9]+\.[0-9]+(?:\.[0-9]+)?)")
+
+
+def _parse_go_version(src: str) -> Optional[str]:
+ for line in src.splitlines():
+ m = _IMAGE_VER_RE.match(line.strip())
+ if m:
+ return m.group(1)
+ return None
+
+
+def check_go_version(patches: dict[str, str]) -> CheckResult:
+ """
+ Detect Go runtime version changes via the base image tag.
+
+ Uses full-file snapshots so the version is read from the actual file
+ state at each ref rather than reconstructed from patch lines.
+ """
+ result = CheckResult(label="Go Runtime Version")
+ if _DOCKERFILE_PATH not in patches:
+ return result
+
+ 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:
+ result.changes.append(f"Go updated: `{old_ver}` → `{new_ver}`")
+ elif new_ver and not old_ver:
+ result.additions.append(f"`{new_ver}`")
+ return result
+
+
+# ── PR description helpers ─────────────────────────────────────────────────────
+
+# Matches lines that were auto-generated by this script so they can be stripped
+# before re-injecting a fresh set on subsequent commits.
+_AUTO_LINE_RE = re.compile(
+ r"^(Added|Removed) `[^`]+`.*(configuration setting|API endpoint|audit log event)\."
+ r"|^Go runtime updated from \S+ to \S+\."
+ r"|^Go runtime set to `[^`]+`\."
+ r"|^🆕 New API file:"
+ r"|^🗑️ Removed API file:"
+)
+
+# Matches placeholder content inside a release-note fence that means "nothing
+# to report yet" (e.g. NONE, N/A, ---). When detected, we replace the
+# placeholder rather than appending alongside it.
+_PLACEHOLDER_RE = re.compile(r"^\s*(?:NONE|N/?A|-+)\s*$", re.IGNORECASE)
+
+
+def _format_lines(result: CheckResult) -> list[str]:
+ """Produce natural-language lines for one checker result."""
+ lines = []
+
+ if "`config.json`" in result.label:
+ for item in result.additions:
+ lines.append(f"Added {item} configuration setting.")
+ for item in result.removals:
+ lines.append(f"Removed {item} configuration setting.")
+
+ elif "API Changes" in result.label:
+ for item in result.additions:
+ lines.append(f"Added {item} API endpoint.")
+ for item in result.removals:
+ lines.append(f"Removed {item} API endpoint.")
+ lines.extend(result.changes) # new/deleted file entries
+
+ elif "Audit" in result.label:
+ for item in result.additions:
+ lines.append(f"Added {item} audit log event.")
+ for item in result.removals:
+ lines.append(f"Removed {item} audit log event.")
+
+ elif "Go Runtime" in result.label:
+ for item in result.additions:
+ # item is e.g. "`1.22`" — strip backticks for the prose form
+ lines.append(f"Go runtime set to {item}.")
+ for c in result.changes:
+ # c arrives as "Go updated: `1.21` → `1.22`" — rewrite it
+ m = re.search(r"`([^`]+)`\s*→\s*`([^`]+)`", c)
+ if m:
+ lines.append(f"Go runtime updated from {m.group(1)} to {m.group(2)}.")
+ else:
+ lines.append(c)
+
+ return lines
+
+
+def build_pr_note(results: list[CheckResult]) -> str:
+ """Assemble all findings into a clean plain-text block."""
+ lines = []
+ for r in results:
+ if r.has_findings():
+ lines.extend(_format_lines(r))
+ return "\n".join(lines)
+
+
+def strip_old_note(body: str) -> str:
+ """
+ Remove previously auto-generated lines from the PR description.
+
+ Primary path — lines inside the ```release-note ... ``` fence.
+ Fallback path — auto-generated lines that were appended outside any fence
+ (e.g. via the ## Release Notes section on earlier runs).
+
+ Lines are identified by pattern rather than visible markers, so the PR
+ description stays clean for human readers.
+ """
+ def _clean_fence(m: re.Match) -> str:
+ open_tag, content, close_tag = m.group(1), m.group(2), m.group(3)
+ cleaned_lines = [
+ line for line in content.split("\n")
+ if not _AUTO_LINE_RE.match(line.strip())
+ ]
+ return open_tag + "\n".join(cleaned_lines) + close_tag
+
+ cleaned = re.sub(
+ r"(```release-note)(.*?)(```)",
+ _clean_fence,
+ body or "",
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+
+ # Fallback: strip any auto-generated lines that appear outside a fence
+ # (written by an older version of this script or via the header-inject path).
+ cleaned_lines = [
+ line for line in cleaned.splitlines()
+ if not _AUTO_LINE_RE.match(line.strip())
+ ]
+ return "\n".join(cleaned_lines).rstrip()
+
+
+def inject_note(body: str, note: str) -> str:
+ """
+ Insert `note` using this priority order:
+
+ 1. INSIDE the ```release-note block, before its closing ```
+ (Mattermost convention — keeps everything in one place for reviewers)
+ 2. After a recognised release-notes section header (## Release Notes, etc.)
+ 3. Fallback: append a new ## Release Notes section at the end
+ """
+ body = strip_old_note(body)
+ if not note:
+ return body
+
+ # 1. Mattermost-style ```release-note ... ``` block — inject INSIDE the fence.
+ # If the fence currently contains only a placeholder (NONE / N/A / ---),
+ # replace the placeholder rather than appending alongside it.
+ release_note_block = re.search(
+ r"(```release-note)(.*?)(```)",
+ body,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ if release_note_block:
+ open_tag = release_note_block.group(1)
+ content = release_note_block.group(2) # everything between the fences
+ close_tag = release_note_block.group(3)
+ block_start = release_note_block.start()
+ block_end = release_note_block.end()
+
+ # Strip leading/trailing newlines inside the fence for comparison
+ inner = content.strip()
+ if _PLACEHOLDER_RE.match(inner):
+ # Replace the entire fence with a fresh one
+ new_block = f"{open_tag}\n{note}\n{close_tag}"
+ else:
+ # Append before the closing fence
+ new_block = open_tag + content + note + "\n" + close_tag
+
+ return body[:block_start] + new_block + body[block_end:]
+
+ # 2. Markdown section headers
+ for header in ["## Release Notes", "## Changelog", "## What Changed", "## What's Changed"]:
+ if header.lower() in body.lower():
+ idx = body.lower().index(header.lower()) + len(header)
+ return body[:idx] + "\n\n" + note + body[idx:]
+
+ # 3. Fallback — append
+ return body + "\n\n## Release Notes\n\n" + note
+
+
+# ── GitHub API ─────────────────────────────────────────────────────────────────
+
+def get_pr_body() -> str:
+ r = requests.get(
+ f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
+ headers=HEADERS,
+ timeout=_TIMEOUT,
+ )
+ r.raise_for_status()
+ return r.json().get("body") or ""
+
+
+def update_pr_body(new_body: str) -> None:
+ r = requests.patch(
+ f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
+ headers=HEADERS,
+ json={"body": new_body},
+ timeout=_TIMEOUT,
+ )
+ r.raise_for_status()
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
+
+def main():
+ print(f"📋 PR #{PR_NUMBER} | base {BASE_SHA[:8]} → head {HEAD_SHA[:8]}")
+ print("🔍 Collecting diffs …")
+
+ full_patch = get_full_patch()
+ if not full_patch.strip():
+ print("ℹ️ No changes in watched paths. Nothing to do.")
+ return
+
+ patches = split_patch_by_file(full_patch)
+ print(f" {len(patches)} file(s) changed in watched paths.\n")
+
+ # Run all checkers
+ checkers = [
+ check_config,
+ check_api,
+ check_audit_events,
+ check_go_version,
+ ]
+ results: list[CheckResult] = [fn(patches) for fn in checkers]
+
+ for r in results:
+ if r.has_findings():
+ print(f" ✅ {r.label}")
+ if r.additions:
+ print(f" Added: {', '.join(r.additions)}")
+ if r.removals:
+ print(f" Removed: {', '.join(r.removals)}")
+ for c in r.changes:
+ print(f" {c}")
+ else:
+ print(f" – {r.label}: no changes")
+
+ note = build_pr_note(results)
+ if not note:
+ print("\nℹ️ No notable changes found across all checkers.")
+ return
+
+ print("\n🔄 Fetching PR description …")
+ body = get_pr_body()
+ new_body = inject_note(body, note)
+
+ if new_body == body:
+ print("ℹ️ PR description already up to date — no changes needed.")
+ return
+
+ update_pr_body(new_body)
+ print(f"✅ PR #{PR_NUMBER} description updated.")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except subprocess.CalledProcessError as e:
+ print(f"❌ git diff failed:\n{e.stderr}", file=sys.stderr)
+ sys.exit(1)
+ except requests.HTTPError as e:
+ # Avoid dumping the full response body (can be large / noisy).
+ # status + reason gives enough context for debugging (e.g. "403 Forbidden").
+ reason = e.response.reason or "unknown"
+ print(f"❌ GitHub API error: {e.response.status_code} {reason}", file=sys.stderr)
+ sys.exit(1)
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/build-server-image.yml b/.github/workflows/build-server-image.yml
index 8a0fdcd6d8d..0293651cfc7 100644
--- a/.github/workflows/build-server-image.yml
+++ b/.github/workflows/build-server-image.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- master
+ - release-*
paths:
- server/build/Dockerfile.buildenv
- server/build/Dockerfile.buildenv-fips
@@ -57,7 +58,7 @@ jobs:
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
- if: github.ref == 'refs/heads/master'
+ if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
provenance: false
@@ -103,7 +104,7 @@ jobs:
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
- if: github.ref == 'refs/heads/master'
+ if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
provenance: false
diff --git a/.github/workflows/config-change-checker.yml b/.github/workflows/config-change-checker.yml
new file mode 100644
index 00000000000..0772baaf0e6
--- /dev/null
+++ b/.github/workflows/config-change-checker.yml
@@ -0,0 +1,66 @@
+# .github/workflows/config-change-checker.yml
+#
+# Automatically detects notable additions/removals across four source files
+# and appends structured release-note entries to the PR description under
+# the "## Release Notes" section.
+#
+# Tracked files / directories:
+# • server/public/model/config.go — config struct field changes
+# • server/channels/api4/ — API endpoint additions/removals
+# • server/public/model/audit_events.go — audit log event constant changes
+# • server/build/Dockerfile.buildenv — Go runtime version changes
+#
+# No secrets needed — uses the built-in GITHUB_TOKEN.
+
+name: Config Change Checker
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'server/public/model/config.go'
+ - 'server/channels/api4/**'
+ - 'server/public/model/audit_events.go'
+ - 'server/build/Dockerfile.buildenv'
+
+# Cancel any in-progress run for the same PR when a new commit is pushed.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check-release-notes:
+ name: Detect release-note-worthy changes
+ runs-on: ubuntu-latest
+ # Skip bot-authored PRs (Dependabot, mattermost-bot, etc.) — they will
+ # not touch these paths intentionally and cannot receive description updates
+ # via GITHUB_TOKEN anyway (fork-like restrictions apply to most bots).
+ if: github.event.pull_request.user.type != 'Bot'
+
+ permissions:
+ pull-requests: write # needed to update the PR description
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ # Fetch enough history to diff against the base branch
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: pip install requests==2.32.3 --quiet
+
+ - name: Detect changes and update PR description
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ REPO: ${{ github.repository }}
+ run: python3 .github/scripts/check_config_changes_ci.py
diff --git a/.github/workflows/e2e-tests-check.yml b/.github/workflows/e2e-tests-check.yml
index 53b59b726f7..13cd9b45fca 100644
--- a/.github/workflows/e2e-tests-check.yml
+++ b/.github/workflows/e2e-tests-check.yml
@@ -25,6 +25,12 @@ jobs:
cache-dependency-path: |
e2e-tests/cypress/package-lock.json
e2e-tests/playwright/package-lock.json
+ webapp/package-lock.json
+ - name: ci/npm-cache-verify
+ # Heal any partial/dangling entries left in the restored ~/.npm cache
+ # before running `npm ci`. Avoids the intermittent EEXIST/ENOENT
+ # failures in npm's cacache writer.
+ run: npm cache verify
# Cypress check
- name: ci/cypress/npm-install
diff --git a/.github/workflows/e2e-tests-cypress-template-v2.yml b/.github/workflows/e2e-tests-cypress-template-v2.yml
index ef82a6a09c8..f08b7ec9f32 100644
--- a/.github/workflows/e2e-tests-cypress-template-v2.yml
+++ b/.github/workflows/e2e-tests-cypress-template-v2.yml
@@ -130,35 +130,17 @@ permissions:
contents: read
statuses: write
id-token: write
- pull-requests: write
env:
SERVER_IMAGE: "${{ inputs.server_image_repo }}/${{ inputs.server_edition == 'fips' && 'mattermost-enterprise-fips-edition' || inputs.server_edition == 'team' && 'mattermost-team-edition' || 'mattermost-enterprise-edition' }}:${{ inputs.server_image_tag }}"
jobs:
- update-initial-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- steps:
- - name: ci/set-initial-status
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: "tests running, image_tag:${{ inputs.server_image_tag }}${{ inputs.server_image_aliases && format(' ({0})', inputs.server_image_aliases) || '' }}"
- status: pending
-
- dispatch-begin:
+ prepare-run:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
- pull-requests: write
+ statuses: write
outputs:
composite-identity-json: ${{ steps.composite-identity.outputs.composite-identity-json }}
workers-matrix: ${{ steps.matrix.outputs.workers }}
@@ -211,13 +193,62 @@ jobs:
run: |
echo "workers=$(jq -nc --argjson n ${{ inputs.workers }} '[range(1; $n+1)]')" >> $GITHUB_OUTPUT
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
+
+ # Install cypress node_modules once, then workers restore from cache.
+ prep-deps:
+ name: prep-deps
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: ci/checkout-repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.commit_sha }}
+ fetch-depth: 1
+ - name: ci/setup-node
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version-file: ".nvmrc"
+ - name: ci/cache-cypress-deps
+ # node_modules + the cypress binary (downloaded to ~/.cache/Cypress by
+ # cypress's postinstall, not into node_modules). Both must be cached;
+ # otherwise workers see "cypress npm package installed but binary missing".
+ id: cache-cypress
+ uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ e2e-tests/cypress/node_modules
+ ~/.cache/Cypress
+ key: e2e-cypress-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/cypress/package-lock.json') }}
+ - name: ci/install-cypress-deps
+ if: steps.cache-cypress.outputs.cache-hit != 'true'
+ working-directory: e2e-tests/cypress
+ run: npm ci
+
+ # Register the Test System IO run AFTER prep-deps so workers reach
+ # dispatch-run within Test System IO's inactivity window.
+ dispatch-begin:
+ runs-on: ubuntu-24.04
+ needs: [prepare-run, prep-deps]
+ permissions:
+ contents: read
+ id-token: write
+ statuses: write
+ steps:
+ - name: ci/checkout-repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.commit_sha }}
+ fetch-depth: 1
- name: ci/dispatch-begin
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
framework: cypress
repo-dir: ${{ github.workspace }}
- composite-identity: ${{ steps.composite-identity.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
total-reports-expected: ${{ inputs.workers }}
retest-on-fail: ${{ inputs.retest_on_fail }}
cypress-stage: ${{ inputs.cypress_stage }}
@@ -226,25 +257,25 @@ jobs:
cypress-skip-on: ${{ inputs.cypress_skip_on }}
cypress-sort-first: ${{ inputs.cypress_sort_first }}
cypress-sort-last: ${{ inputs.cypress_sort_last }}
- post-pr-comment: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
- context-name: ${{ inputs.context_name }}
- server-image: ${{ env.SERVER_IMAGE }}
+ commit-status-context: ${{ inputs.context_name }}
+ image-tag: ${{ inputs.server_image_tag }}
+ image-aliases: ${{ inputs.server_image_aliases }}
workers:
name: dispatch-run-${{ matrix.worker_index }}
runs-on: ubuntu-24.04
timeout-minutes: 30
- needs: dispatch-begin
+ needs: [prepare-run, dispatch-begin]
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
- worker_index: ${{ fromJSON(needs.dispatch-begin.outputs.workers-matrix) }}
+ worker_index: ${{ fromJSON(needs.prepare-run.outputs.workers-matrix) }}
env:
- COMPOSITE_IDENTITY: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ COMPOSITE_IDENTITY: ${{ needs.prepare-run.outputs.composite-identity-json }}
SERVER: "${{ inputs.server }}"
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
ENABLED_DOCKER_SERVICES: "${{ inputs.enabled_docker_services }}"
@@ -278,29 +309,26 @@ jobs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
- cache: npm
- cache-dependency-path: "e2e-tests/cypress/package-lock.json"
- - name: ci/get-webapp-node-modules
- working-directory: webapp
- run: make node_modules
+ - name: ci/restore-cypress-deps
+ uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ e2e-tests/cypress/node_modules
+ ~/.cache/Cypress
+ key: e2e-cypress-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/cypress/package-lock.json') }}
+ fail-on-cache-miss: true
- name: ci/cloud-init
working-directory: e2e-tests
run: make cloud-init
- name: ci/start-server
working-directory: e2e-tests
run: make start-server
- # `npm ci` in the host context replaces the container-built native
- # binaries with host-built ones, since the dispatch adapter spawns
- # `npx cypress run` on the host.
- - name: ci/prepare-cypress
- working-directory: e2e-tests/cypress
- run: npm ci
- name: ci/dispatch-run
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
framework: cypress
- composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
repo-dir: ${{ github.workspace }}
artifacts-root: ${{ github.workspace }}/worker-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -324,12 +352,12 @@ jobs:
report:
runs-on: ubuntu-24.04
- needs: [dispatch-begin, workers]
+ needs: [prepare-run, dispatch-begin, workers]
if: always()
permissions:
contents: read
id-token: write
- pull-requests: write
+ statuses: write
outputs:
commit_status_description: ${{ steps.summary.outputs.commit_status_description }}
webhook_payload: ${{ steps.summary.outputs.webhook_payload }}
@@ -339,10 +367,10 @@ jobs:
- name: ci/run-summary
id: summary
continue-on-error: true
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
- composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
framework: cypress
report-type: ${{ inputs.report_type }}
image-tag: ${{ inputs.server_image_tag }}
@@ -350,8 +378,7 @@ jobs:
server-image: ${{ env.SERVER_IMAGE }}
pr-number: ${{ inputs.pr_number }}
ref-branch: ${{ inputs.ref_branch }}
- context-name: ${{ inputs.context_name }}
- post-pr-comment: 'true'
+ commit-status-context: ${{ inputs.context_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: ci/publish-webhook
if: inputs.enable_reporting && env.REPORT_WEBHOOK_URL != ''
@@ -365,45 +392,3 @@ jobs:
SUMMARY_OUTCOME: ${{ steps.summary.outcome }}
run: |
[ "$SUMMARY_OUTCOME" = "success" ]
-
- update-success-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- if: always() && needs.report.result == 'success'
- needs:
- - dispatch-begin
- - report
- steps:
- - uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: ${{ needs.report.outputs.commit_status_description }}
- status: success
- target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-
- update-failure-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- if: always() && needs.report.result != 'success'
- needs:
- - dispatch-begin
- - report
- steps:
- - uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: ${{ needs.report.outputs.commit_status_description }}
- status: failure
- target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
diff --git a/.github/workflows/e2e-tests-playwright-template-v2.yml b/.github/workflows/e2e-tests-playwright-template-v2.yml
index a008527473d..d2d1966209b 100644
--- a/.github/workflows/e2e-tests-playwright-template-v2.yml
+++ b/.github/workflows/e2e-tests-playwright-template-v2.yml
@@ -96,35 +96,17 @@ permissions:
contents: read
statuses: write
id-token: write
- pull-requests: write
env:
SERVER_IMAGE: "${{ inputs.server_image_repo }}/${{ inputs.server_edition == 'fips' && 'mattermost-enterprise-fips-edition' || inputs.server_edition == 'team' && 'mattermost-team-edition' || 'mattermost-enterprise-edition' }}:${{ inputs.server_image_tag }}"
jobs:
- update-initial-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- steps:
- - name: ci/set-initial-status
- uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: "tests running, image_tag:${{ inputs.server_image_tag }}${{ inputs.server_image_aliases && format(' ({0})', inputs.server_image_aliases) || '' }}"
- status: pending
-
- dispatch-begin:
+ prepare-run:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
- pull-requests: write
+ statuses: write
outputs:
composite-identity-json: ${{ steps.composite-identity.outputs.composite-identity-json }}
workers-matrix: ${{ steps.matrix.outputs.workers }}
@@ -176,35 +158,126 @@ jobs:
run: |
echo "workers=$(jq -nc --argjson n ${{ inputs.workers }} '[range(1; $n+1)]')" >> $GITHUB_OUTPUT
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
+
+ # Build @mattermost/client + @mattermost/types and install playwright deps once,
+ # then workers restore from cache. Playwright only consumes those two packages
+ # from webapp, so we cache just their built lib/ instead of all of webapp/node_modules.
+ prep-deps:
+ name: prep-deps
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ - name: ci/checkout-repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.commit_sha }}
+ fetch-depth: 1
+ - name: ci/setup-node
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version-file: ".nvmrc"
+ - name: ci/cache-platform-pkgs
+ # `webapp/node_modules/@mattermost/{client,types}` are the workspace
+ # symlinks Node walks up to find when platform/client requires
+ # @mattermost/types. Without them, module resolution fails inside
+ # the slim slice.
+ id: cache-platform-pkgs
+ uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ webapp/node_modules/@mattermost/client
+ webapp/node_modules/@mattermost/types
+ webapp/platform/client/lib
+ webapp/platform/client/node_modules
+ webapp/platform/types/lib
+ webapp/platform/types/node_modules
+ key: e2e-platform-pkgs-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json', 'webapp/platform/client/src/**', 'webapp/platform/client/tsconfig*.json', 'webapp/platform/types/src/**', 'webapp/platform/types/tsconfig*.json') }}
+ - name: ci/build-platform-pkgs
+ # Full webapp install is needed for tsc + workspace linking; the
+ # postinstall builds platform/{client,types}/lib. We only cache those.
+ if: steps.cache-platform-pkgs.outputs.cache-hit != 'true'
+ working-directory: webapp
+ run: make node_modules
+ - name: ci/cache-playwright-deps
+ # Caches node_modules + the rolled-up @mattermost/playwright-lib dist
+ # so workers don't re-run rollup on every job.
+ id: cache-playwright
+ uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ e2e-tests/playwright/node_modules
+ e2e-tests/playwright/lib/dist
+ e2e-tests/playwright/lib/node_modules
+ key: e2e-playwright-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json', 'e2e-tests/playwright/lib/src/**', 'e2e-tests/playwright/lib/package.json', 'e2e-tests/playwright/lib/rollup.config.js', 'e2e-tests/playwright/lib/tsconfig.json') }}
+ - name: ci/install-playwright-deps
+ # `npm ci` creates symlinks at node_modules/@mattermost/{client,types}
+ # → webapp/platform/{client,types}; targets must already be built.
+ # The postinstall then builds lib/dist via rollup. Skip browser
+ # download here — chromium is cached separately below.
+ if: steps.cache-playwright.outputs.cache-hit != 'true'
+ working-directory: e2e-tests/playwright
+ env:
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
+ run: npm ci
+ - name: ci/cache-playwright-browsers
+ # Cache chromium binary (~150MB) keyed on the playwright lockfile so a
+ # version bump invalidates. Restored by workers; no docker image needed.
+ id: cache-pw-browsers
+ uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-browsers-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json') }}
+ - name: ci/install-playwright-chromium
+ if: steps.cache-pw-browsers.outputs.cache-hit != 'true'
+ working-directory: e2e-tests/playwright
+ run: npx playwright install chromium
+
+ # Register the Test System IO run AFTER prep-deps so workers reach
+ # dispatch-run within Test System IO's inactivity window.
+ dispatch-begin:
+ runs-on: ubuntu-24.04
+ needs: [prepare-run, prep-deps]
+ permissions:
+ contents: read
+ id-token: write
+ statuses: write
+ steps:
+ - name: ci/checkout-repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ inputs.commit_sha }}
+ fetch-depth: 1
- name: ci/dispatch-begin
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-begin@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
framework: playwright
repo-dir: ${{ github.workspace }}
- composite-identity: ${{ steps.composite-identity.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
total-reports-expected: ${{ inputs.workers }}
retest-on-fail: ${{ inputs.retest_on_fail }}
playwright-project: ${{ inputs.playwright_project }}
- post-pr-comment: 'true'
github-token: ${{ secrets.GITHUB_TOKEN }}
- context-name: ${{ inputs.context_name }}
- server-image: ${{ env.SERVER_IMAGE }}
+ commit-status-context: ${{ inputs.context_name }}
+ image-tag: ${{ inputs.server_image_tag }}
+ image-aliases: ${{ inputs.server_image_aliases }}
workers:
name: dispatch-run-${{ matrix.worker_index }}
runs-on: ubuntu-24.04
timeout-minutes: 30
- needs: dispatch-begin
+ needs: [prepare-run, dispatch-begin]
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
- worker_index: ${{ fromJSON(needs.dispatch-begin.outputs.workers-matrix) }}
+ worker_index: ${{ fromJSON(needs.prepare-run.outputs.workers-matrix) }}
env:
- COMPOSITE_IDENTITY: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ COMPOSITE_IDENTITY: ${{ needs.prepare-run.outputs.composite-identity-json }}
SERVER: "${{ inputs.server }}"
MM_LICENSE: "${{ secrets.MM_LICENSE }}"
ENABLED_DOCKER_SERVICES: "${{ inputs.enabled_docker_services }}"
@@ -232,31 +305,53 @@ jobs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
- cache: npm
- cache-dependency-path: "e2e-tests/playwright/package-lock.json"
- - name: ci/get-webapp-node-modules
- working-directory: webapp
- run: make node_modules
+ - name: ci/restore-platform-pkgs
+ # Built lib/ for @mattermost/client and @mattermost/types, plus the
+ # webapp workspace symlinks under webapp/node_modules/@mattermost/.
+ uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ webapp/node_modules/@mattermost/client
+ webapp/node_modules/@mattermost/types
+ webapp/platform/client/lib
+ webapp/platform/client/node_modules
+ webapp/platform/types/lib
+ webapp/platform/types/node_modules
+ key: e2e-platform-pkgs-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json', 'webapp/platform/client/src/**', 'webapp/platform/client/tsconfig*.json', 'webapp/platform/types/src/**', 'webapp/platform/types/tsconfig*.json') }}
+ fail-on-cache-miss: true
+ - name: ci/restore-playwright-deps
+ uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: |
+ e2e-tests/playwright/node_modules
+ e2e-tests/playwright/lib/dist
+ e2e-tests/playwright/lib/node_modules
+ key: e2e-playwright-deps-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json', 'e2e-tests/playwright/lib/src/**', 'e2e-tests/playwright/lib/package.json', 'e2e-tests/playwright/lib/rollup.config.js', 'e2e-tests/playwright/lib/tsconfig.json') }}
+ fail-on-cache-miss: true
+ - name: ci/restore-playwright-browsers
+ uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-browsers-${{ runner.os }}-${{ hashFiles('e2e-tests/playwright/package-lock.json') }}
+ fail-on-cache-miss: true
- name: ci/cloud-init
working-directory: e2e-tests
run: make cloud-init
- name: ci/start-server
working-directory: e2e-tests
run: make start-server
- # Build once + run the `setup` project so per-spec dispatches can
- # pass --no-deps and skip plugin-load + server-deployment checks.
+ # Run the `setup` project so per-spec dispatches can pass --no-deps
+ # and skip plugin-load + server-deployment checks. node_modules,
+ # lib/dist, and chromium are all restored from cache.
- name: ci/prepare-playwright
working-directory: e2e-tests/playwright
- run: |
- npm ci
- npm run build
- npx playwright test --project=setup
+ run: npx playwright test --project=setup
- name: ci/dispatch-run
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-dispatch-run@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
framework: playwright
- composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
repo-dir: ${{ github.workspace }}
artifacts-root: ${{ github.workspace }}/worker-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -281,12 +376,12 @@ jobs:
report:
runs-on: ubuntu-24.04
- needs: [dispatch-begin, workers]
+ needs: [prepare-run, dispatch-begin, workers]
if: always()
permissions:
contents: read
id-token: write
- pull-requests: write
+ statuses: write
outputs:
commit_status_description: ${{ steps.summary.outputs.commit_status_description }}
webhook_payload: ${{ steps.summary.outputs.webhook_payload }}
@@ -296,10 +391,10 @@ jobs:
- name: ci/run-summary
id: summary
continue-on-error: true
- uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@main
+ uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@a2ea7f005484c28fedf51e16645f6d3bd683fd63 # 2026-05-16
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
- composite-identity: ${{ needs.dispatch-begin.outputs.composite-identity-json }}
+ composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
framework: playwright
report-type: ${{ inputs.report_type }}
image-tag: ${{ inputs.server_image_tag }}
@@ -307,8 +402,7 @@ jobs:
server-image: ${{ env.SERVER_IMAGE }}
pr-number: ${{ inputs.pr_number }}
ref-branch: ${{ inputs.ref_branch }}
- context-name: ${{ inputs.context_name }}
- post-pr-comment: 'true'
+ commit-status-context: ${{ inputs.context_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: ci/publish-webhook
if: inputs.enable_reporting && env.REPORT_WEBHOOK_URL != ''
@@ -322,45 +416,3 @@ jobs:
SUMMARY_OUTCOME: ${{ steps.summary.outcome }}
run: |
[ "$SUMMARY_OUTCOME" = "success" ]
-
- update-success-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- if: always() && needs.report.result == 'success'
- needs:
- - dispatch-begin
- - report
- steps:
- - uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: ${{ needs.report.outputs.commit_status_description }}
- status: success
- target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-
- update-failure-status:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- statuses: write
- if: always() && needs.report.result != 'success'
- needs:
- - dispatch-begin
- - report
- steps:
- - uses: mattermost/actions/delivery/update-commit-status@f324ac89b05cc3511cb06e60642ac2fb829f0a63
- env:
- GITHUB_TOKEN: ${{ github.token }}
- with:
- repository_full_name: ${{ github.repository }}
- commit_sha: ${{ inputs.commit_sha }}
- context: ${{ inputs.context_name }}
- description: ${{ needs.report.outputs.commit_status_description }}
- status: failure
- target_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
diff --git a/.github/workflows/e2e-tests-playwright-template.yml b/.github/workflows/e2e-tests-playwright-template.yml
index af976a3110b..80d92d71794 100644
--- a/.github/workflows/e2e-tests-playwright-template.yml
+++ b/.github/workflows/e2e-tests-playwright-template.yml
@@ -179,7 +179,14 @@ jobs:
with:
node-version-file: ".nvmrc"
cache: npm
- cache-dependency-path: "e2e-tests/playwright/package-lock.json"
+ cache-dependency-path: |
+ e2e-tests/playwright/package-lock.json
+ webapp/package-lock.json
+ - name: ci/npm-cache-verify
+ # Heal any partial/dangling entries left in the restored ~/.npm cache
+ # before running `npm ci`. Avoids the intermittent EEXIST/ENOENT
+ # failures in npm's cacache writer.
+ run: npm cache verify
- name: ci/get-webapp-node-modules
working-directory: webapp
run: make node_modules
diff --git a/.github/workflows/pr-test-analysis-override.yml b/.github/workflows/pr-test-analysis-override.yml
index 9372742d3d5..65e16c0df74 100644
--- a/.github/workflows/pr-test-analysis-override.yml
+++ b/.github/workflows/pr-test-analysis-override.yml
@@ -21,8 +21,7 @@ jobs:
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/test-analysis-override') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
- # Pin to a commit SHA once the reusable workflow is stable. Using @main during initial rollout.
- uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis-override.yml@main
+ uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis-override.yml@93d73f4f101e10dc03c7ed6b76b35eb5ff5babb7 # 2026-05-16
with:
pr_number: ${{ github.event.issue.number }}
target_repo: mattermost/mattermost
diff --git a/.github/workflows/pr-test-analysis.yml b/.github/workflows/pr-test-analysis.yml
index fc232212bec..03828d7dc87 100644
--- a/.github/workflows/pr-test-analysis.yml
+++ b/.github/workflows/pr-test-analysis.yml
@@ -36,8 +36,7 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == 'mattermost/mattermost')
- # Pin to a commit SHA once the reusable workflow is stable. Using @main during initial rollout.
- uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis.yml@main
+ uses: mattermost/mattermost-test-automation-toolkit/.github/workflows/pr-test-analysis.yml@93d73f4f101e10dc03c7ed6b76b35eb5ff5babb7 # 2026-05-16
with:
pr_number: ${{ github.event.pull_request.number || inputs.pr_number }}
target_repo: mattermost/mattermost
diff --git a/.github/workflows/server-ci-report.yml b/.github/workflows/server-ci-report.yml
index f977e2a2bf4..528b0fe34db 100644
--- a/.github/workflows/server-ci-report.yml
+++ b/.github/workflows/server-ci-report.yml
@@ -124,3 +124,32 @@ jobs:
repo: context.repo.repo,
body: body
})
+
+ - name: Report retried tests to flaky-test webhook (pull request)
+ if: >-
+ steps.report.outputs.flaky_summary != '
Test
Retries
'
+ && steps.report.outputs.failed == '0'
+ && github.event.workflow_run.event == 'pull_request'
+ && env.WEBHOOK_URL_FLAKY_TEST != ''
+ && env.WEBHOOK_AUTH_TOKEN_FLAKY_TEST != ''
+ continue-on-error: true
+ env:
+ WEBHOOK_URL_FLAKY_TEST: ${{ secrets.WEBHOOK_URL_FLAKY_TEST }}
+ WEBHOOK_AUTH_TOKEN_FLAKY_TEST: ${{ secrets.WEBHOOK_AUTH_TOKEN_FLAKY_TEST }}
+ FLAKY_SUMMARY: ${{ steps.report.outputs.flaky_summary }}
+ PR_NUMBER: ${{ steps.incoming-pr.outputs.NUMBER }}
+ REPO: ${{ github.repository }}
+ run: |
+ PAYLOAD=$(jq -n \
+ --arg repo "$REPO" \
+ --arg pr_number "$PR_NUMBER" \
+ --arg flaky_summary "$FLAKY_SUMMARY" \
+ '{repo:$repo, pr_number:$pr_number, flaky_summary:$flaky_summary}')
+
+ curl -X POST -fsSL \
+ --connect-timeout 5 \
+ --max-time 30 \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $WEBHOOK_AUTH_TOKEN_FLAKY_TEST" \
+ -d "$PAYLOAD" \
+ "$WEBHOOK_URL_FLAKY_TEST"
diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml
index c26247c67d5..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
@@ -247,6 +248,7 @@ jobs:
artifact-pattern: postgres-server-test-logs-shard-*
artifact-name: postgres-server-test-logs
save-timing-cache: true
+ all-shards-passed: ${{ needs.test-postgres-normal.result == 'success' }}
test-elasticsearch-v8:
name: Elasticsearch v8 Compatibility
@@ -263,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-merge-template.yml b/.github/workflows/server-test-merge-template.yml
index b007cf0929c..c9f6866f854 100644
--- a/.github/workflows/server-test-merge-template.yml
+++ b/.github/workflows/server-test-merge-template.yml
@@ -16,6 +16,11 @@ on:
required: false
type: boolean
default: false
+ all-shards-passed:
+ description: "Whether every upstream shard succeeded. Used to gate the timing-cache save so a single shard failure doesn't poison the cache with missing-package data."
+ required: false
+ type: boolean
+ default: false
jobs:
merge:
@@ -79,11 +84,17 @@ jobs:
echo "has_timing=false" >> "$GITHUB_OUTPUT"
fi
+ # Only save when every upstream shard succeeded. If even one shard
+ # failed/was killed, its gotestsum.json is missing and the merged report
+ # has no timings for that shard's packages — saving that would poison
+ # future shard splits (missing packages default to 1ms, all bin-pack
+ # onto the lightest shard, overloading it and repeating the failure).
- name: Save test timing cache
- if: inputs.save-timing-cache && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
+ if: inputs.save-timing-cache && inputs.all-shards-passed && steps.timing-prep.outputs.has_timing == 'true' && github.ref_name == github.event.repository.default_branch
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
server/prev-report.xml
server/prev-gotestsum.json
- key: server-test-timing-master-${{ github.run_id }}
+ # The v2 prefix matches the v2 restore prefix in server-test-template.yml.
+ key: server-test-timing-v2-master-${{ github.run_id }}
diff --git a/.github/workflows/server-test-template.yml b/.github/workflows/server-test-template.yml
index 775ab55b592..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
@@ -93,9 +97,15 @@ jobs:
server/prev-gotestsum.json
# Always restore from master — timing is only saved on the default
# branch and is stable enough for shard balancing.
- key: server-test-timing-master
+ # NOTE: the v2 prefix invalidates pre-existing caches that were
+ # poisoned by shard failures (a killed shard loses its gotestsum.json,
+ # so the merged report was missing those packages' timings; on the
+ # next run they all defaulted to 1ms and bin-packed onto the lightest
+ # shard, overloading it and perpetuating the cycle). See also the
+ # all-shards-passed guard in server-test-merge-template.yml.
+ key: server-test-timing-v2-master
restore-keys: |
- server-test-timing-
+ server-test-timing-v2-
- name: Setup BUILD_IMAGE
id: build
@@ -116,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/.gitignore b/.gitignore
index 7871bfe276b..be437655668 100644
--- a/.gitignore
+++ b/.gitignore
@@ -160,6 +160,7 @@ docker-compose.override.yaml
.notice-work/
.aider*
.env
+.envrc
.planning/
**/CLAUDE.local.md
diff --git a/NOTICE.txt b/NOTICE.txt
index 1fb5981b936..28ed72d119c 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -2570,6 +2570,78 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+---
+
+## Azure/azure-sdk-for-go
+
+This product contains 'Azure/azure-sdk-for-go' by Microsoft Azure.
+
+This repository is for active development of the Azure SDK for Go. For consumers of the SDK we recommend visiting our public developer docs at:
+
+* HOMEPAGE:
+ * https://docs.microsoft.com/azure/developer/go/
+
+* LICENSE: MIT License
+
+The MIT License (MIT)
+
+Copyright (c) Microsoft Corporation.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+---
+
+## Azure/azure-sdk-for-go
+
+This product contains 'Azure/azure-sdk-for-go' by Microsoft Azure.
+
+This repository is for active development of the Azure SDK for Go. For consumers of the SDK we recommend visiting our public developer docs at:
+
+* HOMEPAGE:
+ * https://docs.microsoft.com/azure/developer/go/
+
+* LICENSE: MIT License
+
+The MIT License (MIT)
+
+Copyright (c) Microsoft Corporation.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
---
## Go
@@ -2846,42 +2918,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
----
-
-## anthonynsimon/bild
-
-This product contains 'anthonynsimon/bild' by anthonynsimon.
-
-Image processing algorithms in pure Go
-
-* HOMEPAGE:
- * https://github.com/anthonynsimon/bild
-
-* LICENSE: MIT License
-
-MIT License
-
-Copyright (c) 2016-2024 Anthony Simon
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-
---
## aws/aws-sdk-go-v2
@@ -4074,6 +4110,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+---
+
+## boxes-ltd/imaging
+
+This product contains 'boxes-ltd/imaging' by Boxes Ltd.
+
+Imaging is a simple image processing package for Go
+
+* HOMEPAGE:
+ * https://github.com/boxes-ltd/imaging
+
+* LICENSE: MIT License
+
+The MIT License (MIT)
+
+Copyright (c) 2012 Grigory Dryapak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
---
## buffer
@@ -5840,6 +5912,48 @@ GoMock is a mocking framework for the Go programming language.
limitations under the License.
+---
+
+## google/uuid
+
+This product contains 'google/uuid' by Google.
+
+Go package for UUIDs based on RFC 4122 and DCE 1.1: Authentication and Security Services.
+
+* HOMEPAGE:
+ * https://github.com/google/uuid
+
+* LICENSE: BSD 3-Clause "New" or "Revised" License
+
+Copyright (c) 2009,2014 Google Inc. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
---
## gorilla/handlers
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 0968f660efe..6f40b0de1bd 100644
--- a/api/v4/source/definitions.yaml
+++ b/api/v4/source/definitions.yaml
@@ -1151,6 +1151,10 @@ components:
description: The time in milliseconds a incoming webhook was deleted
type: integer
format: int64
+ last_used:
+ description: The time in milliseconds this incoming webhook was last used to post a message
+ type: integer
+ format: int64
channel_id:
description: The ID of a public channel or private group that receives the
webhook payloads
@@ -4816,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/api/v4/source/users.yaml b/api/v4/source/users.yaml
index c22441ec34d..e5c6b6de375 100644
--- a/api/v4/source/users.yaml
+++ b/api/v4/source/users.yaml
@@ -1534,6 +1534,54 @@
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
+ /api/v4/users/auth_data:
+ get:
+ tags:
+ - users
+ summary: Get a user by auth data
+ description: >
+ Get a user by their external auth data identifier. The `value` is
+ matched against what is stored in `Users.AuthData`, which for most
+ identity providers is the identifier as the provider issues it.
+
+
+ The exception is Active Directory `objectGUID`: under
+ `auth_service: ldap` it is stored as the LDAP filter hex-escape
+ form (e.g. `\61\14\e1\d1\c5\35\18\4a\b6\60\d6\78\50\fd\0d\5d`),
+ and under `auth_service: saml` it is stored as the standard
+ Base64 of the same bytes (e.g. `YRTh0cU1GEq2YNZ4UP0NXQ==`). Use
+ the form matching the user's current `AuthService`.
+
+
+ ##### Permissions
+
+ Must be a system admin.
+ operationId: GetUserByAuthData
+ parameters:
+ - name: value
+ in: query
+ description: >
+ The user's AuthData as stored in `Users.AuthData`. Must be
+ URL-encoded; in particular, Base64 `+` characters must be sent
+ as `%2B` so they are not decoded as spaces.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: User retrieval successful
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/User"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
/api/v4/users/password/reset:
post:
tags:
diff --git a/e2e-tests/cypress/cypress.config.ts b/e2e-tests/cypress/cypress.config.ts
index b499d4a98c3..cca14200584 100644
--- a/e2e-tests/cypress/cypress.config.ts
+++ b/e2e-tests/cypress/cypress.config.ts
@@ -27,9 +27,9 @@ export default defineConfig({
viewportWidth: 1300,
allowCypressEnv: false,
expose: {
- adminEmail: 'sysadmin@sample.mattermost.com',
- adminUsername: 'sysadmin',
- adminPassword: 'Sys@dmin-sample1',
+ adminEmail: process.env.CYPRESS_adminEmail || 'sysadmin@sample.mattermost.com',
+ adminUsername: process.env.CYPRESS_adminUsername || 'sysadmin',
+ adminPassword: process.env.CYPRESS_adminPassword || 'Sys@dmin-sample1',
allowedUntrustedInternalConnections: 'localhost',
cwsURL: 'http://localhost:8076',
cwsAPIURL: 'http://localhost:8076',
diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
index 5703f9c38a0..031c82da940 100644
--- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_image_spec.js
@@ -16,11 +16,16 @@ describe('Verify Accessibility Support in Different Images', () => {
let otherUser;
before(() => {
- cy.apiInitSetup().then(({offTopicUrl, user}) => {
- otherUser = user;
+ cy.apiInitSetup().then(({team, offTopicUrl, user}) => {
+ cy.apiCreateUser({prefix: 'other'}).then(({user: user1}) => {
+ otherUser = user1;
+ return cy.apiAddUserToTeam(team.id, otherUser.id);
+ }).then(() => {
+ cy.apiLogin(user);
- // Visit the Off Topic channel
- cy.visit(offTopicUrl);
+ // Visit the Off Topic channel
+ cy.visit(offTopicUrl);
+ });
});
});
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/cypress/tests/integration/channels/system_console/environment_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js
index 96e5ac73300..19eff95cda5 100644
--- a/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/system_console/environment_spec.js
@@ -256,7 +256,7 @@ describe('Environment', () => {
cy.get('#TestS3Connection').scrollIntoView().should('be.visible').within(() => {
cy.findByText('Test Connection').should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
- waitForAlert('Connection unsuccessful: Unable to connect to S3. Verify your Amazon S3 connection authorization parameters and authentication settings.');
+ waitForAlert('Connection unsuccessful: Unable to authenticate against the file storage backend. Verify your credentials and authentication settings.');
});
});
diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_cloud_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_cloud_spec.js
index 47012ca7261..88732976cd2 100644
--- a/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_cloud_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/system_console/site_configuration/link_customization_cloud_spec.js
@@ -28,7 +28,7 @@ describe('SupportSettings', () => {
[
{text: 'Ask the community', link: SupportSettings.ASK_COMMUNITY_LINK},
{text: 'Mattermost user guide', link: SupportSettings.MATTERMOST_USER_GUIDE},
- {text: 'Report a problem', link: SupportSettings.REPORT_A_PROBLEM_LINK},
+ {text: 'Report a problem', link: 'mailto:reportaproblem@mattermost.com'},
{text: 'Keyboard shortcuts'},
].forEach(({text, link}) => {
if (link) {
diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_not_cloud_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_not_cloud_spec.js
index 60e0d1ad6b2..07cad0354b4 100644
--- a/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_not_cloud_spec.js
+++ b/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_not_cloud_spec.js
@@ -28,14 +28,14 @@ describe('Customization', () => {
});
it('MM-T1214 - Can change Report a Problem Link setting', () => {
- // * Verify Report a Problem link label is visible and matches the text
- cy.findByTestId('SupportSettings.ReportAProblemLinklabel').scrollIntoView().should('be.visible').and('have.text', 'Report a Problem Link:');
+ // # Select 'Custom link' from the Report a Problem dropdown
+ cy.findByTestId('SupportSettings.ReportAProblemTypedropdown').scrollIntoView().select('Custom link');
- // * Verify Report a Problem link input box has default value. The default value depends on the setup before running the test.
- cy.findByTestId('SupportSettings.ReportAProblemLinkinput').should('have.value', origConfig.SupportSettings.ReportAProblemLink);
+ // * Verify Report a Problem link label is visible and matches the text
+ cy.findByTestId('SupportSettings.ReportAProblemLinklabel').scrollIntoView().should('be.visible').and('have.text', 'Custom Report a Problem Link:');
// * Verify Report a Problem link help text is visible and matches the text
- cy.findByTestId('SupportSettings.ReportAProblemLinkhelp-text').find('span').should('be.visible').and('have.text', 'The URL for the Report a Problem link in the Help Menu. If this field is empty, the link is removed from the Help Menu.');
+ cy.findByTestId('SupportSettings.ReportAProblemLinkhelp-text').find('span').should('be.visible').and('have.text', 'Enter the URL that users will be directed to when they choose "Report a Problem".');
// # Enter a problem link
const reportAProblemLink = 'https://mattermost.com/pl/report-a-bug';
diff --git a/e2e-tests/cypress/tests/support/index.js b/e2e-tests/cypress/tests/support/index.js
index 279c291e647..743703b0761 100644
--- a/e2e-tests/cypress/tests/support/index.js
+++ b/e2e-tests/cypress/tests/support/index.js
@@ -254,6 +254,7 @@ function sysadminSetup(user) {
function resetUserPreference(userId) {
cy.apiSaveTeammateNameDisplayPreference('username');
+ cy.apiSaveMessageDisplayPreference('clean');
cy.apiSaveLinkPreviewsPreference('true');
cy.apiSaveCollapsePreviewsPreference('false');
cy.apiSaveClockDisplayModeTo24HourPreference(false);
diff --git a/e2e-tests/cypress/tests/utils/constants.js b/e2e-tests/cypress/tests/utils/constants.js
index 321e798b74b..19518fec585 100644
--- a/e2e-tests/cypress/tests/utils/constants.js
+++ b/e2e-tests/cypress/tests/utils/constants.js
@@ -6,7 +6,6 @@ export const ABOUT_LINK = 'https://mattermost.com/pl/about-mattermost';
export const ASK_COMMUNITY_LINK = 'https://mattermost.com/pl/default-ask-mattermost-community/';
export const HELP_LINK = 'https://mattermost.com/pl/help/';
export const PRIVACY_POLICY_LINK = 'https://mattermost.com/pl/privacy-policy/';
-export const REPORT_A_PROBLEM_LINK = 'https://mattermost.com/pl/report-a-bug';
export const TERMS_OF_SERVICE_LINK = 'https://mattermost.com/pl/terms-of-use/';
export const MATTERMOST_USER_GUIDE = 'https://docs.mattermost.com/guides/use-mattermost.html';
@@ -24,7 +23,6 @@ export const SupportSettings = {
ASK_COMMUNITY_LINK,
HELP_LINK,
PRIVACY_POLICY_LINK,
- REPORT_A_PROBLEM_LINK,
TERMS_OF_SERVICE_LINK,
MATTERMOST_USER_GUIDE,
};
diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts
index 3c8abca6f6a..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,
@@ -790,6 +789,7 @@ const defaultServerConfig: AdminConfig = {
IntegratedBoards: false,
CJKSearch: false,
ManagedChannelCategories: false,
+ MobileEphemeralMode: true,
},
ImportSettings: {
Directory: './import',
@@ -868,4 +868,10 @@ const defaultServerConfig: AdminConfig = {
LLMServiceID: '',
},
},
+ MobileEphemeralModeSettings: {
+ Enable: false,
+ DisconnectionTimeoutSeconds: 60,
+ OfflinePersistenceTimerHours: 24,
+ AutoCacheCleanupDays: 7,
+ },
};
diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts
new file mode 100644
index 00000000000..5ef6f38477a
--- /dev/null
+++ b/e2e-tests/playwright/lib/src/ui/components/channels/direct_channels_modal.ts
@@ -0,0 +1,62 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {UserProfile} from '@mattermost/types/users';
+import {Locator, expect} from '@playwright/test';
+
+export default class DirectChannelsModal {
+ readonly container;
+
+ readonly goButton;
+ readonly results;
+ readonly searchInput;
+
+ constructor(container: Locator) {
+ this.container = container;
+
+ this.goButton = container.getByRole('button', {name: 'Go'});
+ this.results = container.locator('.more-modal__list');
+ this.searchInput = container.getByRole('combobox', {name: 'Search for people'});
+ }
+
+ async toBeVisible() {
+ await expect(this.container).toBeVisible();
+ }
+
+ async selectUser(user: UserProfile) {
+ await this.fillSearchInput(user.username);
+
+ // This may fail if there's too many group channels containing the provided user
+ const row = this.results
+ .locator('.more-modal__row:not(:has(.more-modal__gm-icon))')
+ .getByText(`@${user.username}`, {exact: false});
+
+ await row.click();
+
+ await expect(this.container.getByRole('button', {name: `Remove ${user.username}`})).toBeVisible();
+ }
+
+ async toHaveNUsersSelected(count: number) {
+ await expect(this.results.locator('.react-select_multi-value')).toHaveCount(count);
+ }
+
+ async goToChannel() {
+ await this.goButton.click();
+
+ await expect(this.container).not.toBeAttached();
+ }
+
+ async toHaveNResults(count: number) {
+ await expect(this.results.locator('.more-modal__row')).toHaveCount(count);
+ }
+
+ async fillSearchInput(text: string) {
+ await this.searchInput.fill(text);
+ }
+
+ async toHaveUserAsNthResult(user: UserProfile, index: number) {
+ const row = this.results.locator('.more-modal__row').nth(index);
+
+ await expect(row).toContainText(`@${user.username}`);
+ }
+}
diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts
index 7dc94566b5b..7d1ba570170 100644
--- a/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/channels/sidebar_left.ts
@@ -11,6 +11,7 @@ export default class ChannelsSidebarLeft {
readonly findChannelButton;
readonly scheduledPostBadge;
readonly unreadChannelFilter;
+ readonly openDirectMessageButton;
constructor(container: Locator) {
this.container = container;
@@ -20,6 +21,7 @@ export default class ChannelsSidebarLeft {
this.findChannelButton = container.getByRole('button', {name: 'Find Channels'});
this.scheduledPostBadge = container.locator('span.scheduledPostBadge');
this.unreadChannelFilter = container.locator('.SidebarFilters_filterButton');
+ this.openDirectMessageButton = container.getByRole('button', {name: 'Write a direct message'});
}
async toBeVisible() {
diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts
index ca4e2a49196..1d7e83a3488 100644
--- a/e2e-tests/playwright/lib/src/ui/components/index.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/index.ts
@@ -21,6 +21,7 @@ import ChannelsSidebarRight from './channels/sidebar_right';
import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog';
import DeletePostModal from './channels/delete_post_modal';
import DeleteScheduledPostModal from './channels/delete_scheduled_post_modal';
+import DirectChannelsModal from './channels/direct_channels_modal';
import DraftPost from './channels/draft_post';
import EmojiGifPicker from './channels/emoji_gif_picker';
import FindChannelsModal from './channels/find_channels_modal';
@@ -53,7 +54,13 @@ import BurnOnReadTimerChip from './channels/burn_on_read_timer_chip';
import BurnOnReadConcealedPlaceholder from './channels/burn_on_read_concealed_placeholder';
import BurnOnReadConfirmationModal from './channels/burn_on_read_confirmation_modal';
// System Console Components
-import {AdminSectionPanel, DropdownSetting, RadioSetting, TextInputSetting} from './system_console/base_components';
+import {
+ AdminSectionPanel,
+ DropdownSetting,
+ NumberInputSetting,
+ RadioSetting,
+ TextInputSetting,
+} from './system_console/base_components';
import DelegatedGranularAdministration from './system_console/sections/user_management/delegated_granular_administration';
import UserDetail from './system_console/sections/user_management/user_detail';
import EditionAndLicense from './system_console/sections/about/edition_and_license';
@@ -89,6 +96,7 @@ const components = {
DeletePostConfirmationDialog,
DeletePostModal,
DeleteScheduledPostModal,
+ DirectChannelsModal,
DraftPost,
EmojiGifPicker,
FindChannelsModal,
@@ -130,6 +138,7 @@ const components = {
EditionAndLicense,
MobileSecurity,
Notifications,
+ NumberInputSetting,
RadioSetting,
UsersAndTeams,
SystemConsoleFeatureDiscovery,
@@ -172,6 +181,7 @@ export {
FlagPostConfirmationDialog,
NewChannelModal,
BrowseChannelsModal,
+ DirectChannelsModal,
GenericConfirmModal,
InvitePeopleModal,
MembersInvitedModal,
@@ -207,6 +217,7 @@ export {
EditionAndLicense,
MobileSecurity,
Notifications,
+ NumberInputSetting,
RadioSetting,
UsersAndTeams,
SystemConsoleFeatureDiscovery,
diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts
index 2cd2c04cc3b..48ed352a8b3 100644
--- a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts
@@ -94,6 +94,40 @@ export class TextInputSetting {
}
}
+/**
+ * Number Input Setting - represents a number input field
+ * Uses getByRole('spinbutton') since has ARIA role spinbutton
+ */
+export class NumberInputSetting {
+ readonly container: Locator;
+ readonly label: Locator;
+ readonly input: Locator;
+ readonly helpText: Locator;
+
+ constructor(container: Locator, labelText: string) {
+ this.container = container;
+ this.label = container.getByText(labelText);
+ this.input = container.getByRole('spinbutton');
+ this.helpText = container.locator('.help-text');
+ }
+
+ async fill(value: string) {
+ await this.input.fill(value);
+ }
+
+ async getValue(): Promise {
+ return (await this.input.inputValue()) ?? '';
+ }
+
+ async clear() {
+ await this.input.clear();
+ }
+
+ async toBeVisible() {
+ await expect(this.container).toBeVisible();
+ }
+}
+
/**
* Dropdown Setting - represents a select dropdown
*/
diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts
index f171dc11cc8..0eb97a64ec2 100644
--- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts
@@ -3,7 +3,13 @@
import {Locator, expect} from '@playwright/test';
-import {RadioSetting, TextInputSetting, DropdownSetting, AdminSectionPanel} from '../../base_components';
+import {
+ RadioSetting,
+ TextInputSetting,
+ NumberInputSetting,
+ DropdownSetting,
+ AdminSectionPanel,
+} from '../../base_components';
/**
* System Console -> Environment -> Mobile Security
@@ -17,6 +23,7 @@ export default class MobileSecurity {
// Panels
readonly generalMobileSecurity: GeneralMobileSecurityPanel;
readonly microsoftIntune: MicrosoftIntunePanel;
+ readonly mobileEphemeralMode: MobileEphemeralModePanel;
// Save section
readonly saveButton: Locator;
@@ -33,6 +40,9 @@ export default class MobileSecurity {
this.microsoftIntune = new MicrosoftIntunePanel(
container.locator('.AdminSectionPanel').filter({hasText: 'Microsoft Intune'}),
);
+ this.mobileEphemeralMode = new MobileEphemeralModePanel(
+ container.locator('.AdminSectionPanel').filter({hasText: 'Mobile Ephemeral Mode'}),
+ );
this.saveButton = container.getByRole('button', {name: 'Save'});
this.errorMessage = container.locator('.error-message');
@@ -77,6 +87,20 @@ export default class MobileSecurity {
get clientId() {
return this.microsoftIntune.clientId;
}
+
+ // Convenience shortcuts for Mobile Ephemeral Mode settings
+ get enableMobileEphemeralMode() {
+ return this.mobileEphemeralMode.enableMobileEphemeralMode;
+ }
+ get disconnectionTimeout() {
+ return this.mobileEphemeralMode.disconnectionTimeout;
+ }
+ get offlinePersistenceTimer() {
+ return this.mobileEphemeralMode.offlinePersistenceTimer;
+ }
+ get autoCacheCleanup() {
+ return this.mobileEphemeralMode.autoCacheCleanup;
+ }
}
class GeneralMobileSecurityPanel extends AdminSectionPanel {
@@ -105,6 +129,33 @@ class GeneralMobileSecurityPanel extends AdminSectionPanel {
}
}
+class MobileEphemeralModePanel extends AdminSectionPanel {
+ readonly enableMobileEphemeralMode: RadioSetting;
+ readonly disconnectionTimeout: NumberInputSetting;
+ readonly offlinePersistenceTimer: NumberInputSetting;
+ readonly autoCacheCleanup: NumberInputSetting;
+
+ constructor(container: Locator) {
+ super(container, 'Mobile Ephemeral Mode');
+
+ this.enableMobileEphemeralMode = new RadioSetting(
+ this.body.getByRole('group', {name: /Enable Mobile Ephemeral Mode/}),
+ );
+ this.disconnectionTimeout = new NumberInputSetting(
+ this.body.locator('.form-group').filter({hasText: 'Disconnection Timeout (seconds):'}),
+ 'Disconnection Timeout (seconds):',
+ );
+ this.offlinePersistenceTimer = new NumberInputSetting(
+ this.body.locator('.form-group').filter({hasText: 'Offline Persistence Timer (hours):'}),
+ 'Offline Persistence Timer (hours):',
+ );
+ this.autoCacheCleanup = new NumberInputSetting(
+ this.body.locator('.form-group').filter({hasText: 'Auto Cache Cleanup (days):'}),
+ 'Auto Cache Cleanup (days):',
+ );
+ }
+}
+
class MicrosoftIntunePanel extends AdminSectionPanel {
readonly enableIntuneMAM: RadioSetting;
readonly authProvider: DropdownSetting;
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 ec3c6df9328..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
@@ -51,6 +51,17 @@ export default class SystemProperties {
return this.container.locator(`input[value="${value}"]`);
}
+ displayNameInput(nth: number): Locator {
+ return this.container.getByTestId('property-display-name-input').nth(nth);
+ }
+
+ displayNameInputNear(identifierValue: string): Locator {
+ return this.container
+ .locator('tr')
+ .filter({has: this.nameInputByValue(identifierValue)})
+ .getByTestId('property-display-name-input');
+ }
+
typeSelector(nth: number): Locator {
return this.container.getByTestId('fieldTypeSelectorMenuButton').nth(nth);
}
@@ -73,6 +84,10 @@ export default class SystemProperties {
return this.container.getByTestId('property-field-input').last();
}
+ lastDisplayNameInput(): Locator {
+ return this.container.getByTestId('property-display-name-input').last();
+ }
+
lastTypeSelector(): Locator {
return this.container.getByTestId('fieldTypeSelectorMenuButton').last();
}
@@ -209,7 +224,31 @@ export default class SystemProperties {
// ── Validation ──────────────────────────────────────────────────────
- validationMessage(text: string): Locator {
+ identifierValidationError(): Locator {
+ 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/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts
index 36cbb4f7dc1..3a6db4afc68 100644
--- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts
+++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts
@@ -38,6 +38,7 @@ export default class ChannelsPage {
readonly findChannelsModal;
readonly newChannelModal;
readonly browseChannelsModal;
+ readonly directChannelsModal;
public invitePeopleModal: InvitePeopleModal | undefined;
public membersInvitedModal: MembersInvitedModal | undefined;
readonly profileModal;
@@ -77,6 +78,9 @@ export default class ChannelsPage {
this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'}));
this.newChannelModal = new NewChannelModal(page.getByRole('dialog', {name: 'Create a new channel'}));
this.browseChannelsModal = new BrowseChannelsModal(page.getByRole('dialog', {name: 'Browse Channels'}));
+ this.directChannelsModal = new components.DirectChannelsModal(
+ page.getByRole('dialog', {name: 'Direct Messages'}),
+ );
this.profileModal = new components.ProfileModal(page.getByRole('dialog', {name: 'Profile'}));
this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'}));
this.teamSettingsModal = new components.TeamSettingsModal(page.getByRole('dialog', {name: 'Team Settings'}));
@@ -242,6 +246,13 @@ export default class ChannelsPage {
return this.browseChannelsModal;
}
+ async openDirectChannelsModal() {
+ await this.sidebarLeft.openDirectMessageButton.click();
+ await this.directChannelsModal.toBeVisible();
+
+ return this.directChannelsModal;
+ }
+
async openCreateTeamForm(): Promise {
await this.sidebarLeft.teamMenuButton.click();
await this.teamMenu.toBeVisible();
diff --git a/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts b/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts
index 613ef8dc3ff..4f090f1760e 100644
--- a/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts
+++ b/e2e-tests/playwright/lib/src/ui/pages/content_review_dm.ts
@@ -17,6 +17,12 @@ export default class ContentReviewPage {
readonly confirmRemoveMessageButton: Locator;
readonly confirmKeepMessageButton: Locator;
readonly confirmationModalComment: Locator;
+ readonly downloadReportCheckbox: Locator;
+ readonly formContinueButton: Locator;
+ readonly removePermanentlyButton: Locator;
+ readonly keepPermanentlyButton: Locator;
+ readonly removeWithoutReportButton: Locator;
+ readonly generatedSection: Locator;
constructor(page: Page) {
this.page = page;
@@ -33,6 +39,16 @@ export default class ContentReviewPage {
this.confirmationModalComment = this.postActionConformationModal.getByTestId(
'RemoveFlaggedMessageConfirmationModal__comment',
);
+ this.downloadReportCheckbox = this.postActionConformationModal.getByTestId('download-report-checkbox');
+ this.formContinueButton = this.postActionConformationModal.getByRole('button', {name: 'Continue'});
+ this.removePermanentlyButton = this.postActionConformationModal.getByRole('button', {
+ name: 'Remove permanently',
+ });
+ this.keepPermanentlyButton = this.postActionConformationModal.getByRole('button', {name: 'Keep permanently'});
+ this.removeWithoutReportButton = this.postActionConformationModal.getByRole('button', {
+ name: 'Remove without report',
+ });
+ this.generatedSection = this.postActionConformationModal.getByTestId('generated-section');
}
async setReportCardByPostID(postID: string) {
@@ -175,4 +191,35 @@ export default class ContentReviewPage {
await this.confirmKeepMessageButton.click();
await this.postActionConformationModal.waitFor({state: 'hidden'});
}
+
+ /**
+ * From the form step, advance to the report-generated step
+ * (downloadReport checkbox is on by default).
+ */
+ async submitFormAndWaitForReport() {
+ await this.formContinueButton.click();
+ await expect(this.generatedSection).toBeVisible({timeout: 30000});
+ }
+
+ async confirmRemovePermanently() {
+ await this.removePermanentlyButton.click();
+ await this.postActionConformationModal.waitFor({state: 'hidden'});
+ }
+
+ async confirmKeepPermanently() {
+ await this.keepPermanentlyButton.click();
+ await this.postActionConformationModal.waitFor({state: 'hidden'});
+ }
+
+ /**
+ * Skip-report path: uncheck the download checkbox, submit the form to reach
+ * the skip-confirm step, then confirm removal without a report.
+ */
+ async confirmRemoveWithoutReport() {
+ await this.downloadReportCheckbox.uncheck();
+ await this.confirmRemoveMessageButton.click();
+ await expect(this.removeWithoutReportButton).toBeVisible({timeout: 10000});
+ await this.removeWithoutReportButton.click();
+ await this.postActionConformationModal.waitFor({state: 'hidden'});
+ }
}
diff --git a/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts
new file mode 100644
index 00000000000..9905e302950
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts
@@ -0,0 +1,381 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/**
+ * Channel Classification E2E tests.
+ * Tests the classification level assignment feature on both new and existing channels.
+ *
+ * Prerequisites: Enterprise-tier license + ClassificationMarkings feature flag enabled.
+ */
+
+import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
+import type {PlaywrightExtended} from '@mattermost/playwright-lib';
+
+import {
+ TEST_LEVELS,
+ setClassificationMarkingsFeatureFlag,
+ setupClassificationWithChannelField,
+ deleteClassificationFieldsIfExist,
+} from './helpers';
+import type {ClassificationLevel} from './helpers';
+
+let classificationLevels: ClassificationLevel[] = [];
+let setupComplete = false;
+
+// Teams created by pw.initSetup() in each test are tracked here and deleted in
+// afterEach so local environments don't accumulate stale teams across runs.
+const createdTeamIds: string[] = [];
+
+async function initSetupTracked(pw: PlaywrightExtended) {
+ const setup = await pw.initSetup();
+ createdTeamIds.push(setup.team.id);
+ return setup;
+}
+
+test.beforeAll(async () => {
+ const {adminClient} = await getAdminClient();
+ const license = await adminClient.getClientLicenseOld();
+ if (licenseTier(license.SkuShortName) < 20) {
+ return;
+ }
+
+ await setClassificationMarkingsFeatureFlag(adminClient, true);
+ const setup = await setupClassificationWithChannelField(adminClient);
+ classificationLevels = setup.levels;
+ setupComplete = true;
+});
+
+test.afterAll(async () => {
+ if (!setupComplete) {
+ return;
+ }
+ const {adminClient} = await getAdminClient();
+ try {
+ await deleteClassificationFieldsIfExist(adminClient);
+ } catch {
+ // Best-effort cleanup
+ }
+});
+
+test.beforeEach(async () => {
+ const {adminClient} = await getAdminClient();
+ const license = await adminClient.getClientLicenseOld();
+ test.skip(licenseTier(license.SkuShortName) < 20, 'Channel classification requires Enterprise-tier license');
+ test.skip(!setupComplete, 'Classification levels were not set up');
+
+ const config = await adminClient.getConfig();
+ test.skip(
+ config.FeatureFlags.ClassificationMarkings !== true,
+ 'ClassificationMarkings feature flag could not be enabled',
+ );
+});
+
+test.afterEach(async () => {
+ if (createdTeamIds.length === 0) {
+ return;
+ }
+ const ids = createdTeamIds.splice(0);
+ try {
+ const {adminClient} = await getAdminClient({skipLog: true});
+ await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
+ } catch {
+ // Best-effort cleanup
+ }
+});
+
+test.describe('Channel Classification - New channel creation', () => {
+ test('Enabling classification toggle without selecting values prevents channel creation', async ({pw}) => {
+ const {adminUser, team} = await initSetupTracked(pw);
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const newChannelModal = await channelsPage.openNewChannelModal();
+ await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
+ await newChannelModal.publicTypeButton.click();
+
+ // Create button should be enabled before toggling classification
+ await expect(newChannelModal.createButton).toBeEnabled();
+
+ // Enable classification toggle
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ // Create button should be disabled (no classification level selected, no banner text)
+ await expect(newChannelModal.createButton).toBeDisabled();
+ });
+
+ test('Classification dropdown displays the correct levels from the template', async ({pw}) => {
+ const {adminUser, team} = await initSetupTracked(pw);
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const newChannelModal = await channelsPage.openNewChannelModal();
+ await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
+
+ // Enable classification toggle
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ // Open the classification dropdown
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await dropdownContainer.click();
+
+ // Verify all test levels are present in the dropdown menu
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await expect(menu).toBeVisible();
+ for (const level of TEST_LEVELS) {
+ await expect(menu.getByText(level.name, {exact: true})).toBeVisible();
+ }
+ });
+
+ test('User can append text to the Banner Text field after selecting a classification', async ({pw}) => {
+ const {adminUser, team} = await initSetupTracked(pw);
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
+ expect(selectedLevel).toBeDefined();
+
+ const newChannelModal = await channelsPage.openNewChannelModal();
+ await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
+
+ // Enable classification toggle
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ // Select a classification level
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await dropdownContainer.click();
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await menu.getByText(selectedLevel!.name, {exact: true}).click();
+
+ // Banner text should be auto-populated with the bold level name
+ const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
+ await expect(bannerTextbox).toBeVisible();
+ const currentValue = await bannerTextbox.inputValue();
+ expect(currentValue).toContain(selectedLevel!.name);
+
+ // Append custom text to the banner
+ await bannerTextbox.click();
+ await bannerTextbox.press('End');
+ await bannerTextbox.pressSequentially(' - Custom suffix');
+
+ const updatedValue = await bannerTextbox.inputValue();
+ expect(updatedValue).toContain('Custom suffix');
+ });
+
+ test('Creating channel with classification shows banner with correct color', async ({pw}) => {
+ const {adminUser, team} = await initSetupTracked(pw);
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
+ expect(selectedLevel).toBeDefined();
+
+ const newChannelModal = await channelsPage.openNewChannelModal();
+ await newChannelModal.fillDisplayName(`classified-${pw.random.id()}`);
+ await newChannelModal.publicTypeButton.click();
+
+ // Enable classification toggle
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ // Select the classification level
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await dropdownContainer.click();
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await menu.getByText(selectedLevel!.name, {exact: true}).click();
+
+ // Wait for banner text to auto-populate, then create the channel
+ const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
+ await expect(bannerTextbox).toBeVisible();
+ await expect(bannerTextbox).not.toHaveValue('');
+
+ await newChannelModal.create();
+
+ // Should be redirected to the new channel and center view loads
+ await expect(channelsPage.page).toHaveURL(/\/channels\//, {timeout: 30000});
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 30000});
+
+ // Channel banner should be visible (allow extra time for property value fetch)
+ const banner = channelsPage.page.getByTestId('channel_banner_container');
+ await expect(banner).toBeVisible({timeout: 30000});
+
+ // Verify the banner has the correct background color
+ const actualBackgroundColor = await banner.evaluate((el) => {
+ return window.getComputedStyle(el).getPropertyValue('background-color');
+ });
+ const expectedRgb = hexToRgb(selectedLevel!.color);
+ expect(actualBackgroundColor).toBe(expectedRgb);
+
+ // Verify the banner contains the classification level name (rendered from **SECRET** markdown)
+ await expect(banner).toContainText(selectedLevel!.name);
+ });
+});
+
+test.describe('Channel Classification - Existing channel settings', () => {
+ test('Classification toggle can be enabled from channel settings', async ({pw}) => {
+ const {adminUser, team, adminClient} = await initSetupTracked(pw);
+
+ const channel = await adminClient.createChannel(
+ pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
+ );
+ await adminClient.addToChannel(adminUser.id, channel.id);
+
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, channel.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openConfigurationTab();
+
+ // The classification toggle should be available
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await expect(classificationToggle).toBeVisible();
+
+ // Toggle it on
+ const classes = await classificationToggle.getAttribute('class');
+ if (!classes?.includes('active')) {
+ await classificationToggle.click();
+ }
+
+ // Toggle should now be active
+ await expect(classificationToggle).toHaveClass(/active/);
+ });
+
+ test('Classification level can be set once toggle is enabled', async ({pw}) => {
+ const {adminUser, team, adminClient} = await initSetupTracked(pw);
+
+ const channel = await adminClient.createChannel(
+ pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
+ );
+ await adminClient.addToChannel(adminUser.id, channel.id);
+
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, channel.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openConfigurationTab();
+
+ // Enable classification toggle
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ // Classification level dropdown should be visible
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await expect(dropdownContainer).toBeVisible();
+
+ // Open dropdown and select a level
+ await dropdownContainer.click();
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await expect(menu).toBeVisible();
+
+ const selectedLevel = classificationLevels.find((l) => l.name === 'CONFIDENTIAL');
+ expect(selectedLevel).toBeDefined();
+ await menu.getByText(selectedLevel!.name, {exact: true}).click();
+
+ // The dropdown should now show the selected value
+ await expect(dropdownContainer.getByText(selectedLevel!.name, {exact: true})).toBeVisible();
+ });
+
+ test('Selecting classification locks banner toggle active and disabled, with matching color', async ({pw}) => {
+ const {adminUser, team, adminClient} = await initSetupTracked(pw);
+
+ const channel = await adminClient.createChannel(
+ pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
+ );
+ await adminClient.addToChannel(adminUser.id, channel.id);
+
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, channel.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ await channelSettingsModal.openConfigurationTab();
+
+ // Enable classification and select a level
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await dropdownContainer.click();
+
+ const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
+ expect(selectedLevel).toBeDefined();
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await menu.getByText(selectedLevel!.name, {exact: true}).click();
+
+ // The channel banner toggle should now be forced active and disabled
+ const bannerToggle = channelsPage.page.getByTestId('channelBannerToggle-button');
+ await expect(bannerToggle).toBeVisible();
+ await expect(bannerToggle).toHaveClass(/active/);
+ await expect(bannerToggle).toBeDisabled();
+
+ // Banner color input should be locked to the classification color
+ const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
+ await expect(colorInput).toBeVisible();
+ const colorValue = await colorInput.inputValue();
+ expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', ''));
+ });
+
+ test('Editing banner text and saving updates the banner in real time', async ({pw}) => {
+ const {adminUser, team, adminClient} = await initSetupTracked(pw);
+
+ const channel = await adminClient.createChannel(
+ pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
+ );
+ await adminClient.addToChannel(adminUser.id, channel.id);
+
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+ await channelsPage.goto(team.name, channel.name);
+ await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
+
+ const channelSettingsModal = await channelsPage.openChannelSettings();
+ const configurationTab = await channelSettingsModal.openConfigurationTab();
+
+ // Enable classification and select a level
+ const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
+ await classificationToggle.click();
+
+ const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
+ await dropdownContainer.click();
+
+ const selectedLevel = classificationLevels.find((l) => l.name === 'TOP SECRET');
+ expect(selectedLevel).toBeDefined();
+ const menu = channelsPage.page.locator('.DropDown__menu');
+ await menu.getByText(selectedLevel!.name, {exact: true}).click();
+
+ // Edit the banner text to a custom value
+ const customBannerText = 'TOP SECRET - Handle via COMINT channels only';
+ const bannerTextbox = channelsPage.page.locator('#channel_banner_banner_text_textbox');
+ await expect(bannerTextbox).toBeVisible();
+ await bannerTextbox.fill(customBannerText);
+
+ // Save the changes
+ await configurationTab.save();
+ await channelSettingsModal.close();
+
+ // The channel banner should now show the custom text with the classification color
+ const banner = channelsPage.page.getByTestId('channel_banner_container');
+ await expect(banner).toBeVisible({timeout: 30000});
+ await expect(banner).toContainText(customBannerText);
+
+ const actualBackgroundColor = await banner.evaluate((el) => {
+ return window.getComputedStyle(el).getPropertyValue('background-color');
+ });
+ expect(actualBackgroundColor).toBe(hexToRgb(selectedLevel!.color));
+ });
+});
+
+function hexToRgb(hex: string): string {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ if (!result) {
+ return hex;
+ }
+ return `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})`;
+}
diff --git a/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts
new file mode 100644
index 00000000000..bcc48151c56
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts
@@ -0,0 +1,125 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {Client4} from '@mattermost/client';
+
+const PROPERTY_GROUP = 'access_control';
+const TEMPLATE_OBJECT_TYPE = 'template';
+const CHANNEL_OBJECT_TYPE = 'channel';
+const TARGET_TYPE = 'system';
+const CLASSIFICATION_FIELD_NAME = 'classification';
+const CHANNEL_LINKED_FIELD_NAME = 'channel_classification';
+
+export const TEST_LEVELS = [
+ {name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
+ {name: 'CONFIDENTIAL', color: '#0033A0', rank: 2},
+ {name: 'SECRET', color: '#C8102E', rank: 3},
+ {name: 'TOP SECRET', color: '#FF8C00', rank: 4},
+];
+
+/**
+ * Sets the ClassificationMarkings feature flag via server config.
+ */
+export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
+ const config = await adminClient.getConfig();
+ await adminClient.updateConfig({
+ ...config,
+ FeatureFlags: {
+ ...config.FeatureFlags,
+ ClassificationMarkings: enabled,
+ },
+ } as Awaited>);
+}
+
+/**
+ * Deletes existing classification fields (channel linked, system linked, and template)
+ * to provide a clean slate.
+ */
+export async function deleteClassificationFieldsIfExist(adminClient: Client4) {
+ // Delete channel linked fields first
+ try {
+ const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, TARGET_TYPE, '');
+ for (const f of channelFields.filter((f) => f.name === CHANNEL_LINKED_FIELD_NAME && f.delete_at === 0)) {
+ await adminClient.deletePropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, f.id);
+ }
+ } catch {
+ // May not exist
+ }
+
+ // Delete system linked fields
+ for (const objectType of ['system', 'user'] as const) {
+ try {
+ const linkedFields = await adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, '');
+ for (const f of linkedFields.filter(
+ (f) => f.name === 'system_classification' && f.delete_at === 0 && f.linked_field_id,
+ )) {
+ await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
+ }
+ } catch {
+ // May not exist
+ }
+ }
+
+ // Delete template fields
+ try {
+ const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, TARGET_TYPE);
+ for (const f of fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0)) {
+ await adminClient.deletePropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, f.id);
+ }
+ } catch {
+ // May not exist
+ }
+}
+
+export type ClassificationLevel = {
+ id: string;
+ name: string;
+ color: string;
+ rank: number;
+};
+
+export type SetupResult = {
+ templateFieldId: string;
+ channelFieldId: string;
+ levels: ClassificationLevel[];
+};
+
+/**
+ * Creates the full classification setup: template field + channel linked field.
+ * Returns the created fields and the resolved levels (with server-assigned IDs).
+ */
+export async function setupClassificationWithChannelField(
+ adminClient: Client4,
+ levels: Array<{name: string; color: string; rank: number}> = TEST_LEVELS,
+): Promise {
+ await deleteClassificationFieldsIfExist(adminClient);
+
+ // Create template field
+ const templateField = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
+ name: CLASSIFICATION_FIELD_NAME,
+ type: 'select',
+ target_type: TARGET_TYPE,
+ target_id: '',
+ attrs: {
+ options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})),
+ },
+ permission_field: 'admin',
+ permission_values: 'admin',
+ permission_options: 'admin',
+ } as Parameters[2]);
+
+ // Create channel linked field
+ const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
+ name: CHANNEL_LINKED_FIELD_NAME,
+ type: 'select',
+ target_type: TARGET_TYPE,
+ target_id: '',
+ linked_field_id: templateField.id,
+ } as Parameters[2]);
+
+ // Resolve levels with server-assigned IDs
+ const options = (templateField.attrs?.options ?? []) as ClassificationLevel[];
+ const resolvedLevels = options.sort((a, b) => a.rank - b.rank);
+
+ return {templateFieldId: templateField.id, channelFieldId: channelField.id, levels: resolvedLevels};
+}
diff --git a/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts
index fec6158ab2d..ea18fcbbf72 100644
--- a/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts
@@ -1,14 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {expect, test} from '@mattermost/playwright-lib';
+import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
+import type {PlaywrightExtended} from '@mattermost/playwright-lib';
+
+// Teams created by pw.initSetup() in each test are tracked here and deleted in
+// afterEach so local environments don't accumulate stale teams across runs.
+const createdTeamIds: string[] = [];
+
+async function initSetupTracked(pw: PlaywrightExtended) {
+ const setup = await pw.initSetup();
+ createdTeamIds.push(setup.team.id);
+ return setup;
+}
+
+test.afterEach(async () => {
+ if (createdTeamIds.length === 0) {
+ return;
+ }
+ const ids = createdTeamIds.splice(0);
+ try {
+ const {adminClient} = await getAdminClient({skipLog: true});
+ await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
+ } catch {
+ // Best-effort cleanup
+ }
+});
test(
'sidebar icon updates from globe to lock when channel converted to private via API',
{tag: ['@channels', '@channel_privacy']},
async ({pw}) => {
// # Initialize setup
- const {adminClient, user, team} = await pw.initSetup();
+ const {adminClient, user, team} = await initSetupTracked(pw);
// # Create a public channel
const channel = await adminClient.createChannel(
@@ -47,7 +71,7 @@ test(
{tag: ['@channels', '@channel_privacy']},
async ({pw}) => {
// # Initialize setup
- const {adminClient, user, team} = await pw.initSetup();
+ const {adminClient, user, team} = await initSetupTracked(pw);
// # Create a private channel
const channel = await adminClient.createChannel(
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/content_flagging/deletion-report/deletion-report.spec.ts b/e2e-tests/playwright/specs/functional/channels/content_flagging/deletion-report/deletion-report.spec.ts
index 32e6da12477..30ad41099fd 100644
--- a/e2e-tests/playwright/specs/functional/channels/content_flagging/deletion-report/deletion-report.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/content_flagging/deletion-report/deletion-report.spec.ts
@@ -14,8 +14,8 @@ import {setupContentFlagging, createPost} from './../support';
* 4. Login as the reviewer and navigate to the content review DM
* 5. Verify the deletion report summary table is posted in the reviewer's thread
*/
-test.fixme('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
- const {adminClient, team, user: reviewerUser, userClient: reviewerUserClient} = await pw.initSetup();
+test('Reviewer receives a deletion report summary after removing a flagged post', async ({pw}) => {
+ const {adminClient, team, user: reviewerUser} = await pw.initSetup();
// Create author user..
const authorUser = await pw.random.user('author');
@@ -28,18 +28,27 @@ test.fixme('Reviewer receives a deletion report summary after removing a flagged
const message = `Sensitive 2 post by @${authorUser.username} to be removed`;
const {post} = await createPost(adminClient, authorUserClient, team, authorUser, message);
- // Flag and remove the post
+ // Flag the post
await adminClient.flagPost(post.id, 'Classification mismatch', 'This message contains sensitive data');
// Login as reviewer and navigate to content review DM
- const {channelsPage} = await pw.testBrowser.login(reviewerUser);
+ const {channelsPage, contentReviewPage} = await pw.testBrowser.login(reviewerUser);
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
const lastPost = await channelsPage.centerView.getLastPost();
await lastPost.toContainText(message);
- await reviewerUserClient.removeFlaggedPost(post.id, 'Removing: data spillage confirmed');
+ // Remove the post via the UI. The remove flow still routes through the
+ // skip-confirm step (destructive action), unlike the keep flow which now
+ // bypasses it.
+ await contentReviewPage.setReportCardByPostID(post.id);
+ await contentReviewPage.openViewDetails();
+ await contentReviewPage.waitForRHSVisible();
+ await contentReviewPage.openViewDetails();
+ await contentReviewPage.clickRemoveMessage();
+ await contentReviewPage.enterConfirmationComment('Removing: data spillage confirmed');
+ await contentReviewPage.confirmRemoveWithoutReport();
await channelsPage.goto(team.name, '@content-review');
await channelsPage.toBeVisible();
diff --git a/e2e-tests/playwright/specs/functional/channels/content_flagging/reviewer-actions/reviewer-actions.spec.ts b/e2e-tests/playwright/specs/functional/channels/content_flagging/reviewer-actions/reviewer-actions.spec.ts
index e363300ef84..232412e4ebd 100644
--- a/e2e-tests/playwright/specs/functional/channels/content_flagging/reviewer-actions/reviewer-actions.spec.ts
+++ b/e2e-tests/playwright/specs/functional/channels/content_flagging/reviewer-actions/reviewer-actions.spec.ts
@@ -5,13 +5,13 @@ import {test} from '@mattermost/playwright-lib';
import {setupContentFlagging, createPost, verifyAuthorNotification} from './../support';
-/** @objective Verify Retained and Removed Flagged posts do not appear in RHS after once reviewed
+/** @objective Verify Removed Flagged posts show appropriate status and do not show the post message
* @testcase
* 1. Create three users and add them as reviewers to a team
* 2. Setup content flagging with the three users as reviewers
* 3. Create a post and flag it
- * 4. As Reviewer 1, Retain the flagged post and verify the status is updated to 'Retained'
- * 5. As Reviewer 2, Verify the flagged post status is 'Retained'
+ * 4. As Reviewer 1, walk through the multi-step removal flow (form → report generated → remove permanently)
+ * 5. As Reviewer 2, verify the flagged post status is 'Removed' and the message has been replaced
*/
test('Verify Removed Flagged posts show appropriate status and do not show the post message', async ({pw}) => {
const {adminClient, team, user, userClient, adminUser} = await pw.initSetup();
@@ -72,7 +72,10 @@ test('Verify Removed Flagged posts show appropriate status and do not show the p
});
await secondContentReviewPage.clickRemoveMessage();
await secondContentReviewPage.enterConfirmationComment(commentRemove);
- await secondContentReviewPage.confirmRemove();
+
+ // New multi-step flow: Continue → wait for report to generate → Remove permanently
+ await secondContentReviewPage.submitFormAndWaitForReport();
+ await secondContentReviewPage.confirmRemovePermanently();
await setupContentFlagging(adminClient, [adminUser.id, secondUserID, thirdUserID]);
const {channelsPage: channelsPageThird, contentReviewPage: contentReviewPageThird} =
diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts
index c08c4877f99..d130f7cb37b 100644
--- a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts
+++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts
@@ -49,6 +49,7 @@ export type CustomProfileAttribute = {
visibility?: string;
managed?: string;
options?: {name: string; color: string}[];
+ display_name?: string;
};
};
diff --git a/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_profiles.spec.ts b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_profiles.spec.ts
new file mode 100644
index 00000000000..0f079c5ba40
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/direct_messages_modal/group_message_profiles.spec.ts
@@ -0,0 +1,163 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Channel} from '@mattermost/types/channels';
+import type {UserProfile} from '@mattermost/types/users';
+import type {Page} from '@playwright/test';
+
+import {expect, test} from '@mattermost/playwright-lib';
+
+/**
+ * @objective Verify that a group message whose channel has fallen out of the sidebar (because the user
+ * has more DMs/GMs than the configured "Number of direct messages to show" limit) still appears in the
+ * Direct Messages modal with its members fully loaded — i.e. with a non-zero member count and the
+ * participant usernames as its name.
+ */
+test(
+ "MM-65058 Direct Messages modal should load group members for GMs which haven't been loaded otherwise",
+ {tag: '@direct_messages'},
+ async ({pw}) => {
+ const {adminClient, user, userClient, team} = await pw.initSetup({withDefaultProfileImage: false});
+
+ // Use a lower visible DM limit than the UI normally lets you use to speed up this test
+ const totalGms = 2;
+ const visibleLimit = 1;
+
+ // # Limit the user's visible DMs/GMs in the sidebar so one GM falls off the sidebar
+ await userClient.savePreferences(user.id, [
+ {
+ user_id: user.id,
+ category: 'sidebar_settings',
+ name: 'limit_visible_dms_gms',
+ value: visibleLimit.toString(),
+ },
+ ]);
+
+ // # Create enough users to populate 11 GMs with unique users
+ const users = [];
+ for (let i = 0; i < totalGms * 2; i++) {
+ const user = await pw.createNewUserProfile(adminClient, {prefix: `mm65058gm${i}`});
+ users.push(user);
+ }
+
+ // # Log the user in and open the channels page
+ const {page, channelsPage} = await pw.testBrowser.login(user);
+ await channelsPage.goto(team.name, 'town-square');
+ await channelsPage.toBeVisible();
+
+ // # Create 11 GMs using the Direct Channels modal
+ const gmChannels = [];
+ for (let i = 0; i < totalGms; i++) {
+ const memberA = users[i * 2];
+ const memberB = users[i * 2 + 1];
+
+ // # Open the modal
+ const dialog = await channelsPage.openDirectChannelsModal();
+
+ // # Select the users and create the channel
+ await dialog.selectUser(memberA);
+ await dialog.selectUser(memberB);
+ await dialog.goToChannel();
+
+ // # Make a post in the channel to ensure that it has a last_post_at value
+ await channelsPage.postMessage(`gm message ${i}`);
+
+ // # Save the channel's information for later
+ gmChannels.push({
+ channel: await getCurrentChannel(page),
+ members: [memberA, memberB],
+ });
+ }
+
+ const targetGm = gmChannels[0];
+ const otherGms = gmChannels.slice(1);
+
+ // # Refresh the app and go back to Town Square
+ await channelsPage.goto(team.name, 'town-square');
+
+ // * Verify the target GM is not present in the sidebar to ensure that the sidebar hasn't loaded it
+ await expect(page.locator(`#sidebarItem_${targetGm.channel.name}`)).toHaveCount(0);
+
+ // * Wait until the other GMs are loaded and present in the sidebar
+ for (const otherGm of otherGms) {
+ const otherGmEntry = page.locator(`#sidebarItem_${otherGm.channel.name}`);
+
+ await expect(otherGmEntry).toHaveCount(1);
+ await expect(otherGmEntry).toContainText(gmChannelDisplayName(otherGm.members));
+ }
+
+ // * Verify that the members of the target GM haven't been loaded and the members of other GMs have
+ await assertChannelUsersNotLoaded(page, targetGm.channel.id);
+ for (const otherGm of otherGms) {
+ await assertChannelUsersLoaded(page, otherGm.channel.id, otherGm.members);
+ }
+
+ // # Open the Direct Messages modal again
+ const dialog = await channelsPage.openDirectChannelsModal();
+
+ // # Wait for the list to populate
+ const rows = dialog.container.locator('#multiSelectList .more-modal__row');
+ await expect.poll(async () => rows.count()).toBeGreaterThanOrEqual(totalGms);
+
+ // * Verify the modal contains an entry for every GM the user has, including the one that fell
+ // * out of the sidebar
+ for (const {channel, members} of gmChannels) {
+ // Each GM row renders the member usernames joined by ', '. We use the second member's
+ // username (which is unique per GM) to locate the corresponding row.
+ const usernameMarker = `@${members[1].username}`;
+ const gmRow = rows.filter({hasText: usernameMarker});
+
+ // * Verify the row is rendered
+ await expect(gmRow, `expected to find a row in the DM modal for GM ${channel.id}`).toHaveCount(1);
+
+ // * Verify the GM icon shows the correct member count (channel members minus current user)
+ await expect(
+ gmRow.locator('.more-modal__gm-icon'),
+ `expected GM ${channel.id} to show a member count of ${members.length}`,
+ ).toHaveText(members.length.toString());
+
+ // * Verify the row's name section includes every participant's username
+ const nameContainer = gmRow.locator('.more-modal__name');
+ for (const participant of members) {
+ await expect(
+ nameContainer,
+ `expected GM ${channel.id} to include @${participant.username} in its name`,
+ ).toContainText(`@${participant.username}`);
+ }
+ }
+
+ // * Double check that the members of the target GM have been loaded now
+ await assertChannelUsersLoaded(page, targetGm.channel.id, targetGm.members);
+ },
+);
+
+async function getCurrentChannel(page: Page) {
+ return await page.evaluate(
+ 'store.getState().entities.channels.channels[store.getState().entities.channels.currentChannelId]',
+ );
+}
+
+function gmChannelDisplayName(users: UserProfile[]) {
+ return users
+ .toSorted((a, b) => {
+ return a.username.localeCompare(b.username, undefined, {numeric: true});
+ })
+ .map((user) => user.username)
+ .join(', ');
+}
+
+async function assertChannelUsersLoaded(page: Page, channelId: string, expectedUsers: UserProfile[]) {
+ // profilesInChannel contains Sets which aren't serializable for return from page.evaluate
+ const loadedIds = await page.evaluate(
+ `Array.from(store.getState().entities.users.profilesInChannel['${channelId}'])`,
+ );
+
+ await expect(loadedIds).toHaveLength(expectedUsers.length);
+ await expect(loadedIds).toEqual(expect.arrayContaining(expectedUsers.map((user) => user.id)));
+}
+
+async function assertChannelUsersNotLoaded(page: Page, channelId: string) {
+ const loadedIds = await page.evaluate(`store.getState().entities.users.profilesInChannel['${channelId}']`);
+
+ await expect(loadedIds).toBeUndefined();
+}
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/abac/user_attributes/display_name_in_selector.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/user_attributes/display_name_in_selector.spec.ts
new file mode 100644
index 00000000000..2b3bcc48edf
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/system_console/abac/user_attributes/display_name_in_selector.spec.ts
@@ -0,0 +1,207 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {Client4} from '@mattermost/client';
+import type {UserPropertyField} from '@mattermost/types/properties';
+
+import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
+
+import {
+ CustomProfileAttribute,
+ deleteCustomProfileAttributes,
+ setupCustomProfileAttributeFields,
+} from '../../../channels/custom_profile_attributes/helpers';
+import {getPolicyIdByName} from '../support';
+
+type FieldsMap = Record;
+
+async function clearExistingFields(client: Client4): Promise {
+ try {
+ const existing = await client.getCustomProfileAttributeFields();
+ if (existing?.length) {
+ const map: FieldsMap = {};
+ for (const f of existing) {
+ map[f.id] = f;
+ }
+ await deleteCustomProfileAttributes(client, map);
+ }
+ } catch {
+ // No fields to clean up
+ }
+}
+
+test.describe('ABAC Attribute Selector - display_name rendering and filtering', () => {
+ /**
+ * @objective Verify the attribute selector renders display_name when set,
+ * falls back to `name`, filters on both, and persists the CEL identifier
+ * in saved policy expressions.
+ *
+ * @precondition
+ * Two admin-managed CPA fields seeded via REST: `dept_head` with
+ * display_name 'Department Head', and `office` with no display_name.
+ */
+ test(
+ 'renders and filters by display_name while persisting CEL identifier',
+ {tag: '@user_attributes'},
+ async ({pw}) => {
+ test.setTimeout(120000);
+
+ await pw.ensureLicense();
+ await pw.skipIfNoLicense();
+
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ await clearExistingFields(adminClient);
+
+ const seedAttributes: CustomProfileAttribute[] = [
+ {
+ name: 'dept_head',
+ type: 'text',
+ attrs: {
+ display_name: 'Department Head',
+ visibility: 'when_set',
+ managed: 'admin',
+ },
+ },
+ {
+ name: 'office',
+ type: 'text',
+ attrs: {
+ visibility: 'when_set',
+ managed: 'admin',
+ },
+ },
+ ];
+
+ const fieldsMap = await setupCustomProfileAttributeFields(adminClient, seedAttributes);
+
+ const policyName = `Display Name Selector ${pw.random.id()}`;
+ let policyId: string | null = null;
+
+ try {
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ const {page} = systemConsolePage;
+
+ await navigateToABACPage(page);
+ await enableABAC(page);
+
+ // # Open the new-policy form
+ await page.getByRole('button', {name: 'Add policy'}).click();
+ await page.waitForLoadState('networkidle');
+
+ const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
+ await nameInput.waitFor({state: 'visible', timeout: 10000});
+ await nameInput.fill(policyName);
+
+ // # Add an attribute rule and open its selector
+ const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
+ await expect(addAttributeButton).toBeEnabled({timeout: 10000});
+ await addAttributeButton.click();
+
+ const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
+ await attributeButton.waitFor({state: 'visible', timeout: 5000});
+
+ const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
+
+ if (!(await attributeMenu.isVisible({timeout: 1000}).catch(() => false))) {
+ await attributeButton.click();
+ }
+ await attributeMenu.waitFor({state: 'visible', timeout: 5000});
+
+ const deptHeadItem = page.locator('[id^="attribute-selector-menu"] li:has-text("Department Head")');
+ const officeItem = page.locator('[id^="attribute-selector-menu"] li:has-text("office")');
+
+ // * Both fields render: 'Department Head' (display_name) and 'office' (name fallback)
+ await expect(deptHeadItem).toBeVisible();
+ await expect(officeItem).toBeVisible();
+
+ const filterInput = attributeMenu.locator('.attribute-selector-search input');
+ await filterInput.waitFor({state: 'visible', timeout: 5000});
+
+ // * Filter by display_name keeps only 'Department Head'
+ await filterInput.fill('department');
+ await expect(deptHeadItem).toBeVisible();
+ await expect(officeItem).toHaveCount(0);
+
+ // * Filter by CEL identifier keeps only 'Department Head'
+ await filterInput.fill('');
+ await filterInput.fill('dept_head');
+ await expect(deptHeadItem).toBeVisible();
+ await expect(officeItem).toHaveCount(0);
+
+ // * Filter on the no-display_name field's `name` keeps only 'office'
+ await filterInput.fill('');
+ await filterInput.fill('office');
+ await expect(officeItem).toBeVisible();
+ await expect(deptHeadItem).toHaveCount(0);
+
+ // # Select 'Department Head'
+ await filterInput.fill('');
+ await deptHeadItem.first().click({force: true});
+
+ // * The trigger button shows display_name, not the CEL identifier
+ await expect(attributeButton).toContainText('Department Head', {timeout: 5000});
+
+ // # Wait for the attribute-selector popover to close before opening the next menu
+ const attributeMenuPopover = page.locator('[id^="attribute-selector-menu"]');
+ await attributeMenuPopover.waitFor({state: 'hidden', timeout: 5000});
+
+ const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
+ await operatorButton.waitFor({state: 'visible', timeout: 5000});
+ await operatorButton.click();
+
+ const operatorMenu = page.locator('[id^="operator-selector-menu"]');
+ await operatorMenu.waitFor({state: 'visible', timeout: 5000});
+ await operatorMenu.locator('li:has-text("is")').first().click();
+
+ const valueInput = page.locator('.values-editor__simple-input').first();
+ await valueInput.waitFor({state: 'visible', timeout: 10000});
+ await valueInput.fill('engineering');
+ await valueInput.press('Tab');
+
+ const saveButton = page.getByRole('button', {name: 'Save'});
+ await expect(saveButton).toBeEnabled({timeout: 10000});
+
+ // # Click Save and wait on the create-policy PUT (button unmounts on navigate)
+ const createPolicyResponse = page.waitForResponse(
+ (r) => /\/access_control_policies(?:\?|$)/.test(r.url()) && r.request().method() === 'PUT',
+ {timeout: 15000},
+ );
+ await saveButton.click();
+ const createResponse = await createPolicyResponse;
+ expect(createResponse.ok()).toBe(true);
+
+ await page.waitForURL(/\/admin_console\/system_attributes\/membership_policies/, {timeout: 10000});
+
+ // * The persisted CEL uses the canonical identifier, not display_name
+ policyId = await getPolicyIdByName(adminClient, policyName);
+ expect(policyId).not.toBeNull();
+
+ const policy = await (adminClient as any).doFetch(
+ `${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
+ {method: 'GET'},
+ );
+
+ const rules = (policy?.rules || []) as Array<{actions?: string[]; expression?: string}>;
+ const membershipRule = rules.find((r) => r.actions?.includes('membership')) || rules[0];
+
+ expect(membershipRule).toBeDefined();
+ expect(membershipRule?.expression || '').toContain('user.attributes.dept_head');
+ expect(membershipRule?.expression || '').not.toContain('Department Head');
+ } finally {
+ if (policyId) {
+ try {
+ await (adminClient as any).doFetch(
+ `${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
+ {method: 'DELETE'},
+ );
+ } catch {
+ // best-effort cleanup
+ }
+ }
+
+ await deleteCustomProfileAttributes(adminClient, fieldsMap);
+ }
+ },
+ );
+});
diff --git a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts
index 44053d653bb..e4f64c21a36 100644
--- a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts
+++ b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts
@@ -507,3 +507,231 @@ test('should disable Intune inputs when toggle is off', async ({pw}) => {
expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(false);
expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(false);
});
+
+/**
+ * @objective Verify timer settings are disabled when Mobile Ephemeral Mode is not enabled, and become editable when enabled
+ */
+test(
+ 'should disable Mobile Ephemeral Mode sub-settings when toggle is off and enable them when toggle is on',
+ {tag: '@mobile_ephemeral_mode'},
+ async ({pw}) => {
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ const license = await adminClient.getClientLicenseOld();
+
+ test.skip(
+ license.SkuShortName !== 'advanced',
+ 'Skipping test - server does not have enterprise advanced license',
+ );
+
+ const config = await adminClient.getConfig();
+ test.skip(
+ config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
+ 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
+ );
+
+ if (!adminUser) {
+ throw new Error('Failed to create admin user');
+ }
+
+ // # Log in as admin
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+
+ // # Visit system console
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ // # Go to Mobile Security section
+ await systemConsolePage.sidebar.mobileSecurity.click();
+ await systemConsolePage.mobileSecurity.toBeVisible();
+
+ // * Verify Mobile Ephemeral Mode toggle is off by default
+ await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeFalse();
+
+ // * Verify all sub-settings are disabled
+ expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(true);
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
+ expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(true);
+
+ // # Enable Mobile Ephemeral Mode toggle
+ await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
+
+ // * Verify all sub-settings are now enabled
+ expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(false);
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
+ expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(false);
+ },
+);
+
+/**
+ * @objective Verify all Mobile Ephemeral Mode settings persist after save and navigation
+ */
+test(
+ 'should save and persist all Mobile Ephemeral Mode settings after navigation',
+ {tag: '@mobile_ephemeral_mode'},
+ async ({pw}) => {
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ const license = await adminClient.getClientLicenseOld();
+
+ test.skip(
+ license.SkuShortName !== 'advanced',
+ 'Skipping test - server does not have enterprise advanced license',
+ );
+
+ const config = await adminClient.getConfig();
+ test.skip(
+ config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
+ 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
+ );
+
+ if (!adminUser) {
+ throw new Error('Failed to create admin user');
+ }
+
+ // # Enable Mobile Ephemeral Mode setting via config API
+ config.MobileEphemeralModeSettings.Enable = true;
+ await adminClient.updateConfig(config);
+
+ // # Log in as admin
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+
+ // # Visit system console
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ // # Go to Mobile Security section
+ await systemConsolePage.sidebar.mobileSecurity.click();
+ await systemConsolePage.mobileSecurity.toBeVisible();
+
+ // # Set custom values
+ await systemConsolePage.mobileSecurity.disconnectionTimeout.fill('120');
+ await systemConsolePage.mobileSecurity.offlinePersistenceTimer.fill('48');
+ await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('14');
+
+ // # Save settings
+ await systemConsolePage.mobileSecurity.save();
+ await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
+
+ // # Navigate away and back
+ await systemConsolePage.sidebar.users.click();
+ await systemConsolePage.users.toBeVisible();
+ await systemConsolePage.sidebar.mobileSecurity.click();
+ await systemConsolePage.mobileSecurity.toBeVisible();
+
+ // * Verify Mobile Ephemeral Mode is still enabled
+ await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeTrue();
+
+ // * Verify all values persisted correctly
+ expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('120');
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('48');
+ expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('14');
+ },
+);
+
+/**
+ * @objective Verify offline persistence timer is disabled when auto cache cleanup is set to 0 (zero-persistence mode)
+ */
+test(
+ 'should disable offline persistence timer when auto cache cleanup is set to zero',
+ {tag: '@mobile_ephemeral_mode'},
+ async ({pw}) => {
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ const license = await adminClient.getClientLicenseOld();
+
+ test.skip(
+ license.SkuShortName !== 'advanced',
+ 'Skipping test - server does not have enterprise advanced license',
+ );
+
+ const config = await adminClient.getConfig();
+ test.skip(
+ config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
+ 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
+ );
+
+ if (!adminUser) {
+ throw new Error('Failed to create admin user');
+ }
+
+ // # Enable Mobile Ephemeral Mode setting via config API
+ config.MobileEphemeralModeSettings.Enable = true;
+ await adminClient.updateConfig(config);
+
+ // # Log in as admin
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+
+ // # Visit system console
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ // # Go to Mobile Security section
+ await systemConsolePage.sidebar.mobileSecurity.click();
+ await systemConsolePage.mobileSecurity.toBeVisible();
+
+ // * Verify offline persistence timer is enabled
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
+
+ // # Set auto cache cleanup to 0
+ await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
+ await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('0');
+
+ // * Verify offline persistence timer is now disabled
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
+
+ // # Set auto cache cleanup back to 7
+ await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
+ await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('7');
+
+ // * Verify offline persistence timer is enabled again
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
+ },
+);
+
+/**
+ * @objective Verify Mobile Ephemeral Mode settings show correct defaults on first enable
+ */
+test(
+ 'should show correct default values when Mobile Ephemeral Mode is first enabled',
+ {tag: '@mobile_ephemeral_mode'},
+ async ({pw}) => {
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ const license = await adminClient.getClientLicenseOld();
+
+ test.skip(
+ license.SkuShortName !== 'advanced',
+ 'Skipping test - server does not have enterprise advanced license',
+ );
+
+ const config = await adminClient.getConfig();
+ test.skip(
+ config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
+ 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
+ );
+
+ if (!adminUser) {
+ throw new Error('Failed to create admin user');
+ }
+
+ // # Log in as admin
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+
+ // # Visit system console
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ // # Go to Mobile Security section
+ await systemConsolePage.sidebar.mobileSecurity.click();
+ await systemConsolePage.mobileSecurity.toBeVisible();
+
+ // # Enable Mobile Ephemeral Mode
+ await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
+
+ // * Verify default values
+ expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('60');
+ expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('24');
+ expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('7');
+ },
+);
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 57881df8610..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);
@@ -498,6 +498,73 @@ test.describe('System Console - Classification markings', () => {
},
);
+ /**
+ * @objective Verify that modifying a preset's levels (rename, delete, add) automatically
+ * switches the dropdown to "Custom classification levels", and selecting a real preset
+ * again removes the Custom option from the dropdown.
+ */
+ test(
+ 'MM-T6212 classification markings: modifying a preset switches dropdown to Custom',
+ {tag: ['@system_console', '@classification_markings']},
+ async ({pw}) => {
+ const {adminUser, adminClient} = await pw.initSetup();
+
+ await setClassificationMarkingsFeatureFlag(adminClient, true);
+ await deleteClassificationMarkingsFieldIfExists(adminClient);
+
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ const {page} = systemConsolePage;
+ await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
+ await page.waitForLoadState('networkidle');
+
+ // # Enable classification markings and select NATO preset
+ await page.locator('input[name="classificationEnabled"][value="true"]').click();
+ await selectClassificationPreset(page, 'NATO');
+
+ const presetControl = page.getByTestId('classificationPreset');
+ await expect(presetControl).toContainText('NATO');
+
+ // # Rename the first level — this should switch to Custom
+ const firstLevelInput = page.getByLabel('Classification level name').first();
+ await firstLevelInput.clear();
+ await firstLevelInput.fill('MY CUSTOM LEVEL');
+
+ // * Preset dropdown should now show "Custom classification levels"
+ await expect(presetControl).toContainText('Custom classification levels');
+
+ // # Open the preset dropdown and verify "Custom classification levels" is listed
+ await presetControl.click();
+ const menu = page.locator('.DropDown__menu');
+ await expect(menu).toBeVisible();
+ await expect(menu.getByText('Custom classification levels', {exact: true})).toBeVisible();
+
+ // # Select a real preset (Canada) — should show the confirmation modal
+ await menu.getByText('Canada', {exact: true}).click();
+ await expect(page.getByText('Change classification preset?')).toBeVisible();
+
+ // # Confirm the preset change
+ await page.getByRole('button', {name: 'Change preset'}).click();
+
+ // * Dropdown now shows Canada, no longer Custom
+ await expect(presetControl).toContainText('Canada');
+
+ // # Open the dropdown again and verify Custom is no longer listed
+ await presetControl.click();
+ const menuAfterSwitch = page.locator('.DropDown__menu');
+ await expect(menuAfterSwitch).toBeVisible();
+ await expect(menuAfterSwitch.getByText('Custom classification levels', {exact: true})).not.toBeVisible();
+
+ // # Close menu by pressing Escape
+ await page.keyboard.press('Escape');
+
+ // # Delete a level from the Canada preset
+ await page.getByRole('button', {name: 'Delete level'}).first().click();
+
+ // * Should switch back to Custom
+ await expect(presetControl).toContainText('Custom classification levels');
+ },
+ );
+
/**
* @objective Validate that saving with global banner enabled but no level selected shows an error.
*/
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 34cbec1b01f..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';
@@ -35,6 +35,16 @@ export async function setClassificationMarkingsFeatureFlag(adminClient: Client4,
* (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors.
*/
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
+ // Delete channel linked fields first (created by channel classification tests).
+ try {
+ const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', TARGET_TYPE, '');
+ for (const f of channelFields.filter((f) => f.name === 'channel_classification' && f.delete_at === 0)) {
+ await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', f.id);
+ }
+ } catch {
+ // May not exist; ignore.
+ }
+
// Clean up both the current 'system' object type and the legacy 'user' object type
// to handle stale data from earlier versions of the feature.
for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) {
@@ -91,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/system_users/column_sort.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/column_sort.spec.ts
index cf05f7f87f3..f451a500f55 100644
--- a/e2e-tests/playwright/specs/functional/system_console/system_users/column_sort.spec.ts
+++ b/e2e-tests/playwright/specs/functional/system_console/system_users/column_sort.spec.ts
@@ -53,7 +53,7 @@ test.describe('System Console - Users table sorting', () => {
}
expect(emails.length).toBeGreaterThan(3);
- const sorted = [...emails].sort((a, b) => a.localeCompare(b));
+ const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
if (sortDirection === 'descending') {
sorted.reverse();
}
@@ -81,7 +81,7 @@ test.describe('System Console - Users table sorting', () => {
}
expect(emails.length).toBeGreaterThan(3);
- const sorted = [...emails].sort((a, b) => a.localeCompare(b));
+ const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
if (reversedDirection === 'descending') {
sorted.reverse();
}
diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts
index c83cf9542f3..cccb2305068 100644
--- a/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts
+++ b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts
@@ -36,6 +36,15 @@ let cpaFieldNames: {
skills: string;
};
+/** Rendered label for each CPA field: attrs.display_name when set, else field.name. */
+let cpaDisplayLabels: {
+ department: string;
+ workEmail: string;
+ personalWebsite: string;
+ location: string;
+ skills: string;
+};
+
let testUserAttributes: CustomProfileAttribute[];
let team: Team;
@@ -76,6 +85,14 @@ test.describe('System Console - Admin User Profile Editing', () => {
location: `UAAE_Location_${suffix}`,
skills: `UAAE_Skills_${suffix}`,
};
+ // Mirror display_name values from testUserAttributes; absent display_name falls back to name.
+ cpaDisplayLabels = {
+ department: cpaFieldNames.department,
+ workEmail: 'Work Email',
+ personalWebsite: 'Personal Website',
+ location: cpaFieldNames.location,
+ skills: cpaFieldNames.skills,
+ };
testUserAttributes = [
{
name: cpaFieldNames.department,
@@ -92,6 +109,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
attrs: {
value_type: 'email',
visibility: 'when_set',
+ display_name: 'Work Email',
},
},
{
@@ -101,6 +119,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
attrs: {
value_type: 'url',
visibility: 'when_set',
+ display_name: 'Personal Website',
},
},
{
@@ -242,8 +261,8 @@ test.describe('System Console - Admin User Profile Editing', () => {
await systemConsolePage.page.waitForURL(`**/admin_console/user_management/user/${testUser.id}`);
await systemConsolePage!.users.userDetail.userCard.container.waitFor({state: 'visible'});
const {userCard} = systemConsolePage!.users.userDetail;
- await expect(userCard.getFieldInputByExactLabel(cpaFieldNames.department)).toBeVisible({timeout: 30_000});
- await expect(userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail)).toBeVisible({timeout: 30_000});
+ await expect(userCard.getFieldInputByExactLabel(cpaDisplayLabels.department)).toBeVisible({timeout: 30_000});
+ await expect(userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail)).toBeVisible({timeout: 30_000});
// Remove the intercept now that field visibility is confirmed.
// Keeping it active through the test body would intercept the save API call
@@ -271,7 +290,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
const {userCard} = userDetail;
// # Find and edit Department field (custom text attribute)
- const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
+ const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
await departmentInput.clear();
await departmentInput.fill('Marketing');
@@ -300,9 +319,8 @@ test.describe('System Console - Admin User Profile Editing', () => {
// * Verify custom user attributes are present
for (const field of testUserAttributes) {
- await expect(
- systemConsolePage!.page.locator('label').filter({hasText: new RegExp(field.name)}),
- ).toBeVisible();
+ const label = field.attrs?.display_name || field.name;
+ await expect(systemConsolePage!.page.locator('label').filter({hasText: label})).toBeVisible();
}
// * Verify we have input fields (at least 4-5 total)
@@ -339,7 +357,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
const {userCard} = userDetail;
// # Find Location select field
- const locationSelect = userCard.getSelectByExactLabel(cpaFieldNames.location);
+ const locationSelect = userCard.getSelectByExactLabel(cpaDisplayLabels.location);
// # Get the first available option (since we can't predict the option value/ID)
const firstOption = await locationSelect.locator('option').nth(1); // Skip the default "Select an option"
@@ -363,11 +381,11 @@ test.describe('System Console - Admin User Profile Editing', () => {
const {userCard} = userDetail;
// * Verify Skills multiselect component is displayed
- const skillsColumn = userCard.getCpaMultiselectContainer(cpaFieldNames.skills);
+ const skillsColumn = userCard.getCpaMultiselectContainer(cpaDisplayLabels.skills);
await expect(skillsColumn).toBeVisible();
// # Make a change to a different field to trigger save state
- const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
+ const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
await departmentInput.fill('Engineering Updated');
// # Verify save button becomes enabled
@@ -407,7 +425,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
});
try {
// # Find CPA email field (Work Email)
- const workEmailInput = userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail);
+ const workEmailInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail);
await workEmailInput.scrollIntoViewIfNeeded();
const originalEmail = await workEmailInput.inputValue();
@@ -416,7 +434,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
await workEmailInput.fill('not-an-email');
// * Verify inline validation error appears
- const fieldError = userCard.getFieldError(cpaFieldNames.workEmail);
+ const fieldError = userCard.getFieldError(cpaDisplayLabels.workEmail);
await expect(fieldError).toBeVisible({timeout: 30000});
await expect(fieldError).toContainText('Invalid email address');
@@ -461,7 +479,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
});
try {
// # Find custom URL field (Personal Website)
- const urlInput = userCard.getFieldInputByExactLabel(cpaFieldNames.personalWebsite);
+ const urlInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.personalWebsite);
const originalUrl = await urlInput.inputValue();
// # Enter invalid URL (specifically the one mentioned: "<%>")
@@ -469,7 +487,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
await urlInput.fill('<%>');
// * Verify inline validation error appears
- const fieldError = userCard.getFieldError(cpaFieldNames.personalWebsite);
+ const fieldError = userCard.getFieldError(cpaDisplayLabels.personalWebsite);
await expect(fieldError).toBeVisible();
await expect(fieldError).toContainText('Invalid URL');
@@ -511,14 +529,14 @@ test.describe('System Console - Admin User Profile Editing', () => {
});
try {
// # Find custom email field (Work Email)
- const workEmailInput = userCard.getFieldInputByExactLabel(cpaFieldNames.workEmail);
+ const workEmailInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.workEmail);
// # Enter invalid email
await workEmailInput.clear();
await workEmailInput.fill('not-an-email-either');
// * Verify inline validation error appears
- const fieldError = userCard.getFieldError(cpaFieldNames.workEmail);
+ const fieldError = userCard.getFieldError(cpaDisplayLabels.workEmail);
await expect(fieldError).toBeVisible();
await expect(fieldError).toContainText('Invalid email address');
@@ -541,7 +559,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
await expect(userDetail.cancelButton).not.toBeVisible();
// # Make a change to trigger save needed state
- const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
+ const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
const originalValue = await departmentInput.inputValue();
await departmentInput.clear();
await departmentInput.fill('Changed Value');
@@ -573,7 +591,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
await userCard.emailInput.clear();
await userCard.emailInput.fill(newEmail);
- const departmentInput = userCard.getFieldInputByExactLabel(cpaFieldNames.department);
+ const departmentInput = userCard.getFieldInputByExactLabel(cpaDisplayLabels.department);
await departmentInput.clear();
await departmentInput.fill('Sales');
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 be0aeea8531..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
@@ -165,14 +165,14 @@ test.describe('System Console - User Attributes Management', () => {
await expect(nameInput).toBeVisible();
// # Type attribute name (must be a valid CEL identifier — no spaces)
- await nameInput.fill('Test_Department');
+ await nameInput.fill('test_department');
await nameInput.blur();
await sp.saveAndWaitForSettled();
// * Verify the field was created by fetching from API
const fieldsMap = await getFieldsMap(adminClient);
- const createdField = Object.values(fieldsMap).find((f) => f.name === 'Test_Department');
+ const createdField = Object.values(fieldsMap).find((f) => f.name === 'test_department');
expect(createdField).toBeDefined();
expect(createdField!.type).toBe('text');
@@ -195,7 +195,7 @@ test.describe('System Console - User Attributes Management', () => {
// # Type attribute name (must be a valid CEL identifier — no spaces)
const nameInput = sp.lastNameInput();
- await nameInput.fill('Office_Location');
+ await nameInput.fill('office_location');
await nameInput.blur();
// # Change type to Select (use selectLastType so the index stays correct
@@ -212,7 +212,7 @@ test.describe('System Console - User Attributes Management', () => {
// * Verify field was created with correct type via API
const fieldsMap = await getFieldsMap(adminClient);
- const createdField = Object.values(fieldsMap).find((f) => f.name === 'Office_Location');
+ const createdField = Object.values(fieldsMap).find((f) => f.name === 'office_location');
expect(createdField).toBeDefined();
expect(createdField!.type).toBe('select');
expect(createdField!.attrs.options).toBeDefined();
@@ -233,16 +233,16 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
- const attributes: CustomProfileAttribute[] = [{name: 'Old_Name', type: 'text'}];
+ const attributes: CustomProfileAttribute[] = [{name: 'old_name', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
// # Navigate to User Attributes page
await sp.goto();
- const nameInputLocator = sp.nameInputByValue('Old_Name');
- await expect(nameInputLocator).toBeVisible();
- await nameInputLocator.focus();
- await nameInputLocator.fill('New_Name');
+ const nameInput = sp.nameInputByValue('old_name');
+ await expect(nameInput).toBeVisible();
+ await nameInput.focus();
+ await nameInput.fill('new_name');
// blur via keyboard — the CSS-attribute locator no longer matches
// after fill() so calling .blur() on it would time out.
await sp.page.keyboard.press('Tab');
@@ -251,7 +251,7 @@ test.describe('System Console - User Attributes Management', () => {
// * Verify field was updated via API
const updatedMap = await getFieldsMap(adminClient);
- expect(Object.values(updatedMap).find((f) => f.name === 'New_Name')).toBeDefined();
+ expect(Object.values(updatedMap).find((f) => f.name === 'new_name')).toBeDefined();
await cleanupFields(adminClient, {...fieldsMap, ...updatedMap});
});
@@ -268,7 +268,7 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
- const attributes: CustomProfileAttribute[] = [{name: 'To_Delete', type: 'text'}];
+ const attributes: CustomProfileAttribute[] = [{name: 'to_delete', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
@@ -276,7 +276,7 @@ test.describe('System Console - User Attributes Management', () => {
await sp.goto();
// * Verify the attribute exists
- await expect(sp.nameInputByValue('To_Delete')).toBeVisible();
+ await expect(sp.nameInputByValue('to_delete')).toBeVisible();
// # Open dot menu for the field
await sp.openDotMenu(fieldId);
@@ -291,7 +291,7 @@ test.describe('System Console - User Attributes Management', () => {
// * Verify field was deleted via API
const updatedMap = await getFieldsMap(adminClient);
- expect(Object.values(updatedMap).find((f) => f.name === 'To_Delete')).toBeUndefined();
+ expect(Object.values(updatedMap).find((f) => f.name === 'to_delete')).toBeUndefined();
await cleanupFields(adminClient, updatedMap);
});
@@ -321,13 +321,10 @@ test.describe('System Console - User Attributes Management', () => {
// # Click "Duplicate attribute"
await sp.duplicateAttribute();
- // * Verify a copy row appeared (server generates "Original (copy)" as the default name)
- await expect(sp.nameInputByValue('Original (copy)')).toBeVisible();
+ // * Verify a copy row appeared with "_copy" suffix in the name
+ await expect(sp.nameInputByValue('Original_copy')).toBeVisible();
- // # Rename the copy to a valid CEL identifier.
- // "Original (copy)" contains spaces and parentheses which the server rejects with 422.
- // Use lastNameInput() for the fill/blur — it's position-based (.last()) so it stays
- // valid after the value changes, unlike the value-based nameInputByValue locator.
+ // # Rename the copy to a valid CEL identifier (server rejects spaces/parens with 422)
const copyInput = sp.lastNameInput();
await copyInput.fill('Original_copy');
await copyInput.blur();
@@ -354,7 +351,7 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
- const attributes: CustomProfileAttribute[] = [{name: 'Visibility_Test', type: 'text'}];
+ const attributes: CustomProfileAttribute[] = [{name: 'visibility_test', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
@@ -371,7 +368,7 @@ test.describe('System Console - User Attributes Management', () => {
// * Verify visibility was updated via API
const updatedMap = await getFieldsMap(adminClient);
- const updatedField = Object.values(updatedMap).find((f) => f.name === 'Visibility_Test');
+ const updatedField = Object.values(updatedMap).find((f) => f.name === 'visibility_test');
expect(updatedField).toBeDefined();
expect(updatedField!.attrs.visibility).toBe('hidden');
@@ -390,7 +387,7 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
- const attributes: CustomProfileAttribute[] = [{name: 'Editable_Test', type: 'text'}];
+ const attributes: CustomProfileAttribute[] = [{name: 'editable_test', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
@@ -420,7 +417,7 @@ test.describe('System Console - User Attributes Management', () => {
await expect
.poll(async () => {
const map = await getFieldsMap(adminClient);
- return Object.values(map).find((f) => f.name === 'Editable_Test');
+ return Object.values(map).find((f) => f.name === 'editable_test');
})
.toMatchObject({attrs: {managed: 'admin'}});
@@ -428,8 +425,10 @@ test.describe('System Console - User Attributes Management', () => {
});
/**
- * @objective Verify that leaving an attribute name empty shows a validation
- * warning and disables the Save button.
+ * @objective Verify that clearing the auto-derived CEL identifier (Name)
+ * after entering a Display Name shows the empty-name validation warning
+ * 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);
@@ -441,56 +440,130 @@ test.describe('System Console - User Attributes Management', () => {
// # Add a new attribute
await sp.addAttribute();
- // # Clear the auto-focused name input (leave it empty).
- // Use lastNameInput() so concurrent UAAE/ABAC rows don't shift the index.
+ // # Fill Display Name so the Name field auto-derives as snake_case
+ const displayNameInput = sp.lastDisplayNameInput();
+ await displayNameInput.fill('Job Title');
+ await displayNameInput.blur();
+
+ // * Verify the Name field auto-populated with the snake_case identifier
const nameInput = sp.lastNameInput();
+ await expect(nameInput).toHaveValue('job_title');
+
+ // # Clear the auto-derived identifier and blur to trigger the empty-name warning
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 = `UniqueName_${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.
@@ -503,7 +576,7 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create a text attribute via API
- const attributes: CustomProfileAttribute[] = [{name: 'Contact_Number', type: 'text'}];
+ const attributes: CustomProfileAttribute[] = [{name: 'contact_number', type: 'text'}];
await setupCustomProfileAttributeFields(adminClient, attributes);
// # Navigate to User Attributes page
@@ -512,13 +585,13 @@ test.describe('System Console - User Attributes Management', () => {
// # Select "Phone" type for the Contact_Number field.
// Use selectTypeForField() — resolves the row index by name so concurrent
// UAAE/ABAC rows don't shift the positional index.
- await sp.selectTypeForField('Contact_Number', 'Phone');
+ await sp.selectTypeForField('contact_number', 'Phone');
await sp.saveAndWaitForSettled();
// * Verify field type was updated via API
const updatedMap = await getFieldsMap(adminClient);
- const updatedField = Object.values(updatedMap).find((f) => f.name === 'Contact_Number');
+ const updatedField = Object.values(updatedMap).find((f) => f.name === 'contact_number');
expect(updatedField).toBeDefined();
expect(updatedField!.type).toBe('text');
expect(updatedField!.attrs.value_type).toBe('phone');
@@ -581,21 +654,21 @@ test.describe('System Console - User Attributes Management', () => {
// # Create first attribute (text) — use lastNameInput() after each addAttribute()
await sp.addAttribute();
const firstInput = sp.lastNameInput();
- await firstInput.fill('Job_Title');
+ await firstInput.fill('job_title');
await firstInput.blur();
// # Create second attribute (text)
await sp.addAttribute();
const secondInput = sp.lastNameInput();
- await secondInput.fill('Team_Name');
+ await secondInput.fill('team_name');
await secondInput.blur();
await sp.saveAndWaitForSettled();
// * Verify both fields were created via API
const fieldsMap = await getFieldsMap(adminClient);
- expect(Object.values(fieldsMap).find((f) => f.name === 'Job_Title')).toBeDefined();
- expect(Object.values(fieldsMap).find((f) => f.name === 'Team_Name')).toBeDefined();
+ expect(Object.values(fieldsMap).find((f) => f.name === 'job_title')).toBeDefined();
+ expect(Object.values(fieldsMap).find((f) => f.name === 'team_name')).toBeDefined();
await cleanupFields(adminClient, fieldsMap);
});
@@ -612,20 +685,20 @@ test.describe('System Console - User Attributes Management', () => {
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
- await setupCustomProfileAttributeFields(adminClient, [{name: 'Persistent_Field', type: 'text'}]);
+ await setupCustomProfileAttributeFields(adminClient, [{name: 'persistent_field', type: 'text'}]);
// # Navigate to User Attributes page
await sp.goto();
// * Verify attribute exists
- await expect(sp.nameInputByValue('Persistent_Field')).toBeVisible();
+ await expect(sp.nameInputByValue('persistent_field')).toBeVisible();
// # Edit the name using a value-based locator so concurrent UAAE/ABAC rows
// don't shift a positional index to the wrong field.
- const nameInput = sp.nameInputByValue('Persistent_Field');
- await expect(nameInput).toHaveValue('Persistent_Field');
+ const nameInput = sp.nameInputByValue('persistent_field');
+ await expect(nameInput).toHaveValue('persistent_field');
await nameInput.focus();
- await nameInput.fill('Updated_Persistent');
+ await nameInput.fill('updated_persistent');
// blur via keyboard — the value-based locator is stale after fill()
await sp.page.keyboard.press('Tab');
@@ -635,11 +708,60 @@ test.describe('System Console - User Attributes Management', () => {
await sp.goto();
// * Verify the updated name persisted
- await expect(sp.nameInputByValue('Updated_Persistent')).toBeVisible();
+ await expect(sp.nameInputByValue('updated_persistent')).toBeVisible();
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
new file mode 100644
index 00000000000..0e4a2bbf482
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/system_console/user_attributes/user_attributes_display_name.spec.ts
@@ -0,0 +1,221 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Client4} from '@mattermost/client';
+import type {UserPropertyField} from '@mattermost/types/properties';
+import type {UserProfile} from '@mattermost/types/users';
+
+import {expect, test, testConfig} from '@mattermost/playwright-lib';
+import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
+
+import {setupCustomProfileAttributeValuesForUser} from '../../channels/custom_profile_attributes/helpers';
+
+type AdminUser = UserProfile & {password: string};
+
+const IDENTIFIER_VALIDATION_MESSAGE =
+ 'Identifier must start with a letter or underscore and contain only letters, numbers, and underscores. Reserved CEL words are not allowed.';
+
+interface TestContext {
+ adminClient: Client4;
+ adminUser: AdminUser;
+ systemConsolePage: SystemConsolePage;
+}
+
+async function createAdminClient(): Promise<{adminClient: Client4; adminUser: AdminUser}> {
+ const adminClient = new Client4();
+ adminClient.setUrl(testConfig.baseURL);
+
+ const loggedInUser = await adminClient.login(testConfig.adminUsername, testConfig.adminPassword);
+ const adminUser = {
+ ...loggedInUser,
+ password: testConfig.adminPassword,
+ } as AdminUser;
+
+ return {adminClient, adminUser};
+}
+
+async function setupTest(pw: PlaywrightExtended): Promise {
+ await pw.ensureLicense();
+ await pw.skipIfNoLicense();
+
+ const {adminClient, adminUser} = await createAdminClient();
+
+ const {systemConsolePage} = await pw.testBrowser.login(adminUser);
+ await systemConsolePage.goto();
+ await systemConsolePage.toBeVisible();
+
+ return {adminClient, adminUser, systemConsolePage};
+}
+
+async function getTownSquareRoute(adminClient: Client4) {
+ const teams = await adminClient.getMyTeams();
+ expect(teams.length).toBeGreaterThan(0);
+
+ const team = teams[0];
+ const channel = await adminClient.getChannelByName(team.id, 'town-square');
+
+ return {
+ teamName: team.name,
+ channelName: channel.name,
+ };
+}
+
+test.describe('System Console - User Attributes display names', () => {
+ /**
+ * @objective Verify that a CPA field's display_name is rendered as the user-facing
+ * label in the user attributes table, the profile popover, account settings, and
+ * the admin user detail page while the identifier remains unchanged in the API.
+ */
+ test('renders display_name across admin and self-service surfaces', {tag: '@user_attributes'}, async ({pw}) => {
+ const {adminClient, adminUser, systemConsolePage} = await setupTest(pw);
+ const sp = systemConsolePage.systemProperties;
+
+ const uid = Date.now();
+ const identifier = `department_${uid}`;
+ const displayName = `Department ${uid}`;
+ const attributeValue = 'Engineering';
+
+ let createdField: UserPropertyField | undefined;
+
+ try {
+ // # Navigate to User Attributes and create a new field with a display name
+ await sp.goto();
+
+ // * Verify the table exposes both Name and Display Name column headers
+ await expect(sp.container.getByRole('columnheader', {name: 'Display Name', exact: true})).toBeVisible();
+ await expect(sp.container.getByRole('columnheader', {name: 'Name', exact: true})).toBeVisible();
+
+ // # Add a new row and fill identifier + display name
+ await sp.addAttribute();
+ await sp.lastNameInput().fill(identifier);
+ await sp.lastNameInput().blur();
+ await sp.lastDisplayNameInput().fill(displayName);
+ await sp.lastDisplayNameInput().blur();
+
+ await sp.saveAndWaitForSettled();
+
+ const fields = await adminClient.getCustomProfileAttributeFields();
+ createdField = fields.find((field) => field.name === identifier);
+
+ expect(createdField).toBeDefined();
+ expect(createdField?.attrs?.display_name).toBe(displayName);
+
+ // # Set a value for sysadmin and open the self profile popover in Channels
+ await setupCustomProfileAttributeValuesForUser(
+ adminClient,
+ [{name: identifier, value: attributeValue, type: 'text'}],
+ {[createdField!.id]: createdField!},
+ adminUser.id,
+ );
+
+ const {teamName, channelName} = await getTownSquareRoute(adminClient);
+ const {channelsPage} = await pw.testBrowser.login(adminUser);
+
+ await channelsPage.goto(teamName, channelName);
+ await channelsPage.postMessage(`phase-5-display-name-${uid}`);
+
+ const lastPost = await channelsPage.getLastPost();
+ await channelsPage.openProfilePopover(lastPost);
+
+ // * Verify the profile popover and account settings render display_name
+ await expect(
+ channelsPage.page.locator(`#user-popover__custom_attributes-title-${createdField!.id}`),
+ ).toHaveText(displayName);
+
+ await channelsPage.userProfilePopover.close();
+
+ const profileModal = await channelsPage.openProfileModal();
+ const section = profileModal.container.locator('.section-min').filter({hasText: displayName});
+ await expect(section).toBeVisible();
+
+ const editButton = profileModal.container.locator(`#customAttribute_${createdField!.id}Edit`);
+ await editButton.scrollIntoViewIfNeeded();
+ await editButton.click();
+
+ const settingsInput = profileModal.container.locator(`#customAttribute_${createdField!.id}`);
+ await expect(settingsInput).toHaveAttribute('aria-label', displayName);
+ await profileModal.closeModal();
+
+ // # Open the admin user detail page for sysadmin
+ await systemConsolePage.page.goto(`/admin_console/user_management/user/${adminUser.id}`);
+ await systemConsolePage.users.userDetail.toBeVisible();
+
+ // * Verify the admin user detail label also uses display_name
+ await expect(
+ systemConsolePage.page.getByTestId(`user-detail-custom-attribute-label-${createdField!.id}`),
+ ).toContainText(displayName);
+ } finally {
+ if (createdField) {
+ await adminClient.deleteCustomProfileAttributeField(createdField.id).catch(() => undefined);
+ }
+ }
+ });
+
+ /**
+ * @objective Verify that invalid CPA identifiers are blocked client-side before
+ * any create-field API request is issued, and that a valid identifier clears the
+ * warning and can be saved successfully.
+ */
+ test('blocks invalid identifiers before API submission', {tag: '@user_attributes'}, async ({pw}) => {
+ const {adminClient, systemConsolePage} = await setupTest(pw);
+ const sp = systemConsolePage.systemProperties;
+ const {page} = systemConsolePage;
+
+ const apiPosts: string[] = [];
+ const validIdentifier = `my_field_${Date.now()}`;
+
+ page.on('request', (request) => {
+ if (request.method() === 'POST' && request.url().includes('/api/v4/custom_profile_attributes/fields')) {
+ apiPosts.push(request.url());
+ }
+ });
+
+ try {
+ // # Add an attribute and exercise invalid identifier inputs in the table.
+ // Use lastNameInput() — not positional nameInput(0) — so a leftover row from
+ // a prior test attempt (or a concurrent UAAE/ABAC suite) cannot shift the
+ // index and cause us to rename someone else's field instead of populating
+ // the row we just added.
+ await sp.goto();
+ await sp.addAttribute();
+
+ const invalidIdentifiers = ['in', 'true', 'for'];
+ for (const invalidIdentifier of invalidIdentifiers) {
+ await sp.lastNameInput().fill(invalidIdentifier);
+ await sp.lastNameInput().blur();
+
+ // * 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();
+ }
+
+ expect(apiPosts).toHaveLength(0);
+
+ // # Correct the identifier to a valid CEL-safe name and save it
+ await sp.lastNameInput().fill(validIdentifier);
+ await sp.lastNameInput().blur();
+
+ // * Verify the warning clears and the field can be created successfully
+ await expect(sp.identifierValidationError()).not.toBeVisible();
+ await expect(sp.saveButton).toBeEnabled();
+
+ await sp.saveAndWaitForSettled();
+
+ const fields = await adminClient.getCustomProfileAttributeFields();
+ const createdField = fields.find((field) => field.name === validIdentifier);
+ expect(createdField).toBeDefined();
+ } finally {
+ // Look up the field by name rather than relying on a captured `createdField`
+ // — the assertions above can throw before that variable is assigned, and we
+ // still want to remove the server-side field so retries start from a clean slate.
+ const fields = await adminClient.getCustomProfileAttributeFields().catch(() => []);
+ const leftover = fields.find((field) => field.name === validIdentifier);
+ if (leftover) {
+ await adminClient.deleteCustomProfileAttributeField(leftover.id).catch(() => undefined);
+ }
+ }
+ });
+});
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/.golangci.yml b/server/.golangci.yml
index d3f09acf71a..0476dcc59ce 100644
--- a/server/.golangci.yml
+++ b/server/.golangci.yml
@@ -1,20 +1,84 @@
version: "2"
linters:
- default: none
- enable:
- - bidichk
- - errcheck
- - govet
- - ineffassign
- - makezero
- - misspell
- - modernize
- - revive
- - staticcheck
- - unconvert
- - unqueryvet
- - unused
- - whitespace
+ default: all
+ disable:
+ - bodyclose
+ - canonicalheader
+ - containedctx # storing context.Context in a struct is an established pattern here
+ - contextcheck
+ - cyclop
+ - depguard
+ - dogsled # test helpers return many values; blank-heavy destructuring is idiomatic
+ - dupl
+ - dupword
+ - embeddedstructfieldcheck
+ - err113
+ - errchkjson
+ - errname
+ - errorlint
+ - exhaustive
+ - exhaustruct
+ - forbidigo
+ - forcetypeassert
+ - funcorder
+ - funlen
+ - gocheckcompilerdirectives # //go:fix is a valid directive in Go 1.24+; linter doesn't know it yet
+ - gochecknoglobals
+ - gochecknoinits
+ - gocognit
+ - goconst
+ - gocritic
+ - gocyclo
+ - godoclint
+ - godot
+ - godox
+ - gomoddirectives # replace directives in go.mod are intentional forks
+ - gomodguard # deprecated since v2.12.0; replaced by gomodguard_v2 (enabled via default: all)
+ - goprintffuncname # Ephemeral → Ephemeralf rename is a plugin API breaking change; deferred
+ - gosec
+ - gosmopolitan
+ - iface # identical job interfaces are intentional — type-safe scheduling without coupling
+ - inamedparam
+ - interfacebloat
+ - intrange
+ - iotamixing # const blocks intentionally mix iota with explicit values (ABI stability, ASCII)
+ - ireturn
+ - lll
+ - maintidx
+ - mnd
+ - musttag
+ - nakedret
+ - nestif
+ - nilerr # intentionally dropping errors is common here (graceful degradation, security non-disclosure, fallbacks)
+ - nilnil
+ - nlreturn
+ - noctx
+ - noinlineerr
+ - nolintlint
+ - nonamedreturns
+ - paralleltest
+ - perfsprint
+ - prealloc
+ - predeclared # variable named 'copy' is intentional; already suppressed for revive
+ - promlinter # metric renames are a breaking change; deferred
+ - protogetter
+ - recvcheck
+ - sqlclosecheck # wrapper functions return *sqlx.Rows to callers who close them; not a real leak
+ - tagalign
+ - tagliatelle
+ - testableexamples
+ - testifylint
+ - testpackage
+ - thelper
+ - tparallel
+ - unparam
+ - usestdlibvars
+ - usetesting
+ - varnamelen
+ - wastedassign
+ - wrapcheck
+ - wsl
+ - wsl_v5
settings:
govet:
disable:
diff --git a/server/Makefile b/server/Makefile
index 5730af0caa2..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))))
@@ -158,15 +158,15 @@ TEMPLATES_DIR=templates
# Plugins Packages
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
-PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.4
+PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.5
PLUGIN_PACKAGES += mattermost-plugin-github-v2.7.1
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.12.2
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.7.0
-PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.8.1
+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
@@ -178,9 +178,9 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
# download the package from to work. This will no longer be needed when we unify
# the way we pre-package FIPS and non-FIPS plugins.
ifeq ($(FIPS_ENABLED),true)
- PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.1%2Bac0a223-fips
+ 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)/...)
@@ -328,7 +328,7 @@ golang-versions: ## Install Golang versions used for compatibility testing (e.g.
export GO_COMPATIBILITY_TEST_VERSIONS="${GO_COMPATIBILITY_TEST_VERSIONS}"
golangci-lint: setup-go-work ## Run golangci-lint on codebase
- $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
+ $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
ifeq ($(BUILD_ENTERPRISE_READY),true)
$(GOBIN)/golangci-lint run ./... ./public/... $(BUILD_ENTERPRISE_DIR)/...
else
@@ -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 c8bb59038b2..6f971e2d0ed 100644
--- a/server/channels/api4/access_control.go
+++ b/server/channels/api4/access_control.go
@@ -15,7 +15,7 @@ import (
)
// shouldRedactExpressions reports whether raw CEL expressions should be masked for this caller.
-// Returns true when both ABAC and attribute-value masking are enabled. Callers reading raw expressions
+// Masking is attribute-based, not permission-based: system admins who do not hold all values
// in a policy must also receive redacted raw expressions.
func shouldRedactExpressions(c *Context) bool {
return c.App.Config().FeatureFlags.AttributeBasedAccessControl &&
@@ -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)
@@ -141,6 +155,10 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
auditRec.AddEventObjectType("access_control_policy")
auditRec.AddEventResultState(np)
+ if shouldRedactExpressions(c) {
+ c.App.MaskPolicyExpressions(c.AppContext, np, c.AppContext.Session().UserId)
+ }
+
js, err := json.Marshal(np)
if err != nil {
c.Err = model.NewAppError("createAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
@@ -193,6 +211,10 @@ func getAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request)
return
}
+ if shouldRedactExpressions(c) {
+ c.App.MaskPolicyExpressions(c.AppContext, policy, c.AppContext.Session().UserId)
+ }
+
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
@@ -268,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
@@ -321,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 == "" {
@@ -381,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"`
@@ -407,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
@@ -496,6 +695,12 @@ func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Requ
policies = filtered
}
+ if shouldRedactExpressions(c) {
+ for _, p := range policies {
+ c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
+ }
+ }
+
result := model.AccessControlPoliciesWithCount{
Policies: policies,
Total: total,
@@ -628,6 +833,12 @@ func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
}
auditRec.Success()
+ if shouldRedactExpressions(c) {
+ for _, p := range policies {
+ c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
+ }
+ }
+
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(policies); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
@@ -913,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
@@ -988,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 8b09d036ce2..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
@@ -474,15 +604,22 @@ func TestDeleteAccessControlPolicy(t *testing.T) {
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
- // DeleteAccessControlPolicy resolves the policy first to decide
- // whether to broadcast a channel access control update; return a
- // parent policy so the channel-update path is not exercised here.
- parentPolicy := &model.AccessControlPolicy{
- ID: samplePolicyID,
- Type: model.AccessControlPolicyTypeParent,
- Version: model.AccessControlPolicyVersionV0_3,
+
+ // DeleteAccessControlPolicy resolves the policy first to decide whether
+ // to broadcast a channel access-control update after deletion.
+ channelPolicy := &model.AccessControlPolicy{
+ ID: samplePolicyID,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Revision: 1,
+ Rules: []model.AccessControlPolicyRule{
+ {
+ Expression: "user.attributes.team == 'engineering'",
+ Actions: []string{"membership"},
+ },
+ },
}
- mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(parentPolicy, nil).Times(1)
+ mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(channelPolicy, nil).Times(1)
mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
@@ -593,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) {
@@ -1180,38 +1352,17 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
t.Run("team admin body TeamIds forced to authorized team", func(t *testing.T) {
setupLicenseAndABAC(t)
- parentPolicy := newSamplePolicy()
- savedParent, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
+ policy := newSamplePolicy()
+ savedPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
defer func() {
- _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedParent.ID)
- }()
-
- // Two teams, each with one private channel. The BasicTeam channel is
- // linked to the parent policy so it shows up in the search; the
- // otherTeam channel is unrelated. The override-correctness test then
- // proves both that the BasicTeam channel IS returned (the search
- // isn't trivially empty) and that the otherTeam channel is NOT
- // returned even though the request body asked for it explicitly.
- basicTeamChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, th.BasicTeam.Id)
- basicTeamChild := &model.AccessControlPolicy{
- ID: basicTeamChannel.Id,
- Type: model.AccessControlPolicyTypeChannel,
- Version: model.AccessControlPolicyVersionV0_3,
- Revision: 1,
- Imports: []string{savedParent.ID},
- Rules: []model.AccessControlPolicyRule{
- {Expression: "user.attributes.team == 'engineering'", Actions: []string{"membership"}},
- },
- }
- _, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, basicTeamChild)
- require.NoError(t, err)
- defer func() {
- _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, basicTeamChannel.Id)
+ _ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedPolicy.ID)
}()
+ // Create a second team with a private channel
otherTeam := th.CreateTeam(t)
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, otherTeam.Id)
+ _ = otherChannel
th.LinkUserToTeam(t, th.TeamAdminUser, th.BasicTeam)
th.UpdateUserToTeamAdmin(t, th.TeamAdminUser, th.BasicTeam)
@@ -1221,26 +1372,19 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
// Attempt to search with body TeamIds pointing to a different team.
// The authZ is against BasicTeam (via team_id query param), but the
- // body tries to query otherTeam's channels. The handler should force
+ // body tries to query otherTeam's channels. The fix should force
// TeamIds to BasicTeam.Id regardless of what the body says.
channelsResp, resp, err := th.Client.SearchChannelsForAccessControlPolicyForTeam(
- context.Background(), savedParent.ID, th.BasicTeam.Id,
+ context.Background(), savedPolicy.ID, th.BasicTeam.Id,
model.ChannelSearch{TeamIds: []string{otherTeam.Id}})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, channelsResp)
- channelsByID := make(map[string]*model.ChannelWithTeamData, len(channelsResp.Channels))
- for _, ch := range channelsResp.Channels {
- channelsByID[ch.Id] = ch
- }
- require.Contains(t, channelsByID, basicTeamChannel.Id,
- "BasicTeam channel must surface — proves the search is exercised, not just trivially empty")
- require.NotContains(t, channelsByID, otherChannel.Id,
- "otherTeam channel must NOT surface even though body asked for it — proves the team_id query param overrides body TeamIds")
+ // None of the returned channels should belong to the other team
for _, ch := range channelsResp.Channels {
require.Equal(t, th.BasicTeam.Id, ch.TeamId,
- "team admin must only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
+ "team admin should only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
}
})
@@ -1492,6 +1636,81 @@ func newParentPolicy(teamID string) *model.AccessControlPolicy {
}
}
+// TestResponseMaskingOnPolicyEndpoints verifies that every API endpoint returning an
+// AccessControlPolicy redacts the raw CEL expression for callers who cannot see all
+// values. The risk is a future endpoint forgetting to call MaskPolicyExpressions
+// before serializing — the masked visual AST would still hide values, but the raw
+// rule.Expression in the same response would leak them in plain text. We force the
+// fail-closed branch (unknown property field) so the masking always produces the
+// "--------" sentinel without requiring a real CPA setup.
+func TestResponseMaskingOnPolicyEndpoints(t *testing.T) {
+ // SetupConfig sets FFs before route init via SetReadOnlyFF(false). Avoids
+ // os.Setenv which isn't parallel-safe.
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.AttributeBasedAccessControl = true
+ cfg.FeatureFlags.AttributeValueMasking = true
+ }).InitBasic(t)
+
+ 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.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
+ })
+
+ const sensitiveExpr = `user.attributes.f_unknown_field == "TF-Zulu"`
+ const expectedMaskedExpr = `user.attributes.f_unknown_field == "--------"`
+
+ // A condition referencing an unknown CPA field forces MaskPolicyExpressions
+ // down the fail-closed branch, which replaces the literal value with the
+ // masked-token sentinel. That gives us a deterministic assertion target
+ // without needing to seed a CPA group + protected field in this test.
+ unknownFieldAST := &model.VisualExpression{
+ Conditions: []model.Condition{
+ {
+ Attribute: "user.attributes.f_unknown_field",
+ Operator: "==",
+ Value: "TF-Zulu",
+ ValueType: model.LiteralValue,
+ },
+ },
+ }
+
+ newPolicy := func(id string) *model.AccessControlPolicy {
+ return &model.AccessControlPolicy{
+ ID: id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Revision: 1,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{"membership"}, Expression: sensitiveExpr},
+ },
+ }
+ }
+
+ t.Run("getAccessControlPolicy response is masked", func(t *testing.T) {
+ // GET is the canonical read path — masking here means the raw CEL in the
+ // policy response cannot leak values the caller couldn't already see in the
+ // visual AST. The create / search / setActive paths share the same
+ // MaskPolicyExpressions call so they're covered by inspection. Unit-testing
+ // them through the HTTP handler is impractical because
+ // validatePolicyExpressionValues rejects unknown-field references before
+ // MaskPolicyExpressions ever runs, and we can't seed a real shared_only
+ // CPA field without plugin context. End-to-end paths are covered by E2E.
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().Channels().AccessControl = mockACS
+ stored := newPolicy(th.BasicChannel.Id)
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), stored.ID).Return(stored, nil)
+ mockACS.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(unknownFieldAST, nil).Maybe()
+
+ result, resp, err := th.SystemAdminClient.GetAccessControlPolicy(context.Background(), stored.ID)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ require.NotEmpty(t, result.Rules)
+ require.Equal(t, expectedMaskedExpr, result.Rules[0].Expression,
+ "get response must mask the raw CEL exactly")
+ })
+}
+
func TestCreateAccessControlPolicyTeamAdmin(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic(t)
@@ -2321,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/content_flagging_report.go b/server/channels/api4/content_flagging_report.go
index f09f99ff868..8ceb32cb4c6 100644
--- a/server/channels/api4/content_flagging_report.go
+++ b/server/channels/api4/content_flagging_report.go
@@ -41,6 +41,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
model.AddEventParameterToAuditRec(auditRec, "comment", actionRequest.Comment)
+ model.AddEventParameterToAuditRec(auditRec, "action", actionRequest.Action)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
@@ -65,7 +66,7 @@ func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Reques
return
}
- reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment)
+ reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment, actionRequest.Action)
if appErr != nil {
c.Err = appErr
return
diff --git a/server/channels/api4/content_flagging_report_test.go b/server/channels/api4/content_flagging_report_test.go
index fc7bf3c8b4b..05a1dc87cc3 100644
--- a/server/channels/api4/content_flagging_report_test.go
+++ b/server/channels/api4/content_flagging_report_test.go
@@ -7,9 +7,11 @@ import (
"archive/zip"
"bytes"
"context"
+ "io"
"net/http"
"testing"
+ "github.com/goccy/go-yaml"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
@@ -128,6 +130,45 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
})
+ t.Run("Should include reviewer decision from request action", func(t *testing.T) {
+ appErr := setBasicCommonReviewerConfig(th)
+ require.Nil(t, appErr)
+
+ post := th.CreatePost(t)
+ flagPostViaAPI(t, client, post.Id)
+
+ report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{
+ Comment: "investigation note",
+ Action: model.ContentFlaggingActionRemove,
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.NotEmpty(t, report)
+
+ zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
+ require.NoError(t, err)
+
+ var review model.FlaggedPostReportContentReview
+ var found bool
+ for _, f := range zr.File {
+ if f.Name != "content_review.yaml" {
+ continue
+ }
+ rc, err := f.Open()
+ require.NoError(t, err)
+ b, err := io.ReadAll(rc)
+ require.NoError(t, err)
+ _ = rc.Close()
+ require.NoError(t, yaml.Unmarshal(b, &review))
+ found = true
+ break
+ }
+ require.True(t, found, "content_review.yaml should be present in the report archive")
+ require.Equal(t, "remove", review.ActorDecision)
+ require.Equal(t, th.BasicUser.Id, review.ActorUserId)
+ require.Equal(t, th.BasicUser.Username, review.ActorUsername)
+ })
+
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
diff --git a/server/channels/api4/custom_profile_attributes.go b/server/channels/api4/custom_profile_attributes.go
index a58729dc8ed..772d26130d4 100644
--- a/server/channels/api4/custom_profile_attributes.go
+++ b/server/channels/api4/custom_profile_attributes.go
@@ -9,6 +9,7 @@ package api4
import (
"encoding/json"
+ "maps"
"net/http"
"strings"
@@ -31,37 +32,37 @@ func (api *API) InitCustomProfileAttributes() {
}
func listCPAFields(c *Context, w http.ResponseWriter, r *http.Request) {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.listCPAFields", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+ group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ c.Err = appErr
return
}
- rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
- fields, appErr := c.App.ListCPAFields(rctx)
+ pfs, appErr := c.App.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{
+ GroupID: group.ID,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ PerPage: model.AccessControlGroupFieldLimit + 5,
+ })
if appErr != nil {
c.Err = appErr
return
}
+ fields, convErr := model.CPAFieldsFromPropertyFields(pfs)
+ if convErr != nil {
+ c.Err = model.NewAppError("listCPAFields", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
+ return
+ }
+
if err := json.NewEncoder(w).Encode(fields); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
- if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
- c.SetPermissionError(model.PermissionManageSystem)
- return
- }
-
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.createCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
- return
- }
-
var pf *model.CPAField
- err := json.NewDecoder(r.Body).Decode(&pf)
- if err != nil || pf == nil {
+ if err := json.NewDecoder(r.Body).Decode(&pf); err != nil || pf == nil {
c.SetInvalidParamWithErr("property_field", err)
return
}
@@ -72,42 +73,71 @@ func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", pf)
- rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
- createdField, appErr := c.App.CreateCPAField(rctx, pf)
- if appErr != nil {
- c.Err = appErr
- return
- }
-
- auditRec.Success()
- auditRec.AddEventResultState(createdField)
- auditRec.AddEventObjectType("property_field")
-
- w.WriteHeader(http.StatusCreated)
- if err := json.NewEncoder(w).Encode(createdField); err != nil {
- c.Logger.Warn("Error while writing response", mlog.Err(err))
- }
-}
-
-func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
+ // CPA fields are system-scoped; only a system administrator may create
+ // them. This mirrors the scope-based permission check the shared generic
+ // handler enforces for system-typed fields.
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.patchCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
+ // Translate to PropertyField and route through the generic property API.
+ // Server-controlled fields (group, type, target shape, creator) are
+ // stamped here; ID/TargetID/Protected are stripped so a caller can't
+ // inject them. Permissions and timestamps are filled in by lower layers.
+ field := pf.ToPropertyField()
+ group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ c.Err = appErr
+ return
+ }
+ field.ID = ""
+ field.GroupID = group.ID
+ field.ObjectType = model.PropertyFieldObjectTypeUser
+ field.TargetType = string(model.PropertyFieldTargetLevelSystem)
+ field.TargetID = ""
+ field.Protected = false
+ field.CreatedBy = c.AppContext.Session().UserId
+ field.UpdatedBy = c.AppContext.Session().UserId
+
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+ connectionID := r.Header.Get(model.ConnectionId)
+
+ createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
+ if appErr != nil {
+ c.Err = appErr
return
}
+ cpaField, convErr := model.NewCPAFieldFromPropertyField(createdField)
+ if convErr != nil {
+ c.Err = model.NewAppError("createCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
+ return
+ }
+
+ // Send CPA-specific websocket event for backwards compatibility
+ message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldCreated, "", "", "", nil, "")
+ message.Add("field", cpaField)
+ c.App.Publish(message)
+
+ auditRec.AddEventObjectType("property_field")
+ auditRec.AddEventResultState(cpaField)
+ auditRec.Success()
+
+ w.WriteHeader(http.StatusCreated)
+ if err := json.NewEncoder(w).Encode(cpaField); err != nil {
+ c.Logger.Warn("Error while writing response", mlog.Err(err))
+ }
+}
+
+func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFieldId()
if c.Err != nil {
return
}
var patch *model.PropertyFieldPatch
- err := json.NewDecoder(r.Body).Decode(&patch)
- if err != nil || patch == nil {
+ if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
c.SetInvalidParamWithErr("property_field_patch", err)
return
}
@@ -115,11 +145,15 @@ func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
if patch.Name != nil {
*patch.Name = strings.TrimSpace(*patch.Name)
}
+ // Target fields are server-controlled; prevent the caller from patching them.
+ patch.TargetID = nil
+ patch.TargetType = nil
+
if err := patch.IsValid(); err != nil {
if appErr, ok := err.(*model.AppError); ok {
c.Err = appErr
} else {
- c.Err = model.NewAppError("createCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
+ c.Err = model.NewAppError("patchCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
}
return
}
@@ -128,41 +162,86 @@ func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
- rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
- originalField, appErr := c.App.GetCPAField(rctx, c.Params.FieldId)
+ group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
- auditRec.AddEventPriorState(originalField)
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
- patchedField, appErr := c.App.PatchCPAField(rctx, c.Params.FieldId, patch)
+ existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
if appErr != nil {
c.Err = appErr
return
}
+ if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
+ c.Err = model.NewAppError("patchCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
+ return
+ }
+
+ // Permission branching (session-bound).
+ isOptionsOnly := isOptionsOnlyPatch(patch)
+ if isOptionsOnly && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
+ isOptionsOnly = false
+ }
+ if isOptionsOnly {
+ if !c.App.SessionHasPermissionToManagePropertyFieldOptions(rctx, *c.AppContext.Session(), existingField) {
+ c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_options_permission.app_error", nil, "", http.StatusForbidden)
+ return
+ }
+ } else {
+ if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
+ c.Err = model.NewAppError("patchCPAField", "api.property_field.update.no_field_permission.app_error", nil, "", http.StatusForbidden)
+ return
+ }
+ }
+
+ // Capture original state for audit before in-place patch (Attrs is
+ // shallow-copied because Patch mutates it).
+ orig := *existingField
+ if existingField.Attrs != nil {
+ orig.Attrs = make(model.StringInterface, len(existingField.Attrs))
+ maps.Copy(orig.Attrs, existingField.Attrs)
+ }
+ auditRec.AddEventPriorState(&orig)
+
+ existingField.Patch(patch, true)
+ existingField.UpdatedBy = c.AppContext.Session().UserId
+ connectionID := r.Header.Get(model.ConnectionId)
+
+ updatedField, clearedIDs, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
+ if updateErr != nil {
+ c.Err = updateErr
+ return
+ }
+
+ cpaField, convErr := model.NewCPAFieldFromPropertyField(updatedField)
+ if convErr != nil {
+ c.Err = model.NewAppError("patchCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
+ return
+ }
+
+ // CPA-specific websocket event (backward compat). delete_values:true tells
+ // pre-PSAv2 webapp clients to clear cached values for this field; PSAv2
+ // clients receive the same signal via WebsocketEventPropertyValuesUpdated
+ // fired by App.UpdatePropertyField.
+ message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldUpdated, "", "", "", nil, "")
+ message.Add("field", cpaField)
+ message.Add("delete_values", len(clearedIDs) > 0)
+ c.App.Publish(message)
+
auditRec.Success()
- auditRec.AddEventResultState(patchedField)
+ auditRec.AddEventResultState(cpaField)
auditRec.AddEventObjectType("property_field")
- if err := json.NewEncoder(w).Encode(patchedField); err != nil {
+ if err := json.NewEncoder(w).Encode(cpaField); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
- if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
- c.SetPermissionError(model.PermissionManageSystem)
- return
- }
-
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.deleteCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
- return
- }
-
c.RequireFieldId()
if c.Err != nil {
return
@@ -172,54 +251,185 @@ func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
- rctx := app.RequestContextWithCallerID(c.AppContext, c.AppContext.Session().UserId)
- field, appErr := c.App.GetCPAField(rctx, c.Params.FieldId)
+ group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
- auditRec.AddEventPriorState(field)
- if appErr := c.App.DeleteCPAField(rctx, c.Params.FieldId); appErr != nil {
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+
+ existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
+ if appErr != nil {
c.Err = appErr
return
}
+ if existingField.ObjectType != model.PropertyFieldObjectTypeUser {
+ c.Err = model.NewAppError("deleteCPAField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
+ return
+ }
+
+ if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
+ c.Err = model.NewAppError("deleteCPAField", "api.property_field.delete.no_permission.app_error", nil, "", http.StatusForbidden)
+ return
+ }
+
+ connectionID := r.Header.Get(model.ConnectionId)
+ if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
+ c.Err = deleteErr
+ return
+ }
+
+ // CPA-specific websocket event (backward compat)
+ message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
+ message.Add("field_id", c.Params.FieldId)
+ c.App.Publish(message)
+
+ auditRec.AddEventPriorState(existingField)
auditRec.Success()
- auditRec.AddEventResultState(field)
+ auditRec.AddEventResultState(existingField)
auditRec.AddEventObjectType("property_field")
ReturnStatusOK(w)
}
func getCPAGroup(c *Context, w http.ResponseWriter, r *http.Request) {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.getCPAGroup", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
+ // Every other CPA endpoint enforces MinimumEnterpriseLicense via the
+ // LicenseCheckHook on field/value operations. GetPropertyGroup is not
+ // hooked, so we enforce the same contract here inline.
+ if !model.MinimumEnterpriseLicense(c.App.License()) {
+ c.Err = model.NewAppError("getCPAGroup", "app.property.license_error", nil, "an Enterprise license is required", http.StatusForbidden)
return
}
- groupID, appErr := c.App.CpaGroupID()
+ group, appErr := c.App.GetPropertyGroup(c.AppContext, model.AccessControlPropertyGroupName)
if appErr != nil {
c.Err = appErr
return
}
- if err := json.NewEncoder(w).Encode(map[string]string{"id": groupID}); err != nil {
+ if err := json.NewEncoder(w).Encode(map[string]string{"id": group.ID}); err != nil {
+ c.Logger.Warn("Error while writing response", mlog.Err(err))
+ }
+}
+
+// cpaPatchValues is the shared implementation for patchCPAValues and
+// patchCPAValuesForUser. It translates the CPA request format to the generic
+// property API, performs the same session-bound checks as the generic value
+// patch handler (target access, batch caps, per-field permission), routes
+// the upsert through App.UpsertPropertyValues, and emits the CPA-specific
+// websocket event.
+func cpaPatchValues(c *Context, w http.ResponseWriter, r *http.Request, userID string, updates map[string]json.RawMessage) {
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+ group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ c.Err = appErr
+ return
+ }
+
+ if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, userID, true) {
+ return
+ }
+
+ // Translate CPA format → generic PropertyValuePatchItem list. Map
+ // iteration is unordered, but FieldID uniqueness is guaranteed by the
+ // JSON object key constraint, so we cannot hit duplicate-FieldID; still,
+ // we keep the same shape as the generic handler for parity.
+ items := make([]model.PropertyValuePatchItem, 0, len(updates))
+ for fieldID, value := range updates {
+ items = append(items, model.PropertyValuePatchItem{
+ FieldID: fieldID,
+ Value: value,
+ })
+ }
+
+ if len(items) == 0 {
+ c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
+ return
+ }
+ if len(items) > maxPropertyValuePatchItems {
+ c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
+ "Max": maxPropertyValuePatchItems,
+ }, "", http.StatusBadRequest)
+ return
+ }
+
+ fieldIDs := make([]string, 0, len(items))
+ for _, item := range items {
+ if !model.IsValidId(item.FieldID) {
+ c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
+ return
+ }
+ fieldIDs = append(fieldIDs, item.FieldID)
+ }
+
+ fields, fieldsErr := c.App.GetPropertyFields(rctx, group.ID, fieldIDs)
+ if fieldsErr != nil {
+ c.Err = fieldsErr
+ return
+ }
+ fieldByID := make(map[string]*model.PropertyField, len(fields))
+ for _, f := range fields {
+ fieldByID[f.ID] = f
+ }
+ for _, item := range items {
+ f, ok := fieldByID[item.FieldID]
+ if !ok {
+ c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.field_not_found.app_error",
+ map[string]any{"FieldID": item.FieldID}, "", http.StatusNotFound)
+ return
+ }
+ if f.ObjectType != model.PropertyFieldObjectTypeUser {
+ 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, userID) {
+ c.Err = model.NewAppError("cpaPatchValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
+ return
+ }
+ }
+
+ callerID := c.AppContext.Session().UserId
+ values := make([]*model.PropertyValue, len(items))
+ for i, item := range items {
+ values[i] = &model.PropertyValue{
+ TargetID: userID,
+ TargetType: model.PropertyFieldObjectTypeUser,
+ GroupID: group.ID,
+ FieldID: item.FieldID,
+ Value: item.Value,
+ CreatedBy: callerID,
+ UpdatedBy: callerID,
+ }
+ }
+ connectionID := r.Header.Get(model.ConnectionId)
+
+ upserted, upsertErr := c.App.UpsertPropertyValues(rctx, values, model.PropertyFieldObjectTypeUser, userID, connectionID)
+ if upsertErr != nil {
+ c.Err = upsertErr
+ return
+ }
+
+ // Translate response to CPA format: {fieldID: value}
+ results := make(map[string]json.RawMessage, len(upserted))
+ for _, value := range upserted {
+ results[value.FieldID] = value.Value
+ }
+
+ // CPA-specific websocket event (backward compat)
+ message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
+ message.Add("user_id", userID)
+ message.Add("values", results)
+ c.App.Publish(message)
+
+ if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.patchCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
- return
- }
-
userID := c.AppContext.Session().UserId
- if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
- c.SetPermissionError(model.PermissionEditOtherUsers)
- return
- }
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
@@ -231,72 +441,38 @@ func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
- // if the user is not an admin, we need to check that there are no
- // admin-managed fields
- session := *c.AppContext.Session()
- rctx := app.RequestContextWithCallerID(c.AppContext, session.UserId)
-
- if !c.App.SessionHasPermissionTo(session, model.PermissionManageSystem) {
- fields, appErr := c.App.ListCPAFields(rctx)
- if appErr != nil {
- c.Err = appErr
- return
- }
-
- // Check if any of the fields being updated are admin-managed
- for _, field := range fields {
- if _, isBeingUpdated := updates[field.ID]; isBeingUpdated {
- if field.IsAdminManaged() {
- c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_is_managed.app_error", nil, "", http.StatusForbidden)
- return
- }
- }
- }
- }
-
- results := make(map[string]json.RawMessage, len(updates))
- for fieldID, rawValue := range updates {
- patchedValue, appErr := c.App.PatchCPAValue(rctx, userID, fieldID, rawValue, false)
- if appErr != nil {
- c.Err = appErr
- return
- }
- results[fieldID] = patchedValue.Value
+ cpaPatchValues(c, w, r, userID, updates)
+ if c.Err != nil {
+ return
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
-
- if err := json.NewEncoder(w).Encode(results); err != nil {
- c.Logger.Warn("Error while writing response", mlog.Err(err))
- }
}
func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.listCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
- return
- }
-
c.RequireUserId()
if c.Err != nil {
return
}
- targetUserID := c.Params.UserId
- callerUserID := c.AppContext.Session().UserId
-
- // we check unrestricted sessions to allow local mode requests to go through
- if !c.AppContext.Session().IsUnrestricted() {
- canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, callerUserID, targetUserID)
- if err != nil || !canSee {
- c.SetPermissionError(model.PermissionViewMembers)
- return
- }
+ if !hasTargetAccess(c, model.PropertyFieldObjectTypeUser, c.Params.UserId, false) {
+ return
}
- rctx := app.RequestContextWithCallerID(c.AppContext, callerUserID)
- values, appErr := c.App.ListCPAValues(rctx, targetUserID)
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+ group, appErr := c.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ c.Err = appErr
+ return
+ }
+
+ values, appErr := c.App.SearchPropertyValues(rctx, group.ID, model.PropertyValueSearchOpts{
+ TargetIDs: []string{c.Params.UserId},
+ TargetType: model.PropertyValueTargetTypeUser,
+ // Single-target search: at most one value per (target, field), so the field cap bounds the page.
+ PerPage: model.AccessControlGroupFieldLimit + 5,
+ })
if appErr != nil {
c.Err = appErr
return
@@ -312,23 +488,12 @@ func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
}
func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
- if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
- c.Err = model.NewAppError("Api4.patchCPAValuesForUser", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
- return
- }
-
- // Get userID from URL
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
- if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
- c.SetPermissionError(model.PermissionEditOtherUsers)
- return
- }
-
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.SetInvalidParamWithErr("value", err)
@@ -339,47 +504,11 @@ func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
- // Check for admin-managed fields
- session := *c.AppContext.Session()
- rctx := app.RequestContextWithCallerID(c.AppContext, session.UserId)
-
- isAdmin := c.App.SessionHasPermissionTo(session, model.PermissionManageSystem)
- if !isAdmin {
- fields, appErr := c.App.ListCPAFields(rctx)
- if appErr != nil {
- c.Err = appErr
- return
- }
-
- for _, field := range fields {
- if _, isBeingUpdated := updates[field.ID]; !isBeingUpdated {
- continue
- }
- // Check for admin-managed fields
- if field.IsAdminManaged() {
- c.Err = model.NewAppError("Api4.patchCPAValuesForUser",
- "app.custom_profile_attributes.property_field_is_managed.app_error",
- nil, "",
- http.StatusForbidden)
- return
- }
- }
- }
-
- results := make(map[string]json.RawMessage, len(updates))
- for fieldID, rawValue := range updates {
- patchedValue, appErr := c.App.PatchCPAValue(rctx, userID, fieldID, rawValue, false)
- if appErr != nil {
- c.Err = appErr
- return
- }
- results[fieldID] = patchedValue.Value
+ cpaPatchValues(c, w, r, userID, updates)
+ if c.Err != nil {
+ return
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
-
- if err := json.NewEncoder(w).Encode(results); err != nil {
- c.Logger.Warn("Error while writing response", mlog.Err(err))
- }
}
diff --git a/server/channels/api4/custom_profile_attributes_test.go b/server/channels/api4/custom_profile_attributes_test.go
index c6259b033fe..37869c8e603 100644
--- a/server/channels/api4/custom_profile_attributes_test.go
+++ b/server/channels/api4/custom_profile_attributes_test.go
@@ -16,6 +16,10 @@ import (
"github.com/stretchr/testify/require"
)
+// celSafeName returns a CPA field name guaranteed to satisfy the CEL identifier
+// rule the AccessControlAttributeValidationHook enforces. model.NewId() uses a base32
+// alphabet that includes digits, so a raw NewId can start with a digit and trip
+// the ^[A-Za-z_]… pattern; the leading "f_" sidesteps that.
func celSafeName() string {
return "f_" + model.NewId()
}
@@ -32,7 +36,7 @@ func TestCreateCPAField(t *testing.T) {
createdField, resp, err := client.CreateCPAField(context.Background(), field)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, createdField)
}, "endpoint should not work if no valid license is present")
@@ -116,6 +120,66 @@ func TestCreateCPAField(t *testing.T) {
require.Equal(t, "admin", createdManagedField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged])
require.Equal(t, "when_set", createdManagedField.Attrs["visibility"])
}, "admin should be able to create a managed field")
+
+ t.Run("server zeroes DeleteAt even if input has a non-zero value", func(t *testing.T) {
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ DeleteAt: time.Now().UnixMilli(),
+ }
+ require.NotZero(t, field.DeleteAt)
+
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.Zero(t, created.DeleteAt)
+ })
+}
+
+func TestCPAFieldLimit(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.CustomProfileAttributes = true
+ }).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ // Create 20 fields — the maximum allowed by FieldLimitHook.
+ createdIDs := make([]string, 0, 20)
+ for i := 1; i <= 20; i++ {
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ createdIDs = append(createdIDs, created.ID)
+ }
+
+ t.Run("creating a 21st field is rejected", func(t *testing.T) {
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ }
+ _, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckUnprocessableEntityStatus(t, resp)
+ require.Error(t, err)
+ })
+
+ t.Run("deleted fields do not count toward the limit", func(t *testing.T) {
+ resp, err := th.SystemAdminClient.DeleteCPAField(context.Background(), createdIDs[0])
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ replacement := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), replacement)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotZero(t, created.ID)
+ })
}
func TestListCPAFields(t *testing.T) {
@@ -124,28 +188,31 @@ func TestListCPAFields(t *testing.T) {
cfg.FeatureFlags.CustomProfileAttributes = true
})
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ // License required for field creation (LicenseCheckHook)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
Attrs: map[string]any{"visibility": "when_set"},
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
t.Run("endpoint should not work if no valid license is present", func(t *testing.T) {
+ th.App.Srv().SetLicense(nil)
+ defer th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
fields, resp, err := th.Client.ListCPAFields(context.Background())
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, fields)
})
- // add a valid license
- th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
-
t.Run("any user should be able to list fields", func(t *testing.T) {
fields, resp, err := th.Client.ListCPAFields(context.Background())
CheckOKStatus(t, resp)
@@ -156,7 +223,10 @@ func TestListCPAFields(t *testing.T) {
})
t.Run("the endpoint should only list non deleted fields", func(t *testing.T) {
- require.Nil(t, th.App.DeleteCPAField(request.TestContext(t), createdField.ID))
+ resp, err := th.SystemAdminClient.DeleteCPAField(context.Background(), createdField.ID)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
fields, resp, err := th.Client.ListCPAFields(context.Background())
CheckOKStatus(t, resp)
require.NoError(t, err)
@@ -171,11 +241,20 @@ func TestPatchCPAField(t *testing.T) {
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
- patch := &model.PropertyFieldPatch{Name: new(celSafeName())}
- patchedField, resp, err := client.PatchCPAField(context.Background(), model.NewId(), patch)
+ // Create a field with a license so we can test the license check on patch.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+ field := &model.PropertyField{Name: celSafeName(), Type: model.PropertyFieldTypeText}
+ createdField, _, createErr := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ require.NoError(t, createErr)
+ require.NotNil(t, createdField)
+
+ // Remove the license and verify patch is blocked.
+ th.App.Srv().SetLicense(nil)
+ patch := &model.PropertyFieldPatch{Name: model.NewPointer(celSafeName())}
+ patchedField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, patch)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, patchedField)
}, "endpoint should not work if no valid license is present")
@@ -183,18 +262,18 @@ func TestPatchCPAField(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
t.Run("a user without admin permissions should not be able to patch a field", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
- patch := &model.PropertyFieldPatch{Name: new(celSafeName())}
- _, resp, err := th.Client.PatchCPAField(context.Background(), createdField.ID, patch)
+ patch := &model.PropertyFieldPatch{Name: model.NewPointer(celSafeName())}
+ _, resp, err = th.Client.PatchCPAField(context.Background(), createdField.ID, patch)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
})
@@ -202,18 +281,18 @@ func TestPatchCPAField(t *testing.T) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
webSocketClient := th.CreateConnectedWebSocketClient(t)
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
newName := celSafeName()
- patch := &model.PropertyFieldPatch{Name: new(fmt.Sprintf(" %s \t ", newName))} // name should be sanitized
+ patch := &model.PropertyFieldPatch{Name: model.NewPointer(fmt.Sprintf(" %s \t ", newName))} // name should be sanitized
patchedField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, patch)
CheckOKStatus(t, resp)
require.NoError(t, err)
@@ -239,85 +318,22 @@ func TestPatchCPAField(t *testing.T) {
require.NotEmpty(t, wsField.ID)
require.Equal(t, patchedField, &wsField)
})
-
- t.Run("sanitization should remove options and sync details when necessary", func(t *testing.T) {
- // Create a select field with options
- optionID1 := model.NewId()
- optionID2 := model.NewId()
- selectField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: celSafeName(),
- Type: model.PropertyFieldTypeSelect,
- Attrs: model.StringInterface{
- "options": []map[string]any{
- {"id": optionID1, "name": "Option 1", "color": "#FF0000"},
- {"id": optionID2, "name": "Option 2", "color": "#00FF00"},
- },
- },
- })
- require.NoError(t, err)
-
- createdField, _, err := client.CreateCPAField(context.Background(), selectField.ToPropertyField())
- require.NoError(t, err)
- require.NotNil(t, createdField)
-
- // Verify options were created
- options, ok := createdField.Attrs["options"]
- require.True(t, ok)
- require.NotNil(t, options)
-
- // Patch to change type to text with LDAP attribute
- // Options should be automatically removed even though we don't explicitly remove them
- ldapAttr := "user_attribute"
- textPatch := &model.PropertyFieldPatch{
- Type: model.NewPointer(model.PropertyFieldTypeText),
- Attrs: &model.StringInterface{"ldap": ldapAttr},
- }
-
- patchedTextField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, textPatch)
- CheckOKStatus(t, resp)
- require.NoError(t, err)
- require.Equal(t, model.PropertyFieldTypeText, patchedTextField.Type)
-
- // Verify options were removed
- options = patchedTextField.Attrs["options"]
- require.Empty(t, options)
-
- // Verify LDAP attribute was set
- ldap, ok := patchedTextField.Attrs["ldap"]
- require.True(t, ok)
- require.Equal(t, ldapAttr, ldap)
-
- // Now patch to change type to date
- // LDAP attribute should be automatically removed even though we don't explicitly remove it
- datePatch := &model.PropertyFieldPatch{
- Type: model.NewPointer(model.PropertyFieldTypeDate),
- }
-
- patchedDateField, resp, err := client.PatchCPAField(context.Background(), patchedTextField.ID, datePatch)
- CheckOKStatus(t, resp)
- require.NoError(t, err)
- require.Equal(t, model.PropertyFieldTypeDate, patchedDateField.Type)
-
- // Verify LDAP attribute was removed
- ldap = patchedDateField.Attrs["ldap"]
- require.Empty(t, ldap)
- })
}, "a user with admin permissions should be able to patch the field")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
// Create a regular field first
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
// Verify field is not isManaged initially
- require.Empty(t, createdField.Attrs.Managed)
+ require.Empty(t, createdField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged])
// Patch to make it managed
managedPatch := &model.PropertyFieldPatch{
@@ -345,6 +361,171 @@ func TestPatchCPAField(t *testing.T) {
// Verify managed attribute is removed or empty
require.Empty(t, patchedUnmanagedField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged])
}, "admin should be able to toggle managed attribute on existing field")
+
+ t.Run("patching select options preserves existing option IDs and assigns new IDs to added options", func(t *testing.T) {
+ selectField := &model.PropertyField{
+ Name: "select_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"name": "Option 1", "color": "#111111"},
+ map[string]any{"name": "Option 2", "color": "#222222"},
+ },
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), selectField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+
+ createdCPA, err := model.NewCPAFieldFromPropertyField(created)
+ require.NoError(t, err)
+ require.Len(t, createdCPA.Attrs.Options, 2)
+ id1 := createdCPA.Attrs.Options[0].ID
+ id2 := createdCPA.Attrs.Options[1].ID
+ require.NotEmpty(t, id1)
+ require.NotEmpty(t, id2)
+
+ patch := &model.PropertyFieldPatch{
+ Attrs: model.NewPointer(model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": id1, "name": "Updated Option 1", "color": "#333333"},
+ map[string]any{"name": "New Option 1.5", "color": "#353535"},
+ map[string]any{"id": id2, "name": "Updated Option 2", "color": "#444444"},
+ },
+ }),
+ }
+ patched, resp, err := th.SystemAdminClient.PatchCPAField(context.Background(), created.ID, patch)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ patchedCPA, err := model.NewCPAFieldFromPropertyField(patched)
+ require.NoError(t, err)
+ require.Len(t, patchedCPA.Attrs.Options, 3)
+
+ require.Equal(t, id1, patchedCPA.Attrs.Options[0].ID)
+ require.Equal(t, "Updated Option 1", patchedCPA.Attrs.Options[0].Name)
+ require.Equal(t, "#333333", patchedCPA.Attrs.Options[0].Color)
+ require.NotEmpty(t, patchedCPA.Attrs.Options[1].ID)
+ require.Equal(t, "New Option 1.5", patchedCPA.Attrs.Options[1].Name)
+ require.Equal(t, id2, patchedCPA.Attrs.Options[2].ID)
+ require.Equal(t, "Updated Option 2", patchedCPA.Attrs.Options[2].Name)
+ })
+
+ t.Run("changing a field's type deletes dependent values and emits delete_values:true", func(t *testing.T) {
+ webSocketClient := th.CreateConnectedWebSocketClient(t)
+
+ selectField := &model.PropertyField{
+ Name: "select_type_change_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"name": "Option 1", "color": "#FF5733"},
+ },
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), selectField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+
+ createdCPA, err := model.NewCPAFieldFromPropertyField(created)
+ require.NoError(t, err)
+ require.NotEmpty(t, createdCPA.Attrs.Options)
+ optionID := createdCPA.Attrs.Options[0].ID
+ require.NotEmpty(t, optionID)
+
+ // Seed a value for BasicUser referencing the option.
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ created.ID: json.RawMessage(fmt.Sprintf(`"%s"`, optionID)),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ // Patch type from select → text.
+ typePatch := &model.PropertyFieldPatch{Type: model.NewPointer(model.PropertyFieldTypeText)}
+ _, resp, err = th.SystemAdminClient.PatchCPAField(context.Background(), created.ID, typePatch)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ // The dependent value must be gone.
+ retrieved, resp, err := th.SystemAdminClient.ListCPAValues(context.Background(), th.BasicUser.Id)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ _, present := retrieved[created.ID]
+ require.False(t, present, "value should be deleted when the field's type changes")
+
+ // The legacy CPA WS event must carry delete_values:true.
+ var sawDeleteValues bool
+ require.Eventually(t, func() bool {
+ for {
+ select {
+ case event := <-webSocketClient.EventChannel:
+ if event.EventType() != model.WebsocketEventCPAFieldUpdated {
+ continue
+ }
+ if dv, ok := event.GetData()["delete_values"].(bool); ok && dv {
+ sawDeleteValues = true
+ return true
+ }
+ default:
+ return false
+ }
+ }
+ }, 5*time.Second, 100*time.Millisecond)
+ require.True(t, sawDeleteValues, "expected cpa_field_updated to carry delete_values:true on a type change")
+ })
+
+ t.Run("patching a field without changing its type preserves existing values", func(t *testing.T) {
+ selectField := &model.PropertyField{
+ Name: "select_with_values_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"name": "Option 1", "color": "#FF5733"},
+ map[string]any{"name": "Option 2", "color": "#33FF57"},
+ },
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), selectField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+
+ createdCPA, err := model.NewCPAFieldFromPropertyField(created)
+ require.NoError(t, err)
+ require.NotEmpty(t, createdCPA.Attrs.Options)
+ optionID := createdCPA.Attrs.Options[0].ID
+ require.NotEmpty(t, optionID)
+
+ // Admin writes a value on behalf of BasicUser.
+ values := map[string]json.RawMessage{
+ created.ID: json.RawMessage(fmt.Sprintf(`"%s"`, optionID)),
+ }
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ // Rename field + add an option, keeping Type unchanged.
+ patch := &model.PropertyFieldPatch{
+ Name: model.NewPointer("renamed_" + model.NewId()),
+ Attrs: model.NewPointer(model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Renamed Option 1", "color": "#FF5733"},
+ map[string]any{"name": "Option 2", "color": "#33FF57"},
+ map[string]any{"name": "Option 3", "color": "#5733FF"},
+ },
+ }),
+ }
+ _, resp, err = th.SystemAdminClient.PatchCPAField(context.Background(), created.ID, patch)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ // BasicUser's value for this field should still be present.
+ retrieved, resp, err := th.SystemAdminClient.ListCPAValues(context.Background(), th.BasicUser.Id)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ rawValue, ok := retrieved[created.ID]
+ require.True(t, ok, "value should still exist after a non-type-changing patch")
+ require.Equal(t, json.RawMessage(fmt.Sprintf(`"%s"`, optionID)), rawValue)
+ })
}
func TestDeleteCPAField(t *testing.T) {
@@ -354,10 +535,19 @@ func TestDeleteCPAField(t *testing.T) {
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
- resp, err := client.DeleteCPAField(context.Background(), model.NewId())
+ // Create a field with a license so we can test the license check on delete.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+ field := &model.PropertyField{Name: celSafeName(), Type: model.PropertyFieldTypeText}
+ createdField, _, createErr := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ require.NoError(t, createErr)
+ require.NotNil(t, createdField)
+
+ // Remove the license and verify delete is blocked.
+ th.App.Srv().SetLicense(nil)
+ resp, err := client.DeleteCPAField(context.Background(), createdField.ID)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
}, "endpoint should not work if no valid license is present")
// add a valid license
@@ -393,7 +583,12 @@ func TestDeleteCPAField(t *testing.T) {
CheckOKStatus(t, resp)
require.NoError(t, err)
- deletedField, appErr := th.App.GetCPAField(request.TestContext(t), createdField.ID)
+ // The list endpoint filters out deleted fields, so read at the app layer
+ // to confirm the soft-delete landed on the record itself.
+ rctx := request.TestContext(t)
+ group, appErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, appErr)
+ deletedField, appErr := th.App.GetPropertyField(rctx, group.ID, createdField.ID)
require.Nil(t, appErr)
require.NotZero(t, deletedField.DeleteAt)
@@ -426,33 +621,39 @@ func TestListCPAValues(t *testing.T) {
cfg.FeatureFlags.CustomProfileAttributes = true
}).InitBasic(t)
+ // License required for field/value creation (LicenseCheckHook)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
th.RemovePermissionFromRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
- _, appErr = th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdField.ID, json.RawMessage(`"Field Value"`), true)
- require.Nil(t, appErr)
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdField.ID: json.RawMessage(`"Field Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
t.Run("endpoint should not work if no valid license is present", func(t *testing.T) {
+ th.App.Srv().SetLicense(nil)
+ defer th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, values)
})
- // add a valid license
- th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
-
// login with Client2 from this point on
th.LoginBasic2(t)
@@ -467,7 +668,7 @@ func TestListCPAValues(t *testing.T) {
t.Run("should handle array values correctly", func(t *testing.T) {
optionID1 := model.NewId()
optionID2 := model.NewId()
- arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ arrayField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeMultiselect,
Attrs: model.StringInterface{
@@ -476,15 +677,18 @@ func TestListCPAValues(t *testing.T) {
{"id": optionID2, "name": "option2"},
},
},
- })
- require.NoError(t, err)
+ }
- createdArrayField, appErr := th.App.CreateCPAField(request.TestContext(t), arrayField)
- require.Nil(t, appErr)
+ createdArrayField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), arrayField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdArrayField)
- _, appErr = th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdArrayField.ID, json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionID1, optionID2)), true)
- require.Nil(t, appErr)
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdArrayField.ID: json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionID1, optionID2)),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id)
CheckOKStatus(t, resp)
@@ -514,28 +718,31 @@ func TestPatchCPAValues(t *testing.T) {
cfg.FeatureFlags.CustomProfileAttributes = true
}).InitBasic(t)
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ // License required for field creation (LicenseCheckHook)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
t.Run("endpoint should not work if no valid license is present", func(t *testing.T) {
+ th.App.Srv().SetLicense(nil)
+ defer th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
values := map[string]json.RawMessage{createdField.ID: json.RawMessage(`"Field Value"`)}
patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, patchedValues)
})
- // add a valid license
- th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
-
t.Run("any team member should be able to create their own values", func(t *testing.T) {
webSocketClient := th.CreateConnectedWebSocketClient(t)
@@ -609,7 +816,7 @@ func TestPatchCPAValues(t *testing.T) {
t.Run("should handle array values correctly", func(t *testing.T) {
optionsID := []string{model.NewId(), model.NewId(), model.NewId(), model.NewId()}
- arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ arrayField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeMultiselect,
Attrs: model.StringInterface{
@@ -620,11 +827,11 @@ func TestPatchCPAValues(t *testing.T) {
{"id": optionsID[3], "name": "option4"},
},
},
- })
- require.NoError(t, err)
+ }
- createdArrayField, appErr := th.App.CreateCPAField(request.TestContext(t), arrayField)
- require.Nil(t, appErr)
+ createdArrayField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), arrayField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdArrayField)
values := map[string]json.RawMessage{
@@ -652,50 +859,50 @@ func TestPatchCPAValues(t *testing.T) {
t.Run("should fail if any of the values belongs to a field that is LDAP/SAML synced", func(t *testing.T) {
// Create a field with LDAP attribute
- ldapField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ ldapField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attr",
},
- })
- require.NoError(t, err)
+ }
- createdLDAPField, appErr := th.App.CreateCPAField(request.TestContext(t), ldapField)
- require.Nil(t, appErr)
+ createdLDAPField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), ldapField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdLDAPField)
// Create a field with SAML attribute
- samlField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ samlField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsSAML: "saml_attr",
},
- })
- require.NoError(t, err)
+ }
- createdSAMLField, appErr := th.App.CreateCPAField(request.TestContext(t), samlField)
- require.Nil(t, appErr)
+ createdSAMLField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), samlField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdSAMLField)
// Test LDAP field
values := map[string]json.RawMessage{
createdLDAPField.ID: json.RawMessage(`"LDAP Value"`),
}
- _, resp, err := th.Client.PatchCPAValues(context.Background(), values)
- CheckBadRequestStatus(t, resp)
+ _, resp, err = th.Client.PatchCPAValues(context.Background(), values)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
// Test SAML field
values = map[string]json.RawMessage{
createdSAMLField.ID: json.RawMessage(`"SAML Value"`),
}
_, resp, err = th.Client.PatchCPAValues(context.Background(), values)
- CheckBadRequestStatus(t, resp)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
// Test multiple fields with one being LDAP synced
values = map[string]json.RawMessage{
@@ -703,20 +910,20 @@ func TestPatchCPAValues(t *testing.T) {
createdLDAPField.ID: json.RawMessage(`"LDAP Value"`),
}
_, resp, err = th.Client.PatchCPAValues(context.Background(), values)
- CheckBadRequestStatus(t, resp)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
})
t.Run("an invalid patch should be rejected", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
// Create a value that's too long (over 64 characters)
@@ -725,16 +932,16 @@ func TestPatchCPAValues(t *testing.T) {
createdField.ID: json.RawMessage(fmt.Sprintf(`"%s"`, tooLongValue)),
}
- _, resp, err := th.Client.PatchCPAValues(context.Background(), values)
+ _, resp, err = th.Client.PatchCPAValues(context.Background(), values)
CheckBadRequestStatus(t, resp)
require.Error(t, err)
- require.Contains(t, err.Error(), "Failed to validate property value")
+ CheckErrorID(t, err, "app.property_value.validate.app_error")
})
t.Run("admin-managed fields", func(t *testing.T) {
// Create a managed field (only admins can create fields)
managedField := &model.PropertyField{
- Name: "managed_field",
+ Name: "managed_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsManaged: "admin",
@@ -748,7 +955,7 @@ func TestPatchCPAValues(t *testing.T) {
// Create a non-managed field for comparison
regularField := &model.PropertyField{
- Name: "regular_field",
+ Name: "regular_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
}
@@ -765,7 +972,7 @@ func TestPatchCPAValues(t *testing.T) {
_, resp, err := th.Client.PatchCPAValues(context.Background(), values)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error")
+ CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
})
t.Run("regular user can update non-managed field", func(t *testing.T) {
@@ -799,13 +1006,18 @@ func TestPatchCPAValues(t *testing.T) {
})
t.Run("batch update with managed fields fails for regular user", func(t *testing.T) {
- // First set some initial values to ensure we can verify they don't change
- // Set initial values for both fields using th.App (admins can set managed field values)
- _, appErr := th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdRegularField.ID, json.RawMessage(`"Initial Regular Value"`), false)
- require.Nil(t, appErr)
+ // Admin seeds initial values for both fields on BasicUser.
+ _, resp, err := th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdRegularField.ID: json.RawMessage(`"Initial Regular Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
- _, appErr = th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Managed Value"`), true)
- require.Nil(t, appErr)
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdManagedField.ID: json.RawMessage(`"Initial Managed Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
// Try to batch update both managed and regular fields - this should fail
attemptedValues := map[string]json.RawMessage{
@@ -813,43 +1025,21 @@ func TestPatchCPAValues(t *testing.T) {
createdRegularField.ID: json.RawMessage(`"Regular Batch Value"`),
}
- _, resp, err := th.Client.PatchCPAValues(context.Background(), attemptedValues)
+ _, resp, err = th.Client.PatchCPAValues(context.Background(), attemptedValues)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error")
+ CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
- // Verify that no values were updated when the batch operation failed
- currentValues, appErr := th.App.ListCPAValues(request.TestContext(t), th.BasicUser.Id)
- require.Nil(t, appErr)
+ // Verify that no values were updated when the batch operation failed.
+ currentValues, resp, err := th.SystemAdminClient.ListCPAValues(context.Background(), th.BasicUser.Id)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
- // Check that values remain unchanged - both fields should retain their initial values
- regularFieldHasOriginalValue := false
- managedFieldHasOriginalValue := false
-
- for _, value := range currentValues {
- if value.FieldID == createdManagedField.ID {
- var currentValue string
- require.NoError(t, json.Unmarshal(value.Value, ¤tValue))
- if currentValue == "Initial Managed Value" {
- managedFieldHasOriginalValue = true
- }
- // Verify it's not the attempted update value
- require.NotEqual(t, "Managed Batch Value", currentValue, "Managed field should not have been updated in failed batch operation")
- }
- if value.FieldID == createdRegularField.ID {
- var currentValue string
- require.NoError(t, json.Unmarshal(value.Value, ¤tValue))
- if currentValue == "Initial Regular Value" {
- regularFieldHasOriginalValue = true
- }
- // Verify it's not the attempted update value
- require.NotEqual(t, "Regular Batch Value", currentValue, "Regular field should not have been updated in failed batch operation")
- }
- }
-
- // Both fields should retain their original values after the failed batch operation
- require.True(t, regularFieldHasOriginalValue, "Regular field should retain its original value")
- require.True(t, managedFieldHasOriginalValue, "Managed field should retain its original value")
+ var managedValue, regularValue string
+ require.NoError(t, json.Unmarshal(currentValues[createdManagedField.ID], &managedValue))
+ require.NoError(t, json.Unmarshal(currentValues[createdRegularField.ID], ®ularValue))
+ require.Equal(t, "Initial Managed Value", managedValue, "Managed field should not have been updated in failed batch operation")
+ require.Equal(t, "Initial Regular Value", regularValue, "Regular field should not have been updated in failed batch operation")
})
t.Run("batch update with managed fields succeeds for admin", func(t *testing.T) {
@@ -870,6 +1060,59 @@ func TestPatchCPAValues(t *testing.T) {
require.Equal(t, "Admin Regular Batch", regularValue)
})
})
+
+ t.Run("patch fails if any field in the map does not exist", func(t *testing.T) {
+ // App.GetPropertyFields rejects an unknown id with a 404 before the
+ // handler's per-field 404 check runs. The property service wraps the
+ // store's ErrResultsMismatch with the ErrFieldNotFound sentinel,
+ // which mapPropertyServiceError translates into a not-found error.
+ values := map[string]json.RawMessage{
+ model.NewId(): json.RawMessage(`"any value"`),
+ }
+ _, resp, err := th.Client.PatchCPAValues(context.Background(), values)
+ CheckNotFoundStatus(t, resp)
+ require.Error(t, err)
+ CheckErrorID(t, err, "app.property_field.not_found.app_error")
+ })
+
+ t.Run("rejects values that fail hook validation", func(t *testing.T) {
+ optionsID := []string{model.NewId(), model.NewId(), model.NewId()}
+ arrayField := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeMultiselect,
+ Attrs: model.StringInterface{
+ "options": []map[string]any{
+ {"id": optionsID[0], "name": "option1"},
+ {"id": optionsID[1], "name": "option2"},
+ {"id": optionsID[2], "name": "option3"},
+ },
+ },
+ }
+
+ createdArrayField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), arrayField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotNil(t, createdArrayField)
+
+ t.Run("invalid option ID", func(t *testing.T) {
+ unknownOption := model.NewId()
+ values := map[string]json.RawMessage{
+ createdArrayField.ID: json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionsID[0], unknownOption)),
+ }
+ _, resp, err := th.Client.PatchCPAValues(context.Background(), values)
+ CheckBadRequestStatus(t, resp)
+ require.Error(t, err)
+ })
+
+ t.Run("wrong value type (string instead of array)", func(t *testing.T) {
+ values := map[string]json.RawMessage{
+ createdArrayField.ID: json.RawMessage(`"not an array"`),
+ }
+ _, resp, err := th.Client.PatchCPAValues(context.Background(), values)
+ CheckBadRequestStatus(t, resp)
+ require.Error(t, err)
+ })
+ })
}
func TestPatchCPAValuesForUser(t *testing.T) {
@@ -879,22 +1122,28 @@ func TestPatchCPAValuesForUser(t *testing.T) {
cfg.FeatureFlags.CustomProfileAttributes = true
}).InitBasic(t)
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ // License required for field creation (LicenseCheckHook)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
t.Run("endpoint should not work if no valid license is present", func(t *testing.T) {
+ th.App.Srv().SetLicense(nil)
+ defer th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
values := map[string]json.RawMessage{createdField.ID: json.RawMessage(`"Field Value"`)}
patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "api.custom_profile_attributes.license_error")
+ CheckErrorID(t, err, "app.property.license_error")
require.Empty(t, patchedValues)
})
@@ -974,7 +1223,7 @@ func TestPatchCPAValuesForUser(t *testing.T) {
t.Run("should handle array values correctly", func(t *testing.T) {
optionsID := []string{model.NewId(), model.NewId(), model.NewId(), model.NewId()}
- arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ arrayField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeMultiselect,
Attrs: model.StringInterface{
@@ -985,11 +1234,11 @@ func TestPatchCPAValuesForUser(t *testing.T) {
{"id": optionsID[3], "name": "option4"},
},
},
- })
- require.NoError(t, err)
+ }
- createdArrayField, appErr := th.App.CreateCPAField(request.TestContext(t), arrayField)
- require.Nil(t, appErr)
+ createdArrayField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), arrayField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdArrayField)
values := map[string]json.RawMessage{
@@ -1017,50 +1266,50 @@ func TestPatchCPAValuesForUser(t *testing.T) {
t.Run("should fail if any of the values belongs to a field that is LDAP/SAML synced", func(t *testing.T) {
// Create a field with LDAP attribute
- ldapField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ ldapField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attr",
},
- })
- require.NoError(t, err)
+ }
- createdLDAPField, appErr := th.App.CreateCPAField(request.TestContext(t), ldapField)
- require.Nil(t, appErr)
+ createdLDAPField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), ldapField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdLDAPField)
// Create a field with SAML attribute
- samlField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ samlField := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsSAML: "saml_attr",
},
- })
- require.NoError(t, err)
+ }
- createdSAMLField, appErr := th.App.CreateCPAField(request.TestContext(t), samlField)
- require.Nil(t, appErr)
+ createdSAMLField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), samlField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdSAMLField)
// Test LDAP field
values := map[string]json.RawMessage{
createdLDAPField.ID: json.RawMessage(`"LDAP Value"`),
}
- _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
- CheckBadRequestStatus(t, resp)
+ _, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
// Test SAML field
values = map[string]json.RawMessage{
createdSAMLField.ID: json.RawMessage(`"SAML Value"`),
}
_, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
- CheckBadRequestStatus(t, resp)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
// Test multiple fields with one being LDAP synced
values = map[string]json.RawMessage{
@@ -1068,20 +1317,20 @@ func TestPatchCPAValuesForUser(t *testing.T) {
createdLDAPField.ID: json.RawMessage(`"LDAP Value"`),
}
_, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
- CheckBadRequestStatus(t, resp)
+ CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error")
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
})
t.Run("an invalid patch should be rejected", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
+ field := &model.PropertyField{
Name: celSafeName(),
Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
+ }
- createdField, appErr := th.App.CreateCPAField(request.TestContext(t), field)
- require.Nil(t, appErr)
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
require.NotNil(t, createdField)
// Create a value that's too long (over 64 characters)
@@ -1090,16 +1339,16 @@ func TestPatchCPAValuesForUser(t *testing.T) {
createdField.ID: json.RawMessage(fmt.Sprintf(`"%s"`, tooLongValue)),
}
- _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
+ _, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
CheckBadRequestStatus(t, resp)
require.Error(t, err)
- require.Contains(t, err.Error(), "Failed to validate property value")
+ CheckErrorID(t, err, "app.property_value.validate.app_error")
})
t.Run("admin-managed fields", func(t *testing.T) {
// Create a managed field (only admins can create fields)
managedField := &model.PropertyField{
- Name: "managed_field_v2",
+ Name: "managed_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsManaged: "admin",
@@ -1113,7 +1362,7 @@ func TestPatchCPAValuesForUser(t *testing.T) {
// Create a non-managed field for comparison
regularField := &model.PropertyField{
- Name: "regular_field_v2",
+ Name: "regular_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
}
@@ -1130,7 +1379,7 @@ func TestPatchCPAValuesForUser(t *testing.T) {
_, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error")
+ CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
})
t.Run("regular user can update non-managed field", func(t *testing.T) {
@@ -1149,9 +1398,12 @@ func TestPatchCPAValuesForUser(t *testing.T) {
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
- // Set initial value through the app layer that we will be replacing during the test
- _, appErr := th.App.PatchCPAValue(request.TestContext(t), th.SystemAdminUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Admin Value"`), true)
- require.Nil(t, appErr)
+ // Seed a baseline value that the test run then replaces.
+ _, resp, err := th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.SystemAdminUser.Id, map[string]json.RawMessage{
+ createdManagedField.ID: json.RawMessage(`"Initial Admin Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
values := map[string]json.RawMessage{
createdManagedField.ID: json.RawMessage(`"Admin Updated Value"`),
@@ -1205,13 +1457,18 @@ func TestPatchCPAValuesForUser(t *testing.T) {
})
t.Run("batch update with managed fields fails for regular user", func(t *testing.T) {
- // First set some initial values to ensure we can verify they don't change
- // Set initial values for both fields using th.App (admins can set managed field values)
- _, appErr := th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdRegularField.ID, json.RawMessage(`"Initial Regular Value"`), false)
- require.Nil(t, appErr)
+ // Admin seeds initial values for both fields on BasicUser.
+ _, resp, err := th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdRegularField.ID: json.RawMessage(`"Initial Regular Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
- _, appErr = th.App.PatchCPAValue(request.TestContext(t), th.BasicUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Managed Value"`), true)
- require.Nil(t, appErr)
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, map[string]json.RawMessage{
+ createdManagedField.ID: json.RawMessage(`"Initial Managed Value"`),
+ })
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
// Try to batch update both managed and regular fields - this should fail
attemptedValues := map[string]json.RawMessage{
@@ -1219,43 +1476,21 @@ func TestPatchCPAValuesForUser(t *testing.T) {
createdRegularField.ID: json.RawMessage(`"Regular Batch Value"`),
}
- _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, attemptedValues)
+ _, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, attemptedValues)
CheckForbiddenStatus(t, resp)
require.Error(t, err)
- CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error")
+ CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
- // Verify that no values were updated when the batch operation failed
- currentValues, appErr := th.App.ListCPAValues(request.TestContext(t), th.BasicUser.Id)
- require.Nil(t, appErr)
+ // Verify that no values were updated when the batch operation failed.
+ currentValues, resp, err := th.SystemAdminClient.ListCPAValues(context.Background(), th.BasicUser.Id)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
- // Check that values remain unchanged - both fields should retain their initial values
- regularFieldHasOriginalValue := false
- managedFieldHasOriginalValue := false
-
- for _, value := range currentValues {
- if value.FieldID == createdManagedField.ID {
- var currentValue string
- require.NoError(t, json.Unmarshal(value.Value, ¤tValue))
- if currentValue == "Initial Managed Value" {
- managedFieldHasOriginalValue = true
- }
- // Verify it's not the attempted update value
- require.NotEqual(t, "Managed Batch Value", currentValue, "Managed field should not have been updated in failed batch operation")
- }
- if value.FieldID == createdRegularField.ID {
- var currentValue string
- require.NoError(t, json.Unmarshal(value.Value, ¤tValue))
- if currentValue == "Initial Regular Value" {
- regularFieldHasOriginalValue = true
- }
- // Verify it's not the attempted update value
- require.NotEqual(t, "Regular Batch Value", currentValue, "Regular field should not have been updated in failed batch operation")
- }
- }
-
- // Both fields should retain their original values after the failed batch operation
- require.True(t, regularFieldHasOriginalValue, "Regular field should retain its original value")
- require.True(t, managedFieldHasOriginalValue, "Managed field should retain its original value")
+ var managedValue, regularValue string
+ require.NoError(t, json.Unmarshal(currentValues[createdManagedField.ID], &managedValue))
+ require.NoError(t, json.Unmarshal(currentValues[createdRegularField.ID], ®ularValue))
+ require.Equal(t, "Initial Managed Value", managedValue, "Managed field should not have been updated in failed batch operation")
+ require.Equal(t, "Initial Regular Value", regularValue, "Regular field should not have been updated in failed batch operation")
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
@@ -1277,3 +1512,346 @@ func TestPatchCPAValuesForUser(t *testing.T) {
}, "batch update with managed fields succeeds for admin")
})
}
+
+// TestCPANonAdminWriteOwnValueViaGenericAPI confirms a non-admin user can set
+// their own value on a regular CPA field via the generic property API.
+func TestCPANonAdminWriteOwnValueViaGenericAPI(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.CustomProfileAttributes = true
+ cfg.FeatureFlags.IntegratedBoards = true
+ }).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ }
+ createdField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotNil(t, createdField)
+
+ value := "Self Value"
+ items := []model.PropertyValuePatchItem{{
+ FieldID: createdField.ID,
+ Value: json.RawMessage(fmt.Sprintf(`%q`, value)),
+ }}
+
+ upserted, resp, err := th.Client.PatchPropertyValues(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ th.BasicUser.Id,
+ items,
+ )
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ require.Len(t, upserted, 1)
+ require.Equal(t, createdField.ID, upserted[0].FieldID)
+ require.Equal(t, th.BasicUser.Id, upserted[0].TargetID)
+ require.Equal(t, model.PropertyValueTargetTypeUser, upserted[0].TargetType)
+
+ var actualValue string
+ require.NoError(t, json.Unmarshal(upserted[0].Value, &actualValue))
+ require.Equal(t, value, actualValue)
+
+ // Verify the write persisted via a generic-API read on the same target.
+ stored, resp, err := th.Client.GetPropertyValues(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ th.BasicUser.Id,
+ model.PropertyValueSearch{PerPage: 60},
+ )
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ require.Len(t, stored, 1)
+ require.Equal(t, createdField.ID, stored[0].FieldID)
+
+ var readValue string
+ require.NoError(t, json.Unmarshal(stored[0].Value, &readValue))
+ require.Equal(t, value, readValue)
+}
+
+// TestCPANonAdminBlockedFromAdminManagedViaGenericAPI confirms a non-admin user
+// is blocked from writing their own value on an admin-only CPA field via the
+// generic property API.
+func TestCPANonAdminBlockedFromAdminManagedViaGenericAPI(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.CustomProfileAttributes = true
+ cfg.FeatureFlags.IntegratedBoards = true
+ }).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ managedField := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ createdManagedField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), managedField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotNil(t, createdManagedField)
+
+ items := []model.PropertyValuePatchItem{{
+ FieldID: createdManagedField.ID,
+ Value: json.RawMessage(`"Non-Admin Value"`),
+ }}
+
+ t.Run("non-admin writing own admin-managed value is forbidden", func(t *testing.T) {
+ _, resp, err := th.Client.PatchPropertyValues(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ th.BasicUser.Id,
+ items,
+ )
+ CheckForbiddenStatus(t, resp)
+ require.Error(t, err)
+ CheckErrorID(t, err, "api.property_value.patch.no_values_permission.app_error")
+ })
+
+ t.Run("admin writing same admin-managed value succeeds", func(t *testing.T) {
+ adminItems := []model.PropertyValuePatchItem{{
+ FieldID: createdManagedField.ID,
+ Value: json.RawMessage(`"Admin Value"`),
+ }}
+ upserted, resp, err := th.SystemAdminClient.PatchPropertyValues(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ th.BasicUser.Id,
+ adminItems,
+ )
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ require.Len(t, upserted, 1)
+
+ var actualValue string
+ require.NoError(t, json.Unmarshal(upserted[0].Value, &actualValue))
+ require.Equal(t, "Admin Value", actualValue)
+ })
+}
+
+// TestCPACrossAPIFieldRoundtrip verifies that a CPA field created via one
+// API surface reads back equivalently from the other. We deliberately do
+// not do a full map-equality on Attrs: ToPropertyField packs empty-string
+// defaults for every CPA-known key, so CPA→generic→CPA is lossy at the
+// map level. Compare the explicit set of fields that should match instead.
+func TestCPACrossAPIFieldRoundtrip(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.CustomProfileAttributes = true
+ cfg.FeatureFlags.IntegratedBoards = true
+ }).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ t.Run("create via CPA API, read via generic API", func(t *testing.T) {
+ name := celSafeName()
+ field := &model.PropertyField{
+ Name: name,
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsValueType: model.CustomProfileAttributesValueTypeEmail,
+ model.CustomProfileAttributesPropertyAttrsSortOrder: 5,
+ model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityWhenSet,
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotNil(t, created)
+
+ listed, resp, err := th.SystemAdminClient.GetPropertyFields(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ model.PropertyFieldSearch{
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ PerPage: 60,
+ },
+ )
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ var found *model.PropertyField
+ for _, pf := range listed {
+ if pf.ID == created.ID {
+ found = pf
+ break
+ }
+ }
+ require.NotNil(t, found, "field created via CPA API should be readable via generic API")
+
+ require.Equal(t, created.ID, found.ID)
+ require.Equal(t, name, found.Name)
+ require.Equal(t, created.Type, found.Type)
+ require.Equal(t, created.GroupID, found.GroupID)
+ require.Equal(t, model.PropertyFieldObjectTypeUser, found.ObjectType)
+ require.Equal(t, string(model.PropertyFieldTargetLevelSystem), found.TargetType)
+ require.Empty(t, found.TargetID)
+ require.Equal(t, created.CreatedBy, found.CreatedBy)
+ require.Equal(t, created.CreateAt, found.CreateAt)
+ require.Equal(t, int64(0), found.DeleteAt)
+ require.Equal(t, created.PermissionField, found.PermissionField)
+ require.Equal(t, created.PermissionValues, found.PermissionValues)
+ require.Equal(t, created.PermissionOptions, found.PermissionOptions)
+
+ require.Equal(t, model.CustomProfileAttributesValueTypeEmail, found.Attrs[model.CustomProfileAttributesPropertyAttrsValueType])
+ require.EqualValues(t, 5, found.Attrs[model.CustomProfileAttributesPropertyAttrsSortOrder])
+ require.Equal(t, model.CustomProfileAttributesVisibilityWhenSet, found.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
+ })
+
+ t.Run("create via generic API, read via CPA API", func(t *testing.T) {
+ name := celSafeName()
+ field := &model.PropertyField{
+ Name: name,
+ Type: model.PropertyFieldTypeText,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsSortOrder: 3,
+ model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityAlways,
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreatePropertyField(
+ context.Background(),
+ model.AccessControlPropertyGroupName,
+ model.PropertyFieldObjectTypeUser,
+ field,
+ )
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ require.NotNil(t, created)
+
+ listed, resp, err := th.SystemAdminClient.ListCPAFields(context.Background())
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ var found *model.PropertyField
+ for _, pf := range listed {
+ if pf.ID == created.ID {
+ found = pf
+ break
+ }
+ }
+ require.NotNil(t, found, "field created via generic API should be readable via CPA ListCPAFields")
+
+ require.Equal(t, created.ID, found.ID)
+ require.Equal(t, name, found.Name)
+ require.Equal(t, created.Type, found.Type)
+ require.Equal(t, created.GroupID, found.GroupID)
+ require.Equal(t, created.CreateAt, found.CreateAt)
+ require.Equal(t, int64(0), found.DeleteAt)
+
+ // The CPA list response is CPAField-shaped: unmarshal to confirm
+ // the typed attrs struct round-trips cleanly.
+ cpaField, err := model.NewCPAFieldFromPropertyField(found)
+ require.NoError(t, err)
+ require.EqualValues(t, 3, cpaField.Attrs.SortOrder)
+ require.Equal(t, model.CustomProfileAttributesVisibilityAlways, cpaField.Attrs.Visibility)
+ })
+}
+
+// TestCPABackwardCompatAfterRefactor spot-checks invariants that could have
+// drifted in the Phase 7 refactor of the CPA handlers into thin shims. Broad
+// behavioral equivalence is already covered by the existing CPA tests (they
+// still pass); these subtests target invariants that those tests don't
+// exercise directly.
+func TestCPABackwardCompatAfterRefactor(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.CustomProfileAttributes = true
+ cfg.FeatureFlags.IntegratedBoards = true
+ }).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ t.Run("ListCPAFields preserves sort_order ordering", func(t *testing.T) {
+ // Create in a non-sorted order; ListCPAFields should return them
+ // sorted ascending by sort_order via CPAFieldsFromPropertyFields.
+ ids := make([]string, 3)
+ for _, order := range []int{2, 0, 1} {
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsSortOrder: order,
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+ ids[order] = created.ID
+ }
+
+ listed, resp, err := th.SystemAdminClient.ListCPAFields(context.Background())
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(listed), 3)
+
+ // Extract the three fields we just created, preserving ListCPAFields
+ // return order, and verify they match ids[0], ids[1], ids[2].
+ var observed []string
+ for _, pf := range listed {
+ for _, expected := range ids {
+ if pf.ID == expected {
+ observed = append(observed, pf.ID)
+ }
+ }
+ }
+ require.Equal(t, ids, observed, "ListCPAFields must return fields in ascending sort_order")
+ })
+
+ t.Run("CPA create response has typed CPAField attrs, with defaults filled", func(t *testing.T) {
+ field := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsValueType: model.CustomProfileAttributesValueTypeEmail,
+ },
+ }
+ created, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), field)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+
+ // The CPA response goes through ToPropertyField on the server side,
+ // so every CPA-known attrs key is present — including defaults like
+ // Visibility="when_set" that the caller did not send.
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsValueType)
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsVisibility)
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsSortOrder)
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsLDAP)
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsSAML)
+ require.Contains(t, created.Attrs, model.CustomProfileAttributesPropertyAttrsManaged)
+
+ cpaField, err := model.NewCPAFieldFromPropertyField(created)
+ require.NoError(t, err)
+ require.Equal(t, model.CustomProfileAttributesValueTypeEmail, cpaField.Attrs.ValueType)
+ require.Equal(t, model.CustomProfileAttributesVisibilityWhenSet, cpaField.Attrs.Visibility)
+ })
+
+ t.Run("AccessControlHook still blocks LDAP-synced writes via CPA path", func(t *testing.T) {
+ ldapField := &model.PropertyField{
+ Name: celSafeName(),
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attr",
+ },
+ }
+ createdLDAPField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), ldapField)
+ CheckCreatedStatus(t, resp)
+ require.NoError(t, err)
+
+ _, resp, err = th.SystemAdminClient.PatchCPAValuesForUser(
+ context.Background(),
+ th.BasicUser.Id,
+ map[string]json.RawMessage{createdLDAPField.ID: json.RawMessage(`"attempted write"`)},
+ )
+ CheckForbiddenStatus(t, resp)
+ require.Error(t, err)
+ CheckErrorID(t, err, "app.property.sync_lock.app_error")
+ })
+}
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/outgoing_oauth_connection_test.go b/server/channels/api4/outgoing_oauth_connection_test.go
index f8df04c41f5..0dab7bf7fc1 100644
--- a/server/channels/api4/outgoing_oauth_connection_test.go
+++ b/server/channels/api4/outgoing_oauth_connection_test.go
@@ -942,7 +942,7 @@ func TestHandlerOutgoingOAuthConnectionUpdate(t *testing.T) {
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
body := &bytes.Buffer{}
- body.Write([]byte(`{/}`))
+ body.WriteString(`{/}`)
req, err := http.NewRequest("PUT", "/", body)
if err != nil {
@@ -990,7 +990,7 @@ func TestHandlerOutgoingOAuthConnectionUpdate(t *testing.T) {
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
body := &bytes.Buffer{}
- body.Write([]byte(`{"Id": "` + model.NewId() + `", "name": "changed name"}`))
+ body.WriteString(`{"Id": "` + model.NewId() + `", "name": "changed name"}`)
req, err := http.NewRequest("PUT", "/", body)
if err != nil {
@@ -1133,7 +1133,7 @@ func TestHandlerOutgoingOAuthConnectionHandlerCreate(t *testing.T) {
th.AddPermissionToRole(t, model.PermissionManageOutgoingOAuthConnections.Id, model.SystemUserRoleId)
body := &bytes.Buffer{}
- body.Write([]byte(`{/}`))
+ body.WriteString(`{/}`)
req, err := http.NewRequest("POST", "/", body)
if err != nil {
diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go
index cf20d5e0c2d..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) {
@@ -5511,6 +5526,7 @@ func TestGetEditHistoryForPost(t *testing.T) {
}
func TestCreatePostNotificationsWithCRT(t *testing.T) {
+ t.Skip("flaky")
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
diff --git a/server/channels/api4/properties.go b/server/channels/api4/properties.go
index 5d647bb792b..acc6985e7b9 100644
--- a/server/channels/api4/properties.go
+++ b/server/channels/api4/properties.go
@@ -6,12 +6,14 @@ package api4
import (
"encoding/json"
"errors"
+ "maps"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
+ "github.com/mattermost/mattermost/server/v8/channels/app"
)
const maxPropertyValuePatchItems = 50
@@ -63,43 +65,33 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- auditRec := c.MakeAuditRecord(model.AuditEventCreatePropertyField, model.AuditStatusFail)
- defer c.LogAuditRec(auditRec)
-
- // Set ObjectType and GroupID from URL
field.ObjectType = c.Params.ObjectType
field.GroupID = group.ID
- // System-object fields attach to the system itself; canonicalize the
- // target fields so clients can't submit inconsistent combinations.
- // Permissions are likewise pinned to sysadmin: a system field's
- // TargetType is "system", which makes member-level scope checks resolve
- // to "any authenticated user" (see hasPropertyFieldScopeAccess), so
- // honouring a member-level permission on a system field would expose
- // the field's definition, options, and values to every logged-in user.
- if field.ObjectType == model.PropertyFieldObjectTypeSystem {
- field.TargetType = string(model.PropertyFieldTargetLevelSystem)
- field.TargetID = ""
- sysadmin := model.PermissionLevelSysadmin
- field.PermissionField = &sysadmin
- field.PermissionValues = &sysadmin
- field.PermissionOptions = &sysadmin
- }
+ auditRec := c.MakeAuditRecord(model.AuditEventCreatePropertyField, model.AuditStatusFail)
+ defer c.LogAuditRec(auditRec)
+ model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", field)
+
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
- // Reject protected field creation via API
if field.Protected {
c.Err = model.NewAppError("createPropertyField", "api.property_field.create.protected_via_api.app_error", nil, "", http.StatusBadRequest)
return
}
- // Template creation is always sysadmin-only, regardless of target_type.
+ // Pre-canonicalize system objects so the scope check below cannot be
+ // bypassed by submitting ObjectType=system with TargetType=channel. The
+ // App layer re-canonicalizes defensively for plugin/internal callers.
+ app.CanonicalizeSystemObjectField(field)
+
+ // Templates are always sysadmin-only, regardless of TargetType.
if field.ObjectType == model.PropertyFieldObjectTypeTemplate &&
!c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
- // Check scope access for creation based on target_type
+ // Scope-based create permission.
switch field.TargetType {
case "channel":
if field.TargetID == "" {
@@ -130,27 +122,15 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- // Trim whitespace from name
- field.Name = strings.TrimSpace(field.Name)
-
- // Set permissions based on admin status.
- // Permissions are not accepted from the request body; they're set by the server.
- // Templates default to sysadmin since they define the schema linked fields inherit.
- // System-object fields likewise default to sysadmin since they attach to the
- // Mattermost instance and only a system administrator should write them.
+ // Default permission levels: pin all three for non-admins, nil-fill for
+ // admins. Stays in API because it is session-bound.
isAdmin := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
- defaultLevel := model.PermissionLevelMember
- if field.ObjectType == model.PropertyFieldObjectTypeTemplate ||
- field.ObjectType == model.PropertyFieldObjectTypeSystem {
- defaultLevel = model.PermissionLevelSysadmin
- }
+ defaultLevel := app.DefaultPropertyFieldPermissionLevel(field)
if !isAdmin {
- // Non-admin: force all permissions to the default level
field.PermissionField = &defaultLevel
field.PermissionValues = &defaultLevel
field.PermissionOptions = &defaultLevel
} else {
- // Admin with nil fields: set defaults
if field.PermissionField == nil {
field.PermissionField = &defaultLevel
}
@@ -162,17 +142,13 @@ func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
- // Set creator
field.CreatedBy = c.AppContext.Session().UserId
field.UpdatedBy = c.AppContext.Session().UserId
-
- model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", field)
-
connectionID := r.Header.Get(model.ConnectionId)
- createdField, err := c.App.CreatePropertyField(c.AppContext, field, false, connectionID)
- if err != nil {
- c.Err = err
+ createdField, appErr := c.App.CreatePropertyField(rctx, field, false, connectionID)
+ if appErr != nil {
+ c.Err = appErr
return
}
@@ -289,7 +265,6 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
- groupID := group.ID
var patch *model.PropertyFieldPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
@@ -301,8 +276,6 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
*patch.Name = strings.TrimSpace(*patch.Name)
}
- // target_id and target_type are identity fields that define the
- // property's scope and cannot be modified via patch
patch.TargetID = nil
patch.TargetType = nil
@@ -316,94 +289,65 @@ func patchPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- // Get existing field
- existingField, err := c.App.GetPropertyField(c.AppContext, groupID, c.Params.FieldId)
- if err != nil {
- c.Err = err
+ auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyField, model.AuditStatusFail)
+ defer c.LogAuditRec(auditRec)
+ model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
+
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+
+ existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
+ if appErr != nil {
+ c.Err = appErr
return
}
- // FIXME: IsPSAv1 currently includes template fields (ObjectType="template"), but
- // templates are valid PSAv2 objects and must be patchable. Once the FIXME in
- // model.PropertyField.IsPSAv1 is resolved, this extra condition can be removed.
- if existingField.IsPSAv1() && existingField.ObjectType == "" {
+ // PSAv2 routes only operate on PSAv2 fields. Reject legacy fields.
+ if existingField.IsPSAv1() {
c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.legacy_field.app_error", nil, "", http.StatusBadRequest)
return
}
- // Verify ObjectType matches
+ // HTTP-routing: a 404 indistinguishable from "no such field" lets us
+ // bucket fields by URL ObjectType without leaking cross-bucket existence.
if existingField.ObjectType != c.Params.ObjectType {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
+ c.Err = model.NewAppError("patchPropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
return
}
- auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyField, model.AuditStatusFail)
- defer c.LogAuditRec(auditRec)
- model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
- auditRec.AddEventPriorState(existingField)
-
- // Reject update of protected field
- if existingField.Protected {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.protected_via_api.app_error", nil, "", http.StatusForbidden)
- return
+ // Permission branching (session-bound): options-only patches use a
+ // narrower permission than full edits.
+ isOptionsOnly := isOptionsOnlyPatch(patch)
+ if isOptionsOnly && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
+ isOptionsOnly = false
}
-
- // Linked field restrictions
- if existingField.LinkedFieldID != nil && *existingField.LinkedFieldID != "" {
- if patch.Type != nil {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_type_change.app_error", nil, "cannot modify type of a linked field", http.StatusBadRequest)
- return
- }
- if patch.Attrs != nil {
- if _, hasOpts := (*patch.Attrs)[model.PropertyFieldAttributeOptions]; hasOpts {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_options_change.app_error", nil, "cannot modify options of a linked field", http.StatusBadRequest)
- return
- }
- }
- // LinkedFieldID patch validation: only allow unlink (empty string) or same value (no-op)
- if patch.LinkedFieldID != nil && *patch.LinkedFieldID != "" && *patch.LinkedFieldID != *existingField.LinkedFieldID {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.linked_field_change.app_error", nil, "cannot change link target; unlink first then create a new linked field", http.StatusBadRequest)
- return
- }
- } else {
- // Field is not linked — reject attempts to set a new LinkedFieldID
- if patch.LinkedFieldID != nil && *patch.LinkedFieldID != "" {
- c.Err = model.NewAppError("patchPropertyField", "api.property_field.patch.cannot_link_existing.app_error", nil, "linked_field_id can only be set at creation time", http.StatusBadRequest)
- return
- }
- }
-
- // Detect if this is an options-only update
- isOptionsOnlyUpdate := isOptionsOnlyPatch(patch)
-
- // Options-only permission path only applies to select/multiselect fields.
- // For other field types, treat options changes as a field update.
- if isOptionsOnlyUpdate && existingField.Type != model.PropertyFieldTypeSelect && existingField.Type != model.PropertyFieldTypeMultiselect {
- isOptionsOnlyUpdate = false
- }
-
- // Check permissions
- if isOptionsOnlyUpdate {
- if !c.App.SessionHasPermissionToManagePropertyFieldOptions(c.AppContext, *c.AppContext.Session(), existingField) {
+ if isOptionsOnly {
+ if !c.App.SessionHasPermissionToManagePropertyFieldOptions(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.no_options_permission.app_error", nil, "", http.StatusForbidden)
return
}
} else {
- if !c.App.SessionHasPermissionToEditPropertyField(c.AppContext, *c.AppContext.Session(), existingField) {
+ if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("patchPropertyField", "api.property_field.update.no_field_permission.app_error", nil, "", http.StatusForbidden)
return
}
}
- // Apply patch
+ // Capture original state for audit before the in-place patch. Attrs is
+ // shallow-copied because Patch mutates it.
+ orig := *existingField
+ if existingField.Attrs != nil {
+ orig.Attrs = make(model.StringInterface, len(existingField.Attrs))
+ maps.Copy(orig.Attrs, existingField.Attrs)
+ }
+ auditRec.AddEventPriorState(&orig)
+
existingField.Patch(patch, true)
existingField.UpdatedBy = c.AppContext.Session().UserId
-
connectionID := r.Header.Get(model.ConnectionId)
- updatedField, err := c.App.UpdatePropertyField(c.AppContext, groupID, existingField, false, connectionID)
- if err != nil {
- c.Err = err
+ updatedField, _, updateErr := c.App.UpdatePropertyField(rctx, group.ID, existingField, false, connectionID)
+ if updateErr != nil {
+ c.Err = updateErr
return
}
@@ -426,42 +370,34 @@ func deletePropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
- groupID := group.ID
-
- // Get existing field
- existingField, err := c.App.GetPropertyField(c.AppContext, groupID, c.Params.FieldId)
- if err != nil {
- c.Err = err
- return
- }
-
- // Verify ObjectType matches
- if existingField.ObjectType != c.Params.ObjectType {
- c.Err = model.NewAppError("deletePropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
- return
- }
auditRec := c.MakeAuditRecord(model.AuditEventDeletePropertyField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
- auditRec.AddEventPriorState(existingField)
- // Reject deletion of protected field
- if existingField.Protected {
- c.Err = model.NewAppError("deletePropertyField", "api.property_field.delete.protected_via_api.app_error", nil, "", http.StatusForbidden)
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+
+ existingField, appErr := c.App.GetPropertyField(rctx, group.ID, c.Params.FieldId)
+ if appErr != nil {
+ c.Err = appErr
return
}
- // Check field edit permission
- if !c.App.SessionHasPermissionToEditPropertyField(c.AppContext, *c.AppContext.Session(), existingField) {
+ if existingField.ObjectType != c.Params.ObjectType {
+ c.Err = model.NewAppError("deletePropertyField", "api.property_field.object_type_mismatch.app_error", nil, "", http.StatusNotFound)
+ return
+ }
+
+ if !c.App.SessionHasPermissionToEditPropertyField(rctx, *c.AppContext.Session(), existingField) {
c.Err = model.NewAppError("deletePropertyField", "api.property_field.delete.no_permission.app_error", nil, "", http.StatusForbidden)
return
}
- connectionID := r.Header.Get(model.ConnectionId)
+ auditRec.AddEventPriorState(existingField)
- if err := c.App.DeletePropertyField(c.AppContext, groupID, c.Params.FieldId, false, connectionID); err != nil {
- c.Err = err
+ connectionID := r.Header.Get(model.ConnectionId)
+ if deleteErr := c.App.DeletePropertyField(rctx, group.ID, c.Params.FieldId, false, connectionID); deleteErr != nil {
+ c.Err = deleteErr
return
}
@@ -594,12 +530,6 @@ func patchPropertyValuesCore(c *Context, w http.ResponseWriter, r *http.Request,
if c.Err != nil {
return
}
- groupID := group.ID
-
- // Check target access based on object type
- if !hasTargetAccess(c, objectType, targetID, true) {
- return
- }
var items []model.PropertyValuePatchItem
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
@@ -607,104 +537,91 @@ func patchPropertyValuesCore(c *Context, w http.ResponseWriter, r *http.Request,
return
}
- if len(items) == 0 {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
- return
- }
-
- if len(items) > maxPropertyValuePatchItems {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
- "Max": maxPropertyValuePatchItems,
- }, "", http.StatusBadRequest)
- return
- }
-
- // Collect and validate field IDs
- idMap := map[string]bool{}
- fieldIDs := make([]string, 0, len(items))
- for _, item := range items {
- if !model.IsValidId(item.FieldID) {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
- return
- }
- if idMap[item.FieldID] {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.duplicate_field_id.app_error", nil, "", http.StatusBadRequest)
- return
- }
- idMap[item.FieldID] = true
- fieldIDs = append(fieldIDs, item.FieldID)
- }
-
- // Load all fields and verify they belong to this group.
- // GetPropertyFields scopes the lookup by groupID, so fields from
- // a different group won't be found, causing a mismatch error.
- fields, err := c.App.GetPropertyFields(c.AppContext, groupID, fieldIDs)
- if err != nil {
- c.Err = err
- return
- }
-
- // Each field's ObjectType must match the route's objectType. Without
- // this, a caller could reference a field of one type via another
- // type's route (e.g. a system field via the user-values route),
- // bypassing the route-level access checks and persisting values whose
- // TargetType disagrees with field.ObjectType. Templates are always
- // rejected because objectType is never "template" on these routes;
- // keep a dedicated error for that case so the cause is clear.
- for _, f := range fields {
- if f.ObjectType == model.PropertyFieldObjectTypeTemplate {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.template_no_values.app_error", nil, "template fields cannot have values", http.StatusBadRequest)
- return
- }
- if f.ObjectType != objectType {
- c.Err = model.NewAppError("patchPropertyValues", "api.property_value.field_object_type_mismatch.app_error", nil, "", http.StatusBadRequest)
- return
- }
- }
-
- // Build field map for permission checks
- fieldMap := make(map[string]*model.PropertyField, len(fields))
- for _, f := range fields {
- fieldMap[f.ID] = f
- }
-
auditRec := c.MakeAuditRecord(model.AuditEventPatchPropertyValues, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "group_name", c.Params.GroupName)
model.AddEventParameterToAuditRec(auditRec, "object_type", objectType)
model.AddEventParameterToAuditRec(auditRec, "target_id", targetID)
- // Check values permission on each field (all-or-nothing)
+ rctx := app.RequestContextWithCallerID(c.AppContext, sessionCallerID(c))
+
+ if !hasTargetAccess(c, objectType, targetID, true) {
+ return
+ }
+
+ if len(items) == 0 {
+ c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.empty_body.app_error", nil, "", http.StatusBadRequest)
+ return
+ }
+ if len(items) > maxPropertyValuePatchItems {
+ c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.too_many_items.request_error", map[string]any{
+ "Max": maxPropertyValuePatchItems,
+ }, "", http.StatusBadRequest)
+ return
+ }
+
+ // Pre-validate IDs and de-dup so we can bulk-load fields for the
+ // session-bound permission check below. The App layer re-validates these
+ // invariants (defense for plugin/internal callers).
+ seen := map[string]bool{}
+ fieldIDs := make([]string, 0, len(items))
for _, item := range items {
- field := fieldMap[item.FieldID]
- if !c.App.SessionHasPermissionToSetPropertyFieldValues(c.AppContext, *c.AppContext.Session(), field) {
+ if !model.IsValidId(item.FieldID) {
+ c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.invalid_field_id.app_error", nil, "", http.StatusBadRequest)
+ return
+ }
+ if seen[item.FieldID] {
+ c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.duplicate_field_id.app_error", nil, "", http.StatusBadRequest)
+ return
+ }
+ seen[item.FieldID] = true
+ fieldIDs = append(fieldIDs, item.FieldID)
+ }
+
+ fields, fieldsErr := c.App.GetPropertyFields(rctx, group.ID, fieldIDs)
+ if fieldsErr != nil {
+ c.Err = fieldsErr
+ return
+ }
+ fieldByID := make(map[string]*model.PropertyField, len(fields))
+ for _, f := range fields {
+ fieldByID[f.ID] = f
+ }
+ for _, item := range items {
+ f, ok := fieldByID[item.FieldID]
+ if !ok {
+ c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.field_not_found.app_error",
+ map[string]any{"FieldID": item.FieldID}, "", http.StatusNotFound)
+ return
+ }
+ if f.ObjectType != objectType {
+ 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, targetID) {
c.Err = model.NewAppError("patchPropertyValues", "api.property_value.patch.no_values_permission.app_error", nil, "", http.StatusForbidden)
return
}
}
- // Build PropertyValue objects for upsert
userID := c.AppContext.Session().UserId
values := make([]*model.PropertyValue, len(items))
for i, item := range items {
values[i] = &model.PropertyValue{
- TargetID: targetID,
- // in PSAv2, values always point to entities of the same
- // type that their field.ObjectType
+ TargetID: targetID,
TargetType: objectType,
- GroupID: groupID,
+ GroupID: group.ID,
FieldID: item.FieldID,
Value: item.Value,
CreatedBy: userID,
UpdatedBy: userID,
}
}
-
connectionID := r.Header.Get(model.ConnectionId)
- upserted, err := c.App.UpsertPropertyValues(c.AppContext, values, objectType, targetID, connectionID)
- if err != nil {
- c.Err = err
+ upserted, upsertErr := c.App.UpsertPropertyValues(rctx, values, objectType, targetID, connectionID)
+ if upsertErr != nil {
+ c.Err = upsertErr
return
}
@@ -767,11 +684,25 @@ func hasTargetAccess(c *Context, objectType, targetID string, write bool) bool {
return false
}
case model.PropertyFieldObjectTypeUser:
- // Any authenticated user can read another user's property values.
- // Only the user themselves or a system admin can write values.
- if write && targetID != c.AppContext.Session().UserId {
- if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
- c.Err = model.NewAppError("hasTargetAccess", "api.property_value.target_user.forbidden.app_error", nil, "", http.StatusForbidden)
+ // Self-access and unrestricted sessions (local mode) always pass.
+ if targetID == c.AppContext.Session().UserId || c.AppContext.Session().IsUnrestricted() {
+ return true
+ }
+ if write {
+ // Writing another user's values requires PermissionEditOtherUsers.
+ if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditOtherUsers) {
+ c.SetPermissionError(model.PermissionEditOtherUsers)
+ return false
+ }
+ } else {
+ // Reading another user's values requires being able to see them.
+ canSee, appErr := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, targetID)
+ if appErr != nil {
+ c.Err = appErr
+ return false
+ }
+ if !canSee {
+ c.SetPermissionError(model.PermissionViewMembers)
return false
}
}
@@ -794,6 +725,18 @@ func hasTargetAccess(c *Context, objectType, targetID string, write bool) bool {
return true
}
+// sessionCallerID returns the caller ID to attach to a request-derived rctx
+// for property-service hook identification. Local-mode (unrestricted)
+// sessions have an empty Session.UserId but full admin privileges, so they
+// are tagged with CallerIDLocalAdmin instead.
+func sessionCallerID(c *Context) string {
+ session := c.AppContext.Session()
+ if session.IsUnrestricted() {
+ return model.CallerIDLocalAdmin
+ }
+ return session.UserId
+}
+
// isOptionsOnlyPatch checks if the patch only modifies the options attribute.
// Returns true if the only change is to attrs.options.
func isOptionsOnlyPatch(patch *model.PropertyFieldPatch) bool {
diff --git a/server/channels/api4/properties_test.go b/server/channels/api4/properties_test.go
index e673916f916..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)
@@ -901,13 +925,14 @@ func TestPatchPropertyField(t *testing.T) {
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
- // Try to update with wrong object_type in URL
+ // Try to update with wrong object_type in URL. Expected 404 to match
+ // the shape of a non-existent field.
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), group.Name, "channel", createdField.ID, patch)
require.Error(t, err)
- CheckBadRequestStatus(t, resp)
+ CheckNotFoundStatus(t, resp)
})
- t.Run("patch with wrong group name should fail", func(t *testing.T) {
+ t.Run("patch with wrong group name should fail 404", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
@@ -924,11 +949,12 @@ func TestPatchPropertyField(t *testing.T) {
newName := model.NewId()
patch := &model.PropertyFieldPatch{Name: &newName}
- // Try to patch using the other group's name — field belongs to `group`, not `otherGroup`
+ // Try to patch using the other group's name — field belongs to `group`, not `otherGroup`.
+ // A field not found because of a wrong group must surface as 404, not a generic 500.
_, resp, err := th.SystemAdminClient.PatchPropertyField(context.Background(), otherGroup.Name, "post", createdField.ID, patch)
require.Error(t, err)
- // GetPropertyField with the wrong groupID should not find the field
- require.NotEqual(t, http.StatusOK, resp.StatusCode)
+ CheckNotFoundStatus(t, resp)
+ require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
})
t.Run("options-only update should check options permission", func(t *testing.T) {
@@ -1435,13 +1461,14 @@ func TestDeletePropertyField(t *testing.T) {
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
- // Try to delete with wrong object_type in URL
+ // Try to delete with wrong object_type in URL. Expected 404 to match
+ // the shape of a non-existent field.
resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), group.Name, "channel", createdField.ID)
require.Error(t, err)
- CheckBadRequestStatus(t, resp)
+ CheckNotFoundStatus(t, resp)
})
- t.Run("delete with wrong group name should fail", func(t *testing.T) {
+ t.Run("delete with wrong group name should fail 404", func(t *testing.T) {
field := &model.PropertyField{
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
@@ -1455,12 +1482,13 @@ func TestDeletePropertyField(t *testing.T) {
createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
- // Try to delete using the other group's name — field belongs to `group`, not `otherGroup`
- th.LoginBasic(t)
- resp, err := th.Client.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
+ // Try to delete using the other group's name — field belongs to `group`, not `otherGroup`.
+ // A field not found because of a wrong group must surface as 404, not a generic 500.
+ th.LoginSystemAdmin(t)
+ resp, err := th.SystemAdminClient.DeletePropertyField(context.Background(), otherGroup.Name, "post", createdField.ID)
require.Error(t, err)
- // GetPropertyField with the wrong groupID should not find the field
- require.NotEqual(t, http.StatusOK, resp.StatusCode)
+ CheckNotFoundStatus(t, resp)
+ require.Equal(t, "app.property.not_found.app_error", err.(*model.AppError).Id)
})
t.Run("user without permission should not be able to delete", func(t *testing.T) {
@@ -1824,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) {
@@ -1978,7 +2006,7 @@ func TestPatchPropertyValues(t *testing.T) {
}
_, resp, patchErr := th.Client.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
require.Error(t, patchErr)
- CheckBadRequestStatus(t, resp)
+ CheckNotFoundStatus(t, resp)
})
t.Run("nonexistent group should fail", func(t *testing.T) {
@@ -1992,6 +2020,35 @@ func TestPatchPropertyValues(t *testing.T) {
CheckNotFoundStatus(t, resp)
})
+ t.Run("field with mismatched object type should fail 404", func(t *testing.T) {
+ // A field in the same group but scoped to a different ObjectType must not
+ // be patchable through the URL of a peer ObjectType; the mismatch collapses
+ // to 404 so callers cannot distinguish "no such field" from "field exists
+ // but in a different object-type bucket".
+ userField := &model.PropertyField{
+ Name: model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ GroupID: group.ID,
+ ObjectType: "user",
+ TargetType: "system",
+ PermissionField: &memberLevel,
+ PermissionValues: &memberLevel,
+ PermissionOptions: &memberLevel,
+ }
+ createdUserField, appErr := th.App.CreatePropertyField(th.Context, userField, false, "")
+ require.Nil(t, appErr)
+
+ th.LoginSystemAdmin(t)
+
+ items := []model.PropertyValuePatchItem{
+ {FieldID: createdUserField.ID, Value: json.RawMessage(`"test"`)},
+ }
+ _, resp, err := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, "post", targetID, items)
+ require.Error(t, err)
+ CheckNotFoundStatus(t, resp)
+ require.Equal(t, "api.property_field.object_type_mismatch.app_error", err.(*model.AppError).Id)
+ })
+
t.Run("channel member can set values on channel-scoped field with values permission member", func(t *testing.T) {
th.LoginBasic(t)
@@ -2020,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,
@@ -2031,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)
})
@@ -2246,6 +2290,23 @@ func TestGetPropertyValuesUserTargetAccess(t *testing.T) {
CheckOKStatus(t, resp)
require.NotEmpty(t, values)
})
+
+ t.Run("non-admin cannot get values of a user they cannot see", func(t *testing.T) {
+ // Strip system-wide view_members so UserCanSeeOtherUser falls back to team/channel membership.
+ th.RemovePermissionFromRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
+ defer th.AddPermissionToRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
+
+ // Drop BasicUser2 from BasicTeam so they no longer share a team with BasicUser.
+ resp, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id)
+ CheckOKStatus(t, resp)
+ require.NoError(t, err)
+
+ th.LoginBasic2(t)
+
+ _, resp, err = th.Client.GetPropertyValues(context.Background(), group.Name, "user", th.BasicUser.Id, model.PropertyValueSearch{PerPage: 60})
+ CheckForbiddenStatus(t, resp)
+ require.Error(t, err)
+ })
}
func TestPatchPropertyValuesUserTargetAccess(t *testing.T) {
@@ -3353,7 +3414,9 @@ func TestSystemObjectType(t *testing.T) {
}
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.Error(t, patchErr)
- CheckBadRequestStatus(t, resp)
+ // Mismatch (template field ObjectType != system route's objectType)
+ // collapses to 404 to match the executePatchPropertyField shape.
+ CheckNotFoundStatus(t, resp)
})
t.Run("system field round-trips a value via the dedicated route", func(t *testing.T) {
@@ -3502,10 +3565,11 @@ func TestSystemObjectType(t *testing.T) {
{FieldID: systemField.ID, Value: json.RawMessage(`"smuggled"`)},
}
// Even sysadmin should be rejected — this is a structural check on
- // the route, not a permission check.
+ // the route, not a permission check. Mismatch collapses to 404 to
+ // match the executePatchPropertyField/executeDeletePropertyField shape.
_, resp, patchErr := th.SystemAdminClient.PatchPropertyValues(context.Background(), group.Name, model.PropertyFieldObjectTypeUser, th.SystemAdminUser.Id, items)
require.Error(t, patchErr)
- CheckBadRequestStatus(t, resp)
+ CheckNotFoundStatus(t, resp)
})
t.Run("system values PATCH route rejects body referencing a non-system field ID", func(t *testing.T) {
@@ -3531,6 +3595,6 @@ func TestSystemObjectType(t *testing.T) {
}
_, resp, patchErr := th.SystemAdminClient.PatchSystemPropertyValues(context.Background(), group.Name, items)
require.Error(t, patchErr)
- CheckBadRequestStatus(t, resp)
+ CheckNotFoundStatus(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 cd8c58c9285..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)
@@ -678,12 +705,12 @@ func TestS3TestConnection(t *testing.T) {
config.FileSettings.AmazonS3Bucket = new("Wrong_bucket")
resp, err = th.SystemAdminClient.TestS3Connection(context.Background(), &config)
CheckInternalErrorStatus(t, resp)
- CheckErrorID(t, err, "api.file.test_connection_s3_bucket_does_not_exist.app_error")
+ CheckErrorID(t, err, "api.file.test_connection_no_bucket.app_error")
*config.FileSettings.AmazonS3Bucket = "shouldnotcreatenewbucket"
resp, err = th.SystemAdminClient.TestS3Connection(context.Background(), &config)
CheckInternalErrorStatus(t, resp)
- CheckErrorID(t, err, "api.file.test_connection_s3_bucket_does_not_exist.app_error")
+ CheckErrorID(t, err, "api.file.test_connection_no_bucket.app_error")
})
t.Run("with incorrect credentials", func(t *testing.T) {
@@ -691,7 +718,7 @@ func TestS3TestConnection(t *testing.T) {
*configCopy.FileSettings.AmazonS3AccessKeyId = "invalidaccesskey"
resp, err := th.SystemAdminClient.TestS3Connection(context.Background(), &configCopy)
CheckInternalErrorStatus(t, resp)
- CheckErrorID(t, err, "api.file.test_connection_s3_auth.app_error")
+ CheckErrorID(t, err, "api.file.test_connection_auth.app_error")
})
t.Run("empty file settings", func(t *testing.T) {
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 aa55372b3bd..545de311912 100644
--- a/server/channels/api4/user.go
+++ b/server/channels/api4/user.go
@@ -76,6 +76,7 @@ func (api *API) InitUser() {
api.BaseRoutes.UserByUsername.Handle("", api.APISessionRequired(getUserByUsername)).Methods(http.MethodGet)
api.BaseRoutes.UserByEmail.Handle("", api.APISessionRequired(getUserByEmail)).Methods(http.MethodGet)
+ api.BaseRoutes.Users.Handle("/auth_data", api.APISessionRequired(getUserByAuthData)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/sessions", api.APISessionRequired(getSessions)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/sessions/revoke", api.APISessionRequired(revokeSession)).Methods(http.MethodPost)
@@ -461,6 +462,61 @@ func getUserByEmail(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+func getUserByAuthData(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !c.IsSystemAdmin() {
+ c.SetPermissionError(model.PermissionManageSystem)
+ return
+ }
+ authData := r.URL.Query().Get("value")
+ if authData == "" {
+ c.SetInvalidParam("value")
+ return
+ }
+ if len(authData) > model.UserAuthDataMaxLength {
+ c.SetInvalidParam("value")
+ return
+ }
+ user, err := c.App.GetUserByAuthData(&authData)
+ if err != nil {
+ c.Err = err
+ return
+ }
+
+ canSee, err2 := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, user.Id)
+ if err2 != nil {
+ c.Err = err2
+ return
+ }
+
+ if !canSee {
+ c.SetPermissionError(model.PermissionViewMembers)
+ return
+ }
+
+ userTermsOfService, err := c.App.GetUserTermsOfService(user.Id)
+ if err != nil && err.StatusCode != http.StatusNotFound {
+ c.Err = err
+ return
+ }
+
+ if userTermsOfService != nil {
+ user.TermsOfServiceId = userTermsOfService.TermsOfServiceId
+ user.TermsOfServiceCreateAt = userTermsOfService.CreateAt
+ }
+
+ etag := user.Etag(*c.App.Config().PrivacySettings.ShowFullName, *c.App.Config().PrivacySettings.ShowEmailAddress)
+
+ if c.HandleEtag(etag, "Get User", w, r) {
+ return
+ }
+
+ c.App.SanitizeProfile(user, c.IsSystemAdmin())
+ w.Header().Set(model.HeaderEtagServer, etag)
+ if jerr := json.NewEncoder(w).Encode(user); jerr != nil {
+ c.Logger.Warn("Error while writing response", mlog.Err(jerr))
+ }
+}
+
func getDefaultProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
@@ -1899,6 +1955,8 @@ func updatePassword(c *Context, w http.ResponseWriter, r *http.Request) {
if user.IsSystemAdmin() {
canUpdatePassword = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
+ } else if user.IsBot {
+ canUpdatePassword = c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) == nil
} else {
canUpdatePassword = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementUsers)
}
@@ -2471,7 +2529,7 @@ func revokeSession(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventRevokeSession, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
- if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
+ if !c.App.SessionHasPermissionToUserOrBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
@@ -2519,7 +2577,7 @@ func revokeAllSessionsForUser(c *Context, w http.ResponseWriter, r *http.Request
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId)
- if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
+ if !c.App.SessionHasPermissionToUserOrBot(c.AppContext, *c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
@@ -2844,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/api4/user_local.go b/server/channels/api4/user_local.go
index 7fba2cb46cc..2747a92d619 100644
--- a/server/channels/api4/user_local.go
+++ b/server/channels/api4/user_local.go
@@ -40,6 +40,7 @@ func (api *API) InitUserLocal() {
api.BaseRoutes.UserByUsername.Handle("", api.APILocal(localGetUserByUsername)).Methods(http.MethodGet)
api.BaseRoutes.UserByEmail.Handle("", api.APILocal(localGetUserByEmail)).Methods(http.MethodGet)
+ api.BaseRoutes.Users.Handle("/auth_data", api.APILocal(localGetUserByAuthData)).Methods(http.MethodGet)
api.BaseRoutes.Users.Handle("/tokens/revoke", api.APILocal(revokeUserAccessToken)).Methods(http.MethodPost)
api.BaseRoutes.User.Handle("/tokens", api.APILocal(getUserAccessTokensForUser)).Methods(http.MethodGet)
@@ -427,6 +428,46 @@ func localGetUserByEmail(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
+func localGetUserByAuthData(c *Context, w http.ResponseWriter, r *http.Request) {
+ authData := r.URL.Query().Get("value")
+ if authData == "" {
+ c.SetInvalidParam("value")
+ return
+ }
+ if len(authData) > model.UserAuthDataMaxLength {
+ c.SetInvalidParam("value")
+ return
+ }
+ user, err := c.App.GetUserByAuthData(&authData)
+ if err != nil {
+ c.Err = err
+ return
+ }
+
+ userTermsOfService, err := c.App.GetUserTermsOfService(user.Id)
+ if err != nil && err.StatusCode != http.StatusNotFound {
+ c.Err = err
+ return
+ }
+
+ if userTermsOfService != nil {
+ user.TermsOfServiceId = userTermsOfService.TermsOfServiceId
+ user.TermsOfServiceCreateAt = userTermsOfService.CreateAt
+ }
+
+ etag := user.Etag(*c.App.Config().PrivacySettings.ShowFullName, *c.App.Config().PrivacySettings.ShowEmailAddress)
+
+ if c.HandleEtag(etag, "Get User", w, r) {
+ return
+ }
+
+ c.App.SanitizeProfile(user, c.IsSystemAdmin())
+ w.Header().Set(model.HeaderEtagServer, etag)
+ if jerr := json.NewEncoder(w).Encode(user); jerr != nil {
+ c.Logger.Warn("Error while writing response", mlog.Err(jerr))
+ }
+}
+
func localGetUploadsForUser(c *Context, w http.ResponseWriter, r *http.Request) {
uss, appErr := c.App.GetUploadSessionsForUser(c.Params.UserId)
if appErr != nil {
diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go
index 82afeea8db1..c0024292995 100644
--- a/server/channels/api4/user_test.go
+++ b/server/channels/api4/user_test.go
@@ -1487,6 +1487,169 @@ func TestGetUserByEmail(t *testing.T) {
})
}
+func TestGetUserByAuthData(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t)
+
+ team := th.CreateTeamWithClient(t, th.SystemAdminClient)
+ regularUser := th.CreateUser(t)
+ th.LinkUserToTeam(t, regularUser, team)
+ user := th.CreateUser(t)
+ th.LinkUserToTeam(t, user, team)
+ _, err := th.App.Srv().Store().User().VerifyEmail(user.Id, user.Email)
+ require.NoError(t, err)
+
+ authID := "extid-" + model.NewId()
+ userAuth := &model.UserAuth{
+ AuthData: model.NewPointer(authID),
+ AuthService: model.UserAuthServiceSaml,
+ }
+ _, _, err = th.SystemAdminClient.UpdateUserAuth(context.Background(), user.Id, userAuth)
+ require.NoError(t, err)
+
+ th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
+ t.Run("returns user and auth fields for system admin and local", func(t *testing.T) {
+ ruser, resp, getErr := client.GetUserByAuthData(context.Background(), authID, "")
+ require.NoError(t, getErr)
+ require.Equal(t, user.Id, ruser.Id)
+ require.NotNil(t, ruser.AuthData)
+ require.Equal(t, authID, *ruser.AuthData)
+ require.Equal(t, model.UserAuthServiceSaml, ruser.AuthService)
+ ruser, resp, _ = client.GetUserByAuthData(context.Background(), authID, resp.Etag)
+ CheckEtag(t, ruser, resp)
+ })
+
+ t.Run("not found returns not found", func(t *testing.T) {
+ _, resp, notFoundErr := client.GetUserByAuthData(context.Background(), "nope-"+model.NewId(), "")
+ require.Error(t, notFoundErr)
+ CheckNotFoundStatus(t, resp)
+ })
+ })
+
+ t.Run("returns accepted terms of service for system admin", func(t *testing.T) {
+ tos, appErr := th.App.CreateTermsOfService("Dummy TOS", user.Id)
+ require.Nil(t, appErr)
+ appErr = th.App.SaveUserTermsOfService(user.Id, tos.Id, true)
+ require.Nil(t, appErr)
+
+ ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), authID, "")
+ require.NoError(t, getErr)
+ require.Equal(t, tos.Id, ruser.TermsOfServiceId, "Terms of service ID should match")
+ require.NotZero(t, ruser.TermsOfServiceCreateAt, "Terms of service CreateAt should be populated")
+ })
+
+ t.Run("returns user when auth_data is an email-shaped value", func(t *testing.T) {
+ // ResetAuthDataToEmailForUsers sets AuthData = Email for whole batches of
+ // users, so email-shaped auth_data values are common in practice. Verify
+ // the route, Client4 path escaping (`@` -> `%40`), and server-side decoding
+ // all round-trip correctly.
+ emailUser := th.CreateUser(t)
+ th.LinkUserToTeam(t, emailUser, team)
+ emailAuth := "user-" + model.NewId() + "@example.com"
+ _, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), emailUser.Id, &model.UserAuth{
+ AuthData: model.NewPointer(emailAuth),
+ AuthService: model.UserAuthServiceSaml,
+ })
+ require.NoError(t, updErr)
+
+ ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), emailAuth, "")
+ require.NoError(t, getErr)
+ require.Equal(t, emailUser.Id, ruser.Id)
+ require.NotNil(t, ruser.AuthData)
+ require.Equal(t, emailAuth, *ruser.AuthData)
+ })
+
+ t.Run("preserves case in auth_data", func(t *testing.T) {
+ // auth_data is opaque and case-sensitive (unlike email, which the email
+ // endpoint lowercases). Non-SAML IdPs commonly issue mixed-case identifiers,
+ // so guard against a regression where the handler normalizes the value.
+ mixedUser := th.CreateUser(t)
+ th.LinkUserToTeam(t, mixedUser, team)
+ mixedAuth := "MixedCase-" + model.NewId() + "@Example.COM"
+ _, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), mixedUser.Id, &model.UserAuth{
+ AuthData: model.NewPointer(mixedAuth),
+ AuthService: model.UserAuthServiceSaml,
+ })
+ require.NoError(t, updErr)
+
+ ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), mixedAuth, "")
+ require.NoError(t, getErr)
+ require.Equal(t, mixedUser.Id, ruser.Id)
+ require.NotNil(t, ruser.AuthData)
+ require.Equal(t, mixedAuth, *ruser.AuthData)
+
+ _, resp, lowerErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), strings.ToLower(mixedAuth), "")
+ require.Error(t, lowerErr)
+ CheckNotFoundStatus(t, resp)
+ })
+
+ t.Run("returns user when auth_data is an LDAP objectGUID hex-escape form", func(t *testing.T) {
+ // AD objectGUID stored under auth_service=ldap uses the LDAP filter
+ // hex-escape form (`\xx` per byte). Backslashes are special in URL paths
+ // (WHATWG rewrites them to `/`), which is why this endpoint uses a query
+ // parameter; this test guards the query-string round-trip for the exact
+ // shape the customer reported.
+ ldapUser := th.CreateUser(t)
+ th.LinkUserToTeam(t, ldapUser, team)
+ ldapAuth := `\61\14\e1\d1\c5\35\18\4a\b6\60\d6\78\50\fd\0d\5d`
+ _, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), ldapUser.Id, &model.UserAuth{
+ AuthData: model.NewPointer(ldapAuth),
+ AuthService: model.UserAuthServiceLdap,
+ })
+ require.NoError(t, updErr)
+
+ ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), ldapAuth, "")
+ require.NoError(t, getErr)
+ require.Equal(t, ldapUser.Id, ruser.Id)
+ require.NotNil(t, ruser.AuthData)
+ require.Equal(t, ldapAuth, *ruser.AuthData)
+ })
+
+ t.Run("returns user when auth_data is SAML base64 with reserved chars", func(t *testing.T) {
+ // AD objectGUID stored under auth_service=saml uses standard Base64,
+ // which can contain `+`, `/`, and `=` padding -- all reserved in
+ // application/x-www-form-urlencoded. url.Values.Set escapes them
+ // correctly; this test guards against a future regression where someone
+ // rewrites the client to skip that escaping.
+ samlUser := th.CreateUser(t)
+ th.LinkUserToTeam(t, samlUser, team)
+ // Bytes chosen to produce all three reserved characters in the Base64
+ // output: 0xfb,0xef,0xff,0x00 -> "++//AA==".
+ samlAuth := base64.StdEncoding.EncodeToString([]byte{0xfb, 0xef, 0xff, 0x00})
+ require.Contains(t, samlAuth, "+")
+ require.Contains(t, samlAuth, "/")
+ require.Contains(t, samlAuth, "=")
+ _, _, updErr := th.SystemAdminClient.UpdateUserAuth(context.Background(), samlUser.Id, &model.UserAuth{
+ AuthData: model.NewPointer(samlAuth),
+ AuthService: model.UserAuthServiceSaml,
+ })
+ require.NoError(t, updErr)
+
+ ruser, _, getErr := th.SystemAdminClient.GetUserByAuthData(context.Background(), samlAuth, "")
+ require.NoError(t, getErr)
+ require.Equal(t, samlUser.Id, ruser.Id)
+ require.NotNil(t, ruser.AuthData)
+ require.Equal(t, samlAuth, *ruser.AuthData)
+ })
+
+ t.Run("rejects non-system admin", func(t *testing.T) {
+ // `user` is converted to SAML below and can no longer use password login; use
+ // a separate team member to assert the endpoint requires a system admin.
+ _, _, err = th.Client.Login(context.Background(), regularUser.Email, regularUser.Password)
+ require.NoError(t, err)
+ _, resp, err := th.Client.GetUserByAuthData(context.Background(), authID, "")
+ require.Error(t, err)
+ CheckForbiddenStatus(t, resp)
+ })
+
+ t.Run("rejects auth data over max length", func(t *testing.T) {
+ longData := strings.Repeat("x", model.UserAuthDataMaxLength+1)
+ _, resp, err := th.SystemAdminClient.GetUserByAuthData(context.Background(), longData, "")
+ require.Error(t, err)
+ CheckBadRequestStatus(t, resp)
+ })
+}
+
// This test can flake if two calls to model.NewId can return the same value.
// Not much can be done about it.
func TestSearchUsers(t *testing.T) {
@@ -4349,6 +4512,61 @@ func TestRevokeSessions(t *testing.T) {
require.NoError(t, err)
}
+func TestRevokeSessionBotPermissions(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.EnableBotAccountCreation = true
+ })
+
+ bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
+ Username: GenerateTestUsername(),
+ DisplayName: "Test Bot",
+ Description: "bot for revoke-session permission test",
+ })
+ require.NoError(t, err)
+ CheckCreatedStatus(t, botResp)
+ defer func() {
+ appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
+ assert.Nil(t, appErr)
+ }()
+
+ t.Run("user manager without bot permissions cannot revoke bot session", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+
+ // Seed a real session so the test confirms the permission gate blocks
+ // access even when the target session genuinely exists.
+ botSession, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
+ require.Nil(t, appErr)
+
+ th.LoginBasic(t)
+
+ resp, err := th.Client.RevokeSession(context.Background(), bot.UserId, botSession.Id)
+ require.Error(t, err)
+ CheckForbiddenStatus(t, resp)
+ })
+
+ t.Run("user with bot management permissions can revoke bot session", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+
+ // Seed a real session for the bot directly via the app layer.
+ botSession, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
+ require.Nil(t, appErr)
+
+ th.LoginBasic(t)
+
+ _, err := th.Client.RevokeSession(context.Background(), bot.UserId, botSession.Id)
+ require.NoError(t, err)
+
+ // Confirm the session row is gone.
+ _, appErr = th.App.GetSessionById(th.Context, botSession.Id)
+ require.NotNil(t, appErr, "session should no longer exist after revocation")
+ })
+}
+
func TestRevokeAllSessions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
@@ -6743,6 +6961,34 @@ func TestDemoteUserToGuest(t *testing.T) {
require.NoError(t, err)
})
+ t.Run("cannot demote bot account", func(t *testing.T) {
+ th.App.Srv().SetLicense(model.NewTestLicense("guest_accounts"))
+
+ prevBotCreation := *th.App.Config().ServiceSettings.EnableBotAccountCreation
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.EnableBotAccountCreation = true
+ })
+ defer th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.EnableBotAccountCreation = prevBotCreation
+ })
+
+ createdBot, resp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
+ Username: "botdemote" + model.NewId(),
+ DisplayName: "Demote Test Bot",
+ Description: "test",
+ })
+ require.NoError(t, err)
+ CheckCreatedStatus(t, resp)
+ defer func() {
+ appErr := th.App.PermanentDeleteBot(th.Context, createdBot.UserId)
+ require.Nil(t, appErr)
+ }()
+
+ demoteResp, err := th.SystemAdminClient.DemoteUserToGuest(context.Background(), createdBot.UserId)
+ CheckBadRequestStatus(t, demoteResp)
+ CheckErrorID(t, err, "api.user.demote_user_to_guest.bot_not_allowed.app_error")
+ })
+
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
_, _, err := c.GetUser(context.Background(), user.Id, "")
require.NoError(t, err)
@@ -7242,6 +7488,49 @@ func TestUpdatePassword(t *testing.T) {
})
}
+func TestUpdatePasswordBotPermissions(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.EnableBotAccountCreation = true
+ })
+
+ bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
+ Username: GenerateTestUsername(),
+ DisplayName: "Test Bot",
+ Description: "bot for update-password permission test",
+ })
+ require.NoError(t, err)
+ CheckCreatedStatus(t, botResp)
+ defer func() {
+ appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
+ assert.Nil(t, appErr)
+ }()
+
+ t.Run("user manager without bot permissions cannot update bot password", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+
+ th.LoginBasic(t)
+
+ resp, err := th.Client.UpdatePassword(context.Background(), bot.UserId, "", model.NewTestPassword())
+ require.Error(t, err)
+ CheckForbiddenStatus(t, resp)
+ })
+
+ t.Run("user with bot management permissions can update bot password", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+
+ th.LoginBasic(t)
+
+ resp, err := th.Client.UpdatePassword(context.Background(), bot.UserId, "", model.NewTestPassword())
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ })
+}
+
func TestUpdatePasswordAudit(t *testing.T) {
logFile, err := os.CreateTemp("", "adv.log")
require.NoError(t, err)
@@ -9660,6 +9949,57 @@ func TestRevokeAllSessionsForUser(t *testing.T) {
})
}
+func TestRevokeAllSessionsForUserBotPermissions(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.EnableBotAccountCreation = true
+ })
+
+ bot, botResp, err := th.SystemAdminClient.CreateBot(context.Background(), &model.Bot{
+ Username: GenerateTestUsername(),
+ DisplayName: "Test Bot",
+ Description: "bot for revoke-all-sessions permission test",
+ })
+ require.NoError(t, err)
+ CheckCreatedStatus(t, botResp)
+ defer func() {
+ appErr := th.App.PermanentDeleteBot(th.Context, bot.UserId)
+ assert.Nil(t, appErr)
+ }()
+
+ t.Run("user manager without bot permissions cannot revoke all sessions for a bot", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionSysconsoleWriteUserManagementUsers.Id, model.SystemUserRoleId)
+
+ th.LoginBasic(t)
+
+ resp, err := th.Client.RevokeAllSessions(context.Background(), bot.UserId)
+ require.Error(t, err)
+ CheckForbiddenStatus(t, resp)
+ })
+
+ t.Run("user with bot management permissions can revoke all sessions for a bot", func(t *testing.T) {
+ th.AddPermissionToRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+ defer th.RemovePermissionFromRole(t, model.PermissionManageOthersBots.Id, model.SystemUserRoleId)
+
+ // Seed a real session so RevokeAllSessions is not a no-op.
+ _, appErr := th.App.CreateSession(th.Context, &model.Session{UserId: bot.UserId})
+ require.Nil(t, appErr)
+
+ th.LoginBasic(t)
+
+ _, err := th.Client.RevokeAllSessions(context.Background(), bot.UserId)
+ require.NoError(t, err)
+
+ // Confirm all sessions for the bot are gone.
+ sessions, appErr := th.App.GetSessions(th.Context, bot.UserId)
+ require.Nil(t, appErr)
+ require.Empty(t, sessions, "all bot sessions should be revoked")
+ })
+}
+
func TestResetPasswordFailedAttempts(t *testing.T) {
th := SetupEnterprise(t).InitBasic(t)
th.SetupLdapConfig()
diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go
index 885c1ee745c..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
@@ -112,6 +114,41 @@ func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model.
}
}
+ // ABAC is gated at route registration; only check masking here. Masking is
+ // attribute-based: edits are allowed with masked values present as long as
+ // the caller doesn't drop a condition holding values they couldn't see.
+ if a.Config().FeatureFlags.AttributeValueMasking {
+ session := rctx.Session()
+ if session == nil {
+ return nil, model.NewAppError("CreateOrUpdateAccessControlPolicy", "api.context.session_expired.app_error", nil, "session required for masking validation", http.StatusUnauthorized)
+ }
+ callerID := session.UserId
+
+ // Validate submitted values BEFORE merge: only the values the caller
+ // actually submitted should be checked against their holdings. Running
+ // validation after merge would reject the re-injected hidden values
+ // (e.g. Bravo, Charlie) that the caller legitimately cannot see.
+ if appErr := a.validatePolicyExpressionValues(rctx, policy, callerID); appErr != nil {
+ return nil, appErr
+ }
+
+ // Merge hidden values back in and block deletion of masked conditions.
+ if appErr := a.mergeStoredPolicyExpressions(rctx, policy, callerID); appErr != nil {
+ return nil, appErr
+ }
+
+ // Self-inclusion check applies only to non-admins. System admins may
+ // legitimately set conditions for attributes they do not personally hold
+ // (e.g., creating a "Clearance == Top Secret" rule without holding that
+ // clearance themselves). Masking and write-path value validation still
+ // apply to system admins above.
+ if !a.HasPermissionTo(callerID, model.PermissionManageSystem) {
+ if appErr := a.checkSelfInclusion(rctx, policy, callerID); appErr != nil {
+ return nil, appErr
+ }
+ }
+ }
+
var appErr *model.AppError
policy, appErr = acs.SavePolicy(rctx, policy)
if appErr != nil {
@@ -128,6 +165,359 @@ func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model.
return policy, nil
}
+// policyHasMaskedValuesForCaller returns true if policy contains any attribute values
+// that are not visible to callerID under the current masking rules.
+// A nil policy is treated as "no hidden values" — there's nothing to protect.
+func (a *App) policyHasMaskedValuesForCaller(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) (bool, *model.AppError) {
+ if policy == nil {
+ return false, nil
+ }
+
+ for _, rule := range policy.Rules {
+ if rule.Expression == "" || rule.Expression == "true" {
+ continue
+ }
+ maskedAST, appErr := a.GetMaskedVisualAST(rctx, rule.Expression, callerID)
+ if appErr != nil {
+ return false, appErr
+ }
+ for _, cond := range maskedAST.Conditions {
+ if cond.HasMaskedValues {
+ return true, nil
+ }
+ }
+ }
+
+ return false, nil
+}
+
+// mergeStoredPolicyExpressions re-injects hidden values from the stored policy into the
+// submitted one, and blocks the save if the caller removed a condition that contained
+// values they cannot see (which would silently widen access beyond what they could audit).
+// No-op for new policies (not found in store).
+func (a *App) mergeStoredPolicyExpressions(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
+ acs := a.Srv().ch.AccessControl
+ if acs == nil {
+ return nil
+ }
+
+ existingPolicy, appErr := acs.GetPolicy(rctx, policy.ID)
+ if appErr != nil {
+ if appErr.StatusCode == http.StatusNotFound {
+ return nil
+ }
+ return appErr
+ }
+
+ // 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
+ }
+ }
+ }
+
+ 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
+ }
+ 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)
+ if appErr != nil {
+ return false, appErr
+ }
+ for _, cond := range maskedAST.Conditions {
+ if cond.HasMaskedValues {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// mergeExpressionWithMaskedValues re-injects hidden values into submittedExpr and
+// returns 403 if the caller dropped a condition with values they cannot see.
+//
+// Two fail-closed shortcuts before the merge:
+// 1. Caller has no masked values on storedExpr → return submitted as-is.
+// 2. storedExpr isn't faithfully representable by the Visual AST (|| or grouping
+// would flatten into ANDs on rebuild) → accept only no-op saves (e.g., rename),
+// reject real edits. Role-neutral: masking is attribute-based, so a sysadmin
+// without the values lands here too.
+//
+// Stopgap until the canonical CEL AST walker refactor.
+func (a *App) mergeExpressionWithMaskedValues(rctx request.CTX, policyID, submittedExpr, storedExpr, callerID string) (string, *model.AppError) {
+ hasMasked, appErr := a.expressionHasMaskedValuesForCaller(rctx, storedExpr, callerID)
+ if appErr != nil {
+ return "", appErr
+ }
+ if !hasMasked {
+ return submittedExpr, nil
+ }
+
+ submittedAST, appErr := a.ExpressionToVisualAST(rctx, submittedExpr)
+ if appErr != nil {
+ return "", appErr
+ }
+
+ storedAST, appErr := a.ExpressionToVisualAST(rctx, storedExpr)
+ if appErr != nil {
+ return "", appErr
+ }
+
+ if !isVisualASTRepresentable(storedExpr, storedAST) {
+ masked, maskErr := a.GetMaskedExpression(rctx, storedExpr, callerID)
+ if maskErr != nil {
+ return "", maskErr
+ }
+ if normalizedEqual(submittedExpr, masked) {
+ // no-op edit (e.g., rename) — keep stored expression as-is
+ return storedExpr, nil
+ }
+ rctx.Logger().Info("save refused: stored rule not representable by Visual AST",
+ mlog.String("policy_id", policyID),
+ mlog.String("caller_id", callerID),
+ )
+ return "", model.NewAppError("mergeExpressionWithMaskedValues",
+ "app.pap.save_policy.advanced_expression_blocked", nil,
+ "this rule expression cannot be safely edited while restricted values are present",
+ http.StatusForbidden)
+ }
+
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ return "", model.NewAppError("mergeExpressionWithMaskedValues", "app.pap.merge_expression.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
+ }
+ cpaGroupID := cpaGroup.ID
+
+ rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
+
+ // Pre-fetch fields once for all stored conditions. We require every referenced field
+ // to resolve — proceeding with an incomplete map would silently strip hidden values
+ // from stored conditions and bypass the masked-condition-delete block.
+ fieldsByName := a.fetchConditionFields(rctxWithCaller, storedAST.Conditions, cpaGroupID)
+ if appErr := requireAllFieldsResolved(rctxWithCaller, storedAST.Conditions, fieldsByName); appErr != nil {
+ return "", appErr
+ }
+
+ // Count submitted conditions per attribute. A simple set isn't enough because the parser
+ // can produce two conditions on the same attribute (e.g. `attr in [...] && attr == "x"`);
+ // dropping one of them while keeping the other must still trigger the deletion guard if
+ // the dropped condition had hidden values.
+ submittedCounts := make(map[string]int, len(submittedAST.Conditions))
+ for _, cond := range submittedAST.Conditions {
+ submittedCounts[cond.Attribute]++
+ }
+
+ storedCounts := make(map[string]int, len(storedAST.Conditions))
+ for _, cond := range storedAST.Conditions {
+ storedCounts[cond.Attribute]++
+ }
+
+ // Block deletion of any stored condition that has hidden values for this caller.
+ // We walk stored conditions and, when one with hidden values appears, require that
+ // the submitted set still has at least as many conditions on the same attribute as
+ // stored had — otherwise some stored condition was dropped.
+ for i := range storedAST.Conditions {
+ hidden := a.getHiddenValues(rctxWithCaller, callerID, &storedAST.Conditions[i], cpaGroupID, fieldsByName)
+ if len(hidden) == 0 {
+ continue
+ }
+ attr := storedAST.Conditions[i].Attribute
+ if submittedCounts[attr] < storedCounts[attr] {
+ return "", model.NewAppError("mergeExpressionWithMaskedValues", "app.pap.save_policy.masked_condition_deleted", nil,
+ "cannot remove a rule condition that contains attribute values you do not hold", http.StatusForbidden)
+ }
+ }
+
+ // Match submitted conditions to stored ones by attribute (in order), merge hidden values.
+ storedByAttr := make(map[string][]model.Condition)
+ for _, cond := range storedAST.Conditions {
+ storedByAttr[cond.Attribute] = append(storedByAttr[cond.Attribute], cond)
+ }
+
+ matchCount := make(map[string]int)
+ var mergedConditions []model.Condition
+
+ for _, submitted := range submittedAST.Conditions {
+ storedList, found := storedByAttr[submitted.Attribute]
+ if !found {
+ mergedConditions = append(mergedConditions, submitted)
+ continue
+ }
+
+ matchIdx := matchCount[submitted.Attribute]
+ matchCount[submitted.Attribute]++
+
+ if matchIdx >= len(storedList) {
+ mergedConditions = append(mergedConditions, submitted)
+ continue
+ }
+
+ stored := storedList[matchIdx]
+ hiddenValues := a.getHiddenValues(rctxWithCaller, callerID, &stored, cpaGroupID, fieldsByName)
+ merged := mergeConditionValues(submitted, hiddenValues)
+ merged.Operator = stored.Operator
+ merged.AttributeType = stored.AttributeType
+ // Frontend emits "attr in []" as the placeholder for any fully-masked row
+ // regardless of the stored operator. After we restore the original operator,
+ // the value shape may not match (e.g., "==" with a []any value). Normalize
+ // scalar operators to a single string from the array.
+ //
+ // When the stored scalar value is hidden, always use hiddenValues[0] directly
+ // rather than taking arr[0] from the merged list. Without this guard a crafted
+ // submission of `in ["caller-visible"]` would pass validateConditionValues,
+ // land in mergeConditionValues as a []any, and arr[0] would be the attacker's
+ // value — silently overwriting the stored hidden value.
+ if isScalarOperator(merged.Operator) {
+ if len(hiddenValues) > 0 {
+ merged.Value = hiddenValues[0]
+ } else if arr, ok := merged.Value.([]any); ok {
+ if len(arr) == 0 {
+ merged.Value = nil
+ } else if s, ok := arr[0].(string); ok {
+ merged.Value = s
+ }
+ }
+ }
+ mergedConditions = append(mergedConditions, merged)
+ }
+
+ return buildCELFromConditions(mergedConditions), nil
+}
+
+// checkSelfInclusion verifies the caller satisfies all policy rules after their edit.
+func (a *App) checkSelfInclusion(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
+ for _, rule := range policy.Rules {
+ if rule.Expression == "" || rule.Expression == "true" {
+ continue
+ }
+
+ matches, appErr := a.ValidateExpressionAgainstRequester(rctx, rule.Expression, callerID)
+ if appErr != nil {
+ return appErr
+ }
+ if !matches {
+ return model.NewAppError("CreateOrUpdateAccessControlPolicy",
+ "app.pap.save_policy.self_exclusion", nil,
+ "You do not satisfy one or more conditions in this policy.", http.StatusForbidden)
+ }
+ }
+
+ return nil
+}
+
func (a *App) DeleteAccessControlPolicy(rctx request.CTX, id string) *model.AppError {
acs := a.Srv().ch.AccessControl
if acs == nil {
@@ -142,6 +532,20 @@ func (a *App) DeleteAccessControlPolicy(rctx request.CTX, id string) *model.AppE
return appErr
}
+ // ABAC is gated at route registration; only check masking here.
+ if a.Config().FeatureFlags.AttributeValueMasking {
+ session := rctx.Session()
+ if session != nil {
+ callerID := session.UserId
+ if hasMasked, appErr := a.policyHasMaskedValuesForCaller(rctx, policy, callerID); appErr != nil {
+ return appErr
+ } else if hasMasked {
+ return model.NewAppError("DeleteAccessControlPolicy", "app.pap.delete_policy.masked_values", nil,
+ "policy contains attribute values you do not hold; you cannot delete this policy", http.StatusForbidden)
+ }
+ }
+ }
+
var affectedChannelIDs []string
if policy != nil && policy.Type != model.AccessControlPolicyTypeChannel {
affectedChannelIDs = a.channelPolicyIDsWithImport(rctx, id)
@@ -188,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 {
@@ -336,18 +1601,45 @@ func (a *App) GetAccessControlPolicyAttributes(rctx request.CTX, channelID strin
return nil, appErr
}
+ if len(attributes) == 0 {
+ return attributes, nil
+ }
+
+ // Strip source_only and shared_only fields: their values must not be
+ // exposed to channel members through the invite modal / members sidebar.
+ // Fail closed: if the CPA group or a field cannot be resolved, omit that
+ // field rather than leaking its values.
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ return map[string][]string{}, nil
+ }
+
+ for fieldName := range attributes {
+ // Read directly from the store so this security filter sees the raw
+ // access_mode, unaffected by property read hooks for the request caller.
+ field, fieldErr := a.Srv().Store().PropertyField().GetFieldByName(rctx.Context(), cpaGroup.ID, "", fieldName)
+ if fieldErr != nil {
+ delete(attributes, fieldName)
+ continue
+ }
+ switch field.GetAccessMode() {
+ case model.PropertyAccessModeSourceOnly, model.PropertyAccessModeSharedOnly:
+ delete(attributes, fieldName)
+ }
+ }
+
return attributes, nil
}
func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, after string, limit int, callerID string) ([]*model.PropertyField, *model.AppError) {
- cpaGroupID, appErr := a.CpaGroupID()
+ group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
// Use property app layer to enforce access control
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
- fields, appErr := a.SearchPropertyFields(rctxWithCaller, cpaGroupID, model.PropertyFieldSearchOpts{
+ fields, appErr := a.SearchPropertyFields(rctxWithCaller, group.ID, model.PropertyFieldSearchOpts{
Cursor: model.PropertyFieldSearchCursor{
PropertyFieldID: after,
CreateAt: 1,
@@ -371,14 +1663,6 @@ func (a *App) UpdateAccessControlPoliciesActive(rctx request.CTX, updates []mode
if err != nil {
return nil, model.NewAppError("UpdateAccessControlPoliciesActive", "app.pap.update_access_control_policies_active.app_error", nil, err.Error(), http.StatusInternalServerError)
}
-
- for _, policy := range policies {
- // only channel policies use the active state
- if policy.Type == model.AccessControlPolicyTypeChannel {
- a.publishChannelPolicyEnforcedUpdate(rctx, policy.ID)
- }
- }
-
return policies, nil
}
@@ -446,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
@@ -466,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",
@@ -680,41 +2047,181 @@ 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)
- groupID, err := a.CpaGroupID()
+ group, err := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if err != nil {
return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
- subject, storeErr := a.Srv().Store().Attributes().GetSubject(rctx, userID, groupID)
+ subject, storeErr := a.Srv().Store().Attributes().GetSubject(rctx, userID, group.ID)
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 b3d9bd315f7..50b057fc857 100644
--- a/server/channels/app/access_control_masking.go
+++ b/server/channels/app/access_control_masking.go
@@ -5,7 +5,9 @@ package app
import (
"encoding/json"
+ "fmt"
"net/http"
+ "strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
@@ -30,10 +32,11 @@ func (a *App) GetMaskedVisualAST(rctx request.CTX, expression string, callerID s
return visualAST, nil
}
- cpaGroupID, appErr := a.CpaGroupID()
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, model.NewAppError("GetMaskedVisualAST", "app.pap.get_masked_visual_ast.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
+ cpaGroupID := cpaGroup.ID
// Embed callerID in context so GetPropertyFieldByName applies per-caller option filtering.
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
@@ -49,7 +52,9 @@ func (a *App) GetMaskedVisualAST(rctx request.CTX, expression string, callerID s
}
// fetchConditionFields collects unique field names from conditions and fetches each once.
-// Fields that fail lookup are omitted; maskConditionValues treats missing entries as fail-closed.
+// Lookup failures are logged and omitted from the returned map; read-path callers treat
+// missing entries as fail-closed (mask the value). Write-path callers should additionally
+// call requireAllFieldsResolved to refuse to proceed when any referenced field is missing.
func (a *App) fetchConditionFields(rctx request.CTX, conditions []model.Condition, cpaGroupID string) map[string]*model.PropertyField {
seen := make(map[string]bool)
for _, c := range conditions {
@@ -76,6 +81,31 @@ func (a *App) fetchConditionFields(rctx request.CTX, conditions []model.Conditio
return fields
}
+// requireAllFieldsResolved returns the generic invalid-value error if any condition
+// references a field name missing from fieldsByName. Write-path callers use this to refuse
+// the save rather than silently strip hidden values from conditions whose fields could not
+// be resolved. We return the same generic 400 used by the rest of write-path validation so
+// unknown/deleted fields don't leak an enumeration signal distinct from hidden-value
+// rejection — the actual field name is logged for operator diagnostics instead.
+func requireAllFieldsResolved(rctx request.CTX, conditions []model.Condition, fieldsByName map[string]*model.PropertyField) *model.AppError {
+ for _, c := range conditions {
+ if c.ValueType == model.AttrValue {
+ continue
+ }
+ name := extractFieldName(c.Attribute)
+ if name == "" {
+ continue
+ }
+ if _, ok := fieldsByName[name]; !ok {
+ rctx.Logger().Warn("Field referenced by condition could not be resolved during write-path validation",
+ mlog.String("field_name", name),
+ )
+ return invalidValueError()
+ }
+ }
+ return nil
+}
+
// maskConditionValues applies masking to a single condition in place.
//
// Masking semantics differ by field type:
@@ -245,3 +275,997 @@ func filterConditionValues(condition *model.Condition, visibleNames map[string]s
}
}
}
+
+// getHiddenValues returns the subset of stored condition values not visible to callerID.
+// fieldsByName is pre-fetched by the caller to avoid N+1 lookups; a missing entry is
+// treated as fail-closed (no hidden values injected for that condition).
+func (a *App) getHiddenValues(rctx request.CTX, callerID string, stored *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) []string {
+ if stored.ValueType == model.AttrValue {
+ return nil
+ }
+
+ fieldName := extractFieldName(stored.Attribute)
+ if fieldName == "" {
+ return nil
+ }
+
+ field, ok := fieldsByName[fieldName]
+ if !ok {
+ return nil
+ }
+
+ switch field.GetAccessMode() {
+ case model.PropertyAccessModeSourceOnly:
+ return extractStringValues(stored.Value)
+ case model.PropertyAccessModeSharedOnly:
+ var visibleNames map[string]struct{}
+ if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
+ visibleNames = extractVisibleOptionNames(field)
+ } else {
+ visibleNames = a.getCallerTextValues(rctx, callerID, field, cpaGroupID)
+ }
+ var hidden []string
+ for _, val := range extractStringValues(stored.Value) {
+ if _, visible := visibleNames[val]; !visible {
+ hidden = append(hidden, val)
+ }
+ }
+ return hidden
+ default:
+ return nil
+ }
+}
+
+// isScalarOperator reports whether the operator expects a single value (not a list).
+// Used by merge-on-save to normalize the value shape after restoring the stored operator.
+func isScalarOperator(op string) bool {
+ switch op {
+ case "==", "!=", ">", ">=", "<", "<=", "contains", "startsWith", "endsWith":
+ return true
+ }
+ return false
+}
+
+// mergeConditionValues appends hiddenValues into the submitted condition's values,
+// deduplicating. A nil submitted value is restored from hidden values alone.
+func mergeConditionValues(submitted model.Condition, hiddenValues []string) model.Condition {
+ if len(hiddenValues) == 0 {
+ return submitted
+ }
+
+ merged := submitted
+
+ switch v := submitted.Value.(type) {
+ case []any:
+ // Strip the masked-token sentinel from submitted values: it's the
+ // server's own placeholder for hidden values (from a masked GET),
+ // not a real value, and we're about to re-inject the actual stored
+ // hidden values from hiddenValues.
+ seen := make(map[string]struct{})
+ cleaned := make([]any, 0, len(v))
+ for _, item := range v {
+ if s, ok := item.(string); ok {
+ if s == maskedTokenValue {
+ continue
+ }
+ seen[s] = struct{}{}
+ }
+ cleaned = append(cleaned, item)
+ }
+ result := make([]any, 0, len(cleaned)+len(hiddenValues))
+ result = append(result, cleaned...)
+ for _, hidden := range hiddenValues {
+ if _, exists := seen[hidden]; !exists {
+ result = append(result, hidden)
+ }
+ }
+ merged.Value = result
+
+ case string:
+ // For scalar conditions the caller cannot edit a value they cannot see.
+ // Always restore the stored hidden value regardless of what was submitted,
+ // preventing a crafted save from overwriting a hidden stored value with a
+ // different caller-visible string that passes validateConditionValues.
+ if len(hiddenValues) > 0 {
+ merged.Value = hiddenValues[0]
+ }
+
+ case nil:
+ if len(hiddenValues) == 1 {
+ merged.Value = hiddenValues[0]
+ } else if len(hiddenValues) > 1 {
+ result := make([]any, 0, len(hiddenValues))
+ for _, h := range hiddenValues {
+ result = append(result, h)
+ }
+ merged.Value = result
+ }
+ }
+
+ return merged
+}
+
+// containsNonStringLiteral reports whether the condition value contains any
+// non-string element (numeric, boolean, etc.). Used by the write-path to reject
+// type-mismatched literals on property-backed conditions — without this guard,
+// extractStringValues would silently drop such elements and let invalid CEL
+// bypass the source_only / shared_only checks.
+func containsNonStringLiteral(value any) bool {
+ switch v := value.(type) {
+ case nil, string:
+ return false
+ case []any:
+ for _, item := range v {
+ if _, ok := item.(string); !ok {
+ return true
+ }
+ }
+ return false
+ default:
+ // numeric, boolean, etc.
+ return true
+ }
+}
+
+// extractStringValues converts a condition's Value to a slice of strings.
+// Non-string elements are silently dropped — write-path callers should pair
+// this with containsNonStringLiteral to reject type-mismatched literals first.
+func extractStringValues(value any) []string {
+ switch v := value.(type) {
+ case []any:
+ var result []string
+ for _, item := range v {
+ if s, ok := item.(string); ok {
+ result = append(result, s)
+ }
+ }
+ return result
+ case string:
+ return []string{v}
+ default:
+ return nil
+ }
+}
+
+// buildCELFromConditions reconstructs a CEL expression from conditions, joined with " && ".
+func buildCELFromConditions(conditions []model.Condition) string {
+ if len(conditions) == 0 {
+ return "true"
+ }
+
+ parts := make([]string, 0, len(conditions))
+ for _, cond := range conditions {
+ cel := conditionToCEL(cond)
+ if cel != "" {
+ parts = append(parts, cel)
+ }
+ }
+
+ if len(parts) == 0 {
+ return "true"
+ }
+
+ return strings.Join(parts, " && ")
+}
+
+// isVisualASTRepresentable reports whether buildCELFromConditions(ast) round-trips
+// back to originalExpr. False means merging would silently rewrite the shape
+// (typically || or grouping that the AST flattens into ANDs). Stopgap until the
+// canonical AST walker lands.
+func isVisualASTRepresentable(originalExpr string, ast *model.VisualExpression) bool {
+ if ast == nil || len(ast.Conditions) == 0 {
+ return originalExpr == "" || originalExpr == "true"
+ }
+ return normalizedEqual(originalExpr, buildCELFromConditions(ast.Conditions))
+}
+
+// normalizedEqual compares two CEL expressions modulo whitespace and quote style.
+// Unbalanced quotes on either side count as not-equal (fail-closed).
+func normalizedEqual(a, b string) bool {
+ na, okA := normalizeForComparison(a)
+ if !okA {
+ return false
+ }
+ nb, okB := normalizeForComparison(b)
+ if !okB {
+ return false
+ }
+ return na == nb
+}
+
+// normalizeForComparison strips whitespace outside string literals and rewrites
+// single quotes to double. String contents are preserved verbatim. Returns
+// ok=false on unbalanced quotes.
+func normalizeForComparison(s string) (string, bool) {
+ var b strings.Builder
+ b.Grow(len(s))
+ var quote byte // 0 outside string literal; '"' or '\'' inside
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case quote == 0 && (c == '"' || c == '\''):
+ quote = c
+ b.WriteByte('"')
+ case quote != 0 && c == '\\' && i+1 < len(s):
+ // keep escapes verbatim
+ b.WriteByte(c)
+ b.WriteByte(s[i+1])
+ i++
+ case quote != 0 && c == quote:
+ b.WriteByte('"')
+ quote = 0
+ case quote == 0 && (c == ' ' || c == '\t' || c == '\n' || c == '\r'):
+ // drop whitespace outside strings
+ default:
+ b.WriteByte(c)
+ }
+ }
+ if quote != 0 {
+ return "", false
+ }
+ return b.String(), true
+}
+
+// conditionToCEL converts a single Condition to its CEL string representation.
+func conditionToCEL(cond model.Condition) string {
+ attr := cond.Attribute
+
+ switch cond.Operator {
+ case "==", "!=", ">", ">=", "<", "<=":
+ if cond.Value == nil {
+ return ""
+ }
+ return attr + " " + cond.Operator + " " + celValueLiteral(cond.Value)
+
+ case "in":
+ values := extractStringValues(cond.Value)
+ if len(values) == 0 {
+ return ""
+ }
+ if cond.AttributeType == "multiselect" {
+ // multiselect: "v1" in attr && "v2" in attr
+ inParts := make([]string, 0, len(values))
+ for _, v := range values {
+ inParts = append(inParts, celStringLiteral(v)+" in "+attr)
+ }
+ return strings.Join(inParts, " && ")
+ }
+ // select: attr in ["v1", "v2"]
+ valLiterals := make([]string, 0, len(values))
+ for _, v := range values {
+ valLiterals = append(valLiterals, celStringLiteral(v))
+ }
+ return attr + " in [" + strings.Join(valLiterals, ", ") + "]"
+
+ case "hasAnyOf":
+ values := extractStringValues(cond.Value)
+ if len(values) == 0 {
+ return ""
+ }
+ orParts := make([]string, 0, len(values))
+ for _, v := range values {
+ orParts = append(orParts, celStringLiteral(v)+" in "+attr)
+ }
+ if len(orParts) == 1 {
+ // When the sole value is the masked-token sentinel, duplicate it into a
+ // two-branch OR so that the parser can recover hasAnyOf on the next read.
+ // A standalone "tok in attr" is promoted to hasAllOf by
+ // mergeMultiselectConditions, which would display the wrong operator in the UI.
+ if values[0] == maskedTokenValue {
+ return "(" + orParts[0] + " || " + orParts[0] + ")"
+ }
+ return orParts[0]
+ }
+ return "(" + strings.Join(orParts, " || ") + ")"
+
+ case "hasAllOf":
+ values := extractStringValues(cond.Value)
+ if len(values) == 0 {
+ return ""
+ }
+ andParts := make([]string, 0, len(values))
+ for _, v := range values {
+ andParts = append(andParts, celStringLiteral(v)+" in "+attr)
+ }
+ return strings.Join(andParts, " && ")
+
+ case "contains", "startsWith", "endsWith":
+ if cond.Value == nil {
+ return ""
+ }
+ return attr + "." + cond.Operator + "(" + celValueLiteral(cond.Value) + ")"
+
+ default:
+ if cond.Value == nil {
+ return ""
+ }
+ return attr + " " + cond.Operator + " " + celValueLiteral(cond.Value)
+ }
+}
+
+// celStringLiteral wraps s in a CEL-compatible double-quoted string literal.
+// strconv.Quote produces Go syntax that overlaps with CEL's escape grammar
+// (backslash, double quote, \a \b \f \n \r \t \v, \xHH, \uHHHH, \UHHHHHHHH),
+// so it safely round-trips strings containing control characters, embedded
+// quotes, or non-ASCII content — none of which the previous naive ReplaceAll
+// handled. Attribute values that legitimately contain newlines or tabs would
+// have produced broken CEL otherwise.
+func celStringLiteral(s string) string {
+ return strconv.Quote(s)
+}
+
+// celValueLiteral returns the CEL literal for a condition value.
+func celValueLiteral(value any) string {
+ switch v := value.(type) {
+ case string:
+ return celStringLiteral(v)
+ case float64:
+ // 'g' with precision -1 produces the shortest representation that
+ // round-trips back to v exactly. Avoids the precision loss from
+ // fmt.Sprintf("%f") which rounds to six fractional digits.
+ return strconv.FormatFloat(v, 'g', -1, 64)
+ case int:
+ return fmt.Sprintf("%d", v)
+ case int64:
+ return fmt.Sprintf("%d", v)
+ case bool:
+ if v {
+ return "true"
+ }
+ return "false"
+ case nil:
+ return "null"
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+}
+
+// maskedTokenValue is the sentinel the frontend uses for masked values; never a valid attribute value.
+const maskedTokenValue = "--------"
+
+// validatePolicyExpressionValues checks that all submitted literal values are held by the caller.
+// Returns the same generic error for every rejection to prevent value enumeration.
+func (a *App) validatePolicyExpressionValues(rctx request.CTX, policy *model.AccessControlPolicy, callerID string) *model.AppError {
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ return model.NewAppError("validatePolicyExpressionValues", "app.pap.validate_expression_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
+ }
+ cpaGroupID := cpaGroup.ID
+
+ rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
+
+ // Parse all rule ASTs first and collect every referenced field so we can
+ // pre-fetch in a single pass, avoiding N+1 lookups across conditions.
+ rulesASTs := make([]*model.VisualExpression, 0, len(policy.Rules))
+ var allConditions []model.Condition
+ for _, rule := range policy.Rules {
+ if rule.Expression == "" || rule.Expression == "true" {
+ continue
+ }
+ visualAST, appErr := a.ExpressionToVisualAST(rctx, rule.Expression)
+ if appErr != nil {
+ return appErr
+ }
+ rulesASTs = append(rulesASTs, visualAST)
+ allConditions = append(allConditions, visualAST.Conditions...)
+ }
+
+ fieldsByName := a.fetchConditionFields(rctxWithCaller, allConditions, cpaGroupID)
+ if appErr := requireAllFieldsResolved(rctxWithCaller, allConditions, fieldsByName); appErr != nil {
+ return appErr
+ }
+
+ for _, visualAST := range rulesASTs {
+ for _, cond := range visualAST.Conditions {
+ if appErr := a.validateConditionValues(rctxWithCaller, &cond, cpaGroupID, fieldsByName); appErr != nil {
+ return appErr
+ }
+ }
+ }
+
+ return nil
+}
+
+// invalidValueError returns the same generic 400 for all write-path rejections (no enumeration leakage).
+func invalidValueError() *model.AppError {
+ return model.NewAppError("validatePolicyExpressionValues", "app.pap.save_policy.invalid_value", nil, "Invalid value.", http.StatusBadRequest)
+}
+
+// validateConditionValues checks that all literal values in a single condition are held by the caller.
+// fieldsByName is pre-fetched by the caller to avoid N+1 lookups; a missing entry means the field
+// could not be resolved (deleted, or DB error at prefetch time) — rejected with the generic error.
+func (a *App) validateConditionValues(rctx request.CTX, cond *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) *model.AppError {
+ if cond.ValueType == model.AttrValue {
+ return nil
+ }
+
+ // The masked-token sentinel is what the server itself emits when masking the
+ // raw CEL of policy GET / search responses. If the frontend round-trips a GET
+ // response back to us unchanged (e.g. the admin only modified channel
+ // assignment, not the rules), it will appear here. Skip it during validation;
+ // mergeConditionValues will strip it from the merged result and re-inject the
+ // actual hidden values from the stored policy.
+ values := extractStringValues(cond.Value)
+ nonTokenValues := make([]string, 0, len(values))
+ for _, v := range values {
+ if v != maskedTokenValue {
+ nonTokenValues = append(nonTokenValues, v)
+ }
+ }
+
+ fieldName := extractFieldName(cond.Attribute)
+ if fieldName == "" {
+ return nil
+ }
+
+ field, ok := fieldsByName[fieldName]
+ if !ok {
+ return invalidValueError() // reject unknown fields to prevent probing
+ }
+
+ // Property-backed conditions must use string literals. Numeric / boolean values
+ // would be silently dropped by extractStringValues above, letting them bypass the
+ // source_only / shared_only checks. Reject them with the same generic error.
+ if containsNonStringLiteral(cond.Value) {
+ return invalidValueError()
+ }
+
+ switch field.GetAccessMode() {
+ case model.PropertyAccessModePublic:
+ return nil
+ case model.PropertyAccessModeSourceOnly:
+ if len(nonTokenValues) > 0 {
+ return invalidValueError()
+ }
+ return nil
+ case model.PropertyAccessModeSharedOnly:
+ var visibleNames map[string]struct{}
+ if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
+ visibleNames = extractVisibleOptionNames(field)
+ } else {
+ callerID, _ := CallerIDFromRequestContext(rctx)
+ visibleNames = a.getCallerTextValues(rctx, callerID, field, cpaGroupID)
+ }
+ for _, v := range nonTokenValues {
+ if _, visible := visibleNames[v]; !visible {
+ return invalidValueError()
+ }
+ }
+ return nil
+ default:
+ if len(nonTokenValues) > 0 {
+ return invalidValueError()
+ }
+ return nil
+ }
+}
+
+func (a *App) GetMaskedExpression(rctx request.CTX, expression string, callerID string) (string, *model.AppError) {
+ if expression == "" || expression == "true" {
+ return expression, nil
+ }
+
+ visualAST, appErr := a.ExpressionToVisualAST(rctx, expression)
+ if appErr != nil {
+ return "", appErr
+ }
+
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ return "", appErr
+ }
+ cpaGroupID := cpaGroup.ID
+
+ rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
+ fieldsByName := a.fetchConditionFields(rctxWithCaller, visualAST.Conditions, cpaGroupID)
+
+ hasMasked := false
+ for i := range visualAST.Conditions {
+ if a.maskConditionValuesWithToken(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByName) {
+ hasMasked = true
+ }
+ }
+ if !hasMasked {
+ return expression, nil
+ }
+
+ return buildCELFromConditions(visualAST.Conditions), nil
+}
+
+// maskConditionValuesWithToken replaces non-held values with the masked token in place,
+// preserving expression structure so the visual AST endpoint can still parse it.
+// fieldsByName is pre-fetched by the caller to avoid N+1 lookups; a missing entry
+// is treated as fail-closed (whole value masked).
+// maskConditionValuesWithToken replaces non-held values with the masked token in place.
+// Returns true if any value was masked.
+func (a *App) maskConditionValuesWithToken(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) bool {
+ if condition.ValueType == model.AttrValue {
+ return false
+ }
+
+ fieldName := extractFieldName(condition.Attribute)
+ if fieldName == "" {
+ return false
+ }
+
+ field, ok := fieldsByName[fieldName]
+ if !ok {
+ condition.Value = maskedTokenValue // fail closed
+ return true
+ }
+
+ switch field.GetAccessMode() {
+ case model.PropertyAccessModePublic:
+ return false
+ case model.PropertyAccessModeSourceOnly:
+ condition.Value = maskedTokenValue
+ return true
+ case model.PropertyAccessModeSharedOnly:
+ var visibleNames map[string]struct{}
+ if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
+ visibleNames = extractVisibleOptionNames(field)
+ } else {
+ visibleNames = a.getCallerTextValues(rctx, callerID, field, cpaGroupID)
+ }
+ return replaceHiddenValuesWithToken(condition, visibleNames)
+ default:
+ condition.Value = maskedTokenValue
+ return true
+ }
+}
+
+// replaceHiddenValuesWithToken keeps visible values and appends a single masked token if any were hidden.
+// One token regardless of count prevents count-based inference about the number of hidden values.
+// Returns true if any value was replaced.
+func replaceHiddenValuesWithToken(condition *model.Condition, visibleNames map[string]struct{}) bool {
+ switch v := condition.Value.(type) {
+ case []any:
+ var result []any
+ hasMasked := false
+ for _, val := range v {
+ if strVal, ok := val.(string); ok {
+ if _, visible := visibleNames[strVal]; visible {
+ result = append(result, val)
+ } else {
+ hasMasked = true
+ }
+ } else {
+ result = append(result, val)
+ }
+ }
+ if hasMasked {
+ result = append(result, maskedTokenValue)
+ }
+ condition.Value = result
+ return hasMasked
+ case string:
+ if _, visible := visibleNames[v]; !visible {
+ condition.Value = maskedTokenValue
+ return true
+ }
+ }
+ 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) {
+ cpaGroup, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ if appErr != nil {
+ rctx.Logger().Warn("MaskPolicyExpressions: failed to resolve CPA group, masking all rules closed",
+ mlog.Err(appErr),
+ )
+ for i, rule := range policy.Rules {
+ if rule.Expression == "" || rule.Expression == "true" {
+ continue
+ }
+ policy.Rules[i].Expression = "true"
+ }
+ return
+ }
+ cpaGroupID := cpaGroup.ID
+
+ rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
+
+ // Parse each rule's AST once and collect all conditions so we can pre-fetch
+ // every referenced field in a single pass, avoiding N+1 lookups across rules.
+ asts := make([]*model.VisualExpression, len(policy.Rules))
+ var allConditions []model.Condition
+ for i, rule := range policy.Rules {
+ if rule.Expression == "" || rule.Expression == "true" {
+ continue
+ }
+ ast, appErr := a.ExpressionToVisualAST(rctx, rule.Expression)
+ if appErr != nil {
+ policy.Rules[i].Expression = "true" // fail closed
+ continue
+ }
+ asts[i] = ast
+ allConditions = append(allConditions, ast.Conditions...)
+ }
+
+ fieldsByName := a.fetchConditionFields(rctxWithCaller, allConditions, cpaGroupID)
+
+ for i, ast := range asts {
+ if ast == nil {
+ continue
+ }
+ hasMasked := false
+ for j := range ast.Conditions {
+ if a.maskConditionValuesWithToken(rctxWithCaller, callerID, &ast.Conditions[j], cpaGroupID, fieldsByName) {
+ hasMasked = true
+ }
+ }
+ if hasMasked {
+ policy.Rules[i].Expression = buildCELFromConditions(ast.Conditions)
+ }
+ }
+}
diff --git a/server/channels/app/access_control_masking_test.go b/server/channels/app/access_control_masking_test.go
index 96242489e0e..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"
@@ -609,20 +610,24 @@ func TestMaskConditionValues_SharedOnlyText(t *testing.T) {
func TestGetMaskedVisualAST_Wiring(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
rctx := request.TestContext(t)
+ cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, cErr)
+ cpaID := cpaGroup.ID
+
callerID := model.NewId()
// Create a plain public text field in the CPA group (no access mode = public).
// Non-protected fields are writable by any caller in the CPA group.
fieldName := "f_" + model.NewId()
field := &model.PropertyField{
- GroupID: cpaID,
- Name: fieldName,
- Type: model.PropertyFieldTypeText,
+ GroupID: cpaID,
+ Name: fieldName,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
_, appErr := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, appErr)
@@ -664,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
new file mode 100644
index 00000000000..27abf12f365
--- /dev/null
+++ b/server/channels/app/access_control_merge_test.go
@@ -0,0 +1,586 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildCELFromConditions(t *testing.T) {
+ t.Run("empty conditions returns true", func(t *testing.T) {
+ result := buildCELFromConditions(nil)
+ assert.Equal(t, "true", result)
+ })
+
+ t.Run("equals operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Team", Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Team == "Engineering"`, result)
+ })
+
+ t.Run("not equals operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Location", Operator: "!=", Value: "Building 7", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Location != "Building 7"`, result)
+ })
+
+ t.Run("in operator with select field", func(t *testing.T) {
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Department",
+ Operator: "in",
+ Value: []any{"Sales", "Engineering", "Legal"},
+ ValueType: model.LiteralValue,
+ AttributeType: "select",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Department in ["Sales", "Engineering", "Legal"]`, result)
+ })
+
+ t.Run("in operator with multiselect field", func(t *testing.T) {
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "in",
+ Value: []any{"Alpha", "Bravo"},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `"Alpha" in user.attributes.Programs && "Bravo" in user.attributes.Programs`, result)
+ })
+
+ t.Run("hasAnyOf operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "hasAnyOf",
+ Value: []any{"Alpha", "Bravo"},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `("Alpha" in user.attributes.Programs || "Bravo" in user.attributes.Programs)`, result)
+ })
+
+ t.Run("hasAnyOf with single value omits parens", func(t *testing.T) {
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "hasAnyOf",
+ Value: []any{"Alpha"},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `"Alpha" in user.attributes.Programs`, result)
+ })
+
+ t.Run("hasAnyOf with single masked-token value emits duplicate OR to preserve operator through re-parse", func(t *testing.T) {
+ // A sole masked-token sentinel must round-trip as hasAnyOf. Without the
+ // duplicate, a standalone "tok in attr" is promoted to hasAllOf by
+ // mergeMultiselectConditions, showing the wrong operator in the table editor.
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "hasAnyOf",
+ Value: []any{maskedTokenValue},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ expected := `("` + maskedTokenValue + `" in user.attributes.Programs || "` + maskedTokenValue + `" in user.attributes.Programs)`
+ assert.Equal(t, expected, result)
+ })
+
+ t.Run("hasAllOf operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "hasAllOf",
+ Value: []any{"Alpha", "Bravo"},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `"Alpha" in user.attributes.Programs && "Bravo" in user.attributes.Programs`, result)
+ })
+
+ t.Run("contains operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Email", Operator: "contains", Value: "@company.com", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Email.contains("@company.com")`, result)
+ })
+
+ t.Run("startsWith operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Name", Operator: "startsWith", Value: "Dr.", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Name.startsWith("Dr.")`, result)
+ })
+
+ t.Run("endsWith operator", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Email", Operator: "endsWith", Value: ".gov", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Email.endsWith(".gov")`, result)
+ })
+
+ t.Run("multiple conditions joined with &&", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Team", Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
+ {Attribute: "user.attributes.Location", Operator: "!=", Value: "Remote", ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Team == "Engineering" && user.attributes.Location != "Remote"`, result)
+ })
+
+ t.Run("string with special characters is escaped", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Team", Operator: "==", Value: `Team "Alpha"`, ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Team == "Team \"Alpha\""`, result)
+ })
+
+ t.Run("boolean value", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Active", Operator: "==", Value: true, ValueType: model.LiteralValue},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, `user.attributes.Active == true`, result)
+ })
+
+ t.Run("empty in-list produces no output", func(t *testing.T) {
+ conditions := []model.Condition{
+ {Attribute: "user.attributes.Department", Operator: "in", Value: []any{}, ValueType: model.LiteralValue, AttributeType: "select"},
+ }
+ result := buildCELFromConditions(conditions)
+ assert.Equal(t, "true", result)
+ })
+
+ t.Run("masked token in values produces valid CEL", func(t *testing.T) {
+ conds := []model.Condition{{
+ Attribute: "user.attributes.Program",
+ Operator: "in",
+ Value: []any{"Alpha", maskedTokenValue},
+ ValueType: model.LiteralValue,
+ AttributeType: "select",
+ }}
+ result := buildCELFromConditions(conds)
+ assert.Contains(t, result, "Alpha")
+ assert.Contains(t, result, maskedTokenValue)
+ })
+}
+
+func TestNormalizeForComparison(t *testing.T) {
+ t.Run("strips whitespace outside string literals", func(t *testing.T) {
+ got, ok := normalizeForComparison(` a == "b" `)
+ require.True(t, ok)
+ assert.Equal(t, `a=="b"`, got)
+ })
+
+ t.Run("preserves whitespace inside string literals", func(t *testing.T) {
+ got, ok := normalizeForComparison(`a == "hello world"`)
+ require.True(t, ok)
+ assert.Equal(t, `a=="hello world"`, got)
+ })
+
+ t.Run("canonicalizes single quotes to double quotes", func(t *testing.T) {
+ single, ok := normalizeForComparison(`a == 'foo'`)
+ require.True(t, ok)
+ double, ok := normalizeForComparison(`a == "foo"`)
+ require.True(t, ok)
+ assert.Equal(t, single, double)
+ })
+
+ t.Run("preserves escape sequences inside string literals", func(t *testing.T) {
+ got, ok := normalizeForComparison(`a == "he said \"hi\""`)
+ require.True(t, ok)
+ assert.Equal(t, `a=="he said \"hi\""`, got)
+ })
+
+ t.Run("unbalanced quote returns ok=false", func(t *testing.T) {
+ _, ok := normalizeForComparison(`a == "unterminated`)
+ assert.False(t, ok)
+ })
+
+ t.Run("empty string normalizes to empty", func(t *testing.T) {
+ got, ok := normalizeForComparison("")
+ require.True(t, ok)
+ assert.Equal(t, "", got)
+ })
+}
+
+func TestIsVisualASTRepresentable(t *testing.T) {
+ t.Run("empty AST on empty expression is representable", func(t *testing.T) {
+ assert.True(t, isVisualASTRepresentable("", &model.VisualExpression{}))
+ })
+
+ t.Run("empty AST on 'true' is representable", func(t *testing.T) {
+ assert.True(t, isVisualASTRepresentable("true", &model.VisualExpression{}))
+ })
+
+ t.Run("simple equals condition round-trips cleanly", func(t *testing.T) {
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {Attribute: "user.attributes.team", Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
+ }}
+ assert.True(t, isVisualASTRepresentable(`user.attributes.team == "Engineering"`, ast))
+ })
+
+ t.Run("simple AND chain of two conditions round-trips cleanly", func(t *testing.T) {
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {Attribute: "user.attributes.team", Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
+ {Attribute: "user.attributes.role", Operator: "==", Value: "Admin", ValueType: model.LiteralValue},
+ }}
+ assert.True(t, isVisualASTRepresentable(
+ `user.attributes.team == "Engineering" && user.attributes.role == "Admin"`,
+ ast,
+ ))
+ })
+
+ t.Run("|| in original but AST flattens to AND is NOT representable", func(t *testing.T) {
+ // Pretend the parser flattened `a == "X" || b == "Y"` into two AND-joined
+ // conditions. The round-trip would emit `&&`, mismatch detected.
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {Attribute: "user.attributes.team", Operator: "==", Value: "X", ValueType: model.LiteralValue},
+ {Attribute: "user.attributes.role", Operator: "==", Value: "Y", ValueType: model.LiteralValue},
+ }}
+ assert.False(t, isVisualASTRepresentable(
+ `user.attributes.team == "X" || user.attributes.role == "Y"`,
+ ast,
+ ))
+ })
+
+ t.Run("grouping in original is NOT representable when AST flattens it", func(t *testing.T) {
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {Attribute: "user.attributes.a", Operator: "==", Value: "1", ValueType: model.LiteralValue},
+ {Attribute: "user.attributes.b", Operator: "==", Value: "2", ValueType: model.LiteralValue},
+ {Attribute: "user.attributes.c", Operator: "==", Value: "3", ValueType: model.LiteralValue},
+ }}
+ assert.False(t, isVisualASTRepresentable(
+ `(user.attributes.a == "1" && user.attributes.b == "2") || user.attributes.c == "3"`,
+ ast,
+ ))
+ })
+
+ t.Run("hasAnyOf with multiple values is representable (|| within a single condition)", func(t *testing.T) {
+ // `("Alpha" in attr || "Bravo" in attr)` is the canonical serialization
+ // for a hasAnyOf condition. It contains || syntactically but the AST
+ // reduces it to one condition that round-trips identically.
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {
+ Attribute: "user.attributes.Programs",
+ Operator: "hasAnyOf",
+ Value: []any{"Alpha", "Bravo"},
+ ValueType: model.LiteralValue,
+ AttributeType: "multiselect",
+ },
+ }}
+ assert.True(t, isVisualASTRepresentable(
+ `("Alpha" in user.attributes.Programs || "Bravo" in user.attributes.Programs)`,
+ ast,
+ ))
+ })
+
+ t.Run("unbalanced quote in original is NOT representable", func(t *testing.T) {
+ ast := &model.VisualExpression{Conditions: []model.Condition{
+ {Attribute: "user.attributes.team", Operator: "==", Value: "X", ValueType: model.LiteralValue},
+ }}
+ assert.False(t, isVisualASTRepresentable(`user.attributes.team == "unterminated`, ast))
+ })
+}
+
+func TestExtractStringValues(t *testing.T) {
+ t.Run("slice of strings", func(t *testing.T) {
+ result := extractStringValues([]any{"Alpha", "Bravo", "Charlie"})
+ assert.Equal(t, []string{"Alpha", "Bravo", "Charlie"}, result)
+ })
+
+ t.Run("single string", func(t *testing.T) {
+ result := extractStringValues("Alpha")
+ assert.Equal(t, []string{"Alpha"}, result)
+ })
+
+ t.Run("nil", func(t *testing.T) {
+ result := extractStringValues(nil)
+ assert.Nil(t, result)
+ })
+
+ t.Run("mixed types in slice", func(t *testing.T) {
+ result := extractStringValues([]any{"Alpha", 42, "Bravo"})
+ assert.Equal(t, []string{"Alpha", "Bravo"}, result)
+ })
+
+ t.Run("non-string non-slice", func(t *testing.T) {
+ result := extractStringValues(42)
+ assert.Nil(t, result)
+ })
+}
+
+func TestCelStringLiteral(t *testing.T) {
+ assert.Equal(t, `"hello"`, celStringLiteral("hello"))
+ assert.Equal(t, `"hello \"world\""`, celStringLiteral(`hello "world"`))
+ assert.Equal(t, `"path\\to\\file"`, celStringLiteral(`path\to\file`))
+ assert.Equal(t, `""`, celStringLiteral(""))
+
+ // Control characters must be escaped or the emitted CEL literal won't parse.
+ assert.Equal(t, `"line1\nline2"`, celStringLiteral("line1\nline2"))
+ assert.Equal(t, `"col1\tcol2"`, celStringLiteral("col1\tcol2"))
+ assert.Equal(t, `"carriage\rreturn"`, celStringLiteral("carriage\rreturn"))
+}
+
+func TestCelValueLiteral(t *testing.T) {
+ assert.Equal(t, `"hello"`, celValueLiteral("hello"))
+ assert.Equal(t, "true", celValueLiteral(true))
+ assert.Equal(t, "false", celValueLiteral(false))
+ assert.Equal(t, "42", celValueLiteral(int(42)))
+ assert.Equal(t, "42", celValueLiteral(int64(42)))
+ assert.Equal(t, "3.14", celValueLiteral(float64(3.14)))
+ assert.Equal(t, "null", celValueLiteral(nil))
+
+ // Float precision must round-trip — %f would round 0.123456789 to 0.123457.
+ assert.Equal(t, "0.123456789", celValueLiteral(float64(0.123456789)))
+}
+
+func TestContainsNonStringLiteral(t *testing.T) {
+ assert.False(t, containsNonStringLiteral(nil))
+ assert.False(t, containsNonStringLiteral("Alpha"))
+ assert.False(t, containsNonStringLiteral([]any{"Alpha", "Bravo"}))
+
+ assert.True(t, containsNonStringLiteral(float64(1)))
+ assert.True(t, containsNonStringLiteral(true))
+ assert.True(t, containsNonStringLiteral(int64(7)))
+ assert.True(t, containsNonStringLiteral([]any{"Alpha", 1.0}))
+}
+
+func TestConditionToCEL_NilValue(t *testing.T) {
+ // A condition whose Value was masked to nil (e.g. all options hidden) must be dropped
+ // rather than emitting `attr == null`, which is invalid CEL for string attributes.
+ nilValueOps := []string{"==", "!=", ">", ">=", "<", "<=", "contains", "startsWith", "endsWith", "unknownOp"}
+ for _, op := range nilValueOps {
+ cond := model.Condition{
+ Attribute: "user.attributes.Clearance",
+ Operator: op,
+ Value: nil,
+ ValueType: model.LiteralValue,
+ }
+ assert.Equal(t, "", conditionToCEL(cond), "operator %q with nil value must produce empty string", op)
+ }
+}
+
+func TestConditionToCEL_UnknownOperatorWithValue(t *testing.T) {
+ // An unknown operator with a non-nil value produces a best-effort CEL expression.
+ // buildCELFromConditions will include it as-is; if the operator is truly unknown
+ // the downstream CEL engine will reject the expression during validation.
+ // This documents the intended (pass-through) behaviour for forward-compatibility.
+ cond := model.Condition{
+ Attribute: "user.attributes.Clearance",
+ Operator: "futureOp",
+ Value: "Secret",
+ ValueType: model.LiteralValue,
+ }
+ result := conditionToCEL(cond)
+ 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"}}
+ result := mergeConditionValues(submitted, nil)
+ assert.Equal(t, []any{"Alpha"}, result.Value)
+ })
+
+ t.Run("appends hidden values without duplicates", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Program", Operator: "in", Value: []any{"Alpha"}}
+ result := mergeConditionValues(submitted, []string{"Bravo", "Charlie"})
+ values, ok := result.Value.([]any)
+ require.True(t, ok)
+ assert.Len(t, values, 3)
+ assert.Contains(t, values, "Alpha")
+ assert.Contains(t, values, "Bravo")
+ assert.Contains(t, values, "Charlie")
+ })
+
+ t.Run("deduplicates overlapping values", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Program", Operator: "in", Value: []any{"Alpha", "Bravo"}}
+ result := mergeConditionValues(submitted, []string{"Bravo", "Charlie"})
+ values, ok := result.Value.([]any)
+ require.True(t, ok)
+ assert.Len(t, values, 3)
+ })
+
+ t.Run("restores hidden values when submitted is nil (fully-masked placeholder)", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Program", Operator: "in", Value: nil}
+ result := mergeConditionValues(submitted, []string{"Bravo", "Charlie"})
+ values, ok := result.Value.([]any)
+ require.True(t, ok)
+ assert.Len(t, values, 2)
+ })
+
+ t.Run("restores single hidden value when submitted is nil", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Location", Operator: "==", Value: nil}
+ result := mergeConditionValues(submitted, []string{"Building 7"})
+ assert.Equal(t, "Building 7", result.Value)
+ })
+
+ t.Run("scalar: hidden value wins over empty submitted string", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Location", Operator: "!=", Value: ""}
+ result := mergeConditionValues(submitted, []string{"Building 7"})
+ assert.Equal(t, "Building 7", result.Value)
+ })
+
+ t.Run("scalar: hidden value wins over masked-token submitted string", func(t *testing.T) {
+ submitted := model.Condition{Attribute: "user.attributes.Location", Operator: "!=", Value: maskedTokenValue}
+ result := mergeConditionValues(submitted, []string{"Building 7"})
+ assert.Equal(t, "Building 7", result.Value)
+ })
+
+ t.Run("scalar: hidden value wins over caller-visible submitted string (security: prevents overwrite)", func(t *testing.T) {
+ // A crafted save can submit a caller-held value that passes validateConditionValues.
+ // mergeConditionValues must still restore the stored hidden value so the caller
+ // cannot overwrite a shared_only scalar they cannot see.
+ submitted := model.Condition{Attribute: "user.attributes.Location", Operator: "!=", Value: "Building 1"}
+ result := mergeConditionValues(submitted, []string{"Building 7"})
+ assert.Equal(t, "Building 7", result.Value)
+ })
+
+ t.Run("scalar via []any: mergeConditionValues appends hidden value last; isScalarOperator block must use hiddenValues[0] not arr[0]", func(t *testing.T) {
+ // Second attack vector: a crafted submission of `in ["Building 1"]` (a list,
+ // not a string) also passes validateConditionValues for a shared_only caller.
+ // mergeConditionValues produces ["Building 1", "Building 7"] — the caller's
+ // value comes first. The isScalarOperator normalization in
+ // mergeExpressionWithMaskedValues must use hiddenValues[0] directly rather
+ // than arr[0], otherwise the attacker's value wins.
+ submitted := model.Condition{Attribute: "user.attributes.Location", Operator: "in", Value: []any{"Building 1"}}
+ result := mergeConditionValues(submitted, []string{"Building 7"})
+ values, ok := result.Value.([]any)
+ require.True(t, ok)
+ // Hidden value is appended after the submitted one — arr[0] would be "Building 1".
+ // The fix in mergeExpressionWithMaskedValues (isScalarOperator block) must
+ // pick hiddenValues[0] = "Building 7" instead of arr[0].
+ assert.Equal(t, "Building 1", values[0], "submitted value is first in merged list")
+ assert.Equal(t, "Building 7", values[1], "hidden value is appended last")
+ })
+}
+
+func TestGetHiddenValues(t *testing.T) {
+ var a *App
+
+ options := []any{
+ map[string]any{"id": "id1", "name": "Alpha"},
+ map[string]any{"id": "id2", "name": "Bravo"},
+ }
+ makeField := func(accessMode string, fieldType model.PropertyFieldType) *model.PropertyField {
+ attrs := model.StringInterface{model.PropertyAttrsAccessMode: accessMode}
+ if fieldType == model.PropertyFieldTypeSelect || fieldType == model.PropertyFieldTypeMultiselect {
+ attrs[model.PropertyFieldAttributeOptions] = options
+ }
+ return &model.PropertyField{Type: fieldType, Attrs: attrs}
+ }
+
+ t.Run("AttrValue condition: returns nil immediately", func(t *testing.T) {
+ stored := &model.Condition{Attribute: "user.attributes.Team", Value: "user.attributes.Dept", ValueType: model.AttrValue}
+ assert.Nil(t, a.getHiddenValues(nil, "caller", stored, "", nil))
+ })
+
+ t.Run("field missing from prefetch map: returns nil (fail closed)", func(t *testing.T) {
+ stored := &model.Condition{Attribute: "user.attributes.Program", Value: []any{"Alpha", "Bravo"}, ValueType: model.LiteralValue}
+ assert.Nil(t, a.getHiddenValues(nil, "caller", stored, "", map[string]*model.PropertyField{}))
+ })
+
+ t.Run("source_only: all stored values treated as hidden", func(t *testing.T) {
+ stored := &model.Condition{Attribute: "user.attributes.Clearance", Value: []any{"Top Secret", "Secret"}, ValueType: model.LiteralValue}
+ fields := map[string]*model.PropertyField{"Clearance": makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect)}
+ result := a.getHiddenValues(nil, "caller", stored, "", fields)
+ assert.Equal(t, []string{"Top Secret", "Secret"}, result)
+ })
+
+ t.Run("shared_only select: values absent from options are hidden", func(t *testing.T) {
+ stored := &model.Condition{Attribute: "user.attributes.Program", Value: []any{"Alpha", "Charlie"}, ValueType: model.LiteralValue}
+ fields := map[string]*model.PropertyField{"Program": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect)}
+ result := a.getHiddenValues(nil, "caller", stored, "", fields)
+ assert.Equal(t, []string{"Charlie"}, result)
+ })
+
+ t.Run("public field: no values hidden", func(t *testing.T) {
+ stored := &model.Condition{Attribute: "user.attributes.Dept", Value: []any{"Eng", "Sales"}, ValueType: model.LiteralValue}
+ fields := map[string]*model.PropertyField{"Dept": makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect)}
+ result := a.getHiddenValues(nil, "caller", stored, "", fields)
+ assert.Nil(t, result)
+ })
+}
diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go
index 4f8c11bbe79..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)
@@ -319,6 +326,156 @@ func TestDeleteAccessControlPolicy(t *testing.T) {
mockChannelStore.AssertNotCalled(t, "InvalidateChannel", mock.Anything)
mockChannelStore.AssertNotCalled(t, "Get", mock.Anything, mock.Anything)
})
+
+ t.Run("Caller with masked values is blocked from deleting (403)", func(t *testing.T) {
+ // When AttributeValueMasking is on and the caller cannot see all values in the
+ // policy, the delete must be refused with the masked_values 403. This closes
+ // the gap where a delegated admin could remove a policy whose conditions they
+ // could not audit. Forcing an unknown-field reference in the rule makes
+ // GetMaskedVisualAST fail-closed (HasMaskedValues=true) without requiring a
+ // full CPA setup for the test.
+ th := SetupConfig(t, func(cfg *model.Config) {
+ cfg.FeatureFlags.AttributeBasedAccessControl = true
+ cfg.FeatureFlags.AttributeValueMasking = true
+ }).InitBasic(t)
+
+ callerID := model.NewId()
+ th.Context = th.Context.WithSession(&model.Session{UserId: callerID, Id: model.NewId()}).(*request.Context)
+
+ policyID := model.NewId()
+ sensitivePolicy := &model.AccessControlPolicy{
+ ID: policyID,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: `user.attributes.f_unknown_field == "Secret"`},
+ },
+ }
+
+ mockAccessControl := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockAccessControl
+ mockAccessControl.On("GetPolicy", th.Context, policyID).Return(sensitivePolicy, nil).Once()
+ // Force GetMaskedVisualAST → maskConditionValues → fail-closed (unknown field).
+ mockAccessControl.On("ExpressionToVisualAST", mock.Anything, mock.Anything).Return(&model.VisualExpression{
+ Conditions: []model.Condition{
+ {Attribute: "user.attributes.f_unknown_field", Operator: "==", Value: "Secret", ValueType: model.LiteralValue},
+ },
+ }, nil).Maybe()
+
+ appErr := th.App.DeleteAccessControlPolicy(th.Context, policyID)
+ require.NotNil(t, appErr)
+ require.Equal(t, http.StatusForbidden, appErr.StatusCode)
+ require.Equal(t, "app.pap.delete_policy.masked_values", appErr.Id)
+
+ mockAccessControl.AssertNotCalled(t, "DeletePolicy", mock.Anything, mock.Anything)
+ mockAccessControl.AssertExpectations(t)
+ })
+
+ t.Run("Masking flag off: delete proceeds for callers that would otherwise be blocked", func(t *testing.T) {
+ // Belt-and-braces: with AttributeValueMasking off, the masking guard must not
+ // fire — the policy deletes normally even if the caller wouldn't have seen all
+ // values. Guards against accidentally inverting the flag condition.
+ thMock := SetupWithStoreMock(t)
+ // Note: SetupWithStoreMock doesn't take a config callback. Feature flags
+ // default to false, which is exactly the state this test wants.
+
+ thMock.Context = thMock.Context.WithSession(&model.Session{UserId: model.NewId(), Id: model.NewId()}).(*request.Context)
+
+ channelID := model.NewId()
+ channelPolicy := &model.AccessControlPolicy{
+ ID: channelID,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ }
+
+ mockStore := thMock.App.Srv().Store().(*storemocks.Store)
+ mockChannelStore := storemocks.ChannelStore{}
+ mockStore.On("Channel").Return(&mockChannelStore)
+ mockChannelStore.On("InvalidateChannel", channelID).Once()
+ mockChannelStore.On("Get", channelID, true).Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate}, nil).Once()
+
+ mockAccessControl := &mocks.AccessControlServiceInterface{}
+ thMock.App.Srv().ch.AccessControl = mockAccessControl
+ mockAccessControl.On("GetPolicy", thMock.Context, channelID).Return(channelPolicy, nil).Once()
+ mockAccessControl.On("DeletePolicy", thMock.Context, channelID).Return(nil).Once()
+
+ appErr := thMock.App.DeleteAccessControlPolicy(thMock.Context, channelID)
+ require.Nil(t, appErr)
+ mockAccessControl.AssertExpectations(t)
+ mockChannelStore.AssertExpectations(t)
+ })
+}
+
+// TestCheckSelfInclusion verifies the self-exclusion guard: non-admin callers must
+// satisfy their own policy after saving, or the save is refused with 403
+// self_exclusion. Sysadmins are exempt at the call site
+// (CreateOrUpdateAccessControlPolicy), not inside checkSelfInclusion itself — this
+// test exercises the function directly.
+func TestCheckSelfInclusion(t *testing.T) {
+ t.Run("caller who satisfies the policy passes", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ callerID := th.BasicUser.Id
+
+ policy := &model.AccessControlPolicy{
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: `user.attributes.team == "ops"`},
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+ // QueryUsersForExpression returns the caller → matches → no error.
+ mockACS.On("QueryUsersForExpression", mock.Anything, mock.Anything, mock.Anything).
+ Return([]*model.User{{Id: callerID}}, int64(1), nil).Once()
+
+ appErr := th.App.checkSelfInclusion(th.Context, policy, callerID)
+ require.Nil(t, appErr)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("caller who does not satisfy the policy is rejected with 403", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ callerID := th.BasicUser.Id
+
+ policy := &model.AccessControlPolicy{
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: `user.attributes.team == "ops"`},
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+ // No users returned → caller does not satisfy → expect self_exclusion 403.
+ mockACS.On("QueryUsersForExpression", mock.Anything, mock.Anything, mock.Anything).
+ Return([]*model.User{}, int64(0), nil).Once()
+
+ appErr := th.App.checkSelfInclusion(th.Context, policy, callerID)
+ require.NotNil(t, appErr)
+ require.Equal(t, http.StatusForbidden, appErr.StatusCode)
+ require.Equal(t, "app.pap.save_policy.self_exclusion", appErr.Id)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("trivial rules (empty / 'true') are skipped without querying", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ callerID := th.BasicUser.Id
+
+ policy := &model.AccessControlPolicy{
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: ""},
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"},
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+ // No query should fire for trivial expressions — if it does, the mock will fail
+ // the test by returning the default zero-value response.
+
+ appErr := th.App.checkSelfInclusion(th.Context, policy, callerID)
+ require.Nil(t, appErr)
+ mockACS.AssertNotCalled(t, "QueryUsersForExpression", mock.Anything, mock.Anything, mock.Anything)
+ })
}
func TestGetChannelsForPolicy(t *testing.T) {
@@ -1969,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)
@@ -2082,3 +2318,2262 @@ func TestGetRecommendedPublicChannelsForUser(t *testing.T) {
mockACS.AssertExpectations(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
+// regular channel members through the invite modal or members sidebar.
+func TestGetAccessControlPolicyAttributes_MaskedFieldsFiltered(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ rctx := request.TestContext(t)
+
+ cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, cErr)
+
+ permNone := model.PermissionLevelNone
+
+ makeField := func(name, accessMode string) {
+ protected := accessMode == model.PropertyAccessModeSourceOnly || accessMode == model.PropertyAccessModeSharedOnly
+ f := &model.PropertyField{
+ GroupID: cpaGroup.ID,
+ Name: name,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Protected: protected,
+ Attrs: model.StringInterface{model.PropertyAttrsAccessMode: accessMode},
+ }
+ if protected {
+ f.PermissionField = &permNone
+ f.Attrs[model.PropertyAttrsProtected] = true
+ _, err := th.App.Srv().Store().PropertyField().Create(f)
+ require.NoError(t, err)
+ } else {
+ _, appErr := th.App.CreatePropertyField(rctx, f, false, "")
+ require.Nil(t, appErr)
+ }
+ }
+
+ makeField("PublicField", model.PropertyAccessModePublic)
+ makeField("SourceField", model.PropertyAccessModeSourceOnly)
+ makeField("SharedField", model.PropertyAccessModeSharedOnly)
+
+ channelID := model.NewId()
+ rawAttributes := map[string][]string{
+ "PublicField": {"Engineering"},
+ "SourceField": {"TopSecret"},
+ "SharedField": {"Alpha", "Bravo"},
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+ mockACS.On("GetPolicyRuleAttributes", mock.Anything, channelID, model.AccessControlPolicyActionMembership).
+ Return(rawAttributes, nil).Once()
+
+ result, appErr := th.App.GetAccessControlPolicyAttributes(th.Context, channelID, model.AccessControlPolicyActionMembership)
+ require.Nil(t, appErr)
+
+ // Only the public field should survive.
+ assert.Equal(t, map[string][]string{"PublicField": {"Engineering"}}, result)
+ assert.NotContains(t, result, "SourceField")
+ assert.NotContains(t, result, "SharedField")
+ mockACS.AssertExpectations(t)
+}
+
+// TestGetAccessControlPolicyAttributes_PublicFieldsPassThrough verifies that
+// public attribute fields are returned unchanged.
+func TestGetAccessControlPolicyAttributes_PublicFieldsPassThrough(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ rctx := request.TestContext(t)
+
+ cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, cErr)
+
+ fieldName := "f_" + model.NewId()[:8]
+ field := &model.PropertyField{
+ GroupID: cpaGroup.ID,
+ Name: fieldName,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{model.PropertyAttrsAccessMode: model.PropertyAccessModePublic},
+ }
+ _, appErr := th.App.CreatePropertyField(rctx, field, false, "")
+ require.Nil(t, appErr)
+
+ channelID := model.NewId()
+ rawAttributes := map[string][]string{fieldName: {"Engineering", "Sales"}}
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+ mockACS.On("GetPolicyRuleAttributes", mock.Anything, channelID, model.AccessControlPolicyActionMembership).
+ Return(rawAttributes, nil).Once()
+
+ result, appErr := th.App.GetAccessControlPolicyAttributes(th.Context, channelID, model.AccessControlPolicyActionMembership)
+ require.Nil(t, appErr)
+ assert.Equal(t, rawAttributes, result)
+ mockACS.AssertExpectations(t)
+}
+
+// TestMergeStoredPolicyExpressions_ActionsLocked verifies that a caller who
+// cannot see all values in a stored rule cannot change that rule's Actions.
+// 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))
+
+ rctx := request.TestContext(t)
+
+ // Insert a source_only field directly into the store to bypass the property
+ // service hook that restricts protected-field creation to plugin callers.
+ cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, cErr)
+
+ fieldName := "f_" + model.NewId()[:8]
+ permNone := model.PermissionLevelNone
+ field := &model.PropertyField{
+ GroupID: cpaGroup.ID,
+ Name: fieldName,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Protected: true,
+ PermissionField: &permNone,
+ Attrs: model.StringInterface{
+ model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
+ model.PropertyAttrsProtected: true,
+ },
+ }
+ _, storeErr := th.App.Srv().Store().PropertyField().Create(field)
+ require.NoError(t, storeErr)
+
+ 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.AccessControlPolicyTypeChannel,
+ Rules: []model.AccessControlPolicyRule{
+ {
+ Name: ruleName,
+ Role: model.ChannelUserRoleId,
+ Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
+ Expression: storedExpr,
+ },
+ },
+ }
+
+ // 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.AccessControlPolicyTypeChannel,
+ Rules: []model.AccessControlPolicyRule{
+ {
+ Name: ruleName,
+ Role: model.ChannelUserRoleId,
+ Actions: []string{model.AccessControlPolicyActionDownloadFileAttachment},
+ Expression: maskedExpr,
+ },
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+
+ mockACS.On("GetPolicy", mock.Anything, policyID).Return(storedPolicy, nil).Once()
+ mockACS.On("ExpressionToVisualAST", mock.Anything, storedExpr).Return(&model.VisualExpression{
+ Conditions: []model.Condition{
+ {Attribute: "user.attributes." + fieldName, Operator: "==", Value: "TopSecret", ValueType: model.LiteralValue},
+ },
+ }, nil).Maybe()
+ mockACS.On("ExpressionToVisualAST", mock.Anything, maskedExpr).Return(&model.VisualExpression{
+ Conditions: []model.Condition{
+ {Attribute: "user.attributes." + fieldName, Operator: "==", Value: maskedTokenValue, ValueType: model.LiteralValue},
+ },
+ }, nil).Maybe()
+
+ mergeErr := th.App.mergeStoredPolicyExpressions(th.Context, submittedPolicy, callerID)
+ require.Nil(t, mergeErr)
+
+ require.Len(t, submittedPolicy.Rules, 1)
+ // 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.AccessControlPolicyActionUploadFileAttachment}, submittedPolicy.Rules[0].Actions)
+ mockACS.AssertExpectations(t)
+}
+
+// TestMergeStoredPolicyExpressions_FailClosedTrueRejectedOnResubmit verifies the
+// claim from the PR review: if MaskPolicyExpressions emitted "true" for a rule
+// because the stored expression could not be parsed (fail-closed), a caller who
+// re-submits that "true" unchanged will be blocked on the save path.
+//
+// How it works:
+// 1. MaskPolicyExpressions (GET path) calls ExpressionToVisualAST on the stored
+// expression; on parse failure it sets the rule to "true" (fail-closed).
+// 2. The caller sees "true" in the GET response and re-submits it.
+// 3. On the save path, mergeExpressionWithMaskedValues calls
+// expressionHasMaskedValuesForCaller, which calls GetMaskedVisualAST, which
+// calls ExpressionToVisualAST on the *stored* expression again.
+// 4. That second parse also fails → error propagates → save is blocked.
+// "true" is never written to the DB.
+func TestMergeStoredPolicyExpressions_FailClosedTrueRejectedOnResubmit(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ callerID := model.NewId()
+ policyID := model.NewId()
+
+ // A stored expression that ExpressionToVisualAST cannot parse.
+ // In production this is guarded by save-time validation, but defensive
+ // code paths must still protect against it.
+ storedExpr := `user.attributes.TopSecret == "Value"`
+
+ storedPolicy := &model.AccessControlPolicy{
+ ID: policyID,
+ Type: model.AccessControlPolicyTypeParent,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: storedExpr},
+ },
+ }
+
+ // Caller re-submits "true" — what MaskPolicyExpressions emitted as the
+ // fail-closed value when it could not parse the stored expression on GET.
+ submittedPolicy := &model.AccessControlPolicy{
+ ID: policyID,
+ Type: model.AccessControlPolicyTypeParent,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: "true"},
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+
+ mockACS.On("GetPolicy", mock.Anything, policyID).Return(storedPolicy, nil).Once()
+ // Simulate the same parse failure that would have triggered fail-closed on GET.
+ parseErr := model.NewAppError("ExpressionToVisualAST", "app.pap.expression_to_visual_ast.app_error", nil, "simulated parse failure", http.StatusInternalServerError)
+ mockACS.On("ExpressionToVisualAST", mock.Anything, storedExpr).Return(nil, parseErr).Maybe()
+
+ mergeErr := th.App.mergeStoredPolicyExpressions(th.Context, submittedPolicy, callerID)
+
+ // Save must be blocked. The error returned here causes UpdateAccessControlPolicy
+ // to abort before any DB write — the in-memory struct may still hold "true"
+ // but it never reaches the store.
+ require.NotNil(t, mergeErr, "expected mergeStoredPolicyExpressions to return an error when stored expression is unparseable")
+ mockACS.AssertExpectations(t)
+}
+
+// TestMergeStoredPolicyExpressions_ActionsEditableWhenNoMasking verifies that
+// a caller who holds all values in a rule can freely change its Actions.
+func TestMergeStoredPolicyExpressions_ActionsEditableWhenNoMasking(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+
+ rctx := request.TestContext(t)
+
+ // Create a public field — values are always visible, so no masking occurs.
+ cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
+ require.Nil(t, cErr)
+
+ fieldName := "f_" + model.NewId()[:8]
+ field := &model.PropertyField{
+ GroupID: cpaGroup.ID,
+ Name: fieldName,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{model.PropertyAttrsAccessMode: model.PropertyAccessModePublic},
+ }
+ _, appErr := th.App.CreatePropertyField(rctx, field, false, "")
+ require.Nil(t, appErr)
+
+ callerID := model.NewId()
+ policyID := model.NewId()
+
+ expr := `user.attributes.` + fieldName + ` == "Engineering"`
+
+ storedPolicy := &model.AccessControlPolicy{
+ ID: policyID,
+ Type: model.AccessControlPolicyTypeParent,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: expr},
+ },
+ }
+ // Caller legitimately changes the action on a rule with no masked values.
+ submittedPolicy := &model.AccessControlPolicy{
+ ID: policyID,
+ Type: model.AccessControlPolicyTypeParent,
+ Rules: []model.AccessControlPolicyRule{
+ {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, Expression: expr},
+ },
+ }
+
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().ch.AccessControl = mockACS
+
+ mockACS.On("GetPolicy", mock.Anything, policyID).Return(storedPolicy, nil).Once()
+ mockACS.On("ExpressionToVisualAST", mock.Anything, expr).Return(&model.VisualExpression{
+ Conditions: []model.Condition{
+ {Attribute: "user.attributes." + fieldName, Operator: "==", Value: "Engineering", ValueType: model.LiteralValue},
+ },
+ }, nil).Maybe()
+
+ appErr = th.App.mergeStoredPolicyExpressions(th.Context, submittedPolicy, callerID)
+ require.Nil(t, appErr)
+
+ require.Len(t, submittedPolicy.Rules, 1)
+ // Expression unchanged (no masking, submitted passes through).
+ assert.Equal(t, expr, submittedPolicy.Rules[0].Expression)
+ // Actions must NOT be locked — caller's submitted value stands.
+ assert.Equal(t, []string{model.AccessControlPolicyActionUploadFileAttachment}, submittedPolicy.Rules[0].Actions)
+ mockACS.AssertExpectations(t)
+}
diff --git a/server/channels/app/access_control_validation_test.go b/server/channels/app/access_control_validation_test.go
new file mode 100644
index 00000000000..05823ef7743
--- /dev/null
+++ b/server/channels/app/access_control_validation_test.go
@@ -0,0 +1,231 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInvalidValueError(t *testing.T) {
+ err := invalidValueError()
+ require.NotNil(t, err)
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ assert.Equal(t, 400, err.StatusCode)
+ assert.Equal(t, "Invalid value.", err.DetailedError)
+}
+
+func TestMaskedTokenConstant(t *testing.T) {
+ // The masked-token sentinel must be the eight-dash string the frontend
+ // renders for hidden chips and the server emits when masking raw CEL
+ // on GET / search responses.
+ assert.Equal(t, "--------", maskedTokenValue)
+}
+
+func TestGenericErrorConsistency(t *testing.T) {
+ // All rejection reasons must produce identical errors to prevent enumeration.
+ err1 := invalidValueError()
+ err2 := invalidValueError()
+
+ assert.Equal(t, err1.Id, err2.Id)
+ assert.Equal(t, err1.StatusCode, err2.StatusCode)
+ assert.Equal(t, err1.DetailedError, err2.DetailedError)
+}
+
+func TestValidateConditionValues(t *testing.T) {
+ rctx := request.TestContext(t)
+
+ // nil App is safe for every branch except shared_only + text (which calls
+ // a.getCallerTextValues → a.SearchPropertyValues). Those paths are covered
+ // by the integration tests in access_control_test.go.
+ var a *App
+
+ makeField := func(accessMode string, fieldType model.PropertyFieldType, options []any) *model.PropertyField {
+ attrs := model.StringInterface{model.PropertyAttrsAccessMode: accessMode}
+ if options != nil {
+ attrs[model.PropertyFieldAttributeOptions] = options
+ }
+ return &model.PropertyField{Name: "Team", Type: fieldType, Attrs: attrs}
+ }
+
+ selectOptions := []any{
+ map[string]any{"id": "id1", "name": "Alpha"},
+ map[string]any{"id": "id2", "name": "Bravo"},
+ }
+
+ t.Run("AttrValue conditions are skipped (no literals to validate)", func(t *testing.T) {
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "user.attributes.Department",
+ ValueType: model.AttrValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", nil)
+ assert.Nil(t, err)
+ })
+
+ t.Run("non-attribute references are skipped", func(t *testing.T) {
+ cond := &model.Condition{
+ Attribute: "channel.id",
+ Operator: "==",
+ Value: "X",
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", nil)
+ assert.Nil(t, err)
+ })
+
+ t.Run("unknown field is rejected with the generic error", func(t *testing.T) {
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "Alpha",
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", map[string]*model.PropertyField{})
+ require.NotNil(t, err)
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ })
+
+ t.Run("public field allows any value", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "anything",
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", fields)
+ assert.Nil(t, err)
+ })
+
+ t.Run("source_only field rejects any literal value", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "Alpha",
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", fields)
+ require.NotNil(t, err)
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ })
+
+ t.Run("source_only field allows the masked-token sentinel (round-tripped from GET)", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: maskedTokenValue,
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", fields)
+ assert.Nil(t, err, "the sentinel is stripped/re-injected at merge; validation must let it through")
+ })
+
+ t.Run("shared_only select: held value passes, non-held rejected, token allowed", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+
+ // "Alpha" is in the visible-options set (caller holds it)
+ ok := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "Alpha",
+ ValueType: model.LiteralValue,
+ }
+ require.Nil(t, a.validateConditionValues(rctx, ok, "groupID", fields))
+
+ // "Charlie" is not in the visible-options set → rejected
+ bad := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: "Charlie",
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, bad, "groupID", fields)
+ require.NotNil(t, err)
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+
+ // Masked-token sentinel passes through (handled by merge, not validation)
+ tokenCond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: maskedTokenValue,
+ ValueType: model.LiteralValue,
+ }
+ assert.Nil(t, a.validateConditionValues(rctx, tokenCond, "groupID", fields))
+ })
+
+ t.Run("source_only field rejects non-string literals (numeric, bool)", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: float64(1),
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", fields)
+ require.NotNil(t, err, "numeric literal must not slip through extractStringValues silently")
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ })
+
+ t.Run("shared_only field rejects non-string literals", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "==",
+ Value: true,
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond, "groupID", fields)
+ require.NotNil(t, err)
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ })
+
+ t.Run("shared_only multiselect: array values are validated element by element", func(t *testing.T) {
+ field := makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeMultiselect, selectOptions)
+ fields := map[string]*model.PropertyField{"Team": field}
+
+ allHeld, _ := json.Marshal([]any{"Alpha", "Bravo"})
+ cond := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "in",
+ Value: parseAny(t, allHeld),
+ ValueType: model.LiteralValue,
+ }
+ require.Nil(t, a.validateConditionValues(rctx, cond, "groupID", fields))
+
+ mixed, _ := json.Marshal([]any{"Alpha", "Charlie"})
+ cond2 := &model.Condition{
+ Attribute: "user.attributes.Team",
+ Operator: "in",
+ Value: parseAny(t, mixed),
+ ValueType: model.LiteralValue,
+ }
+ err := a.validateConditionValues(rctx, cond2, "groupID", fields)
+ require.NotNil(t, err, "any non-held element must trigger rejection")
+ assert.Equal(t, "app.pap.save_policy.invalid_value", err.Id)
+ })
+}
+
+// parseAny round-trips a JSON-encoded value back to the untyped interface{}
+// shape that the visual-AST parser produces for condition.Value.
+func parseAny(t *testing.T, raw []byte) any {
+ t.Helper()
+ var v any
+ require.NoError(t, json.Unmarshal(raw, &v))
+ return v
+}
diff --git a/server/channels/app/app_test.go b/server/channels/app/app_test.go
index 0ebfc035610..03792e4acf9 100644
--- a/server/channels/app/app_test.go
+++ b/server/channels/app/app_test.go
@@ -151,6 +151,8 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) {
model.PermissionManageChannelAccessRules.Id,
model.PermissionManagePublicChannelAutoTranslation.Id,
model.PermissionManagePrivateChannelAutoTranslation.Id,
+ model.PermissionManagePrivateChannelDiscoverability.Id,
+ model.PermissionManageChannelJoinRequests.Id,
},
"team_user": {
model.PermissionListTeamChannels.Id,
diff --git a/server/channels/app/authentication.go b/server/channels/app/authentication.go
index edda80f5aac..73e7668f856 100644
--- a/server/channels/app/authentication.go
+++ b/server/channels/app/authentication.go
@@ -62,7 +62,7 @@ func (a *App) IsPasswordValid(rctx request.CTX, password string) *model.AppError
return nil
}
-func (a *App) checkUserPassword(user *model.User, password string, invalidateCache bool) *model.AppError {
+func (a *App) checkUserPassword(user *model.User, password string) *model.AppError {
if user.Password == "" || password == "" {
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
@@ -76,16 +76,6 @@ func (a *App) checkUserPassword(user *model.User, password string, invalidateCac
// Compare the password using the hasher that generated it
err = hasher.CompareHashAndPassword(phc, password)
if err != nil && errors.Is(err, hashers.ErrMismatchedHashAndPassword) {
- // Increment the number of failed password attempts in case of
- // mismatched hash and password
- if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, user.FailedAttempts+1); passErr != nil {
- return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
- }
-
- if invalidateCache {
- a.InvalidateCacheForUser(user.Id)
- }
-
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized).Wrap(err)
} else if err != nil {
return model.NewAppError("checkUserPassword", "app.valid_password_generic.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
@@ -118,11 +108,6 @@ func (a *App) migratePassword(user *model.User, password string) *model.AppError
}
func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, password string, mfaToken string) *model.AppError {
- // MM-37585
- // Use locks to avoid concurrently checking AND updating the failed login attempts.
- a.ch.emailLoginAttemptsMut.Lock()
- defer a.ch.emailLoginAttemptsMut.Unlock()
-
user, err := a.GetUser(userID)
if err != nil {
if err.Id != MissingAccountError {
@@ -137,16 +122,36 @@ func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, passw
return err
}
- if err := a.checkUserPassword(user, password, false); err != nil {
+ maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
+ claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
+ if claimErr != nil {
+ return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
+ }
+ if !claimed {
+ return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
+ }
+
+ if err := a.checkUserPassword(user, password); err != nil {
+ // Only keep the claimed slot when the failure is an actual
+ // credential mismatch; backend errors (hasher failures, migration
+ // failures, malformed stored hash) must not consume a slot or a
+ // transient infra issue could lock out a user with valid creds.
+ if err.Id != "api.user.check_user_password.invalid.app_error" {
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
+ }
+ }
return err
}
if err := a.CheckUserMfa(rctx, user, mfaToken); err != nil {
- // If the mfaToken is not set, we assume the client used this as a pre-flight request to query the server
- // about the MFA state of the user in question
- if mfaToken != "" {
- if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, user.FailedAttempts+1); passErr != nil {
- return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
+ // The slot we claimed already counts this as a failed attempt;
+ // the only special case is when no mfaToken was provided, which
+ // is treated as a pre-flight MFA-state probe rather than a real
+ // attempt — refund the slot so the probe is not counted.
+ if mfaToken == "" {
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund MFA probe slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
@@ -166,11 +171,21 @@ func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, passw
// This to be used for places we check the users password when they are already logged in
func (a *App) DoubleCheckPassword(rctx request.CTX, user *model.User, password string) *model.AppError {
- if err := checkUserLoginAttempts(user, *a.Config().ServiceSettings.MaximumLoginAttempts); err != nil {
- return err
+ maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
+ claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
+ if claimErr != nil {
+ return model.NewAppError("DoubleCheckPassword", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
+ }
+ if !claimed {
+ return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
- if err := a.checkUserPassword(user, password, true); err != nil {
+ if err := a.checkUserPassword(user, password); err != nil {
+ if err.Id != "api.user.check_user_password.invalid.app_error" {
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
+ }
+ }
return err
}
@@ -184,11 +199,7 @@ func (a *App) DoubleCheckPassword(rctx request.CTX, user *model.User, password s
}
func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.User, password, mfaToken string) (*model.User, *model.AppError) {
- // MM-37585: Use locks to avoid concurrently checking AND updating the failed login attempts.
- a.ch.ldapLoginAttemptsMut.Lock()
- defer a.ch.ldapLoginAttemptsMut.Unlock()
-
- // We need to get the latest value of the user from the database after we acquire the lock. user is nil for first-time LDAP users.
+ // We need to get the latest value of the user from the database. user.Id is empty for first-time LDAP users.
if user.Id != "" {
var err *model.AppError
user, err = a.GetUser(user.Id)
@@ -209,10 +220,16 @@ func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.
return nil, err
}
- // First time LDAP users will not have a userID
+ maxAttempts := *a.Config().LdapSettings.MaximumLoginAttempts
+
+ // First-time LDAP users have no local row yet to pre-claim against.
if user.Id != "" {
- if err := checkUserLoginAttempts(user, *a.Config().LdapSettings.MaximumLoginAttempts); err != nil {
- return nil, err
+ claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
+ if claimErr != nil {
+ return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
+ }
+ if !claimed {
+ return nil, model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many_ldap.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
}
}
@@ -233,8 +250,23 @@ func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.
if err.Id == "ent.ldap.do_login.invalid_password.app_error" {
rctx.Logger().LogM(mlog.MlvlLDAPInfo, "A user tried to sign in, which matched an LDAP account, but the password was incorrect.", mlog.String("ldap_id", *ldapID))
- if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, ldapUser.FailedAttempts+1); passErr != nil {
- return nil, model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
+ // For existing users we already claimed the slot above, so the
+ // counter has already been bumped. For first-time users (the
+ // row was just created by DoLogin) we still need to count the
+ // failed attempt explicitly, using the atomic primitive so
+ // concurrent first-attempt requests cannot overwrite each
+ // other's increments.
+ if user.Id == "" {
+ if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
+ rctx.Logger().Warn("failed to record failed attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
+ }
+ }
+ } else if user.Id != "" {
+ // Non-credential failure (LDAP unreachable, transient error,
+ // etc.) on an existing user must not consume the slot we
+ // pre-claimed, or an LDAP outage could lock out everyone.
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund LDAP login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
}
}
@@ -243,24 +275,43 @@ func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.
}
if err = a.CheckUserMfa(rctx, ldapUser, mfaToken); err != nil {
- // If the mfaToken is not set, we assume the client used this as a pre-flight request to query the server
- // about the MFA state of the user in question
- if mfaToken != "" && ldapUser.Id != "" {
- if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, ldapUser.FailedAttempts+1); passErr != nil {
- return nil, model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
+ // For existing LDAP users we pre-claimed a slot, so it already
+ // counts as a failed attempt. The only special case is when no
+ // mfaToken was provided, which is treated as a pre-flight
+ // MFA-state probe rather than a real attempt — refund the slot
+ // so the probe is not counted.
+ //
+ // For first-time LDAP users we did not pre-claim (no row to
+ // claim against), so a real MFA attempt with a non-empty token
+ // still needs to be counted explicitly against the freshly
+ // created row.
+ switch {
+ case user.Id == "" && mfaToken != "":
+ if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
+ rctx.Logger().Warn("failed to record failed MFA attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
+ }
+ case user.Id != "" && mfaToken == "":
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund LDAP MFA probe slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
}
}
return nil, err
}
if err = checkUserNotDisabled(ldapUser); err != nil {
+ // Existing LDAP users had a slot pre-claimed; a disabled-account
+ // rejection is not a credential failure, so refund the slot so a
+ // reactivated user is not immediately rate-limited.
+ if user.Id != "" {
+ if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
+ rctx.Logger().Warn("failed to refund disabled LDAP login attempt slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
+ }
+ }
return nil, err
}
- if ldapUser.FailedAttempts > 0 {
- if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, 0); passErr != nil {
- return nil, model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
- }
+ if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, 0); passErr != nil {
+ return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
}
// user successfully authenticated
diff --git a/server/channels/app/authentication_test.go b/server/channels/app/authentication_test.go
index 4e718b58d80..a581af0e4e9 100644
--- a/server/channels/app/authentication_test.go
+++ b/server/channels/app/authentication_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
+ "golang.org/x/sync/errgroup"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app/password/hashers"
@@ -96,6 +97,63 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) {
appErr = th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, token)
require.Nil(t, appErr)
+
+ updatedUser, appErr := th.App.GetUser(th.BasicUser.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "successful login must reset FailedAttempts")
+ })
+
+ t.Run("MFA pre-flight probe does not consume a slot", func(t *testing.T) {
+ // An empty mfaToken on an MFA-enabled user is a pre-flight probe
+ // (the client is checking whether MFA is required) and must not
+ // count as a failed attempt.
+ err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, 0)
+ require.NoError(t, err)
+
+ appErr := th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, "")
+ require.NotNil(t, appErr)
+ require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(th.BasicUser.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe must not consume a slot")
+ })
+
+ t.Run("MFA real attempt with a wrong token consumes a slot", func(t *testing.T) {
+ // A non-empty bad mfaToken is a real attempt, not a probe; the
+ // slot the pre-claim consumed stays consumed.
+ err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(th.BasicUser.Id, 0)
+ require.NoError(t, err)
+
+ appErr := th.App.CheckPasswordAndAllCriteria(th.Context, th.BasicUser.Id, password, "123456")
+ require.NotNil(t, appErr)
+ require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(th.BasicUser.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 1, updatedUser.FailedAttempts, "real MFA failure must consume a slot")
+ })
+
+ t.Run("backend error refunds the slot", func(t *testing.T) {
+ // Backend errors during the password check (malformed stored hash,
+ // hasher misc failure, migration failure) must not consume a slot
+ // or a transient infra issue could lock out a user with valid
+ // credentials. We trigger this via an unparseable PHC string,
+ // which surfaces as invalid_hash.app_error.
+ badHashUser := th.CreateUser(t)
+ err := th.Server.Store().User().UpdatePassword(badHashUser.Id, "$pbkdf2$bogus")
+ require.NoError(t, err)
+ th.App.InvalidateCacheForUser(badHashUser.Id)
+ err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(badHashUser.Id, 0)
+ require.NoError(t, err)
+
+ appErr := th.App.CheckPasswordAndAllCriteria(th.Context, badHashUser.Id, "any-password", "")
+ require.NotNil(t, appErr)
+ require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(badHashUser.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot")
})
t.Run("validate concurrent failed attempts to bypass checks", func(t *testing.T) {
@@ -159,6 +217,66 @@ func TestCheckPasswordAndAllCriteria(t *testing.T) {
})
}
+func TestDoubleCheckPassword(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ const maxFailedLoginAttempts = 3
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ *cfg.ServiceSettings.MaximumLoginAttempts = maxFailedLoginAttempts
+ })
+
+ password := model.NewTestPassword()
+ appErr := th.App.UpdatePassword(th.Context, th.BasicUser, password)
+ require.Nil(t, appErr)
+
+ // DoubleCheckPassword does not re-fetch the user; it inspects user.Password
+ // directly. Pull a fresh struct that reflects the hash we just wrote.
+ user, appErr := th.App.GetUser(th.BasicUser.Id)
+ require.Nil(t, appErr)
+
+ t.Run("correct password succeeds and resets the counter", func(t *testing.T) {
+ err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, maxFailedLoginAttempts-1)
+ require.NoError(t, err)
+
+ appErr := th.App.DoubleCheckPassword(th.Context, user, password)
+ require.Nil(t, appErr)
+
+ updatedUser, appErr := th.App.GetUser(user.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts)
+ })
+
+ t.Run("rate limit is enforced once max attempts is reached", func(t *testing.T) {
+ err := th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, maxFailedLoginAttempts)
+ require.NoError(t, err)
+
+ appErr := th.App.DoubleCheckPassword(th.Context, user, password)
+ require.NotNil(t, appErr)
+ require.Equal(t, "api.user.check_user_login_attempts.too_many.app_error", appErr.Id)
+ })
+
+ t.Run("backend error refunds the slot", func(t *testing.T) {
+ badHashUser := th.CreateUser(t)
+ err := th.Server.Store().User().UpdatePassword(badHashUser.Id, "$pbkdf2$bogus")
+ require.NoError(t, err)
+ th.App.InvalidateCacheForUser(badHashUser.Id)
+ err = th.App.Srv().Store().User().UpdateFailedPasswordAttempts(badHashUser.Id, 0)
+ require.NoError(t, err)
+
+ user, appErr := th.App.GetUser(badHashUser.Id)
+ require.Nil(t, appErr)
+
+ appErr = th.App.DoubleCheckPassword(th.Context, user, "any-password")
+ require.NotNil(t, appErr)
+ require.Equal(t, "api.user.check_user_password.invalid_hash.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(badHashUser.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "backend error must not consume a slot")
+ })
+}
+
func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) {
th := SetupEnterprise(t).InitBasic(t)
@@ -256,6 +374,172 @@ func TestCheckLdapUserPasswordAndAllCriteria(t *testing.T) {
}
})
}
+
+ // The cases below cover paths the table loop above does not exercise:
+ // first-time LDAP users (user.Id == ""), LDAP backend errors that are
+ // not credential failures, and the MFA pre-flight probe refund. Each
+ // subtest builds its own mockLdap so expectations from previous
+ // subtests cannot match the wrong call.
+
+ createLdapUserWithMFA := func(t *testing.T, emailLocal string) (*model.User, *string) {
+ t.Helper()
+ userAuthData := model.NewRandomString(32)
+ created, appErr := th.App.CreateUser(th.Context, &model.User{
+ Email: emailLocal + "@mattermost-customer.com",
+ Username: emailLocal,
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: &userAuthData,
+ EmailVerified: true,
+ })
+ require.Nil(t, appErr)
+ secret, appErr := th.App.GenerateMfaSecret(created.Id)
+ require.Nil(t, appErr)
+ require.NoError(t, th.Server.Store().User().UpdateMfaActive(created.Id, true))
+ require.NoError(t, th.Server.Store().User().UpdateMfaSecret(created.Id, secret.Secret))
+ require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(created.Id, 0))
+ created, appErr = th.App.GetUser(created.Id)
+ require.Nil(t, appErr)
+ created.AuthData = &userAuthData
+ return created, &userAuthData
+ }
+
+ t.Run("first-time LDAP user with wrong password increments counter", func(t *testing.T) {
+ // DoLogin in production creates the row before reporting a bad
+ // password; we pre-create it here so GetUserByAuth can resolve it.
+ firstAuthData := model.NewRandomString(32)
+ preCreated, appErr := th.App.CreateUser(th.Context, &model.User{
+ Email: "ldapuser-first-bad-pwd@mattermost-customer.com",
+ Username: "ldapuser-first-bad-pwd",
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: &firstAuthData,
+ EmailVerified: true,
+ })
+ require.Nil(t, appErr)
+ require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(preCreated.Id, 0))
+
+ freshMock := &mocks.LdapInterface{}
+ th.App.Channels().Ldap = freshMock
+ t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
+ freshMock.Mock.On("DoLogin", th.Context, firstAuthData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"})
+
+ _, appErr = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: &firstAuthData,
+ }, wrongPassword, "")
+ require.NotNil(t, appErr)
+ require.Equal(t, "ent.ldap.do_login.invalid_password.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(preCreated.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong password must be counted")
+ })
+
+ t.Run("first-time LDAP user with wrong MFA token increments counter", func(t *testing.T) {
+ // DoLogin returns the freshly created user struct; the function
+ // then calls CheckUserMfa, which fails on a wrong non-empty token.
+ preCreated, authDataPtr := createLdapUserWithMFA(t, "ldapuser-first-bad-mfa")
+
+ freshMock := &mocks.LdapInterface{}
+ th.App.Channels().Ldap = freshMock
+ t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
+ freshMock.Mock.On("DoLogin", th.Context, *authDataPtr, validPassword).Return(preCreated, nil)
+
+ _, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: authDataPtr,
+ }, validPassword, "123456")
+ require.NotNil(t, appErr)
+ require.Equal(t, "api.user.check_user_mfa.bad_code.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(preCreated.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 1, updatedUser.FailedAttempts, "first-time LDAP wrong MFA must be counted")
+ })
+
+ t.Run("existing LDAP user with LDAP backend error refunds the slot", func(t *testing.T) {
+ // A non-credential LDAP error (server unreachable, transient
+ // failure) on an existing user must not consume the pre-claimed
+ // slot, or an LDAP outage could lock out everyone.
+ require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0))
+
+ freshMock := &mocks.LdapInterface{}
+ th.App.Channels().Ldap = freshMock
+ t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
+ freshMock.Mock.On("DoLogin", th.Context, authData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.unable_to_connect.app_error"})
+
+ _, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, user, wrongPassword, "")
+ require.NotNil(t, appErr)
+ require.Equal(t, "ent.ldap.do_login.unable_to_connect.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(user.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "LDAP backend error must refund the slot")
+ })
+
+ t.Run("existing LDAP user MFA pre-flight probe refunds the slot", func(t *testing.T) {
+ // Empty mfaToken on an MFA-enabled LDAP user is a probe; the slot
+ // the pre-claim consumed is refunded.
+ preCreated, authDataPtr := createLdapUserWithMFA(t, "ldapuser-existing-mfa-probe")
+
+ freshMock := &mocks.LdapInterface{}
+ th.App.Channels().Ldap = freshMock
+ t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
+ freshMock.Mock.On("DoLogin", th.Context, *authDataPtr, validPassword).Return(preCreated, nil)
+
+ _, appErr := th.App.checkLdapUserPasswordAndAllCriteria(th.Context, preCreated, validPassword, "")
+ require.NotNil(t, appErr)
+ require.Equal(t, "mfa.validate_token.authenticate.app_error", appErr.Id)
+
+ updatedUser, appErr := th.App.GetUser(preCreated.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, 0, updatedUser.FailedAttempts, "MFA probe on existing LDAP user must not consume a slot")
+ })
+
+ t.Run("concurrent first-time LDAP wrong password caps at maxAttempts", func(t *testing.T) {
+ // A first-time LDAP user has no local row yet, so the slot is
+ // not pre-claimed. The fallback counter bump must use the atomic
+ // TryIncrement primitive: a previous implementation used an
+ // absolute UPDATE Users SET FailedAttempts = ldapUser.FailedAttempts + 1
+ // based on an in-memory snapshot, which lost increments when
+ // concurrent first-attempt requests all read FailedAttempts = 0
+ // and all wrote 1. Under the atomic primitive the counter caps
+ // at maxFailedLoginAttempts regardless of contention.
+ concurrentAuthData := model.NewRandomString(32)
+ preCreated, appErr := th.App.CreateUser(th.Context, &model.User{
+ Email: "ldapuser-first-bad-pwd-conc@mattermost-customer.com",
+ Username: "ldapuser-first-bad-pwd-conc",
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: &concurrentAuthData,
+ EmailVerified: true,
+ })
+ require.Nil(t, appErr)
+ require.NoError(t, th.App.Srv().Store().User().UpdateFailedPasswordAttempts(preCreated.Id, 0))
+
+ freshMock := &mocks.LdapInterface{}
+ th.App.Channels().Ldap = freshMock
+ t.Cleanup(func() { th.App.Channels().Ldap = mockLdap })
+ freshMock.Mock.On("DoLogin", th.Context, concurrentAuthData, wrongPassword).Return(nil, &model.AppError{Id: "ent.ldap.do_login.invalid_password.app_error"})
+
+ const goroutines = maxFailedLoginAttempts * 3
+ var g errgroup.Group
+ start := make(chan struct{})
+ for range goroutines {
+ g.Go(func() error {
+ <-start
+ _, _ = th.App.checkLdapUserPasswordAndAllCriteria(th.Context, &model.User{
+ AuthService: model.UserAuthServiceLdap,
+ AuthData: &concurrentAuthData,
+ }, wrongPassword, "")
+ return nil
+ })
+ }
+ close(start)
+ require.NoError(t, g.Wait())
+
+ updatedUser, appErr := th.App.GetUser(preCreated.Id)
+ require.Nil(t, appErr)
+ require.Equal(t, maxFailedLoginAttempts, updatedUser.FailedAttempts, "concurrent first-time attempts must not lose increments and must cap at maxAttempts")
+ })
}
func TestCheckLdapUserPasswordConcurrency(t *testing.T) {
@@ -400,13 +684,7 @@ func TestCheckUserPassword(t *testing.T) {
t.Run("valid password with current hashing", func(t *testing.T) {
user := createUserWithHash(pwdPBKDF2)
- err := th.App.checkUserPassword(user, pwd, false)
- require.Nil(t, err)
- })
-
- t.Run("valid password with current hashing and cache invalidation", func(t *testing.T) {
- user := createUserWithHash(pwdPBKDF2)
- err := th.App.checkUserPassword(user, pwd, true)
+ err := th.App.checkUserPassword(user, pwd)
require.Nil(t, err)
})
@@ -415,13 +693,9 @@ func TestCheckUserPassword(t *testing.T) {
t.Run("invalid password", func(t *testing.T) {
user := createUserWithHash(pwdPBKDF2)
- err := th.App.checkUserPassword(user, wrongPassword, false)
+ err := th.App.checkUserPassword(user, wrongPassword)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
-
- updatedUser, err := th.App.GetUser(user.Id)
- require.Nil(t, err)
- require.Equal(t, user.FailedAttempts+1, updatedUser.FailedAttempts)
})
t.Run("password migration from outdated hash", func(t *testing.T) {
@@ -429,7 +703,7 @@ func TestCheckUserPassword(t *testing.T) {
require.Contains(t, user.Password, "$2a$10")
require.NotContains(t, user.Password, "pbkdf2")
- err := th.App.checkUserPassword(user, pwd, false)
+ err := th.App.checkUserPassword(user, pwd)
require.Nil(t, err)
updatedUser, err := th.App.GetUser(user.Id)
@@ -438,20 +712,16 @@ func TestCheckUserPassword(t *testing.T) {
require.Contains(t, updatedUser.Password, "$pbkdf2")
// Re-check with updated password
- err = th.App.checkUserPassword(user, pwd, false)
+ err = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, err)
})
t.Run("password migration fails with invalid password", func(t *testing.T) {
user := createUserWithHash(pwdBcrypt)
- err := th.App.checkUserPassword(user, wrongPassword, false)
+ err := th.App.checkUserPassword(user, wrongPassword)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
-
- updatedUser, err := th.App.GetUser(user.Id)
- require.Nil(t, err)
- require.Equal(t, user.FailedAttempts+1, updatedUser.FailedAttempts)
})
t.Run("empty password", func(t *testing.T) {
@@ -460,7 +730,7 @@ func TestCheckUserPassword(t *testing.T) {
user, err := th.App.GetUser(user.Id)
require.Nil(t, err)
- err = th.App.checkUserPassword(user, "", false)
+ err = th.App.checkUserPassword(user, "")
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
@@ -471,7 +741,7 @@ func TestCheckUserPassword(t *testing.T) {
user, err := th.App.GetUser(user.Id)
require.Nil(t, err)
- err = th.App.checkUserPassword(user, pwd, false)
+ err = th.App.checkUserPassword(user, pwd)
require.NotNil(t, err)
require.Equal(t, "api.user.check_user_password.invalid.app_error", err.Id)
})
@@ -489,7 +759,7 @@ func TestCheckUserPassword(t *testing.T) {
// The user hash contains the old parameter
require.Contains(t, user.Password, "w=10000")
- appErr := th.App.checkUserPassword(user, pwd, false)
+ appErr := th.App.checkUserPassword(user, pwd)
require.Nil(t, appErr)
updatedUser, appErr := th.App.GetUser(user.Id)
@@ -500,7 +770,7 @@ func TestCheckUserPassword(t *testing.T) {
require.NotContains(t, updatedUser.Password, "w=10000")
// Re-check with updated password
- appErr = th.App.checkUserPassword(user, pwd, false)
+ appErr = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, appErr)
})
}
@@ -542,7 +812,7 @@ func TestMigratePassword(t *testing.T) {
require.Contains(t, updatedUser.Password, "$pbkdf2")
// Re-check with updated password
- err = th.App.checkUserPassword(user, pwd, false)
+ err = th.App.checkUserPassword(updatedUser, pwd)
require.Nil(t, err)
})
}
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 35020f7e9b3..8c927ebb65f 100644
--- a/server/channels/app/authorization_test.go
+++ b/server/channels/app/authorization_test.go
@@ -17,6 +17,7 @@ import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
+ "github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
@@ -1202,8 +1203,9 @@ func TestHasPermissionToEditPropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ groupID := cpaGroup.ID
testCases := []struct {
name string
@@ -1336,20 +1338,23 @@ 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)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ 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
@@ -1369,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),
@@ -1383,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),
@@ -1403,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),
@@ -1418,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),
@@ -1433,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,
@@ -1449,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),
@@ -1464,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),
@@ -1479,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),
@@ -1494,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),
@@ -1509,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),
@@ -1523,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),
@@ -1543,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),
@@ -1552,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))
})
}
}
@@ -1563,8 +1585,9 @@ func TestHasPermissionToManagePropertyFieldOptions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ groupID := cpaGroup.ID
testCases := []struct {
name string
@@ -1701,8 +1724,9 @@ func TestSessionHasPermissionToEditPropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ groupID := cpaGroup.ID
testCases := []struct {
name string
@@ -1849,20 +1873,22 @@ 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)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ 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
@@ -1882,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,
@@ -1893,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),
@@ -1907,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),
@@ -1916,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),
@@ -1931,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),
@@ -1946,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),
@@ -1961,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),
@@ -1976,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),
@@ -1991,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),
@@ -2006,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),
@@ -2021,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),
@@ -2035,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),
@@ -2055,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),
@@ -2064,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))
})
}
}
@@ -2075,8 +2118,9 @@ func TestSessionHasPermissionToManagePropertyFieldOptions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ groupID := cpaGroup.ID
testCases := []struct {
name string
@@ -2208,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 3acd05496e7..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) {
@@ -4153,7 +4211,7 @@ func (a *App) setSidebarCategoriesForConvertedGroupMessage(rctx request.CTX, gmC
channelsCategory := categories.Categories[0]
_, appErr = a.UpdateSidebarCategories(rctx, user.Id, gmConversionRequest.TeamID, []*model.SidebarCategoryWithChannels{channelsCategory})
if appErr != nil {
- rctx.Logger().Error("Failed to add converted GM to default sidebar category for user", mlog.String("user_id", user.Id), mlog.Err(err))
+ rctx.Logger().Error("Failed to add converted GM to default sidebar category for user", mlog.String("user_id", user.Id), mlog.Err(appErr))
}
}
@@ -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 df3e90c2e85..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
@@ -92,11 +100,9 @@ type Channels struct {
postReminderMut sync.Mutex
postReminderTask *model.ScheduledTask
- interruptQuitChan chan struct{}
- scheduledPostMut sync.Mutex
- scheduledPostTask *model.ScheduledTask
- emailLoginAttemptsMut sync.Mutex
- ldapLoginAttemptsMut sync.Mutex
+ interruptQuitChan chan struct{}
+ scheduledPostMut sync.Mutex
+ scheduledPostTask *model.ScheduledTask
}
func NewChannels(s *Server) (*Channels, error) {
@@ -109,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
@@ -233,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
}
@@ -327,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
@@ -353,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/content_flagging.go b/server/channels/app/content_flagging.go
index efad00bbbaf..eac209d023f 100644
--- a/server/channels/app/content_flagging.go
+++ b/server/channels/app/content_flagging.go
@@ -1167,14 +1167,14 @@ func (a *App) AssignFlaggedPostReviewer(rctx request.CTX, flaggedPostId, flagged
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
}
- assigneePropertyValue, appErr = a.UpsertPropertyValue(nil, assigneePropertyValue)
+ assigneePropertyValue, appErr = a.UpsertPropertyValue(rctx, assigneePropertyValue)
if appErr != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.upsert_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if status == model.ContentFlaggingStatusPending {
statusPropertyValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusAssigned))
- statusPropertyValue, appErr = a.UpdatePropertyValue(nil, groupId, statusPropertyValue)
+ statusPropertyValue, appErr = a.UpdatePropertyValue(rctx, groupId, statusPropertyValue)
if appErr != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.update_status_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
diff --git a/server/channels/app/content_flagging_report.go b/server/channels/app/content_flagging_report.go
index c9cd28f5134..a821b1b4593 100644
--- a/server/channels/app/content_flagging_report.go
+++ b/server/channels/app/content_flagging_report.go
@@ -33,7 +33,7 @@ const (
// GenerateFlaggedPostReport builds a ZIP archive of a flagged post's data into a
// temporary file and returns the file path. The caller is responsible for
// removing the file when the response has been served.
-func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment string) (string, *model.AppError) {
+func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment, action string) (string, *model.AppError) {
if appErr := a.ensureActorCommentForReport(rctx, postID, comment); appErr != nil {
return "", appErr
}
@@ -51,7 +51,7 @@ func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUse
zw := zip.NewWriter(tmp)
- if appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID); appErr != nil {
+ if appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID, action); appErr != nil {
_ = zw.Close()
cleanup()
return "", appErr
@@ -73,7 +73,7 @@ func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUse
return tmpPath, nil
}
-func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID string) *model.AppError {
+func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID, action string) *model.AppError {
rc, appErr := a.loadFlaggedPostReportContext(rctx, postID)
if appErr != nil {
return appErr
@@ -89,7 +89,7 @@ func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, g
if appErr := a.writeEditHistorySection(rctx, zw, rc, seenFiles); appErr != nil {
return appErr
}
- if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post); appErr != nil {
+ if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post, generatedByUserID, action); appErr != nil {
return appErr
}
if appErr := a.writeReportMetadataEntry(zw, generatedByUserID); appErr != nil {
@@ -183,8 +183,8 @@ func (a *App) writeEditHistorySection(rctx request.CTX, zw *zip.Writer, rc *mode
return nil
}
-func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post) *model.AppError {
- payload, appErr := a.buildContentReviewYAML(rctx, post)
+func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post, generatedByUserID, action string) *model.AppError {
+ payload, appErr := a.buildContentReviewYAML(rctx, post, generatedByUserID, action)
if appErr != nil {
return appErr
}
@@ -199,16 +199,18 @@ func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *mo
// already present (set by a prior keep/remove or report-generation), it is
// preserved so the existing reviewer note is never overwritten.
func (a *App) ensureActorCommentForReport(rctx request.CTX, postID, comment string) *model.AppError {
+ if comment == "" {
+ return nil
+ }
+
existing, appErr := a.GetPostContentFlaggingPropertyValue(postID, contentFlaggingPropertyNameActorComment)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
+
if existing != nil {
return nil
}
- if comment == "" {
- return nil
- }
groupID, gErr := a.ContentFlaggingGroupId()
if gErr != nil {
@@ -233,6 +235,7 @@ func (a *App) ensureActorCommentForReport(rctx request.CTX, postID, comment stri
Value: json.RawMessage(commentBytes),
},
}
+
if _, appErr := a.CreatePropertyValues(rctx, propertyValues); appErr != nil {
return model.NewAppError("ensureActorCommentForReport", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
@@ -279,7 +282,7 @@ func buildPostYAML(post *model.Post, channel *model.Channel, team *model.Team, a
return out
}
-func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post) (model.FlaggedPostReportContentReview, *model.AppError) {
+func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post, generatedByUserID, pendingAction string) (model.FlaggedPostReportContentReview, *model.AppError) {
out := model.FlaggedPostReportContentReview{}
values, appErr := a.GetPostContentFlaggingPropertyValues(post.Id)
@@ -332,13 +335,32 @@ func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post) (model.
}
}
- // Reviewer details: prefer the actor (the one who took the keep/remove action)
- // when present, otherwise fall back to the assigned reviewer.
reviewerID := decodePropertyString(rctx, byName, contentFlaggingPropertyNameReviewerUserID)
out.ReviewerUserID = reviewerID
out.ReviewerComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameActorComment)
out.ActionTime = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameActionTime)
+ // We want to include the actor details only when an action is being performed - retain or delete the quarantined post.
+ if pendingAction != "" {
+ if u, uErr := a.GetUser(generatedByUserID); uErr == nil {
+ out.ActorUsername = u.Username
+ out.ActorUserId = u.Id
+ } else {
+ rctx.Logger().Warn("Failed to fetch report generator user for flagged post report", mlog.String("user_id", generatedByUserID), mlog.Err(uErr))
+ }
+ }
+
+ switch decodePropertyString(rctx, byName, ContentFlaggingPropertyNameStatus) {
+ case model.ContentFlaggingStatusRetained:
+ out.ActorDecision = model.ContentFlaggingActionKeep
+ case model.ContentFlaggingStatusRemoved:
+ out.ActorDecision = model.ContentFlaggingActionRemove
+ default:
+ if pendingAction == model.ContentFlaggingActionKeep || pendingAction == model.ContentFlaggingActionRemove {
+ out.ActorDecision = pendingAction
+ }
+ }
+
if reviewerID != "" {
if u, uErr := a.GetUser(reviewerID); uErr == nil {
out.ReviewerUsername = u.Username
diff --git a/server/channels/app/content_flagging_report_test.go b/server/channels/app/content_flagging_report_test.go
index 6253c79a79c..cc463e0023d 100644
--- a/server/channels/app/content_flagging_report_test.go
+++ b/server/channels/app/content_flagging_report_test.go
@@ -52,7 +52,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "", "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
@@ -63,7 +63,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "", "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
@@ -74,7 +74,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
require.NotEmpty(t, path)
@@ -93,7 +93,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -113,7 +113,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -131,7 +131,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
post := setupFlaggedPost(t, th)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -143,6 +143,85 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
require.Equal(t, "spam", review.ReporterReason)
require.Equal(t, "This is spam content", review.ReporterComment)
require.Greater(t, review.ReportTimestamp, int64(0))
+ require.Empty(t, review.ActorDecision)
+ require.Empty(t, review.ActorUserId)
+ require.Empty(t, review.ActorUsername)
+ })
+
+ t.Run("content_review.yaml records remove decision after permanent delete", func(t *testing.T) {
+ appErr := setBaseConfig(th)
+ require.Nil(t, appErr)
+
+ post := setupFlaggedPost(t, th)
+
+ appErr = th.App.PermanentDeleteFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "violates policy"}, th.SystemAdminUser.Id, post)
+ require.Nil(t, appErr)
+
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
+ require.Nil(t, appErr)
+
+ entries := readReportZip(t, path)
+ var review model.FlaggedPostReportContentReview
+ require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
+ require.Equal(t, "remove", review.ActorDecision)
+ require.Empty(t, review.ActorUserId)
+ require.Empty(t, review.ActorUsername)
+ })
+
+ t.Run("content_review.yaml records keep decision after keep action", func(t *testing.T) {
+ appErr := setBaseConfig(th)
+ require.Nil(t, appErr)
+
+ post := setupFlaggedPost(t, th)
+
+ appErr = th.App.KeepFlaggedPost(th.Context, &model.FlagContentActionRequest{Comment: "looks fine"}, th.SystemAdminUser.Id, post)
+ require.Nil(t, appErr)
+
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
+ require.Nil(t, appErr)
+
+ entries := readReportZip(t, path)
+ var review model.FlaggedPostReportContentReview
+ require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
+ require.Equal(t, "keep", review.ActorDecision)
+ require.Empty(t, review.ActorUserId)
+ require.Empty(t, review.ActorUsername)
+ })
+
+ t.Run("content_review.yaml uses pending action when status is not yet committed", func(t *testing.T) {
+ appErr := setBaseConfig(th)
+ require.Nil(t, appErr)
+
+ post := setupFlaggedPost(t, th)
+
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", model.ContentFlaggingActionRemove)
+ require.Nil(t, appErr)
+
+ entries := readReportZip(t, path)
+ var review model.FlaggedPostReportContentReview
+ require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
+ require.Equal(t, "remove", review.ActorDecision)
+ require.Equal(t, th.BasicUser.Id, review.ActorUserId)
+ require.Equal(t, th.BasicUser.Username, review.ActorUsername)
+ })
+
+ t.Run("content_review.yaml ignores invalid pending action", func(t *testing.T) {
+ appErr := setBaseConfig(th)
+ require.Nil(t, appErr)
+
+ post := setupFlaggedPost(t, th)
+
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "bogus")
+ require.Nil(t, appErr)
+
+ entries := readReportZip(t, path)
+ var review model.FlaggedPostReportContentReview
+ require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
+ require.Empty(t, review.ActorDecision)
+ // Actor details are still populated whenever a pending action is supplied,
+ // even when the value isn't a recognised decision.
+ require.Equal(t, th.BasicUser.Id, review.ActorUserId)
+ require.Equal(t, th.BasicUser.Username, review.ActorUsername)
})
t.Run("includes file attachments for the base post", func(t *testing.T) {
@@ -181,7 +260,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
@@ -217,7 +296,7 @@ func TestGenerateFlaggedPostReport(t *testing.T) {
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
- path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
+ path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "", "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
diff --git a/server/channels/app/content_flagging_test.go b/server/channels/app/content_flagging_test.go
index 9b413647720..6f14bba1c94 100644
--- a/server/channels/app/content_flagging_test.go
+++ b/server/channels/app/content_flagging_test.go
@@ -3278,7 +3278,8 @@ func TestScrubPost(t *testing.T) {
// Verify non-content fields are preserved
require.Equal(t, postId, post.Id)
require.Equal(t, createAt, post.CreateAt)
- require.Equal(t, updateAt, post.UpdateAt)
+ // scrubPost refreshes UpdateAt when scrubbing content.
+ require.GreaterOrEqual(t, post.UpdateAt, updateAt)
require.Equal(t, editAt, post.EditAt)
require.Equal(t, userId, post.UserId)
require.Equal(t, channelId, post.ChannelId)
diff --git a/server/channels/app/custom_profile_attributes.go b/server/channels/app/custom_profile_attributes.go
deleted file mode 100644
index e27cdf3c6f2..00000000000
--- a/server/channels/app/custom_profile_attributes.go
+++ /dev/null
@@ -1,326 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-// This file implements the "User Attributes" feature (formerly "Custom
-// Profile Attributes" / CPA). Internal identifiers retain the old naming
-// for backward compatibility. See MM-68235.
-
-package app
-
-import (
- "encoding/json"
- "errors"
- "net/http"
- "sort"
-
- "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"
-)
-
-const (
- CustomProfileAttributesFieldLimit = 20
-)
-
-func (a *App) CpaGroupID() (string, *model.AppError) {
- group, appErr := a.GetPropertyGroup(nil, model.CustomProfileAttributesPropertyGroupName)
- if appErr != nil {
- return "", appErr
- }
- return group.ID, nil
-}
-
-func (a *App) GetCPAField(rctx request.CTX, fieldID string) (*model.CPAField, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- field, appErr := a.GetPropertyField(rctx, groupID, fieldID)
- if appErr != nil {
- var notFoundErr *store.ErrNotFound
- if errors.As(appErr, ¬FoundErr) {
- return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr)
- }
- return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- cpaField, err := model.NewCPAFieldFromPropertyField(field)
- if err != nil {
- return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- }
-
- return cpaField, nil
-}
-
-func (a *App) ListCPAFields(rctx request.CTX) ([]*model.CPAField, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("ListCPAFields", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- opts := model.PropertyFieldSearchOpts{
- GroupID: groupID,
- PerPage: CustomProfileAttributesFieldLimit,
- }
-
- fields, appErr := a.SearchPropertyFields(rctx, groupID, opts)
- if appErr != nil {
- return nil, model.NewAppError("ListCPAFields", "app.custom_profile_attributes.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- // Convert PropertyFields to CPAFields
- cpaFields := make([]*model.CPAField, 0, len(fields))
- for _, field := range fields {
- cpaField, convErr := model.NewCPAFieldFromPropertyField(field)
- if convErr != nil {
- return nil, model.NewAppError("ListCPAFields", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(convErr)
- }
- cpaFields = append(cpaFields, cpaField)
- }
-
- sort.Slice(cpaFields, func(i, j int) bool {
- return cpaFields[i].Attrs.SortOrder < cpaFields[j].Attrs.SortOrder
- })
-
- return cpaFields, nil
-}
-
-func (a *App) CreateCPAField(rctx request.CTX, field *model.CPAField) (*model.CPAField, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- fieldCount, appErr := a.CountPropertyFieldsForGroup(rctx, groupID, false)
- if appErr != nil {
- return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.count_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- if fieldCount >= CustomProfileAttributesFieldLimit {
- return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.limit_reached.app_error", nil, "", http.StatusUnprocessableEntity)
- }
-
- field.GroupID = groupID
-
- if appErr = field.SanitizeAndValidate(); appErr != nil {
- return nil, appErr
- }
-
- if appErr = model.ValidateCPAFieldName(field.Name); appErr != nil {
- return nil, appErr
- }
-
- newField, appErr := a.CreatePropertyField(rctx, field.ToPropertyField(), false, "")
- if appErr != nil {
- return nil, appErr
- }
-
- cpaField, err := model.NewCPAFieldFromPropertyField(newField)
- if err != nil {
- return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- }
-
- message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldCreated, "", "", "", nil, "")
- message.Add("field", cpaField)
- a.Publish(message)
-
- return cpaField, nil
-}
-
-func (a *App) PatchCPAField(rctx request.CTX, fieldID string, patch *model.PropertyFieldPatch) (*model.CPAField, *model.AppError) {
- existingField, appErr := a.GetCPAField(rctx, fieldID)
- if appErr != nil {
- return nil, appErr
- }
- originalName := existingField.Name
-
- shouldDeleteValues := false
- if patch.Type != nil && *patch.Type != existingField.Type {
- shouldDeleteValues = true
- }
-
- if err := existingField.Patch(patch); err != nil {
- return nil, model.NewAppError("PatchCPAField", "app.custom_profile_attributes.patch_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- }
-
- if appErr = existingField.SanitizeAndValidate(); appErr != nil {
- return nil, appErr
- }
-
- // Lenient grandfather: only validate Name against CEL rules when it actually changes.
- // Pre-existing fields with invalid names remain editable on all other attrs.
- if existingField.Name != originalName {
- if appErr = model.ValidateCPAFieldName(existingField.Name); appErr != nil {
- return nil, appErr
- }
- }
-
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("PatchCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- patchedField, appErr := a.UpdatePropertyField(rctx, groupID, existingField.ToPropertyField(), false, "")
- if appErr != nil {
- var notFoundErr *store.ErrNotFound
- if errors.As(appErr, ¬FoundErr) {
- return nil, model.NewAppError("PatchCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr)
- }
- return nil, model.NewAppError("PatchCPAField", "app.custom_profile_attributes.property_field_update.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- cpaField, err := model.NewCPAFieldFromPropertyField(patchedField)
- if err != nil {
- return nil, model.NewAppError("PatchCPAField", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- }
-
- if shouldDeleteValues {
- if dErr := a.DeletePropertyValuesForField(rctx, groupID, cpaField.ID); dErr != nil {
- a.Log().Error("Error deleting property values when updating field",
- mlog.String("fieldID", cpaField.ID),
- mlog.Err(dErr),
- )
- }
- }
-
- message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldUpdated, "", "", "", nil, "")
- message.Add("field", cpaField)
- message.Add("delete_values", shouldDeleteValues)
- a.Publish(message)
-
- return cpaField, nil
-}
-
-func (a *App) DeleteCPAField(rctx request.CTX, id string) *model.AppError {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- if appErr := a.DeletePropertyField(rctx, groupID, id, false, ""); appErr != nil {
- var notFoundErr *store.ErrNotFound
- if errors.As(appErr, ¬FoundErr) {
- return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr)
- }
- return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- message := model.NewWebSocketEvent(model.WebsocketEventCPAFieldDeleted, "", "", "", nil, "")
- message.Add("field_id", id)
- a.Publish(message)
-
- return nil
-}
-
-func (a *App) ListCPAValues(rctx request.CTX, targetUserID string) ([]*model.PropertyValue, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("ListCPAValues", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- values, appErr := a.SearchPropertyValues(rctx, groupID, model.PropertyValueSearchOpts{
- TargetIDs: []string{targetUserID},
- PerPage: CustomProfileAttributesFieldLimit,
- })
- if appErr != nil {
- return nil, model.NewAppError("ListCPAValues", "app.custom_profile_attributes.list_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- return values, nil
-}
-
-func (a *App) GetCPAValue(rctx request.CTX, valueID string) (*model.PropertyValue, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- value, appErr := a.GetPropertyValue(rctx, groupID, valueID)
- if appErr != nil {
- return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.get_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- return value, nil
-}
-
-func (a *App) PatchCPAValue(rctx request.CTX, userID string, fieldID string, value json.RawMessage, allowSynced bool) (*model.PropertyValue, *model.AppError) {
- values, appErr := a.PatchCPAValues(rctx, userID, map[string]json.RawMessage{fieldID: value}, allowSynced)
- if appErr != nil {
- return nil, appErr
- }
-
- return values[0], nil
-}
-
-func (a *App) PatchCPAValues(rctx request.CTX, userID string, fieldValueMap map[string]json.RawMessage, allowSynced bool) ([]*model.PropertyValue, *model.AppError) {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- valuesToUpdate := []*model.PropertyValue{}
- for fieldID, rawValue := range fieldValueMap {
- // make sure field exists in this group
- cpaField, fieldErr := a.GetCPAField(rctx, fieldID)
- if fieldErr != nil {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(fieldErr)
- } else if cpaField.DeleteAt > 0 {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound)
- }
-
- if !allowSynced && cpaField.IsSynced() {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.property_field_is_synced.app_error", nil, "", http.StatusBadRequest)
- }
-
- sanitizedValue, sErr := model.SanitizeAndValidatePropertyValue(cpaField, rawValue)
- if sErr != nil {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.validate_value.app_error", nil, "", http.StatusBadRequest).Wrap(sErr)
- }
-
- value := &model.PropertyValue{
- GroupID: groupID,
- TargetType: model.PropertyValueTargetTypeUser,
- TargetID: userID,
- FieldID: fieldID,
- Value: sanitizedValue,
- }
- valuesToUpdate = append(valuesToUpdate, value)
- }
-
- updatedValues, appErr := a.UpsertPropertyValues(rctx, valuesToUpdate, "", "", "")
- if appErr != nil {
- return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.property_value_upsert.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- updatedFieldValueMap := map[string]json.RawMessage{}
- for _, value := range updatedValues {
- updatedFieldValueMap[value.FieldID] = value.Value
- }
-
- message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
- message.Add("user_id", userID)
- message.Add("values", updatedFieldValueMap)
- a.Publish(message)
-
- return updatedValues, nil
-}
-
-func (a *App) DeleteCPAValues(rctx request.CTX, userID string) *model.AppError {
- groupID, appErr := a.CpaGroupID()
- if appErr != nil {
- return model.NewAppError("DeleteCPAValues", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- if appErr := a.DeletePropertyValuesForTarget(rctx, groupID, "user", userID); appErr != nil {
- return model.NewAppError("DeleteCPAValues", "app.custom_profile_attributes.delete_property_values_for_user.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
- }
-
- message := model.NewWebSocketEvent(model.WebsocketEventCPAValuesUpdated, "", "", "", nil, "")
- message.Add("user_id", userID)
- message.Add("values", map[string]json.RawMessage{})
- a.Publish(message)
-
- return nil
-}
diff --git a/server/channels/app/custom_profile_attributes_test.go b/server/channels/app/custom_profile_attributes_test.go
index ebdc85d26b3..74688ce4707 100644
--- a/server/channels/app/custom_profile_attributes_test.go
+++ b/server/channels/app/custom_profile_attributes_test.go
@@ -6,810 +6,37 @@ package app
import (
"encoding/json"
"fmt"
- "net/http"
"testing"
- "time"
"github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/require"
)
-func celSafeName() string {
- return "f_" + model.NewId()
-}
-
-func TestGetCPAField(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- t.Run("should fail when getting a non-existent field", func(t *testing.T) {
- field, appErr := th.App.GetCPAField(rctx, model.NewId())
- require.NotNil(t, appErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", appErr.Id)
- require.Empty(t, field)
- })
-
- t.Run("should fail when getting a field from a different group", func(t *testing.T) {
- otherGroup, gErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
- Name: "test_get_cpa_other_group_" + model.NewId(),
- Version: model.PropertyGroupVersionV1,
- })
- require.Nil(t, gErr)
-
- field := &model.PropertyField{
- GroupID: otherGroup.ID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
- }
- createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
- require.Nil(t, err)
-
- fetchedField, appErr := th.App.GetCPAField(rctx, createdField.ID)
- require.NotNil(t, appErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", appErr.Id)
- require.Empty(t, fetchedField)
- })
-
- t.Run("should get an existing CPA field", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "test_field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden},
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.NotEmpty(t, createdField.ID)
-
- fetchedField, appErr := th.App.GetCPAField(rctx, createdField.ID)
- require.Nil(t, appErr)
- require.Equal(t, createdField.ID, fetchedField.ID)
- require.Equal(t, "test_field", fetchedField.Name)
- require.Equal(t, model.CustomProfileAttributesVisibilityHidden, fetchedField.Attrs.Visibility)
- })
-
- t.Run("should initialize default attrs when field has nil Attrs", func(t *testing.T) {
- // Create a field with nil Attrs directly via property service (bypassing CPA validation)
- field := &model.PropertyField{
- GroupID: cpaID,
- Name: "Field with nil attrs",
- Type: model.PropertyFieldTypeText,
- Attrs: nil,
- }
- createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
- require.Nil(t, err)
-
- // GetCPAField should initialize Attrs with defaults
- fetchedField, appErr := th.App.GetCPAField(rctx, createdField.ID)
- require.Nil(t, appErr)
- require.Equal(t, model.CustomProfileAttributesVisibilityDefault, fetchedField.Attrs.Visibility)
- require.Equal(t, float64(0), fetchedField.Attrs.SortOrder)
- })
-
- t.Run("should initialize default attrs when field has empty Attrs", func(t *testing.T) {
- // Create a field with empty Attrs directly via property service
- field := &model.PropertyField{
- GroupID: cpaID,
- Name: "Field with empty attrs",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{},
- }
- createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
- require.Nil(t, err)
-
- // GetCPAField should add missing default attrs
- fetchedField, appErr := th.App.GetCPAField(rctx, createdField.ID)
- require.Nil(t, appErr)
- require.Equal(t, model.CustomProfileAttributesVisibilityDefault, fetchedField.Attrs.Visibility)
- require.Equal(t, float64(0), fetchedField.Attrs.SortOrder)
- })
-
- t.Run("should validate LDAP/SAML synced fields", func(t *testing.T) {
- // Create LDAP synced field
- ldapField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "ldap_field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{
- model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attribute",
- },
- })
- require.NoError(t, err)
- createdLDAPField, appErr := th.App.CreateCPAField(rctx, ldapField)
- require.Nil(t, appErr)
-
- // Create SAML synced field
- samlField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "saml_field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{
- model.CustomProfileAttributesPropertyAttrsSAML: "saml_attribute",
- },
- })
- require.NoError(t, err)
- createdSAMLField, appErr := th.App.CreateCPAField(rctx, samlField)
- require.Nil(t, appErr)
-
- // Test with allowSynced=false
- userID := model.NewId()
-
- // Test LDAP field
- _, appErr = th.App.PatchCPAValue(rctx, userID, createdLDAPField.ID, json.RawMessage(`"test value"`), false)
- require.NotNil(t, appErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_is_synced.app_error", appErr.Id)
-
- // Test SAML field
- _, appErr = th.App.PatchCPAValue(rctx, userID, createdSAMLField.ID, json.RawMessage(`"test value"`), false)
- require.NotNil(t, appErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_is_synced.app_error", appErr.Id)
-
- // Test with allowSynced=true
- // LDAP field should work
- patchedValue, appErr := th.App.PatchCPAValue(rctx, userID, createdLDAPField.ID, json.RawMessage(`"test value"`), true)
- require.Nil(t, appErr)
- require.NotNil(t, patchedValue)
- require.Equal(t, json.RawMessage(`"test value"`), patchedValue.Value)
-
- // SAML field should work
- patchedValue, appErr = th.App.PatchCPAValue(rctx, userID, createdSAMLField.ID, json.RawMessage(`"test value"`), true)
- require.Nil(t, appErr)
- require.NotNil(t, patchedValue)
- require.Equal(t, json.RawMessage(`"test value"`), patchedValue.Value)
- })
-}
-
-func TestListCPAFields(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- t.Run("should list the CPA property fields", func(t *testing.T) {
- field1 := model.PropertyField{
- GroupID: cpaID,
- Name: "Field 1",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 1},
- }
-
- _, err := th.App.CreatePropertyField(rctx, &field1, false, "")
- require.Nil(t, err)
-
- otherGroup, gErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
- Name: "test_list_cpa_other_group_" + model.NewId(),
- Version: model.PropertyGroupVersionV1,
- })
- require.Nil(t, gErr)
-
- field2 := &model.PropertyField{
- GroupID: otherGroup.ID,
- Name: "Field 2",
- Type: model.PropertyFieldTypeText,
- }
- _, err = th.App.CreatePropertyField(rctx, field2, false, "")
- require.Nil(t, err)
-
- field3 := model.PropertyField{
- GroupID: cpaID,
- Name: "Field 3",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 0},
- }
- _, err = th.App.CreatePropertyField(rctx, &field3, false, "")
- require.Nil(t, err)
-
- fields, appErr := th.App.ListCPAFields(rctx)
- require.Nil(t, appErr)
- require.Len(t, fields, 2)
- require.Equal(t, "Field 3", fields[0].Name)
- require.Equal(t, "Field 1", fields[1].Name)
- })
-
- t.Run("should initialize default attrs for fields with nil or empty Attrs", func(t *testing.T) {
- // Create a field with nil Attrs
- fieldWithNilAttrs := &model.PropertyField{
- GroupID: cpaID,
- Name: "Field with nil attrs",
- Type: model.PropertyFieldTypeText,
- Attrs: nil,
- }
- _, err := th.App.CreatePropertyField(rctx, fieldWithNilAttrs, false, "")
- require.Nil(t, err)
-
- // Create a field with empty Attrs
- fieldWithEmptyAttrs := &model.PropertyField{
- GroupID: cpaID,
- Name: "Field with empty attrs",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{},
- }
- _, err = th.App.CreatePropertyField(rctx, fieldWithEmptyAttrs, false, "")
- require.Nil(t, err)
-
- // ListCPAFields should initialize Attrs with defaults
- fields, appErr := th.App.ListCPAFields(rctx)
- require.Nil(t, appErr)
- require.NotEmpty(t, fields)
-
- // Find our test fields and verify default attrs are set
- for _, field := range fields {
- if field.Name == "Field with nil attrs" || field.Name == "Field with empty attrs" {
- require.Equal(t, model.CustomProfileAttributesVisibilityDefault, field.Attrs.Visibility)
- require.Equal(t, float64(0), field.Attrs.SortOrder)
- }
- }
- })
-
- t.Run("list fields should return defaults for fields created without visibility and sort_order", func(t *testing.T) {
- // Create a field with minimal attrs (no visibility or sort_order)
- fieldMinimal, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: "field_without_defaults",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{}, // Empty attrs - no visibility or sort_order
- })
- require.NoError(t, err)
- createdFieldMinimal, appErr := th.App.CreateCPAField(rctx, fieldMinimal)
- require.Nil(t, appErr)
- require.NotNil(t, createdFieldMinimal)
-
- // Create another field to ensure we test list results with explicit values
- fieldNormal, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: "normal_field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{
- model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityAlways,
- model.CustomProfileAttributesPropertyAttrsSortOrder: 5.0,
- },
- })
- require.NoError(t, err)
- createdFieldNormal, appErr := th.App.CreateCPAField(rctx, fieldNormal)
- require.Nil(t, appErr)
- require.NotNil(t, createdFieldNormal)
-
- // List all fields
- fields, appErr := th.App.ListCPAFields(rctx)
- require.Nil(t, appErr)
- require.NotEmpty(t, fields)
-
- // Find our test fields and verify defaults
- foundMinimal := false
- foundNormal := false
- for _, f := range fields {
- if f.ID == createdFieldMinimal.ID {
- foundMinimal = true
- // Verify defaults are set for field created without them
- require.Equal(t, model.CustomProfileAttributesVisibilityDefault, f.Attrs.Visibility, "visibility should have default value")
- require.Equal(t, float64(0), f.Attrs.SortOrder, "sort_order should default to 0")
- }
- if f.ID == createdFieldNormal.ID {
- foundNormal = true
- // Verify createdFieldNormal are preserved
- require.Equal(t, model.CustomProfileAttributesVisibilityAlways, f.Attrs.Visibility)
- require.Equal(t, float64(5), f.Attrs.SortOrder)
- }
- }
- require.True(t, foundMinimal, "should have found createdFieldMinimal in list")
- require.True(t, foundNormal, "should have found createdFieldNormal in list")
- })
-}
-
-func TestCreateCPAField(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- t.Run("should fail if the field is not valid", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{Name: celSafeName()})
- require.NoError(t, err)
-
- createdField, err := th.App.CreateCPAField(rctx, field)
- require.Error(t, err)
- require.Empty(t, createdField)
- })
-
- t.Run("should not be able to create a property field for a different feature", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: model.NewId(),
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.Equal(t, cpaID, createdField.GroupID)
- })
-
- t.Run("should correctly create a CPA field", func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden},
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.NotZero(t, createdField.ID)
- require.Equal(t, cpaID, createdField.GroupID)
- require.Equal(t, model.CustomProfileAttributesVisibilityHidden, createdField.Attrs.Visibility)
-
- fetchedField, gErr := th.App.GetPropertyField(rctx, "", createdField.ID)
- require.Nil(t, gErr)
- require.Equal(t, field.Name, fetchedField.Name)
- require.NotZero(t, fetchedField.CreateAt)
- require.Equal(t, fetchedField.CreateAt, fetchedField.UpdateAt)
- })
-
- t.Run("should create CPA field with DeleteAt set to 0 even if input has non-zero DeleteAt", func(t *testing.T) {
- // Create a CPAField with DeleteAt != 0
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden},
- })
- require.NoError(t, err)
-
- // Set DeleteAt to non-zero value before creation
- field.DeleteAt = time.Now().UnixMilli()
- require.NotZero(t, field.DeleteAt, "Pre-condition: field should have non-zero DeleteAt")
-
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.NotZero(t, createdField.ID)
- require.Equal(t, cpaID, createdField.GroupID)
-
- // Verify that DeleteAt has been reset to 0
- require.Zero(t, createdField.DeleteAt, "DeleteAt should be 0 after creation")
-
- // Double-check by fetching the field from the database
- fetchedField, gErr := th.App.GetPropertyField(rctx, "", createdField.ID)
- require.Nil(t, gErr)
- require.Zero(t, fetchedField.DeleteAt, "DeleteAt should be 0 in database")
- })
-
- t.Run("CPA should honor the field limit", func(t *testing.T) {
- th := Setup(t).InitBasic(t)
-
- t.Run("should not be able to create CPA fields above the limit", func(t *testing.T) {
- // we create the rest of the fields required to reach the limit
- for i := 1; i <= CustomProfileAttributesFieldLimit; i++ {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: fmt.Sprintf("f_%d_%s", i, model.NewId()),
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.NotZero(t, createdField.ID)
- }
-
- // then, we create a last one that would exceed the limit
- field := &model.CPAField{
- PropertyField: model.PropertyField{
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- },
- }
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.NotNil(t, appErr)
- require.Equal(t, http.StatusUnprocessableEntity, appErr.StatusCode)
- require.Zero(t, createdField)
- })
-
- t.Run("deleted fields should not count for the limit", func(t *testing.T) {
- // we retrieve the list of fields and check we've reached the limit
- fields, appErr := th.App.ListCPAFields(rctx)
- require.Nil(t, appErr)
- require.Len(t, fields, CustomProfileAttributesFieldLimit)
-
- // then we delete one field
- require.Nil(t, th.App.DeleteCPAField(rctx, fields[0].ID))
-
- // creating a new one should work now
- field := &model.CPAField{
- PropertyField: model.PropertyField{
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- },
- }
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
- require.NotZero(t, createdField.ID)
- })
- })
-}
-
-func TestPatchCPAField(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- newField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden},
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, newField)
- require.Nil(t, appErr)
-
- patch := &model.PropertyFieldPatch{
- Name: new("patched_name"),
- Attrs: new(model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityWhenSet}),
- TargetID: new(model.NewId()),
- TargetType: new(model.NewId()),
- }
-
- t.Run("should fail if the field doesn't exist", func(t *testing.T) {
- updatedField, appErr := th.App.PatchCPAField(rctx, model.NewId(), patch)
- require.NotNil(t, appErr)
- require.Empty(t, updatedField)
- })
-
- t.Run("should not allow to patch a field outside of CPA", func(t *testing.T) {
- otherGroup, gErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
- Name: "test_patch_cpa_other_group_" + model.NewId(),
- Version: model.PropertyGroupVersionV1,
- })
- require.Nil(t, gErr)
-
- newField := &model.PropertyField{
- GroupID: otherGroup.ID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
- }
-
- field, err := th.App.CreatePropertyField(rctx, newField, false, "")
- require.Nil(t, err)
-
- updatedField, uErr := th.App.PatchCPAField(rctx, field.ID, patch)
- require.NotNil(t, uErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", uErr.Id)
- require.Empty(t, updatedField)
- })
-
- t.Run("should correctly patch the CPA property field", func(t *testing.T) {
- time.Sleep(10 * time.Millisecond) // ensure the UpdateAt is different than CreateAt
-
- updatedField, appErr := th.App.PatchCPAField(rctx, createdField.ID, patch)
- require.Nil(t, appErr)
- require.Equal(t, createdField.ID, updatedField.ID)
- require.Equal(t, "patched_name", updatedField.Name)
- require.Equal(t, model.CustomProfileAttributesVisibilityWhenSet, updatedField.Attrs.Visibility)
- require.Empty(t, updatedField.TargetID, "CPA should not allow to patch the field's target ID")
- require.Empty(t, updatedField.TargetType, "CPA should not allow to patch the field's target type")
- require.Greater(t, updatedField.UpdateAt, createdField.UpdateAt)
- })
-
- t.Run("should preserve option IDs when patching select field options", func(t *testing.T) {
- // Create a select field with options
- selectField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "select_field",
- Type: model.PropertyFieldTypeSelect,
- Attrs: map[string]any{
- model.PropertyFieldAttributeOptions: []any{
- map[string]any{
- "name": "Option 1",
- "color": "#111111",
- },
- map[string]any{
- "name": "Option 2",
- "color": "#222222",
- },
- },
- },
- })
- require.NoError(t, err)
-
- createdSelectField, appErr := th.App.CreateCPAField(rctx, selectField)
- require.Nil(t, appErr)
-
- // Get the original option IDs
- options := createdSelectField.Attrs.Options
- require.Len(t, options, 2)
- originalID1 := options[0].ID
- originalID2 := options[1].ID
- require.NotEmpty(t, originalID1)
- require.NotEmpty(t, originalID2)
-
- // Patch the field with updated option names and colors
- selectPatch := &model.PropertyFieldPatch{
- Attrs: new(model.StringInterface{
- model.PropertyFieldAttributeOptions: []any{
- map[string]any{
- "id": originalID1,
- "name": "Updated Option 1",
- "color": "#333333",
- },
- map[string]any{
- "name": "New Option 1.5",
- "color": "#353535",
- },
- map[string]any{
- "id": originalID2,
- "name": "Updated Option 2",
- "color": "#444444",
- },
- },
- }),
- }
-
- updatedSelectField, appErr := th.App.PatchCPAField(rctx, createdSelectField.ID, selectPatch)
- require.Nil(t, appErr)
-
- updatedOptions := updatedSelectField.Attrs.Options
- require.Len(t, updatedOptions, 3)
-
- // Verify the options were updated while preserving IDs
- require.Equal(t, originalID1, updatedOptions[0].ID)
- require.Equal(t, "Updated Option 1", updatedOptions[0].Name)
- require.Equal(t, "#333333", updatedOptions[0].Color)
- require.Equal(t, originalID2, updatedOptions[2].ID)
- require.Equal(t, "Updated Option 2", updatedOptions[2].Name)
- require.Equal(t, "#444444", updatedOptions[2].Color)
-
- // Check the new option
- require.Equal(t, "New Option 1.5", updatedOptions[1].Name)
- require.Equal(t, "#353535", updatedOptions[1].Color)
- })
-
- t.Run("Should not delete the values of a field after patching it if the type has not changed", func(t *testing.T) {
- // Create a select field with options
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "select_field_with_values",
- Type: model.PropertyFieldTypeSelect,
- Attrs: model.StringInterface{
- model.PropertyFieldAttributeOptions: []any{
- map[string]any{
- "name": "Option 1",
- "color": "#FF5733",
- },
- map[string]any{
- "name": "Option 2",
- "color": "#33FF57",
- },
- },
- },
- })
- require.NoError(t, err)
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
-
- // Get the option IDs
- options := createdField.Attrs.Options
- require.Len(t, options, 2)
- optionID := options[0].ID
- require.NotEmpty(t, optionID)
-
- // Create values for this field using the first option
- userID := model.NewId()
- value, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(fmt.Sprintf(`"%s"`, optionID)), false)
- require.Nil(t, appErr)
- require.NotNil(t, value)
-
- // Patch the field without changing type (just update name and add a new option)
- patch := &model.PropertyFieldPatch{
- Name: new("updated_select_field_name"),
- Attrs: new(model.StringInterface{
- model.PropertyFieldAttributeOptions: []any{
- map[string]any{
- "id": optionID, // Keep the same ID for the first option
- "name": "Updated Option 1",
- "color": "#FF5733",
- },
- map[string]any{
- "name": "Option 2",
- "color": "#33FF57",
- },
- map[string]any{
- "name": "Option 3",
- "color": "#5733FF",
- },
- },
- }),
- }
- updatedField, appErr := th.App.PatchCPAField(rctx, createdField.ID, patch)
- require.Nil(t, appErr)
- require.Equal(t, "updated_select_field_name", updatedField.Name)
- require.Equal(t, model.PropertyFieldTypeSelect, updatedField.Type)
-
- // Verify values still exist
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Len(t, values, 1)
- require.Equal(t, json.RawMessage(fmt.Sprintf(`"%s"`, optionID)), values[0].Value)
- })
-
- t.Run("Should delete the values of a field after patching it if the type has changed", func(t *testing.T) {
- // Create a select field with options
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: "select_field_with_type_change",
- Type: model.PropertyFieldTypeSelect,
- Attrs: model.StringInterface{
- model.PropertyFieldAttributeOptions: []any{
- map[string]any{
- "name": "Option A",
- "color": "#FF5733",
- },
- map[string]any{
- "name": "Option B",
- "color": "#33FF57",
- },
- },
- },
- })
- require.NoError(t, err)
- createdField, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr)
-
- // Get the option IDs
- options := createdField.Attrs.Options
- require.Len(t, options, 2)
- optionID := options[0].ID
- require.NotEmpty(t, optionID)
-
- // Create values for this field
- userID := model.NewId()
- value, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(fmt.Sprintf(`"%s"`, optionID)), false)
- require.Nil(t, appErr)
- require.NotNil(t, value)
-
- // Verify value exists before type change
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Len(t, values, 1)
-
- // Patch the field and change type from select to text
- patch := &model.PropertyFieldPatch{
- Type: model.NewPointer(model.PropertyFieldTypeText),
- }
- updatedField, appErr := th.App.PatchCPAField(rctx, createdField.ID, patch)
- require.Nil(t, appErr)
- require.Equal(t, model.PropertyFieldTypeText, updatedField.Type)
-
- // Verify values have been deleted
- values, appErr = th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Empty(t, values)
- })
-}
-
-func TestDeleteCPAField(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- newField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: celSafeName(),
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
-
- createdField, appErr := th.App.CreateCPAField(rctx, newField)
- require.Nil(t, appErr)
-
- for i := range 3 {
- newValue := &model.PropertyValue{
- TargetID: model.NewId(),
- TargetType: model.PropertyValueTargetTypeUser,
- GroupID: cpaID,
- FieldID: createdField.ID,
- Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
- }
- value, err := th.App.CreatePropertyValue(rctx, newValue)
- require.Nil(t, err)
- require.NotZero(t, value.ID)
- }
-
- t.Run("should fail if the field doesn't exist", func(t *testing.T) {
- err := th.App.DeleteCPAField(rctx, model.NewId())
- require.NotNil(t, err)
- require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", err.Id)
- })
-
- t.Run("should not allow to delete a field outside of CPA", func(t *testing.T) {
- otherGroup, gErr := th.App.RegisterPropertyGroup(rctx, &model.PropertyGroup{
- Name: "test_delete_cpa_other_group_" + model.NewId(),
- Version: model.PropertyGroupVersionV1,
- })
- require.Nil(t, gErr)
-
- newField := &model.PropertyField{
- GroupID: otherGroup.ID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
- }
- field, err := th.App.CreatePropertyField(rctx, newField, false, "")
- require.Nil(t, err)
-
- dErr := th.App.DeleteCPAField(rctx, field.ID)
- require.NotNil(t, dErr)
- require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", dErr.Id)
- })
-
- t.Run("should correctly delete the field", func(t *testing.T) {
- // check that we have the associated values to the field prior deletion
- opts := model.PropertyValueSearchOpts{PerPage: 10, FieldID: createdField.ID}
- values, err := th.App.SearchPropertyValues(rctx, cpaID, opts)
- require.Nil(t, err)
- require.Len(t, values, 3)
-
- // delete the field
- require.Nil(t, th.App.DeleteCPAField(rctx, createdField.ID))
-
- // check that it is marked as deleted
- fetchedField, err := th.App.GetPropertyField(rctx, "", createdField.ID)
- require.Nil(t, err)
- require.NotZero(t, fetchedField.DeleteAt)
-
- // ensure that the associated fields have been marked as deleted too
- values, err = th.App.SearchPropertyValues(rctx, cpaID, opts)
- require.Nil(t, err)
- require.Len(t, values, 0)
-
- opts.IncludeDeleted = true
- values, err = th.App.SearchPropertyValues(rctx, cpaID, opts)
- require.Nil(t, err)
- require.Len(t, values, 3)
- for _, value := range values {
- require.NotZero(t, value.DeleteAt)
- }
- })
-}
-
func TestGetCPAValue(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
rctx := th.emptyContextWithCallerID(anonymousCallerId)
field := &model.PropertyField{
- GroupID: cpaID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
+ GroupID: cpaID,
+ Name: "f_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
require.Nil(t, err)
fieldID := createdField.ID
t.Run("should fail if the value doesn't exist", func(t *testing.T) {
- pv, appErr := th.App.GetCPAValue(rctx, model.NewId())
+ pv, appErr := th.App.GetPropertyValue(rctx, cpaID, model.NewId())
require.NotNil(t, appErr)
require.Nil(t, pv)
})
@@ -826,7 +53,7 @@ func TestGetCPAValue(t *testing.T) {
require.Nil(t, err)
require.NotNil(t, created)
- pv, appErr := th.App.GetCPAValue(rctx, created.ID)
+ pv, appErr := th.App.GetPropertyValue(rctx, cpaID, created.ID)
require.NotNil(t, appErr)
require.Nil(t, pv)
})
@@ -842,16 +69,26 @@ func TestGetCPAValue(t *testing.T) {
propertyValue, err := th.App.CreatePropertyValue(rctx, propertyValue)
require.Nil(t, err)
- pv, appErr := th.App.GetCPAValue(rctx, propertyValue.ID)
+ pv, appErr := th.App.GetPropertyValue(rctx, cpaID, propertyValue.ID)
require.Nil(t, appErr)
require.NotNil(t, pv)
})
t.Run("should handle array values correctly", func(t *testing.T) {
+ optionIDs := []string{model.NewId(), model.NewId(), model.NewId()}
arrayField := &model.PropertyField{
- GroupID: cpaID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeMultiselect,
+ GroupID: cpaID,
+ Name: "f_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionIDs[0], "name": "option1"},
+ map[string]any{"id": optionIDs[1], "name": "option2"},
+ map[string]any{"id": optionIDs[2], "name": "option3"},
+ },
+ },
}
createdField, err := th.App.CreatePropertyField(rctx, arrayField, false, "")
require.Nil(t, err)
@@ -861,202 +98,17 @@ func TestGetCPAValue(t *testing.T) {
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaID,
FieldID: createdField.ID,
- Value: json.RawMessage(`["option1", "option2", "option3"]`),
+ Value: json.RawMessage(fmt.Sprintf(`["%s", "%s", "%s"]`, optionIDs[0], optionIDs[1], optionIDs[2])),
}
propertyValue, err = th.App.CreatePropertyValue(rctx, propertyValue)
require.Nil(t, err)
- pv, appErr := th.App.GetCPAValue(rctx, propertyValue.ID)
+ pv, appErr := th.App.GetPropertyValue(rctx, cpaID, propertyValue.ID)
require.Nil(t, appErr)
require.NotNil(t, pv)
var arrayValues []string
require.NoError(t, json.Unmarshal(pv.Value, &arrayValues))
- require.Equal(t, []string{"option1", "option2", "option3"}, arrayValues)
- })
-}
-
-func TestListCPAValues(t *testing.T) {
- mainHelper.Parallel(t)
- th := SetupConfig(t, func(cfg *model.Config) {
- cfg.FeatureFlags.CustomProfileAttributes = true
- }).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- userID := model.NewId()
-
- t.Run("should return empty list when user has no values", func(t *testing.T) {
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Empty(t, values)
- })
-
- t.Run("should list all values for a user", func(t *testing.T) {
- var expectedValues []json.RawMessage
-
- for i := 1; i <= CustomProfileAttributesFieldLimit; i++ {
- field := &model.PropertyField{
- GroupID: cpaID,
- Name: fmt.Sprintf("Field %d", i),
- Type: model.PropertyFieldTypeText,
- }
- _, err := th.App.CreatePropertyField(rctx, field, false, "")
- require.Nil(t, err)
-
- value := &model.PropertyValue{
- TargetID: userID,
- TargetType: model.PropertyValueTargetTypeUser,
- GroupID: cpaID,
- FieldID: field.ID,
- Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
- }
- _, err = th.App.CreatePropertyValue(rctx, value)
- require.Nil(t, err)
- expectedValues = append(expectedValues, value.Value)
- }
-
- // List values for original user
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Len(t, values, CustomProfileAttributesFieldLimit)
-
- actualValues := make([]json.RawMessage, len(values))
- for i, value := range values {
- require.Equal(t, userID, value.TargetID)
- require.Equal(t, "user", value.TargetType)
- require.Equal(t, cpaID, value.GroupID)
- actualValues[i] = value.Value
- }
- require.ElementsMatch(t, expectedValues, actualValues)
- })
-}
-
-func TestPatchCPAValue(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- t.Run("should fail if the field doesn't exist", func(t *testing.T) {
- invalidFieldID := model.NewId()
- _, appErr := th.App.PatchCPAValue(rctx, model.NewId(), invalidFieldID, json.RawMessage(`"fieldValue"`), true)
- require.NotNil(t, appErr)
- })
-
- t.Run("should create value if new field value", func(t *testing.T) {
- newField := &model.PropertyField{
- GroupID: cpaID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
- }
- createdField, err := th.App.CreatePropertyField(rctx, newField, false, "")
- require.Nil(t, err)
-
- userID := model.NewId()
- patchedValue, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(`"test value"`), true)
- require.Nil(t, appErr)
- require.NotNil(t, patchedValue)
- require.Equal(t, json.RawMessage(`"test value"`), patchedValue.Value)
- require.Equal(t, userID, patchedValue.TargetID)
-
- t.Run("should correctly patch the CPA property value", func(t *testing.T) {
- patch2, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(`"new patched value"`), true)
- require.Nil(t, appErr)
- require.NotNil(t, patch2)
- require.Equal(t, patchedValue.ID, patch2.ID)
- require.Equal(t, json.RawMessage(`"new patched value"`), patch2.Value)
- require.Equal(t, userID, patch2.TargetID)
- })
- })
-
- t.Run("should fail if field is deleted", func(t *testing.T) {
- newField := &model.PropertyField{
- GroupID: cpaID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
- }
- createdField, err := th.App.CreatePropertyField(rctx, newField, false, "")
- require.Nil(t, err)
- err = th.App.DeletePropertyField(rctx, cpaID, createdField.ID, false, "")
- require.Nil(t, err)
-
- userID := model.NewId()
- patchedValue, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(`"test value"`), true)
- require.NotNil(t, appErr)
- require.Nil(t, patchedValue)
- })
-
- t.Run("should handle array values correctly", func(t *testing.T) {
- optionsID := []string{model.NewId(), model.NewId(), model.NewId(), model.NewId()}
- arrayField := &model.PropertyField{
- GroupID: cpaID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeMultiselect,
- Attrs: model.StringInterface{
- "options": []map[string]any{
- {"id": optionsID[0], "name": "option1"},
- {"id": optionsID[1], "name": "option2"},
- {"id": optionsID[2], "name": "option3"},
- {"id": optionsID[3], "name": "option4"},
- },
- },
- }
- createdField, err := th.App.CreatePropertyField(rctx, arrayField, false, "")
- require.Nil(t, err)
-
- // Create a JSON array with option IDs (not names)
- optionJSON := fmt.Sprintf(`["%s", "%s", "%s"]`, optionsID[0], optionsID[1], optionsID[2])
-
- userID := model.NewId()
- patchedValue, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(optionJSON), true)
- require.Nil(t, appErr)
- require.NotNil(t, patchedValue)
- var arrayValues []string
- require.NoError(t, json.Unmarshal(patchedValue.Value, &arrayValues))
- require.Equal(t, []string{optionsID[0], optionsID[1], optionsID[2]}, arrayValues)
- require.Equal(t, userID, patchedValue.TargetID)
-
- // Update array values with valid option IDs
- updatedOptionJSON := fmt.Sprintf(`["%s", "%s"]`, optionsID[1], optionsID[3])
- updatedValue, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(updatedOptionJSON), true)
- require.Nil(t, appErr)
- require.NotNil(t, updatedValue)
- require.Equal(t, patchedValue.ID, updatedValue.ID)
- arrayValues = nil
- require.NoError(t, json.Unmarshal(updatedValue.Value, &arrayValues))
- require.Equal(t, []string{optionsID[1], optionsID[3]}, arrayValues)
- require.Equal(t, userID, updatedValue.TargetID)
-
- t.Run("should fail if it tries to set a value that not valid for a field", func(t *testing.T) {
- // Try to use an ID that doesn't exist in the options
- invalidID := model.NewId()
- invalidOptionJSON := fmt.Sprintf(`["%s", "%s"]`, optionsID[0], invalidID)
-
- invalidValue, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(invalidOptionJSON), true)
- require.NotNil(t, appErr)
- require.Nil(t, invalidValue)
- require.Equal(t, "app.custom_profile_attributes.validate_value.app_error", appErr.Id)
-
- // Test with completely invalid JSON format
- invalidJSON := `[not valid json]`
- invalidValue, appErr = th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(invalidJSON), true)
- require.NotNil(t, appErr)
- require.Nil(t, invalidValue)
- require.Equal(t, "app.custom_profile_attributes.validate_value.app_error", appErr.Id)
-
- // Test with wrong data type (sending string instead of array)
- wrongTypeJSON := `"not an array"`
- invalidValue, appErr = th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(wrongTypeJSON), true)
- require.NotNil(t, appErr)
- require.Nil(t, invalidValue)
- require.Equal(t, "app.custom_profile_attributes.validate_value.app_error", appErr.Id)
- })
+ require.Equal(t, optionIDs, arrayValues)
})
}
@@ -1065,232 +117,176 @@ func TestDeleteCPAValues(t *testing.T) {
th := SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.CustomProfileAttributes = true
}).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
rctx := th.emptyContextWithCallerID(anonymousCallerId)
userID := model.NewId()
otherUserID := model.NewId()
- // Create multiple fields and values for the user
- var createdFields []*model.CPAField
- for i := 1; i <= 3; i++ {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- GroupID: cpaID,
- Name: fmt.Sprintf("field_%d", i),
- Type: model.PropertyFieldTypeText,
+ listValues := func(targetID string) []*model.PropertyValue {
+ t.Helper()
+ values, appErr := th.App.SearchPropertyValues(rctx, cpaID, model.PropertyValueSearchOpts{
+ TargetIDs: []string{targetID},
+ TargetType: model.PropertyValueTargetTypeUser,
+ // Single-target search: at most one value per (target, field), so the field cap bounds the page.
+ PerPage: model.AccessControlGroupFieldLimit + 5,
})
- require.NoError(t, err)
- createdField, appErr := th.App.CreateCPAField(rctx, field)
require.Nil(t, appErr)
- createdFields = append(createdFields, createdField)
-
- // Create a value for this field
- value, appErr := th.App.PatchCPAValue(rctx, userID, createdField.ID, json.RawMessage(fmt.Sprintf(`"Value %d"`, i)), false)
- require.Nil(t, appErr)
- require.NotNil(t, value)
+ return values
}
- // Verify values exist before deletion
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Len(t, values, 3)
+ // Create multiple fields and a value per field for userID.
+ var createdFields []*model.PropertyField
+ for i := 1; i <= 3; i++ {
+ field := &model.PropertyField{
+ GroupID: cpaID,
+ Name: fmt.Sprintf("field_%d", i),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdField, err := th.App.CreatePropertyField(rctx, field, false, "")
+ require.Nil(t, err)
+ createdFields = append(createdFields, createdField)
+
+ value := &model.PropertyValue{
+ TargetID: userID,
+ TargetType: model.PropertyValueTargetTypeUser,
+ GroupID: cpaID,
+ FieldID: createdField.ID,
+ Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
+ }
+ _, err = th.App.CreatePropertyValue(rctx, value)
+ require.Nil(t, err)
+ }
+
+ require.Len(t, listValues(userID), 3)
- // Test deleting values for user
t.Run("should delete all values for a user", func(t *testing.T) {
- appErr := th.App.DeleteCPAValues(rctx, userID)
+ appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, userID)
require.Nil(t, appErr)
- // Verify values are gone
- values, appErr := th.App.ListCPAValues(rctx, userID)
- require.Nil(t, appErr)
- require.Empty(t, values)
+ require.Empty(t, listValues(userID))
})
t.Run("should handle deleting values for a user with no values", func(t *testing.T) {
- appErr := th.App.DeleteCPAValues(rctx, otherUserID)
+ appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, otherUserID)
require.Nil(t, appErr)
})
t.Run("should not affect values for other users", func(t *testing.T) {
- // Create values for another user
+ // Create values for otherUserID.
for _, field := range createdFields {
- value, appErr := th.App.PatchCPAValue(rctx, otherUserID, field.ID, json.RawMessage(`"Other user value"`), false)
- require.Nil(t, appErr)
- require.NotNil(t, value)
+ value := &model.PropertyValue{
+ TargetID: otherUserID,
+ TargetType: model.PropertyValueTargetTypeUser,
+ GroupID: cpaID,
+ FieldID: field.ID,
+ Value: json.RawMessage(`"Other user value"`),
+ }
+ _, err := th.App.CreatePropertyValue(rctx, value)
+ require.Nil(t, err)
}
- // Delete values for original user
- appErr := th.App.DeleteCPAValues(rctx, userID)
+ appErr := th.App.DeletePropertyValuesForTarget(rctx, cpaID, model.PropertyFieldObjectTypeUser, userID)
require.Nil(t, appErr)
- // Verify other user's values still exist
- values, appErr := th.App.ListCPAValues(rctx, otherUserID)
- require.Nil(t, appErr)
- require.Len(t, values, 3)
+ require.Len(t, listValues(otherUserID), 3)
})
}
-func TestCreateCPAField_RejectsInvalidName(t *testing.T) {
+// TestCPAValueSyncLock exercises AccessControlHook.checkSyncLock end-to-end
+// at the app layer: a write for a field with ldap= or saml= set only
+// succeeds when the caller ID matches the field's sync source. Covering this
+// at the app layer also asserts that the startup wiring in server.go
+// (access_control group registration, AccessControlHook install, and
+// CallerIDExtractor reading from request.CTX) is intact — something the
+// properties-package tests cannot verify because they install the hook
+// themselves.
+func TestCPAValueSyncLock(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
- tests := []struct {
- name string
- fieldName string
- wantErrID string
- }{
- {
- name: "space in name",
- fieldName: "My Field",
- wantErrID: "model.cpa_field.name.invalid_charset.app_error",
- },
- {
- name: "leading digit",
- fieldName: "7department",
- wantErrID: "model.cpa_field.name.invalid_charset.app_error",
- },
- {
- name: "reserved word in",
- fieldName: "in",
- wantErrID: "model.cpa_field.name.reserved_word.app_error",
- },
- {
- name: "reserved word true",
- fieldName: "true",
- wantErrID: "model.cpa_field.name.reserved_word.app_error",
- },
- }
+ adminRctx := th.emptyContextWithCallerID(th.SystemAdminUser.Id)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: tt.fieldName,
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
-
- _, appErr := th.App.CreateCPAField(rctx, field)
- require.NotNil(t, appErr, "expected error for name %q", tt.fieldName)
- require.Equal(t, tt.wantErrID, appErr.Id)
- })
- }
-}
-
-func TestCreateCPAField_AcceptsValidName(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- validNames := []string{"department", "_private", "A1", "a_b_c", "Department", "DEPT"}
- for _, n := range validNames {
- t.Run(n, func(t *testing.T) {
- field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: n,
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, err)
-
- created, appErr := th.App.CreateCPAField(rctx, field)
- require.Nil(t, appErr, "unexpected error for name %q: %v", n, appErr)
- require.NotEmpty(t, created.ID)
-
- _ = th.App.DeleteCPAField(rctx, created.ID)
- })
- }
-}
-
-func TestPatchCPAField_GrandfatherSkipsValidationOnUnchangedName(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- // Seed a field with an invalid CPA name directly via CreatePropertyField (bypassing CPA validation).
- // This simulates a pre-existing legacy field whose name violates the new CEL rule.
- legacyField, err := th.App.CreatePropertyField(rctx, &model.PropertyField{
- GroupID: cpaID,
- Name: "My Legacy Field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityWhenSet},
- }, false, "")
- require.Nil(t, err)
- defer func() { _ = th.App.DeleteCPAField(rctx, legacyField.ID) }()
-
- t.Run("patching only visibility leaves invalid name unchanged (grandfather passes)", func(t *testing.T) {
- newVisibility := model.CustomProfileAttributesVisibilityAlways
- patch := &model.PropertyFieldPatch{
- Attrs: &model.StringInterface{
- model.CustomProfileAttributesPropertyAttrsVisibility: newVisibility,
+ createField := func(name string, attrs model.CPAAttrs) *model.PropertyField {
+ t.Helper()
+ cpa := &model.CPAField{
+ PropertyField: model.PropertyField{
+ GroupID: cpaID,
+ Name: name,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
},
+ Attrs: attrs,
}
- patched, appErr := th.App.PatchCPAField(rctx, legacyField.ID, patch)
- require.Nil(t, appErr, "grandfather: patching non-name attrs on a legacy field must not trigger validation")
- require.Equal(t, "My Legacy Field", patched.Name, "name must remain unchanged")
- require.Equal(t, newVisibility, patched.Attrs.Visibility)
- })
-
- t.Run("patching name to another invalid value returns validation error", func(t *testing.T) {
- stillInvalidName := "still invalid name"
- patch := &model.PropertyFieldPatch{
- Name: new(stillInvalidName),
- }
- _, appErr := th.App.PatchCPAField(rctx, legacyField.ID, patch)
- require.NotNil(t, appErr, "renaming to an invalid name must be rejected")
- require.Equal(t, "model.cpa_field.name.invalid_charset.app_error", appErr.Id)
- })
-
- t.Run("patching name to a valid value succeeds", func(t *testing.T) {
- validName := "my_legacy_field"
- patch := &model.PropertyFieldPatch{
- Name: new(validName),
- }
- patched, appErr := th.App.PatchCPAField(rctx, legacyField.ID, patch)
- require.Nil(t, appErr, "renaming to a valid CEL identifier must succeed")
- require.Equal(t, validName, patched.Name)
- })
-}
-
-// TestCreatePropertyField_BypassesCPANameValidation_ExpectedBehavior asserts the documented
-// Option C bypass: the generic property-field App API does NOT enforce the CPA name regex
-// on master. This is intentional and time-bounded.
-//
-// PR #36173's AttributeValidationHook will close the bypass at the property-service layer.
-// Do NOT "fix" this test by adding CPA name validation in App.CreatePropertyField ahead of
-// #36173 landing — doing so would conflict with @davidkrauser's diff.
-//
-// See spec.md §Out of Scope and the CPAAttrs godoc block in
-// server/public/model/custom_profile_attributes.go (§Non-enforcement) for full context.
-func TestCreatePropertyField_BypassesCPANameValidation_ExpectedBehavior(t *testing.T) {
- mainHelper.Parallel(t)
- th := Setup(t).InitBasic(t)
-
- cpaID, cErr := th.App.CpaGroupID()
- require.Nil(t, cErr)
-
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
-
- // "My Field" violates CPAFieldNamePattern — would be rejected by CreateCPAField.
- // Via CreatePropertyField (the generic property API), it must succeed.
- field := &model.PropertyField{
- GroupID: cpaID,
- Name: "My Field",
- Type: model.PropertyFieldTypeText,
+ // Sanitization/validation runs inside CreatePropertyField via the
+ // AccessControlAttributeValidationHook.
+ created, appErr := th.App.CreatePropertyField(adminRctx, cpa.ToPropertyField(), false, "")
+ require.Nil(t, appErr)
+ return created
}
- created, appErr := th.App.CreatePropertyField(rctx, field, false, "")
- require.Nil(t, appErr,
- "CreatePropertyField must NOT enforce the CPA name regex on master — "+
- "that enforcement belongs to PR #36173's AttributeValidationHook")
- require.NotEmpty(t, created.ID)
+ ldapField := createField("ldap_attr_"+model.NewId(), model.CPAAttrs{LDAP: "mail"})
+ samlField := createField("saml_attr_"+model.NewId(), model.CPAAttrs{SAML: "email"})
+ plainField := createField("plain_attr_"+model.NewId(), model.CPAAttrs{})
- _ = th.App.DeleteCPAField(rctx, created.ID)
+ userID := model.NewId()
+ upsertAs := func(callerID string, field *model.PropertyField) *model.AppError {
+ t.Helper()
+ rctx := th.emptyContextWithCallerID(callerID)
+ _, appErr := th.App.UpsertPropertyValues(rctx, []*model.PropertyValue{{
+ GroupID: cpaID,
+ TargetType: model.PropertyValueTargetTypeUser,
+ TargetID: userID,
+ FieldID: field.ID,
+ Value: json.RawMessage(`"value"`),
+ }}, model.PropertyFieldObjectTypeUser, userID, "")
+ return appErr
+ }
+
+ requireSyncLock := func(appErr *model.AppError) {
+ t.Helper()
+ require.NotNil(t, appErr)
+ require.Equal(t, "app.property.sync_lock.app_error", appErr.Id)
+ }
+
+ t.Run("anonymous caller is blocked on an LDAP-synced field", func(t *testing.T) {
+ requireSyncLock(upsertAs(anonymousCallerId, ldapField))
+ })
+
+ t.Run("anonymous caller is blocked on a SAML-synced field", func(t *testing.T) {
+ requireSyncLock(upsertAs(anonymousCallerId, samlField))
+ })
+
+ t.Run("anonymous caller is allowed on a non-synced field", func(t *testing.T) {
+ require.Nil(t, upsertAs(anonymousCallerId, plainField))
+ })
+
+ t.Run("LDAP sync caller is allowed on an LDAP-synced field", func(t *testing.T) {
+ require.Nil(t, upsertAs(model.CallerIDLDAPSync, ldapField))
+ })
+
+ t.Run("LDAP sync caller is blocked on a SAML-synced field", func(t *testing.T) {
+ requireSyncLock(upsertAs(model.CallerIDLDAPSync, samlField))
+ })
+
+ t.Run("SAML sync caller is allowed on a SAML-synced field", func(t *testing.T) {
+ require.Nil(t, upsertAs(model.CallerIDSAMLSync, samlField))
+ })
+
+ t.Run("SAML sync caller is blocked on an LDAP-synced field", func(t *testing.T) {
+ requireSyncLock(upsertAs(model.CallerIDSAMLSync, ldapField))
+ })
}
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 b6ab0a8e97c..2188b2488b3 100644
--- a/server/channels/app/file.go
+++ b/server/channels/app/file.go
@@ -75,14 +75,22 @@ func (a *App) CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppErr
}
func connectionTestErrorToAppError(connTestErr error) *model.AppError {
- switch err := connTestErr.(type) {
- case *filestore.S3FileBackendAuthError:
- return model.NewAppError("TestConnection", "api.file.test_connection_s3_auth.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- case *filestore.S3FileBackendNoBucketError:
- return model.NewAppError("TestConnection", "api.file.test_connection_s3_bucket_does_not_exist.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- default:
- return model.NewAppError("TestConnection", "api.file.test_connection.app_error", nil, "", http.StatusInternalServerError).Wrap(connTestErr)
+ // errors.As (rather than a type switch) so that future wrapping of
+ // the backend's typed errors does not silently fall through to the
+ // generic "test_connection" message.
+ var authErr *filestore.FileBackendAuthError
+ if errors.As(connTestErr, &authErr) {
+ // Carry the underlying SDK detail (S3 InvalidAccessKeyId,
+ // Azure AuthenticationFailed, clock-skew, etc.) into the
+ // AppError's detail string so the Test Connection toast
+ // shows admins what actually failed.
+ return model.NewAppError("TestConnection", "api.file.test_connection_auth.app_error", nil, authErr.Error(), http.StatusInternalServerError).Wrap(authErr)
}
+ var noBucketErr *filestore.FileBackendNoBucketError
+ if errors.As(connTestErr, &noBucketErr) {
+ return model.NewAppError("TestConnection", "api.file.test_connection_no_bucket.app_error", nil, noBucketErr.Error(), http.StatusInternalServerError).Wrap(noBucketErr)
+ }
+ return model.NewAppError("TestConnection", "api.file.test_connection.app_error", nil, connTestErr.Error(), http.StatusInternalServerError).Wrap(connTestErr)
}
func (a *App) TestFileStoreConnection() *model.AppError {
@@ -1520,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)
@@ -1559,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)
@@ -1579,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
@@ -1606,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/migrations.go b/server/channels/app/migrations.go
index 4888a194491..2033abf976c 100644
--- a/server/channels/app/migrations.go
+++ b/server/channels/app/migrations.go
@@ -753,7 +753,7 @@ func (s *Server) doSetupContentFlaggingProperties() error {
}
if len(propertiesToUpdate) > 0 {
- if _, _, err := s.propertyService.UpdatePropertyFields(nil, group.ID, propertiesToUpdate); err != nil {
+ if _, _, _, err := s.propertyService.UpdatePropertyFields(nil, group.ID, propertiesToUpdate); err != nil {
// Another server may have won the race and updated these fields
// concurrently (e.g. parallel tests sharing a database pool).
// Both servers write the same expected values, so tolerate the
@@ -844,13 +844,25 @@ func (s *Server) doSetupBoardsProperties() error {
for _, property := range propertiesToCreate {
if _, err := s.propertyService.CreatePropertyField(nil, property); err != nil {
- return fmt.Errorf("failed to create boards property: %q, error: %w", property.Name, err)
+ // Another server may have won the race and created this field
+ // concurrently (e.g. parallel tests sharing a database pool).
+ // Tolerate that but propagate any other error.
+ if _, retryErr := s.propertyService.GetPropertyFieldByName(nil, group.ID, "", property.Name); retryErr != nil {
+ return fmt.Errorf("failed to create boards property: %q, error: %w", property.Name, err)
+ }
}
}
if len(propertiesToUpdate) > 0 {
- if _, _, err := s.propertyService.UpdatePropertyFields(nil, group.ID, propertiesToUpdate); err != nil {
- return fmt.Errorf("failed to update boards property fields: %w", err)
+ if _, _, _, err := s.propertyService.UpdatePropertyFields(nil, group.ID, propertiesToUpdate); err != nil {
+ // Another server may have won the race and updated these fields
+ // concurrently (e.g. parallel tests sharing a database pool).
+ // Both servers write the same expected values, so tolerate the
+ // conflict but propagate any other error.
+ var conflictErr *store.ErrConflict
+ if !errors.As(err, &conflictErr) {
+ return fmt.Errorf("failed to update boards property fields: %w", err)
+ }
}
}
diff --git a/server/channels/app/migrations_test.go b/server/channels/app/migrations_test.go
index 91b4e823adf..a80a455a3a2 100644
--- a/server/channels/app/migrations_test.go
+++ b/server/channels/app/migrations_test.go
@@ -190,41 +190,54 @@ func TestCPADisplayNameBackfill_NoExistingFields(t *testing.T) {
func TestCPADisplayNameBackfill_BackfillsMissing(t *testing.T) {
th := Setup(t)
+ // LicenseCheckHook gates writes to the access_control group on an
+ // Enterprise license; the seed CreatePropertyField calls below would
+ // otherwise be rejected with app.property.license_error.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
- // fieldA exercises the "display_name present as empty string in JSONB" case — the true
- // idempotency boundary.
- fieldABase, convErr := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: "department",
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, convErr)
- fieldA, appErr := th.App.CreateCPAField(th.Context, fieldABase)
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
- require.Equal(t, "", fieldA.Attrs.DisplayName, "seed invariant: fieldA must have empty display_name")
- fieldBBase, convErr := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: "job_title",
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, convErr)
- fieldBBase.Attrs.DisplayName = "Job Title"
- fieldB, appErr := th.App.CreateCPAField(th.Context, fieldBBase)
+ // fieldA exercises the "display_name absent / empty in JSONB" case — the
+ // true idempotency boundary the migration is designed to fix.
+ fieldA, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "department",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }, false, "")
require.Nil(t, appErr)
- require.Equal(t, "Job Title", fieldB.Attrs.DisplayName, "seed invariant: fieldB must have display_name set")
+ require.Empty(t, fieldA.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
+ "seed invariant: fieldA must have empty display_name")
+
+ fieldB, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "job_title",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsDisplayName: "Job Title",
+ },
+ }, false, "")
+ require.Nil(t, appErr)
+ require.Equal(t, "Job Title", fieldB.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
+ "seed invariant: fieldB must have display_name set")
err := th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err)
- updatedFieldA, appErr := th.App.GetCPAField(th.Context, fieldA.ID)
+ updatedFieldA, appErr := th.App.GetPropertyField(th.Context, group.ID, fieldA.ID)
require.Nil(t, appErr)
- require.Equal(t, "department", updatedFieldA.Attrs.DisplayName,
+ require.Equal(t, "department", updatedFieldA.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"fieldA: display_name must be backfilled to field name")
- updatedFieldB, appErr := th.App.GetCPAField(th.Context, fieldB.ID)
+ updatedFieldB, appErr := th.App.GetPropertyField(th.Context, group.ID, fieldB.ID)
require.Nil(t, appErr)
- require.Equal(t, "Job Title", updatedFieldB.Attrs.DisplayName,
+ require.Equal(t, "Job Title", updatedFieldB.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"fieldB: display_name must not be overwritten when already set")
data, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
@@ -235,15 +248,23 @@ func TestCPADisplayNameBackfill_BackfillsMissing(t *testing.T) {
func TestCPADisplayNameBackfill_Idempotent(t *testing.T) {
th := Setup(t)
+ // LicenseCheckHook gates writes to the access_control group on an
+ // Enterprise license; the seed CreatePropertyField call below would
+ // otherwise be rejected with app.property.license_error.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
- fieldBase, convErr := model.NewCPAFieldFromPropertyField(&model.PropertyField{
- Name: "location",
- Type: model.PropertyFieldTypeText,
- })
- require.NoError(t, convErr)
- seeded, appErr := th.App.CreateCPAField(th.Context, fieldBase)
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
+ require.Nil(t, appErr)
+
+ seeded, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "location",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }, false, "")
require.Nil(t, appErr)
err := th.Server.doSetupCPADisplayNameBackfill(th.Context)
@@ -253,9 +274,9 @@ func TestCPADisplayNameBackfill_Idempotent(t *testing.T) {
require.NoError(t, sysErr)
require.Equal(t, "true", data1.Value)
- updatedAfterFirst, appErr := th.App.GetCPAField(th.Context, seeded.ID)
+ updatedAfterFirst, appErr := th.App.GetPropertyField(th.Context, group.ID, seeded.ID)
require.Nil(t, appErr)
- require.Equal(t, "location", updatedAfterFirst.Attrs.DisplayName)
+ require.Equal(t, "location", updatedAfterFirst.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName])
// Snapshot UpdateAt before the second run so we can prove the second run is a no-op
// at the DB-write level. PropertyField.UpdateAt is set to model.GetMillis() on every
@@ -272,9 +293,9 @@ func TestCPADisplayNameBackfill_Idempotent(t *testing.T) {
require.NoError(t, sysErr)
require.Equal(t, "true", data2.Value)
- updatedAfterSecond, appErr := th.App.GetCPAField(th.Context, seeded.ID)
+ updatedAfterSecond, appErr := th.App.GetPropertyField(th.Context, group.ID, seeded.ID)
require.Nil(t, appErr)
- require.Equal(t, "location", updatedAfterSecond.Attrs.DisplayName,
+ require.Equal(t, "location", updatedAfterSecond.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"second run must not change display_name")
require.Equal(t, firstFieldUpdate, updatedAfterSecond.UpdateAt,
@@ -283,21 +304,30 @@ func TestCPADisplayNameBackfill_Idempotent(t *testing.T) {
func TestCPADisplayNameBackfill_BackfillsProtectedSourceOnlyField(t *testing.T) {
th := Setup(t)
+ // LicenseCheckHook gates writes to the access_control group on an
+ // Enterprise license. The seed below bypasses Create-side hooks via a
+ // direct store insert, but the backfill migration calls UpdatePropertyFields
+ // (unhooked) which still runs the version-match check; the license is
+ // nevertheless required by other CPA paths exercised across the suite.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
- groupID, appErr := th.App.CpaGroupID()
+ group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
+ groupID := group.ID
// Insert directly via the store so we bypass the property service's
// access-control routing (which would reject creating a protected
- // source_only field from a non-plugin caller). Type=text avoids the
- // options-stripping branch in read access control, but the migration's
- // correctness here doesn't depend on the field type.
+ // source_only field from a non-plugin caller). ObjectType/TargetType are
+ // required so the field is recognized as PSAv2 and matches the group's
+ // version when the migration's UpdatePropertyFields runs.
field := &model.PropertyField{
- GroupID: groupID,
- Name: "uas_employee_id",
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: "uas_employee_id",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go
index 0a7e788b5c2..83444fe4c6a 100644
--- a/server/channels/app/permissions_migrations.go
+++ b/server/channels/app/permissions_migrations.go
@@ -9,6 +9,7 @@ import (
"strings"
"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/store/sqlstore"
)
@@ -1325,6 +1326,22 @@ func (a *App) getAddEditFileAttachmentPermissionMigration() (permissionsMap, err
}, nil
}
+func (a *App) getAddDiscoverableChannelPermissionsMigration() (permissionsMap, error) {
+ return permissionsMap{
+ permissionTransformation{
+ On: permissionOr(
+ isRole(model.ChannelAdminRoleId),
+ isRole(model.TeamAdminRoleId),
+ isRole(model.SystemAdminRoleId),
+ ),
+ Add: []string{
+ model.PermissionManagePrivateChannelDiscoverability.Id,
+ model.PermissionManageChannelJoinRequests.Id,
+ },
+ },
+ }, nil
+}
+
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
func (a *App) DoPermissionsMigrations() error {
return a.Srv().doPermissionsMigrations()
@@ -1387,6 +1404,7 @@ func (s *Server) doPermissionsMigrations() error {
{Key: model.MigrationKeyRestoreManageOAuthPermission, Migration: a.getRestoreManageOAuthPermissionMigration},
{Key: model.MigrationKeyAddManageAgentPermissions, Migration: a.getAddManageAgentPermissionsMigration},
{Key: model.MigrationKeyAddEditFileAttachmentPermission, Migration: a.getAddEditFileAttachmentPermissionMigration},
+ {Key: model.MigrationKeyAddDiscoverableChannelPermissions, Migration: a.getAddDiscoverableChannelPermissionsMigration},
}
roles, err := s.Store().Role().GetAll()
@@ -1400,6 +1418,7 @@ func (s *Server) doPermissionsMigrations() error {
return err
}
if err := s.doPermissionsMigration(migration.Key, migMap, roles); err != nil {
+ mlog.Error("Failed to run permissions migration", mlog.String("key", migration.Key), mlog.Err(err))
return err
}
}
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/platform/web_hub.go b/server/channels/app/platform/web_hub.go
index 9ca6c998b8b..e5e1990c435 100644
--- a/server/channels/app/platform/web_hub.go
+++ b/server/channels/app/platform/web_hub.go
@@ -164,7 +164,7 @@ func (ps *PlatformService) GetHubForUserId(userID string) *Hub {
// https://mattermost.atlassian.net/browse/MM-26629.
var hash maphash.Hash
hash.SetSeed(ps.hashSeed)
- _, err := hash.Write([]byte(userID))
+ _, err := hash.WriteString(userID)
if err != nil {
ps.logger.Error("Unable to write userID to hash", mlog.String("userID", userID), mlog.Err(err))
}
diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go
index 3ff20d667c4..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()
}
@@ -1596,7 +1616,7 @@ func (api *PluginAPI) GetPropertyFields(groupID string, ids []string) ([]*model.
}
func (api *PluginAPI) UpdatePropertyField(groupID string, field *model.PropertyField) (*model.PropertyField, error) {
- updatedField, appErr := api.app.UpdatePropertyField(api.psaPluginContext(), groupID, field, false, "")
+ updatedField, _, appErr := api.app.UpdatePropertyField(api.psaPluginContext(), groupID, field, false, "")
if appErr != nil {
return nil, appErr
}
@@ -1690,6 +1710,14 @@ func (api *PluginAPI) SearchPropertyValues(groupID string, opts model.PropertyVa
}
func (api *PluginAPI) RegisterPropertyGroup(name string) (*model.PropertyGroup, error) {
+ if name == model.DeprecatedCPAPropertyGroupName {
+ return nil, fmt.Errorf(
+ "the group name %q has been renamed to %q; use %q instead",
+ model.DeprecatedCPAPropertyGroupName,
+ model.AccessControlPropertyGroupName,
+ model.AccessControlPropertyGroupName,
+ )
+ }
group, appErr := api.app.RegisterPropertyGroup(api.psaPluginContext(), &model.PropertyGroup{
Name: name,
Version: model.PropertyGroupVersionV1,
@@ -1701,6 +1729,7 @@ func (api *PluginAPI) RegisterPropertyGroup(name string) (*model.PropertyGroup,
}
func (api *PluginAPI) GetPropertyGroup(name string) (*model.PropertyGroup, error) {
+ name = migrateDeprecatedPropertyGroupName(name)
group, appErr := api.app.GetPropertyGroup(api.psaPluginContext(), name)
if appErr != nil {
return nil, appErr
@@ -1708,6 +1737,15 @@ func (api *PluginAPI) GetPropertyGroup(name string) (*model.PropertyGroup, error
return group, nil
}
+// migrateDeprecatedPropertyGroupName maps the deprecated "custom_profile_attributes"
+// group name to the current "access_control" name for backward compatibility.
+func migrateDeprecatedPropertyGroupName(name string) string {
+ if name == model.DeprecatedCPAPropertyGroupName {
+ return model.AccessControlPropertyGroupName
+ }
+ return name
+}
+
func (api *PluginAPI) GetPropertyFieldByName(groupID, targetID, name string) (*model.PropertyField, error) {
field, appErr := api.app.GetPropertyFieldByName(api.psaPluginContext(), groupID, targetID, name)
if appErr != nil {
@@ -1717,7 +1755,7 @@ func (api *PluginAPI) GetPropertyFieldByName(groupID, targetID, name string) (*m
}
func (api *PluginAPI) UpdatePropertyFields(groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
- updatedFields, appErr := api.app.UpdatePropertyFields(api.psaPluginContext(), groupID, fields, false, "")
+ updatedFields, _, appErr := api.app.UpdatePropertyFields(api.psaPluginContext(), groupID, fields, false, "")
if appErr != nil {
return nil, appErr
}
diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go
index 4421513bb31..b8a66f15f5a 100644
--- a/server/channels/app/plugin_api_test.go
+++ b/server/channels/app/plugin_api_test.go
@@ -3864,3 +3864,70 @@ func TestPluginAPICreateChannelAnonymousURLs(t *testing.T) {
assert.Equal(t, originalName, createdChannel.Name, "channel name should not be overridden")
})
}
+
+func TestPluginAPIPropertyGroupDeprecatedName(t *testing.T) {
+ mainHelper.Parallel(t)
+
+ t.Run("RegisterPropertyGroup rejects deprecated name", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+
+ api := th.SetupPluginAPI()
+
+ // Register using the deprecated name must fail
+ _, err := api.RegisterPropertyGroup(model.DeprecatedCPAPropertyGroupName)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "renamed")
+
+ // Register using the canonical name should still work
+ group, err := api.RegisterPropertyGroup(model.AccessControlPropertyGroupName)
+ require.NoError(t, err)
+ require.NotNil(t, group)
+ assert.Equal(t, model.AccessControlPropertyGroupName, group.Name)
+ })
+
+ t.Run("GetPropertyGroup maps deprecated name to canonical name", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+
+ api := th.SetupPluginAPI()
+
+ // The access_control group is registered at server startup, so
+ // we can look it up directly.
+ canonical, err := api.GetPropertyGroup(model.AccessControlPropertyGroupName)
+ require.NoError(t, err)
+ require.NotNil(t, canonical)
+
+ // Looking up by the deprecated name should return the same group
+ deprecated, err := api.GetPropertyGroup(model.DeprecatedCPAPropertyGroupName)
+ require.NoError(t, err)
+ require.NotNil(t, deprecated)
+
+ assert.Equal(t, canonical.ID, deprecated.ID)
+ assert.Equal(t, model.AccessControlPropertyGroupName, deprecated.Name)
+ })
+
+ t.Run("other group names are not affected by the mapping", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+
+ api := th.SetupPluginAPI()
+
+ // Register a different group — no mapping should occur
+ group, err := api.RegisterPropertyGroup("my_plugin_group")
+ require.NoError(t, err)
+ require.NotNil(t, group)
+ assert.Equal(t, "my_plugin_group", group.Name)
+
+ // Look it up
+ fetched, err := api.GetPropertyGroup("my_plugin_group")
+ require.NoError(t, err)
+ assert.Equal(t, group.ID, fetched.ID)
+ })
+
+ t.Run("GetPropertyGroup with nonexistent name returns error", func(t *testing.T) {
+ th := Setup(t).InitBasic(t)
+
+ api := th.SetupPluginAPI()
+
+ _, err := api.GetPropertyGroup("no_such_group")
+ require.Error(t, err)
+ })
+}
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/plugin_properties_test.go b/server/channels/app/plugin_properties_test.go
index d945e802823..67e742be59f 100644
--- a/server/channels/app/plugin_properties_test.go
+++ b/server/channels/app/plugin_properties_test.go
@@ -9,14 +9,16 @@ import (
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
)
// cleanupCPAFields deletes all existing CPA fields to ensure a clean state
func cleanupCPAFields(t *testing.T, th *TestHelper) {
t.Helper()
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
fields, searchErr := th.App.Srv().Store().PropertyField().SearchPropertyFields(model.PropertyFieldSearchOpts{
GroupID: cpaID,
@@ -33,6 +35,11 @@ func cleanupCPAFields(t *testing.T, th *TestHelper) {
func TestPluginProperties(t *testing.T) {
th := Setup(t).InitBasic(t)
+ // Subtests that exercise the access_control group require an
+ // Enterprise license because LicenseCheckHook gates that group.
+ th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
+ t.Cleanup(func() { _ = th.App.Srv().RemoveLicense() })
+
t.Run("test property field methods", func(t *testing.T) {
groupName := model.NewId()
tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{`
@@ -457,8 +464,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin-created CPA field gets source_plugin_id", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{`
package main
@@ -476,9 +484,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
// Create a CPA field
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "CPA Test Field",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "cpa_test_field",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdField, err := p.API.CreatePropertyField(field)
@@ -521,8 +531,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin can update its own protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{`
package main
@@ -540,9 +551,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
// Create a protected CPA field
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Protected Field",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "protected_field",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -554,13 +567,13 @@ func TestPluginProperties(t *testing.T) {
}
// Try to update the protected field (should succeed since we created it)
- createdField.Name = "Updated Protected Field"
+ createdField.Name = "updated_protected_field"
updatedField, err := p.API.UpdatePropertyField("` + cpaID + `", createdField)
if err != nil {
return fmt.Errorf("failed to update own protected field: %w", err)
}
- if updatedField.Name != "Updated Protected Field" {
+ if updatedField.Name != "updated_protected_field" {
return fmt.Errorf("field name not updated correctly")
}
@@ -585,8 +598,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin cannot update another plugin's protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
// Both plugins in same environment
tearDown, _, activationErrors := SetAppEnvironmentWithPlugins(t, []string{
@@ -607,9 +621,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
// Create a protected CPA field
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Plugin1 Protected Field",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "plugin1_protected_field",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -650,7 +666,7 @@ func TestPluginProperties(t *testing.T) {
var plugin1Field *model.PropertyField
for _, field := range fields {
- if field.Name == "Plugin1 Protected Field" {
+ if field.Name == "plugin1_protected_field" {
plugin1Field = field
break
}
@@ -685,8 +701,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin can delete its own protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{`
package main
@@ -704,9 +721,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
// Create a protected CPA field
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Field To Delete",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "field_to_delete",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -744,8 +763,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin cannot delete another plugin's protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
// Both plugins in same environment
tearDown, _, activationErrors := SetAppEnvironmentWithPlugins(t, []string{
@@ -765,9 +785,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Plugin1 Field To Keep",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "plugin1_field_to_keep",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -808,7 +830,7 @@ func TestPluginProperties(t *testing.T) {
var plugin1Field *model.PropertyField
for _, field := range fields {
- if field.Name == "Plugin1 Field To Keep" {
+ if field.Name == "plugin1_field_to_keep" {
plugin1Field = field
break
}
@@ -842,8 +864,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin can update values for its own protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
tearDown, pluginIDs, activationErrors := SetAppEnvironmentWithPlugins(t, []string{`
package main
@@ -861,9 +884,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
// Create a protected CPA field
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Protected Field With Values",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "protected_field_with_values",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -921,8 +946,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin cannot update values for another plugin's protected field", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
testTargetID := model.NewId()
@@ -944,9 +970,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Plugin1 Field With Protected Values",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "plugin1_field_with_protected_values",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: map[string]any{
"protected": true,
},
@@ -1001,7 +1029,7 @@ func TestPluginProperties(t *testing.T) {
var plugin1Field *model.PropertyField
for _, field := range fields {
- if field.Name == "Plugin1 Field With Protected Values" {
+ if field.Name == "plugin1_field_with_protected_values" {
plugin1Field = field
break
}
@@ -1043,8 +1071,9 @@ func TestPluginProperties(t *testing.T) {
t.Run("test plugin can modify non-protected CPA fields from other plugins", func(t *testing.T) {
cleanupCPAFields(t, th)
- cpaID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ cpaGroup, groupErr := th.App.GetPropertyGroup(request.TestContext(t), model.AccessControlPropertyGroupName)
+ require.Nil(t, groupErr)
+ cpaID := cpaGroup.ID
// Both plugins in same environment
tearDown, _, activationErrors := SetAppEnvironmentWithPlugins(t, []string{
@@ -1064,9 +1093,11 @@ func TestPluginProperties(t *testing.T) {
func (p *MyPlugin) OnActivate() error {
field := &model.PropertyField{
- GroupID: "` + cpaID + `",
- Name: "Non-Protected Field",
- Type: model.PropertyFieldTypeText,
+ GroupID: "` + cpaID + `",
+ Name: "non_protected_field",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
// Note: protected is not set
}
@@ -1105,7 +1136,7 @@ func TestPluginProperties(t *testing.T) {
var plugin1Field *model.PropertyField
for _, field := range fields {
- if field.Name == "Non-Protected Field" {
+ if field.Name == "non_protected_field" {
plugin1Field = field
break
}
@@ -1116,7 +1147,7 @@ func TestPluginProperties(t *testing.T) {
}
// Update it (should succeed since it's not protected)
- plugin1Field.Name = "Modified By Plugin2"
+ plugin1Field.Name = "modified_by_plugin2"
_, err = p.API.UpdatePropertyField("` + cpaID + `", plugin1Field)
if err != nil {
return fmt.Errorf("failed to update non-protected field: %w", err)
@@ -1136,12 +1167,15 @@ func TestPluginProperties(t *testing.T) {
require.NoError(t, activationErrors[1])
// Verify the field was actually updated
- rctx := th.emptyContextWithCallerID(anonymousCallerId)
- updatedFields, appErr := th.App.ListCPAFields(rctx)
+ updatedFields, appErr := th.App.SearchPropertyFields(request.TestContext(t), cpaID, model.PropertyFieldSearchOpts{
+ GroupID: cpaID,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ PerPage: model.AccessControlGroupFieldLimit + 5,
+ })
require.Nil(t, appErr)
var fieldWasUpdated bool
for _, field := range updatedFields {
- if field.Name == "Modified By Plugin2" {
+ if field.Name == "modified_by_plugin2" {
fieldWasUpdated = true
break
}
diff --git a/server/channels/app/plugin_requests.go b/server/channels/app/plugin_requests.go
index ef033f4888d..ff535237357 100644
--- a/server/channels/app/plugin_requests.go
+++ b/server/channels/app/plugin_requests.go
@@ -232,7 +232,7 @@ func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, h
session, appErr := app.GetSession(token)
if appErr != nil {
if appErr.StatusCode == http.StatusInternalServerError {
- handleInternalServerError(rctx, "Internal server error while loading session", err)
+ handleInternalServerError(rctx, "Internal server error while loading session", appErr)
return
}
rctx.Logger().Debug("Token in plugin request is invalid. Treating request as unauthenticated",
@@ -254,7 +254,7 @@ func (ch *Channels) servePluginRequest(w http.ResponseWriter, r *http.Request, h
// If MFA is required and user has not activated it, treat it as unauthenticated
if appErr := app.MFARequired(rctx); appErr != nil {
if appErr.StatusCode == http.StatusInternalServerError {
- handleInternalServerError(rctx, "Internal server error during MFA validation", err)
+ handleInternalServerError(rctx, "Internal server error during MFA validation", appErr)
return
}
rctx.Logger().Warn("Treating session as unauthenticated since MFA required",
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.go b/server/channels/app/properties/access_control.go
index 63fd0f6b608..944644a0ceb 100644
--- a/server/channels/app/properties/access_control.go
+++ b/server/channels/app/properties/access_control.go
@@ -21,18 +21,25 @@ package properties
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"maps"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
+var (
+ ErrAccessDenied = errors.New("access denied")
+ ErrSyncLocked = errors.New("field is managed by external sync")
+ ErrInvalidAccessMode = errors.New("invalid access_mode")
+ ErrFieldNotFound = errors.New("property field not found")
+)
+
const (
- // propertyAccessPaginationPageSize is the default page size for pagination when fetching property values
- propertyAccessPaginationPageSize = 100
- // propertyAccessMaxPaginationIterations is the maximum number of pagination iterations before returning an error
+ propertyAccessPaginationPageSize = 100
propertyAccessMaxPaginationIterations = 10
)
@@ -40,87 +47,93 @@ const (
// Returns true if the plugin exists and is installed, false otherwise.
type PluginChecker func(pluginID string) bool
-// PropertyAccessService is a layer around PropertyService that enforces access
-// control based on caller identity. All property operations go through this
-// service to ensure consistent access control enforcement.
-type PropertyAccessService struct {
+// AccessControlHook implements the PropertyHook interface to enforce access
+// control based on caller identity. It checks protected fields, plugin
+// ownership, and access modes (public, source-only, shared-only).
+//
+// The hook only applies to groups whose IDs are in managedGroupIDs. Operations
+// on other groups pass through without access control checks.
+type AccessControlHook struct {
propertyService *PropertyService
pluginChecker PluginChecker
+ managedGroupIDs map[string]struct{}
}
-// NewPropertyAccessService creates a new PropertyAccessService.
-// It receives the PropertyService to call private methods for database operations.
-// The pluginChecker function is used to verify plugin installation status when checking access
-// to protected fields. Pass nil if plugin checking is not needed (e.g., in tests).
-func NewPropertyAccessService(ps *PropertyService, pluginChecker PluginChecker) *PropertyAccessService {
- return &PropertyAccessService{
+// Compile-time check that AccessControlHook implements PropertyHook.
+var _ PropertyHook = (*AccessControlHook)(nil)
+
+// NewAccessControlHook creates a new AccessControlHook.
+// It receives the PropertyService to call private methods for database lookups
+// needed during access control checks. The pluginChecker function is used to
+// verify plugin installation status when checking access to protected fields.
+// Pass nil for pluginChecker if plugin checking is not needed (e.g., in tests).
+// managedGroupIDs lists the property group IDs that this hook enforces access
+// control for. Operations on groups not in this list are passed through.
+func NewAccessControlHook(ps *PropertyService, pluginChecker PluginChecker, managedGroupIDs ...string) *AccessControlHook {
+ ids := make(map[string]struct{}, len(managedGroupIDs))
+ for _, id := range managedGroupIDs {
+ ids[id] = struct{}{}
+ }
+ return &AccessControlHook{
propertyService: ps,
pluginChecker: pluginChecker,
+ managedGroupIDs: ids,
}
}
-func (pas *PropertyAccessService) setPluginCheckerForTests(pluginChecker PluginChecker) {
- pas.pluginChecker = pluginChecker
+// isGroupManaged checks whether the given group ID is managed by this hook.
+func (h *AccessControlHook) isGroupManaged(groupID string) bool {
+ _, ok := h.managedGroupIDs[groupID]
+ return ok
}
-// Property Field Methods
+// Field Pre-Hooks
-// isCallerPlugin checks whether the callerID corresponds to an installed plugin.
-func (pas *PropertyAccessService) isCallerPlugin(callerID string) bool {
- return callerID != "" && pas.pluginChecker != nil && pas.pluginChecker(callerID)
-}
-
-// CreatePropertyField creates a new property field with access control.
-// When the caller is an installed plugin, source_plugin_id is automatically set
-// to the callerID and the protected attribute is allowed.
-// When the caller is not a plugin, source_plugin_id and protected are rejected
-// to prevent unauthorized field ownership claims.
+// PreCreatePropertyField enforces access control on field creation.
+// When the caller is an installed plugin, source_plugin_id is automatically set.
+// When the caller is not a plugin, source_plugin_id and protected are rejected.
// When linking to a source template, security attributes are validated and
// inherited from the source.
-func (pas *PropertyAccessService) CreatePropertyField(callerID string, field *model.PropertyField) (*model.PropertyField, error) {
- if pas.isCallerPlugin(callerID) {
- // Caller is a plugin — auto-set source_plugin_id
+func (h *AccessControlHook) PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if !h.isGroupManaged(field.GroupID) {
+ return field, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ if h.isCallerPlugin(callerID) {
if field.Attrs == nil {
field.Attrs = make(model.StringInterface)
}
field.Attrs[model.PropertyAttrsSourcePluginID] = callerID
} else {
- // Non-plugin caller — reject source_plugin_id and protected
- if pas.getSourcePluginID(field) != "" {
- return nil, fmt.Errorf("CreatePropertyField: source_plugin_id can only be set by a plugin")
+ if h.getSourcePluginID(field) != "" {
+ return nil, fmt.Errorf("source_plugin_id can only be set by a plugin: %w", ErrAccessDenied)
}
if model.IsPropertyFieldProtected(field) {
- return nil, fmt.Errorf("CreatePropertyField: protected can only be set by a plugin")
+ return nil, fmt.Errorf("protected can only be set by a plugin: %w", ErrAccessDenied)
}
}
- // If linking to a source, validate and inherit security attributes
if field.LinkedFieldID != nil && *field.LinkedFieldID != "" {
- if err := pas.validateAndInheritLinkedFieldSecurity(callerID, field); err != nil {
- return nil, fmt.Errorf("CreatePropertyField: %w", err)
+ if err := h.validateAndInheritLinkedFieldSecurity(callerID, field); err != nil {
+ return nil, fmt.Errorf("PreCreatePropertyField: %w", err)
}
}
- // Validate access mode (after inheritance so protected flag is correct)
if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
- return nil, fmt.Errorf("CreatePropertyField: %w", err)
+ return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidAccessMode)
}
- result, err := pas.propertyService.createPropertyField(field)
- if err != nil {
- return nil, fmt.Errorf("CreatePropertyField: %w", err)
- }
- return result, nil
+ return field, nil
}
-// validateAndInheritLinkedFieldSecurity enforces that linked fields inherit the
-// source template's security posture. If the source is protected, only the
-// source plugin may create linked fields. The linked field's access_mode must
-// match the source's — divergence is rejected to avoid a false sense of
-// security (callers can always inspect the template directly).
-// Inherits: Attrs[protected], Attrs[source_plugin_id], Attrs[access_mode].
-func (pas *PropertyAccessService) validateAndInheritLinkedFieldSecurity(callerID string, field *model.PropertyField) error {
- source, err := pas.propertyService.getPropertyFieldFromMaster("", *field.LinkedFieldID)
+// validateAndInheritLinkedFieldSecurity enforces that linked fields inherit
+// the source template's security posture. If the source is protected, only
+// the source plugin may create linked fields. Security attrs (protected,
+// source_plugin_id, access_mode) are copied from the source onto the field.
+func (h *AccessControlHook) validateAndInheritLinkedFieldSecurity(callerID string, field *model.PropertyField) error {
+ source, err := h.propertyService.getPropertyFieldFromMaster("", *field.LinkedFieldID)
if err != nil {
if store.IsErrNotFound(err) {
return model.NewAppError(
@@ -138,7 +151,7 @@ func (pas *PropertyAccessService) validateAndInheritLinkedFieldSecurity(callerID
return nil
}
- sourcePluginID := pas.getSourcePluginID(source)
+ sourcePluginID := h.getSourcePluginID(source)
if sourcePluginID == "" || callerID != sourcePluginID {
return model.NewAppError(
"CreatePropertyField",
@@ -162,428 +175,318 @@ func (pas *PropertyAccessService) validateAndInheritLinkedFieldSecurity(callerID
return nil
}
-// GetPropertyField retrieves a property field by group and field ID.
-// Field details are filtered based on the caller's access permissions.
-func (pas *PropertyAccessService) GetPropertyField(callerID string, groupID, id string) (*model.PropertyField, error) {
- field, err := pas.propertyService.getPropertyField(groupID, id)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyField: %w", err)
- }
-
- return pas.applyFieldReadAccessControl(field, callerID), nil
-}
-
-// GetPropertyFields retrieves multiple property fields by their IDs.
-// Field details are filtered based on the caller's access permissions.
-func (pas *PropertyAccessService) GetPropertyFields(callerID string, groupID string, ids []string) ([]*model.PropertyField, error) {
- fields, err := pas.propertyService.getPropertyFields(groupID, ids)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyFields: %w", err)
- }
-
- return pas.applyFieldReadAccessControlToList(fields, callerID), nil
-}
-
-// GetPropertyFieldByName retrieves a property field by name.
-// Field details are filtered based on the caller's access permissions.
-func (pas *PropertyAccessService) GetPropertyFieldByName(callerID string, groupID, targetID, name string) (*model.PropertyField, error) {
- field, err := pas.propertyService.getPropertyFieldByName(groupID, targetID, name)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyFieldByName: %w", err)
- }
-
- return pas.applyFieldReadAccessControl(field, callerID), nil
-}
-
-// CountActivePropertyFieldsForGroup counts active property fields for a group.
-func (pas *PropertyAccessService) CountActivePropertyFieldsForGroup(groupID string) (int64, error) {
- return pas.propertyService.countActivePropertyFieldsForGroup(groupID)
-}
-
-// CountAllPropertyFieldsForGroup counts all property fields (including deleted) for a group.
-func (pas *PropertyAccessService) CountAllPropertyFieldsForGroup(groupID string) (int64, error) {
- return pas.propertyService.countAllPropertyFieldsForGroup(groupID)
-}
-
-// CountActivePropertyFieldsForTarget counts active property fields for a specific target.
-func (pas *PropertyAccessService) CountActivePropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
- return pas.propertyService.countActivePropertyFieldsForTarget(groupID, targetType, targetID)
-}
-
-// CountAllPropertyFieldsForTarget counts all property fields (including deleted) for a specific target.
-func (pas *PropertyAccessService) CountAllPropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
- return pas.propertyService.countAllPropertyFieldsForTarget(groupID, targetType, targetID)
-}
-
-// SearchPropertyFields searches for property fields based on the given options.
-// Field details are filtered based on the caller's access permissions.
-func (pas *PropertyAccessService) SearchPropertyFields(callerID string, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
- fields, err := pas.propertyService.searchPropertyFields(groupID, opts)
- if err != nil {
- return nil, fmt.Errorf("SearchPropertyFields: %w", err)
- }
-
- return pas.applyFieldReadAccessControlToList(fields, callerID), nil
-}
-
-// UpdatePropertyField updates a property field.
+// PreUpdatePropertyField enforces access control on field updates.
// Checks write access and ensures source_plugin_id is not changed.
-func (pas *PropertyAccessService) UpdatePropertyField(callerID string, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
- // Get existing field to check access
- existingField, existsErr := pas.propertyService.getPropertyField(groupID, field.ID)
- if existsErr != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", existsErr)
+func (h *AccessControlHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
+ if !h.isGroupManaged(groupID) {
+ return field, nil
}
- // Check write access
- if err := pas.checkFieldWriteAccess(existingField, callerID); err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
- }
+ callerID := h.extractCallerID(rctx)
- // Ensure source_plugin_id hasn't changed
- if err := pas.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
- }
-
- // Validate protected field update
- if err := pas.validateProtectedFieldUpdate(field, callerID); err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
- }
-
- // Validate access mode
- if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
- }
-
- result, err := pas.propertyService.updatePropertyField(groupID, field)
+ existingField, err := h.propertyService.getPropertyField(groupID, field.ID)
if err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
+ return nil, err
}
- return result, nil
+
+ if err := h.checkFieldWriteAccess(existingField, callerID); err != nil {
+ return nil, err
+ }
+
+ if err := h.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
+ return nil, err
+ }
+
+ if err := h.validateProtectedFieldUpdate(field, callerID); err != nil {
+ return nil, err
+ }
+
+ if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
+ return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidAccessMode)
+ }
+
+ return field, nil
}
-// UpdatePropertyFields updates multiple property fields.
-// Checks write access for all fields atomically before updating any.
-func (pas *PropertyAccessService) UpdatePropertyFields(callerID string, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, error) {
- if len(fields) == 0 {
- return fields, nil, nil
+// PreUpdatePropertyFields enforces access control on batch field updates.
+// Checks write access for all fields atomically before allowing any updates.
+func (h *AccessControlHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if len(fields) == 0 || !h.isGroupManaged(groupID) {
+ return fields, nil
}
+ callerID := h.extractCallerID(rctx)
+
// Get field IDs
fieldIDs := make([]string, len(fields))
for i, field := range fields {
fieldIDs[i] = field.ID
}
- // Fetch existing fields
- existingFields, existsErr := pas.propertyService.getPropertyFields(groupID, fieldIDs)
- if existsErr != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: %w", existsErr)
+ existingFields, err := h.propertyService.getPropertyFields(groupID, fieldIDs)
+ if err != nil {
+ return nil, err
}
- // Build map for easy lookup
existingFieldMap := make(map[string]*model.PropertyField, len(existingFields))
for _, field := range existingFields {
existingFieldMap[field.ID] = field
}
- // Check write access for all fields before updating any
for _, field := range fields {
existingField, exists := existingFieldMap[field.ID]
if !exists {
- return nil, nil, fmt.Errorf("field %s not found", field.ID)
+ return nil, fmt.Errorf("field %s: %w", field.ID, ErrFieldNotFound)
}
- // Check write access
- if err := pas.checkFieldWriteAccess(existingField, callerID); err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
+ if err := h.checkFieldWriteAccess(existingField, callerID); err != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, err)
}
- // Ensure source_plugin_id hasn't changed
- if err := pas.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
+ if err := h.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, err)
}
- // Validate protected field update
- if err := pas.validateProtectedFieldUpdate(field, callerID); err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
+ if err := h.validateProtectedFieldUpdate(field, callerID); err != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, err)
}
- // Validate access mode
if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
+ return nil, fmt.Errorf("field %s: %s: %w", field.ID, err.Error(), ErrInvalidAccessMode)
}
}
- // All checks passed - proceed with update
- requested, propagated, err := pas.propertyService.updatePropertyFields(groupID, fields)
- if err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: %w", err)
- }
- return requested, propagated, nil
+ return fields, nil
}
-// DeletePropertyField deletes a property field and all its values.
-// Checks delete access before allowing deletion.
-func (pas *PropertyAccessService) DeletePropertyField(callerID string, groupID, id string) error {
- // Get existing field to check access
- existingField, err := pas.propertyService.getPropertyField(groupID, id)
- if err != nil {
- return fmt.Errorf("DeletePropertyField: %w", err)
- }
-
- // Check delete access
- if err := pas.checkFieldDeleteAccess(existingField, callerID); err != nil {
- return fmt.Errorf("DeletePropertyField: %w", err)
- }
-
- if err := pas.propertyService.deletePropertyField(groupID, id); err != nil {
- return fmt.Errorf("DeletePropertyField: %w", err)
- }
+// PreCountPropertyFields is a no-op — counts don't expose per-row metadata,
+// so access control doesn't apply. License gating happens in LicenseCheckHook.
+func (h *AccessControlHook) PreCountPropertyFields(_ request.CTX, _ string) error {
return nil
}
-// Property Value Methods
-
-// CreatePropertyValue creates a new property value.
-// Checks write access before allowing the creation.
-func (pas *PropertyAccessService) CreatePropertyValue(callerID string, value *model.PropertyValue) (*model.PropertyValue, error) {
- // Get the associated field to check access
- field, err := pas.propertyService.getPropertyField(value.GroupID, value.FieldID)
- if err != nil {
- return nil, fmt.Errorf("CreatePropertyValue: %w", err)
- }
-
- // Check write access
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("CreatePropertyValue: %w", err)
- }
-
- result, err := pas.propertyService.createPropertyValue(value)
- if err != nil {
- return nil, fmt.Errorf("CreatePropertyValue: %w", err)
- }
- return result, nil
-}
-
-// CreatePropertyValues creates multiple property values.
-// Checks write access for all fields atomically before creating any values.
-func (pas *PropertyAccessService) CreatePropertyValues(callerID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
- fieldMap, err := pas.getFieldsForValues(values)
- if err != nil {
- return nil, fmt.Errorf("CreatePropertyValues: %w", err)
- }
-
- // Check write access for all fields before creating any values
- for _, value := range values {
- field, exists := fieldMap[value.FieldID]
- if !exists {
- return nil, fmt.Errorf("CreatePropertyValues: field %s not found", value.FieldID)
- }
-
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("CreatePropertyValues: field %s: %w", value.FieldID, err)
- }
- }
-
- // All checks passed - proceed with creation
- result, err := pas.propertyService.createPropertyValues(values)
- if err != nil {
- return nil, fmt.Errorf("CreatePropertyValues: %w", err)
- }
- return result, nil
-}
-
-// GetPropertyValue retrieves a property value by ID.
-// Returns (nil, nil) if the value exists but the caller doesn't have access.
-func (pas *PropertyAccessService) GetPropertyValue(callerID string, groupID, id string) (*model.PropertyValue, error) {
- value, err := pas.propertyService.getPropertyValue(groupID, id)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyValue: %w", err)
- }
-
- // Apply access control filtering
- filtered, err := pas.applyValueReadAccessControl([]*model.PropertyValue{value}, callerID)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyValue: %w", err)
- }
-
- // If the value was filtered out, return nil
- if len(filtered) == 0 {
- return nil, nil
- }
-
- return filtered[0], nil
-}
-
-// GetPropertyValues retrieves multiple property values by their IDs.
-// Values the caller doesn't have access to are silently filtered out.
-func (pas *PropertyAccessService) GetPropertyValues(callerID string, groupID string, ids []string) ([]*model.PropertyValue, error) {
- values, err := pas.propertyService.getPropertyValues(groupID, ids)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyValues: %w", err)
- }
-
- // Apply access control filtering
- filtered, err := pas.applyValueReadAccessControl(values, callerID)
- if err != nil {
- return nil, fmt.Errorf("GetPropertyValues: %w", err)
- }
- return filtered, nil
-}
-
-// SearchPropertyValues searches for property values based on the given options.
-// Values the caller doesn't have access to are silently filtered out.
-func (pas *PropertyAccessService) SearchPropertyValues(callerID string, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
- values, err := pas.propertyService.searchPropertyValues(groupID, opts)
- if err != nil {
- return nil, fmt.Errorf("SearchPropertyValues: %w", err)
- }
-
- // Apply access control filtering
- filtered, err := pas.applyValueReadAccessControl(values, callerID)
- if err != nil {
- return nil, fmt.Errorf("SearchPropertyValues: %w", err)
- }
- return filtered, nil
-}
-
-// UpdatePropertyValue updates a property value.
-// Checks write access before allowing the update.
-func (pas *PropertyAccessService) UpdatePropertyValue(callerID string, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
- // Get the associated field to check access
- field, err := pas.propertyService.getPropertyField(groupID, value.FieldID)
- if err != nil {
- return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
- }
-
- // Check write access
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
- }
-
- result, err := pas.propertyService.updatePropertyValue(groupID, value)
- if err != nil {
- return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
- }
- return result, nil
-}
-
-// UpdatePropertyValues updates multiple property values.
-// Checks write access for all fields atomically before updating any values.
-func (pas *PropertyAccessService) UpdatePropertyValues(callerID string, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
- if len(values) == 0 {
- return values, nil
- }
-
- fieldMap, err := pas.getFieldsForValues(values)
- if err != nil {
- return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
- }
-
- // Check write access for all fields before updating any values
- for _, value := range values {
- field, exists := fieldMap[value.FieldID]
- if !exists {
- return nil, fmt.Errorf("UpdatePropertyValues: field %s not found", value.FieldID)
- }
-
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("UpdatePropertyValues: field %s: %w", value.FieldID, err)
- }
- }
-
- // All checks passed - proceed with update
- result, err := pas.propertyService.updatePropertyValues(groupID, values)
- if err != nil {
- return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
- }
- return result, nil
-}
-
-// UpsertPropertyValue creates or updates a property value.
-// Checks write access before allowing the upsert.
-func (pas *PropertyAccessService) UpsertPropertyValue(callerID string, value *model.PropertyValue) (*model.PropertyValue, error) {
- // Get the associated field to check access
- field, err := pas.propertyService.getPropertyField(value.GroupID, value.FieldID)
- if err != nil {
- return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
- }
-
- // Check write access (works for both create and update)
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
- }
-
- result, err := pas.propertyService.upsertPropertyValue(value)
- if err != nil {
- return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
- }
- return result, nil
-}
-
-// UpsertPropertyValues creates or updates multiple property values.
-// Checks write access for all fields atomically before upserting any values.
-func (pas *PropertyAccessService) UpsertPropertyValues(callerID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
- if len(values) == 0 {
- return values, nil
- }
-
- fieldMap, err := pas.getFieldsForValues(values)
- if err != nil {
- return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
- }
-
- // Check write access for all fields before upserting any values
- for _, value := range values {
- field, exists := fieldMap[value.FieldID]
- if !exists {
- return nil, fmt.Errorf("UpsertPropertyValues: field %s not found", value.FieldID)
- }
-
- if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
- return nil, fmt.Errorf("UpsertPropertyValues: field %s: %w", value.FieldID, err)
- }
- }
-
- // All checks passed - proceed with upsert
- result, err := pas.propertyService.upsertPropertyValues(values)
- if err != nil {
- return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
- }
- return result, nil
-}
-
-// DeletePropertyValue deletes a property value.
-// Checks write access before allowing deletion.
-func (pas *PropertyAccessService) DeletePropertyValue(callerID string, groupID, id string) error {
- // Get the value to find its field ID
- value, err := pas.propertyService.getPropertyValue(groupID, id)
- if err != nil {
- // Value doesn't exist - return nil to match original behavior
+// PreDeletePropertyField enforces access control on field deletion.
+func (h *AccessControlHook) PreDeletePropertyField(rctx request.CTX, groupID string, id string) error {
+ if !h.isGroupManaged(groupID) {
return nil
}
- // Get the associated field to check access
- field, err := pas.propertyService.getPropertyField(groupID, value.FieldID)
+ callerID := h.extractCallerID(rctx)
+
+ existingField, err := h.propertyService.getPropertyField(groupID, id)
if err != nil {
- return fmt.Errorf("DeletePropertyValue: %w", err)
+ return err
}
- // Check write access
- if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
- return fmt.Errorf("DeletePropertyValue: %w", err)
- }
-
- if err := pas.propertyService.deletePropertyValue(groupID, id); err != nil {
- return fmt.Errorf("DeletePropertyValue: %w", err)
- }
- return nil
+ return h.checkFieldDeleteAccess(existingField, callerID)
}
-// DeletePropertyValuesForTarget deletes all property values for a specific target.
-// Checks write access for all affected fields atomically before deleting.
-func (pas *PropertyAccessService) DeletePropertyValuesForTarget(callerID string, groupID string, targetType string, targetID string) error {
+// PostUpdatePropertyFields is a no-op for access control; cleanup of dependent
+// values is handled by TypeChangeValueCleanupHook.
+func (h *AccessControlHook) PostUpdatePropertyFields(_ request.CTX, _ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ return requested, propagated, nil, nil
+}
+
+// Field Post-Hooks
+
+// PostGetPropertyField applies read access control to a single field.
+func (h *AccessControlHook) PostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if !h.isGroupManaged(field.GroupID) {
+ return field, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+ return h.applyFieldReadAccessControl(field, callerID), nil
+}
+
+// PostGetPropertyFields applies read access control to a list of fields.
+// All fields in a batch share the same GroupID (enforced by the public API).
+func (h *AccessControlHook) PostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if len(fields) == 0 {
+ return fields, nil
+ }
+
+ if !h.isGroupManaged(fields[0].GroupID) {
+ return fields, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+ return h.applyFieldReadAccessControlToList(fields, callerID), nil
+}
+
+// Value Pre-Hooks
+
+// PreCreatePropertyValue enforces write access and sync locking on the value's field before creation.
+func (h *AccessControlHook) PreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if !h.isGroupManaged(value.GroupID) {
+ return value, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ field, err := h.propertyService.getPropertyField(value.GroupID, value.FieldID)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, err
+ }
+
+ return value, nil
+}
+
+// PreCreatePropertyValues enforces write access and sync locking for all fields atomically before creation.
+// All values in a batch share the same GroupID (enforced by the public API).
+func (h *AccessControlHook) PreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) {
+ return values, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ fieldMap, err := h.getFieldsForValues(values)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, value := range values {
+ field, exists := fieldMap[value.FieldID]
+ if !exists {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound)
+ }
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, err)
+ }
+ }
+
+ return values, nil
+}
+
+// PreUpdatePropertyValue enforces write access and sync locking on the value's field before update.
+func (h *AccessControlHook) PreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if !h.isGroupManaged(groupID) {
+ return value, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ field, err := h.propertyService.getPropertyField(groupID, value.FieldID)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, err
+ }
+
+ return value, nil
+}
+
+// PreUpdatePropertyValues enforces write access and sync locking for all fields atomically before update.
+// All values in a batch share the same GroupID (enforced by the public API).
+func (h *AccessControlHook) PreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 || !h.isGroupManaged(groupID) {
+ return values, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ fieldMap, err := h.getFieldsForValues(values)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, value := range values {
+ field, exists := fieldMap[value.FieldID]
+ if !exists {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound)
+ }
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, err)
+ }
+ }
+
+ return values, nil
+}
+
+// PreUpsertPropertyValue enforces write access and sync locking on the value's field before upsert.
+func (h *AccessControlHook) PreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if !h.isGroupManaged(value.GroupID) {
+ return value, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ field, err := h.propertyService.getPropertyField(value.GroupID, value.FieldID)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, err
+ }
+
+ return value, nil
+}
+
+// PreUpsertPropertyValues enforces write access and sync locking for all fields atomically before upsert.
+// All values in a batch share the same GroupID (enforced by the public API).
+func (h *AccessControlHook) PreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) {
+ return values, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ fieldMap, err := h.getFieldsForValues(values)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, value := range values {
+ field, exists := fieldMap[value.FieldID]
+ if !exists {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound)
+ }
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return nil, fmt.Errorf("field %s: %w", value.FieldID, err)
+ }
+ }
+
+ return values, nil
+}
+
+// PreDeletePropertyValue enforces write access before deleting a value.
+func (h *AccessControlHook) PreDeletePropertyValue(rctx request.CTX, groupID string, id string) error {
+ if !h.isGroupManaged(groupID) {
+ return nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ value, err := h.propertyService.getPropertyValue(groupID, id)
+ if err != nil {
+ return err
+ }
+
+ field, err := h.propertyService.getPropertyField(groupID, value.FieldID)
+ if err != nil {
+ return err
+ }
+
+ return h.checkValueWriteAccess(field, callerID)
+}
+
+// PreDeletePropertyValuesForTarget enforces write access for all affected fields
+// before deleting all values for a target.
+func (h *AccessControlHook) PreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
+ if !h.isGroupManaged(groupID) {
+ return nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
// Collect unique field IDs across all values without loading all values into memory
fieldIDs := make(map[string]struct{})
var cursor model.PropertyValueSearchCursor
@@ -592,7 +495,7 @@ func (pas *PropertyAccessService) DeletePropertyValuesForTarget(callerID string,
for {
iterations++
if iterations > propertyAccessMaxPaginationIterations {
- return fmt.Errorf("DeletePropertyValuesForTarget: exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
+ return fmt.Errorf("exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
}
opts := model.PropertyValueSearchOpts{
@@ -605,22 +508,19 @@ func (pas *PropertyAccessService) DeletePropertyValuesForTarget(callerID string,
opts.Cursor = cursor
}
- values, err := pas.propertyService.searchPropertyValues(groupID, opts)
+ values, err := h.propertyService.searchPropertyValues(groupID, opts)
if err != nil {
- return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
+ return err
}
- // Extract field IDs from this batch
for _, value := range values {
fieldIDs[value.FieldID] = struct{}{}
}
- // If we got fewer results than the page size, we're done
if len(values) < propertyAccessPaginationPageSize {
break
}
- // Update cursor for next page
lastValue := values[len(values)-1]
cursor = model.PropertyValueSearchCursor{
PropertyValueID: lastValue.ID,
@@ -629,62 +529,97 @@ func (pas *PropertyAccessService) DeletePropertyValuesForTarget(callerID string,
}
if len(fieldIDs) == 0 {
- // No values to delete - return nil to match original behavior
return nil
}
- // Convert map to slice
fieldIDSlice := make([]string, 0, len(fieldIDs))
for fieldID := range fieldIDs {
fieldIDSlice = append(fieldIDSlice, fieldID)
}
- // Fetch all fields
- fields, err := pas.propertyService.getPropertyFields(groupID, fieldIDSlice)
+ fields, err := h.propertyService.getPropertyFields(groupID, fieldIDSlice)
if err != nil {
- return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
+ return err
}
- // Check write access for all fields before deleting any values
for _, field := range fields {
- if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
- return fmt.Errorf("DeletePropertyValuesForTarget: field %s: %w", field.ID, err)
+ if err := h.checkValueWriteAccess(field, callerID); err != nil {
+ return fmt.Errorf("field %s: %w", field.ID, err)
}
}
- // All checks passed - proceed with deletion
- if err := pas.propertyService.deletePropertyValuesForTarget(groupID, targetType, targetID); err != nil {
- return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
- }
return nil
}
-// DeletePropertyValuesForField deletes all property values for a specific field.
-// Checks write access before allowing deletion.
-func (pas *PropertyAccessService) DeletePropertyValuesForField(callerID string, groupID, fieldID string) error {
- // Get the field to check access
- field, err := pas.propertyService.getPropertyField(groupID, fieldID)
- if err != nil {
- // Field doesn't exist - return nil to match original behavior
+// PreDeletePropertyValuesForField enforces write access before deleting all values for a field.
+func (h *AccessControlHook) PreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error {
+ if !h.isGroupManaged(groupID) {
return nil
}
- // Check write access
- if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
- return fmt.Errorf("DeletePropertyValuesForField: %w", err)
+ callerID := h.extractCallerID(rctx)
+
+ field, err := h.propertyService.getPropertyField(groupID, fieldID)
+ if err != nil {
+ return err
}
- if err := pas.propertyService.deletePropertyValuesForField(groupID, fieldID); err != nil {
- return fmt.Errorf("DeletePropertyValuesForField: %w", err)
+ return h.checkValueWriteAccess(field, callerID)
+}
+
+// Value Post-Hooks
+
+// PostGetPropertyValue applies read access control to a single value.
+// Returns nil if the caller doesn't have access.
+func (h *AccessControlHook) PostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if value == nil {
+ return nil, nil
}
- return nil
+ if !h.isGroupManaged(value.GroupID) {
+ return value, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ filtered, err := h.applyValueReadAccessControl([]*model.PropertyValue{value}, callerID)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(filtered) == 0 {
+ return nil, nil
+ }
+
+ return filtered[0], nil
+}
+
+// PostGetPropertyValues applies read access control to a list of values.
+// Values the caller doesn't have access to are silently filtered out.
+// All values in a batch share the same GroupID (enforced by the public API).
+func (h *AccessControlHook) PostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) {
+ return values, nil
+ }
+
+ callerID := h.extractCallerID(rctx)
+
+ return h.applyValueReadAccessControl(values, callerID)
}
// Access Control Helper Methods
+// extractCallerID gets the caller ID from a request context using the property service's extractor.
+func (h *AccessControlHook) extractCallerID(rctx request.CTX) string {
+ return h.propertyService.extractCallerID(rctx)
+}
+
+// isCallerPlugin checks whether the callerID corresponds to an installed plugin.
+func (h *AccessControlHook) isCallerPlugin(callerID string) bool {
+ return callerID != "" && h.pluginChecker != nil && h.pluginChecker(callerID)
+}
+
// getSourcePluginID extracts the source_plugin_id from a PropertyField's attrs.
-// Returns empty string if not set.
-func (pas *PropertyAccessService) getSourcePluginID(field *model.PropertyField) string {
+func (h *AccessControlHook) getSourcePluginID(field *model.PropertyField) string {
if field.Attrs == nil {
return ""
}
@@ -692,57 +627,60 @@ func (pas *PropertyAccessService) getSourcePluginID(field *model.PropertyField)
return sourcePluginID
}
-// checkUnrestrictedFieldReadAccess checks if the given caller can read a PropertyField without restrictions.
-// Returns true if the caller has unrestricted read access (public field or source plugin).
-// Returns an error if access requires filtering or should be denied entirely.
-func (pas *PropertyAccessService) hasUnrestrictedFieldReadAccess(field *model.PropertyField, callerID string) bool {
- accessMode := field.GetAccessMode()
+// getAccessMode extracts the access_mode from a PropertyField's attrs.
+func (h *AccessControlHook) getAccessMode(field *model.PropertyField) string {
+ if field.Attrs == nil {
+ return model.PropertyAccessModePublic
+ }
+ accessMode, ok := field.Attrs[model.PropertyAttrsAccessMode].(string)
+ if !ok {
+ return model.PropertyAccessModePublic
+ }
+ return accessMode
+}
+
+// hasUnrestrictedFieldReadAccess checks if the given caller can read a PropertyField without restrictions.
+// Returns true if the caller has unrestricted read access (public field or source plugin).
+func (h *AccessControlHook) hasUnrestrictedFieldReadAccess(field *model.PropertyField, callerID string) bool {
+ accessMode := h.getAccessMode(field)
- // Public fields are readable by everyone without restrictions
if accessMode == model.PropertyAccessModePublic {
return true
}
- // Source plugin always has unrestricted access to fields they created
- sourcePluginID := pas.getSourcePluginID(field)
+ sourcePluginID := h.getSourcePluginID(field)
if sourcePluginID != "" && sourcePluginID == callerID {
return true
}
- // All other cases require filtering or access denial
return false
}
// ensureSourcePluginIDUnchanged checks that the source_plugin_id attribute hasn't changed between fields.
-// Used during field updates to ensure source_plugin_id is immutable.
-// Returns nil if unchanged, or an error if source_plugin_id was modified.
-func (pas *PropertyAccessService) ensureSourcePluginIDUnchanged(existingField, updatedField *model.PropertyField) error {
- existingSourcePluginID := pas.getSourcePluginID(existingField)
- updatedSourcePluginID := pas.getSourcePluginID(updatedField)
+func (h *AccessControlHook) ensureSourcePluginIDUnchanged(existingField, updatedField *model.PropertyField) error {
+ existingSourcePluginID := h.getSourcePluginID(existingField)
+ updatedSourcePluginID := h.getSourcePluginID(updatedField)
if existingSourcePluginID != updatedSourcePluginID {
- return fmt.Errorf("source_plugin_id is immutable and cannot be changed from '%s' to '%s'", existingSourcePluginID, updatedSourcePluginID)
+ return fmt.Errorf("source_plugin_id is immutable and cannot be changed from '%s' to '%s': %w", existingSourcePluginID, updatedSourcePluginID, ErrAccessDenied)
}
return nil
}
// validateProtectedFieldUpdate validates that a field can be updated to protected=true.
-// Prevents creating orphaned protected fields (protected=true but no source_plugin_id).
-// Also ensures only the source plugin can set protected=true on fields with a source_plugin_id.
-// Returns nil if the update is valid, or an error if it should be rejected.
-func (pas *PropertyAccessService) validateProtectedFieldUpdate(updatedField *model.PropertyField, callerID string) error {
+func (h *AccessControlHook) validateProtectedFieldUpdate(updatedField *model.PropertyField, callerID string) error {
if !model.IsPropertyFieldProtected(updatedField) {
return nil
}
- sourcePluginID := pas.getSourcePluginID(updatedField)
+ sourcePluginID := h.getSourcePluginID(updatedField)
if sourcePluginID == "" {
- return fmt.Errorf("cannot set protected=true on a field without a source_plugin_id")
+ return fmt.Errorf("cannot set protected=true on a field without a source_plugin_id: %w", ErrAccessDenied)
}
if sourcePluginID != callerID {
- return fmt.Errorf("cannot set protected=true: only source plugin '%s' can modify this field", sourcePluginID)
+ return fmt.Errorf("cannot set protected=true: only source plugin '%s' can modify this field: %w", sourcePluginID, ErrAccessDenied)
}
return nil
@@ -750,21 +688,18 @@ func (pas *PropertyAccessService) validateProtectedFieldUpdate(updatedField *mod
// checkFieldWriteAccess checks if the given caller can modify a PropertyField.
// IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller.
-// Returns nil if modification is allowed, or an error if denied.
-func (pas *PropertyAccessService) checkFieldWriteAccess(field *model.PropertyField, callerID string) error {
- // Check if field is protected
+func (h *AccessControlHook) checkFieldWriteAccess(field *model.PropertyField, callerID string) error {
if !model.IsPropertyFieldProtected(field) {
return nil
}
- // Protected fields can only be modified by the source plugin
- sourcePluginID := pas.getSourcePluginID(field)
+ sourcePluginID := h.getSourcePluginID(field)
if sourcePluginID == "" {
- return fmt.Errorf("field %s is protected, but has no associated source plugin", field.ID)
+ return fmt.Errorf("field %s is protected, but has no associated source plugin: %w", field.ID, ErrAccessDenied)
}
if sourcePluginID != callerID {
- return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s'", field.ID, sourcePluginID)
+ return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s': %w", field.ID, sourcePluginID, ErrAccessDenied)
}
return nil
@@ -772,37 +707,66 @@ func (pas *PropertyAccessService) checkFieldWriteAccess(field *model.PropertyFie
// checkFieldDeleteAccess checks if the given caller can delete a PropertyField.
// IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller.
-// Returns nil if deletion is allowed, or an error if denied.
-func (pas *PropertyAccessService) checkFieldDeleteAccess(field *model.PropertyField, callerID string) error {
- // Check if field is protected
+func (h *AccessControlHook) checkFieldDeleteAccess(field *model.PropertyField, callerID string) error {
if !model.IsPropertyFieldProtected(field) {
return nil
}
- // Protected fields can only be deleted by the source plugin
- sourcePluginID := pas.getSourcePluginID(field)
+ sourcePluginID := h.getSourcePluginID(field)
if sourcePluginID == "" {
- // Protected field with no source plugin - allow deletion
return nil
}
- // Check if the source plugin is still installed
- if pas.pluginChecker != nil && !pas.pluginChecker(sourcePluginID) {
- // Plugin has been uninstalled - allow deletion of orphaned field
+ if h.pluginChecker != nil && !h.pluginChecker(sourcePluginID) {
return nil
}
if sourcePluginID != callerID {
- return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s'", field.ID, sourcePluginID)
+ return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s': %w", field.ID, sourcePluginID, ErrAccessDenied)
}
return nil
}
+// checkSyncLock checks whether the caller is allowed to write values for a
+// synced field. Synced fields have an ldap or saml attr set, and only the
+// corresponding sync service (identified by well-known caller IDs) may write
+// their values.
+func (h *AccessControlHook) checkSyncLock(field *model.PropertyField, callerID string) error {
+ syncSource := model.GetPropertyFieldSyncSource(field)
+ if syncSource == "" {
+ return nil
+ }
+
+ // Map sync source to the expected caller ID
+ var expectedCallerID string
+ switch syncSource {
+ case "ldap":
+ expectedCallerID = model.CallerIDLDAPSync
+ case "saml":
+ expectedCallerID = model.CallerIDSAMLSync
+ default:
+ return fmt.Errorf("field %s has unknown sync source %q: %w", field.ID, syncSource, ErrInvalidFieldAttrs)
+ }
+
+ if callerID != expectedCallerID {
+ return fmt.Errorf("field %s is managed by %s sync and cannot be modified by caller %q: %w", field.ID, syncSource, callerID, ErrSyncLocked)
+ }
+
+ return nil
+}
+
+// checkValueWriteAccess combines the protected-field write access check and
+// the sync lock check for value write operations.
+func (h *AccessControlHook) checkValueWriteAccess(field *model.PropertyField, callerID string) error {
+ if err := h.checkFieldWriteAccess(field, callerID); err != nil {
+ return err
+ }
+ return h.checkSyncLock(field, callerID)
+}
+
// getCallerValuesForField retrieves all property values for the caller on a specific field.
-// This is used internally for shared_only filtering.
-// Returns an empty slice if callerID is empty or if there are no values.
-func (pas *PropertyAccessService) getCallerValuesForField(groupID, fieldID, callerID string) ([]*model.PropertyValue, error) {
+func (h *AccessControlHook) getCallerValuesForField(groupID, fieldID, callerID string) ([]*model.PropertyValue, error) {
if callerID == "" {
return []*model.PropertyValue{}, nil
}
@@ -814,7 +778,7 @@ func (pas *PropertyAccessService) getCallerValuesForField(groupID, fieldID, call
for {
iterations++
if iterations > propertyAccessMaxPaginationIterations {
- return nil, fmt.Errorf("getCallerValuesForField: exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
+ return nil, fmt.Errorf("exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
}
opts := model.PropertyValueSearchOpts{
@@ -827,19 +791,17 @@ func (pas *PropertyAccessService) getCallerValuesForField(groupID, fieldID, call
opts.Cursor = cursor
}
- values, err := pas.propertyService.searchPropertyValues(groupID, opts)
+ values, err := h.propertyService.searchPropertyValues(groupID, opts)
if err != nil {
return nil, fmt.Errorf("failed to get caller values for field: %w", err)
}
allValues = append(allValues, values...)
- // If we got fewer results than the page size, we're done
if len(values) < propertyAccessPaginationPageSize {
break
}
- // Update cursor for next page
lastValue := values[len(values)-1]
cursor = model.PropertyValueSearchCursor{
PropertyValueID: lastValue.ID,
@@ -851,10 +813,7 @@ func (pas *PropertyAccessService) getCallerValuesForField(groupID, fieldID, call
}
// extractOptionIDsFromValue parses a JSON value and extracts option IDs into a set.
-// For select fields: returns a set with one option ID
-// For multiselect fields: returns a set with multiple option IDs
-// Returns nil if value is empty, or an error if field type is not select/multiselect.
-func (pas *PropertyAccessService) extractOptionIDsFromValue(fieldType model.PropertyFieldType, value []byte) (map[string]struct{}, error) {
+func (h *AccessControlHook) extractOptionIDsFromValue(fieldType model.PropertyFieldType, value []byte) (map[string]struct{}, error) {
if len(value) == 0 {
return nil, nil
}
@@ -889,8 +848,14 @@ func (pas *PropertyAccessService) extractOptionIDsFromValue(fieldType model.Prop
return optionIDs, nil
}
-// copyPropertyField creates a deep copy of a PropertyField, including its Attrs map.
-func (pas *PropertyAccessService) copyPropertyField(field *model.PropertyField) *model.PropertyField {
+// copyPropertyField returns a copy of a PropertyField with a fresh Attrs map.
+// The Attrs copy is shallow: nested slices/maps (notably Attrs["options"])
+// share backing storage with the original. That is safe today because
+// filterSharedOnlyFieldOptions replaces Attrs["options"] wholesale rather
+// than mutating in place. A future hook that mutates a nested value in the
+// returned copy would also mutate the caller's original — deep-copy those
+// entries if that changes.
+func (h *AccessControlHook) copyPropertyField(field *model.PropertyField) *model.PropertyField {
copied := *field
copied.Attrs = make(model.StringInterface)
if field.Attrs != nil {
@@ -900,10 +865,8 @@ func (pas *PropertyAccessService) copyPropertyField(field *model.PropertyField)
}
// getCallerOptionIDsForField retrieves the caller's values for a field and extracts all option IDs.
-// This is used for shared_only filtering to determine which options the caller has.
-// Returns an empty set if callerID is empty, if there are no values, or on error.
-func (pas *PropertyAccessService) getCallerOptionIDsForField(groupID, fieldID, callerID string, fieldType model.PropertyFieldType) (map[string]struct{}, error) {
- callerValues, err := pas.getCallerValuesForField(groupID, fieldID, callerID)
+func (h *AccessControlHook) getCallerOptionIDsForField(groupID, fieldID, callerID string, fieldType model.PropertyFieldType) (map[string]struct{}, error) {
+ callerValues, err := h.getCallerValuesForField(groupID, fieldID, callerID)
if err != nil {
return make(map[string]struct{}), err
}
@@ -912,10 +875,9 @@ func (pas *PropertyAccessService) getCallerOptionIDsForField(groupID, fieldID, c
return make(map[string]struct{}), nil
}
- // Extract option IDs from caller's values
callerOptionIDs := make(map[string]struct{})
for _, val := range callerValues {
- optionIDs, err := pas.extractOptionIDsFromValue(fieldType, val.Value)
+ optionIDs, err := h.extractOptionIDsFromValue(fieldType, val.Value)
if err == nil && optionIDs != nil {
for optionID := range optionIDs {
callerOptionIDs[optionID] = struct{}{}
@@ -927,24 +889,18 @@ func (pas *PropertyAccessService) getCallerOptionIDsForField(groupID, fieldID, c
}
// filterSharedOnlyFieldOptions filters a field's options to only include those the caller has values for.
-// Returns a new PropertyField with filtered options in the attrs.
-// If the caller has no values, returns a field with empty options.
-func (pas *PropertyAccessService) filterSharedOnlyFieldOptions(field *model.PropertyField, callerID string) *model.PropertyField {
- // Only applies to select and multiselect fields
+func (h *AccessControlHook) filterSharedOnlyFieldOptions(field *model.PropertyField, callerID string) *model.PropertyField {
if field.Type != model.PropertyFieldTypeSelect && field.Type != model.PropertyFieldTypeMultiselect {
return field
}
- // Get caller's option IDs for this field
- callerOptionIDs, err := pas.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
+ callerOptionIDs, err := h.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
if err != nil || len(callerOptionIDs) == 0 {
- // If no values or error, return field with empty options
- filteredField := pas.copyPropertyField(field)
+ filteredField := h.copyPropertyField(field)
filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{}
return filteredField
}
- // Get current options from field attrs
if field.Attrs == nil {
return field
}
@@ -953,13 +909,11 @@ func (pas *PropertyAccessService) filterSharedOnlyFieldOptions(field *model.Prop
return field
}
- // Convert to slice of maps (generic option representation)
optionsSlice, ok := optionsArr.([]any)
if !ok {
return field
}
- // Filter options
filteredOptions := []any{}
for _, opt := range optionsSlice {
optMap, ok := opt.(map[string]any)
@@ -975,8 +929,7 @@ func (pas *PropertyAccessService) filterSharedOnlyFieldOptions(field *model.Prop
}
}
- // Create a new field with filtered options
- filteredField := pas.copyPropertyField(field)
+ filteredField := h.copyPropertyField(field)
filteredField.Attrs[model.PropertyFieldAttributeOptions] = filteredOptions
return filteredField
}
@@ -990,25 +943,21 @@ func (pas *PropertyAccessService) filterSharedOnlyFieldOptions(field *model.Prop
// The binary path is what protects scenarios like LDAP/SAML-synced text codenames whose
// existence is itself controlled information: a caller who doesn't hold the same value
// must not see the target's value through any read endpoint.
-func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue {
+func (h *AccessControlHook) filterSharedOnlyValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue {
if field.Type != model.PropertyFieldTypeSelect && field.Type != model.PropertyFieldTypeMultiselect {
- return pas.filterSharedOnlyScalarValue(field, value, callerID)
+ return h.filterSharedOnlyScalarValue(field, value, callerID)
}
- // Get caller's option IDs for this field
- callerOptionIDs, err := pas.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
+ callerOptionIDs, err := h.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
if err != nil || len(callerOptionIDs) == 0 {
- // No intersection possible
return nil
}
- // Extract option IDs from target value
- targetOptionIDs, err := pas.extractOptionIDsFromValue(field.Type, value.Value)
+ targetOptionIDs, err := h.extractOptionIDsFromValue(field.Type, value.Value)
if err != nil || targetOptionIDs == nil || len(targetOptionIDs) == 0 {
return nil
}
- // Find intersection
intersection := []string{}
for targetID := range targetOptionIDs {
if _, exists := callerOptionIDs[targetID]; exists {
@@ -1016,17 +965,14 @@ func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyFie
}
}
- // If no intersection, return nil
if len(intersection) == 0 {
return nil
}
- // Create filtered value based on field type
filteredValue := *value
switch field.Type {
case model.PropertyFieldTypeSelect:
- // For single-select, return the single matching value
jsonValue, err := json.Marshal(intersection[0])
if err != nil {
return nil
@@ -1035,7 +981,6 @@ func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyFie
return &filteredValue
case model.PropertyFieldTypeMultiselect:
- // For multi-select, return the array of matching values
jsonValue, err := json.Marshal(intersection)
if err != nil {
return nil
@@ -1044,7 +989,6 @@ func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyFie
return &filteredValue
default:
- // Should never reach here due to check at function start
return nil
}
}
@@ -1053,12 +997,12 @@ func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyFie
// returns the value as-is if the caller's own stored value for the same field equals
// the target's value, otherwise nil. Caller and target may legitimately store nothing,
// in which case the value is hidden.
-func (pas *PropertyAccessService) filterSharedOnlyScalarValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue {
+func (h *AccessControlHook) filterSharedOnlyScalarValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue {
if value == nil || len(value.Value) == 0 {
return nil
}
- callerValues, err := pas.getCallerValuesForField(field.GroupID, field.ID, callerID)
+ callerValues, err := h.getCallerValuesForField(field.GroupID, field.ID, callerID)
if err != nil || len(callerValues) == 0 {
return nil
}
@@ -1079,23 +1023,19 @@ func (pas *PropertyAccessService) filterSharedOnlyScalarValue(field *model.Prope
// - Source-only fields: returned with empty options if caller is not the source plugin
// - Shared-only fields: returned with options filtered using filterSharedOnlyFieldOptions
// - Unknown access modes: treated as source-only (secure default)
-func (pas *PropertyAccessService) applyFieldReadAccessControl(field *model.PropertyField, callerID string) *model.PropertyField {
- // Check if caller has unrestricted access (public field or source plugin for source_only)
- if pas.hasUnrestrictedFieldReadAccess(field, callerID) {
- // Unrestricted access - return as-is
+func (h *AccessControlHook) applyFieldReadAccessControl(field *model.PropertyField, callerID string) *model.PropertyField {
+ if h.hasUnrestrictedFieldReadAccess(field, callerID) {
return field
}
- // Access requires filtering
- accessMode := field.GetAccessMode()
+ accessMode := h.getAccessMode(field)
- // Shared-only fields: use existing helper to filter options
if accessMode == model.PropertyAccessModeSharedOnly {
- return pas.filterSharedOnlyFieldOptions(field, callerID)
+ return h.filterSharedOnlyFieldOptions(field, callerID)
}
// Source-only or unknown: return with empty options (secure default)
- filteredField := pas.copyPropertyField(field)
+ filteredField := h.copyPropertyField(field)
if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{}
}
@@ -1103,29 +1043,25 @@ func (pas *PropertyAccessService) applyFieldReadAccessControl(field *model.Prope
}
// applyFieldReadAccessControlToList applies read access control to a list of fields.
-// Returns a new list with each field's options filtered based on the caller's access permissions.
-func (pas *PropertyAccessService) applyFieldReadAccessControlToList(fields []*model.PropertyField, callerID string) []*model.PropertyField {
+func (h *AccessControlHook) applyFieldReadAccessControlToList(fields []*model.PropertyField, callerID string) []*model.PropertyField {
if len(fields) == 0 {
return fields
}
filtered := make([]*model.PropertyField, 0, len(fields))
for _, field := range fields {
- filtered = append(filtered, pas.applyFieldReadAccessControl(field, callerID))
+ filtered = append(filtered, h.applyFieldReadAccessControl(field, callerID))
}
return filtered
}
// getFieldsForValues fetches all unique fields associated with the given values.
-// Returns a map of fieldID -> PropertyField.
-// Returns an error if any field cannot be fetched.
-func (pas *PropertyAccessService) getFieldsForValues(values []*model.PropertyValue) (map[string]*model.PropertyField, error) {
+func (h *AccessControlHook) getFieldsForValues(values []*model.PropertyValue) (map[string]*model.PropertyField, error) {
if len(values) == 0 {
return make(map[string]*model.PropertyField), nil
}
- // Get unique field IDs and group ID
groupAndFieldIDs := make(map[string]map[string]struct{})
for _, value := range values {
if groupAndFieldIDs[value.GroupID] == nil {
@@ -1136,19 +1072,16 @@ func (pas *PropertyAccessService) getFieldsForValues(values []*model.PropertyVal
fieldMap := make(map[string]*model.PropertyField)
for groupID, fieldIDs := range groupAndFieldIDs {
- // Convert field map to slice
fieldIDSlice := make([]string, 0, len(fieldIDs))
for fieldID := range fieldIDs {
fieldIDSlice = append(fieldIDSlice, fieldID)
}
- // Fetch all fields
- fields, err := pas.propertyService.getPropertyFields(groupID, fieldIDSlice)
+ fields, err := h.propertyService.getPropertyFields(groupID, fieldIDSlice)
if err != nil {
return nil, fmt.Errorf("failed to fetch fields for values: %w", err)
}
- // Build map for easy lookup
for _, field := range fields {
fieldMap[field.ID] = field
}
@@ -1158,20 +1091,16 @@ func (pas *PropertyAccessService) getFieldsForValues(values []*model.PropertyVal
}
// applyValueReadAccessControl applies read access control to a list of values.
-// Returns a new list containing only the values the caller can access, with shared_only values filtered.
-// Values are silently filtered out if the caller doesn't have access.
-func (pas *PropertyAccessService) applyValueReadAccessControl(values []*model.PropertyValue, callerID string) ([]*model.PropertyValue, error) {
+func (h *AccessControlHook) applyValueReadAccessControl(values []*model.PropertyValue, callerID string) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
- // Fetch all associated fields
- fieldMap, err := pas.getFieldsForValues(values)
+ fieldMap, err := h.getFieldsForValues(values)
if err != nil {
return nil, fmt.Errorf("applyValueReadAccessControl: %w", err)
}
- // Filter values based on field access
filtered := make([]*model.PropertyValue, 0, len(values))
for _, value := range values {
field, exists := fieldMap[value.FieldID]
@@ -1179,19 +1108,15 @@ func (pas *PropertyAccessService) applyValueReadAccessControl(values []*model.Pr
return nil, fmt.Errorf("applyValueReadAccessControl: field not found for value %s", value.ID)
}
- accessMode := field.GetAccessMode()
+ accessMode := h.getAccessMode(field)
- // Check if caller can read this value
- if pas.hasUnrestrictedFieldReadAccess(field, callerID) {
- // Caller has unrestricted access (public or source plugin) - include as-is
+ if h.hasUnrestrictedFieldReadAccess(field, callerID) {
filtered = append(filtered, value)
} else if accessMode == model.PropertyAccessModeSharedOnly {
- // Shared-only mode: apply filtering
- filteredValue := pas.filterSharedOnlyValue(field, value, callerID)
+ filteredValue := h.filterSharedOnlyValue(field, value, callerID)
if filteredValue != nil {
filtered = append(filtered, filteredValue)
}
- // If filteredValue is nil, skip this value (no intersection)
}
// For source_only mode where caller is not the source, skip the value
}
diff --git a/server/channels/app/properties/access_control_attribute_validation.go b/server/channels/app/properties/access_control_attribute_validation.go
new file mode 100644
index 00000000000..c645e54eb9c
--- /dev/null
+++ b/server/channels/app/properties/access_control_attribute_validation.go
@@ -0,0 +1,523 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+)
+
+var (
+ ErrInvalidFieldAttrs = errors.New("invalid field attrs")
+ ErrInvalidValue = errors.New("invalid property value")
+ ErrAdminRequired = errors.New("admin privileges required")
+)
+
+// PermissionChecker checks whether a user has a specific permission.
+// This avoids a circular dependency between the properties and app packages.
+type PermissionChecker func(userID string, permission *model.Permission) bool
+
+// AccessControlAttributeValidationHook validates and sanitizes property field attributes
+// and values for managed property groups. It owns the full attr pipeline
+// for these groups:
+//
+// - validates field Name against the CEL-safe identifier rules
+// ([model.ValidateCPAFieldName]); on update this fires only when Name
+// actually changes, so pre-existing fields with non-conforming names
+// remain editable on all other attrs (lenient grandfather)
+// - trims whitespace on string attrs
+// - applies the visibility default when unset
+// - clears attrs that don't apply to the field type (options on non-select,
+// ldap/saml on non-text or admin-managed fields)
+// - auto-assigns IDs to options that lack one and validates option shape
+// - validates visibility, value_type, managed, display_name, and sort_order
+// - validates property values for text fields against value_type
+// constraints (email, url, phone)
+// - enforces that managed="admin" can only be set by callers with
+// PermissionManageSystem, and keeps PermissionValues in sync with the
+// managed attribute
+//
+// The hook only applies to groups whose IDs are in managedGroupIDs.
+type AccessControlAttributeValidationHook struct {
+ BasePropertyHook
+ propertyService *PropertyService
+ managedGroupIDs map[string]struct{}
+ permissionChecker PermissionChecker
+}
+
+var _ PropertyHook = (*AccessControlAttributeValidationHook)(nil)
+
+// NewAccessControlAttributeValidationHook creates a hook that validates field attributes and
+// values for the given property groups.
+func NewAccessControlAttributeValidationHook(ps *PropertyService, permChecker PermissionChecker, managedGroupIDs ...string) *AccessControlAttributeValidationHook {
+ ids := make(map[string]struct{}, len(managedGroupIDs))
+ for _, id := range managedGroupIDs {
+ ids[id] = struct{}{}
+ }
+ return &AccessControlAttributeValidationHook{
+ propertyService: ps,
+ managedGroupIDs: ids,
+ permissionChecker: permChecker,
+ }
+}
+
+func (h *AccessControlAttributeValidationHook) isGroupManaged(groupID string) bool {
+ _, ok := h.managedGroupIDs[groupID]
+ return ok
+}
+
+// sanitizeAndValidateFieldAttrs trims string attrs, applies the visibility
+// default, clears attrs that don't apply to the field type, validates each
+// attr, and auto-IDs+validates options for select-shaped fields. Mutates
+// field.Attrs in place.
+func (h *AccessControlAttributeValidationHook) sanitizeAndValidateFieldAttrs(field *model.PropertyField) error {
+ if field.Attrs == nil {
+ field.Attrs = model.StringInterface{}
+ }
+
+ for _, key := range trimmedFieldAttrKeys {
+ if v, ok := field.Attrs[key].(string); ok {
+ field.Attrs[key] = strings.TrimSpace(v)
+ }
+ }
+
+ if v, _ := field.Attrs[model.PropertyFieldAttrVisibility].(string); v == "" {
+ field.Attrs[model.PropertyFieldAttrVisibility] = model.PropertyFieldVisibilityWhenSet
+ }
+
+ // Type-based attr clearing: select-shaped fields keep options, only text
+ // supports external sync, and admin-managed fields can never be synced
+ // (mutual exclusivity).
+ isSelect := field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect
+ isText := field.Type == model.PropertyFieldTypeText
+ managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string)
+
+ if !isSelect {
+ delete(field.Attrs, model.PropertyFieldAttributeOptions)
+ }
+ if !isText || managed == "admin" {
+ delete(field.Attrs, model.PropertyFieldAttrLDAP)
+ delete(field.Attrs, model.PropertyFieldAttrSAML)
+ }
+
+ if err := model.ValidatePropertyFieldVisibility(field); err != nil {
+ return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs)
+ }
+ if isText {
+ if vt, _ := field.Attrs[model.PropertyFieldAttrValueType].(string); vt != "" && !model.IsValidPropertyFieldValueType(vt) {
+ return fmt.Errorf("invalid value_type %q: %w", vt, ErrInvalidFieldAttrs)
+ }
+ }
+ if managed != "" && managed != "admin" {
+ return fmt.Errorf("invalid managed %q (must be empty or %q): %w", managed, "admin", ErrInvalidFieldAttrs)
+ }
+ if dn, _ := field.Attrs[model.PropertyFieldAttrDisplayName].(string); utf8.RuneCountInString(dn) > model.PropertyFieldNameMaxRunes {
+ return fmt.Errorf("display_name exceeds max length of %d runes: %w", model.PropertyFieldNameMaxRunes, ErrInvalidFieldAttrs)
+ }
+ if isSelect {
+ if err := h.sanitizeAndValidateOptions(field); err != nil {
+ return err
+ }
+ }
+ if err := model.ValidatePropertyFieldSortOrder(field); err != nil {
+ return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs)
+ }
+ return nil
+}
+
+// trimmedFieldAttrKeys lists the string-valued attrs the hook trims on the
+// way in. Listed explicitly rather than iterating Attrs to avoid touching
+// keys this hook doesn't own (e.g. plugin-set attrs).
+var trimmedFieldAttrKeys = []string{
+ model.PropertyFieldAttrVisibility,
+ model.PropertyFieldAttrValueType,
+ model.PropertyFieldAttrManaged,
+ model.PropertyFieldAttrLDAP,
+ model.PropertyFieldAttrSAML,
+ model.PropertyFieldAttrDisplayName,
+}
+
+// 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 {
+ return nil
+ }
+
+ data, err := json.Marshal(rawOptions)
+ if err != nil {
+ return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
+ }
+ var options model.PropertyOptions[*model.CustomProfileAttributesSelectOption]
+ if err = json.Unmarshal(data, &options); err != nil {
+ return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
+ }
+
+ for i := range options {
+ if options[i].ID == "" {
+ options[i].ID = model.NewId()
+ }
+ }
+ if err = options.IsValid(); err != nil {
+ return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs)
+ }
+
+ 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
+}
+
+// enforceGroupPermissions pins schema-edit permissions for fields in
+// managed groups and applies the managed=admin upgrade to PermissionValues:
+// - PermissionField and PermissionOptions are always set to sysadmin so
+// that only admins can modify field definitions and options.
+// - When managed="admin", PermissionValues is set to sysadmin. This is
+// gated on PermissionManageSystem; callers without an identifiable
+// caller ID (e.g. internal callers with no session on rctx) are
+// treated as non-admin and rejected.
+// - Otherwise, PermissionValues is left as-is when set, and default-filled
+// by ObjectType when nil (member for user fields, sysadmin for system
+// and template). Caller pins are never downgraded.
+func (h *AccessControlAttributeValidationHook) enforceGroupPermissions(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ sysadmin := model.PermissionLevelSysadmin
+
+ if managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string); managed == "admin" {
+ // Verify the caller has admin privileges. Default-deny if the
+ // permission checker isn't wired up or if the caller is
+ // unidentifiable — we never silently promote to sysadmin.
+ if h.permissionChecker == nil {
+ return nil, fmt.Errorf("missing permission to set managed=admin: no permission checker configured: %w", ErrAdminRequired)
+ }
+ callerID := h.propertyService.extractCallerID(rctx)
+ if callerID == "" || !h.permissionChecker(callerID, model.PermissionManageSystem) {
+ return nil, fmt.Errorf("missing permission to set managed=admin: only system admins can set managed=admin: %w", ErrAdminRequired)
+ }
+ field.PermissionValues = &sysadmin
+ } else if field.PermissionValues == nil {
+ defaultLevel := defaultPermissionValuesForObjectType(field.ObjectType)
+ field.PermissionValues = &defaultLevel
+ }
+
+ // Fields in managed groups always require sysadmin for field/options edits.
+ field.PermissionField = &sysadmin
+ field.PermissionOptions = &sysadmin
+
+ return field, nil
+}
+
+// defaultPermissionValuesForObjectType returns the PermissionValues level a
+// field should default to when the caller doesn't pin one. User fields are
+// member-writable so users can set their own values; system and template
+// fields attach to admin-owned scopes and require sysadmin.
+func defaultPermissionValuesForObjectType(objectType string) model.PermissionLevel {
+ switch objectType {
+ case model.PropertyFieldObjectTypeSystem, model.PropertyFieldObjectTypeTemplate:
+ return model.PermissionLevelSysadmin
+ default:
+ return model.PermissionLevelMember
+ }
+}
+
+func (h *AccessControlAttributeValidationHook) PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if !h.isGroupManaged(field.GroupID) {
+ return field, nil
+ }
+
+ // Names in managed groups are referenced from ABAC policy expressions
+ // (user.attributes.), so they must satisfy the CEL grammar and
+ // avoid CEL reserved words. Returning the AppError directly preserves
+ // its specific i18n key through the HTTP layer's mapPropertyServiceError
+ // fallback (no sentinel wrap).
+ if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil {
+ return nil, appErr
+ }
+
+ if err := h.sanitizeAndValidateFieldAttrs(field); err != nil {
+ return nil, err
+ }
+
+ return h.enforceGroupPermissions(rctx, field)
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
+ if !h.isGroupManaged(groupID) {
+ return field, nil
+ }
+
+ // Lenient grandfather: only validate Name against CEL rules when it
+ // actually changes, so pre-existing fields whose names predate this
+ // validation remain editable on all other attrs.
+ existing, err := h.propertyService.getPropertyField(groupID, field.ID)
+ if err != nil {
+ return nil, err
+ }
+ if existing.Name != field.Name {
+ if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil {
+ return nil, appErr
+ }
+ }
+
+ if err := h.sanitizeAndValidateFieldAttrs(field); err != nil {
+ return nil, err
+ }
+
+ return h.enforceGroupPermissions(rctx, field)
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if len(fields) == 0 || !h.isGroupManaged(groupID) {
+ return fields, nil
+ }
+
+ // Single batched read for the lenient-grandfather name check; a missing
+ // ID falls through to the store, which surfaces the not-found error.
+ fieldIDs := make([]string, len(fields))
+ for i, f := range fields {
+ fieldIDs[i] = f.ID
+ }
+ existingFields, err := h.propertyService.getPropertyFields(groupID, fieldIDs)
+ if err != nil {
+ return nil, err
+ }
+ existingByID := make(map[string]*model.PropertyField, len(existingFields))
+ for _, ex := range existingFields {
+ existingByID[ex.ID] = ex
+ }
+
+ for i, field := range fields {
+ if existing, ok := existingByID[field.ID]; ok && existing.Name != field.Name {
+ if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, appErr)
+ }
+ }
+
+ if err := h.sanitizeAndValidateFieldAttrs(field); err != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, err)
+ }
+
+ updated, err := h.enforceGroupPermissions(rctx, field)
+ if err != nil {
+ return nil, fmt.Errorf("field %s: %w", field.ID, err)
+ }
+ fields[i] = updated
+ }
+
+ return fields, nil
+}
+
+// extractOptionIDs extracts the set of valid option IDs from a
+// select or multiselect PropertyField's attrs. Returns nil if the
+// field has no options.
+func extractOptionIDs(field *model.PropertyField) (map[string]struct{}, error) {
+ if field.Attrs == nil {
+ return nil, nil
+ }
+
+ rawOptions, ok := field.Attrs[model.PropertyFieldAttributeOptions]
+ if !ok || rawOptions == nil {
+ return nil, nil
+ }
+
+ data, err := json.Marshal(rawOptions)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal options: %w", err)
+ }
+
+ var options []struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(data, &options); err != nil {
+ return nil, fmt.Errorf("invalid options format: %w", err)
+ }
+
+ ids := make(map[string]struct{}, len(options))
+ for _, opt := range options {
+ if opt.ID != "" {
+ ids[opt.ID] = struct{}{}
+ }
+ }
+ return ids, nil
+}
+
+// validateValueAgainstField checks a property value against field-type
+// constraints:
+// - text: max length, value_type format (email, url, phone)
+// - select: option ID must exist in the field's options
+// - multiselect: all option IDs must exist
+// - user: value must be a valid Mattermost ID
+// - multiuser: all values must be valid Mattermost IDs
+func (h *AccessControlAttributeValidationHook) validateValueAgainstField(field *model.PropertyField, value *model.PropertyValue) error {
+ switch field.Type {
+ case model.PropertyFieldTypeText:
+ var str string
+ if err := json.Unmarshal(value.Value, &str); err != nil {
+ return fmt.Errorf("expected string value: %w", err)
+ }
+ if len(strings.TrimSpace(str)) > model.PropertyFieldValueTypeTextMaxLength {
+ return fmt.Errorf("text value exceeds maximum length of %d characters", model.PropertyFieldValueTypeTextMaxLength)
+ }
+
+ valueType := model.GetPropertyFieldValueType(field)
+ if valueType == "" {
+ return nil
+ }
+ return model.ValidatePropertyValueForValueType(valueType, value.Value)
+
+ case model.PropertyFieldTypeSelect:
+ var str string
+ if err := json.Unmarshal(value.Value, &str); err != nil {
+ return fmt.Errorf("expected string value for select field: %w", err)
+ }
+ if str == "" {
+ return nil
+ }
+ optionIDs, err := extractOptionIDs(field)
+ if err != nil {
+ return fmt.Errorf("failed to extract options: %w", err)
+ }
+ if _, ok := optionIDs[str]; !ok {
+ return fmt.Errorf("option %q does not exist", str)
+ }
+
+ case model.PropertyFieldTypeMultiselect:
+ var values []string
+ if err := json.Unmarshal(value.Value, &values); err != nil {
+ return fmt.Errorf("expected string array value for multiselect field: %w", err)
+ }
+ optionIDs, err := extractOptionIDs(field)
+ if err != nil {
+ return fmt.Errorf("failed to extract options: %w", err)
+ }
+ for _, v := range values {
+ if _, ok := optionIDs[v]; !ok {
+ return fmt.Errorf("option %q does not exist", v)
+ }
+ }
+
+ case model.PropertyFieldTypeUser:
+ var str string
+ if err := json.Unmarshal(value.Value, &str); err != nil {
+ return fmt.Errorf("expected string value for user field: %w", err)
+ }
+ if str != "" && !model.IsValidId(str) {
+ return fmt.Errorf("invalid user id")
+ }
+
+ case model.PropertyFieldTypeMultiuser:
+ var values []string
+ if err := json.Unmarshal(value.Value, &values); err != nil {
+ return fmt.Errorf("expected string array value for multiuser field: %w", err)
+ }
+ for _, v := range values {
+ if !model.IsValidId(v) {
+ return fmt.Errorf("invalid user id: %s", v)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (h *AccessControlAttributeValidationHook) validateValues(values []*model.PropertyValue) error {
+ if len(values) == 0 {
+ return nil
+ }
+
+ groupID := values[0].GroupID
+ if !h.isGroupManaged(groupID) {
+ return nil
+ }
+
+ // Collect unique field IDs
+ fieldIDSet := make(map[string]struct{})
+ for _, v := range values {
+ fieldIDSet[v.FieldID] = struct{}{}
+ }
+ fieldIDs := make([]string, 0, len(fieldIDSet))
+ for id := range fieldIDSet {
+ fieldIDs = append(fieldIDs, id)
+ }
+
+ fields, err := h.propertyService.getPropertyFields(groupID, fieldIDs)
+ if err != nil {
+ return fmt.Errorf("failed to fetch fields for validation: %w", err)
+ }
+
+ fieldMap := make(map[string]*model.PropertyField, len(fields))
+ for _, f := range fields {
+ fieldMap[f.ID] = f
+ }
+
+ for _, value := range values {
+ field, ok := fieldMap[value.FieldID]
+ if !ok {
+ return fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound)
+ }
+ if err := h.validateValueAgainstField(field, value); err != nil {
+ return fmt.Errorf("field %s: %s: %w", value.FieldID, err.Error(), ErrInvalidValue)
+ }
+ }
+
+ return nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if err := h.validateValues([]*model.PropertyValue{value}); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if err := h.validateValues(values); err != nil {
+ return nil, err
+ }
+ return values, nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if err := h.validateValues([]*model.PropertyValue{value}); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if err := h.validateValues(values); err != nil {
+ return nil, err
+ }
+ return values, nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpdatePropertyValue(_ request.CTX, _ string, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if err := h.validateValues([]*model.PropertyValue{value}); err != nil {
+ return nil, err
+ }
+ return value, nil
+}
+
+func (h *AccessControlAttributeValidationHook) PreUpdatePropertyValues(_ request.CTX, _ string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if err := h.validateValues(values); err != nil {
+ return nil, err
+ }
+ return values, 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
new file mode 100644
index 00000000000..90a93e57d46
--- /dev/null
+++ b/server/channels/app/properties/access_control_attribute_validation_test.go
@@ -0,0 +1,1123 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/v8/channels/store"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccessControlAttributeValidationHook(t *testing.T) {
+ th := Setup(t)
+
+ group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_attr_validation", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, err)
+
+ hook := NewAccessControlAttributeValidationHook(th.service, nil, group.ID)
+ th.service.AddHook(hook)
+
+ t.Run("allows valid visibility on create", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrVisibility: "always"},
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.NotEmpty(t, created.ID)
+ })
+
+ t.Run("rejects invalid visibility on create", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrVisibility: "public"},
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "visibility")
+ })
+
+ t.Run("rejects non-numeric sort_order on create", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrSortOrder: "not_a_number"},
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "sort_order")
+ })
+
+ t.Run("allows numeric sort_order on create", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrSortOrder: float64(1.5)},
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.NotEmpty(t, created.ID)
+ })
+
+ t.Run("rejects invalid visibility on update", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Attrs = model.StringInterface{model.PropertyFieldAttrVisibility: "bad"}
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "visibility")
+ })
+
+ t.Run("skips validation for unmanaged groups", func(t *testing.T) {
+ otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_other_group", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, groupErr)
+
+ field := &model.PropertyField{
+ GroupID: otherGroup.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrVisibility: "invalid_but_ignored"},
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.NotEmpty(t, created.ID)
+ })
+
+ t.Run("validates value_type on upsert — rejects invalid email", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "email_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttrValueType: "email",
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"not-an-email"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "email")
+ })
+
+ t.Run("validates value_type on upsert — accepts valid email", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "email_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttrValueType: "email",
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"test@example.com"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("skips value_type validation for non-text fields", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "date_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeDate,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"2024-01-01"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("allows empty value even with value_type", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "email_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttrValueType: "email",
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`""`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ // Select field validation tests
+
+ t.Run("select — accepts valid option ID", func(t *testing.T) {
+ optionID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "select_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"` + optionID + `"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("select — rejects non-existent option ID", func(t *testing.T) {
+ optionID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "select_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"` + model.NewId() + `"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "does not exist")
+ })
+
+ t.Run("select — allows empty string value", func(t *testing.T) {
+ optionID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "select_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`""`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ // Multiselect field validation tests
+
+ t.Run("multiselect — accepts valid option IDs", func(t *testing.T) {
+ optionID1 := model.NewId()
+ optionID2 := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiselect_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID1, "name": "Option 1"},
+ map[string]any{"id": optionID2, "name": "Option 2"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`["` + optionID1 + `","` + optionID2 + `"]`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("multiselect — rejects if any option ID is invalid", func(t *testing.T) {
+ optionID1 := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiselect_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID1, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`["` + optionID1 + `","` + model.NewId() + `"]`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "does not exist")
+ })
+
+ t.Run("multiselect — accepts empty array", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiselect_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": model.NewId(), "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`[]`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ // User field validation tests
+
+ t.Run("user — accepts valid user ID", func(t *testing.T) {
+ userID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "user_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeUser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"` + userID + `"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("user — rejects invalid user ID format", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "user_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeUser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"not-a-valid-id"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "invalid user id")
+ })
+
+ t.Run("user — allows empty string", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "user_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeUser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`""`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ // Multiuser field validation tests
+
+ t.Run("multiuser — accepts valid user IDs", func(t *testing.T) {
+ userID1 := model.NewId()
+ userID2 := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiuser_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiuser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`["` + userID1 + `","` + userID2 + `"]`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("multiuser — rejects if any user ID is invalid", func(t *testing.T) {
+ validID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiuser_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiuser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`["` + validID + `","bad-id"]`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "invalid user id")
+ })
+
+ t.Run("multiuser — accepts empty array", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiuser_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiuser,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`[]`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ // Edge case: select with wrong JSON type
+
+ t.Run("select — rejects non-string JSON value", func(t *testing.T) {
+ optionID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "select_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`123`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "expected string value")
+ })
+
+ t.Run("multiselect — rejects non-array JSON value", func(t *testing.T) {
+ optionID := model.NewId()
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "multiselect_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": optionID, "name": "Option 1"},
+ },
+ },
+ })
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"not-an-array"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "expected string array")
+ })
+
+ t.Run("upsert with unknown field id returns ErrFieldNotFound", func(t *testing.T) {
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: model.NewId(),
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"anything"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.ErrorIs(t, upsertErr, ErrFieldNotFound)
+ var resultsMismatchErr *store.ErrResultsMismatch
+ assert.ErrorAs(t, upsertErr, &resultsMismatchErr, "original store error should remain in chain")
+ })
+
+ // Group permission enforcement tests
+ //
+ // These tests run with the hook configured with a nil permissionChecker
+ // (see the Setup block at the top of this test function). In that
+ // configuration, managed="admin" is default-denied since there is no
+ // way to verify the caller's admin status. The "allowed" side of the
+ // authorization matrix is covered in TestAccessControlAttributeValidationHookManagedAuthorization.
+
+ t.Run("create field with managed=admin is rejected when no permission checker is configured", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "managed=admin")
+ })
+
+ t.Run("create field without managed sets PermissionValues to member", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{},
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ require.NotNil(t, created.PermissionValues)
+ assert.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
+ require.NotNil(t, created.PermissionField)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionField)
+ require.NotNil(t, created.PermissionOptions)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionOptions)
+ })
+
+ t.Run("update field to managed=admin is rejected when no permission checker is configured", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{},
+ })
+
+ field.Attrs = model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ }
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "managed=admin")
+ })
+
+ t.Run("update field to remove managed sets PermissionValues to member", func(t *testing.T) {
+ member := model.PermissionLevelMember
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ PermissionValues: &member,
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ })
+
+ field.Attrs = model.StringInterface{}
+ updated, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.NoError(t, updateErr)
+ require.NotNil(t, updated.PermissionValues)
+ assert.Equal(t, model.PermissionLevelMember, *updated.PermissionValues)
+ })
+
+ t.Run("sanitization on create: defaults visibility to when_set", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.Equal(t, model.CustomProfileAttributesVisibilityWhenSet, created.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
+ })
+
+ t.Run("sanitization on create: trims display_name and rejects when too long", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsDisplayName: " Department Head ",
+ },
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.Equal(t, "Department Head", created.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName])
+
+ // Build a 256-rune string — exceeds the 255-rune cap (PropertyFieldNameMaxRunes).
+ tooLong := strings.Repeat("a", model.PropertyFieldNameMaxRunes+1)
+ bad := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsDisplayName: tooLong},
+ }
+ _, badErr := th.service.CreatePropertyField(th.Context, bad)
+ require.Error(t, badErr)
+ assert.Contains(t, badErr.Error(), "display_name")
+ })
+
+ t.Run("sanitization on update: rejects display_name longer than max", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Attrs = model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsDisplayName: strings.Repeat("a", model.PropertyFieldNameMaxRunes+1),
+ }
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "display_name")
+ })
+
+ t.Run("sanitization on update: rejects unknown value_type on text field", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Attrs = model.StringInterface{model.PropertyFieldAttrValueType: "wat"}
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "value_type")
+ })
+
+ t.Run("sanitization on update: rejects unknown managed value", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Attrs = model.StringInterface{model.PropertyFieldAttrManaged: "kinda"}
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "managed")
+ })
+
+ t.Run("name validation on create: rejects non-CEL identifier", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "Has Space",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ var appErr *model.AppError
+ require.ErrorAs(t, createErr, &appErr)
+ assert.Equal(t, "model.cpa_field.name.invalid_charset.app_error", appErr.Id)
+ })
+
+ t.Run("name validation on create: rejects CEL reserved word", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "for",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ var appErr *model.AppError
+ require.ErrorAs(t, createErr, &appErr)
+ assert.Equal(t, "model.cpa_field.name.reserved_word.app_error", appErr.Id)
+ })
+
+ t.Run("name validation on create: accepts CEL-safe identifier", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "department_head",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.Equal(t, "department_head", created.Name)
+ })
+
+ t.Run("name validation on update: lenient grandfather lets non-conforming name through when unchanged", func(t *testing.T) {
+ // Direct store insert bypasses the hook so we can seed a name that
+ // would fail current validation, simulating a field that predates it.
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "legacy name",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ // Patch a different attr without touching Name — should succeed.
+ field.Attrs = model.StringInterface{model.PropertyFieldAttrVisibility: "always"}
+ updated, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.NoError(t, updateErr)
+ assert.Equal(t, "legacy name", updated.Name)
+ })
+
+ t.Run("name validation on update: rejects rename to non-CEL identifier", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "good_name_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Name = "Bad Name"
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ var appErr *model.AppError
+ require.ErrorAs(t, updateErr, &appErr)
+ assert.Equal(t, "model.cpa_field.name.invalid_charset.app_error", appErr.Id)
+ })
+
+ t.Run("name validation on update: rejects rename to CEL reserved word", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "good_name_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ field.Name = "in"
+ _, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.Error(t, updateErr)
+ var appErr *model.AppError
+ require.ErrorAs(t, updateErr, &appErr)
+ assert.Equal(t, "model.cpa_field.name.reserved_word.app_error", appErr.Id)
+ })
+
+ t.Run("name validation on update: accepts rename to CEL-safe identifier", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "old_name_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ newName := "new_name_" + model.NewId()
+ field.Name = newName
+ updated, _, updateErr := th.service.UpdatePropertyField(th.Context, group.ID, field)
+ require.NoError(t, updateErr)
+ assert.Equal(t, newName, updated.Name)
+ })
+
+ t.Run("name validation on batch update: lenient grandfather applies per-field", func(t *testing.T) {
+ grandfathered := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "still legacy",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+ renamable := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "rename_src_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ // Touch grandfathered without renaming; rename renamable to a CEL-safe
+ // name. Both should be accepted.
+ grandfathered.Attrs = model.StringInterface{model.PropertyFieldAttrVisibility: "hidden"}
+ newName := "rename_dst_" + model.NewId()
+ renamable.Name = newName
+ _, _, _, updateErr := th.service.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{grandfathered, renamable})
+ require.NoError(t, updateErr)
+ })
+
+ t.Run("name validation on batch update: one bad rename rejects the batch", func(t *testing.T) {
+ ok := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "ok_src_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+ bad := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "bad_src_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ ok.Name = "ok_dst_" + model.NewId()
+ bad.Name = "for" // CEL reserved word
+ _, _, _, updateErr := th.service.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{ok, bad})
+ require.Error(t, updateErr)
+ var appErr *model.AppError
+ require.ErrorAs(t, updateErr, &appErr)
+ assert.Equal(t, "model.cpa_field.name.reserved_word.app_error", appErr.Id)
+ })
+
+ t.Run("text — rejects value exceeding max length", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "text_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ // Create a string longer than PropertyFieldValueTypeTextMaxLength (64)
+ longValue := make([]byte, 0, 70)
+ for range 70 {
+ longValue = append(longValue, 'a')
+ }
+
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"` + string(longValue) + `"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ 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) {
+ th := Setup(t)
+
+ group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_managed_auth", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, err)
+
+ adminUserID := model.NewId()
+ regularUserID := model.NewId()
+
+ permChecker := func(userID string, perm *model.Permission) bool {
+ return userID == adminUserID && perm.Id == model.PermissionManageSystem.Id
+ }
+
+ hook := NewAccessControlAttributeValidationHook(th.service, permChecker, group.ID)
+ th.service.AddHook(hook)
+
+ t.Run("admin can create field with managed=admin", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, adminUserID)
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ created, createErr := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, createErr)
+ require.NotNil(t, created.PermissionValues)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
+ })
+
+ t.Run("non-admin is blocked from creating field with managed=admin", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, regularUserID)
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ _, createErr := th.service.CreatePropertyField(rctx, field)
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "permission")
+ })
+
+ t.Run("non-admin can create field without managed attr", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, regularUserID)
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{},
+ }
+ created, createErr := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, createErr)
+ require.NotNil(t, created.PermissionValues)
+ assert.Equal(t, model.PermissionLevelMember, *created.PermissionValues)
+ })
+
+ t.Run("non-admin is blocked from updating field to managed=admin", func(t *testing.T) {
+ // Create field as admin
+ adminRctx := RequestContextWithCallerID(th.Context, adminUserID)
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{},
+ }
+ created, createErr := th.service.CreatePropertyField(adminRctx, field)
+ require.NoError(t, createErr)
+
+ // Try to update as non-admin
+ rctx := RequestContextWithCallerID(th.Context, regularUserID)
+ created.Attrs = model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ }
+ _, _, updateErr := th.service.UpdatePropertyField(rctx, group.ID, created)
+ require.Error(t, updateErr)
+ assert.Contains(t, updateErr.Error(), "permission")
+ })
+
+ t.Run("admin can update field to managed=admin", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, adminUserID)
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{},
+ }
+ created, createErr := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, createErr)
+
+ created.Attrs = model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ }
+ updated, _, updateErr := th.service.UpdatePropertyField(rctx, group.ID, created)
+ require.NoError(t, updateErr)
+ require.NotNil(t, updated.PermissionValues)
+ assert.Equal(t, model.PermissionLevelSysadmin, *updated.PermissionValues)
+ })
+
+ t.Run("managed check skipped for unmanaged groups", func(t *testing.T) {
+ otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_other_managed", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, groupErr)
+
+ rctx := RequestContextWithCallerID(th.Context, regularUserID)
+ field := &model.PropertyField{
+ GroupID: otherGroup.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ // Should succeed because the hook doesn't apply to this group
+ created, createErr := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, createErr)
+ // PermissionValues should NOT be set by the hook for unmanaged groups
+ assert.Nil(t, created.PermissionValues)
+ })
+
+ t.Run("empty caller ID is rejected (default-deny for unidentified callers)", func(t *testing.T) {
+ // th.Context has no caller ID set. The hook must treat this as
+ // non-admin and block managed=admin rather than silently
+ // promoting to sysadmin.
+ field := &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{
+ model.CustomProfileAttributesPropertyAttrsManaged: "admin",
+ },
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "managed=admin")
+ })
+}
diff --git a/server/channels/app/properties/access_control_field_test.go b/server/channels/app/properties/access_control_field_test.go
index 6ceec86ce78..e940a3a830e 100644
--- a/server/channels/app/properties/access_control_field_test.go
+++ b/server/channels/app/properties/access_control_field_test.go
@@ -35,7 +35,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
model.PropertyFieldAttributeOptions: []any{
@@ -77,7 +78,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -102,7 +104,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-2",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -127,7 +130,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-3",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -152,7 +156,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-4",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -177,7 +182,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-field",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -226,7 +232,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-field-2",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -251,7 +258,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-field-source",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsSourcePluginID: pluginID1,
@@ -281,14 +289,15 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
})
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_routing_read", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_routing_read", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "routing-test-non-cpa-source-only",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -315,7 +324,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "no-attrs-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: nil,
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -332,7 +342,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "empty-access-mode-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{},
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -349,7 +360,8 @@ func TestGetPropertyFieldReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "invalid-access-mode-field",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: "invalid-mode",
model.PropertyFieldAttributeOptions: []any{
@@ -382,7 +394,8 @@ func TestGetPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -394,7 +407,8 @@ func TestGetPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -410,7 +424,8 @@ func TestGetPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-field",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -486,7 +501,8 @@ func TestSearchPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-search-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -498,7 +514,8 @@ func TestSearchPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-search-field",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -514,7 +531,8 @@ func TestSearchPropertyFieldsReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-search-field",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -583,7 +601,6 @@ func TestGetPropertyFieldByNameReadAccess(t *testing.T) {
pluginID := "plugin-1"
userID := model.NewId()
- targetID := model.NewId()
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
rctxUser := RequestContextWithCallerID(th.Context, userID)
@@ -593,8 +610,8 @@ func TestGetPropertyFieldByNameReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "byname-source-only",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
- TargetID: targetID,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -607,12 +624,12 @@ func TestGetPropertyFieldByNameReadAccess(t *testing.T) {
require.NoError(t, err)
// Source plugin can see options
- retrieved, err := th.service.GetPropertyFieldByName(rctxPlugin, th.CPAGroupID, targetID, created.Name)
+ retrieved, err := th.service.GetPropertyFieldByName(rctxPlugin, th.CPAGroupID, "", created.Name)
require.NoError(t, err)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
// User sees empty options
- retrieved, err = th.service.GetPropertyFieldByName(rctxUser, th.CPAGroupID, targetID, created.Name)
+ retrieved, err = th.service.GetPropertyFieldByName(rctxUser, th.CPAGroupID, "", created.Name)
require.NoError(t, err)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
@@ -634,9 +651,11 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
t.Run("non-plugin caller can create field without source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
+ GroupID: th.CPAGroupID,
+ Name: model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxUser1, field)
@@ -654,6 +673,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "plugin-1",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "user-id-123"), field)
@@ -670,6 +691,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxUser1, field)
@@ -686,6 +709,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "plugin-1",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -702,6 +727,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -718,6 +745,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -729,9 +758,11 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
t.Run("plugin caller auto-sets source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
+ GroupID: th.CPAGroupID,
+ Name: model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -748,6 +779,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "malicious-plugin",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -761,7 +794,8 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
@@ -780,13 +814,15 @@ func TestCreatePropertyField_AccessControl(t *testing.T) {
})
t.Run("non-CPA group routes directly to PropertyService without setting source_plugin_id", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_create", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_create", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
- GroupID: nonCpaGroup.ID,
- Name: model.NewId(),
- Type: model.PropertyFieldTypeText,
+ GroupID: nonCpaGroup.ID,
+ Name: model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
rctx := RequestContextWithCallerID(th.Context, "plugin-2")
@@ -811,16 +847,18 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
t.Run("allows update of unprotected field", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: "Original Name",
- Type: model.PropertyFieldTypeText,
+ GroupID: th.CPAGroupID,
+ Name: "Original Name",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Updated Name"
- updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, "Updated Name", updated.Name)
})
@@ -833,13 +871,15 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Updated Protected Field"
- updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, "Updated Protected Field", updated.Name)
})
@@ -852,13 +892,15 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Attempted Update"
- updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
@@ -873,13 +915,15 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Attempted Update"
- updated, err := th.service.UpdatePropertyField(rctxAnon, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxAnon, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
@@ -887,10 +931,12 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
t.Run("prevents changing source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: "Field",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{},
+ GroupID: th.CPAGroupID,
+ Name: "Field",
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -898,7 +944,7 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
// Try to change source_plugin_id
created.Attrs[model.PropertyAttrsSourcePluginID] = "plugin-2"
- updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "immutable")
@@ -906,10 +952,12 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
t.Run("prevents setting protected=true without source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: "Field Without Source Plugin",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{},
+ GroupID: th.CPAGroupID,
+ Name: "Field Without Source Plugin",
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
@@ -917,7 +965,7 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
// Try to set protected=true without having a source_plugin_id
created.Attrs[model.PropertyAttrsProtected] = true
- updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
@@ -926,10 +974,12 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
t.Run("prevents non-source plugin from setting protected=true", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: th.CPAGroupID,
- Name: "Field With Source Plugin",
- Type: model.PropertyFieldTypeText,
- Attrs: model.StringInterface{},
+ GroupID: th.CPAGroupID,
+ Name: "Field With Source Plugin",
+ Type: model.PropertyFieldTypeText,
+ Attrs: model.StringInterface{},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
// Create field via plugin-1 (sets source_plugin_id automatically)
@@ -939,20 +989,20 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
// Try to set protected=true by a different plugin (plugin-2)
created.Attrs[model.PropertyAttrsProtected] = true
- updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
assert.Contains(t, err.Error(), "plugin-1")
// Verify the source plugin (plugin-1) CAN set protected=true
- updated, err = th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
+ updated, _, err = th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
assert.True(t, model.IsPropertyFieldProtected(updated))
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_update", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_update", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
@@ -963,6 +1013,8 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -970,7 +1022,7 @@ func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
// Update with different plugin - should be allowed (no access control)
created.Name = "Updated by Plugin2"
- updated, err := th.service.UpdatePropertyField(rctxPlugin2, nonCpaGroup.ID, created)
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, nonCpaGroup.ID, created)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, "Updated by Plugin2", updated.Name)
@@ -989,8 +1041,8 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows bulk update of unprotected fields", func(t *testing.T) {
- field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText}
- field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText}
+ field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
+ field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
@@ -1000,14 +1052,14 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
created1.Name = "Updated Field1"
created2.Name = "Updated Field2"
- updated, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
+ updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.NoError(t, err)
assert.Len(t, updated, 2)
})
t.Run("fails atomically when one protected field in batch", func(t *testing.T) {
// Create unprotected field
- field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText}
+ field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
@@ -1019,6 +1071,8 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
@@ -1027,7 +1081,7 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
created1.Name = "Updated Unprotected"
created2.Name = "Updated Protected"
- updated, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
+ updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
@@ -1046,8 +1100,8 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
rctxAnon := RequestContextWithCallerID(th.Context, "")
// Create two unprotected fields without source_plugin_id
- field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}}
- field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}}
+ field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
+ field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
@@ -1058,7 +1112,7 @@ func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
created1.Name = "Updated Field1"
created2.Attrs[model.PropertyAttrsProtected] = true
- updated, _, err := th.service.UpdatePropertyFields(rctxPlugin1, th.CPAGroupID, []*model.PropertyField{created1, created2})
+ updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin1, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
@@ -1084,7 +1138,7 @@ func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deletion of unprotected field", func(t *testing.T) {
- field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText}
+ field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -1100,6 +1154,8 @@ func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -1120,6 +1176,8 @@ func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -1130,7 +1188,7 @@ func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_delete", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_delete", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
@@ -1141,6 +1199,8 @@ func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -1169,6 +1229,8 @@ func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
require.NoError(t, err)
@@ -1194,6 +1256,8 @@ func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "installed-plugin"), field)
require.NoError(t, err)
@@ -1220,6 +1284,8 @@ func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
require.NoError(t, err)
@@ -1230,7 +1296,7 @@ func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
})
created.Name = "Updated Orphaned Field"
- updated, err := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created)
+ updated, _, err := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
@@ -1440,7 +1506,3 @@ func TestLinkedPropertyField_SecurityInheritance(t *testing.T) {
assert.False(t, model.IsPropertyFieldProtected(linked))
})
}
-
-// The previous "member-writable shared_only" early-return in applyFieldReadAccessControl
-// was removed in favor of rejecting that contradictory configuration at validation time
-// (see TestValidatePropertyFieldAccessMode in server/public/model/property_access_test.go).
diff --git a/server/channels/app/properties/access_control_value_test.go b/server/channels/app/properties/access_control_value_test.go
index 8b40e179b03..aed0746c55f 100644
--- a/server/channels/app/properties/access_control_value_test.go
+++ b/server/channels/app/properties/access_control_value_test.go
@@ -24,7 +24,7 @@ func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows creating value for public field", func(t *testing.T) {
- field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
+ field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -50,6 +50,8 @@ func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -75,6 +77,8 @@ func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -94,7 +98,7 @@ func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_value_create", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_value_create", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
@@ -105,6 +109,8 @@ func TestCreatePropertyValue_WriteAccessControl(t *testing.T) {
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
@@ -137,7 +143,7 @@ func TestDeletePropertyValue_WriteAccessControl(t *testing.T) {
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deleting value for public field", func(t *testing.T) {
- field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
+ field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -163,6 +169,8 @@ func TestDeletePropertyValue_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
@@ -195,8 +203,8 @@ func TestDeletePropertyValuesForTarget_WriteAccessControl(t *testing.T) {
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deleting all values when caller has write access to all fields", func(t *testing.T) {
- field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText}
- field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText}
+ field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
+ field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
@@ -218,7 +226,7 @@ func TestDeletePropertyValuesForTarget_WriteAccessControl(t *testing.T) {
t.Run("fails atomically when caller lacks access to one field", func(t *testing.T) {
// Create public field
- field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText}
+ field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
@@ -230,6 +238,8 @@ func TestDeletePropertyValuesForTarget_WriteAccessControl(t *testing.T) {
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
@@ -281,7 +291,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -334,7 +345,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -370,7 +382,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -414,7 +427,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-single-select",
Type: model.PropertyFieldTypeSelect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -487,7 +501,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-multi-select",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -567,7 +582,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-text",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -637,7 +653,8 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-only-no-values",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -669,14 +686,15 @@ func TestGetPropertyValueReadAccess(t *testing.T) {
})
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_value_read", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_value_read", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-value-source-only",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -733,7 +751,8 @@ func TestGetPropertyValuesReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-bulk",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -746,7 +765,8 @@ func TestGetPropertyValuesReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-bulk",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -822,7 +842,8 @@ func TestSearchPropertyValuesReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-search",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -835,7 +856,8 @@ func TestSearchPropertyValuesReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "source-only-field-search",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -891,7 +913,8 @@ func TestSearchPropertyValuesReadAccess(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "shared-field-search",
Type: model.PropertyFieldTypeMultiselect,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
@@ -965,7 +988,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -977,7 +1001,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -1018,7 +1043,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "protected-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -1031,7 +1057,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "protected-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -1073,7 +1100,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-batch",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -1085,7 +1113,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "protected-field-batch",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -1134,7 +1163,7 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
t.Run("rejects values across multiple groups", func(t *testing.T) {
// Register a second group
- group2, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_2", Version: model.PropertyGroupVersionV1})
+ group2, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_2", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create fields in both groups
@@ -1142,7 +1171,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "field-group1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -1154,7 +1184,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: group2.ID,
Name: "field-group2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -1194,7 +1225,7 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
t.Run("rejects mixed groups before checking access control", func(t *testing.T) {
// Register a third group
- group3, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_3", Version: model.PropertyGroupVersionV1})
+ group3, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_3", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create public field in CPA group
@@ -1202,7 +1233,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-multigroup",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
@@ -1215,7 +1247,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: group3.ID,
Name: "protected-field-multigroup",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
@@ -1256,7 +1289,7 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
// Register a non-CPA group
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_bulk", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_bulk", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create two fields in non-CPA group
@@ -1264,13 +1297,15 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
field2 := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
@@ -1304,7 +1339,7 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
t.Run("mixed CPA and non-CPA groups are rejected before access control", func(t *testing.T) {
// Register a non-CPA group
- nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_mixed", Version: model.PropertyGroupVersionV1})
+ nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_mixed", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create protected field in CPA group via plugin API
@@ -1312,7 +1347,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "cpa-protected-mixed",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1325,7 +1361,8 @@ func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: nonCpaGroup.ID,
Name: "non-cpa-field-mixed",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
nonCpaField, err = th.service.CreatePropertyField(rctx1, nonCpaField)
require.NoError(t, err)
@@ -1370,7 +1407,8 @@ func TestUpdatePropertyValue_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "protected-field-for-update",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1403,7 +1441,8 @@ func TestUpdatePropertyValue_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "protected-field-for-update-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1436,7 +1475,8 @@ func TestUpdatePropertyValue_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-for-update",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
@@ -1480,7 +1520,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-update-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1489,7 +1530,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-update-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1533,7 +1575,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1542,7 +1585,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1593,7 +1637,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "mixed-update-protected-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1602,7 +1647,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "mixed-update-public-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
@@ -1660,7 +1706,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "multi-owner-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1669,7 +1716,8 @@ func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "multi-owner-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1756,7 +1804,8 @@ func TestUpsertPropertyValue_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "upsert-protected-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1790,7 +1839,8 @@ func TestUpsertPropertyValue_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "upsert-protected-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1832,7 +1882,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1841,7 +1892,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1880,7 +1932,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1889,7 +1942,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1937,7 +1991,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "mixed-protected-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -1946,7 +2001,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "mixed-public-field",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
@@ -1999,7 +2055,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-1",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -2008,7 +2065,8 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-2",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -2091,6 +2149,146 @@ func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
})
}
+func TestUpsertPropertyValue_SyncLock(t *testing.T) {
+ th := Setup(t)
+
+ group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_sync_lock", Version: model.PropertyGroupVersionV1})
+ require.NoError(t, err)
+
+ hook := NewAccessControlHook(th.service, nil, group.ID)
+ th.service.AddHook(hook)
+
+ ldapField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "ldap_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrLDAP: "cn"},
+ })
+
+ samlField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "saml_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ Attrs: model.StringInterface{model.PropertyFieldAttrSAML: "displayName"},
+ })
+
+ nonSyncedField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: group.ID,
+ Name: "normal_field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ })
+
+ targetID := model.NewId()
+
+ t.Run("blocks upsert on LDAP-synced field without caller ID", func(t *testing.T) {
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: ldapField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"test"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "ldap sync")
+ })
+
+ t.Run("allows LDAP sync service to upsert LDAP-synced field", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: ldapField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"John Doe"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(rctx, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("blocks SAML sync service from writing LDAP-synced field", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: ldapField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"wrong caller"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(rctx, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "ldap sync")
+ })
+
+ t.Run("allows SAML sync service to upsert SAML-synced field", func(t *testing.T) {
+ rctx := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: samlField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"Jane Doe"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(rctx, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("blocks regular user from writing SAML-synced field", func(t *testing.T) {
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: samlField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"sneaky"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "saml sync")
+ })
+
+ t.Run("allows regular user to upsert non-synced field", func(t *testing.T) {
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: nonSyncedField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"hello"`),
+ }
+ result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.NoError(t, upsertErr)
+ assert.NotEmpty(t, result.ID)
+ })
+
+ t.Run("sync lock applies to batch upsert", func(t *testing.T) {
+ values := []*model.PropertyValue{
+ {
+ GroupID: group.ID,
+ FieldID: ldapField.ID,
+ TargetID: targetID,
+ TargetType: "user",
+ Value: json.RawMessage(`"batch test"`),
+ },
+ }
+ _, upsertErr := th.service.UpsertPropertyValues(th.Context, values)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "ldap sync")
+
+ // Same batch with the right caller should succeed
+ rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
+ results, upsertErr := th.service.UpsertPropertyValues(rctx, values)
+ require.NoError(t, upsertErr)
+ assert.Len(t, results, 1)
+ })
+}
+
func TestDeletePropertyValuesForField_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
@@ -2105,7 +2303,8 @@ func TestDeletePropertyValuesForField_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "field-delete-values",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -2154,7 +2353,8 @@ func TestDeletePropertyValuesForField_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "field-delete-values-fail",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
@@ -2193,7 +2393,8 @@ func TestDeletePropertyValuesForField_WriteAccessControl(t *testing.T) {
GroupID: th.CPAGroupID,
Name: "public-field-delete-values",
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
diff --git a/server/channels/app/properties/field_limit.go b/server/channels/app/properties/field_limit.go
new file mode 100644
index 00000000000..491aa8ac234
--- /dev/null
+++ b/server/channels/app/properties/field_limit.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+)
+
+var (
+ ErrFieldLimitReached = errors.New("per-object-type field limit reached")
+ ErrGroupFieldLimitReached = errors.New("group field limit reached")
+)
+
+// FieldLimitConfig defines limits for a specific property group.
+type FieldLimitConfig struct {
+ // PerObjectType maps ObjectType values to their maximum field count.
+ // For example: {"user": 20} means at most 20 fields with ObjectType="user".
+ PerObjectType map[string]int64
+
+ // GlobalLimit is the maximum total number of fields across the entire group,
+ // regardless of ObjectType. Zero means no global limit.
+ GlobalLimit int64
+}
+
+// FieldLimitHook enforces per-group field creation limits. It checks both
+// per-object-type limits and global group limits before allowing a field
+// to be created. The hook only applies to groups that have been configured
+// with limits.
+type FieldLimitHook struct {
+ BasePropertyHook
+ propertyService *PropertyService
+ limits map[string]*FieldLimitConfig // groupID -> config
+}
+
+var _ PropertyHook = (*FieldLimitHook)(nil)
+
+// NewFieldLimitHook creates a hook that enforces field limits. Call
+// AddGroupLimit to configure limits for specific groups.
+func NewFieldLimitHook(ps *PropertyService) *FieldLimitHook {
+ return &FieldLimitHook{
+ propertyService: ps,
+ limits: make(map[string]*FieldLimitConfig),
+ }
+}
+
+// AddGroupLimit registers a limit configuration for the given group ID.
+func (h *FieldLimitHook) AddGroupLimit(groupID string, config *FieldLimitConfig) {
+ h.limits[groupID] = config
+}
+
+func (h *FieldLimitHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ config, ok := h.limits[field.GroupID]
+ if !ok {
+ return field, nil
+ }
+
+ // Check per-object-type limit
+ if field.ObjectType != "" {
+ if limit, hasLimit := config.PerObjectType[field.ObjectType]; hasLimit {
+ count, err := h.propertyService.countActivePropertyFieldsForGroupObjectType(field.GroupID, field.ObjectType)
+ if err != nil {
+ return nil, fmt.Errorf("failed to count fields: %w", err)
+ }
+ if count >= limit {
+ return nil, fmt.Errorf("limit_reached: field limit of %d reached for object type %q: %w", limit, field.ObjectType, ErrFieldLimitReached)
+ }
+ }
+ }
+
+ // Check global group limit
+ if config.GlobalLimit > 0 {
+ count, err := h.propertyService.countActivePropertyFieldsForGroup(field.GroupID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to count group fields: %w", err)
+ }
+ if count >= config.GlobalLimit {
+ return nil, fmt.Errorf("group_limit_reached: global field limit of %d reached for group: %w", config.GlobalLimit, ErrGroupFieldLimitReached)
+ }
+ }
+
+ return field, nil
+}
diff --git a/server/channels/app/properties/field_limit_test.go b/server/channels/app/properties/field_limit_test.go
new file mode 100644
index 00000000000..ba6d59907b4
--- /dev/null
+++ b/server/channels/app/properties/field_limit_test.go
@@ -0,0 +1,84 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFieldLimitHook(t *testing.T) {
+ th := Setup(t)
+
+ group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_field_limit", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, err)
+
+ hook := NewFieldLimitHook(th.service)
+ hook.AddGroupLimit(group.ID, &FieldLimitConfig{
+ PerObjectType: map[string]int64{
+ "user": 3,
+ },
+ GlobalLimit: 5,
+ })
+ th.service.AddHook(hook)
+
+ makeField := func(objectType string) *model.PropertyField {
+ return &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: objectType,
+ }
+ }
+
+ t.Run("allows fields up to per-object-type limit", func(t *testing.T) {
+ for range 3 {
+ _, createErr := th.service.CreatePropertyField(th.Context, makeField("user"))
+ require.NoError(t, createErr)
+ }
+ })
+
+ t.Run("rejects field at per-object-type limit", func(t *testing.T) {
+ _, createErr := th.service.CreatePropertyField(th.Context, makeField("user"))
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "limit_reached")
+ })
+
+ t.Run("allows fields for different object type", func(t *testing.T) {
+ _, createErr := th.service.CreatePropertyField(th.Context, makeField("post"))
+ require.NoError(t, createErr)
+ })
+
+ t.Run("rejects at global limit", func(t *testing.T) {
+ // We have 3 user + 1 post = 4 fields. One more should succeed.
+ _, createErr := th.service.CreatePropertyField(th.Context, makeField("post"))
+ require.NoError(t, createErr)
+
+ // Now at 5, should hit global limit
+ _, createErr = th.service.CreatePropertyField(th.Context, makeField("post"))
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "group_limit_reached")
+ })
+
+ t.Run("skips limit check for unregistered groups", func(t *testing.T) {
+ otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_no_limits", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, groupErr)
+
+ for range 10 {
+ field := &model.PropertyField{
+ GroupID: otherGroup.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ _, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ }
+ })
+}
diff --git a/server/channels/app/properties/helper_test.go b/server/channels/app/properties/helper_test.go
index 35825db767f..fe95d9c3c80 100644
--- a/server/channels/app/properties/helper_test.go
+++ b/server/channels/app/properties/helper_test.go
@@ -48,10 +48,6 @@ func setupTestHelper(s store.Store, tb testing.TB) *TestHelper {
})
require.NoError(tb, err)
- // Create and wire the PropertyAccessService
- pas := NewPropertyAccessService(service, nil)
- service.SetPropertyAccessService(pas)
-
tb.Cleanup(func() {
s.Close()
})
@@ -69,12 +65,29 @@ func RequestContextWithCallerID(rctx request.CTX, callerID string) request.CTX {
return rctx.WithContext(ctx)
}
+// setPluginCheckerForTests sets the plugin checker on the AccessControlHook for testing.
+func (ps *PropertyService) setPluginCheckerForTests(pluginChecker PluginChecker) {
+ for _, hook := range ps.hooks {
+ if ach, ok := hook.(*AccessControlHook); ok {
+ ach.setPluginCheckerForTests(pluginChecker)
+ }
+ }
+}
+
+func (h *AccessControlHook) setPluginCheckerForTests(pluginChecker PluginChecker) {
+ h.pluginChecker = pluginChecker
+}
+
func (th *TestHelper) RegisterCPAPropertyGroup(tb testing.TB) *TestHelper {
// Register the CPA group so requiresAccessControl can always look it up
- group, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: model.CustomProfileAttributesPropertyGroupName, Version: model.PropertyGroupVersionV1})
+ group, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2})
require.NoError(tb, groupErr)
th.CPAGroupID = group.ID
+ // Create and register the access control hook now that the group ID is known
+ hook := NewAccessControlHook(th.service, nil, group.ID)
+ th.service.AddHook(hook)
+
return th
}
diff --git a/server/channels/app/properties/hooks.go b/server/channels/app/properties/hooks.go
new file mode 100644
index 00000000000..9e62c5956df
--- /dev/null
+++ b/server/channels/app/properties/hooks.go
@@ -0,0 +1,455 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "errors"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/mlog"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+)
+
+// errNilHookResult is returned when a pre-hook returns a nil result without an
+// error. This catches buggy hook implementations early rather than letting a
+// nil propagate into the store layer.
+var (
+ errNilHookResult = errors.New("property hook returned nil result")
+ errFieldCardinalityBroken = errors.New("PostGetPropertyFields hook returned fewer fields than it received")
+)
+
+// PropertyHook defines an interface for hooks that run before and after property
+// service operations. Hooks can inspect and modify inputs (pre-hooks) or filter
+// outputs (post-hooks). A pre-hook returns an error to block the operation; a
+// post-hook returns an error to suppress the result. Returning nil means the
+// hook has no objection and the operation may proceed.
+//
+// Pre-hooks receive the operation's input parameters and may return modified
+// versions. Post-hooks receive the operation's results and may return filtered
+// or modified versions.
+//
+// Multiple hooks are called in registration order. Each hook receives the
+// output of the previous hook (or the original input for the first hook).
+type PropertyHook interface {
+ // Field pre-hooks (write operations)
+
+ PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error)
+ PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error)
+ PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error)
+ PreDeletePropertyField(rctx request.CTX, groupID string, id string) error
+
+ // PostUpdatePropertyFields runs after a successful field update (including
+ // the linked-field propagation pass). It receives the pre-update state of
+ // the requested fields (parallel to requested), the post-update requested
+ // fields, and the post-update propagated fields. Hooks may transform attrs
+ // on either bucket (e.g. redact information for the caller); the
+ // dispatcher enforces cardinality preservation on both buckets so a buggy
+ // hook that drops fields surfaces an error rather than silently truncating
+ // the broadcast. Returns the IDs of fields whose dependent property values
+ // were cleared as a side effect (e.g. type-change cleanup); the caller
+ // publishes the corresponding WS events. Errors are best-effort: the
+ // dispatcher logs and continues, the update is not rolled back.
+ PostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) (newRequested, newPropagated []*model.PropertyField, clearedFieldIDs []string, err error)
+
+ // Field pre-hook for count operations. Count operations return only a
+ // scalar so there is no post-hook — access control applied to per-row
+ // data does not apply, but license/group-level gating still does.
+ // Return an error to block the count.
+ PreCountPropertyFields(rctx request.CTX, groupID string) error
+
+ // Field post-hooks (read operations)
+ //
+ // PostGetPropertyField is called after retrieving a single field (by ID or by name).
+ // Implementations must return a non-nil field; returning nil is treated as a
+ // hook bug and the dispatcher surfaces errNilHookResult. To block a caller
+ // from seeing a field, return a sentinel error instead.
+ PostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error)
+ // PostGetPropertyFields is called after retrieving multiple fields (by IDs or search).
+ // Implementations must preserve slice length — the dispatcher enforces this and will
+ // return an error if a hook returns fewer fields than it received.
+ PostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error)
+
+ // Value pre-hooks (write operations)
+
+ PreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
+ PreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
+ PreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error)
+ PreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error)
+ PreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
+ PreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
+ PreDeletePropertyValue(rctx request.CTX, groupID string, id string) error
+ PreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error
+ PreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error
+
+ // Value post-hooks (read operations)
+ //
+ // PostGetPropertyValue is called after retrieving a single value.
+ // Return nil value to indicate the value is not accessible.
+ PostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
+ // PostGetPropertyValues is called after retrieving multiple values (by IDs or search).
+ // Implementations may remove entries from the returned slice.
+ PostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
+}
+
+// BasePropertyHook provides default passthrough implementations for every
+// PropertyHook method. Embed it in concrete hooks to only override the
+// methods you care about.
+type BasePropertyHook struct{}
+
+func (BasePropertyHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, nil
+}
+func (BasePropertyHook) PreUpdatePropertyField(_ request.CTX, _ string, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, nil
+}
+func (BasePropertyHook) PreUpdatePropertyFields(_ request.CTX, _ string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ return fields, nil
+}
+func (BasePropertyHook) PreDeletePropertyField(_ request.CTX, _ string, _ string) error {
+ return nil
+}
+func (BasePropertyHook) PostUpdatePropertyFields(_ request.CTX, _ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ return requested, propagated, nil, nil
+}
+func (BasePropertyHook) PreCountPropertyFields(_ request.CTX, _ string) error {
+ return nil
+}
+func (BasePropertyHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, nil
+}
+func (BasePropertyHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ return fields, nil
+}
+func (BasePropertyHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, nil
+}
+func (BasePropertyHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ return values, nil
+}
+func (BasePropertyHook) PreUpdatePropertyValue(_ request.CTX, _ string, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, nil
+}
+func (BasePropertyHook) PreUpdatePropertyValues(_ request.CTX, _ string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ return values, nil
+}
+func (BasePropertyHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, nil
+}
+func (BasePropertyHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ return values, nil
+}
+func (BasePropertyHook) PreDeletePropertyValue(_ request.CTX, _ string, _ string) error {
+ return nil
+}
+func (BasePropertyHook) PreDeletePropertyValuesForTarget(_ request.CTX, _ string, _ string, _ string) error {
+ return nil
+}
+func (BasePropertyHook) PreDeletePropertyValuesForField(_ request.CTX, _ string, _ string) error {
+ return nil
+}
+func (BasePropertyHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, nil
+}
+func (BasePropertyHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ return values, nil
+}
+
+// AddHook registers a hook with the property service. Hooks are called in
+// registration order for each operation.
+func (ps *PropertyService) AddHook(hook PropertyHook) {
+ ps.hooks = append(ps.hooks, hook)
+}
+
+// runPreCreatePropertyField runs all registered pre-hooks for CreatePropertyField.
+func (ps *PropertyService) runPreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ field, err = hook.PreCreatePropertyField(rctx, field)
+ if err != nil {
+ return nil, err
+ }
+ if field == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return field, nil
+}
+
+// runPreUpdatePropertyField runs all registered pre-hooks for UpdatePropertyField.
+func (ps *PropertyService) runPreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ field, err = hook.PreUpdatePropertyField(rctx, groupID, field)
+ if err != nil {
+ return nil, err
+ }
+ if field == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return field, nil
+}
+
+// runPreUpdatePropertyFields runs all registered pre-hooks for UpdatePropertyFields.
+func (ps *PropertyService) runPreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ fields, err = hook.PreUpdatePropertyFields(rctx, groupID, fields)
+ if err != nil {
+ return nil, err
+ }
+ if fields == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return fields, nil
+}
+
+// runPostUpdatePropertyFields runs all registered post-hooks for
+// UpdatePropertyFields. Each hook may transform the requested and propagated
+// buckets in place (e.g. redaction); the dispatcher chains the transformed
+// slices through subsequent hooks and enforces cardinality preservation on
+// both buckets so a buggy hook that drops fields surfaces an error rather
+// than silently truncating the broadcast. The cleared field IDs returned by
+// each hook are deduped into a single slice. Best-effort: hook errors and
+// cardinality violations are logged and skipped (the offending hook's
+// transform is dropped for the chain, but the update itself is not rolled
+// back).
+func (ps *PropertyService) runPostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string) {
+ seen := map[string]struct{}{}
+ var cleared []string
+ for _, hook := range ps.hooks {
+ newRequested, newPropagated, ids, err := hook.PostUpdatePropertyFields(rctx, groupID, prev, requested, propagated)
+ if err != nil {
+ rctx.Logger().Error("PostUpdatePropertyFields hook failed",
+ mlog.String("group_id", groupID),
+ mlog.Err(err),
+ )
+ continue
+ }
+ if len(newRequested) != len(requested) || len(newPropagated) != len(propagated) {
+ rctx.Logger().Error("PostUpdatePropertyFields hook returned wrong-length slice",
+ mlog.String("group_id", groupID),
+ mlog.Err(errFieldCardinalityBroken),
+ )
+ continue
+ }
+ requested = newRequested
+ propagated = newPropagated
+ for _, id := range ids {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ cleared = append(cleared, id)
+ }
+ }
+ return requested, propagated, cleared
+}
+
+// runPreDeletePropertyField runs all registered pre-hooks for DeletePropertyField.
+func (ps *PropertyService) runPreDeletePropertyField(rctx request.CTX, groupID string, id string) error {
+ for _, hook := range ps.hooks {
+ if err := hook.PreDeletePropertyField(rctx, groupID, id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// runPreCountPropertyFields runs all registered pre-hooks for the public
+// CountProperty* methods.
+func (ps *PropertyService) runPreCountPropertyFields(rctx request.CTX, groupID string) error {
+ for _, hook := range ps.hooks {
+ if err := hook.PreCountPropertyFields(rctx, groupID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// runPostGetPropertyField runs all registered post-hooks for single field retrieval.
+func (ps *PropertyService) runPostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if field == nil {
+ return nil, nil
+ }
+ var err error
+ for _, hook := range ps.hooks {
+ field, err = hook.PostGetPropertyField(rctx, field)
+ if err != nil {
+ return nil, err
+ }
+ if field == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return field, nil
+}
+
+// runPostGetPropertyFields runs all registered post-hooks for multi-field retrieval.
+// It enforces that hooks preserve slice length — a hook that drops fields is a bug.
+func (ps *PropertyService) runPostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ n := len(fields)
+ fields, err = hook.PostGetPropertyFields(rctx, fields)
+ if err != nil {
+ return nil, err
+ }
+ if len(fields) != n {
+ return nil, errFieldCardinalityBroken
+ }
+ }
+ return fields, nil
+}
+
+// runPreCreatePropertyValue runs all registered pre-hooks for CreatePropertyValue.
+func (ps *PropertyService) runPreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ value, err = hook.PreCreatePropertyValue(rctx, value)
+ if err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return value, nil
+}
+
+// runPreCreatePropertyValues runs all registered pre-hooks for CreatePropertyValues.
+func (ps *PropertyService) runPreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ values, err = hook.PreCreatePropertyValues(rctx, values)
+ if err != nil {
+ return nil, err
+ }
+ if values == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return values, nil
+}
+
+// runPreUpdatePropertyValue runs all registered pre-hooks for UpdatePropertyValue.
+func (ps *PropertyService) runPreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ value, err = hook.PreUpdatePropertyValue(rctx, groupID, value)
+ if err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return value, nil
+}
+
+// runPreUpdatePropertyValues runs all registered pre-hooks for UpdatePropertyValues.
+func (ps *PropertyService) runPreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ values, err = hook.PreUpdatePropertyValues(rctx, groupID, values)
+ if err != nil {
+ return nil, err
+ }
+ if values == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return values, nil
+}
+
+// runPreUpsertPropertyValue runs all registered pre-hooks for UpsertPropertyValue.
+func (ps *PropertyService) runPreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ value, err = hook.PreUpsertPropertyValue(rctx, value)
+ if err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return value, nil
+}
+
+// runPreUpsertPropertyValues runs all registered pre-hooks for UpsertPropertyValues.
+func (ps *PropertyService) runPreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ values, err = hook.PreUpsertPropertyValues(rctx, values)
+ if err != nil {
+ return nil, err
+ }
+ if values == nil {
+ return nil, errNilHookResult
+ }
+ }
+ return values, nil
+}
+
+// runPreDeletePropertyValue runs all registered pre-hooks for DeletePropertyValue.
+func (ps *PropertyService) runPreDeletePropertyValue(rctx request.CTX, groupID string, id string) error {
+ for _, hook := range ps.hooks {
+ if err := hook.PreDeletePropertyValue(rctx, groupID, id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// runPreDeletePropertyValuesForTarget runs all registered pre-hooks for DeletePropertyValuesForTarget.
+func (ps *PropertyService) runPreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
+ for _, hook := range ps.hooks {
+ if err := hook.PreDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// runPreDeletePropertyValuesForField runs all registered pre-hooks for DeletePropertyValuesForField.
+func (ps *PropertyService) runPreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error {
+ for _, hook := range ps.hooks {
+ if err := hook.PreDeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// runPostGetPropertyValue runs all registered post-hooks for single value retrieval.
+func (ps *PropertyService) runPostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if value == nil {
+ return nil, nil
+ }
+ var err error
+ for _, hook := range ps.hooks {
+ value, err = hook.PostGetPropertyValue(rctx, value)
+ if err != nil {
+ return nil, err
+ }
+ if value == nil {
+ return nil, nil
+ }
+ }
+ return value, nil
+}
+
+// runPostGetPropertyValues runs all registered post-hooks for multi-value retrieval.
+func (ps *PropertyService) runPostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ var err error
+ for _, hook := range ps.hooks {
+ values, err = hook.PostGetPropertyValues(rctx, values)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return values, nil
+}
diff --git a/server/channels/app/properties/hooks_test.go b/server/channels/app/properties/hooks_test.go
new file mode 100644
index 00000000000..efa52e1f778
--- /dev/null
+++ b/server/channels/app/properties/hooks_test.go
@@ -0,0 +1,637 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testHook is a configurable PropertyHook implementation for testing hook
+// registration, ordering, chaining, and blocking behavior. It embeds
+// BasePropertyHook for default passthrough behavior and only overrides
+// methods where a test-specific function is set.
+type testHook struct {
+ BasePropertyHook
+ preCreateFieldFn func(*model.PropertyField) (*model.PropertyField, error)
+ preUpdateFieldFn func(string, *model.PropertyField) (*model.PropertyField, error)
+ preUpdateFieldsFn func(string, []*model.PropertyField) ([]*model.PropertyField, error)
+ preDeleteFieldFn func(string, string) error
+ postUpdateFieldsFn func(string, []*model.PropertyField, []*model.PropertyField, []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error)
+ postGetFieldFn func(*model.PropertyField) (*model.PropertyField, error)
+ postGetFieldsFn func([]*model.PropertyField) ([]*model.PropertyField, error)
+ preUpsertValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
+ preUpsertValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
+ postGetValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
+ postGetValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
+}
+
+func (h *testHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if h.preCreateFieldFn != nil {
+ return h.preCreateFieldFn(field)
+ }
+ return field, nil
+}
+
+func (h *testHook) PreUpdatePropertyField(_ request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
+ if h.preUpdateFieldFn != nil {
+ return h.preUpdateFieldFn(groupID, field)
+ }
+ return field, nil
+}
+
+func (h *testHook) PreUpdatePropertyFields(_ request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if h.preUpdateFieldsFn != nil {
+ return h.preUpdateFieldsFn(groupID, fields)
+ }
+ return fields, nil
+}
+
+func (h *testHook) PreDeletePropertyField(_ request.CTX, groupID string, id string) error {
+ if h.preDeleteFieldFn != nil {
+ return h.preDeleteFieldFn(groupID, id)
+ }
+ return nil
+}
+
+func (h *testHook) PostUpdatePropertyFields(_ request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ if h.postUpdateFieldsFn != nil {
+ return h.postUpdateFieldsFn(groupID, prev, requested, propagated)
+ }
+ return requested, propagated, nil, nil
+}
+
+func (h *testHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ if h.postGetFieldFn != nil {
+ return h.postGetFieldFn(field)
+ }
+ return field, nil
+}
+
+func (h *testHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if h.postGetFieldsFn != nil {
+ return h.postGetFieldsFn(fields)
+ }
+ return fields, nil
+}
+
+func (h *testHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if h.preUpsertValueFn != nil {
+ return h.preUpsertValueFn(value)
+ }
+ return value, nil
+}
+
+func (h *testHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if h.preUpsertValuesFn != nil {
+ return h.preUpsertValuesFn(values)
+ }
+ return values, nil
+}
+
+func (h *testHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if h.postGetValueFn != nil {
+ return h.postGetValueFn(value)
+ }
+ return value, nil
+}
+
+func (h *testHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if h.postGetValuesFn != nil {
+ return h.postGetValuesFn(values)
+ }
+ return values, nil
+}
+
+func TestHookRegistration(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+
+ t.Run("service starts with no hooks", func(t *testing.T) {
+ service, err := New(ServiceConfig{
+ PropertyGroupStore: th.dbStore.PropertyGroup(),
+ PropertyFieldStore: th.dbStore.PropertyField(),
+ PropertyValueStore: th.dbStore.PropertyValue(),
+ })
+ require.NoError(t, err)
+ assert.Empty(t, service.hooks)
+ })
+
+ t.Run("AddHook appends hooks in order", func(t *testing.T) {
+ service, err := New(ServiceConfig{
+ PropertyGroupStore: th.dbStore.PropertyGroup(),
+ PropertyFieldStore: th.dbStore.PropertyField(),
+ PropertyValueStore: th.dbStore.PropertyValue(),
+ })
+ require.NoError(t, err)
+
+ hook1 := &testHook{}
+ hook2 := &testHook{}
+ service.AddHook(hook1)
+ service.AddHook(hook2)
+ assert.Len(t, service.hooks, 2)
+ })
+}
+
+func TestPreHookBlocking(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("pre-hook error blocks CreatePropertyField", func(t *testing.T) {
+ hook := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ return nil, fmt.Errorf("blocked by hook")
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "blocked-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ }
+ _, err := th.service.CreatePropertyField(rctx, field)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "blocked by hook")
+ })
+
+ t.Run("pre-hook error blocks DeletePropertyField", func(t *testing.T) {
+ hook := &testHook{
+ preDeleteFieldFn: func(gid string, id string) error {
+ return fmt.Errorf("delete blocked")
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ err := th.service.DeletePropertyField(rctx, groupID, model.NewId())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "delete blocked")
+ })
+
+ t.Run("pre-hook error blocks UpsertPropertyValue", func(t *testing.T) {
+ hook := &testHook{
+ preUpsertValueFn: func(value *model.PropertyValue) (*model.PropertyValue, error) {
+ return nil, fmt.Errorf("upsert blocked")
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ value := &model.PropertyValue{
+ GroupID: groupID,
+ FieldID: model.NewId(),
+ }
+ _, err := th.service.UpsertPropertyValue(rctx, value)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "upsert blocked")
+ })
+}
+
+func TestPreHookInputModification(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("pre-hook modifies field before creation", func(t *testing.T) {
+ hook := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ // Modify the field name
+ field.Name = "modified-" + field.Name
+ return field, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "original",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ }
+ result, err := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, err)
+ assert.Equal(t, "modified-original", result.Name)
+ })
+}
+
+func TestPostHookFiltering(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("post-hook returning nil field without error surfaces errNilHookResult", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "nil-return-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+
+ hook := &testHook{
+ postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
+ return nil, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
+ require.ErrorIs(t, err, errNilHookResult)
+ assert.Nil(t, result)
+ })
+
+ t.Run("post-hook that drops fields from list returns error", func(t *testing.T) {
+ field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "keep-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+ field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "remove-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+
+ hook := &testHook{
+ postGetFieldsFn: func(fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ filtered := []*model.PropertyField{}
+ for _, f := range fields {
+ if f.ID == field1.ID {
+ filtered = append(filtered, f)
+ }
+ }
+ return filtered, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ _, err := th.service.GetPropertyFields(rctx, groupID, []string{field1.ID, field2.ID})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "fewer fields")
+ })
+}
+
+func TestMultipleHooksChaining(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("multiple pre-hooks chain modifications in order", func(t *testing.T) {
+ order := []string{}
+
+ hook1 := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ order = append(order, "hook1")
+ field.Name = field.Name + "-h1"
+ return field, nil
+ },
+ }
+ hook2 := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ order = append(order, "hook2")
+ field.Name = field.Name + "-h2"
+ return field, nil
+ },
+ }
+ th.service.AddHook(hook1)
+ th.service.AddHook(hook2)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
+
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "base",
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ }
+ result, err := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, err)
+ assert.Equal(t, "base-h1-h2", result.Name)
+ assert.Equal(t, []string{"hook1", "hook2"}, order)
+ })
+
+ t.Run("first hook error prevents second hook from running", func(t *testing.T) {
+ hook2Called := false
+
+ hook1 := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ return nil, fmt.Errorf("hook1 blocked")
+ },
+ }
+ hook2 := &testHook{
+ preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
+ hook2Called = true
+ return field, nil
+ },
+ }
+ th.service.AddHook(hook1)
+ th.service.AddHook(hook2)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
+
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "should-fail-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ }
+ _, err := th.service.CreatePropertyField(rctx, field)
+ require.Error(t, err)
+ assert.False(t, hook2Called, "second hook should not have been called")
+ })
+
+ t.Run("multiple post-hooks chain in order", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "chain-post-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ Attrs: model.StringInterface{"step": "0"},
+ })
+
+ hook1 := &testHook{
+ postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
+ if f.Attrs == nil {
+ f.Attrs = make(model.StringInterface)
+ }
+ f.Attrs["hook1"] = true
+ return f, nil
+ },
+ }
+ hook2 := &testHook{
+ postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
+ if f.Attrs == nil {
+ f.Attrs = make(model.StringInterface)
+ }
+ f.Attrs["hook2"] = true
+ return f, nil
+ },
+ }
+ th.service.AddHook(hook1)
+ th.service.AddHook(hook2)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
+
+ result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
+ require.NoError(t, err)
+ assert.Equal(t, true, result.Attrs["hook1"])
+ assert.Equal(t, true, result.Attrs["hook2"])
+ })
+}
+
+func TestAccessControlHookGroupScoping(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+
+ th.service.setPluginCheckerForTests(func(pluginID string) bool {
+ return pluginID == "plugin-1"
+ })
+
+ rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
+ rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
+
+ t.Run("access control enforced for managed group (CPA)", func(t *testing.T) {
+ // Create a protected field in the CPA group via the source plugin
+ field := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "protected-managed-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ created, err := th.service.CreatePropertyField(rctxPlugin1, field)
+ require.NoError(t, err)
+ assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
+
+ // Another plugin should NOT be able to update it (protected)
+ created.Attrs[model.PropertyAttrsProtected] = true
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
+ require.NoError(t, err)
+
+ updated.Name = "attempt-update"
+ _, _, err = th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, updated)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "protected")
+ })
+
+ t.Run("access control NOT enforced for unmanaged group", func(t *testing.T) {
+ unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_scoping_test", Version: model.PropertyGroupVersionV1})
+ require.NoError(t, err)
+
+ // Create a protected field in an unmanaged group
+ field := &model.PropertyField{
+ GroupID: unmanagedGroup.ID,
+ Name: "protected-unmanaged-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyAttrsProtected: true,
+ model.PropertyAttrsSourcePluginID: "plugin-1",
+ },
+ }
+ created, err := th.service.CreatePropertyField(rctxPlugin1, field)
+ require.NoError(t, err)
+
+ // Another plugin CAN update it (no access control for this group)
+ created.Name = "updated-by-plugin2"
+ updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, unmanagedGroup.ID, created)
+ require.NoError(t, err)
+ assert.Equal(t, "updated-by-plugin2", updated.Name)
+ })
+
+ t.Run("read filtering applied for managed group", func(t *testing.T) {
+ // Create a source-only protected field in the CPA group
+ field := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "source-only-managed-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
+ model.PropertyAttrsProtected: true,
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": "opt1", "value": "Option 1"},
+ map[string]any{"id": "opt2", "value": "Option 2"},
+ },
+ },
+ }
+ created, err := th.service.CreatePropertyField(rctxPlugin1, field)
+ require.NoError(t, err)
+
+ // Source plugin sees all options
+ result, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created.ID)
+ require.NoError(t, err)
+ opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
+ assert.Len(t, opts, 2)
+
+ // Other caller sees empty options
+ result2, err := th.service.GetPropertyField(rctx, th.CPAGroupID, created.ID)
+ require.NoError(t, err)
+ opts2 := result2.Attrs[model.PropertyFieldAttributeOptions].([]any)
+ assert.Len(t, opts2, 0)
+ })
+
+ t.Run("read filtering NOT applied for unmanaged group", func(t *testing.T) {
+ unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_read_test", Version: model.PropertyGroupVersionV1})
+ require.NoError(t, err)
+
+ // Create a source-only field in an unmanaged group
+ field := &model.PropertyField{
+ GroupID: unmanagedGroup.ID,
+ Name: "source-only-unmanaged-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ TargetType: "user",
+ Attrs: model.StringInterface{
+ model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
+ model.PropertyAttrsSourcePluginID: "plugin-1",
+ model.PropertyFieldAttributeOptions: []any{
+ map[string]any{"id": "opt1", "value": "Option 1"},
+ map[string]any{"id": "opt2", "value": "Option 2"},
+ },
+ },
+ }
+ created, err := th.service.CreatePropertyField(rctxPlugin1, field)
+ require.NoError(t, err)
+
+ // Non-source caller sees ALL options (no filtering for unmanaged groups)
+ result, err := th.service.GetPropertyField(rctx, unmanagedGroup.ID, created.ID)
+ require.NoError(t, err)
+ opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
+ assert.Len(t, opts, 2)
+ })
+}
+
+func TestPreUpdatePropertyFieldsHook(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("pre-hook error blocks batch UpdatePropertyFields", func(t *testing.T) {
+ hook := &testHook{
+ preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ return nil, fmt.Errorf("batch update blocked")
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "batch-block-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+ field.Name = "updated"
+ _, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "batch update blocked")
+ })
+
+ t.Run("pre-hook modifies fields in batch update", func(t *testing.T) {
+ hook := &testHook{
+ preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ for _, f := range fields {
+ f.Name = "modified-" + f.Name
+ }
+ return fields, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "batch-a-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+ field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "batch-b-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+
+ field1.Name = "a"
+ field2.Name = "b"
+ results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field1, field2})
+ require.NoError(t, err)
+ require.Len(t, results, 2)
+ assert.Equal(t, "modified-a", results[0].Name)
+ assert.Equal(t, "modified-b", results[1].Name)
+ })
+}
+
+func TestPostUpdatePropertyFieldsHook(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ rctx := th.Context
+ groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
+
+ t.Run("post-hook transforms requested attrs and surfaces cleared IDs", func(t *testing.T) {
+ hook := &testHook{
+ postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ for _, f := range requested {
+ if f.Attrs == nil {
+ f.Attrs = model.StringInterface{}
+ }
+ f.Attrs["redacted"] = true
+ }
+ ids := make([]string, 0, len(requested))
+ for _, f := range requested {
+ ids = append(ids, f.ID)
+ }
+ return requested, propagated, ids, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "post-transform-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+ field.Name = "post-transform-renamed-" + model.NewId()
+
+ results, _, cleared, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, true, results[0].Attrs["redacted"], "post-hook attr transform must reach caller")
+ assert.Equal(t, []string{field.ID}, cleared, "cleared IDs from post-hook must be surfaced")
+ })
+
+ t.Run("post-hook returning wrong-length requested slice is skipped", func(t *testing.T) {
+ hook := &testHook{
+ postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ // Drop a field — cardinality guard must reject this transform.
+ return requested[:0], propagated, nil, nil
+ },
+ }
+ th.service.AddHook(hook)
+ defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
+
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: groupID,
+ Name: "post-cardinality-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "user",
+ })
+ field.Name = "post-cardinality-renamed-" + model.NewId()
+
+ results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
+ require.NoError(t, err)
+ assert.Len(t, results, 1, "wrong-length transform must be discarded; original requested must survive")
+ })
+}
diff --git a/server/channels/app/properties/license_check.go b/server/channels/app/properties/license_check.go
new file mode 100644
index 00000000000..6ede3a3cdd0
--- /dev/null
+++ b/server/channels/app/properties/license_check.go
@@ -0,0 +1,148 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "errors"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+)
+
+var ErrLicenseRequired = errors.New("license_error: an Enterprise license is required")
+
+// LicenseProvider is a function that returns the current license.
+type LicenseProvider func() *model.License
+
+// LicenseCheckHook enforces license requirements for property operations on
+// specific groups. Operations on groups without a license requirement pass
+// through without checks.
+type LicenseCheckHook struct {
+ BasePropertyHook
+ licenseProvider LicenseProvider
+ managedGroupIDs map[string]struct{}
+}
+
+var _ PropertyHook = (*LicenseCheckHook)(nil)
+
+// NewLicenseCheckHook creates a hook that requires an Enterprise license for
+// all field and value operations on the given property groups.
+func NewLicenseCheckHook(licenseProvider LicenseProvider, managedGroupIDs ...string) *LicenseCheckHook {
+ ids := make(map[string]struct{}, len(managedGroupIDs))
+ for _, id := range managedGroupIDs {
+ ids[id] = struct{}{}
+ }
+ return &LicenseCheckHook{
+ licenseProvider: licenseProvider,
+ managedGroupIDs: ids,
+ }
+}
+
+// requireLicense returns ErrLicenseRequired when groupID is in the managed set
+// and no Enterprise license is active. Unmanaged groups and licensed calls
+// return nil.
+func (h *LicenseCheckHook) requireLicense(groupID string) error {
+ if _, managed := h.managedGroupIDs[groupID]; !managed {
+ return nil
+ }
+ if !model.MinimumEnterpriseLicense(h.licenseProvider()) {
+ return ErrLicenseRequired
+ }
+ return nil
+}
+
+// Field pre-hooks
+
+func (h *LicenseCheckHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, h.requireLicense(field.GroupID)
+}
+
+func (h *LicenseCheckHook) PreUpdatePropertyField(_ request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreUpdatePropertyFields(_ request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ return fields, h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreDeletePropertyField(_ request.CTX, groupID string, _ string) error {
+ return h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreCountPropertyFields(_ request.CTX, groupID string) error {
+ return h.requireLicense(groupID)
+}
+
+// Field post-hooks
+
+func (h *LicenseCheckHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
+ return field, h.requireLicense(field.GroupID)
+}
+
+func (h *LicenseCheckHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
+ if len(fields) == 0 {
+ return fields, nil
+ }
+ return fields, h.requireLicense(fields[0].GroupID)
+}
+
+// Value pre-hooks
+
+func (h *LicenseCheckHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, h.requireLicense(value.GroupID)
+}
+
+func (h *LicenseCheckHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 {
+ return values, nil
+ }
+ return values, h.requireLicense(values[0].GroupID)
+}
+
+func (h *LicenseCheckHook) PreUpdatePropertyValue(_ request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreUpdatePropertyValues(_ request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ return values, h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ return value, h.requireLicense(value.GroupID)
+}
+
+func (h *LicenseCheckHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 {
+ return values, nil
+ }
+ return values, h.requireLicense(values[0].GroupID)
+}
+
+func (h *LicenseCheckHook) PreDeletePropertyValue(_ request.CTX, groupID string, _ string) error {
+ return h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreDeletePropertyValuesForTarget(_ request.CTX, groupID string, _ string, _ string) error {
+ return h.requireLicense(groupID)
+}
+
+func (h *LicenseCheckHook) PreDeletePropertyValuesForField(_ request.CTX, groupID string, _ string) error {
+ return h.requireLicense(groupID)
+}
+
+// Value post-hooks
+
+func (h *LicenseCheckHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
+ if value == nil {
+ return value, nil
+ }
+ return value, h.requireLicense(value.GroupID)
+}
+
+func (h *LicenseCheckHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
+ if len(values) == 0 {
+ return values, nil
+ }
+ return values, h.requireLicense(values[0].GroupID)
+}
diff --git a/server/channels/app/properties/license_check_test.go b/server/channels/app/properties/license_check_test.go
new file mode 100644
index 00000000000..a47254b6c70
--- /dev/null
+++ b/server/channels/app/properties/license_check_test.go
@@ -0,0 +1,140 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLicenseCheckHook(t *testing.T) {
+ th := Setup(t)
+
+ group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_license_check", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, err)
+
+ var currentLicense *model.License
+ hook := NewLicenseCheckHook(func() *model.License {
+ return currentLicense
+ }, group.ID)
+ th.service.AddHook(hook)
+
+ enterpriseLicense := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
+
+ makeField := func() *model.PropertyField {
+ return &model.PropertyField{
+ GroupID: group.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ }
+
+ t.Run("blocks field create without license", func(t *testing.T) {
+ currentLicense = nil
+ _, createErr := th.service.CreatePropertyField(th.Context, makeField())
+ require.Error(t, createErr)
+ assert.Contains(t, createErr.Error(), "license_error")
+ })
+
+ t.Run("allows field create with license, blocks read after license loss", func(t *testing.T) {
+ currentLicense = enterpriseLicense
+ created, createErr := th.service.CreatePropertyField(th.Context, makeField())
+ require.NoError(t, createErr)
+ assert.NotEmpty(t, created.ID)
+
+ currentLicense = nil
+ _, getErr := th.service.GetPropertyField(th.Context, group.ID, created.ID)
+ require.Error(t, getErr)
+ assert.Contains(t, getErr.Error(), "license_error")
+ })
+
+ t.Run("blocks value upsert without license", func(t *testing.T) {
+ currentLicense = enterpriseLicense
+ field := th.CreatePropertyFieldDirect(t, makeField())
+
+ currentLicense = nil
+ value := &model.PropertyValue{
+ GroupID: group.ID,
+ FieldID: field.ID,
+ TargetID: model.NewId(),
+ TargetType: "user",
+ Value: json.RawMessage(`"hello"`),
+ }
+ _, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
+ require.Error(t, upsertErr)
+ assert.Contains(t, upsertErr.Error(), "license_error")
+ })
+
+ t.Run("allows operations on unmanaged groups without license", func(t *testing.T) {
+ currentLicense = nil
+ otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_no_license_needed", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, groupErr)
+
+ field := &model.PropertyField{
+ GroupID: otherGroup.ID,
+ Name: "field_" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ TargetType: "system",
+ ObjectType: "user",
+ }
+ created, createErr := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, createErr)
+ assert.NotEmpty(t, created.ID)
+ })
+
+ countCalls := []struct {
+ name string
+ call func(groupID string) error
+ }{
+ {"CountActivePropertyFieldsForGroup", func(id string) error {
+ _, err := th.service.CountActivePropertyFieldsForGroup(th.Context, id)
+ return err
+ }},
+ {"CountAllPropertyFieldsForGroup", func(id string) error {
+ _, err := th.service.CountAllPropertyFieldsForGroup(th.Context, id)
+ return err
+ }},
+ {"CountActivePropertyFieldsForTarget", func(id string) error {
+ _, err := th.service.CountActivePropertyFieldsForTarget(th.Context, id, "user", model.NewId())
+ return err
+ }},
+ {"CountAllPropertyFieldsForTarget", func(id string) error {
+ _, err := th.service.CountAllPropertyFieldsForTarget(th.Context, id, "user", model.NewId())
+ return err
+ }},
+ }
+
+ t.Run("blocks field counts without license on managed group", func(t *testing.T) {
+ currentLicense = enterpriseLicense
+ th.CreatePropertyFieldDirect(t, makeField())
+ currentLicense = nil
+ for _, c := range countCalls {
+ err := c.call(group.ID)
+ require.Error(t, err, c.name)
+ assert.Contains(t, err.Error(), "license_error", c.name)
+ }
+ })
+
+ t.Run("allows field counts with license on managed group", func(t *testing.T) {
+ currentLicense = enterpriseLicense
+ for _, c := range countCalls {
+ require.NoError(t, c.call(group.ID), c.name)
+ }
+ })
+
+ t.Run("allows field counts without license on unmanaged group", func(t *testing.T) {
+ currentLicense = nil
+ otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "count_no_license_needed", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, groupErr)
+ for _, c := range countCalls {
+ require.NoError(t, c.call(otherGroup.ID), c.name)
+ }
+ })
+}
diff --git a/server/channels/app/properties/migrations.go b/server/channels/app/properties/migrations.go
index 78acc86574e..c522303f5f1 100644
--- a/server/channels/app/properties/migrations.go
+++ b/server/channels/app/properties/migrations.go
@@ -30,7 +30,7 @@ import (
// Returns the number of fields that were backfilled and the number that were
// skipped, so the caller can log a summary.
func (ps *PropertyService) MigrateBackfillCPADisplayName(rctx request.CTX) (backfilled int, skipped int, err error) {
- group, err := ps.Group(model.CustomProfileAttributesPropertyGroupName)
+ group, err := ps.Group(model.AccessControlPropertyGroupName)
if err != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to get CPA property group: %w", err)
}
@@ -74,7 +74,7 @@ func (ps *PropertyService) MigrateBackfillCPADisplayName(rctx request.CTX) (back
// Use the unexported updatePropertyFields for the same reason as
// searchPropertyFields above: the AC layer rejects writes from the
// system to fields owned by a source plugin.
- if _, _, updateErr := ps.updatePropertyFields(groupID, fieldsToUpdate); updateErr != nil {
+ if _, _, _, updateErr := ps.updatePropertyFields(rctx, groupID, fieldsToUpdate); updateErr != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to update CPA fields: %w", updateErr)
}
}
diff --git a/server/channels/app/properties/property_field.go b/server/channels/app/properties/property_field.go
index befc9ff1e9a..f4649600cfe 100644
--- a/server/channels/app/properties/property_field.go
+++ b/server/channels/app/properties/property_field.go
@@ -5,6 +5,7 @@ package properties
import (
"context"
+ "errors"
"fmt"
"net/http"
"reflect"
@@ -43,10 +44,8 @@ func (ps *PropertyService) createPropertyField(field *model.PropertyField) (*mod
return nil, err
}
- // FIXME: Legacy properties (PSAv1) skip conflict check, but
- // template fields still need it because they can have linked
- // dependents.
- if field.IsPSAv1() && field.ObjectType != model.PropertyFieldObjectTypeTemplate {
+ // Legacy properties (PSAv1) skip the conflict check.
+ if field.IsPSAv1() {
return ps.fieldStore.Create(field)
}
@@ -182,7 +181,15 @@ func (ps *PropertyService) getPropertyFieldFromMaster(groupID, id string) (*mode
}
func (ps *PropertyService) getPropertyFields(groupID string, ids []string) ([]*model.PropertyField, error) {
- return ps.fieldStore.GetMany(context.Background(), groupID, ids)
+ fields, err := ps.fieldStore.GetMany(context.Background(), groupID, ids)
+ if err != nil {
+ var resultsMismatchErr *store.ErrResultsMismatch
+ if errors.As(err, &resultsMismatchErr) {
+ return nil, fmt.Errorf("%w: %w", ErrFieldNotFound, err)
+ }
+ return nil, err
+ }
+ return fields, nil
}
func (ps *PropertyService) getPropertyFieldByName(groupID, targetID, name string) (*model.PropertyField, error) {
@@ -197,6 +204,10 @@ func (ps *PropertyService) countAllPropertyFieldsForGroup(groupID string) (int64
return ps.fieldStore.CountForGroup(groupID, true)
}
+func (ps *PropertyService) countActivePropertyFieldsForGroupObjectType(groupID, objectType string) (int64, error) {
+ return ps.fieldStore.CountForGroupObjectType(groupID, objectType, false)
+}
+
func (ps *PropertyService) countActivePropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
return ps.fieldStore.CountForTarget(groupID, targetType, targetID, false)
}
@@ -213,25 +224,25 @@ func (ps *PropertyService) searchPropertyFields(groupID string, opts model.Prope
return ps.fieldStore.SearchPropertyFields(opts)
}
-func (ps *PropertyService) updatePropertyField(groupID string, field *model.PropertyField) (*model.PropertyField, error) {
- fields, _, err := ps.updatePropertyFields(groupID, []*model.PropertyField{field})
+func (ps *PropertyService) updatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, []string, error) {
+ fields, _, clearedIDs, err := ps.updatePropertyFields(rctx, groupID, []*model.PropertyField{field})
if err != nil {
- return nil, err
+ return nil, nil, err
}
- return fields[0], nil
+ return fields[0], clearedIDs, nil
}
-func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, err error) {
+func (ps *PropertyService) updatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, clearedFieldIDs []string, err error) {
if len(fields) == 0 {
- return nil, nil, nil
+ return nil, nil, nil, nil
}
// Fetch existing fields to compare for changes that require conflict check
ids := make([]string, len(fields))
for i, f := range fields {
if f == nil {
- return nil, nil, fmt.Errorf("field at index %d is nil", i)
+ return nil, nil, nil, fmt.Errorf("field at index %d is nil", i)
}
ids[i] = f.ID
}
@@ -241,7 +252,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
// TOCTOU window that a replica read would leave open.
existingFields, err := ps.fieldStore.GetMany(store.WithMaster(context.Background()), groupID, ids)
if err != nil {
- return nil, nil, fmt.Errorf("failed to get existing fields for update: %w", err)
+ return nil, nil, nil, fmt.Errorf("failed to get existing fields for update: %w", err)
}
// Build a map of existing fields by ID for quick lookup
@@ -253,7 +264,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
// Enforce version match between field and group for each field
for _, field := range fields {
if err := ps.enforceFieldGroupVersionMatch("UpdatePropertyFields", groupID, field); err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
}
@@ -264,16 +275,14 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
continue
}
- // FIXME: Legacy properties (PSAv1) skip conflict check, but
- // template fields still need it because they can have linked
- // dependents.
- if field.IsPSAv1() && field.ObjectType != model.PropertyFieldObjectTypeTemplate {
+ // Legacy properties (PSAv1) skip the conflict check.
+ if field.IsPSAv1() {
continue
}
// Block type changes on linked fields
if existing.LinkedFieldID != nil && *existing.LinkedFieldID != "" && field.Type != existing.Type {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.linked_type_change.app_error",
nil,
@@ -284,7 +293,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
// Block options changes on linked fields
if existing.LinkedFieldID != nil && *existing.LinkedFieldID != "" && optionsChanged(existing.Attrs, field.Attrs) {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.linked_options_change.app_error",
nil,
@@ -308,7 +317,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
newIsLinked := field.LinkedFieldID != nil
if !existingIsLinked && newIsLinked {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.cannot_link_existing.app_error",
nil,
@@ -320,7 +329,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
// Block changing link target. To re-link, unlink first then create a
// new linked field.
if existingIsLinked && newIsLinked && *field.LinkedFieldID != *existing.LinkedFieldID {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.cannot_change_link_target.app_error",
nil,
@@ -333,11 +342,11 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
if field.Type != existing.Type {
count, cErr := ps.fieldStore.CountLinkedFields(field.ID)
if cErr != nil {
- return nil, nil, fmt.Errorf("failed to count linked fields: %w", cErr)
+ return nil, nil, nil, fmt.Errorf("failed to count linked fields: %w", cErr)
}
if count > 0 {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.type_change_with_dependents.app_error",
nil,
@@ -357,11 +366,11 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
existing.ObjectType != field.ObjectType {
conflictLevel, cErr := ps.fieldStore.CheckPropertyNameConflict(field, field.ID)
if cErr != nil {
- return nil, nil, fmt.Errorf("failed to check property name conflict: %w", cErr)
+ return nil, nil, nil, fmt.Errorf("failed to check property name conflict: %w", cErr)
}
if conflictLevel != "" {
- return nil, nil, model.NewAppError(
+ return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.name_conflict.app_error",
map[string]any{"Name": field.Name, "ConflictLevel": string(conflictLevel)},
@@ -384,7 +393,7 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
// options to linked dependents automatically via a JOIN-based UPDATE.
all, uErr := ps.fieldStore.Update(groupID, fields, expectedUpdateAts)
if uErr != nil {
- return nil, nil, uErr
+ return nil, nil, nil, uErr
}
// Partition the returned fields into requested vs propagated by matching
@@ -405,7 +414,18 @@ func (ps *PropertyService) updatePropertyFields(groupID string, fields []*model.
}
}
- return requested, propagated, nil
+ // Run post-hooks. prev is parallel to requested. Hooks may transform
+ // either the requested or propagated bucket (e.g. attr redaction); the
+ // dispatcher enforces cardinality preservation on both buckets so a buggy
+ // hook that drops fields surfaces an error rather than silently truncating
+ // the broadcast. cleared IDs are unioned across hooks.
+ prev := make([]*model.PropertyField, 0, len(requested))
+ for _, r := range requested {
+ prev = append(prev, existingByID[r.ID])
+ }
+ requested, propagated, clearedFieldIDs = ps.runPostUpdatePropertyFields(rctx, groupID, prev, requested, propagated)
+
+ return requested, propagated, clearedFieldIDs, nil
}
func (ps *PropertyService) deletePropertyField(groupID, id string) error {
@@ -438,176 +458,122 @@ func (ps *PropertyService) deletePropertyField(groupID, id string) error {
return ps.fieldStore.Delete(groupID, id)
}
-// Public routing methods
+// Public methods
func (ps *PropertyService) CreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(field.GroupID)
+ field, err := ps.runPreCreatePropertyField(rctx, field)
if err != nil {
return nil, fmt.Errorf("CreatePropertyField: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.CreatePropertyField(callerID, field)
- }
-
return ps.createPropertyField(field)
}
func (ps *PropertyService) GetPropertyField(rctx request.CTX, groupID, id string) (*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ field, err := ps.getPropertyField(groupID, id)
if err != nil {
return nil, fmt.Errorf("GetPropertyField: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.GetPropertyField(callerID, groupID, id)
- }
-
- return ps.getPropertyField(groupID, id)
+ return ps.runPostGetPropertyField(rctx, field)
}
func (ps *PropertyService) GetPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ fields, err := ps.getPropertyFields(groupID, ids)
if err != nil {
return nil, fmt.Errorf("GetPropertyFields: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.GetPropertyFields(callerID, groupID, ids)
- }
-
- return ps.getPropertyFields(groupID, ids)
+ return ps.runPostGetPropertyFields(rctx, fields)
}
func (ps *PropertyService) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ field, err := ps.getPropertyFieldByName(groupID, targetID, name)
if err != nil {
return nil, fmt.Errorf("GetPropertyFieldByName: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.GetPropertyFieldByName(callerID, groupID, targetID, name)
- }
-
- return ps.getPropertyFieldByName(groupID, targetID, name)
+ return ps.runPostGetPropertyField(rctx, field)
}
func (ps *PropertyService) CountActivePropertyFieldsForGroup(rctx request.CTX, groupID string) (int64, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountActivePropertyFieldsForGroup: %w", err)
}
-
- if requiresAC {
- return ps.propertyAccess.CountActivePropertyFieldsForGroup(groupID)
- }
-
return ps.countActivePropertyFieldsForGroup(groupID)
}
func (ps *PropertyService) CountAllPropertyFieldsForGroup(rctx request.CTX, groupID string) (int64, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountAllPropertyFieldsForGroup: %w", err)
}
-
- if requiresAC {
- return ps.propertyAccess.CountAllPropertyFieldsForGroup(groupID)
- }
-
return ps.countAllPropertyFieldsForGroup(groupID)
}
func (ps *PropertyService) CountActivePropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string) (int64, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountActivePropertyFieldsForTarget: %w", err)
}
-
- if requiresAC {
- return ps.propertyAccess.CountActivePropertyFieldsForTarget(groupID, targetType, targetID)
- }
-
return ps.countActivePropertyFieldsForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) CountAllPropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string) (int64, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountAllPropertyFieldsForTarget: %w", err)
}
-
- if requiresAC {
- return ps.propertyAccess.CountAllPropertyFieldsForTarget(groupID, targetType, targetID)
- }
-
return ps.countAllPropertyFieldsForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) SearchPropertyFields(rctx request.CTX, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ fields, err := ps.searchPropertyFields(groupID, opts)
if err != nil {
return nil, fmt.Errorf("SearchPropertyFields: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.SearchPropertyFields(callerID, groupID, opts)
- }
-
- return ps.searchPropertyFields(groupID, opts)
+ return ps.runPostGetPropertyFields(rctx, fields)
}
-func (ps *PropertyService) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+// UpdatePropertyField updates a single field. It returns the updated field and
+// the IDs of fields whose dependent property values were cleared as a side
+// effect (e.g. by TypeChangeValueCleanupHook on a type change). Hooks may
+// cascade clears to other fields, so the slice is not necessarily limited to
+// the updated field's own ID. The caller is expected to publish any
+// value-cleanup WS events.
+func (ps *PropertyService) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, []string, error) {
+ field, err := ps.runPreUpdatePropertyField(rctx, groupID, field)
if err != nil {
- return nil, fmt.Errorf("UpdatePropertyField: %w", err)
+ return nil, nil, fmt.Errorf("UpdatePropertyField: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpdatePropertyField(callerID, groupID, field)
- }
-
- return ps.updatePropertyField(groupID, field)
+ return ps.updatePropertyField(rctx, groupID, field)
}
-func (ps *PropertyService) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, err error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+// UpdatePropertyFields updates a batch of fields and returns the requested set,
+// any linked-property propagated fields, and the IDs of fields whose dependent
+// property values were cleared as a side effect. The caller is expected to
+// publish any value-cleanup WS events.
+func (ps *PropertyService) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, clearedFieldIDs []string, err error) {
+ fields, err = ps.runPreUpdatePropertyFields(rctx, groupID, fields)
if err != nil {
- return nil, nil, fmt.Errorf("UpdatePropertyFields: %w", err)
+ return nil, nil, nil, fmt.Errorf("UpdatePropertyFields: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpdatePropertyFields(callerID, groupID, fields)
- }
-
- return ps.updatePropertyFields(groupID, fields)
+ return ps.updatePropertyFields(rctx, groupID, fields)
}
func (ps *PropertyService) DeletePropertyField(rctx request.CTX, groupID, id string) error {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreDeletePropertyField(rctx, groupID, id); err != nil {
return fmt.Errorf("DeletePropertyField: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.DeletePropertyField(callerID, groupID, id)
- }
-
return ps.deletePropertyField(groupID, id)
}
// 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
@@ -667,9 +633,9 @@ func optionsChanged(oldAttrs, newAttrs model.StringInterface) bool {
return false
}
-// extractOptionIDs extracts the "id" field from each option in the given options value
+// extractOptionIDList extracts the "id" field from each option in the given options value
// using direct type assertions (no JSON marshaling).
-func extractOptionIDs(options any) []string {
+func extractOptionIDList(options any) []string {
if options == nil {
return nil
}
diff --git a/server/channels/app/properties/property_field_test.go b/server/channels/app/properties/property_field_test.go
index afff492e788..b419679ccdf 100644
--- a/server/channels/app/properties/property_field_test.go
+++ b/server/channels/app/properties/property_field_test.go
@@ -13,63 +13,39 @@ import (
"github.com/stretchr/testify/require"
)
-func TestRequiresAccessControlFailsClosed(t *testing.T) {
- th := Setup(t)
+func TestHooksOnlyScopeToManagedGroups(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
- // Use an unregistered group — this means any call to
- // requiresAccessControl will fail to look up the group.
- // The service must return an error rather than silently bypassing
- // access control.
- unregisteredGroupID := model.NewId()
+ // Operations on an unmanaged group should bypass the access control
+ // hook entirely and proceed directly to the store layer.
+ unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_group", Version: model.PropertyGroupVersionV2})
+ require.NoError(t, err)
- t.Run("CreatePropertyField returns error when group lookup fails", func(t *testing.T) {
+ t.Run("CreatePropertyField on unmanaged group bypasses hooks", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: unregisteredGroupID,
- Name: "test-field",
+ GroupID: unmanagedGroup.ID,
+ Name: "test-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
- TargetType: "user",
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
- _, err := th.service.CreatePropertyField(rctx, field)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
+ result, err := th.service.CreatePropertyField(rctx, field)
+ require.NoError(t, err)
+ assert.NotEmpty(t, result.ID)
})
- t.Run("GetPropertyField returns error when group lookup fails", func(t *testing.T) {
- _, err := th.service.GetPropertyField(rctx, unregisteredGroupID, model.NewId())
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
- })
-
- t.Run("GetPropertyFields returns error when group lookup fails", func(t *testing.T) {
- _, err := th.service.GetPropertyFields(rctx, unregisteredGroupID, []string{model.NewId()})
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
- })
-
- t.Run("UpdatePropertyField returns error when group lookup fails", func(t *testing.T) {
- field := &model.PropertyField{
- ID: model.NewId(),
- GroupID: unregisteredGroupID,
- Name: "test-field",
+ t.Run("GetPropertyField on unmanaged group bypasses hooks", func(t *testing.T) {
+ field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
+ GroupID: unmanagedGroup.ID,
+ Name: "get-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
- TargetType: "user",
- }
- _, err := th.service.UpdatePropertyField(rctx, unregisteredGroupID, field)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
- })
-
- t.Run("DeletePropertyField returns error when group lookup fails", func(t *testing.T) {
- err := th.service.DeletePropertyField(rctx, unregisteredGroupID, model.NewId())
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
- })
-
- t.Run("SearchPropertyFields returns error when group lookup fails", func(t *testing.T) {
- _, err := th.service.SearchPropertyFields(rctx, unregisteredGroupID, model.PropertyFieldSearchOpts{})
- require.Error(t, err)
- assert.Contains(t, err.Error(), "failed to check access control")
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ })
+ result, err := th.service.GetPropertyField(rctx, unmanagedGroup.ID, field.ID)
+ require.NoError(t, err)
+ assert.Equal(t, field.ID, result.ID)
})
}
@@ -616,18 +592,14 @@ func TestUpdatePropertyField(t *testing.T) {
},
})
- // Update non-name fields (Type, Attrs)
- field.Type = model.PropertyFieldTypeSelect
+ // Update non-name fields (Attrs only)
field.Attrs = map[string]any{
- "options": []any{
- map[string]any{"name": "a"},
- map[string]any{"name": "b"},
- },
+ "key": "updated",
}
- result, err := th.service.UpdatePropertyField(rctx, groupID, field)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
- assert.Equal(t, model.PropertyFieldTypeSelect, result.Type)
+ assert.Equal(t, "updated", result.Attrs["key"])
})
t.Run("updating name to non-conflicting value should succeed", func(t *testing.T) {
@@ -644,7 +616,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Update name to non-conflicting value
field.Name = "NewUniqueName"
- result, err := th.service.UpdatePropertyField(rctx, groupID, field)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "NewUniqueName", result.Name)
})
@@ -674,7 +646,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Try to update system-level to name that conflicts with team-level
systemField.Name = "ExistingTeamProp"
- result, err := th.service.UpdatePropertyField(rctx, groupID, systemField)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, systemField)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
@@ -712,7 +684,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Update DM property to same name as regular channel property - should succeed
// because DM channels have no team, so they don't conflict with team channels
dmField.Name = "ChannelProp"
- result, err := th.service.UpdatePropertyField(rctx, groupID, dmField)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, dmField)
require.NoError(t, err)
assert.Equal(t, "ChannelProp", result.Name)
})
@@ -742,7 +714,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Try to update team-level to name that conflicts with system-level
teamField.Name = "ExistingSystemProp"
- result, err := th.service.UpdatePropertyField(rctx, groupID, teamField)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, teamField)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
@@ -781,7 +753,7 @@ func TestUpdatePropertyField(t *testing.T) {
channel2Field.TargetType = string(model.PropertyFieldTargetLevelSystem)
channel2Field.TargetID = ""
- result, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
require.Error(t, err)
assert.Nil(t, result)
appErr, ok := err.(*model.AppError)
@@ -823,7 +795,7 @@ func TestUpdatePropertyField(t *testing.T) {
// We only verify an error occurs without checking the specific error type.
channel2Field.TargetID = channel1.Id
- result, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, channel2Field)
require.Error(t, err)
assert.Nil(t, result)
})
@@ -842,7 +814,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Update name should succeed without conflict check
field.Name = "UpdatedLegacyProp"
- result, err := th.service.UpdatePropertyField(rctx, groupID, field)
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "UpdatedLegacyProp", result.Name)
})
@@ -860,8 +832,8 @@ func TestUpdatePropertyField(t *testing.T) {
})
// Update with same name should succeed (no actual change to name)
- field.Type = model.PropertyFieldTypeSelect // Change something else
- result, err := th.service.UpdatePropertyField(rctx, groupID, field)
+ field.Attrs = map[string]any{"key": "changed"} // Change something else
+ result, _, err := th.service.UpdatePropertyField(rctx, groupID, field)
require.NoError(t, err)
assert.Equal(t, "SameName", result.Name)
})
@@ -1024,7 +996,7 @@ func TestLinkedPropertyFields(t *testing.T) {
})
linked.Type = model.PropertyFieldTypeText
- _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
+ _, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
@@ -1046,7 +1018,7 @@ func TestLinkedPropertyFields(t *testing.T) {
linked.Attrs[model.PropertyFieldAttributeOptions] = []any{
map[string]any{"id": model.NewId(), "name": "Different"},
}
- _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
+ _, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
@@ -1066,7 +1038,7 @@ func TestLinkedPropertyFields(t *testing.T) {
})
linked.Name = "NewName-" + model.NewId()
- result, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
+ result, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.NoError(t, err)
assert.Equal(t, linked.Name, result.Name)
})
@@ -1100,7 +1072,7 @@ func TestLinkedPropertyFields(t *testing.T) {
}
source.Attrs[model.PropertyFieldAttributeOptions] = newOptions
- result, propagated, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
+ result, propagated, _, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
require.NoError(t, err)
require.Len(t, result, 1) // only the requested source field
require.Len(t, propagated, 2) // 2 linked fields
@@ -1112,8 +1084,8 @@ func TestLinkedPropertyFields(t *testing.T) {
require.NoError(t, err)
for _, linked := range []*model.PropertyField{updatedLinked1, updatedLinked2} {
- opts := extractOptionIDs(linked.Attrs[model.PropertyFieldAttributeOptions])
- expectedOpts := extractOptionIDs(newOptions)
+ opts := extractOptionIDList(linked.Attrs[model.PropertyFieldAttributeOptions])
+ expectedOpts := extractOptionIDList(newOptions)
assert.Equal(t, expectedOpts, opts)
}
})
@@ -1131,7 +1103,7 @@ func TestLinkedPropertyFields(t *testing.T) {
})
source.Type = model.PropertyFieldTypeMultiselect
- _, err := th.service.UpdatePropertyField(rctx, group.ID, source)
+ _, _, err := th.service.UpdatePropertyField(rctx, group.ID, source)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
@@ -1192,14 +1164,14 @@ func TestLinkedPropertyFields(t *testing.T) {
// Unlink by clearing LinkedFieldID
linked.LinkedFieldID = nil
- result, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
+ result, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.NoError(t, err)
assert.Nil(t, result.LinkedFieldID)
assert.Equal(t, source.Type, result.Type)
// Verify options are preserved after unlinking
- sourceOpts := extractOptionIDs(source.Attrs[model.PropertyFieldAttributeOptions])
- resultOpts := extractOptionIDs(result.Attrs[model.PropertyFieldAttributeOptions])
+ sourceOpts := extractOptionIDList(source.Attrs[model.PropertyFieldAttributeOptions])
+ resultOpts := extractOptionIDList(result.Attrs[model.PropertyFieldAttributeOptions])
require.NotEmpty(t, sourceOpts, "source should have options")
assert.Equal(t, sourceOpts, resultOpts, "options should be preserved after unlinking")
})
@@ -1251,7 +1223,7 @@ func TestLinkedPropertyFields(t *testing.T) {
// Attempt to set LinkedFieldID on update — should be rejected
source := createSourceField(t, "LinkAttemptSource-"+model.NewId())
regular.LinkedFieldID = &source.ID
- _, err := th.service.UpdatePropertyField(rctx, group.ID, regular)
+ _, _, err := th.service.UpdatePropertyField(rctx, group.ID, regular)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
@@ -1274,7 +1246,7 @@ func TestLinkedPropertyFields(t *testing.T) {
// Attempt to change the link target — should be rejected
linked.LinkedFieldID = &source2.ID
- _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
+ _, _, err := th.service.UpdatePropertyField(rctx, group.ID, linked)
require.Error(t, err)
appErr, ok := err.(*model.AppError)
require.True(t, ok)
@@ -1353,7 +1325,7 @@ func TestLinkedPropertyFields(t *testing.T) {
map[string]any{"id": optCID, "name": "Option C", "color": "green"},
}
- result, propagated, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
+ result, propagated, _, err := th.service.UpdatePropertyFields(rctx, group.ID, []*model.PropertyField{source})
require.NoError(t, err)
require.Len(t, result, 1) // only the requested source field
require.Len(t, propagated, 1) // 1 linked field
@@ -1362,7 +1334,7 @@ func TestLinkedPropertyFields(t *testing.T) {
updatedLinked, err := th.service.GetPropertyField(rctx, group.ID, linked.ID)
require.NoError(t, err)
- linkedOptIDs := extractOptionIDs(updatedLinked.Attrs[model.PropertyFieldAttributeOptions])
+ linkedOptIDs := extractOptionIDList(updatedLinked.Attrs[model.PropertyFieldAttributeOptions])
assert.Equal(t, []string{optAID, optCID}, linkedOptIDs, "option B should be removed from linked field")
// Verify option content (names, colors) was propagated correctly
@@ -1374,12 +1346,10 @@ func TestLinkedPropertyFields(t *testing.T) {
assert.Equal(t, "green", linkedOpts[1]["color"])
})
- // FIXME: remove this test once CPA is fully migrated to v2 — template
- // fields should then only be created on v2 groups.
- t.Run("template field creation is allowed on v1 group", func(t *testing.T) {
+ t.Run("template field creation is rejected on v1 group", func(t *testing.T) {
v1Group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1)
- template, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
+ _, err := th.service.CreatePropertyField(rctx, &model.PropertyField{
GroupID: v1Group.ID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
@@ -1391,8 +1361,7 @@ func TestLinkedPropertyFields(t *testing.T) {
},
},
})
- require.NoError(t, err)
- assert.Equal(t, model.PropertyFieldObjectTypeTemplate, template.ObjectType)
+ require.Error(t, err)
})
t.Run("cross-group linking is rejected", func(t *testing.T) {
@@ -1601,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/properties/property_value.go b/server/channels/app/properties/property_value.go
index cd656d328f0..dec126b738d 100644
--- a/server/channels/app/properties/property_value.go
+++ b/server/channels/app/properties/property_value.go
@@ -130,23 +130,18 @@ func (ps *PropertyService) deletePropertyValuesForField(groupID, fieldID string)
return ps.valueStore.DeleteForField(groupID, fieldID)
}
-// Public routing methods
+// Public methods
func (ps *PropertyService) CreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if value == nil {
return nil, fmt.Errorf("CreatePropertyValue: value cannot be nil")
}
- requiresAC, err := ps.requiresAccessControlForGroupID(value.GroupID)
+ value, err := ps.runPreCreatePropertyValue(rctx, value)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.CreatePropertyValue(callerID, value)
- }
-
return ps.createPropertyValue(value)
}
@@ -164,84 +159,71 @@ func (ps *PropertyService) CreatePropertyValues(rctx request.CTX, values []*mode
}
}
- requiresAC, err := ps.requiresAccessControlForGroupID(values[0].GroupID)
+ values, err := ps.runPreCreatePropertyValues(rctx, values)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValues: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.CreatePropertyValues(callerID, values)
- }
-
return ps.createPropertyValues(values)
}
func (ps *PropertyService) GetPropertyValue(rctx request.CTX, groupID, id string) (*model.PropertyValue, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ value, err := ps.getPropertyValue(groupID, id)
if err != nil {
return nil, fmt.Errorf("GetPropertyValue: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.GetPropertyValue(callerID, groupID, id)
- }
-
- return ps.getPropertyValue(groupID, id)
+ return ps.runPostGetPropertyValue(rctx, value)
}
func (ps *PropertyService) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ values, err := ps.getPropertyValues(groupID, ids)
if err != nil {
return nil, fmt.Errorf("GetPropertyValues: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.GetPropertyValues(callerID, groupID, ids)
- }
-
- return ps.getPropertyValues(groupID, ids)
+ return ps.runPostGetPropertyValues(rctx, values)
}
func (ps *PropertyService) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ values, err := ps.searchPropertyValues(groupID, opts)
if err != nil {
return nil, fmt.Errorf("SearchPropertyValues: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.SearchPropertyValues(callerID, groupID, opts)
- }
-
- return ps.searchPropertyValues(groupID, opts)
+ return ps.runPostGetPropertyValues(rctx, values)
}
func (ps *PropertyService) UpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
+ value, err := ps.runPreUpdatePropertyValue(rctx, groupID, value)
if err != nil {
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpdatePropertyValue(callerID, groupID, value)
- }
-
return ps.updatePropertyValue(groupID, value)
}
func (ps *PropertyService) UpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
- return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
+ if len(values) == 0 {
+ return values, nil
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpdatePropertyValues(callerID, groupID, values)
+ // Hooks gate on values[0].GroupID for batch operations, so enforce
+ // single-group batches at the public boundary — otherwise a mixed
+ // batch could silently bypass per-group hook logic (license,
+ // validation, access control).
+ for i, v := range values {
+ if v == nil {
+ return nil, fmt.Errorf("UpdatePropertyValues: nil element at index %d", i)
+ }
+ if v.GroupID != values[0].GroupID {
+ return nil, fmt.Errorf("UpdatePropertyValues: mixed group IDs in batch")
+ }
+ }
+
+ values, err := ps.runPreUpdatePropertyValues(rctx, groupID, values)
+ if err != nil {
+ return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
}
return ps.updatePropertyValues(groupID, values)
@@ -252,16 +234,11 @@ func (ps *PropertyService) UpsertPropertyValue(rctx request.CTX, value *model.Pr
return nil, fmt.Errorf("UpsertPropertyValue: value cannot be nil")
}
- requiresAC, err := ps.requiresAccessControlForGroupID(value.GroupID)
+ value, err := ps.runPreUpsertPropertyValue(rctx, value)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpsertPropertyValue(callerID, value)
- }
-
return ps.upsertPropertyValue(value)
}
@@ -279,57 +256,34 @@ func (ps *PropertyService) UpsertPropertyValues(rctx request.CTX, values []*mode
}
}
- requiresAC, err := ps.requiresAccessControlForGroupID(values[0].GroupID)
+ values, err := ps.runPreUpsertPropertyValues(rctx, values)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.UpsertPropertyValues(callerID, values)
- }
-
return ps.upsertPropertyValues(values)
}
func (ps *PropertyService) DeletePropertyValue(rctx request.CTX, groupID, id string) error {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreDeletePropertyValue(rctx, groupID, id); err != nil {
return fmt.Errorf("DeletePropertyValue: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.DeletePropertyValue(callerID, groupID, id)
- }
-
return ps.deletePropertyValue(groupID, id)
}
func (ps *PropertyService) DeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.DeletePropertyValuesForTarget(callerID, groupID, targetType, targetID)
- }
-
return ps.deletePropertyValuesForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) error {
- requiresAC, err := ps.requiresAccessControlForGroupID(groupID)
- if err != nil {
+ if err := ps.runPreDeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
return fmt.Errorf("DeletePropertyValuesForField: %w", err)
}
- if requiresAC {
- callerID := ps.extractCallerID(rctx)
- return ps.propertyAccess.DeletePropertyValuesForField(callerID, groupID, fieldID)
- }
-
return ps.deletePropertyValuesForField(groupID, fieldID)
}
diff --git a/server/channels/app/properties/service.go b/server/channels/app/properties/service.go
index 50508cffcf3..0543a281a01 100644
--- a/server/channels/app/properties/service.go
+++ b/server/channels/app/properties/service.go
@@ -5,10 +5,8 @@ package properties
import (
"errors"
- "fmt"
"sync"
- "github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
@@ -21,7 +19,7 @@ type PropertyService struct {
groupStore store.PropertyGroupStore
fieldStore store.PropertyFieldStore
valueStore store.PropertyValueStore
- propertyAccess *PropertyAccessService
+ hooks []PropertyHook
callerIDExtractor CallerIDExtractor
groupCache sync.Map // name -> *model.PropertyGroup
groupIDCache sync.Map // id -> *model.PropertyGroup
@@ -44,7 +42,6 @@ func New(c ServiceConfig) (*PropertyService, error) {
fieldStore: c.PropertyFieldStore,
valueStore: c.PropertyValueStore,
callerIDExtractor: c.CallerIDExtractor,
- propertyAccess: nil,
}, nil
}
@@ -55,27 +52,6 @@ func (c *ServiceConfig) validate() error {
return nil
}
-func (ps *PropertyService) SetPropertyAccessService(pas *PropertyAccessService) {
- ps.propertyAccess = pas
-}
-
-// requiresAccessControlForGroupID checks if a group ID requires access control enforcement.
-// Currently, only the CPA group requires access control, but this may change in the future.
-func (ps *PropertyService) requiresAccessControlForGroupID(groupID string) (bool, error) {
- group, err := ps.Group(model.CustomProfileAttributesPropertyGroupName)
- if err != nil {
- return false, fmt.Errorf("failed to check access control for group %q: %w", groupID, err)
- }
- return groupID == group.ID, nil
-}
-
-// setPluginCheckerForTests sets the plugin checker on the underlying PropertyAccessService.
-func (ps *PropertyService) setPluginCheckerForTests(pluginChecker PluginChecker) {
- if ps.propertyAccess != nil {
- ps.propertyAccess.setPluginCheckerForTests(pluginChecker)
- }
-}
-
// extractCallerID gets the caller ID from a request context using the configured extractor.
func (ps *PropertyService) extractCallerID(rctx request.CTX) string {
if ps.callerIDExtractor == nil || rctx == nil {
diff --git a/server/channels/app/properties/type_change_value_cleanup.go b/server/channels/app/properties/type_change_value_cleanup.go
new file mode 100644
index 00000000000..65957f57e43
--- /dev/null
+++ b/server/channels/app/properties/type_change_value_cleanup.go
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/mlog"
+ "github.com/mattermost/mattermost/server/public/shared/request"
+)
+
+// TypeChangeValueCleanupHook deletes a field's dependent property values when
+// the field's Type changes on update. The Type column is part of the schema
+// contract for stored values (e.g. select-option IDs are only valid against a
+// matching select field), so leaving values behind across a type change leaves
+// the field functionally broken until callers manually reset the values.
+//
+// The hook runs in PostUpdatePropertyFields. Earlier hooks
+// (linked-property checks at the store layer) already reject the type-change
+// cases that would corrupt linked state, so by the time this hook runs the
+// only remaining type changes are on standalone fields where cleanup is the
+// expected behavior. Cleanup failures are logged and skipped — the field
+// update is not rolled back — to keep the operation atomic from the caller's
+// perspective.
+type TypeChangeValueCleanupHook struct {
+ BasePropertyHook
+ propertyService *PropertyService
+}
+
+var _ PropertyHook = (*TypeChangeValueCleanupHook)(nil)
+
+// NewTypeChangeValueCleanupHook constructs the hook. The PropertyService
+// reference is used to delete dependent values via the unexported
+// deletePropertyValuesForField path so the hook does not re-enter the public
+// hook chain (which would deadlock on its own pre-hook gating).
+func NewTypeChangeValueCleanupHook(ps *PropertyService) *TypeChangeValueCleanupHook {
+ return &TypeChangeValueCleanupHook{propertyService: ps}
+}
+
+// PostUpdatePropertyFields returns the IDs of fields whose dependent values
+// were cleared. The caller publishes the corresponding WS events. Linked-
+// property propagation cannot trigger a type change (blocked upstream), so
+// the propagated bucket is passed through unchanged.
+func (h *TypeChangeValueCleanupHook) PostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
+ var cleared []string
+ for i, u := range requested {
+ if i >= len(prev) || prev[i] == nil || u == nil {
+ continue
+ }
+ if prev[i].Type == u.Type {
+ continue
+ }
+ if err := h.propertyService.deletePropertyValuesForField(groupID, u.ID); err != nil {
+ rctx.Logger().Error("type-change value cleanup failed",
+ mlog.String("group_id", groupID),
+ mlog.String("field_id", u.ID),
+ mlog.String("from_type", string(prev[i].Type)),
+ mlog.String("to_type", string(u.Type)),
+ mlog.Err(err),
+ )
+ continue
+ }
+ cleared = append(cleared, u.ID)
+ }
+ return requested, propagated, cleared, nil
+}
diff --git a/server/channels/app/properties/type_change_value_cleanup_test.go b/server/channels/app/properties/type_change_value_cleanup_test.go
new file mode 100644
index 00000000000..4a121efdc63
--- /dev/null
+++ b/server/channels/app/properties/type_change_value_cleanup_test.go
@@ -0,0 +1,216 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package properties
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTypeChangeValueCleanupHook verifies the post-update hook detects a Type
+// change and deletes the field's dependent property values, surfacing the
+// cleared field IDs to the caller.
+func TestTypeChangeValueCleanupHook(t *testing.T) {
+ th := Setup(t).RegisterCPAPropertyGroup(t)
+ th.service.AddHook(NewTypeChangeValueCleanupHook(th.service))
+
+ t.Run("type change deletes values and reports cleared field id", func(t *testing.T) {
+ // Create a select field with two options.
+ optionAID := model.NewId()
+ optionBID := model.NewId()
+ field := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "select-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": optionAID, "name": "Option A"},
+ {"id": optionBID, "name": "Option B"},
+ },
+ },
+ }
+ created, err := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, err)
+
+ // Seed a value referencing one of the options.
+ userID := model.NewId()
+ raw, err := json.Marshal(optionAID)
+ require.NoError(t, err)
+ _, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
+ GroupID: th.CPAGroupID,
+ FieldID: created.ID,
+ TargetID: userID,
+ TargetType: model.PropertyValueTargetTypeUser,
+ Value: raw,
+ })
+ require.NoError(t, err)
+
+ // Confirm the value exists pre-patch.
+ preValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
+ FieldID: created.ID,
+ PerPage: 10,
+ })
+ require.NoError(t, err)
+ require.Len(t, preValues, 1)
+
+ // Patch to type=text. AccessControlAttributeValidationHook strips the now-invalid
+ // options attr; TypeChangeValueCleanupHook deletes the dependent value.
+ created.Type = model.PropertyFieldTypeText
+ _, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
+ require.NoError(t, err)
+ assert.Equal(t, []string{created.ID}, clearedIDs, "expected post-hook to report the type-changed field as cleared")
+
+ // Confirm the value is gone.
+ postValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
+ FieldID: created.ID,
+ PerPage: 10,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, postValues, "expected dependent values to be cleared")
+ })
+
+ t.Run("multiselect type change deletes values and reports cleared field id", func(t *testing.T) {
+ // Same shape as the select case above, but for multiselect.
+ optionAID := model.NewId()
+ optionBID := model.NewId()
+ field := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "multiselect-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeMultiselect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": optionAID, "name": "Option A"},
+ {"id": optionBID, "name": "Option B"},
+ },
+ },
+ }
+ created, err := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, err)
+
+ // Multiselect value is a JSON array of option IDs.
+ userID := model.NewId()
+ raw, err := json.Marshal([]string{optionAID, optionBID})
+ require.NoError(t, err)
+ _, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
+ GroupID: th.CPAGroupID,
+ FieldID: created.ID,
+ TargetID: userID,
+ TargetType: model.PropertyValueTargetTypeUser,
+ Value: raw,
+ })
+ require.NoError(t, err)
+
+ preValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
+ FieldID: created.ID,
+ PerPage: 10,
+ })
+ require.NoError(t, err)
+ require.Len(t, preValues, 1)
+
+ created.Type = model.PropertyFieldTypeText
+ _, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
+ require.NoError(t, err)
+ assert.Equal(t, []string{created.ID}, clearedIDs, "expected post-hook to report the type-changed field as cleared")
+
+ postValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
+ FieldID: created.ID,
+ PerPage: 10,
+ })
+ require.NoError(t, err)
+ assert.Empty(t, postValues, "expected dependent values to be cleared")
+ })
+
+ t.Run("same-type patch is a no-op for cleanup", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "text-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ created, err := th.service.CreatePropertyField(th.Context, field)
+ require.NoError(t, err)
+
+ raw, err := json.Marshal("hello")
+ require.NoError(t, err)
+ _, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
+ GroupID: th.CPAGroupID,
+ FieldID: created.ID,
+ TargetID: model.NewId(),
+ TargetType: model.PropertyValueTargetTypeUser,
+ Value: raw,
+ })
+ require.NoError(t, err)
+
+ // Rename only — no Type change.
+ created.Name = "text-field-renamed-" + model.NewId()
+ _, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
+ require.NoError(t, err)
+ assert.Empty(t, clearedIDs, "rename without type change must not clear values")
+
+ values, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
+ FieldID: created.ID,
+ PerPage: 10,
+ })
+ require.NoError(t, err)
+ assert.Len(t, values, 1, "value must survive a rename")
+ })
+
+ t.Run("plural batch reports cleared ids per affected field", func(t *testing.T) {
+ // Field 1: select with a value, will be patched to text → cleanup expected.
+ optID := model.NewId()
+ f1 := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "batch-select-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": optID, "name": "Only Option"},
+ },
+ },
+ }
+ created1, err := th.service.CreatePropertyField(th.Context, f1)
+ require.NoError(t, err)
+
+ // Field 2: text, will be renamed only → no cleanup expected.
+ f2 := &model.PropertyField{
+ GroupID: th.CPAGroupID,
+ Name: "batch-text-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ created2, err := th.service.CreatePropertyField(th.Context, f2)
+ require.NoError(t, err)
+
+ raw, err := json.Marshal(optID)
+ require.NoError(t, err)
+ _, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
+ GroupID: th.CPAGroupID,
+ FieldID: created1.ID,
+ TargetID: model.NewId(),
+ TargetType: model.PropertyValueTargetTypeUser,
+ Value: raw,
+ })
+ require.NoError(t, err)
+
+ // Mutate both: f1 changes Type, f2 changes Name only.
+ created1.Type = model.PropertyFieldTypeText
+ created2.Name = "batch-text-renamed-" + model.NewId()
+
+ _, _, clearedIDs, err := th.service.UpdatePropertyFields(th.Context, th.CPAGroupID, []*model.PropertyField{created1, created2})
+ require.NoError(t, err)
+ assert.Equal(t, []string{created1.ID}, clearedIDs, "only the type-changed field should be in clearedIDs")
+ })
+}
diff --git a/server/channels/app/property_errors.go b/server/channels/app/property_errors.go
new file mode 100644
index 00000000000..3a1a8b3413f
--- /dev/null
+++ b/server/channels/app/property_errors.go
@@ -0,0 +1,77 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/v8/channels/app/properties"
+ "github.com/mattermost/mattermost/server/v8/channels/store"
+)
+
+// mapPropertyServiceError translates known errors from the property service /
+// PropertyHook chain — package sentinels (properties.Err*) and store-layer
+// errors (*store.ErrNotFound, *store.ErrConflict, *store.ErrResultsMismatch) —
+// into HTTP-shaped AppErrors. Returns nil if err is not recognised and does
+// not wrap an AppError; callers should fall back to wrapping with their own
+// default 500 in that case.
+//
+// Sentinel matches take priority over a wrapped AppError so that hook code
+// wrapping an inner AppError with a sentinel still drives the mapping.
+//
+// User-facing DetailedError is left empty on access-control rejections to
+// avoid leaking field IDs, plugin IDs, and sync source names. The full
+// chain remains available for operator logs via Wrap(err).
+func mapPropertyServiceError(where string, err error) *model.AppError {
+ if err == nil {
+ return nil
+ }
+
+ switch {
+ case errors.Is(err, properties.ErrAccessDenied):
+ return model.NewAppError(where, "app.property.access_denied.app_error", nil, "", http.StatusForbidden).Wrap(err)
+ case errors.Is(err, properties.ErrSyncLocked):
+ return model.NewAppError(where, "app.property.sync_lock.app_error", nil, "", http.StatusForbidden).Wrap(err)
+ case errors.Is(err, properties.ErrInvalidAccessMode):
+ return model.NewAppError(where, "app.property.invalid_access_mode.app_error", nil, err.Error(), http.StatusBadRequest).Wrap(err)
+ case errors.Is(err, properties.ErrFieldLimitReached):
+ return model.NewAppError(where, "app.property_field.create.limit_reached.app_error", nil, err.Error(), http.StatusUnprocessableEntity).Wrap(err)
+ case errors.Is(err, properties.ErrGroupFieldLimitReached):
+ return model.NewAppError(where, "app.property_field.create.group_limit_reached.app_error", nil, err.Error(), http.StatusUnprocessableEntity).Wrap(err)
+ case errors.Is(err, properties.ErrLicenseRequired):
+ return model.NewAppError(where, "app.property.license_error", nil, "", http.StatusForbidden).Wrap(err)
+ case errors.Is(err, properties.ErrInvalidFieldAttrs):
+ return model.NewAppError(where, "app.property_field.invalid_attrs.app_error", nil, err.Error(), http.StatusBadRequest).Wrap(err)
+ case errors.Is(err, properties.ErrInvalidValue):
+ return model.NewAppError(where, "app.property_value.validate.app_error", nil, err.Error(), http.StatusBadRequest).Wrap(err)
+ case errors.Is(err, properties.ErrAdminRequired):
+ return model.NewAppError(where, "app.property_field.managed_admin.permission.app_error", nil, "", http.StatusForbidden).Wrap(err)
+ case errors.Is(err, properties.ErrFieldNotFound):
+ return model.NewAppError(where, "app.property_field.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err)
+ }
+
+ var conflictErr *store.ErrConflict
+ if errors.As(err, &conflictErr) {
+ return model.NewAppError(where, "app.property_field.update.conflict.app_error", nil, "concurrent modification detected; please retry", http.StatusConflict).Wrap(err)
+ }
+
+ var notFoundErr *store.ErrNotFound
+ if errors.As(err, ¬FoundErr) {
+ return model.NewAppError(where, "app.property.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err)
+ }
+
+ var resultsMismatchErr *store.ErrResultsMismatch
+ if errors.As(err, &resultsMismatchErr) {
+ return model.NewAppError(where, "app.property.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err)
+ }
+
+ var appErr *model.AppError
+ if errors.As(err, &appErr) {
+ return appErr
+ }
+
+ return nil
+}
diff --git a/server/channels/app/property_errors_test.go b/server/channels/app/property_errors_test.go
new file mode 100644
index 00000000000..83bbbe0aac6
--- /dev/null
+++ b/server/channels/app/property_errors_test.go
@@ -0,0 +1,146 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/v8/channels/app/properties"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMapPropertyServiceError(t *testing.T) {
+ t.Run("nil err returns nil", func(t *testing.T) {
+ require.Nil(t, mapPropertyServiceError("Where", nil))
+ })
+
+ t.Run("unknown err returns nil so caller can 500-wrap", func(t *testing.T) {
+ require.Nil(t, mapPropertyServiceError("Where", errors.New("db connection lost")))
+ })
+
+ t.Run("unwrapped AppError is returned as-is via fallback", func(t *testing.T) {
+ orig := model.NewAppError("SomeSource", "some.id", nil, "detail", http.StatusTeapot)
+ got := mapPropertyServiceError("Where", orig)
+ require.NotNil(t, got)
+ assert.Same(t, orig, got)
+ })
+
+ sentinelCases := []struct {
+ name string
+ sentinel error
+ expectedID string
+ expectedStatus int
+ expectDetail bool
+ }{
+ {
+ name: "access denied",
+ sentinel: properties.ErrAccessDenied,
+ expectedID: "app.property.access_denied.app_error",
+ expectedStatus: http.StatusForbidden,
+ expectDetail: false,
+ },
+ {
+ name: "sync locked",
+ sentinel: properties.ErrSyncLocked,
+ expectedID: "app.property.sync_lock.app_error",
+ expectedStatus: http.StatusForbidden,
+ expectDetail: false,
+ },
+ {
+ name: "invalid access mode",
+ sentinel: properties.ErrInvalidAccessMode,
+ expectedID: "app.property.invalid_access_mode.app_error",
+ expectedStatus: http.StatusBadRequest,
+ expectDetail: true,
+ },
+ {
+ name: "field limit reached",
+ sentinel: properties.ErrFieldLimitReached,
+ expectedID: "app.property_field.create.limit_reached.app_error",
+ expectedStatus: http.StatusUnprocessableEntity,
+ expectDetail: true,
+ },
+ {
+ name: "group field limit reached",
+ sentinel: properties.ErrGroupFieldLimitReached,
+ expectedID: "app.property_field.create.group_limit_reached.app_error",
+ expectedStatus: http.StatusUnprocessableEntity,
+ expectDetail: true,
+ },
+ {
+ name: "license required",
+ sentinel: properties.ErrLicenseRequired,
+ expectedID: "app.property.license_error",
+ expectedStatus: http.StatusForbidden,
+ expectDetail: false,
+ },
+ {
+ name: "invalid field attrs",
+ sentinel: properties.ErrInvalidFieldAttrs,
+ expectedID: "app.property_field.invalid_attrs.app_error",
+ expectedStatus: http.StatusBadRequest,
+ expectDetail: true,
+ },
+ {
+ name: "invalid value",
+ sentinel: properties.ErrInvalidValue,
+ expectedID: "app.property_value.validate.app_error",
+ expectedStatus: http.StatusBadRequest,
+ expectDetail: true,
+ },
+ {
+ name: "admin required",
+ sentinel: properties.ErrAdminRequired,
+ expectedID: "app.property_field.managed_admin.permission.app_error",
+ expectedStatus: http.StatusForbidden,
+ expectDetail: false,
+ },
+ {
+ name: "field not found",
+ sentinel: properties.ErrFieldNotFound,
+ expectedID: "app.property_field.not_found.app_error",
+ expectedStatus: http.StatusNotFound,
+ expectDetail: false,
+ },
+ }
+
+ for _, tc := range sentinelCases {
+ t.Run("direct sentinel: "+tc.name, func(t *testing.T) {
+ got := mapPropertyServiceError("Where", tc.sentinel)
+ require.NotNil(t, got)
+ assert.Equal(t, tc.expectedID, got.Id)
+ assert.Equal(t, tc.expectedStatus, got.StatusCode)
+ assert.Equal(t, "Where", got.Where)
+ if tc.expectDetail {
+ assert.NotEmpty(t, got.DetailedError, "sentinel %s should carry operator-facing detail", tc.name)
+ } else {
+ assert.Empty(t, got.DetailedError, "sentinel %s should redact detail to avoid leaking internal identifiers", tc.name)
+ }
+ })
+
+ t.Run("wrapped sentinel detected through chain: "+tc.name, func(t *testing.T) {
+ wrapped := fmt.Errorf("outer context: %w", fmt.Errorf("inner context: %w", tc.sentinel))
+ got := mapPropertyServiceError("Where", wrapped)
+ require.NotNil(t, got)
+ assert.Equal(t, tc.expectedID, got.Id)
+ assert.Equal(t, tc.expectedStatus, got.StatusCode)
+ })
+ }
+
+ t.Run("sentinel priority over wrapped AppError", func(t *testing.T) {
+ // A hook that wraps an AppError with a sentinel should be mapped by
+ // the sentinel, not by the embedded AppError.
+ inner := model.NewAppError("OldPath", "old.id", nil, "old detail", http.StatusTeapot)
+ wrapped := fmt.Errorf("authz denied: %w: %w", properties.ErrAccessDenied, inner)
+ got := mapPropertyServiceError("Where", wrapped)
+ require.NotNil(t, got)
+ assert.Equal(t, "app.property.access_denied.app_error", got.Id)
+ assert.Equal(t, http.StatusForbidden, got.StatusCode)
+ })
+}
diff --git a/server/channels/app/property_field.go b/server/channels/app/property_field.go
index 2634db9181c..2d749194857 100644
--- a/server/channels/app/property_field.go
+++ b/server/channels/app/property_field.go
@@ -5,15 +5,27 @@ package app
import (
"encoding/json"
- "errors"
"net/http"
+ "reflect"
+ "strings"
"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"
)
+// propertyFieldOptionsEqual reports whether two values from
+// PropertyField.Attrs[options] are equivalent. Used to detect a no-op options
+// patch on a linked field — see UpdatePropertyFields' linked-field invariants.
+// Both nil/zero forms compare equal; otherwise reflect.DeepEqual handles the
+// nested map/slice shape produced by JSON unmarshalling.
+func propertyFieldOptionsEqual(a, b any) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ return reflect.DeepEqual(a, b)
+}
+
func propertyFieldBroadcastParams(rctx request.CTX, field *model.PropertyField) (teamID, channelID string, ok bool) {
switch field.TargetType {
case "team":
@@ -57,6 +69,10 @@ func (a *App) CreatePropertyField(rctx request.CTX, field *model.PropertyField,
return nil, model.NewAppError("CreatePropertyField", "app.property_field.invalid_input.app_error", nil, "property field is required", http.StatusBadRequest)
}
+ // Intrinsic invariants (apply to every caller — HTTP, plugin, internal).
+ CanonicalizeSystemObjectField(field)
+ field.Name = strings.TrimSpace(field.Name)
+
if !bypassProtectedCheck && field.Protected {
return nil, model.NewAppError(
"CreatePropertyField",
@@ -69,8 +85,7 @@ func (a *App) CreatePropertyField(rctx request.CTX, field *model.PropertyField,
createdField, err := a.Srv().propertyService.CreatePropertyField(rctx, field)
if err != nil {
- var appErr *model.AppError
- if errors.As(err, &appErr) {
+ if appErr := mapPropertyServiceError("CreatePropertyField", err); appErr != nil {
return nil, appErr
}
return nil, model.NewAppError("CreatePropertyField", "app.property_field.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
@@ -85,6 +100,9 @@ func (a *App) CreatePropertyField(rctx request.CTX, field *model.PropertyField,
func (a *App) GetPropertyField(rctx request.CTX, groupID, fieldID string) (*model.PropertyField, *model.AppError) {
field, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, fieldID)
if err != nil {
+ if appErr := mapPropertyServiceError("GetPropertyField", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("GetPropertyField", "app.property_field.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return field, nil
@@ -94,9 +112,8 @@ func (a *App) GetPropertyField(rctx request.CTX, groupID, fieldID string) (*mode
func (a *App) GetPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, *model.AppError) {
fields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
if err != nil {
- var resultsMismatchErr *store.ErrResultsMismatch
- if errors.As(err, &resultsMismatchErr) {
- return nil, model.NewAppError("GetPropertyFields", "app.property_field.get_many.fields_not_found.app_error", nil, "", http.StatusBadRequest).Wrap(err)
+ if appErr := mapPropertyServiceError("GetPropertyFields", err); appErr != nil {
+ return nil, appErr
}
return nil, model.NewAppError("GetPropertyFields", "app.property_field.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
@@ -107,6 +124,9 @@ func (a *App) GetPropertyFields(rctx request.CTX, groupID string, ids []string)
func (a *App) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, *model.AppError) {
field, err := a.Srv().propertyService.GetPropertyFieldByName(rctx, groupID, targetID, name)
if err != nil {
+ if appErr := mapPropertyServiceError("GetPropertyFieldByName", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("GetPropertyFieldByName", "app.property_field.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return field, nil
@@ -116,6 +136,9 @@ func (a *App) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name s
func (a *App) SearchPropertyFields(rctx request.CTX, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, *model.AppError) {
fields, err := a.Srv().propertyService.SearchPropertyFields(rctx, groupID, opts)
if err != nil {
+ if appErr := mapPropertyServiceError("SearchPropertyFields", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("SearchPropertyFields", "app.property_field.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return fields, nil
@@ -132,6 +155,9 @@ func (a *App) CountPropertyFieldsForGroup(rctx request.CTX, groupID string, incl
}
if err != nil {
+ if appErr := mapPropertyServiceError("CountPropertyFieldsForGroup", err); appErr != nil {
+ return 0, appErr
+ }
return 0, model.NewAppError("CountPropertyFieldsForGroup", "app.property_field.count_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return count, nil
@@ -148,64 +174,140 @@ func (a *App) CountPropertyFieldsForTarget(rctx request.CTX, groupID, targetType
}
if err != nil {
+ if appErr := mapPropertyServiceError("CountPropertyFieldsForTarget", err); appErr != nil {
+ return 0, appErr
+ }
return 0, model.NewAppError("CountPropertyFieldsForTarget", "app.property_field.count_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return count, nil
}
-// UpdatePropertyField updates an existing property field.
-func (a *App) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, *model.AppError) {
- fields, err := a.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field}, bypassProtectedCheck, connectionID)
+// UpdatePropertyField updates an existing property field. The second return
+// value lists the IDs of fields whose dependent property values were cleared
+// as a side effect (e.g. by TypeChangeValueCleanupHook on a type change).
+// Hooks may cascade clears to other fields, so the slice is not necessarily
+// limited to the updated field's own ID.
+func (a *App) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, []string, *model.AppError) {
+ fields, clearedIDs, err := a.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field}, bypassProtectedCheck, connectionID)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- return fields[0], nil
+ return fields[0], clearedIDs, nil
}
-// UpdatePropertyFields updates multiple property fields.
-func (a *App) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField, bypassProtectedCheck bool, connectionID string) ([]*model.PropertyField, *model.AppError) {
+// UpdatePropertyFields updates multiple property fields. The second return
+// value lists the IDs of fields whose dependent property values were cleared
+// as a side effect.
+func (a *App) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField, bypassProtectedCheck bool, connectionID string) ([]*model.PropertyField, []string, *model.AppError) {
if len(fields) == 0 {
- return nil, model.NewAppError("UpdatePropertyFields", "app.property_field.invalid_input.app_error", nil, "property fields are required", http.StatusBadRequest)
+ return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.invalid_input.app_error", nil, "property fields are required", http.StatusBadRequest)
}
- if !bypassProtectedCheck {
- ids := make([]string, len(fields))
- for i, f := range fields {
- ids[i] = f.ID
+ // Intrinsic invariants — apply to every caller (HTTP, plugin, internal).
+ // Service returns DB-order, not input-order, so we'll build a lookup map
+ // keyed by ID below; collect IDs in this same pass.
+ ids := make([]string, len(fields))
+ for i, f := range fields {
+ f.Name = strings.TrimSpace(f.Name)
+ ids[i] = f.ID
+ }
+
+ // Load existing fields once. Used for: protected-check (gated by
+ // bypassProtectedCheck), PSAv1 reject (always-on), linked-field diff
+ // invariants (always-on).
+
+ existingFields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
+ if err != nil {
+ if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
+ return nil, nil, appErr
+ }
+ return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
+ }
+
+ existingByID := make(map[string]*model.PropertyField, len(existingFields))
+ for _, ex := range existingFields {
+ existingByID[ex.ID] = ex
+ }
+
+ for _, f := range fields {
+ existing, ok := existingByID[f.ID]
+ if !ok {
+ // Service-level GetPropertyFields returns an ErrResultsMismatch when
+ // any input ID is missing, so this branch is defensive.
+ continue
}
- existingFields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
- if err != nil {
- return nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
- }
+ // Linked-field diff invariants. "Linked" = LinkedFieldID != nil &&
+ // *LinkedFieldID != "". Unlink (nil or "") is always allowed when
+ // existing was linked.
+ existingLinked := existing.LinkedFieldID != nil && *existing.LinkedFieldID != ""
+ incomingLinked := f.LinkedFieldID != nil && *f.LinkedFieldID != ""
- for _, existing := range existingFields {
- if existing.Protected {
- return nil, model.NewAppError(
+ if existingLinked {
+ if f.Type != existing.Type {
+ return nil, nil, model.NewAppError(
"UpdatePropertyFields",
- "app.property_field.update.protected.app_error",
+ "app.property_field.update.linked_type_change.app_error",
map[string]any{"FieldID": existing.ID},
- "cannot update protected field",
- http.StatusForbidden,
+ "cannot modify type of a linked field",
+ http.StatusBadRequest,
)
}
+ // Compare the options portion of Attrs.
+ var existingOpts, incomingOpts any
+ if existing.Attrs != nil {
+ existingOpts = existing.Attrs[model.PropertyFieldAttributeOptions]
+ }
+ if f.Attrs != nil {
+ incomingOpts = f.Attrs[model.PropertyFieldAttributeOptions]
+ }
+ if !propertyFieldOptionsEqual(existingOpts, incomingOpts) {
+ return nil, nil, model.NewAppError(
+ "UpdatePropertyFields",
+ "app.property_field.update.linked_options_change.app_error",
+ map[string]any{"FieldID": existing.ID},
+ "cannot modify options of a linked field",
+ http.StatusBadRequest,
+ )
+ }
+ if incomingLinked && *f.LinkedFieldID != *existing.LinkedFieldID {
+ return nil, nil, model.NewAppError(
+ "UpdatePropertyFields",
+ "app.property_field.update.cannot_change_link_target.app_error",
+ map[string]any{"FieldID": existing.ID},
+ "cannot change link target",
+ http.StatusBadRequest,
+ )
+ }
+ } else if incomingLinked {
+ return nil, nil, model.NewAppError(
+ "UpdatePropertyFields",
+ "app.property_field.update.cannot_link_existing.app_error",
+ map[string]any{"FieldID": existing.ID},
+ "linked_field_id can only be set at creation time",
+ http.StatusBadRequest,
+ )
+ }
+
+ // Protected-check is the only invariant gated on the caller's opt-out.
+ if !bypassProtectedCheck && existing.Protected {
+ return nil, nil, model.NewAppError(
+ "UpdatePropertyFields",
+ "app.property_field.update.protected.app_error",
+ map[string]any{"FieldID": existing.ID},
+ "cannot update protected field",
+ http.StatusForbidden,
+ )
}
}
- updated, propagated, err := a.Srv().propertyService.UpdatePropertyFields(rctx, groupID, fields)
+ updated, propagated, clearedFieldIDs, err := a.Srv().propertyService.UpdatePropertyFields(rctx, groupID, fields)
if err != nil {
- var appErr *model.AppError
- if errors.As(err, &appErr) {
- return nil, appErr
+ if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
+ return nil, nil, appErr
}
-
- var conflictErr *store.ErrConflict
- if errors.As(err, &conflictErr) {
- return nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.conflict.app_error", nil, "concurrent modification detected; please retry", http.StatusConflict).Wrap(err)
- }
-
- return nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
+ return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Broadcast websocket events for both requested and propagated fields
@@ -216,13 +318,27 @@ func (a *App) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*m
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldUpdated, field, "")
}
- return updated, nil
+ // For each field whose dependent values were cleared as a side effect of
+ // the update (e.g. a type change handled by TypeChangeValueCleanupHook),
+ // publish the generic property_values_updated event so subscribers refresh
+ // their local caches. Mirrors App.DeletePropertyValuesForField's wire shape.
+ for _, fieldID := range clearedFieldIDs {
+ message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, "", "", "", nil, "")
+ message.Add("field_id", fieldID)
+ message.Add("values", "[]")
+ a.Publish(message)
+ }
+
+ return updated, clearedFieldIDs, nil
}
// DeletePropertyField deletes a property field.
func (a *App) DeletePropertyField(rctx request.CTX, groupID, id string, bypassProtectedCheck bool, connectionID string) *model.AppError {
existing, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, id)
if err != nil {
+ if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
+ return appErr
+ }
return model.NewAppError("DeletePropertyField", "app.property_field.delete.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if existing == nil {
@@ -240,8 +356,7 @@ func (a *App) DeletePropertyField(rctx request.CTX, groupID, id string, bypassPr
}
if err := a.Srv().propertyService.DeletePropertyField(rctx, groupID, id); err != nil {
- var appErr *model.AppError
- if errors.As(err, &appErr) {
+ if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
return appErr
}
return model.NewAppError("DeletePropertyField", "app.property_field.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
diff --git a/server/channels/app/property_field_helpers.go b/server/channels/app/property_field_helpers.go
new file mode 100644
index 00000000000..235b954661a
--- /dev/null
+++ b/server/channels/app/property_field_helpers.go
@@ -0,0 +1,43 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "github.com/mattermost/mattermost/server/public/model"
+)
+
+// DefaultPropertyFieldPermissionLevel returns the permission level that
+// nil-fill / non-admin-pin should use for this field. Templates and system
+// fields default to sysadmin (templates define the schema linked fields
+// inherit; system fields attach to the Mattermost instance and only an
+// administrator should write them). Other object types default to member.
+func DefaultPropertyFieldPermissionLevel(field *model.PropertyField) model.PermissionLevel {
+ if field.ObjectType == model.PropertyFieldObjectTypeTemplate ||
+ field.ObjectType == model.PropertyFieldObjectTypeSystem {
+ return model.PermissionLevelSysadmin
+ }
+ return model.PermissionLevelMember
+}
+
+// CanonicalizeSystemObjectField forces a system-object field to its only
+// valid shape: TargetType="system", TargetID="", and all three Permission*
+// pinned to sysadmin. A system field's TargetType makes member-level scope
+// checks resolve to "any authenticated user" (see hasPropertyFieldScopeAccess
+// in app/authorization.go), so honouring a member-level permission would
+// expose the field's definition, options, and values to every logged-in user.
+//
+// Idempotent. Safe to call from both the API handler (before scope check)
+// and from inside App.CreatePropertyField (defense in depth, covers
+// plugin/internal callers).
+func CanonicalizeSystemObjectField(field *model.PropertyField) {
+ if field == nil || field.ObjectType != model.PropertyFieldObjectTypeSystem {
+ return
+ }
+ field.TargetType = string(model.PropertyFieldTargetLevelSystem)
+ field.TargetID = ""
+ sysadmin := model.PermissionLevelSysadmin
+ field.PermissionField = &sysadmin
+ field.PermissionValues = &sysadmin
+ field.PermissionOptions = &sysadmin
+}
diff --git a/server/channels/app/property_field_helpers_test.go b/server/channels/app/property_field_helpers_test.go
new file mode 100644
index 00000000000..0f07e0839c5
--- /dev/null
+++ b/server/channels/app/property_field_helpers_test.go
@@ -0,0 +1,102 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package app
+
+import (
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDefaultPropertyFieldPermissionLevel(t *testing.T) {
+ t.Parallel()
+
+ t.Run("template defaults to sysadmin", func(t *testing.T) {
+ f := &model.PropertyField{ObjectType: model.PropertyFieldObjectTypeTemplate}
+ assert.Equal(t, model.PermissionLevelSysadmin, DefaultPropertyFieldPermissionLevel(f))
+ })
+
+ t.Run("system defaults to sysadmin", func(t *testing.T) {
+ f := &model.PropertyField{ObjectType: model.PropertyFieldObjectTypeSystem}
+ assert.Equal(t, model.PermissionLevelSysadmin, DefaultPropertyFieldPermissionLevel(f))
+ })
+
+ t.Run("user defaults to member", func(t *testing.T) {
+ f := &model.PropertyField{ObjectType: model.PropertyFieldObjectTypeUser}
+ assert.Equal(t, model.PermissionLevelMember, DefaultPropertyFieldPermissionLevel(f))
+ })
+
+ t.Run("channel defaults to member", func(t *testing.T) {
+ f := &model.PropertyField{ObjectType: model.PropertyFieldObjectTypeChannel}
+ assert.Equal(t, model.PermissionLevelMember, DefaultPropertyFieldPermissionLevel(f))
+ })
+
+ t.Run("post defaults to member", func(t *testing.T) {
+ f := &model.PropertyField{ObjectType: model.PropertyFieldObjectTypePost}
+ assert.Equal(t, model.PermissionLevelMember, DefaultPropertyFieldPermissionLevel(f))
+ })
+}
+
+func TestCanonicalizeSystemObjectField(t *testing.T) {
+ t.Parallel()
+
+ t.Run("system object: forces TargetType=system, empty TargetID, all permissions sysadmin", func(t *testing.T) {
+ member := model.PermissionLevelMember
+ f := &model.PropertyField{
+ ObjectType: model.PropertyFieldObjectTypeSystem,
+ TargetType: "channel",
+ TargetID: "ch1",
+ PermissionField: &member,
+ PermissionValues: &member,
+ PermissionOptions: &member,
+ }
+ CanonicalizeSystemObjectField(f)
+ assert.Equal(t, string(model.PropertyFieldTargetLevelSystem), f.TargetType)
+ assert.Empty(t, f.TargetID)
+ assert.NotNil(t, f.PermissionField)
+ assert.Equal(t, model.PermissionLevelSysadmin, *f.PermissionField)
+ assert.NotNil(t, f.PermissionValues)
+ assert.Equal(t, model.PermissionLevelSysadmin, *f.PermissionValues)
+ assert.NotNil(t, f.PermissionOptions)
+ assert.Equal(t, model.PermissionLevelSysadmin, *f.PermissionOptions)
+ })
+
+ t.Run("non-system object: untouched", func(t *testing.T) {
+ member := model.PermissionLevelMember
+ f := &model.PropertyField{
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: "channel",
+ TargetID: "ch1",
+ PermissionField: &member,
+ PermissionValues: &member,
+ PermissionOptions: &member,
+ }
+ CanonicalizeSystemObjectField(f)
+ assert.Equal(t, "channel", f.TargetType)
+ assert.Equal(t, "ch1", f.TargetID)
+ assert.Equal(t, model.PermissionLevelMember, *f.PermissionField)
+ assert.Equal(t, model.PermissionLevelMember, *f.PermissionValues)
+ assert.Equal(t, model.PermissionLevelMember, *f.PermissionOptions)
+ })
+
+ t.Run("idempotent", func(t *testing.T) {
+ f := &model.PropertyField{
+ ObjectType: model.PropertyFieldObjectTypeSystem,
+ TargetType: "channel",
+ TargetID: "ch1",
+ }
+ CanonicalizeSystemObjectField(f)
+ first := *f
+ CanonicalizeSystemObjectField(f)
+ assert.Equal(t, first.TargetType, f.TargetType)
+ assert.Equal(t, first.TargetID, f.TargetID)
+ })
+
+ t.Run("nil field: no panic", func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ CanonicalizeSystemObjectField(nil)
+ })
+ })
+}
diff --git a/server/channels/app/property_field_test.go b/server/channels/app/property_field_test.go
index 1ae94ddea97..c4e3fe23391 100644
--- a/server/channels/app/property_field_test.go
+++ b/server/channels/app/property_field_test.go
@@ -13,13 +13,23 @@ import (
"github.com/stretchr/testify/require"
)
+// registerTestPropertyGroup creates a fresh, unmanaged PSAv2 property group
+// for tests that exercise generic PropertyField CRUD.
+func registerTestPropertyGroup(tb testing.TB, th *TestHelper) string {
+ tb.Helper()
+ group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{
+ Name: "test_" + model.NewId(),
+ Version: model.PropertyGroupVersionV2,
+ })
+ require.Nil(tb, appErr)
+ return group.ID
+}
+
func TestCreatePropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- group, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_create_field_v2_group", Version: model.PropertyGroupVersionV2})
- require.Nil(t, appErr)
- groupID := group.ID
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should create a non-protected field without bypass", func(t *testing.T) {
field := &model.PropertyField{
@@ -118,9 +128,7 @@ func TestUpdatePropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- group, appErr2 := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_update_field_v2_group", Version: model.PropertyGroupVersionV2})
- require.Nil(t, appErr2)
- groupID := group.ID
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should update a non-protected field without bypass", func(t *testing.T) {
field := &model.PropertyField{
@@ -134,7 +142,7 @@ func TestUpdatePropertyField(t *testing.T) {
require.Nil(t, appErr)
created.Name = "Updated Field Name"
- updated, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
require.Nil(t, appErr)
assert.Equal(t, "Updated Field Name", updated.Name)
})
@@ -155,7 +163,7 @@ func TestUpdatePropertyField(t *testing.T) {
require.Nil(t, appErr)
created.Name = "Attempted Update"
- updated, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
require.NotNil(t, appErr)
assert.Nil(t, updated)
assert.Equal(t, "app.property_field.update.protected.app_error", appErr.Id)
@@ -178,7 +186,7 @@ func TestUpdatePropertyField(t *testing.T) {
require.Nil(t, appErr)
created.Name = "Successfully Updated Protected"
- updated, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, true, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, true, "")
require.Nil(t, appErr)
assert.Equal(t, "Successfully Updated Protected", updated.Name)
})
@@ -196,7 +204,7 @@ func TestUpdatePropertyField(t *testing.T) {
// Try to update with empty name (invalid)
created.Name = ""
- updated, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
require.NotNil(t, appErr)
assert.Nil(t, updated)
})
@@ -206,9 +214,7 @@ func TestUpdatePropertyFields(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- group, appErr2 := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_update_fields_v2_group", Version: model.PropertyGroupVersionV2})
- require.Nil(t, appErr2)
- groupID := group.ID
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should update multiple non-protected fields without bypass", func(t *testing.T) {
field1 := &model.PropertyField{
@@ -234,7 +240,7 @@ func TestUpdatePropertyFields(t *testing.T) {
created1.Name = "Updated Batch 1"
created2.Name = "Updated Batch 2"
- updated, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{created1, created2}, false, "")
+ updated, _, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{created1, created2}, false, "")
require.Nil(t, appErr)
require.Len(t, updated, 2)
})
@@ -267,7 +273,7 @@ func TestUpdatePropertyFields(t *testing.T) {
createdNonProtected.Name = "Updated Non-Protected"
createdProtected.Name = "Updated Protected"
- updated, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdNonProtected, createdProtected}, false, "")
+ updated, _, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdNonProtected, createdProtected}, false, "")
require.NotNil(t, appErr)
assert.Nil(t, updated)
assert.Equal(t, "app.property_field.update.protected.app_error", appErr.Id)
@@ -311,7 +317,7 @@ func TestUpdatePropertyFields(t *testing.T) {
createdNonProtected.Name = "Bypass Updated Non-Protected"
createdProtected.Name = "Bypass Updated Protected"
- updated, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdNonProtected, createdProtected}, true, "")
+ updated, _, appErr := th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdNonProtected, createdProtected}, true, "")
require.Nil(t, appErr)
require.Len(t, updated, 2)
})
@@ -344,7 +350,7 @@ func TestUpdatePropertyFields(t *testing.T) {
createdMain.Name = "Updated Main"
createdOther.Name = "Updated Other"
- _, appErr = th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdMain, createdOther}, false, "")
+ _, _, appErr = th.App.UpdatePropertyFields(th.Context, groupID, []*model.PropertyField{createdMain, createdOther}, false, "")
require.NotNil(t, appErr)
// Verify neither field was updated
@@ -454,7 +460,7 @@ func TestUpdatePropertyFieldVersionEnforcement(t *testing.T) {
// Attempt to update it as a v2 field (add ObjectType to make it v2)
created.ObjectType = model.PropertyFieldObjectTypeUser
created.TargetType = string(model.PropertyFieldTargetLevelSystem)
- updated, appErr := th.App.UpdatePropertyField(th.Context, v1Group.ID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, v1Group.ID, created, false, "")
require.NotNil(t, appErr)
assert.Nil(t, updated)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
@@ -478,7 +484,7 @@ func TestUpdatePropertyFieldVersionEnforcement(t *testing.T) {
// Attempt to update it as a v1 field (remove ObjectType to make it v1)
created.ObjectType = ""
created.TargetType = "user"
- updated, appErr := th.App.UpdatePropertyField(th.Context, v2Group.ID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, v2Group.ID, created, false, "")
require.NotNil(t, appErr)
assert.Nil(t, updated)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
@@ -498,7 +504,7 @@ func TestUpdatePropertyFieldVersionEnforcement(t *testing.T) {
require.Nil(t, appErr)
created.Name = "V1 Field Updated"
- updated, appErr := th.App.UpdatePropertyField(th.Context, v1Group.ID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, v1Group.ID, created, false, "")
require.Nil(t, appErr)
assert.Equal(t, "V1 Field Updated", updated.Name)
})
@@ -518,7 +524,7 @@ func TestUpdatePropertyFieldVersionEnforcement(t *testing.T) {
require.Nil(t, appErr)
created.Name = "V2 Field Updated"
- updated, appErr := th.App.UpdatePropertyField(th.Context, v2Group.ID, created, false, "")
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, v2Group.ID, created, false, "")
require.Nil(t, appErr)
assert.Equal(t, "V2 Field Updated", updated.Name)
})
@@ -528,9 +534,7 @@ func TestDeletePropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- group, appErr2 := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{Name: "test_delete_field_v2_group", Version: model.PropertyGroupVersionV2})
- require.Nil(t, appErr2)
- groupID := group.ID
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should delete a non-protected field without bypass", func(t *testing.T) {
field := &model.PropertyField{
@@ -668,14 +672,15 @@ func TestGetPropertyField(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should get an existing field", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: groupID,
- Name: "Field to Get",
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: "Field to Get",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
@@ -696,19 +701,22 @@ func TestGetPropertyFields(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should get multiple fields", func(t *testing.T) {
field1 := &model.PropertyField{
- GroupID: groupID,
- Name: "Multi Get Field 1",
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: "Multi Get Field 1",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
field2 := &model.PropertyField{
- GroupID: groupID,
- Name: "Multi Get Field 2",
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: "Multi Get Field 2",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created1, appErr := th.App.CreatePropertyField(th.Context, field1, false, "")
@@ -726,14 +734,15 @@ func TestSearchPropertyFields(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
- groupID, err := th.App.CpaGroupID()
- require.Nil(t, err)
+ groupID := registerTestPropertyGroup(t, th)
t.Run("should search for fields", func(t *testing.T) {
field := &model.PropertyField{
- GroupID: groupID,
- Name: "Searchable Field",
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: "Searchable Field",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
}
_, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
require.Nil(t, appErr)
@@ -747,3 +756,281 @@ func TestSearchPropertyFields(t *testing.T) {
assert.NotEmpty(t, results)
})
}
+
+func TestCreatePropertyField_SystemCanonicalization(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ t.Run("system object: TargetType+TargetID and Permission* are canonicalized", func(t *testing.T) {
+ member := model.PermissionLevelMember
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "System Canonicalize",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeSystem,
+ TargetType: "channel",
+ TargetID: model.NewId(),
+ PermissionField: &member,
+ PermissionValues: &member,
+ PermissionOptions: &member,
+ }
+
+ created, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
+ require.Nil(t, appErr)
+ assert.Equal(t, string(model.PropertyFieldTargetLevelSystem), created.TargetType)
+ assert.Empty(t, created.TargetID)
+ require.NotNil(t, created.PermissionField)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionField)
+ require.NotNil(t, created.PermissionValues)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
+ require.NotNil(t, created.PermissionOptions)
+ assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionOptions)
+ })
+}
+
+func TestCreatePropertyField_TrimName(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ t.Run("trims whitespace around name", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: " trim-me ",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+
+ created, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
+ require.Nil(t, appErr)
+ assert.Equal(t, "trim-me", created.Name)
+ })
+}
+
+func TestUpdatePropertyField_TrimNameOnUpdate(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ t.Run("trims whitespace on update", func(t *testing.T) {
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "Trim Update",
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ created, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
+ require.Nil(t, appErr)
+
+ created.Name = " trimmed-on-update "
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, created, false, "")
+ require.Nil(t, appErr)
+ assert.Equal(t, "trimmed-on-update", updated.Name)
+ })
+}
+
+func TestUpdatePropertyField_LinkedFieldInvariants(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ makeLinkedPair := func(t *testing.T) (template, linked *model.PropertyField) {
+ t.Helper()
+ tmpl := &model.PropertyField{
+ GroupID: groupID,
+ Name: "tmpl-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeTemplate,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": model.NewId(), "name": "opt1"},
+ },
+ },
+ }
+ createdTmpl, appErr := th.App.CreatePropertyField(th.Context, tmpl, false, "")
+ require.Nil(t, appErr)
+
+ linkedID := createdTmpl.ID
+ linkedField := &model.PropertyField{
+ GroupID: groupID,
+ Name: "linked-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ LinkedFieldID: &linkedID,
+ }
+ createdLinked, appErr := th.App.CreatePropertyField(th.Context, linkedField, false, "")
+ require.Nil(t, appErr)
+ return createdTmpl, createdLinked
+ }
+
+ t.Run("type immutable on linked field", func(t *testing.T) {
+ _, linked := makeLinkedPair(t)
+ linked.Type = model.PropertyFieldTypeText
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, linked, false, "")
+ require.NotNil(t, appErr)
+ assert.Nil(t, updated)
+ assert.Equal(t, "app.property_field.update.linked_type_change.app_error", appErr.Id)
+ assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
+ })
+
+ t.Run("options immutable on linked field", func(t *testing.T) {
+ _, linked := makeLinkedPair(t)
+ linked.Attrs = model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": model.NewId(), "name": "different"},
+ },
+ }
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, linked, false, "")
+ require.NotNil(t, appErr)
+ assert.Nil(t, updated)
+ assert.Equal(t, "app.property_field.update.linked_options_change.app_error", appErr.Id)
+ assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
+ })
+
+ t.Run("link target immutable: cannot change to different target", func(t *testing.T) {
+ altTmpl, linked := makeLinkedPair(t)
+ // Create another template to point to
+ _ = altTmpl
+ newTmpl := &model.PropertyField{
+ GroupID: groupID,
+ Name: "tmpl-alt-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeTemplate,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": model.NewId(), "name": "x"},
+ },
+ },
+ }
+ createdNew, appErr := th.App.CreatePropertyField(th.Context, newTmpl, false, "")
+ require.Nil(t, appErr)
+
+ newID := createdNew.ID
+ linked.LinkedFieldID = &newID
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, linked, false, "")
+ require.NotNil(t, appErr)
+ assert.Nil(t, updated)
+ assert.Equal(t, "app.property_field.update.cannot_change_link_target.app_error", appErr.Id)
+ assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
+ })
+
+ t.Run("cannot link a previously-unlinked field", func(t *testing.T) {
+ unlinked := &model.PropertyField{
+ GroupID: groupID,
+ Name: "unlinked-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdUnlinked, appErr := th.App.CreatePropertyField(th.Context, unlinked, false, "")
+ require.Nil(t, appErr)
+
+ // Create a template to link to
+ tmpl := &model.PropertyField{
+ GroupID: groupID,
+ Name: "tmpl-late-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeTemplate,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdTmpl, appErr := th.App.CreatePropertyField(th.Context, tmpl, false, "")
+ require.Nil(t, appErr)
+ tID := createdTmpl.ID
+
+ createdUnlinked.LinkedFieldID = &tID
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, createdUnlinked, false, "")
+ require.NotNil(t, appErr)
+ assert.Nil(t, updated)
+ assert.Equal(t, "app.property_field.update.cannot_link_existing.app_error", appErr.Id)
+ assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
+ })
+}
+
+func TestUpdatePropertyField_LinkedFieldNoOpPatchOK(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ t.Run("setting Type to current value on a linked field passes", func(t *testing.T) {
+ // Build template + linked
+ tmpl := &model.PropertyField{
+ GroupID: groupID,
+ Name: "tmpl-noop-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeTemplate,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ Attrs: model.StringInterface{
+ model.PropertyFieldAttributeOptions: []map[string]any{
+ {"id": model.NewId(), "name": "n"},
+ },
+ },
+ }
+ createdTmpl, appErr := th.App.CreatePropertyField(th.Context, tmpl, false, "")
+ require.Nil(t, appErr)
+ linkedID := createdTmpl.ID
+
+ linked := &model.PropertyField{
+ GroupID: groupID,
+ Name: "linked-noop-" + model.NewId(),
+ Type: model.PropertyFieldTypeSelect,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ LinkedFieldID: &linkedID,
+ }
+ createdLinked, appErr := th.App.CreatePropertyField(th.Context, linked, false, "")
+ require.Nil(t, appErr)
+
+ // No-op update: Type unchanged.
+ createdLinked.Name = "linked-renamed"
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, createdLinked, false, "")
+ require.Nil(t, appErr)
+ assert.Equal(t, "linked-renamed", updated.Name)
+ })
+}
+
+func TestUpdatePropertyField_LinkedFieldUnlinkAllowed(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ t.Run("plugin path: setting LinkedFieldID = nil on a linked field unlinks it", func(t *testing.T) {
+ tmpl := &model.PropertyField{
+ GroupID: groupID,
+ Name: "tmpl-unlink-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeTemplate,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdTmpl, appErr := th.App.CreatePropertyField(th.Context, tmpl, false, "")
+ require.Nil(t, appErr)
+ linkedID := createdTmpl.ID
+
+ linked := &model.PropertyField{
+ GroupID: groupID,
+ Name: "linked-unlink-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ LinkedFieldID: &linkedID,
+ }
+ createdLinked, appErr := th.App.CreatePropertyField(th.Context, linked, false, "")
+ require.Nil(t, appErr)
+
+ createdLinked.LinkedFieldID = nil
+ updated, _, appErr := th.App.UpdatePropertyField(th.Context, groupID, createdLinked, false, "")
+ require.Nil(t, appErr)
+ assert.Nil(t, updated.LinkedFieldID)
+ })
+}
diff --git a/server/channels/app/property_value.go b/server/channels/app/property_value.go
index 56238be938b..8892deb0058 100644
--- a/server/channels/app/property_value.go
+++ b/server/channels/app/property_value.go
@@ -42,9 +42,13 @@ func (a *App) CreatePropertyValue(rctx request.CTX, value *model.PropertyValue)
if value == nil {
return nil, model.NewAppError("CreatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
+ value.Value = model.SanitizePropertyValue(value.Value)
createdValue, err := a.Srv().propertyService.CreatePropertyValue(rctx, value)
if err != nil {
+ if appErr := mapPropertyServiceError("CreatePropertyValue", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("CreatePropertyValue", "app.property_value.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return createdValue, nil
@@ -55,9 +59,15 @@ func (a *App) CreatePropertyValues(rctx request.CTX, values []*model.PropertyVal
if len(values) == 0 {
return nil, model.NewAppError("CreatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
+ for _, v := range values {
+ v.Value = model.SanitizePropertyValue(v.Value)
+ }
createdValues, err := a.Srv().propertyService.CreatePropertyValues(rctx, values)
if err != nil {
+ if appErr := mapPropertyServiceError("CreatePropertyValues", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("CreatePropertyValues", "app.property_value.create_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return createdValues, nil
@@ -67,6 +77,9 @@ func (a *App) CreatePropertyValues(rctx request.CTX, values []*model.PropertyVal
func (a *App) GetPropertyValue(rctx request.CTX, groupID, valueID string) (*model.PropertyValue, *model.AppError) {
value, err := a.Srv().propertyService.GetPropertyValue(rctx, groupID, valueID)
if err != nil {
+ if appErr := mapPropertyServiceError("GetPropertyValue", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("GetPropertyValue", "app.property_value.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return value, nil
@@ -76,6 +89,9 @@ func (a *App) GetPropertyValue(rctx request.CTX, groupID, valueID string) (*mode
func (a *App) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, *model.AppError) {
values, err := a.Srv().propertyService.GetPropertyValues(rctx, groupID, ids)
if err != nil {
+ if appErr := mapPropertyServiceError("GetPropertyValues", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("GetPropertyValues", "app.property_value.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return values, nil
@@ -85,6 +101,9 @@ func (a *App) GetPropertyValues(rctx request.CTX, groupID string, ids []string)
func (a *App) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, *model.AppError) {
values, err := a.Srv().propertyService.SearchPropertyValues(rctx, groupID, opts)
if err != nil {
+ if appErr := mapPropertyServiceError("SearchPropertyValues", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("SearchPropertyValues", "app.property_value.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return values, nil
@@ -95,9 +114,13 @@ func (a *App) UpdatePropertyValue(rctx request.CTX, groupID string, value *model
if value == nil {
return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
+ value.Value = model.SanitizePropertyValue(value.Value)
updatedValue, err := a.Srv().propertyService.UpdatePropertyValue(rctx, groupID, value)
if err != nil {
+ if appErr := mapPropertyServiceError("UpdatePropertyValue", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return updatedValue, nil
@@ -108,9 +131,15 @@ func (a *App) UpdatePropertyValues(rctx request.CTX, groupID string, values []*m
if len(values) == 0 {
return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
+ for _, v := range values {
+ v.Value = model.SanitizePropertyValue(v.Value)
+ }
updatedValues, err := a.Srv().propertyService.UpdatePropertyValues(rctx, groupID, values)
if err != nil {
+ if appErr := mapPropertyServiceError("UpdatePropertyValues", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.update_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return updatedValues, nil
@@ -121,9 +150,13 @@ func (a *App) UpsertPropertyValue(rctx request.CTX, value *model.PropertyValue)
if value == nil {
return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest)
}
+ value.Value = model.SanitizePropertyValue(value.Value)
upsertedValue, err := a.Srv().propertyService.UpsertPropertyValue(rctx, value)
if err != nil {
+ if appErr := mapPropertyServiceError("UpsertPropertyValue", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.upsert.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return upsertedValue, nil
@@ -131,14 +164,103 @@ func (a *App) UpsertPropertyValue(rctx request.CTX, value *model.PropertyValue)
// UpsertPropertyValues creates or updates multiple property values.
// When objectType is non-empty, WebSocket events are broadcast to notify
-// clients of the updated values.
+// clients of the updated values, and every referenced field is required
+// to have a matching ObjectType.
func (a *App) UpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue, objectType, targetID, connectionID string) ([]*model.PropertyValue, *model.AppError) {
if len(values) == 0 {
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest)
}
+ // Intrinsic invariants — apply to every caller (HTTP, plugin, internal).
+ // Single-group invariant must run before the bulk-load below, since
+ // GetPropertyFields takes a single groupID. Guard values[0] explicitly
+ // because the per-element nil check inside the loop would otherwise be
+ // reached after the values[0].GroupID dereference.
+ if values[0] == nil {
+ return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest)
+ }
+ groupID := values[0].GroupID
+ seenIDs := make(map[string]bool, len(values))
+ fieldIDs := make([]string, 0, len(values))
+ for _, v := range values {
+ if v == nil {
+ return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest)
+ }
+ if v.GroupID != groupID {
+ return nil, model.NewAppError(
+ "UpsertPropertyValues",
+ "app.property_value.upsert.mixed_groups.app_error",
+ nil,
+ "all values in a batch must belong to the same group",
+ http.StatusBadRequest,
+ )
+ }
+ if !model.IsValidId(v.FieldID) {
+ return nil, model.NewAppError(
+ "UpsertPropertyValues",
+ "app.property_value.upsert.invalid_field_id.app_error",
+ map[string]any{"FieldID": v.FieldID},
+ "invalid field ID",
+ http.StatusBadRequest,
+ )
+ }
+ if seenIDs[v.FieldID] {
+ return nil, model.NewAppError(
+ "UpsertPropertyValues",
+ "app.property_value.upsert.duplicate_field_id.app_error",
+ map[string]any{"FieldID": v.FieldID},
+ "duplicate field ID in batch",
+ http.StatusBadRequest,
+ )
+ }
+ seenIDs[v.FieldID] = true
+ fieldIDs = append(fieldIDs, v.FieldID)
+ v.Value = model.SanitizePropertyValue(v.Value)
+ }
+
+ // ObjectType-mismatch check is gated on a non-empty objectType argument.
+ // Plugin API today always passes objectType="" and keeps its loose
+ // contract on this specific check.
+ if objectType != "" {
+ fields, fieldsErr := a.GetPropertyFields(rctx, groupID, fieldIDs)
+ if fieldsErr != nil {
+ return nil, fieldsErr
+ }
+ fieldByID := make(map[string]*model.PropertyField, len(fields))
+ for _, f := range fields {
+ fieldByID[f.ID] = f
+ }
+ for _, v := range values {
+ f, ok := fieldByID[v.FieldID]
+ if !ok {
+ return nil, model.NewAppError(
+ "UpsertPropertyValues",
+ "app.property_value.upsert.field_not_found.app_error",
+ map[string]any{"FieldID": v.FieldID},
+ "field not found",
+ http.StatusNotFound,
+ )
+ }
+ if f.ObjectType != objectType {
+ // 404 matches the shape of a non-existent field so callers
+ // cannot distinguish "no such field" from "field exists but
+ // in a different object-type bucket".
+ return nil, model.NewAppError(
+ "UpsertPropertyValues",
+ "app.property_value.upsert.object_type_mismatch.app_error",
+ map[string]any{"FieldID": v.FieldID},
+ "object type mismatch",
+ http.StatusNotFound,
+ )
+ }
+ }
+ }
+
result, err := a.Srv().propertyService.UpsertPropertyValues(rctx, values)
if err != nil {
+ if appErr := mapPropertyServiceError("UpsertPropertyValues", err); appErr != nil {
+ return nil, appErr
+ }
return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.upsert_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
@@ -172,6 +294,9 @@ func (a *App) DeletePropertyValue(rctx request.CTX, groupID, valueID string) *mo
}
if err := a.Srv().propertyService.DeletePropertyValue(rctx, groupID, valueID); err != nil {
+ if mappedErr := mapPropertyServiceError("DeletePropertyValue", err); mappedErr != nil {
+ return mappedErr
+ }
return model.NewAppError("DeletePropertyValue", "app.property_value.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
@@ -204,6 +329,9 @@ func (a *App) DeletePropertyValue(rctx request.CTX, groupID, valueID string) *mo
// DeletePropertyValuesForTarget deletes all property values for a target and broadcasts a property_values_updated event.
func (a *App) DeletePropertyValuesForTarget(rctx request.CTX, groupID, targetType, targetID string) *model.AppError {
if err := a.Srv().propertyService.DeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
+ if appErr := mapPropertyServiceError("DeletePropertyValuesForTarget", err); appErr != nil {
+ return appErr
+ }
return model.NewAppError("DeletePropertyValuesForTarget", "app.property_value.delete_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
@@ -224,6 +352,9 @@ func (a *App) DeletePropertyValuesForTarget(rctx request.CTX, groupID, targetTyp
// DeletePropertyValuesForField deletes all property values for a field and broadcasts a property_values_updated event.
func (a *App) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) *model.AppError {
if err := a.Srv().propertyService.DeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
+ if appErr := mapPropertyServiceError("DeletePropertyValuesForField", err); appErr != nil {
+ return appErr
+ }
return model.NewAppError("DeletePropertyValuesForField", "app.property_value.delete_for_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
diff --git a/server/channels/app/property_value_test.go b/server/channels/app/property_value_test.go
index 4b65660aed3..2ed0ea7dbd9 100644
--- a/server/channels/app/property_value_test.go
+++ b/server/channels/app/property_value_test.go
@@ -59,3 +59,103 @@ func TestResolveValueBroadcastParams(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
}
+
+func TestUpsertPropertyValues_Invariants(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := Setup(t).InitBasic(t)
+
+ groupID := registerTestPropertyGroup(t, th)
+
+ // Create a target user-typed field for the happy paths.
+ field := &model.PropertyField{
+ GroupID: groupID,
+ Name: "upsert-target-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdField, appErr := th.App.CreatePropertyField(th.Context, field, false, "")
+ require.Nil(t, appErr)
+
+ makeValue := func(fieldID string) *model.PropertyValue {
+ return &model.PropertyValue{
+ TargetID: th.BasicUser.Id,
+ TargetType: model.PropertyFieldObjectTypeUser,
+ GroupID: groupID,
+ FieldID: fieldID,
+ Value: []byte("\"v\""),
+ CreatedBy: th.BasicUser.Id,
+ UpdatedBy: th.BasicUser.Id,
+ }
+ }
+
+ t.Run("rejects duplicate FieldID", func(t *testing.T) {
+ v := []*model.PropertyValue{makeValue(createdField.ID), makeValue(createdField.ID)}
+ result, err := th.App.UpsertPropertyValues(th.Context, v, model.PropertyFieldObjectTypeUser, th.BasicUser.Id, "")
+ require.NotNil(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, "app.property_value.upsert.duplicate_field_id.app_error", err.Id)
+ assert.Equal(t, http.StatusBadRequest, err.StatusCode)
+ })
+
+ t.Run("rejects invalid FieldID", func(t *testing.T) {
+ v := []*model.PropertyValue{makeValue("not-an-id")}
+ result, err := th.App.UpsertPropertyValues(th.Context, v, model.PropertyFieldObjectTypeUser, th.BasicUser.Id, "")
+ require.NotNil(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, "app.property_value.upsert.invalid_field_id.app_error", err.Id)
+ assert.Equal(t, http.StatusBadRequest, err.StatusCode)
+ })
+
+ t.Run("rejects mixed group IDs as a clean 400", func(t *testing.T) {
+ altGroup, appErr := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{
+ Name: "alt_mix_" + model.NewId(),
+ Version: model.PropertyGroupVersionV2,
+ })
+ require.Nil(t, appErr)
+
+ altField := &model.PropertyField{
+ GroupID: altGroup.ID,
+ Name: "alt-field-" + model.NewId(),
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
+ }
+ createdAlt, appErr := th.App.CreatePropertyField(th.Context, altField, false, "")
+ require.Nil(t, appErr)
+
+ v1 := makeValue(createdField.ID)
+ v2 := makeValue(createdAlt.ID)
+ v2.GroupID = altGroup.ID
+ result, err := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{v1, v2}, model.PropertyFieldObjectTypeUser, th.BasicUser.Id, "")
+ require.NotNil(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, "app.property_value.upsert.mixed_groups.app_error", err.Id)
+ assert.Equal(t, http.StatusBadRequest, err.StatusCode)
+ })
+
+ t.Run("rejects ObjectType mismatch when objectType is non-empty", func(t *testing.T) {
+ // Field is ObjectType=user; request specifies channel.
+ v := []*model.PropertyValue{makeValue(createdField.ID)}
+ result, err := th.App.UpsertPropertyValues(th.Context, v, model.PropertyFieldObjectTypeChannel, "ch1", "")
+ require.NotNil(t, err)
+ assert.Nil(t, result)
+ assert.Equal(t, "app.property_value.upsert.object_type_mismatch.app_error", err.Id)
+ assert.Equal(t, http.StatusNotFound, err.StatusCode)
+ })
+
+ t.Run("plugin path: empty objectType skips ObjectType match", func(t *testing.T) {
+ // We don't actually need the upsert to succeed (target/etc may not
+ // satisfy schema), only to bypass the ObjectType-mismatch reject.
+ // Confirm by passing a wrong-typed field with objectType="" — the
+ // app-layer reject should not fire; any error must come from
+ // downstream layers, not "object_type_mismatch".
+ v := []*model.PropertyValue{makeValue(createdField.ID)}
+ _, err := th.App.UpsertPropertyValues(th.Context, v, "", "", "")
+ // Either succeeds, or fails for a different reason — never the
+ // object_type_mismatch reject.
+ if err != nil {
+ assert.NotEqual(t, "app.property_value.upsert.object_type_mismatch.app_error", err.Id)
+ }
+ })
+}
diff --git a/server/channels/app/reaction.go b/server/channels/app/reaction.go
index 7ece20157d8..126264e95c9 100644
--- a/server/channels/app/reaction.go
+++ b/server/channels/app/reaction.go
@@ -165,7 +165,7 @@ func (a *App) DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction)
restrictDM, appErr := a.CheckIfChannelIsRestrictedDM(rctx, channel)
if appErr != nil {
- return err
+ return appErr
}
if restrictDM {
diff --git a/server/channels/app/remote_cluster.go b/server/channels/app/remote_cluster.go
index 4f7b144b2fe..b87ba525eaf 100644
--- a/server/channels/app/remote_cluster.go
+++ b/server/channels/app/remote_cluster.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
+ "time"
"github.com/pkg/errors"
@@ -19,6 +20,34 @@ import (
"github.com/mattermost/mattermost/server/public/shared/request"
)
+// pluginRemoteInitialPingDelay is how long the framework waits after
+// RegisterPluginForSharedChannels returns before firing the first ping to
+// the newly created or restored plugin remote. The delay gives the
+// calling plugin a chance to record the returned RemoteId in its own
+// state, so the synchronous OnSharedChannelsPing hook the framework
+// invokes can resolve the remote. Without the delay, the first ping for
+// every freshly registered SiteURL fails and the remote stays offline
+// until the periodic pingLoop refreshes it (up to PingFreq, default 1
+// minute). Declared as a var, not const, so tests can shorten it.
+var pluginRemoteInitialPingDelay = 5 * time.Second
+
+// schedulePluginRemoteInitialPing schedules a single deferred ping for a
+// freshly registered or restored plugin remote. The goroutine is launched
+// via Server.Go so it cannot outlive the server. The remote is re-read
+// before the ping fires because the plugin may have unregistered it
+// inside the delay window; pinging a soft-deleted row is harmless but
+// produces a stray "ping failed" warning.
+func (a *App) schedulePluginRemoteInitialPing(rcService remotecluster.RemoteClusterServiceIFace, rc *model.RemoteCluster) {
+ a.Srv().Go(func() {
+ time.Sleep(pluginRemoteInitialPingDelay)
+ current, err := a.Srv().Store().RemoteCluster().Get(rc.RemoteId, true)
+ if err != nil || current.DeleteAt != 0 {
+ return
+ }
+ rcService.PingNow(current)
+ })
+}
+
func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.RegisterPluginOpts) (remoteID string, err error) {
// When SiteURL is empty, fall back to the legacy single-remote behavior
// using the "plugin_" prefix. This preserves compatibility for older plugins
@@ -59,6 +88,18 @@ func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.Regis
if _, err = a.Srv().Store().RemoteCluster().Update(rc); err != nil {
return "", err
}
+
+ // Ping the restored plugin remote so its LastPingAt is refreshed
+ // before sync attempts. Deferred via a goroutine (see
+ // schedulePluginRemoteInitialPing) so the caller has a chance
+ // to record the returned RemoteId before the synchronous
+ // OnSharedChannelsPing hook fires. Without this the restored
+ // remote stays offline until the next pingLoop iteration (up to
+ // PingFreq), causing transient sync failures.
+ rcService, _ := a.GetRemoteClusterService()
+ if rcService != nil {
+ a.schedulePluginRemoteInitialPing(rcService, rc)
+ }
return rc.RemoteId, nil
}
@@ -86,13 +127,19 @@ func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.Regis
mlog.String("site_url", opts.SiteURL),
)
- // Ping the plugin remote immediately if the service is running.
- // If the service is not available the ping will happen once the
- // service starts. This is expected since plugins start before the
- // service.
+ // Ping the new plugin remote, deferred via a goroutine so the
+ // calling plugin has a chance to record the returned RemoteId
+ // before the synchronous OnSharedChannelsPing hook fires for the
+ // first time. A synchronous ping here would invoke the hook
+ // before the caller's return statement, the plugin would fail to
+ // resolve the new RemoteId, the ping would be recorded as failed,
+ // and the remote would stay offline until the next pingLoop
+ // iteration (up to PingFreq, default 1 minute). If the service is
+ // not yet running the ping will fire from the periodic pingLoop
+ // once the service starts.
rcService, _ := a.GetRemoteClusterService()
if rcService != nil {
- rcService.PingNow(rcSaved)
+ a.schedulePluginRemoteInitialPing(rcService, rcSaved)
}
return rcSaved.RemoteId, nil
diff --git a/server/channels/app/remote_cluster_test.go b/server/channels/app/remote_cluster_test.go
index f4b5ec604dd..4a5637d4a1d 100644
--- a/server/channels/app/remote_cluster_test.go
+++ b/server/channels/app/remote_cluster_test.go
@@ -6,13 +6,39 @@ package app
import (
"strings"
"testing"
+ "time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
+ "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
+// Shorten the deferred initial ping for tests so RegisterPluginForSharedChannels
+// teardown does not block on a 5s goroutine. No test in this package needs the
+// production headroom. The value is large enough that even a slow runner where
+// RegisterPluginForSharedChannels takes a couple hundred milliseconds still
+// has comfortable margin before the deferred goroutine fires.
+func init() {
+ pluginRemoteInitialPingDelay = 500 * time.Millisecond
+}
+
+// pingTrackingRCService wraps a real RemoteClusterServiceIFace and records the
+// time of every PingNow call without forwarding it. Embedding the interface
+// satisfies the other methods by delegation.
+type pingTrackingRCService struct {
+ remotecluster.RemoteClusterServiceIFace
+ pings chan time.Time
+}
+
+func (p *pingTrackingRCService) PingNow(rc *model.RemoteCluster) {
+ select {
+ case p.pings <- time.Now():
+ default:
+ }
+}
+
func setupRemoteCluster(tb testing.TB) *TestHelper {
return SetupConfig(tb, func(cfg *model.Config) {
*cfg.ConnectedWorkspacesSettings.EnableRemoteClusterService = true
@@ -221,6 +247,46 @@ func TestRegisterPluginForSharedChannels(t *testing.T) {
require.Equal(t, id1, id2)
})
+ t.Run("re-registering a soft-deleted SiteURL restores the row and pings the remote (MM-68838)", func(t *testing.T) {
+ pluginID := "com.test.restore-" + model.NewId()
+ siteURL := "nats://restore-" + model.NewId()
+
+ // 1. Initial registration.
+ id1, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore test plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+
+ // 2. Unregister soft-deletes the row.
+ require.NoError(t, th.App.UnregisterPluginRemoteForSharedChannels(pluginID, id1))
+
+ rcDeleted, err := th.App.Srv().Store().RemoteCluster().Get(id1, true)
+ require.NoError(t, err)
+ require.NotZero(t, rcDeleted.DeleteAt, "row should be soft-deleted after unregister")
+
+ // 3. Re-register the same SiteURL. The restore path must run.
+ id2, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore test plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ require.Equal(t, id1, id2, "restore path must reuse the existing remoteID")
+
+ // 4. The row must be restored (DeleteAt cleared). PingNow is called
+ // inside the restore branch; the actual ping fails in unit tests
+ // because no plugin process is loaded to answer OnSharedChannelsPing,
+ // so we cannot assert on LastPingAt here. The presence of the call
+ // is what fixes MM-68838 (offline-for-PingFreq window on restart).
+ rcRestored, err := th.App.Srv().Store().RemoteCluster().Get(id2, false)
+ require.NoError(t, err)
+ require.Zero(t, rcRestored.DeleteAt, "row should be restored after re-register")
+ })
+
t.Run("multi-remote registration returns distinct remoteIDs", func(t *testing.T) {
pluginID := "com.test.multi-" + model.NewId()
@@ -322,3 +388,117 @@ func TestUnregisterPluginForSharedChannelsBulk(t *testing.T) {
require.NoError(t, err)
require.Empty(t, remotes)
}
+
+// TestRegisterPluginForSharedChannelsPingIsDeferred guards the race fix.
+// A synchronous PingNow inside RegisterPluginForSharedChannels invoked the
+// plugin's OnSharedChannelsPing hook before the calling plugin could record
+// the returned RemoteId, so the very first ping always failed and the remote
+// stayed offline for ~PingFreq (1 minute). The fix is to defer the initial
+// ping to a goroutine. Both the new-row branch and the soft-delete-restore
+// branch must defer.
+func TestRegisterPluginForSharedChannelsPingIsDeferred(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := setupRemoteCluster(t).InitBasic(t)
+
+ tracker := &pingTrackingRCService{
+ RemoteClusterServiceIFace: th.Server.remoteClusterService,
+ pings: make(chan time.Time, 8),
+ }
+ original := th.Server.remoteClusterService
+ th.Server.remoteClusterService = tracker
+ t.Cleanup(func() { th.Server.remoteClusterService = original })
+
+ // Generous upper bound on real wall-time variance under load: the
+ // production delay is 5s; init() overrides to 100ms; we wait up to
+ // delay + 2s for the ping to actually arrive.
+ const arrivalGrace = 2 * time.Second
+ delay := pluginRemoteInitialPingDelay
+
+ // drain consumes any pending ping timestamps so a later sub-case does
+ // not see a stale one from an earlier sub-case.
+ drain := func(ch <-chan time.Time) {
+ for {
+ select {
+ case <-ch:
+ default:
+ return
+ }
+ }
+ }
+
+ assertDeferred := func(t *testing.T, registerStart time.Time) {
+ t.Helper()
+ // Phase 1: no ping in the first half of the delay (proves async).
+ var prematurePing bool
+ select {
+ case <-tracker.pings:
+ prematurePing = true
+ case <-time.After(delay / 2):
+ }
+ require.False(t, prematurePing, "PingNow fired synchronously inside RegisterPluginForSharedChannels; the deferral is broken")
+ // Phase 2: a ping arrives within delay + grace, and not before delay.
+ var pingAt time.Time
+ var pingArrived bool
+ select {
+ case pingAt = <-tracker.pings:
+ pingArrived = true
+ case <-time.After(delay + arrivalGrace):
+ }
+ require.True(t, pingArrived, "expected PingNow to fire within delay + grace, but it never did")
+ elapsed := pingAt.Sub(registerStart)
+ require.GreaterOrEqual(t, elapsed, delay,
+ "PingNow fired %s after Register returned, before the configured delay of %s", elapsed, delay)
+ }
+
+ t.Run("new-row branch defers the initial ping", func(t *testing.T) {
+ drain(tracker.pings)
+
+ start := time.Now()
+ _, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "deferred ping plugin",
+ PluginID: "com.test.deferred-" + model.NewId(),
+ CreatorID: th.BasicUser.Id,
+ SiteURL: "nats://deferred-" + model.NewId(),
+ })
+ require.NoError(t, err)
+ assertDeferred(t, start)
+ })
+
+ t.Run("soft-delete restore branch defers the ping (MM-68838)", func(t *testing.T) {
+ drain(tracker.pings)
+
+ pluginID := "com.test.restore-defer-" + model.NewId()
+ siteURL := "nats://restore-defer-" + model.NewId()
+
+ // Initial register to create the row; consume its deferred ping.
+ id1, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore defer plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ var initialPingArrived bool
+ select {
+ case <-tracker.pings:
+ initialPingArrived = true
+ case <-time.After(delay + arrivalGrace):
+ }
+ require.True(t, initialPingArrived, "initial register's deferred ping never arrived")
+
+ // Unregister soft-deletes the row.
+ require.NoError(t, th.App.UnregisterPluginRemoteForSharedChannels(pluginID, id1))
+ drain(tracker.pings)
+
+ // Re-register: the restore branch must also defer.
+ start := time.Now()
+ _, err = th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore defer plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ assertDeferred(t, start)
+ })
+}
diff --git a/server/channels/app/report.go b/server/channels/app/report.go
index 9866469b52f..89b5de0c306 100644
--- a/server/channels/app/report.go
+++ b/server/channels/app/report.go
@@ -75,7 +75,7 @@ func (a *App) compileCSVChunks(prefix string, numberOfChunks int, headers []stri
}
_, writeErr := compiledBuf.Write(chunk)
if writeErr != nil {
- return err
+ return model.NewAppError("compileCSVChunks", "app.compile_csv_chunks.write_error", nil, "", http.StatusInternalServerError).Wrap(writeErr)
}
}
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 7883817c7d9..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"
@@ -269,21 +270,10 @@ func NewServer(options ...Option) (*Server, error) {
return nil, errors.Wrapf(err, "unable to create properties service")
}
- propertyAccessService := properties.NewPropertyAccessService(s.propertyService, func(pluginID string) bool {
- if s.ch == nil {
- return false
- }
-
- _, err := s.ch.GetPluginStatus(pluginID)
- return err == nil
- })
- s.propertyService.SetPropertyAccessService(propertyAccessService)
-
- // Register builtin property groups after fully initializing the propertyService
+ // Register builtin property groups before creating hooks that reference them
if err = s.propertyService.RegisterBuiltinGroups([]*model.PropertyGroup{
- {Name: model.CustomProfileAttributesPropertyGroupName, Version: model.PropertyGroupVersionV1},
+ {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")
}
@@ -310,6 +300,64 @@ func NewServer(options ...Option) (*Server, error) {
// After channel is initialized set it to the App object
app := New(ServerConnector(channels))
+ // Register property-service hooks AFTER s.ch is populated. The
+ // access-control and attribute-validation hooks capture s and use
+ // s.ch for plugin-status and permission lookups; registering them
+ // earlier leaves a window where hook invocations race against a
+ // nil s.ch.
+ cpaGroup, err := s.propertyService.Group(model.AccessControlPropertyGroupName)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to look up CPA property group")
+ }
+
+ // License check hook — must run before other hooks so unlicensed
+ // operations are rejected early.
+ licenseCheckHook := properties.NewLicenseCheckHook(func() *model.License {
+ return s.License()
+ }, cpaGroup.ID)
+ s.propertyService.AddHook(licenseCheckHook)
+
+ accessControlHook := properties.NewAccessControlHook(s.propertyService, func(pluginID string) bool {
+ _, err := s.ch.GetPluginStatus(pluginID)
+ return err == nil
+ }, cpaGroup.ID)
+ s.propertyService.AddHook(accessControlHook)
+
+ // Attribute validation hook — validates visibility, sort_order on fields,
+ // field-type constraints on values (options, user IDs, value_type), and
+ // managed-flag authorization + permission level enforcement.
+ permChecker := func(userID string, perm *model.Permission) bool {
+ // Local-mode (unrestricted) sessions are tagged with
+ // CallerIDLocalAdmin by the HTTP layer; grant them admin
+ // permissions without a user lookup.
+ if userID == model.CallerIDLocalAdmin {
+ return true
+ }
+ return app.HasPermissionTo(userID, perm)
+ }
+ attrValidationHook := properties.NewAccessControlAttributeValidationHook(s.propertyService, permChecker, cpaGroup.ID)
+ s.propertyService.AddHook(attrValidationHook)
+
+ // Field limit hook — enforces per-object-type and global field limits.
+ // Only "user" has a per-type cap today; when channel/team/post CPA fields
+ // are added, set their per-type caps here. Until then
+ // AccessControlGroupFieldLimit is the only ceiling for non-user
+ // object types within this group.
+ fieldLimitHook := properties.NewFieldLimitHook(s.propertyService)
+ fieldLimitHook.AddGroupLimit(cpaGroup.ID, &properties.FieldLimitConfig{
+ PerObjectType: map[string]int64{
+ model.PropertyFieldObjectTypeUser: 20,
+ },
+ GlobalLimit: model.AccessControlGroupFieldLimit,
+ })
+ s.propertyService.AddHook(fieldLimitHook)
+
+ // Type-change value cleanup — registered last so the field write has
+ // passed every other gate (license, access control, validation, limit)
+ // before we cascade-delete dependent values. PostUpdate hooks run after
+ // the store write succeeds.
+ s.propertyService.AddHook(properties.NewTypeChangeValueCleanupHook(s.propertyService))
+
// -------------------------------------------------------------------------
// Everything below this is not order sensitive and safe to be moved around.
// If you are adding a new field that is non-channels specific, please add
@@ -900,8 +948,26 @@ func (s *Server) Start() error {
err := s.FileBackend().TestConnection()
if err != nil {
- if _, ok := err.(*filestore.S3FileBackendNoBucketError); ok {
- err = s.FileBackend().(*filestore.S3FileBackend).MakeBucket()
+ var noBucket *filestore.FileBackendNoBucketError
+ if errors.As(err, &noBucket) {
+ // Each backend exposes its own provisioning entry point, so
+ // dispatch by capability rather than concrete type. New
+ // backends opt in by implementing this interface; backends
+ // that do not are reported with the original error so the
+ // missing-bucket condition surfaces in logs instead of being
+ // silently swallowed.
+ type bucketMaker interface {
+ MakeBucket() error
+ }
+ type containerMaker interface {
+ MakeContainer() error
+ }
+ switch b := s.FileBackend().(type) {
+ case bucketMaker:
+ err = b.MakeBucket()
+ case containerMaker:
+ err = b.MakeContainer()
+ }
}
if err != nil {
mlog.Error("Problem with file storage settings", mlog.Err(err))
@@ -1658,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/slashcommands/command_custom_status.go b/server/channels/app/slashcommands/command_custom_status.go
index 914484a5b00..2dd320fa16e 100644
--- a/server/channels/app/slashcommands/command_custom_status.go
+++ b/server/channels/app/slashcommands/command_custom_status.go
@@ -119,7 +119,7 @@ func GetCustomStatus(message string) *model.CustomStatus {
func removeUnicodeSkinTone(unicodeString string) string {
skinToneDetectorRegex := regexp.MustCompile("-(1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)")
- skinToneLocations := skinToneDetectorRegex.FindIndex([]byte(unicodeString))
+ skinToneLocations := skinToneDetectorRegex.FindStringIndex(unicodeString)
if len(skinToneLocations) == 0 {
return unicodeString
diff --git a/server/channels/app/slashcommands/command_invite_people.go b/server/channels/app/slashcommands/command_invite_people.go
index 601f0cec10b..011947299b7 100644
--- a/server/channels/app/slashcommands/command_invite_people.go
+++ b/server/channels/app/slashcommands/command_invite_people.go
@@ -41,6 +41,17 @@ func (*InvitePeopleProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model
}
}
+func parseEmailList(message string) []string {
+ var emails []string
+ for token := range strings.FieldsSeq(message) {
+ token = strings.Trim(token, ",")
+ if strings.Contains(token, "@") {
+ emails = append(emails, token)
+ }
+ }
+ return emails
+}
+
func (*InvitePeopleProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionToTeam(rctx, args.UserId, args.TeamId, model.PermissionInviteUser) {
return &model.CommandResponse{Text: args.T("api.command_invite_people.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
@@ -62,14 +73,7 @@ func (*InvitePeopleProvider) DoCommand(a *app.App, rctx request.CTX, args *model
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.email_invitations_off")}
}
- emailList := strings.Fields(message)
-
- for i := len(emailList) - 1; i >= 0; i-- {
- emailList[i] = strings.Trim(emailList[i], ",")
- if !strings.Contains(emailList[i], "@") {
- emailList = append(emailList[:i], emailList[i+1:]...)
- }
- }
+ emailList := parseEmailList(message)
if len(emailList) == 0 {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.no_email")}
diff --git a/server/channels/app/slashcommands/command_invite_people_test.go b/server/channels/app/slashcommands/command_invite_people_test.go
index 7bba88f465f..33edc3a7d69 100644
--- a/server/channels/app/slashcommands/command_invite_people_test.go
+++ b/server/channels/app/slashcommands/command_invite_people_test.go
@@ -7,10 +7,62 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
+func TestParseEmailList(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "single valid email",
+ input: "user@example.com",
+ expected: []string{"user@example.com"},
+ },
+ {
+ name: "multiple valid emails",
+ input: "a@example.com b@example.com",
+ expected: []string{"a@example.com", "b@example.com"},
+ },
+ {
+ name: "trailing commas stripped",
+ input: "a@example.com, b@example.com,",
+ expected: []string{"a@example.com", "b@example.com"},
+ },
+ {
+ name: "non-email tokens filtered out",
+ input: "notanemail a@example.com alsoinvalid",
+ expected: []string{"a@example.com"},
+ },
+ {
+ name: "comma immediately after email treated as one token",
+ input: "a@example.com,b@example.com",
+ expected: []string{"a@example.com,b@example.com"},
+ },
+ {
+ name: "empty input",
+ input: "",
+ expected: nil,
+ },
+ {
+ name: "all tokens invalid",
+ input: "notanemail alsoinvalid",
+ expected: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := parseEmailList(tc.input)
+ require.Equal(t, tc.expected, result)
+ })
+ }
+}
+
func TestInvitePeopleProvider(t *testing.T) {
th := setup(t).initBasic(t)
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/user.go b/server/channels/app/user.go
index 360fff44ca6..22060540f07 100644
--- a/server/channels/app/user.go
+++ b/server/channels/app/user.go
@@ -606,6 +606,24 @@ func (a *App) GetUserByAuth(authData *string, authService string) (*model.User,
return user, nil
}
+func (a *App) GetUserByAuthData(authData *string) (*model.User, *model.AppError) {
+ user, err := a.ch.srv.userService.GetUserByAuthData(authData)
+ if err != nil {
+ var invErr *store.ErrInvalidInput
+ var nfErr *store.ErrNotFound
+ switch {
+ case errors.As(err, &invErr):
+ return nil, model.NewAppError("GetUserByAuthData", MissingAccountError, nil, "", http.StatusBadRequest).Wrap(err)
+ case errors.As(err, &nfErr):
+ return nil, model.NewAppError("GetUserByAuthData", MissingAccountError, nil, "", http.StatusNotFound).Wrap(err)
+ default:
+ return nil, model.NewAppError("GetUserByAuthData", MissingAccountError, nil, "", http.StatusInternalServerError).Wrap(err)
+ }
+ }
+
+ return user, nil
+}
+
func (a *App) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) {
users, err := a.ch.srv.userService.GetUsersFromProfiles(options)
if err != nil {
@@ -1832,7 +1850,7 @@ func (a *App) CreatePasswordRecoveryToken(rctx request.CTX, userID, email string
// remove any previously created tokens for user
appErr := a.InvalidatePasswordRecoveryTokensForUser(userID)
if appErr != nil {
- rctx.Logger().Warn("Error while deleting additional user tokens.", mlog.Err(err))
+ rctx.Logger().Warn("Error while deleting additional user tokens.", mlog.Err(appErr))
}
token := model.NewToken(model.TokenTypePasswordRecovery, string(jsonData))
@@ -2724,6 +2742,10 @@ func (a *App) PromoteGuestToUser(rctx request.CTX, user *model.User, requestorId
// DemoteUserToGuest Convert user's roles and all his membership's roles from
// regular user roles to guest roles.
func (a *App) DemoteUserToGuest(rctx request.CTX, user *model.User) *model.AppError {
+ if user.IsBot {
+ return model.NewAppError("DemoteUserToGuest", "api.user.demote_user_to_guest.bot_not_allowed.app_error", nil, "", http.StatusBadRequest)
+ }
+
demotedUser, nErr := a.ch.srv.userService.DemoteUserToGuest(user)
a.InvalidateCacheForUser(user.Id)
if nErr != nil {
diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go
index 88b7b19461e..f82cd9915c6 100644
--- a/server/channels/app/user_test.go
+++ b/server/channels/app/user_test.go
@@ -2012,6 +2012,18 @@ func TestDemoteUserToGuest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+ t.Run("Must reject bot user", func(t *testing.T) {
+ bot := th.CreateBot(t)
+ user, err := th.App.GetUser(bot.UserId)
+ require.Nil(t, err)
+ require.True(t, user.IsBot)
+
+ appErr := th.App.DemoteUserToGuest(th.Context, user)
+ require.NotNil(t, appErr)
+ assert.Equal(t, "api.user.demote_user_to_guest.bot_not_allowed.app_error", appErr.Id)
+ assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
+ })
+
t.Run("Must invalidate channel stats cache when demoting a user", func(t *testing.T) {
user := th.CreateUser(t)
require.Equal(t, "system_user", user.Roles)
diff --git a/server/channels/app/users/users.go b/server/channels/app/users/users.go
index 900184f781e..c81c0f8ccec 100644
--- a/server/channels/app/users/users.go
+++ b/server/channels/app/users/users.go
@@ -114,6 +114,10 @@ func (us *UserService) GetUserByAuth(authData *string, authService string) (*mod
return us.store.GetByAuth(authData, authService)
}
+func (us *UserService) GetUserByAuthData(authData *string) (*model.User, error) {
+ return us.store.GetByAuthData(authData)
+}
+
func (us *UserService) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, error) {
return us.store.GetAllProfiles(options)
}
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 8ee15553f59..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)
}
@@ -445,6 +451,7 @@ func (a *App) UpdateIncomingWebhook(oldHook, updatedHook *model.IncomingWebhook)
updatedHook.UpdateAt = model.GetMillis()
updatedHook.TeamId = oldHook.TeamId
updatedHook.DeleteAt = oldHook.DeleteAt
+ updatedHook.LastUsed = oldHook.LastUsed
newWebhook, err := a.Srv().Store().Webhook().UpdateIncoming(updatedHook)
if err != nil {
@@ -903,7 +910,16 @@ func (a *App) HandleIncomingWebhook(rctx request.CTX, hookID string, req *model.
}
_, err := a.CreateWebhookPost(rctx, hook.UserId, channel, text, overrideUsername, overrideIconURL, req.IconEmoji, req.Props, webhookType, threadRootID, req.Priority)
- return err
+ if err != nil {
+ return err
+ }
+
+ now := model.GetMillis()
+ if nErr := a.Srv().Store().Webhook().UpdateIncomingLastUsed(hook.Id, now); nErr != nil {
+ rctx.Logger().Warn("Failed to update incoming webhook LastUsed", mlog.String("hook_id", hook.Id), mlog.Err(nErr))
+ }
+
+ return nil
}
func (a *App) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) {
diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list
index 71a28e96008..4b54f05bbd0 100644
--- a/server/channels/db/migrations/migrations.list
+++ b/server/channels/db/migrations/migrations.list
@@ -347,3 +347,31 @@ channels/db/migrations/postgres/000174_set_posts_statistics_targets.down.sql
channels/db/migrations/postgres/000174_set_posts_statistics_targets.up.sql
channels/db/migrations/postgres/000175_add_board_channel_types.down.sql
channels/db/migrations/postgres/000175_add_board_channel_types.up.sql
+channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.down.sql
+channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.up.sql
+channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.down.sql
+channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.up.sql
+channels/db/migrations/postgres/000178_add_discoverable_to_channels.down.sql
+channels/db/migrations/postgres/000178_add_discoverable_to_channels.up.sql
+channels/db/migrations/postgres/000179_add_channels_discoverable_index.down.sql
+channels/db/migrations/postgres/000179_add_channels_discoverable_index.up.sql
+channels/db/migrations/postgres/000180_create_channel_join_requests.down.sql
+channels/db/migrations/postgres/000180_create_channel_join_requests.up.sql
+channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.down.sql
+channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.up.sql
+channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.down.sql
+channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.up.sql
+channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.down.sql
+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/000176_migrate_cpa_to_access_control.down.sql b/server/channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.down.sql
new file mode 100644
index 00000000000..a82b8414260
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.down.sql
@@ -0,0 +1,16 @@
+-- Rename the group back to custom_profile_attributes and revert to V1.
+UPDATE PropertyGroups
+SET Name = 'custom_profile_attributes',
+ Version = 1
+WHERE Name = 'access_control';
+
+-- Revert field metadata to the pre-migration state.
+UPDATE PropertyFields
+SET ObjectType = '',
+ TargetType = '',
+ PermissionField = NULL,
+ PermissionValues = NULL,
+ PermissionOptions = NULL
+WHERE GroupID = (SELECT ID FROM PropertyGroups WHERE Name = 'custom_profile_attributes')
+ AND ObjectType = 'user'
+ AND TargetType = 'system';
diff --git a/server/channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.up.sql b/server/channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.up.sql
new file mode 100644
index 00000000000..c7ec3832c23
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000176_migrate_cpa_to_access_control.up.sql
@@ -0,0 +1,22 @@
+-- Update all fields belonging to the CPA group before renaming it.
+-- Row-level locks only; bounded by the per-group field limit (~200 max).
+-- PermissionValues is 'sysadmin' for admin-managed fields, 'member' for all
+-- others so that regular users can write their own profile values through the
+-- generic property API.
+UPDATE PropertyFields
+SET ObjectType = 'user',
+ TargetType = 'system',
+ PermissionField = 'sysadmin',
+ PermissionValues = (CASE
+ WHEN Attrs->>'managed' = 'admin' THEN 'sysadmin'
+ ELSE 'member'
+ END)::permission_level,
+ PermissionOptions = 'sysadmin'
+WHERE GroupID = (SELECT ID FROM PropertyGroups WHERE Name = 'custom_profile_attributes');
+
+-- Rename the group and bump it to PSAv2. Single-row update, non-blocking.
+-- The Version column was added in 000170; existing CPA groups default to V1.
+UPDATE PropertyGroups
+SET Name = 'access_control',
+ Version = 2
+WHERE Name = 'custom_profile_attributes';
diff --git a/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.down.sql b/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.down.sql
new file mode 100644
index 00000000000..7885253b9c2
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.down.sql
@@ -0,0 +1,30 @@
+-- Restore the materialized view without the ObjectType filter (000137 version).
+DROP MATERIALIZED VIEW IF EXISTS AttributeView;
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS AttributeView AS
+SELECT
+ pv.GroupID,
+ pv.TargetID,
+ pv.TargetType,
+ jsonb_object_agg(
+ pf.Name,
+ CASE
+ WHEN pf.Type = 'select' THEN (
+ SELECT to_jsonb(options.name)
+ FROM jsonb_to_recordset(pf.Attrs->'options') AS options(id text, name text)
+ WHERE options.id = pv.Value #>> '{}'
+ LIMIT 1
+ )
+ WHEN pf.Type = 'multiselect' AND jsonb_typeof(pv.Value) = 'array' THEN (
+ SELECT jsonb_agg(option_names.name)
+ FROM jsonb_array_elements_text(pv.Value) AS option_id
+ JOIN jsonb_to_recordset(pf.Attrs->'options') AS option_names(id text, name text)
+ ON option_id = option_names.id
+ )
+ ELSE pv.Value
+ END
+ ) AS Attributes
+FROM PropertyValues pv
+LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID
+WHERE (pv.DeleteAt = 0 OR pv.DeleteAt IS NULL) AND (pf.DeleteAt = 0 OR pf.DeleteAt IS NULL)
+GROUP BY pv.GroupID, pv.TargetID, pv.TargetType;
diff --git a/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.up.sql b/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.up.sql
new file mode 100644
index 00000000000..be06808a004
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000177_filter_attribute_view_by_object_type.up.sql
@@ -0,0 +1,35 @@
+-- Recreate the materialized view with an ObjectType = 'user' filter so it
+-- only materializes user-scoped attributes. Split from 000172 so the row
+-- locks taken by that migration's UPDATEs aren't held for the duration of
+-- the matview scan. Same drop+create pattern as migration 000137.
+DROP MATERIALIZED VIEW IF EXISTS AttributeView;
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS AttributeView AS
+SELECT
+ pv.GroupID,
+ pv.TargetID,
+ pv.TargetType,
+ jsonb_object_agg(
+ pf.Name,
+ CASE
+ WHEN pf.Type = 'select' THEN (
+ SELECT to_jsonb(options.name)
+ FROM jsonb_to_recordset(pf.Attrs->'options') AS options(id text, name text)
+ WHERE options.id = pv.Value #>> '{}'
+ LIMIT 1
+ )
+ WHEN pf.Type = 'multiselect' AND jsonb_typeof(pv.Value) = 'array' THEN (
+ SELECT jsonb_agg(option_names.name)
+ FROM jsonb_array_elements_text(pv.Value) AS option_id
+ JOIN jsonb_to_recordset(pf.Attrs->'options') AS option_names(id text, name text)
+ ON option_id = option_names.id
+ )
+ ELSE pv.Value
+ END
+ ) AS Attributes
+FROM PropertyValues pv
+LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID
+WHERE (pv.DeleteAt = 0 OR pv.DeleteAt IS NULL)
+ AND (pf.DeleteAt = 0 OR pf.DeleteAt IS NULL)
+ AND pf.ObjectType = 'user'
+GROUP BY pv.GroupID, pv.TargetID, pv.TargetType;
diff --git a/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.down.sql b/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.down.sql
new file mode 100644
index 00000000000..98788019071
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.down.sql
@@ -0,0 +1 @@
+ALTER TABLE Channels DROP COLUMN IF EXISTS Discoverable;
diff --git a/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.up.sql b/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.up.sql
new file mode 100644
index 00000000000..dce84a520df
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000178_add_discoverable_to_channels.up.sql
@@ -0,0 +1 @@
+ALTER TABLE Channels ADD COLUMN IF NOT EXISTS Discoverable BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.down.sql b/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.down.sql
new file mode 100644
index 00000000000..d3d4d6b3545
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.down.sql
@@ -0,0 +1,2 @@
+-- morph:nontransactional
+DROP INDEX CONCURRENTLY IF EXISTS idx_channels_discoverable_team;
diff --git a/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.up.sql b/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.up.sql
new file mode 100644
index 00000000000..b838ee35a52
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000179_add_channels_discoverable_index.up.sql
@@ -0,0 +1,4 @@
+-- morph:nontransactional
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_channels_discoverable_team
+ ON Channels (TeamId)
+ WHERE Discoverable = true AND Type = 'P' AND DeleteAt = 0;
diff --git a/server/channels/db/migrations/postgres/000180_create_channel_join_requests.down.sql b/server/channels/db/migrations/postgres/000180_create_channel_join_requests.down.sql
new file mode 100644
index 00000000000..8c692c6b8e5
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000180_create_channel_join_requests.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS ChannelJoinRequests;
diff --git a/server/channels/db/migrations/postgres/000180_create_channel_join_requests.up.sql b/server/channels/db/migrations/postgres/000180_create_channel_join_requests.up.sql
new file mode 100644
index 00000000000..1e8076dab23
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000180_create_channel_join_requests.up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS ChannelJoinRequests (
+ Id VARCHAR(26) PRIMARY KEY,
+ ChannelId VARCHAR(26) NOT NULL,
+ UserId VARCHAR(26) NOT NULL,
+ Message TEXT NOT NULL DEFAULT '',
+ Status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ DenialReason TEXT NOT NULL DEFAULT '',
+ CreateAt BIGINT NOT NULL,
+ UpdateAt BIGINT NOT NULL,
+ ReviewedBy VARCHAR(26) NOT NULL DEFAULT '',
+ ReviewedAt BIGINT NOT NULL DEFAULT 0
+);
diff --git a/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.down.sql b/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.down.sql
new file mode 100644
index 00000000000..ca606fc8a74
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.down.sql
@@ -0,0 +1,2 @@
+-- morph:nontransactional
+DROP INDEX CONCURRENTLY IF EXISTS idx_channeljoinrequests_pending_unique;
diff --git a/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.up.sql b/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.up.sql
new file mode 100644
index 00000000000..d2317fecf77
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000181_create_channel_join_requests_pending_unique_index.up.sql
@@ -0,0 +1,4 @@
+-- morph:nontransactional
+CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_channeljoinrequests_pending_unique
+ ON ChannelJoinRequests (ChannelId, UserId)
+ WHERE Status = 'pending';
diff --git a/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.down.sql b/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.down.sql
new file mode 100644
index 00000000000..f5a3ed0da5a
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.down.sql
@@ -0,0 +1,2 @@
+-- morph:nontransactional
+DROP INDEX CONCURRENTLY IF EXISTS idx_channeljoinrequests_channel_status_createat;
diff --git a/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.up.sql b/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.up.sql
new file mode 100644
index 00000000000..dbaf927fbb5
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.up.sql
@@ -0,0 +1,3 @@
+-- morph:nontransactional
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_channeljoinrequests_channel_status_createat
+ ON ChannelJoinRequests (ChannelId, Status, CreateAt DESC);
diff --git a/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.down.sql b/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.down.sql
new file mode 100644
index 00000000000..134bcc459f5
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.down.sql
@@ -0,0 +1,2 @@
+-- morph:nontransactional
+DROP INDEX CONCURRENTLY IF EXISTS idx_channeljoinrequests_user_status_createat;
diff --git a/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql b/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql
new file mode 100644
index 00000000000..73271ffa61e
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql
@@ -0,0 +1,3 @@
+-- morph:nontransactional
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_channeljoinrequests_user_status_createat
+ ON ChannelJoinRequests (UserId, Status, CreateAt DESC);
diff --git a/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql b/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql
new file mode 100644
index 00000000000..a0dc89dc227
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql
@@ -0,0 +1 @@
+ALTER TABLE incomingwebhooks DROP COLUMN IF EXISTS lastused;
diff --git a/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql b/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql
new file mode 100644
index 00000000000..8031f7eeee4
--- /dev/null
+++ b/server/channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql
@@ -0,0 +1 @@
+ALTER TABLE incomingwebhooks ADD COLUMN IF NOT EXISTS lastused bigint NOT NULL DEFAULT 0;
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/batch_report_worker.go b/server/channels/jobs/batch_report_worker.go
index b0553003467..c8686421313 100644
--- a/server/channels/jobs/batch_report_worker.go
+++ b/server/channels/jobs/batch_report_worker.go
@@ -106,7 +106,7 @@ func (worker *BatchReportWorker) processChunk(job *model.Job, reportData []model
appErr := worker.app.SaveReportChunk(worker.reportFormat, job.Id, fileCount, reportData)
if appErr != nil {
- return err
+ return appErr
}
fileCount++
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/jobs/export_users_to_csv/export_users_to_csv.go b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go
index 0c89a929a88..d9655a6cd2a 100644
--- a/server/channels/jobs/export_users_to_csv/export_users_to_csv.go
+++ b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go
@@ -114,7 +114,7 @@ func getData(app ExportUsersToCSVAppIFace) func(jobData model.StringMap) ([]mode
users, appErr := app.GetUsersForReporting(filter)
if appErr != nil {
- return nil, nil, false, errors.Wrapf(err, "failed to get the next batch (column_value=%v, user_id=%v)", filter.FromColumnValue, filter.FromId)
+ return nil, nil, false, errors.Wrapf(appErr, "failed to get the next batch (column_value=%v, user_id=%v)", filter.FromColumnValue, filter.FromId)
}
if len(users) == 0 {
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/localcachelayer/user_layer.go b/server/channels/store/localcachelayer/user_layer.go
index f9c6c182a61..4132007705e 100644
--- a/server/channels/store/localcachelayer/user_layer.go
+++ b/server/channels/store/localcachelayer/user_layer.go
@@ -222,6 +222,25 @@ func (s *LocalCacheUserStore) UpdateFailedPasswordAttempts(userID string, attemp
return s.UserStore.UpdateFailedPasswordAttempts(userID, attempts)
}
+func (s *LocalCacheUserStore) TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error) {
+ claimed, err := s.UserStore.TryIncrementFailedPasswordAttempts(userID, maxAttempts)
+ if err != nil {
+ return false, err
+ }
+ if claimed {
+ s.InvalidateProfileCacheForUser(userID)
+ }
+ return claimed, nil
+}
+
+func (s *LocalCacheUserStore) DecrementFailedPasswordAttempts(userID string) error {
+ if err := s.UserStore.DecrementFailedPasswordAttempts(userID); err != nil {
+ return err
+ }
+ s.InvalidateProfileCacheForUser(userID)
+ return nil
+}
+
// Get is a cache wrapper around the SqlStore method to get a user profile by id.
// It checks if the user entry is present in the cache, returning the entry from cache
// if it is present. Otherwise, it fetches the entry from the store and stores it in the
diff --git a/server/channels/store/localcachelayer/webhook_layer.go b/server/channels/store/localcachelayer/webhook_layer.go
index 9a753b4b4ff..75d11d89671 100644
--- a/server/channels/store/localcachelayer/webhook_layer.go
+++ b/server/channels/store/localcachelayer/webhook_layer.go
@@ -87,3 +87,13 @@ func (s LocalCacheWebhookStore) PermanentDeleteIncomingByChannel(channelId strin
s.ClearCaches()
return nil
}
+
+func (s LocalCacheWebhookStore) UpdateIncomingLastUsed(webhookID string, lastUsed int64) error {
+ err := s.WebhookStore.UpdateIncomingLastUsed(webhookID, lastUsed)
+ if err != nil {
+ return err
+ }
+
+ s.InvalidateWebhookCache(webhookID)
+ return nil
+}
diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go
index f1b8fe9950d..23c5c02b429 100644
--- a/server/channels/store/retrylayer/retrylayer.go
+++ b/server/channels/store/retrylayer/retrylayer.go
@@ -27,6 +27,8 @@ type RetryLayer struct {
BotStore store.BotStore
ChannelStore store.ChannelStore
ChannelBookmarkStore store.ChannelBookmarkStore
+ ChannelGuardStore store.ChannelGuardStore
+ ChannelJoinRequestStore store.ChannelJoinRequestStore
ChannelMemberHistoryStore store.ChannelMemberHistoryStore
ClusterDiscoveryStore store.ClusterDiscoveryStore
CommandStore store.CommandStore
@@ -107,6 +109,14 @@ 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
+}
+
func (s *RetryLayer) ChannelMemberHistory() store.ChannelMemberHistoryStore {
return s.ChannelMemberHistoryStore
}
@@ -342,6 +352,16 @@ type RetryLayerChannelBookmarkStore struct {
Root *RetryLayer
}
+type RetryLayerChannelGuardStore struct {
+ store.ChannelGuardStore
+ Root *RetryLayer
+}
+
+type RetryLayerChannelJoinRequestStore struct {
+ store.ChannelJoinRequestStore
+ Root *RetryLayer
+}
+
type RetryLayerChannelMemberHistoryStore struct {
store.ChannelMemberHistoryStore
Root *RetryLayer
@@ -645,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
@@ -2235,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
@@ -2808,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
@@ -3858,6 +3962,237 @@ 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
+ for {
+ result, err := s.ChannelJoinRequestStore.CountPending(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 *RetryLayerChannelJoinRequestStore) Get(id string) (*model.ChannelJoinRequest, error) {
+
+ tries := 0
+ for {
+ result, err := s.ChannelJoinRequestStore.Get(id)
+ 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 *RetryLayerChannelJoinRequestStore) GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+
+ tries := 0
+ for {
+ result, resultVar1, err := s.ChannelJoinRequestStore.GetForChannel(channelId, opts)
+ if err == nil {
+ return result, resultVar1, nil
+ }
+ if !isRepeatableError(err) {
+ return result, resultVar1, err
+ }
+ tries++
+ if tries >= 3 {
+ err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
+ return result, resultVar1, err
+ }
+ timepkg.Sleep(100 * timepkg.Millisecond)
+ }
+
+}
+
+func (s *RetryLayerChannelJoinRequestStore) GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+
+ tries := 0
+ for {
+ result, resultVar1, err := s.ChannelJoinRequestStore.GetForUser(userId, opts)
+ if err == nil {
+ return result, resultVar1, nil
+ }
+ if !isRepeatableError(err) {
+ return result, resultVar1, err
+ }
+ tries++
+ if tries >= 3 {
+ err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
+ return result, resultVar1, err
+ }
+ timepkg.Sleep(100 * timepkg.Millisecond)
+ }
+
+}
+
+func (s *RetryLayerChannelJoinRequestStore) GetPendingForChannelAndUser(channelId string, userId string) (*model.ChannelJoinRequest, error) {
+
+ tries := 0
+ for {
+ result, err := s.ChannelJoinRequestStore.GetPendingForChannelAndUser(channelId, userId)
+ 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 *RetryLayerChannelJoinRequestStore) Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+
+ tries := 0
+ for {
+ result, err := s.ChannelJoinRequestStore.Save(req)
+ 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 *RetryLayerChannelJoinRequestStore) Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+
+ tries := 0
+ for {
+ result, err := s.ChannelJoinRequestStore.Update(req)
+ 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 *RetryLayerChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (int64, error) {
tries := 0
@@ -9996,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
@@ -15939,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
@@ -16149,6 +16526,27 @@ func (s *RetryLayerUserStore) GetByAuth(authData *string, authService string) (*
}
+func (s *RetryLayerUserStore) GetByAuthData(authData *string) (*model.User, error) {
+
+ tries := 0
+ for {
+ result, err := s.UserStore.GetByAuthData(authData)
+ 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) GetByEmail(email string) (*model.User, error) {
tries := 0
@@ -17172,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
@@ -17424,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
@@ -18388,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()
}
@@ -18404,6 +18869,8 @@ 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}
newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore}
diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go
index 9c1e08dcfd9..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{})
@@ -74,6 +75,7 @@ func genStore() *mocks.Store {
mock.On("Recap").Return(&mocks.RecapStore{})
mock.On("TemporaryPost").Return(&mocks.TemporaryPostStore{})
mock.On("View").Return(&mocks.ViewStore{})
+ mock.On("ChannelJoinRequest").Return(&mocks.ChannelJoinRequestStore{})
return mock
}
diff --git a/server/channels/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go
index bfc506bd500..4a08f96dafc 100644
--- a/server/channels/store/sqlstore/access_control_policy_store.go
+++ b/server/channels/store/sqlstore/access_control_policy_store.go
@@ -79,8 +79,12 @@ func (s *storeAccessControlPolicy) toModel() (*model.AccessControlPolicy, error)
}
func fromModel(policy *model.AccessControlPolicy) (*storeAccessControlPolicy, error) {
+ imports := policy.Imports
+ if imports == nil {
+ imports = []string{}
+ }
data, err := json.Marshal(&accessControlPolicyV0_1{
- Imports: policy.Imports,
+ Imports: imports,
Rules: policy.Rules,
Roles: policy.Roles,
Scope: policy.Scope,
@@ -186,7 +190,7 @@ func (s *SqlAccessControlPolicyStore) Save(rctx request.CTX, policy *model.Acces
return nil, err
}
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
@@ -303,7 +307,7 @@ func (s *SqlAccessControlPolicyStore) Save(rctx request.CTX, policy *model.Acces
}
func (s *SqlAccessControlPolicyStore) Delete(rctx request.CTX, id string) error {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "failed to start transaction")
}
@@ -360,7 +364,7 @@ func (s *SqlAccessControlPolicyStore) deleteT(_ request.CTX, tx *sqlxTxWrapper,
}
func (s *SqlAccessControlPolicyStore) SetActiveStatus(rctx request.CTX, id string, active bool) (*model.AccessControlPolicy, error) {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
@@ -410,7 +414,7 @@ func (s *SqlAccessControlPolicyStore) SetActiveStatus(rctx request.CTX, id strin
}
func (s *SqlAccessControlPolicyStore) SetActiveStatusMultiple(rctx request.CTX, list []model.AccessControlPolicyActiveUpdate) ([]*model.AccessControlPolicy, error) {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
@@ -706,7 +710,7 @@ func (s *SqlAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts mode
condition := sq.Expr(`Id IN (
SELECT parent_id FROM (
- SELECT ch.TeamId, jsonb_array_elements_text(cp.Data -> 'imports') AS parent_id
+ SELECT ch.TeamId, jsonb_array_elements_text(COALESCE(NULLIF(cp.Data -> 'imports', 'null'::jsonb), '[]'::jsonb)) AS parent_id
FROM AccessControlPolicies cp
JOIN Channels ch ON ch.Id = cp.Id
WHERE cp.Type = 'channel'
@@ -777,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/attributes_store.go b/server/channels/store/sqlstore/attributes_store.go
index 69140069c28..9c6cff78f2b 100644
--- a/server/channels/store/sqlstore/attributes_store.go
+++ b/server/channels/store/sqlstore/attributes_store.go
@@ -66,10 +66,7 @@ func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*
return nil, errors.Wrap(err, "failed to build query for subject")
}
- row := s.GetReplica().QueryRowxContext(rctx.Context(), q, args...)
- if err := row.Err(); err != nil {
- return nil, errors.Wrap(err, "failed to get subject")
- }
+ row := s.GetReplica().QueryRowContext(rctx.Context(), q, args...)
var subject model.Subject
var properties []byte
diff --git a/server/channels/store/sqlstore/channel_bookmark_store.go b/server/channels/store/sqlstore/channel_bookmark_store.go
index 770060c06dd..f3da639c1ac 100644
--- a/server/channels/store/sqlstore/channel_bookmark_store.go
+++ b/server/channels/store/sqlstore/channel_bookmark_store.go
@@ -120,7 +120,7 @@ func (s *SqlChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increase
return nil, err
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -234,7 +234,7 @@ func (s *SqlChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error
func (s *SqlChannelBookmarkStore) UpdateSortOrder(bookmarkId, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) {
now := model.GetMillis()
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -294,7 +294,7 @@ func (s *SqlChannelBookmarkStore) UpdateSortOrder(bookmarkId, channelId string,
func (s *SqlChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error {
now := model.GetMillis()
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return err
}
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_join_request_store.go b/server/channels/store/sqlstore/channel_join_request_store.go
new file mode 100644
index 00000000000..700639fa70a
--- /dev/null
+++ b/server/channels/store/sqlstore/channel_join_request_store.go
@@ -0,0 +1,244 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package sqlstore
+
+import (
+ "database/sql"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/v8/channels/store"
+ sq "github.com/mattermost/squirrel"
+ "github.com/pkg/errors"
+)
+
+const channelJoinRequestsTable = "ChannelJoinRequests"
+
+var channelJoinRequestColumns = []string{
+ "Id",
+ "ChannelId",
+ "UserId",
+ "Message",
+ "Status",
+ "DenialReason",
+ "CreateAt",
+ "UpdateAt",
+ "ReviewedBy",
+ "ReviewedAt",
+}
+
+type SqlChannelJoinRequestStore struct {
+ *SqlStore
+
+ selectQuery sq.SelectBuilder
+}
+
+func newSqlChannelJoinRequestStore(sqlStore *SqlStore) store.ChannelJoinRequestStore {
+ s := &SqlChannelJoinRequestStore{SqlStore: sqlStore}
+ s.selectQuery = s.getQueryBuilder().
+ Select(channelJoinRequestColumns...).
+ From(channelJoinRequestsTable)
+ return s
+}
+
+func (s *SqlChannelJoinRequestStore) toMap(r *model.ChannelJoinRequest) map[string]any {
+ return map[string]any{
+ "Id": r.Id,
+ "ChannelId": r.ChannelId,
+ "UserId": r.UserId,
+ "Message": r.Message,
+ "Status": r.Status,
+ "DenialReason": r.DenialReason,
+ "CreateAt": r.CreateAt,
+ "UpdateAt": r.UpdateAt,
+ "ReviewedBy": r.ReviewedBy,
+ "ReviewedAt": r.ReviewedAt,
+ }
+}
+
+// Save inserts a new join request. The partial unique index in Postgres
+// (channelid, userid) WHERE status = 'pending' enforces at-most-one pending
+// row per (channel, user). On conflict we translate the unique-violation into
+// a store.ErrConflict so the app layer can return 409.
+func (s *SqlChannelJoinRequestStore) Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ req.PreSave()
+
+ if err := req.IsValid(); err != nil {
+ return nil, err
+ }
+
+ query := s.getQueryBuilder().
+ Insert(channelJoinRequestsTable).
+ SetMap(s.toMap(req))
+
+ if _, err := s.GetMaster().ExecBuilder(query); err != nil {
+ if IsUniqueConstraintError(err, []string{"idx_channeljoinrequests_pending_unique"}) {
+ return nil, store.NewErrConflict("ChannelJoinRequest", err, "channel_id="+req.ChannelId+" user_id="+req.UserId)
+ }
+ return nil, errors.Wrap(err, "failed to save ChannelJoinRequest")
+ }
+
+ return req, nil
+}
+
+func (s *SqlChannelJoinRequestStore) Get(id string) (*model.ChannelJoinRequest, error) {
+ var req model.ChannelJoinRequest
+ query := s.selectQuery.Where(sq.Eq{"Id": id})
+
+ if err := s.GetReplica().GetBuilder(&req, query); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, store.NewErrNotFound("ChannelJoinRequest", id)
+ }
+ return nil, errors.Wrapf(err, "failed to get ChannelJoinRequest with id=%s", id)
+ }
+
+ return &req, nil
+}
+
+func (s *SqlChannelJoinRequestStore) GetPendingForChannelAndUser(channelId, userId string) (*model.ChannelJoinRequest, error) {
+ var req model.ChannelJoinRequest
+ query := s.selectQuery.Where(sq.Eq{
+ "ChannelId": channelId,
+ "UserId": userId,
+ "Status": model.ChannelJoinRequestStatusPending,
+ })
+
+ if err := s.GetReplica().GetBuilder(&req, query); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, store.NewErrNotFound("ChannelJoinRequest", "channel_id="+channelId+" user_id="+userId)
+ }
+ return nil, errors.Wrapf(err, "failed to get pending ChannelJoinRequest for channel_id=%s user_id=%s", channelId, userId)
+ }
+
+ return &req, nil
+}
+
+// applyStatusFilter applies the opts.Status filter (defaulting to pending if empty)
+// to both the select and count queries. Returning the two filtered builders keeps
+// list and count perfectly in sync.
+func applyJoinRequestStatusFilter(opts model.GetChannelJoinRequestsOpts) sq.Eq {
+ status := opts.Status
+ if status == "" {
+ status = model.ChannelJoinRequestStatusPending
+ }
+ return sq.Eq{"Status": status}
+}
+
+func paginate(opts model.GetChannelJoinRequestsOpts) (limit, offset uint64) {
+ perPage := opts.PerPage
+ if perPage <= 0 {
+ perPage = 60
+ }
+ page := max(opts.Page, 0)
+ return uint64(perPage), uint64(page) * uint64(perPage)
+}
+
+func (s *SqlChannelJoinRequestStore) GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ where := sq.And{sq.Eq{"ChannelId": channelId}, applyJoinRequestStatusFilter(opts)}
+
+ limit, offset := paginate(opts)
+ listQuery := s.selectQuery.
+ Where(where).
+ OrderBy("CreateAt DESC", "Id DESC").
+ Limit(limit).
+ Offset(offset)
+
+ var rows []*model.ChannelJoinRequest
+ if err := s.GetReplica().SelectBuilder(&rows, listQuery); err != nil {
+ return nil, 0, errors.Wrapf(err, "failed to list ChannelJoinRequests for channel_id=%s", channelId)
+ }
+
+ countQuery := s.getQueryBuilder().
+ Select("COUNT(*)").
+ From(channelJoinRequestsTable).
+ Where(where)
+
+ var total int64
+ if err := s.GetReplica().GetBuilder(&total, countQuery); err != nil {
+ return nil, 0, errors.Wrapf(err, "failed to count ChannelJoinRequests for channel_id=%s", channelId)
+ }
+
+ return rows, total, nil
+}
+
+func (s *SqlChannelJoinRequestStore) GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ where := sq.And{sq.Eq{"UserId": userId}, applyJoinRequestStatusFilter(opts)}
+
+ limit, offset := paginate(opts)
+ listQuery := s.selectQuery.
+ Where(where).
+ OrderBy("CreateAt DESC", "Id DESC").
+ Limit(limit).
+ Offset(offset)
+
+ var rows []*model.ChannelJoinRequest
+ if err := s.GetReplica().SelectBuilder(&rows, listQuery); err != nil {
+ return nil, 0, errors.Wrapf(err, "failed to list ChannelJoinRequests for user_id=%s", userId)
+ }
+
+ countQuery := s.getQueryBuilder().
+ Select("COUNT(*)").
+ From(channelJoinRequestsTable).
+ Where(where)
+
+ var total int64
+ if err := s.GetReplica().GetBuilder(&total, countQuery); err != nil {
+ return nil, 0, errors.Wrapf(err, "failed to count ChannelJoinRequests for user_id=%s", userId)
+ }
+
+ return rows, total, nil
+}
+
+// Update writes the mutable fields back. Id/ChannelId/UserId/CreateAt are
+// immutable post-create — the partial-unique index relies on (ChannelId, UserId)
+// being stable for the lifetime of a row.
+func (s *SqlChannelJoinRequestStore) Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ req.PreUpdate()
+
+ if err := req.IsValid(); err != nil {
+ return nil, err
+ }
+
+ query := s.getQueryBuilder().
+ Update(channelJoinRequestsTable).
+ SetMap(map[string]any{
+ "Status": req.Status,
+ "Message": req.Message,
+ "DenialReason": req.DenialReason,
+ "UpdateAt": req.UpdateAt,
+ "ReviewedBy": req.ReviewedBy,
+ "ReviewedAt": req.ReviewedAt,
+ }).
+ Where(sq.Eq{"Id": req.Id})
+
+ res, err := s.GetMaster().ExecBuilder(query)
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to update ChannelJoinRequest with id=%s", req.Id)
+ }
+
+ n, err := res.RowsAffected()
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to read RowsAffected on ChannelJoinRequest update")
+ }
+ if n == 0 {
+ return nil, store.NewErrNotFound("ChannelJoinRequest", req.Id)
+ }
+
+ return req, nil
+}
+
+func (s *SqlChannelJoinRequestStore) CountPending(channelId string) (int64, error) {
+ query := s.getQueryBuilder().
+ Select("COUNT(*)").
+ From(channelJoinRequestsTable).
+ Where(sq.Eq{
+ "ChannelId": channelId,
+ "Status": model.ChannelJoinRequestStatusPending,
+ })
+
+ var count int64
+ if err := s.GetReplica().GetBuilder(&count, query); err != nil {
+ return 0, errors.Wrapf(err, "failed to count pending ChannelJoinRequests for channel_id=%s", channelId)
+ }
+ return count, nil
+}
diff --git a/server/channels/store/sqlstore/channel_join_request_store_test.go b/server/channels/store/sqlstore/channel_join_request_store_test.go
new file mode 100644
index 00000000000..bbbfdc8f52e
--- /dev/null
+++ b/server/channels/store/sqlstore/channel_join_request_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 TestChannelJoinRequestStore(t *testing.T) {
+ StoreTest(t, storetest.TestChannelJoinRequestStore)
+}
diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go
index abfdc215b0c..9b4cbf7805c 100644
--- a/server/channels/store/sqlstore/channel_store.go
+++ b/server/channels/store/sqlstore/channel_store.go
@@ -158,6 +158,7 @@ func channelSliceColumns(isSelect bool, prefix ...string) []string {
p + "LastRootPostAt",
p + "BannerInfo",
p + "DefaultCategoryName",
+ p + "Discoverable",
}
if isSelect {
@@ -196,6 +197,7 @@ func channelToSlice(channel *model.Channel) []any {
channel.LastRootPostAt,
channel.BannerInfo,
channel.DefaultCategoryName,
+ channel.Discoverable,
}
}
@@ -630,7 +632,7 @@ func (s SqlChannelStore) Save(rctx request.CTX, channel *model.Channel, maxChann
}
var newChannel *model.Channel
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -694,7 +696,7 @@ func (s SqlChannelStore) SaveDirectChannel(rctx request.CTX, directChannel *mode
return nil, store.NewErrInvalidInput("Channel", "Type", directChannel.Type)
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -737,7 +739,7 @@ func (s SqlChannelStore) SaveBoardChannel(rctx request.CTX, channel *model.Chann
return nil, nil, store.NewErrInvalidInput("View", "nil", "view is required for board channels")
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, nil, errors.Wrap(err, "begin_transaction")
}
@@ -818,7 +820,7 @@ func (s SqlChannelStore) saveChannelT(transaction *sqlxTxWrapper, channel *model
// Update writes the updated channel to the database.
func (s SqlChannelStore) Update(rctx request.CTX, channel *model.Channel) (_ *model.Channel, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -872,7 +874,8 @@ func (s SqlChannelStore) updateChannelT(transaction *sqlxTxWrapper, channel *mod
LastRootPostAt=:LastRootPostAt,
BannerInfo=:BannerInfo,
DefaultCategoryName=:DefaultCategoryName,
- AutoTranslation=:AutoTranslation
+ AutoTranslation=:AutoTranslation,
+ Discoverable=:Discoverable
WHERE Id=:Id`, channel)
if err != nil {
if IsUniqueConstraintError(err, []string{"Name", "channels_name_teamid_key"}) {
@@ -1033,7 +1036,7 @@ func (s SqlChannelStore) Restore(channelId string, time int64) error {
func (s SqlChannelStore) SetDeleteAt(channelId string, deleteAt, updateAt int64) (err error) {
defer s.InvalidateChannel(channelId)
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "SetDeleteAt: begin_transaction")
}
@@ -1077,7 +1080,7 @@ func (s SqlChannelStore) setDeleteAtT(transaction *sqlxTxWrapper, channelId stri
// PermanentDeleteByTeam removes all channels for the given team from the database.
func (s SqlChannelStore) PermanentDeleteByTeam(teamId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "PermanentDeleteByTeam: begin_transaction")
}
@@ -1114,7 +1117,7 @@ func (s SqlChannelStore) permanentDeleteByTeamtT(transaction *sqlxTxWrapper, tea
// PermanentDelete removes the given channel from the database.
func (s SqlChannelStore) PermanentDelete(rctx request.CTX, channelId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "PermanentDelete: begin_transaction")
}
@@ -1841,7 +1844,7 @@ func (s SqlChannelStore) saveMultipleMembers(members []*model.ChannelMember) (_
defaultTeamRolesByChannel[defaultRoles.Id] = defaultRoles
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -1909,7 +1912,7 @@ func (s SqlChannelStore) UpdateMultipleMembers(members []*model.ChannelMember) (
var transaction *sqlxTxWrapper
- if transaction, err = s.GetMaster().Beginx(); err != nil {
+ if transaction, err = s.GetMaster().Begin(); err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
@@ -1968,7 +1971,7 @@ func (s SqlChannelStore) UpdateMember(rctx request.CTX, member *model.ChannelMem
}
func (s SqlChannelStore) UpdateMemberNotifyProps(channelID, userID string, props map[string]string) (_ *model.ChannelMember, err error) {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -2051,7 +2054,7 @@ func (s SqlChannelStore) PatchMultipleMembersNotifyProps(members []*model.Channe
Set("LastUpdateAt", model.GetMillis()).
Where(whereClause)
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -2462,7 +2465,7 @@ func (s SqlChannelStore) GetAllChannelMembersForUser(rctx request.CTX, userId st
}
defer deferClose(rows, &err)
- scanner := func(rows *sql.Rows) (string, string, error) {
+ scanner := func(rows rowScanner) (string, string, error) {
var cm allChannelMember
err = rows.Scan(
&cm.ChannelId, &cm.Roles, &cm.SchemeGuest, &cm.SchemeUser,
@@ -2504,7 +2507,7 @@ func (s SqlChannelStore) GetChannelsMemberCount(channelIDs []string) (_ map[stri
defaults[channelID] = 0
}
- scanner := func(rows *sql.Rows) (string, int64, error) {
+ scanner := func(rows rowScanner) (string, int64, error) {
var channelID string
var count int64
err := rows.Scan(&channelID, &count)
@@ -3153,7 +3156,7 @@ func (s SqlChannelStore) AnalyticsCountAll(teamId string) (map[model.ChannelType
}
defer rows.Close()
- scanner := func(rows *sql.Rows) (model.ChannelType, int64, error) {
+ scanner := func(rows rowScanner) (model.ChannelType, int64, error) {
var channelType model.ChannelType
var count int64
err := rows.Scan(&channelType, &count)
@@ -3257,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{
@@ -3265,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},
+ },
})
}
@@ -3308,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},
+ },
})
}
@@ -3970,7 +3987,7 @@ func (s SqlChannelStore) GetChannelsByScheme(schemeId string, offset int, limit
func (s SqlChannelStore) MigrateChannelMembers(fromChannelId string, fromUserId string) (_ map[string]string, err error) {
var transaction *sqlxTxWrapper
- if transaction, err = s.GetMaster().Beginx(); err != nil {
+ if transaction, err = s.GetMaster().Begin(); err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
@@ -4064,7 +4081,7 @@ func (s SqlChannelStore) MigrateChannelMembers(fromChannelId string, fromUserId
}
func (s SqlChannelStore) ResetAllChannelSchemes() (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -4098,7 +4115,7 @@ func (s SqlChannelStore) ClearAllCustomRoleAssignments() (err error) {
for {
var transaction *sqlxTxWrapper
- if transaction, err = s.GetMaster().Beginx(); err != nil {
+ if transaction, err = s.GetMaster().Begin(); err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/channel_store_categories.go b/server/channels/store/sqlstore/channel_store_categories.go
index 4edbb439955..7cf02d0adad 100644
--- a/server/channels/store/sqlstore/channel_store_categories.go
+++ b/server/channels/store/sqlstore/channel_store_categories.go
@@ -17,7 +17,7 @@ import (
)
func (s SqlChannelStore) CreateInitialSidebarCategories(rctx request.CTX, userId string, teamID string) (_ *model.OrderedSidebarCategories, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "CreateInitialSidebarCategories: begin_transaction")
}
@@ -183,7 +183,7 @@ func (s SqlChannelStore) migrateFavoritesToSidebarT(transaction *sqlxTxWrapper,
// MigrateFavoritesToSidebarChannels populates the SidebarChannels table by analyzing existing user preferences for favorites
// **IMPORTANT** This function should only be called from the migration task and shouldn't be used by itself
func (s SqlChannelStore) MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (_ map[string]any, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -230,7 +230,7 @@ type sidebarCategoryForJoin struct {
}
func (s SqlChannelStore) CreateSidebarCategory(userId, teamId string, newCategory *model.SidebarCategoryWithChannels) (_ *model.SidebarCategoryWithChannels, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -590,7 +590,7 @@ func (s SqlChannelStore) updateSidebarCategoryOrderT(transaction *sqlxTxWrapper,
}
func (s SqlChannelStore) UpdateSidebarCategoryOrder(userId, teamId string, categoryOrder []string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -627,7 +627,7 @@ func (s SqlChannelStore) UpdateSidebarCategoryOrder(userId, teamId string, categ
//nolint:unparam
func (s SqlChannelStore) UpdateSidebarCategories(userId, teamId string, categories []*model.SidebarCategoryWithChannels) (updated []*model.SidebarCategoryWithChannels, original []*model.SidebarCategoryWithChannels, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, nil, errors.Wrap(err, "begin_transaction")
}
@@ -820,7 +820,7 @@ func (s SqlChannelStore) UpdateSidebarCategories(userId, teamId string, categori
// UpdateSidebarChannelsByPreferences is called when the Preference table is being updated to keep SidebarCategories in sync
// At the moment, it's only handling Favorites and NOT DMs/GMs (those will be handled client side)
func (s SqlChannelStore) UpdateSidebarChannelsByPreferences(preferences model.Preferences) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "UpdateSidebarChannelsByPreferences: begin_transaction")
}
@@ -951,7 +951,7 @@ func (s SqlChannelStore) addChannelToFavoritesCategoryT(transaction *sqlxTxWrapp
// DeleteSidebarChannelsByPreferences is called when the Preference table is being updated to keep SidebarCategories in sync
// At the moment, it's only handling Favorites and NOT DMs/GMs (those will be handled client side)
func (s SqlChannelStore) DeleteSidebarChannelsByPreferences(preferences model.Preferences) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "DeleteSidebarChannelsByPreferences: begin_transaction")
}
@@ -1011,7 +1011,7 @@ func (s SqlChannelStore) ClearSidebarOnTeamLeave(userId, teamId string) error {
// DeleteSidebarCategory removes a custom category and moves any channels into it into the Channels and Direct Messages
// categories respectively. Assumes that the provided user ID and team ID match the given category ID.
func (s SqlChannelStore) DeleteSidebarCategory(categoryId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/content_flagging_store.go b/server/channels/store/sqlstore/content_flagging_store.go
index 7968b7c643d..2c1574e01be 100644
--- a/server/channels/store/sqlstore/content_flagging_store.go
+++ b/server/channels/store/sqlstore/content_flagging_store.go
@@ -17,7 +17,7 @@ func newContentFlaggingStore(sqlStore *SqlStore) *SqlContentFlaggingStore {
}
func (s *SqlContentFlaggingStore) SaveReviewerSettings(reviewerSettings model.ReviewerIDsSettings) error {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.SaveReviewerSettings failed to begin transaction")
}
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/group_store.go b/server/channels/store/sqlstore/group_store.go
index 6e9e5277882..3f37611c997 100644
--- a/server/channels/store/sqlstore/group_store.go
+++ b/server/channels/store/sqlstore/group_store.go
@@ -166,7 +166,7 @@ func (s *SqlGroupStore) CreateWithUserIds(g *model.GroupWithUserIds) (_ *model.G
return nil, err
}
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -1952,7 +1952,7 @@ func (s *SqlGroupStore) UpsertMembers(groupID string, userIDs []string) (_ []*mo
return members, nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/job_store.go b/server/channels/store/sqlstore/job_store.go
index cb39c58d13d..6b42c46b535 100644
--- a/server/channels/store/sqlstore/job_store.go
+++ b/server/channels/store/sqlstore/job_store.go
@@ -87,7 +87,7 @@ func (jss SqlJobStore) SaveOnce(job *model.Job) (*model.Job, error) {
jsonData = AppendBinaryFlag(jsonData)
}
- tx, err := jss.GetMaster().BeginXWithIsolation(&sql.TxOptions{
+ tx, err := jss.GetMaster().BeginWithIsolation(&sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
@@ -447,7 +447,7 @@ func (jss SqlJobStore) Cleanup(expiryTime int64, batchSize int) error {
var rowErr error
rowsAffected, rowErr = sqlResult.RowsAffected()
if rowErr != nil {
- return errors.Wrap(err, "unable to delete jobs")
+ return errors.Wrap(rowErr, "unable to delete jobs")
}
time.Sleep(jobsCleanupDelay)
diff --git a/server/channels/store/sqlstore/migration_000185_test.go b/server/channels/store/sqlstore/migration_000185_test.go
new file mode 100644
index 00000000000..bb6a37fa990
--- /dev/null
+++ b/server/channels/store/sqlstore/migration_000185_test.go
@@ -0,0 +1,331 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package sqlstore
+
+import (
+ "database/sql"
+ "encoding/json"
+ "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/mlog"
+ "github.com/mattermost/mattermost/server/v8/channels/db"
+)
+
+func readMigrationSQL(t *testing.T, filename string) string {
+ t.Helper()
+ data, err := db.Assets().ReadFile("migrations/postgres/" + filename)
+ require.NoError(t, err, "failed to read migration file %s", filename)
+ return string(data)
+}
+
+func TestMigration000185(t *testing.T) {
+ logger := mlog.CreateTestLogger(t)
+
+ settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
+ if err != nil {
+ t.Skip(err)
+ }
+
+ store, err := New(*settings, logger, nil)
+ require.NoError(t, err)
+ defer store.Close()
+
+ master := store.GetMaster()
+
+ upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
+ downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
+
+ // Insert a group simulating pre-migration CPA state.
+ groupID := model.NewId()
+ _, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ master.Exec("DELETE FROM PropertyValues WHERE GroupID = ?", groupID) //nolint:errcheck
+ master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
+ master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
+ })
+
+ now := model.GetMillis()
+
+ // Insert active fields with old format (no ObjectType, no permissions).
+ // fieldID1 and fieldID2 are non-managed; fieldID3 is admin-managed.
+ fieldID1 := model.NewId()
+ fieldID2 := model.NewId()
+ fieldID3 := model.NewId()
+ for _, f := range []struct {
+ id string
+ name string
+ ftype string
+ attrs string
+ }{
+ {fieldID1, "Text Field", "text", `{"visibility":"always","sort_order":1}`},
+ {fieldID2, "Select Field", "select", `{"options":[{"id":"opt1","name":"Option 1"}]}`},
+ {fieldID3, "Admin Managed Field", "text", `{"visibility":"always","sort_order":3,"managed":"admin"}`},
+ } {
+ _, err = master.Exec(
+ `INSERT INTO PropertyFields
+ (ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
+ VALUES (?, ?, ?, ?, ?::jsonb, '', '', '', ?, ?, 0, false)`,
+ f.id, groupID, f.name, f.ftype, f.attrs, now, now,
+ )
+ require.NoError(t, err, "inserting field %s", f.name)
+ }
+
+ // Insert a soft-deleted field to verify all fields are migrated.
+ deletedFieldID := model.NewId()
+ _, err = master.Exec(
+ `INSERT INTO PropertyFields
+ (ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
+ VALUES (?, ?, 'Deleted Field', 'text', '{}'::jsonb, '', '', '', ?, ?, ?, false)`,
+ deletedFieldID, groupID, now, now, now,
+ )
+ require.NoError(t, err)
+
+ // Insert a property value.
+ valueID := model.NewId()
+ targetUserID := model.NewId()
+ _, err = master.Exec(
+ `INSERT INTO PropertyValues
+ (ID, TargetID, TargetType, GroupID, FieldID, Value, CreateAt, UpdateAt, DeleteAt)
+ VALUES (?, ?, 'user', ?, ?, '"hello"'::jsonb, ?, ?, 0)`,
+ valueID, targetUserID, groupID, fieldID1, now, now,
+ )
+ require.NoError(t, err)
+
+ // ---- Run UP migration ----
+ _, err = master.ExecNoTimeout(upSQL)
+ require.NoError(t, err, "up migration should succeed")
+
+ // Verify: group renamed.
+ var groupName string
+ require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
+ assert.Equal(t, "access_control", groupName)
+
+ // Verify: all fields (including soft-deleted) have new metadata.
+ // Non-managed fields get PermissionValues = 'member'.
+ // Admin-managed fields get PermissionValues = 'sysadmin'.
+ for _, tc := range []struct {
+ id string
+ label string
+ expectedPermissionValues string
+ }{
+ {fieldID1, "non-managed text field", "member"},
+ {fieldID2, "non-managed select field", "member"},
+ {fieldID3, "admin-managed field", "sysadmin"},
+ {deletedFieldID, "soft-deleted non-managed field", "member"},
+ } {
+ var f struct {
+ ObjectType string `db:"objecttype"`
+ TargetType string `db:"targettype"`
+ PermissionField sql.NullString `db:"permissionfield"`
+ PermissionValues sql.NullString `db:"permissionvalues"`
+ PermissionOptions sql.NullString `db:"permissionoptions"`
+ }
+ require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", tc.id))
+ assert.Equal(t, "user", f.ObjectType, "%s ObjectType", tc.label)
+ assert.Equal(t, "system", f.TargetType, "%s TargetType", tc.label)
+ assert.True(t, f.PermissionField.Valid, "%s PermissionField should be set", tc.label)
+ assert.Equal(t, "sysadmin", f.PermissionField.String, "%s PermissionField", tc.label)
+ assert.True(t, f.PermissionValues.Valid, "%s PermissionValues should be set", tc.label)
+ assert.Equal(t, tc.expectedPermissionValues, f.PermissionValues.String, "%s PermissionValues", tc.label)
+ assert.True(t, f.PermissionOptions.Valid, "%s PermissionOptions should be set", tc.label)
+ assert.Equal(t, "sysadmin", f.PermissionOptions.String, "%s PermissionOptions", tc.label)
+ }
+
+ // Verify: property value is unchanged (GroupID still references the same ID).
+ var val struct {
+ GroupID string `db:"groupid"`
+ TargetID string `db:"targetid"`
+ TargetType string `db:"targettype"`
+ }
+ require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
+ assert.Equal(t, groupID, val.GroupID, "value GroupID should be unchanged")
+ assert.Equal(t, targetUserID, val.TargetID, "value TargetID should be unchanged")
+ assert.Equal(t, "user", val.TargetType, "value TargetType should be unchanged")
+
+ // Verify: AttributeView exists and includes the ObjectType filter (user-type fields only).
+ var viewDef string
+ err = master.Get(&viewDef, "SELECT definition FROM pg_matviews WHERE matviewname = 'attributeview'")
+ require.NoError(t, err, "AttributeView should exist")
+ assert.Contains(t, viewDef, "pf.objecttype", "view definition should filter by pf.ObjectType")
+
+ // Verify: materialized view contains expected data after refresh.
+ _, err = master.ExecNoTimeout("REFRESH MATERIALIZED VIEW AttributeView")
+ require.NoError(t, err, "refreshing AttributeView should succeed")
+
+ var viewRow struct {
+ GroupID string `db:"groupid"`
+ TargetID string `db:"targetid"`
+ TargetType string `db:"targettype"`
+ Attributes []byte `db:"attributes"`
+ }
+ err = master.Get(&viewRow, "SELECT GroupID, TargetID, TargetType, Attributes FROM AttributeView WHERE TargetID = ?", targetUserID)
+ require.NoError(t, err, "AttributeView should contain a row for the target user")
+ assert.Equal(t, groupID, viewRow.GroupID)
+ assert.Equal(t, targetUserID, viewRow.TargetID)
+ assert.Equal(t, "user", viewRow.TargetType)
+
+ // The text field value "hello" should appear under the field name "Text Field".
+ var attrs map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(viewRow.Attributes, &attrs))
+ assert.JSONEq(t, `"hello"`, string(attrs["Text Field"]), "text field value should be materialized")
+
+ // ---- Run DOWN migration ----
+ _, err = master.ExecNoTimeout(downSQL)
+ require.NoError(t, err, "down migration should succeed")
+
+ // Verify: group name reverted.
+ require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
+ assert.Equal(t, "custom_profile_attributes", groupName)
+
+ // Verify: fields reverted.
+ for _, fid := range []string{fieldID1, fieldID2, fieldID3, deletedFieldID} {
+ var f struct {
+ ObjectType string `db:"objecttype"`
+ TargetType string `db:"targettype"`
+ PermissionField sql.NullString `db:"permissionfield"`
+ PermissionValues sql.NullString `db:"permissionvalues"`
+ PermissionOptions sql.NullString `db:"permissionoptions"`
+ }
+ require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", fid))
+ assert.Equal(t, "", f.ObjectType, "field %s ObjectType should revert", fid)
+ assert.Equal(t, "", f.TargetType, "field %s TargetType should revert", fid)
+ assert.False(t, f.PermissionField.Valid, "field %s PermissionField should be NULL", fid)
+ assert.False(t, f.PermissionValues.Valid, "field %s PermissionValues should be NULL", fid)
+ assert.False(t, f.PermissionOptions.Valid, "field %s PermissionOptions should be NULL", fid)
+ }
+
+ // Verify: value still unchanged after down migration.
+ require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
+ assert.Equal(t, groupID, val.GroupID, "value GroupID should remain unchanged after down")
+}
+
+func TestMigration000185DownPreservesNonUserFields(t *testing.T) {
+ logger := mlog.CreateTestLogger(t)
+
+ settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
+ if err != nil {
+ t.Skip(err)
+ }
+
+ store, err := New(*settings, logger, nil)
+ require.NoError(t, err)
+ defer store.Close()
+
+ master := store.GetMaster()
+
+ upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
+ downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
+
+ groupID := model.NewId()
+ _, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
+ master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
+ })
+
+ now := model.GetMillis()
+
+ // Insert a legacy user field that the up migration will touch.
+ userFieldID := model.NewId()
+ _, err = master.Exec(
+ `INSERT INTO PropertyFields
+ (ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
+ VALUES (?, ?, 'Legacy User Field', 'text', '{}'::jsonb, '', '', '', ?, ?, 0, false)`,
+ userFieldID, groupID, now, now,
+ )
+ require.NoError(t, err)
+
+ // Run UP migration — legacy user field gets ObjectType='user', TargetType='system'.
+ _, err = master.ExecNoTimeout(upSQL)
+ require.NoError(t, err, "up migration should succeed")
+
+ // Simulate a post-migration channel-scoped field created via the
+ // generic property API against the (now renamed) access_control
+ // group.
+ channelFieldID := model.NewId()
+ channelTargetID := model.NewId()
+ _, err = master.Exec(
+ `INSERT INTO PropertyFields
+ (ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, PermissionField, PermissionValues, PermissionOptions, CreateAt, UpdateAt, DeleteAt, Protected)
+ VALUES (?, ?, 'Channel Classification', 'select', '{}'::jsonb, ?, 'channel', 'channel', 'sysadmin', 'member', 'sysadmin', ?, ?, 0, false)`,
+ channelFieldID, groupID, channelTargetID, now, now,
+ )
+ require.NoError(t, err)
+
+ // Run DOWN migration — must revert only user/system fields, not the channel one.
+ _, err = master.ExecNoTimeout(downSQL)
+ require.NoError(t, err, "down migration should succeed")
+
+ // The original user field reverts to legacy metadata.
+ var userField struct {
+ ObjectType string `db:"objecttype"`
+ TargetType string `db:"targettype"`
+ PermissionField sql.NullString `db:"permissionfield"`
+ PermissionValues sql.NullString `db:"permissionvalues"`
+ PermissionOptions sql.NullString `db:"permissionoptions"`
+ }
+ require.NoError(t, master.Get(&userField, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", userFieldID))
+ assert.Equal(t, "", userField.ObjectType, "user field ObjectType should revert")
+ assert.Equal(t, "", userField.TargetType, "user field TargetType should revert")
+ assert.False(t, userField.PermissionField.Valid, "user field PermissionField should be NULL")
+
+ // The post-migration channel field keeps its PSAv2 metadata intact.
+ var channelField struct {
+ ObjectType string `db:"objecttype"`
+ TargetType string `db:"targettype"`
+ TargetID string `db:"targetid"`
+ PermissionField sql.NullString `db:"permissionfield"`
+ PermissionValues sql.NullString `db:"permissionvalues"`
+ PermissionOptions sql.NullString `db:"permissionoptions"`
+ }
+ require.NoError(t, master.Get(&channelField, "SELECT ObjectType, TargetType, TargetID, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", channelFieldID))
+ assert.Equal(t, "channel", channelField.ObjectType, "channel field ObjectType must survive rollback")
+ assert.Equal(t, "channel", channelField.TargetType, "channel field TargetType must survive rollback")
+ assert.Equal(t, channelTargetID, channelField.TargetID, "channel field TargetID must survive rollback")
+ assert.True(t, channelField.PermissionField.Valid, "channel field PermissionField must survive rollback")
+ assert.Equal(t, "sysadmin", channelField.PermissionField.String)
+ assert.True(t, channelField.PermissionValues.Valid)
+ assert.Equal(t, "member", channelField.PermissionValues.String)
+ assert.True(t, channelField.PermissionOptions.Valid)
+ assert.Equal(t, "sysadmin", channelField.PermissionOptions.String)
+}
+
+func TestMigration000185NoOpOnFreshDB(t *testing.T) {
+ logger := mlog.CreateTestLogger(t)
+
+ settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
+ if err != nil {
+ t.Skip(err)
+ }
+
+ store, err := New(*settings, logger, nil)
+ require.NoError(t, err)
+ defer store.Close()
+
+ master := store.GetMaster()
+
+ upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
+ downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
+
+ // On a fresh database with no CPA group, both up and down should be
+ // safe no-ops (the UPDATE statements match zero rows).
+ _, err = master.ExecNoTimeout(upSQL)
+ assert.NoError(t, err, "up migration should be a safe no-op on fresh DB")
+
+ // Even with no CPA data, the view should be (re)created.
+ var viewExists bool
+ require.NoError(t, master.Get(&viewExists, "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'attributeview')"))
+ assert.True(t, viewExists, "AttributeView should exist after up migration on fresh DB")
+
+ _, err = master.ExecNoTimeout(downSQL)
+ assert.NoError(t, err, "down migration should be a safe no-op on fresh DB")
+}
diff --git a/server/channels/store/sqlstore/oauth_store.go b/server/channels/store/sqlstore/oauth_store.go
index d4aa2e2f4ae..7caaf6b57c3 100644
--- a/server/channels/store/sqlstore/oauth_store.go
+++ b/server/channels/store/sqlstore/oauth_store.go
@@ -161,7 +161,7 @@ func (as SqlOAuthStore) GetAuthorizedApps(userId string, offset, limit int) ([]*
func (as SqlOAuthStore) DeleteApp(id string) (err error) {
// wrap in a transaction so that if one fails, everything fails
- transaction, err := as.GetMaster().Beginx()
+ transaction, err := as.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/plugin_store.go b/server/channels/store/sqlstore/plugin_store.go
index 4fe47217efe..ba7b0eecd3a 100644
--- a/server/channels/store/sqlstore/plugin_store.go
+++ b/server/channels/store/sqlstore/plugin_store.go
@@ -217,7 +217,7 @@ func (ps SqlPluginStore) Get(pluginId, key string) (*model.PluginKeyValue, error
return nil, errors.Wrap(err, "plugin_tosql")
}
- row := ps.GetReplica().QueryRowx(queryString, args...)
+ row := ps.GetReplica().QueryRow(queryString, args...)
var kv model.PluginKeyValue
if err := row.Scan(&kv.PluginId, &kv.Key, &kv.Value, &kv.ExpireAt); err != nil {
if err == sql.ErrNoRows {
diff --git a/server/channels/store/sqlstore/post_acknowledgements_store.go b/server/channels/store/sqlstore/post_acknowledgements_store.go
index 923cc2aa0e8..3375344e73d 100644
--- a/server/channels/store/sqlstore/post_acknowledgements_store.go
+++ b/server/channels/store/sqlstore/post_acknowledgements_store.go
@@ -51,7 +51,7 @@ func (s *SqlPostAcknowledgementStore) SaveWithModel(acknowledgement *model.PostA
acknowledgement.PreSave()
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -77,7 +77,7 @@ func (s *SqlPostAcknowledgementStore) SaveWithModel(acknowledgement *model.PostA
}
func (s *SqlPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -285,7 +285,7 @@ func (s *SqlPostAcknowledgementStore) BatchSave(acknowledgements []*model.PostAc
}
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -328,7 +328,7 @@ func (s *SqlPostAcknowledgementStore) BatchDelete(acknowledgements []*model.Post
return nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/post_priority_store.go b/server/channels/store/sqlstore/post_priority_store.go
index d6f157e7cff..452310c83fb 100644
--- a/server/channels/store/sqlstore/post_priority_store.go
+++ b/server/channels/store/sqlstore/post_priority_store.go
@@ -68,7 +68,7 @@ func (s *SqlPostPriorityStore) GetForPosts(postIds []string) ([]*model.PostPrior
}
func (s *SqlPostPriorityStore) Save(priority *model.PostPriority) (*model.PostPriority, error) {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -143,7 +143,7 @@ func (s *SqlPostPriorityStore) Save(priority *model.PostPriority) (*model.PostPr
}
func (s *SqlPostPriorityStore) Delete(postId string) error {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go
index 8c4a7bd7506..61311fdb1fa 100644
--- a/server/channels/store/sqlstore/post_store.go
+++ b/server/channels/store/sqlstore/post_store.go
@@ -245,7 +245,7 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m
}
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return posts, -1, errors.Wrap(err, "begin_transaction")
}
@@ -466,7 +466,7 @@ func (s *SqlPostStore) OverwriteMultiple(rctx request.CTX, posts []*model.Post)
post.ValidateProps(rctx.Logger())
}
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, -1, errors.Wrap(err, "begin_transaction")
}
@@ -970,7 +970,7 @@ func (s *SqlPostStore) GetEtag(channelId string, allowFromCache, collapsedThread
// Soft deletes a post
// and cleans up the thread if it's a comment
func (s *SqlPostStore) Delete(rctx request.CTX, postID string, time int64, deleteByID string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -1025,7 +1025,7 @@ func (s *SqlPostStore) PermanentDelete(rctx request.CTX, postID string) (err err
}
func (s *SqlPostStore) permanentDelete(postIds []string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -1058,7 +1058,7 @@ func (s *SqlPostStore) permanentDelete(postIds []string) (err error) {
// - Read Receipts
// - Thread replies if post is a root post
func (s *SqlPostStore) PermanentDeleteAssociatedData(postIds []string) error {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -1112,7 +1112,7 @@ type postIds struct {
func (s *SqlPostStore) permanentDeleteAllCommentByUser(userId string) (err error) {
results := []postIds{}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -1200,7 +1200,7 @@ func (s *SqlPostStore) PermanentDeleteByUser(rctx request.CTX, userId string) er
// deletes all reactions
// no thread comment cleanup needed, since we are deleting threads and thread memberships
func (s *SqlPostStore) PermanentDeleteByChannel(rctx request.CTX, channelId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -3196,7 +3196,7 @@ func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts
}
func (s *SqlPostStore) SetPostReminder(reminder *model.PostReminder) error {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -3304,7 +3304,7 @@ func (s *SqlPostStore) RefreshPostStats() error {
// it only restores posts deleted by the specified deletedBy user ID, which in case of Content Flagging is
// the Content Reviewer bot.
func (s *SqlPostStore) RestoreContentFlaggedPost(flaggedPost *model.Post, statusFieldId, contentFlaggingManagedFieldId string) error {
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/preference_store.go b/server/channels/store/sqlstore/preference_store.go
index 2f0d5dbf071..e923c0175e9 100644
--- a/server/channels/store/sqlstore/preference_store.go
+++ b/server/channels/store/sqlstore/preference_store.go
@@ -43,7 +43,7 @@ func (s SqlPreferenceStore) deleteUnusedFeatures() {
func (s SqlPreferenceStore) Save(preferences model.Preferences) (err error) {
// wrap in a transaction so that if one fails, everything fails
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/product_notices_store.go b/server/channels/store/sqlstore/product_notices_store.go
index 80fa6617222..ae94aead339 100644
--- a/server/channels/store/sqlstore/product_notices_store.go
+++ b/server/channels/store/sqlstore/product_notices_store.go
@@ -60,7 +60,7 @@ func (s SqlProductNoticesStore) ClearOldNotices(currentNotices model.ProductNoti
}
func (s SqlProductNoticesStore) View(userId string, notices []string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/property_field_store.go b/server/channels/store/sqlstore/property_field_store.go
index adaa4fd8cee..db8d6ad4210 100644
--- a/server/channels/store/sqlstore/property_field_store.go
+++ b/server/channels/store/sqlstore/property_field_store.go
@@ -67,7 +67,7 @@ func (s *SqlPropertyFieldStore) Get(ctx context.Context, groupID, id string) (*m
var field model.PropertyField
if err := s.DBXFromContext(ctx).GetBuilder(&field, builder); err != nil {
- if err == sql.ErrNoRows {
+ if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("PropertyField", id)
}
return nil, errors.Wrap(err, "property_field_get_select")
@@ -85,6 +85,9 @@ func (s *SqlPropertyFieldStore) GetFieldByName(ctx context.Context, groupID, tar
var field model.PropertyField
if err := s.DBXFromContext(ctx).GetBuilder(&field, builder); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, store.NewErrNotFound("PropertyField", name)
+ }
return nil, errors.Wrap(err, "property_field_get_by_name_select")
}
@@ -127,6 +130,24 @@ func (s *SqlPropertyFieldStore) CountForGroup(groupID string, includeDeleted boo
return count, nil
}
+func (s *SqlPropertyFieldStore) CountForGroupObjectType(groupID, objectType string, includeDeleted bool) (int64, error) {
+ var count int64
+ builder := s.getQueryBuilder().
+ Select("COUNT(id)").
+ From("PropertyFields").
+ Where(sq.Eq{"GroupID": groupID}).
+ Where(sq.Eq{"ObjectType": objectType})
+
+ if !includeDeleted {
+ builder = builder.Where(sq.Eq{"DeleteAt": 0})
+ }
+
+ if err := s.GetReplica().GetBuilder(&count, builder); err != nil {
+ return int64(0), errors.Wrap(err, "failed to count property fields for group and object type")
+ }
+ return count, nil
+}
+
func (s *SqlPropertyFieldStore) CountForTarget(groupID, targetType, targetID string, includeDeleted bool) (int64, error) {
var count int64
builder := s.getQueryBuilder().
@@ -210,7 +231,7 @@ func (s *SqlPropertyFieldStore) Update(groupID string, fields []*model.PropertyF
return nil, nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_field_update_begin_transaction")
}
@@ -444,8 +465,7 @@ func (s *SqlPropertyFieldStore) buildConflictSubquery(level string, objectType,
// new fields.
func (s *SqlPropertyFieldStore) CheckPropertyNameConflict(field *model.PropertyField, excludeID string) (model.PropertyFieldTargetLevel, error) {
// Legacy properties (PSAv1) use old uniqueness via DB constraint
- // FIXME: explicitly excluding templates from the shortcircuit, should be removed after CPA is fully migrated to v2
- if field.IsPSAv1() && field.ObjectType != model.PropertyFieldObjectTypeTemplate {
+ if field.IsPSAv1() {
return "", nil
}
diff --git a/server/channels/store/sqlstore/property_value_store.go b/server/channels/store/sqlstore/property_value_store.go
index 89cac63f0b1..e72d6a7161e 100644
--- a/server/channels/store/sqlstore/property_value_store.go
+++ b/server/channels/store/sqlstore/property_value_store.go
@@ -4,6 +4,7 @@
package sqlstore
import (
+ "database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
@@ -61,7 +62,7 @@ func (s *SqlPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*mo
return nil, nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_create_many_begin_transaction")
}
@@ -105,6 +106,9 @@ func (s *SqlPropertyValueStore) Get(groupID, id string) (*model.PropertyValue, e
var value model.PropertyValue
if err := s.GetReplica().GetBuilder(&value, builder); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, store.NewErrNotFound("PropertyValue", id)
+ }
return nil, errors.Wrap(err, "property_value_get_select")
}
@@ -194,7 +198,7 @@ func (s *SqlPropertyValueStore) Update(groupID string, values []*model.PropertyV
return nil, nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_update_begin_transaction")
}
@@ -260,7 +264,7 @@ func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*mode
return nil, nil
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_upsert_begin_transaction")
}
@@ -269,6 +273,11 @@ func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*mode
updatedValues := make([]*model.PropertyValue, len(values))
updateTime := model.GetMillis()
for i, value := range values {
+ // Pin CreateAt to updateTime so PreSave does not capture a later
+ // GetMillis() — keeping CreateAt == UpdateAt on insert.
+ if value.CreateAt == 0 {
+ value.CreateAt = updateTime
+ }
value.PreSave()
value.UpdateAt = updateTime
diff --git a/server/channels/store/sqlstore/reaction_store.go b/server/channels/store/sqlstore/reaction_store.go
index 9b20a70ccbd..cb605832152 100644
--- a/server/channels/store/sqlstore/reaction_store.go
+++ b/server/channels/store/sqlstore/reaction_store.go
@@ -30,7 +30,7 @@ func (s *SqlReactionStore) Save(reaction *model.Reaction) (re *model.Reaction, e
if err := reaction.IsValid(); err != nil {
return nil, err
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -68,7 +68,7 @@ func (s *SqlReactionStore) Save(reaction *model.Reaction) (re *model.Reaction, e
func (s *SqlReactionStore) Delete(reaction *model.Reaction) (re *model.Reaction, err error) {
reaction.PreUpdate()
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -252,7 +252,7 @@ func (s *SqlReactionStore) DeleteAllWithEmojiName(emojiName string) error {
}
func (s *SqlReactionStore) permanentDeleteReactions(userId string) ([]string, error) {
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -289,7 +289,7 @@ func (s SqlReactionStore) PermanentDeleteByUser(userId string) error {
return err
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return err
}
@@ -312,7 +312,7 @@ func (s SqlReactionStore) PermanentDeleteByUser(userId string) error {
}
func (s *SqlReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) (int64, error) {
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return 0, err
}
diff --git a/server/channels/store/sqlstore/remote_cluster_store.go b/server/channels/store/sqlstore/remote_cluster_store.go
index 2f51b242105..cf8bae08e63 100644
--- a/server/channels/store/sqlstore/remote_cluster_store.go
+++ b/server/channels/store/sqlstore/remote_cluster_store.go
@@ -121,7 +121,7 @@ func (s sqlRemoteClusterStore) Update(remoteCluster *model.RemoteCluster) (*mode
}
func (s sqlRemoteClusterStore) Delete(remoteId string) (bool, error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return false, errors.Wrap(err, "DeleteRemoteCluster: begin_transaction")
}
diff --git a/server/channels/store/sqlstore/retention_policy_store.go b/server/channels/store/sqlstore/retention_policy_store.go
index c89aa3e4c25..f3af3af6173 100644
--- a/server/channels/store/sqlstore/retention_policy_store.go
+++ b/server/channels/store/sqlstore/retention_policy_store.go
@@ -77,7 +77,7 @@ func (s *SqlRetentionPolicyStore) Save(policy *model.RetentionPolicyWithTeamAndC
return nil, err
}
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -272,7 +272,7 @@ func (s *SqlRetentionPolicyStore) Patch(patch *model.RetentionPolicyWithTeamAndC
return nil, err
}
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
@@ -807,7 +807,7 @@ func (s *SqlRetentionPolicyStore) GetChannelPoliciesCountForUser(userID string)
return count, nil
}
-func scanRetentionIdsForDeletion(rows *sql.Rows) ([]*model.RetentionIdsForDeletion, error) {
+func scanRetentionIdsForDeletion(rows rowScanner) ([]*model.RetentionIdsForDeletion, error) {
idsForDeletion := []*model.RetentionIdsForDeletion{}
for rows.Next() {
var row model.RetentionIdsForDeletion
@@ -1014,7 +1014,7 @@ func genericRetentionPoliciesDeletion(
}
if r.StoreDeletedIds {
- txn, err := s.GetMaster().Beginx()
+ txn, err := s.GetMaster().Begin()
if err != nil {
return 0, err
}
@@ -1023,7 +1023,7 @@ func genericRetentionPoliciesDeletion(
primaryKeysStr := "(" + strings.Join(r.PrimaryKeys, ",") + ")"
query = fmt.Sprintf("DELETE FROM %s WHERE %s IN (%s) RETURNING %s.%s", r.Table, primaryKeysStr, query, r.Table, r.PrimaryKeys[0])
- var rows *sql.Rows
+ var rows *sqlxRows
rows, err = txn.Query(query, args...)
if err != nil {
return 0, errors.Wrap(err, "failed to delete "+r.Table)
diff --git a/server/channels/store/sqlstore/role_store.go b/server/channels/store/sqlstore/role_store.go
index 4bf1d9f1677..c6ff9d8a061 100644
--- a/server/channels/store/sqlstore/role_store.go
+++ b/server/channels/store/sqlstore/role_store.go
@@ -101,12 +101,12 @@ func newSqlRoleStore(sqlStore *SqlStore) store.RoleStore {
func (s *SqlRoleStore) Save(role *model.Role) (_ *model.Role, err error) {
// Check the role is valid before proceeding.
- if !role.IsValidWithoutId() {
- return nil, store.NewErrInvalidInput("Role", "", fmt.Sprintf("%v", role))
+ if err = role.IsValidWithoutId(); err != nil {
+ return nil, store.NewErrInvalidInput("Role", "", err.Error())
}
if role.Id == "" {
- transaction, terr := s.GetMaster().Beginx()
+ transaction, terr := s.GetMaster().Begin()
if terr != nil {
return nil, errors.Wrap(terr, "begin_transaction")
}
@@ -148,8 +148,8 @@ func (s *SqlRoleStore) Save(role *model.Role) (_ *model.Role, err error) {
func (s *SqlRoleStore) createRole(role *model.Role, transaction *sqlxTxWrapper) (*model.Role, error) {
// Check the role is valid before proceeding.
- if !role.IsValidWithoutId() {
- return nil, store.NewErrInvalidInput("Role", "", fmt.Sprintf("%v", role))
+ if err := role.IsValidWithoutId(); err != nil {
+ return nil, store.NewErrInvalidInput("Role", "", err.Error())
}
dbRole := NewRoleFromModel(role)
diff --git a/server/channels/store/sqlstore/schema_dump.go b/server/channels/store/sqlstore/schema_dump.go
index 385abeecfa7..312584bc61f 100644
--- a/server/channels/store/sqlstore/schema_dump.go
+++ b/server/channels/store/sqlstore/schema_dump.go
@@ -177,6 +177,9 @@ func (ss *SqlStore) getTableOptions() (map[string]map[string]string, error) {
// Add option to the table
tableOptions[tableName][key] = value
}
+ if err := optionsRows.Err(); err != nil {
+ rErr = multierror.Append(rErr, errors.Wrap(err, "error iterating table options rows"))
+ }
return tableOptions, rErr.ErrorOrNil()
}
@@ -253,6 +256,9 @@ func (ss *SqlStore) getTableSchemaInformation() (map[string]*model.DatabaseTable
})
}
}
+ if err := rows.Err(); err != nil {
+ rErr = multierror.Append(rErr, errors.Wrap(err, "error iterating schema rows"))
+ }
return tablesMap, tableCollations, rErr.ErrorOrNil()
}
@@ -298,6 +304,9 @@ func (ss *SqlStore) getTableIndexes() (map[string][]model.DatabaseIndex, error)
tableIndexes[tableName] = append(tableIndexes[tableName], index)
}
+ if err := rows.Err(); err != nil {
+ rErr = multierror.Append(rErr, errors.Wrap(err, "error iterating index rows"))
+ }
return tableIndexes, rErr.ErrorOrNil()
}
diff --git a/server/channels/store/sqlstore/schema_dump_test.go b/server/channels/store/sqlstore/schema_dump_test.go
index 4ca5ee3f200..a4b52ec2e62 100644
--- a/server/channels/store/sqlstore/schema_dump_test.go
+++ b/server/channels/store/sqlstore/schema_dump_test.go
@@ -72,7 +72,7 @@ func TestGetSchemaDefinition(t *testing.T) {
if table.Name == "channels" {
// Check that indexes are present
assert.NotEmpty(t, table.Indexes, "channels table should have indexes")
- assert.Equal(t, 12, len(table.Indexes), "channels table should have 12 indexes")
+ assert.Equal(t, 13, len(table.Indexes), "channels table should have 13 indexes")
// Expected index definitions
expectedIndexDefs := map[string]string{
@@ -88,6 +88,7 @@ func TestGetSchemaDefinition(t *testing.T) {
"idx_channels_team_id_display_name": "CREATE INDEX idx_channels_team_id_display_name ON public.channels USING btree (teamid, displayname)",
"idx_channels_team_id_type": "CREATE INDEX idx_channels_team_id_type ON public.channels USING btree (teamid, type)",
"idx_channels_autotranslation_enabled": "CREATE INDEX idx_channels_autotranslation_enabled ON public.channels USING btree (id) WHERE (autotranslation = true)",
+ "idx_channels_discoverable_team": "CREATE INDEX idx_channels_discoverable_team ON public.channels USING btree (teamid) WHERE ((discoverable = true) AND (type = 'P'::channel_type) AND (deleteat = 0))",
}
// Verify all expected indexes are present with correct definitions
diff --git a/server/channels/store/sqlstore/scheme_store.go b/server/channels/store/sqlstore/scheme_store.go
index c42962fd82c..8a85aaa47f6 100644
--- a/server/channels/store/sqlstore/scheme_store.go
+++ b/server/channels/store/sqlstore/scheme_store.go
@@ -56,7 +56,7 @@ func newSqlSchemeStore(sqlStore *SqlStore) store.SchemeStore {
func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error) {
if scheme.Id == "" {
- transaction, terr := s.GetMaster().Beginx()
+ transaction, terr := s.GetMaster().Begin()
if terr != nil {
return nil, errors.Wrap(terr, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/session_store.go b/server/channels/store/sqlstore/session_store.go
index 98e0e280d39..141d5a2acfc 100644
--- a/server/channels/store/sqlstore/session_store.go
+++ b/server/channels/store/sqlstore/session_store.go
@@ -381,7 +381,7 @@ func (me SqlSessionStore) Cleanup(expiryTime int64, batchSize int64) error {
var rowErr error
rowsAffected, rowErr = sqlResult.RowsAffected()
if rowErr != nil {
- return errors.Wrap(err, "unable to delete sessions")
+ return errors.Wrap(rowErr, "unable to delete sessions")
}
time.Sleep(sessionsCleanupDelay)
diff --git a/server/channels/store/sqlstore/shared_channel_store.go b/server/channels/store/sqlstore/shared_channel_store.go
index c520f5fc77f..abfc729425e 100644
--- a/server/channels/store/sqlstore/shared_channel_store.go
+++ b/server/channels/store/sqlstore/shared_channel_store.go
@@ -43,7 +43,7 @@ func (s SqlSharedChannelStore) Save(sc *model.SharedChannel) (sh *model.SharedCh
return nil, fmt.Errorf("invalid channel: %w", err)
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -273,7 +273,7 @@ func (s SqlSharedChannelStore) Update(sc *model.SharedChannel) (*model.SharedCha
// Returns true if shared channel found and deleted, false if not
// found.
func (s SqlSharedChannelStore) Delete(channelId string) (ok bool, err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return false, errors.Wrap(err, "DeleteSharedChannel: begin_transaction")
}
diff --git a/server/channels/store/sqlstore/sqlx_wrapper.go b/server/channels/store/sqlstore/sqlx_wrapper.go
index 2fdce549169..28fc24dacf0 100644
--- a/server/channels/store/sqlstore/sqlx_wrapper.go
+++ b/server/channels/store/sqlstore/sqlx_wrapper.go
@@ -46,6 +46,45 @@ type Builder interface {
ToSql() (string, []any, error)
}
+// sqlxRow wraps *sqlx.Row together with the context cancel function for the
+// query that produced it. Scan calls cancel immediately after scanning so that
+// the timeout context is released as soon as the row has been consumed, rather
+// than waiting for the timer to fire.
+type sqlxRow struct {
+ row *sqlx.Row
+ cancel context.CancelFunc
+}
+
+func (r *sqlxRow) Scan(dest ...any) error {
+ defer r.cancel()
+ return r.row.Scan(dest...)
+}
+
+// sqlxRows wraps *sqlx.Rows together with the context cancel function for the
+// query that produced it. Close calls cancel so that timeout resources are
+// released as soon as the caller is done iterating, rather than waiting for
+// the timer to fire.
+type sqlxRows struct {
+ *sqlx.Rows
+ cancel context.CancelFunc
+}
+
+// Next advances the cursor and cancels the timeout context on EOF so that
+// resources are released even when the caller exhausts the rows without an
+// explicit Close.
+func (r *sqlxRows) Next() bool {
+ ok := r.Rows.Next()
+ if !ok {
+ r.cancel()
+ }
+ return ok
+}
+
+func (r *sqlxRows) Close() error {
+ defer r.cancel()
+ return r.Rows.Close()
+}
+
// sqlxExecutor exposes sqlx operations. It is used to enable some internal store methods to
// accept both transactions (*sqlxTxWrapper) and common db handlers (*sqlxDbWrapper).
type sqlxExecutor interface {
@@ -55,9 +94,8 @@ type sqlxExecutor interface {
Exec(query string, args ...any) (sql.Result, error)
ExecBuilder(builder Builder) (sql.Result, error)
ExecRaw(query string, args ...any) (sql.Result, error)
- NamedQuery(query string, arg any) (*sqlx.Rows, error)
- QueryRowX(query string, args ...any) *sqlx.Row
- QueryX(query string, args ...any) (*sqlx.Rows, error)
+ QueryRow(query string, args ...any) *sqlxRow
+ Query(query string, args ...any) (*sqlxRows, error)
Select(dest any, query string, args ...any) error
SelectBuilder(dest any, builder Builder) error
}
@@ -107,7 +145,27 @@ func (w *sqlxDBWrapper) Rebind(query string) string {
return w.db.Rebind(query)
}
-func (w *sqlxDBWrapper) Beginx() (*sqlxTxWrapper, error) {
+// noTimeoutKey is the context key that opts a context out of automatic timeout
+// injection by ensureQueryTimeout. Use noTimeoutContext() to create such a context.
+type noTimeoutKey struct{}
+
+// ensureQueryTimeout returns ctx unchanged if it already carries a deadline or
+// has been explicitly marked as timeout-exempt (via noTimeoutContext), otherwise
+// wraps it with the given timeout. Callers that complete synchronously should
+// defer the returned cancel. Callers that return a handle to be consumed later
+// (e.g. QueryRowContext) may discard it — the timer bounds any resource leak to
+// at most timeout duration.
+func ensureQueryTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
+ if _, ok := ctx.Deadline(); ok {
+ return ctx, func() {}
+ }
+ if ctx.Value(noTimeoutKey{}) != nil {
+ return ctx, func() {}
+ }
+ return context.WithTimeout(ctx, timeout)
+}
+
+func (w *sqlxDBWrapper) Begin() (*sqlxTxWrapper, error) {
tx, err := w.db.Beginx()
if err != nil {
return nil, w.checkErr(err)
@@ -116,7 +174,7 @@ func (w *sqlxDBWrapper) Beginx() (*sqlxTxWrapper, error) {
return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil
}
-func (w *sqlxDBWrapper) BeginXWithIsolation(opts *sql.TxOptions) (*sqlxTxWrapper, error) {
+func (w *sqlxDBWrapper) BeginWithIsolation(opts *sql.TxOptions) (*sqlxTxWrapper, error) {
tx, err := w.db.BeginTxx(context.Background(), opts)
if err != nil {
return nil, w.checkErr(err)
@@ -204,40 +262,12 @@ func (w *sqlxDBWrapper) ExecRaw(query string, args ...any) (sql.Result, error) {
return w.checkErrWithResult(w.db.ExecContext(ctx, query, args...))
}
-func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
- query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower)
- ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
-
- if w.trace {
- defer func(then time.Time) {
- printArgs(query, time.Since(then), arg)
- }(time.Now())
- }
-
- return w.checkErrWithRows(w.db.NamedQueryContext(ctx, query, arg))
-}
-
-// QueryRowx forwards to the underlying *sqlx.DB without adding a timeout.
-func (w *sqlxDBWrapper) QueryRowx(query string, args ...any) *sqlx.Row {
- return w.db.QueryRowxContext(context.Background(), query, args...)
-}
-
-// QueryRowxContext forwards to the underlying *sqlx.DB with the caller-supplied context.
-// The caller is responsible for applying an appropriate timeout.
-func (w *sqlxDBWrapper) QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row {
- return w.db.QueryRowxContext(ctx, query, args...)
-}
-
-// QueryRow forwards to the underlying *sqlx.DB without adding a timeout.
-func (w *sqlxDBWrapper) QueryRow(query string, args ...any) *sql.Row {
- return w.db.QueryRow(query, args...)
-}
-
-func (w *sqlxDBWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
+// QueryRowContext forwards to the underlying *sqlx.DB, adding the wrapper timeout
+// if the caller's context carries no deadline. The cancel is released when the
+// caller calls Scan on the returned *sqlxRow.
+func (w *sqlxDBWrapper) QueryRowContext(ctx context.Context, query string, args ...any) *sqlxRow {
query = w.db.Rebind(query)
- ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
+ ctx, cancel := ensureQueryTimeout(ctx, w.queryTimeout)
if w.trace {
defer func(then time.Time) {
@@ -245,13 +275,12 @@ func (w *sqlxDBWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
}(time.Now())
}
- return w.db.QueryRowxContext(ctx, query, args...)
+ return &sqlxRow{row: w.db.QueryRowxContext(ctx, query, args...), cancel: cancel}
}
-func (w *sqlxDBWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
+func (w *sqlxDBWrapper) QueryRow(query string, args ...any) *sqlxRow {
query = w.db.Rebind(query)
ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
if w.trace {
defer func(then time.Time) {
@@ -259,29 +288,50 @@ func (w *sqlxDBWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
}(time.Now())
}
- return w.checkErrWithRows(w.db.QueryxContext(ctx, query, args...))
+ return &sqlxRow{row: w.db.QueryRowxContext(ctx, query, args...), cancel: cancel}
}
-// Query forwards to the underlying *sql.DB without adding a timeout.
-// Callers that need timeout enforcement should use Select or QueryX instead.
-func (w *sqlxDBWrapper) Query(query string, args ...any) (*sql.Rows, error) {
- rows, err := w.db.Query(query, args...)
- return rows, w.checkErr(err)
+func (w *sqlxDBWrapper) Query(query string, args ...any) (*sqlxRows, error) {
+ query = w.db.Rebind(query)
+ ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
+
+ if w.trace {
+ defer func(then time.Time) {
+ printArgs(query, time.Since(then), args)
+ }(time.Now())
+ }
+
+ rows, err := w.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ cancel()
+ return nil, w.checkErr(err)
+ }
+ return &sqlxRows{Rows: rows, cancel: cancel}, nil
}
-// ExecContext forwards to the underlying DB with the caller-supplied context.
-// The caller is responsible for applying an appropriate timeout.
+// ExecContext forwards to the underlying DB, adding the wrapper timeout if the
+// caller's context carries no deadline.
func (w *sqlxDBWrapper) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
+ query = w.db.Rebind(query)
+ ctx, cancel := ensureQueryTimeout(ctx, w.queryTimeout)
+ defer cancel()
+
+ if w.trace {
+ defer func(then time.Time) {
+ printArgs(query, time.Since(then), args)
+ }(time.Now())
+ }
+
return w.checkErrWithResult(w.db.ExecContext(ctx, query, args...))
}
func (w *sqlxDBWrapper) Select(dest any, query string, args ...any) error {
- return w.SelectCtx(context.Background(), dest, query, args...)
+ return w.SelectContext(context.Background(), dest, query, args...)
}
-func (w *sqlxDBWrapper) SelectCtx(ctx context.Context, dest any, query string, args ...any) error {
+func (w *sqlxDBWrapper) SelectContext(ctx context.Context, dest any, query string, args ...any) error {
query = w.db.Rebind(query)
- ctx, cancel := context.WithTimeout(ctx, w.queryTimeout)
+ ctx, cancel := ensureQueryTimeout(ctx, w.queryTimeout)
defer cancel()
if w.trace {
@@ -293,17 +343,25 @@ func (w *sqlxDBWrapper) SelectCtx(ctx context.Context, dest any, query string, a
return w.checkErr(w.db.SelectContext(ctx, dest, query, args...))
}
-// QueryRowContext forwards to the underlying DB with the caller-supplied context.
-// The caller is responsible for applying an appropriate timeout.
-func (w *sqlxDBWrapper) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
- return w.db.QueryRowContext(ctx, query, args...)
-}
+// QueryContext forwards to the underlying DB, adding the wrapper timeout if the
+// caller's context carries no deadline. The cancel is released when the caller
+// calls Close on the returned *sqlxRows (or when Next reaches EOF).
+func (w *sqlxDBWrapper) QueryContext(ctx context.Context, query string, args ...any) (*sqlxRows, error) {
+ query = w.db.Rebind(query)
+ ctx, cancel := ensureQueryTimeout(ctx, w.queryTimeout)
-// QueryContext forwards to the underlying DB with the caller-supplied context.
-// The caller is responsible for applying an appropriate timeout.
-func (w *sqlxDBWrapper) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
- rows, err := w.db.QueryContext(ctx, query, args...)
- return rows, w.checkErr(err)
+ if w.trace {
+ defer func(then time.Time) {
+ printArgs(query, time.Since(then), args)
+ }(time.Now())
+ }
+
+ rows, err := w.db.QueryxContext(ctx, query, args...)
+ if err != nil {
+ cancel()
+ return nil, w.checkErr(err)
+ }
+ return &sqlxRows{Rows: rows, cancel: cancel}, nil
}
func (w *sqlxDBWrapper) SelectBuilder(dest any, builder Builder) error {
@@ -316,7 +374,7 @@ func (w *sqlxDBWrapper) SelectBuilderCtx(ctx context.Context, dest any, builder
return err
}
- return w.SelectCtx(ctx, dest, query, args...)
+ return w.SelectContext(ctx, dest, query, args...)
}
type sqlxTxWrapper struct {
@@ -422,52 +480,9 @@ func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) {
return w.dbw.checkErrWithResult(w.tx.NamedExecContext(ctx, query, arg))
}
-func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
- query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower)
- ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
-
- if w.trace {
- defer func(then time.Time) {
- printArgs(query, time.Since(then), arg)
- }(time.Now())
- }
-
- // There is no tx.NamedQueryContext support in the sqlx API. (https://github.com/jmoiron/sqlx/issues/447)
- // So we need to implement this ourselves.
- type result struct {
- rows *sqlx.Rows
- err error
- }
-
- // Need to add a buffer of 1 to prevent goroutine leak.
- resChan := make(chan *result, 1)
- go func() {
- rows, err := w.tx.NamedQuery(query, arg)
- resChan <- &result{
- rows: rows,
- err: err,
- }
- }()
-
- // staticcheck fails to check that res gets re-assigned later.
- res := &result{} //nolint:staticcheck
- select {
- case res = <-resChan:
- case <-ctx.Done():
- res = &result{
- rows: nil,
- err: ctx.Err(),
- }
- }
-
- return res.rows, w.dbw.checkErr(res.err)
-}
-
-func (w *sqlxTxWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
+func (w *sqlxTxWrapper) QueryRow(query string, args ...any) *sqlxRow {
query = w.tx.Rebind(query)
ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
if w.trace {
defer func(then time.Time) {
@@ -475,13 +490,12 @@ func (w *sqlxTxWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
}(time.Now())
}
- return w.tx.QueryRowxContext(ctx, query, args...)
+ return &sqlxRow{row: w.tx.QueryRowxContext(ctx, query, args...), cancel: cancel}
}
-func (w *sqlxTxWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
+func (w *sqlxTxWrapper) Query(query string, args ...any) (*sqlxRows, error) {
query = w.tx.Rebind(query)
ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout)
- defer cancel()
if w.trace {
defer func(then time.Time) {
@@ -489,7 +503,12 @@ func (w *sqlxTxWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
}(time.Now())
}
- return w.dbw.checkErrWithRows(w.tx.QueryxContext(ctx, query, args...))
+ rows, err := w.tx.QueryxContext(ctx, query, args...)
+ if err != nil {
+ cancel()
+ return nil, w.dbw.checkErr(err)
+ }
+ return &sqlxRows{Rows: rows, cancel: cancel}, nil
}
func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error {
@@ -506,13 +525,6 @@ func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error {
return w.dbw.checkErr(w.tx.SelectContext(ctx, dest, query, args...))
}
-// Query forwards to the underlying *sqlx.Tx without adding a timeout.
-// Callers that need timeout enforcement should use Select or QueryX instead.
-func (w *sqlxTxWrapper) Query(query string, args ...any) (*sql.Rows, error) {
- rows, err := w.tx.Query(query, args...)
- return rows, w.dbw.checkErr(err)
-}
-
func (w *sqlxTxWrapper) SelectBuilder(dest any, builder Builder) error {
query, args, err := builder.ToSql()
if err != nil {
@@ -546,10 +558,6 @@ func (w *sqlxDBWrapper) checkErrWithResult(res sql.Result, err error) (sql.Resul
return res, w.checkErr(err)
}
-func (w *sqlxDBWrapper) checkErrWithRows(res *sqlx.Rows, err error) (*sqlx.Rows, error) {
- return res, w.checkErr(err)
-}
-
func (w *sqlxDBWrapper) checkErr(err error) error {
var netError *net.OpError
if errors.As(err, &netError) && (!netError.Temporary() && !netError.Timeout()) {
diff --git a/server/channels/store/sqlstore/sqlx_wrapper_test.go b/server/channels/store/sqlstore/sqlx_wrapper_test.go
index 01449d833c1..74ec277b9ca 100644
--- a/server/channels/store/sqlstore/sqlx_wrapper_test.go
+++ b/server/channels/store/sqlstore/sqlx_wrapper_test.go
@@ -5,10 +5,14 @@ package sqlstore
import (
"context"
+ "database/sql/driver"
+ "errors"
"strings"
"sync"
"testing"
+ "time"
+ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -16,42 +20,29 @@ import (
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
+// requireQueryTimeout asserts that err represents a query-timeout cancellation.
+// Three error shapes are possible depending on timing in the pq driver:
+// - context.DeadlineExceeded — context fires before the query reaches the server.
+// - pq.Error{Code:"57014"} — PostgreSQL cancels the in-flight query and returns
+// "canceling statement due to user request" on the original connection.
+// - driver.ErrBadConn — pq's watchCancel goroutine sets cn.err on the client side
+// before the query loop reads the server's 57014 response; pq short-circuits and
+// returns this sentinel rather than the server error.
+func requireQueryTimeout(t *testing.T, err error) {
+ t.Helper()
+ require.Error(t, err)
+ var pqErr *pq.Error
+ switch {
+ case errors.As(err, &pqErr):
+ require.Equal(t, "57014", string(pqErr.Code))
+ case errors.Is(err, driver.ErrBadConn):
+ // client-side short-circuit; see comment above
+ default:
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ }
+}
+
func TestSqlX(t *testing.T) {
- t.Run("NamedQuery", func(t *testing.T) {
- testDrivers := []string{
- model.DatabaseDriverPostgres,
- }
-
- for _, driver := range testDrivers {
- settings, err := makeSqlSettings(driver)
- if err != nil {
- continue
- }
- *settings.QueryTimeout = 1
- store := &SqlStore{
- rrCounter: 0,
- srCounter: 0,
- settings: settings,
- logger: mlog.CreateConsoleTestLogger(t),
- quitMonitor: make(chan struct{}),
- wgMonitor: &sync.WaitGroup{},
- }
-
- require.NoError(t, store.initConnection())
-
- defer store.Close()
-
- tx, err := store.GetMaster().Beginx()
- require.NoError(t, err)
-
- query := `SELECT pg_sleep(:timeout);`
- arg := struct{ Timeout int }{Timeout: 2}
- _, err = tx.NamedQuery(query, arg)
- require.Equal(t, context.DeadlineExceeded, err)
- require.NoError(t, tx.Commit())
- }
- })
-
t.Run("NamedParse", func(t *testing.T) {
queries := []struct {
in string
@@ -85,61 +76,200 @@ func TestSqlX(t *testing.T) {
})
}
-func TestSqlxSelect(t *testing.T) {
- testDrivers := []string{
- model.DatabaseDriverPostgres,
+func makeStoreWithTimeout(t *testing.T, driver string, queryTimeout time.Duration) *SqlStore {
+ t.Helper()
+ settings, err := makeSqlSettings(driver)
+ if err != nil {
+ t.Skip(err)
}
- for _, driver := range testDrivers {
- t.Run(driver, func(t *testing.T) {
- settings, err := makeSqlSettings(driver)
- if err != nil {
- t.Skip(err)
- }
- *settings.QueryTimeout = 1
- store := &SqlStore{
- rrCounter: 0,
- srCounter: 0,
- settings: settings,
- logger: mlog.CreateConsoleTestLogger(t),
- quitMonitor: make(chan struct{}),
- wgMonitor: &sync.WaitGroup{},
- }
-
- require.NoError(t, store.initConnection())
- defer store.Close()
-
- t.Run("SelectCtx", func(t *testing.T) {
- var result []string
- err := store.GetMaster().SelectCtx(context.Background(), &result, "SELECT 'test' AS col")
- require.NoError(t, err)
- require.Equal(t, []string{"test"}, result)
-
- // Test timeout
- ctx, cancel := context.WithTimeout(context.Background(), 1)
- defer cancel()
- query := "SELECT pg_sleep(2)"
- err = store.GetMaster().SelectCtx(ctx, &result, query)
- require.Error(t, err)
- require.Equal(t, context.DeadlineExceeded, err)
- })
-
- t.Run("SelectBuilderCtx", func(t *testing.T) {
- var result []string
- builder := store.getQueryBuilder().
- Select("'test' AS col")
- err := store.GetMaster().SelectBuilderCtx(context.Background(), &result, builder)
- require.NoError(t, err)
- require.Equal(t, []string{"test"}, result)
-
- // Test timeout
- ctx, cancel := context.WithTimeout(context.Background(), 1)
- defer cancel()
- builder = store.getQueryBuilder().
- Select("pg_sleep(2)")
- err = store.GetMaster().SelectBuilderCtx(ctx, &result, builder)
- require.Error(t, err)
- require.Equal(t, context.DeadlineExceeded, err)
- })
- })
+ *settings.QueryTimeout = int(queryTimeout.Seconds())
+ store := &SqlStore{
+ settings: settings,
+ logger: mlog.CreateConsoleTestLogger(t),
+ quitMonitor: make(chan struct{}),
+ wgMonitor: &sync.WaitGroup{},
}
+ require.NoError(t, store.initConnection())
+ t.Cleanup(store.Close)
+ return store
+}
+
+func TestSqlxQuery(t *testing.T) {
+ store := makeStoreWithTimeout(t, model.DatabaseDriverPostgres, 1*time.Second)
+
+ t.Run("db/happy", func(t *testing.T) {
+ rows, err := store.GetMaster().Query("SELECT 1")
+ require.NoError(t, err)
+ defer rows.Close()
+ require.True(t, rows.Next())
+ var v int
+ require.NoError(t, rows.Scan(&v))
+ require.Equal(t, 1, v)
+ })
+
+ t.Run("db/timeout", func(t *testing.T) {
+ _, err := store.GetMaster().Query("SELECT pg_sleep(2)")
+ requireQueryTimeout(t, err)
+ })
+
+ t.Run("tx/happy", func(t *testing.T) {
+ tx, err := store.GetMaster().Begin()
+ require.NoError(t, err)
+ defer func() { assert.NoError(t, tx.Rollback()) }()
+ rows, err := tx.Query("SELECT 1")
+ require.NoError(t, err)
+ defer rows.Close()
+ require.True(t, rows.Next())
+ var v int
+ require.NoError(t, rows.Scan(&v))
+ require.Equal(t, 1, v)
+ })
+
+ t.Run("tx/timeout", func(t *testing.T) {
+ tx, err := store.GetMaster().Begin()
+ require.NoError(t, err)
+ _, err = tx.Query("SELECT pg_sleep(2)")
+ require.Error(t, tx.Rollback()) // connection is dead after timeout
+ requireQueryTimeout(t, err)
+ })
+}
+
+func TestSqlxQueryRow(t *testing.T) {
+ store := makeStoreWithTimeout(t, model.DatabaseDriverPostgres, 1*time.Second)
+
+ t.Run("db/happy", func(t *testing.T) {
+ var v int
+ require.NoError(t, store.GetMaster().QueryRow("SELECT 1").Scan(&v))
+ require.Equal(t, 1, v)
+ })
+
+ t.Run("db/timeout", func(t *testing.T) {
+ var v int
+ err := store.GetMaster().QueryRow("SELECT 1 FROM (SELECT pg_sleep(2)) s").Scan(&v)
+ requireQueryTimeout(t, err)
+ })
+
+ t.Run("tx/happy", func(t *testing.T) {
+ tx, err := store.GetMaster().Begin()
+ require.NoError(t, err)
+ defer func() { assert.NoError(t, tx.Rollback()) }()
+ var v int
+ require.NoError(t, tx.QueryRow("SELECT 1").Scan(&v))
+ require.Equal(t, 1, v)
+ })
+
+ t.Run("tx/timeout", func(t *testing.T) {
+ tx, err := store.GetMaster().Begin()
+ require.NoError(t, err)
+ var v int
+ err = tx.QueryRow("SELECT 1 FROM (SELECT pg_sleep(2)) s").Scan(&v)
+ require.Error(t, tx.Rollback()) // connection is dead after timeout
+ requireQueryTimeout(t, err)
+ })
+}
+
+func TestEnsureQueryTimeout(t *testing.T) {
+ t.Run("no deadline adds timeout", func(t *testing.T) {
+ timeout := 30 * time.Second
+ ctx, cancel := ensureQueryTimeout(context.Background(), timeout)
+ defer cancel()
+ deadline, ok := ctx.Deadline()
+ require.True(t, ok)
+ require.WithinDuration(t, time.Now().Add(timeout), deadline, time.Second)
+ })
+
+ t.Run("existing deadline is preserved", func(t *testing.T) {
+ originalDeadline := time.Now().Add(5 * time.Minute)
+ parent, parentCancel := context.WithDeadline(context.Background(), originalDeadline)
+ defer parentCancel()
+
+ ctx, cancel := ensureQueryTimeout(parent, 30*time.Second)
+ defer cancel()
+
+ deadline, ok := ctx.Deadline()
+ require.True(t, ok)
+ require.Equal(t, originalDeadline, deadline)
+ })
+
+ t.Run("no-op cancel is safe to call", func(t *testing.T) {
+ parent, parentCancel := context.WithTimeout(context.Background(), time.Minute)
+ defer parentCancel()
+
+ _, cancel := ensureQueryTimeout(parent, 30*time.Second)
+ require.NotPanics(t, func() { cancel() }) // calling the no-op cancel must not panic
+ })
+
+ t.Run("noTimeoutKey suppresses timeout injection", func(t *testing.T) {
+ ctx := context.WithValue(context.Background(), noTimeoutKey{}, true)
+ newCtx, cancel := ensureQueryTimeout(ctx, 30*time.Second)
+ defer cancel()
+ _, ok := newCtx.Deadline()
+ require.False(t, ok)
+ })
+}
+
+func TestSqlxSelectContext(t *testing.T) {
+ store := makeStoreWithTimeout(t, model.DatabaseDriverPostgres, 1*time.Second)
+
+ t.Run("adds timeout when context has none", func(t *testing.T) {
+ // context.Background() has no deadline — wrapper adds 1s; pg_sleep(2) times out.
+ var result []string
+ err := store.GetMaster().SelectContext(context.Background(), &result, "SELECT pg_sleep(2)")
+ requireQueryTimeout(t, err)
+ })
+
+ t.Run("respects caller deadline shorter than wrapper timeout", func(t *testing.T) {
+ // 1ns context fires before the query reaches the server.
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel()
+ var result []string
+ err := store.GetMaster().SelectContext(ctx, &result, "SELECT pg_sleep(2)")
+ requireQueryTimeout(t, err)
+ })
+
+ t.Run("respects caller deadline longer than wrapper timeout", func(t *testing.T) {
+ // Caller supplies a 5s deadline; wrapper queryTimeout is 1s.
+ // With "add only if missing" the 5s deadline is used — the 2s sleep completes.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var result []int
+ err := store.GetMaster().SelectContext(ctx, &result, "SELECT 1 FROM (SELECT pg_sleep(2)) s")
+ require.NoError(t, err)
+ require.Equal(t, []int{1}, result)
+ })
+}
+
+func TestSqlxSelect(t *testing.T) {
+ store := makeStoreWithTimeout(t, model.DatabaseDriverPostgres, 1*time.Second)
+
+ t.Run("SelectCtx", func(t *testing.T) {
+ var result []string
+ err := store.GetMaster().SelectContext(context.Background(), &result, "SELECT 'test' AS col")
+ require.NoError(t, err)
+ require.Equal(t, []string{"test"}, result)
+
+ // Test timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel()
+ query := "SELECT pg_sleep(2)"
+ err = store.GetMaster().SelectContext(ctx, &result, query)
+ requireQueryTimeout(t, err)
+ })
+
+ t.Run("SelectBuilderCtx", func(t *testing.T) {
+ var result []string
+ builder := store.getQueryBuilder().
+ Select("'test' AS col")
+ err := store.GetMaster().SelectBuilderCtx(context.Background(), &result, builder)
+ require.NoError(t, err)
+ require.Equal(t, []string{"test"}, result)
+
+ // Test timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel()
+ builder = store.getQueryBuilder().
+ Select("pg_sleep(2)")
+ err = store.GetMaster().SelectBuilderCtx(ctx, &result, builder)
+ requireQueryTimeout(t, err)
+ })
}
diff --git a/server/channels/store/sqlstore/status_store.go b/server/channels/store/sqlstore/status_store.go
index 06c21453130..a0f167e1f0b 100644
--- a/server/channels/store/sqlstore/status_store.go
+++ b/server/channels/store/sqlstore/status_store.go
@@ -80,7 +80,7 @@ func (s SqlStatusStore) SaveOrUpdateMany(statuses map[string]*model.Status) (ret
statusList = append(statusList, st)
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go
index fa5da6042c6..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
@@ -117,6 +118,7 @@ type SqlStoreStores struct {
recap store.RecapStore
readReceipt store.ReadReceiptStore
temporaryPost store.TemporaryPostStore
+ channelJoinRequest store.ChannelJoinRequestStore
}
type SqlStore struct {
@@ -291,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)
@@ -303,6 +306,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
store.stores.recap = newSqlRecapStore(store)
store.stores.readReceipt = newSqlReadReceiptStore(store, metrics)
store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics)
+ store.stores.channelJoinRequest = newSqlChannelJoinRequestStore(store)
store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures()
@@ -479,9 +483,11 @@ func (ss *SqlStore) analyticsContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Duration(*ss.settings.AnalyticsQueryTimeout)*time.Second)
}
-// noTimeoutContext should only be used with queries that expect no client-side timeout.
+// noTimeoutContext returns a context that suppresses automatic timeout injection
+// by ensureQueryTimeout. Use only for queries that legitimately must be unbounded,
+// such as schema introspection or long-running migrations.
func (ss *SqlStore) noTimeoutContext() context.Context {
- return context.Background()
+ return context.WithValue(context.Background(), noTimeoutKey{}, true)
}
func (ss *SqlStore) monitorReplicas() {
@@ -543,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 {
@@ -597,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
@@ -884,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
}
@@ -924,6 +959,10 @@ func (ss *SqlStore) TemporaryPost() store.TemporaryPostStore {
return ss.stores.temporaryPost
}
+func (ss *SqlStore) ChannelJoinRequest() store.ChannelJoinRequestStore {
+ return ss.stores.channelJoinRequest
+}
+
func (ss *SqlStore) DropAllTables() {
ss.masterX.Exec(`DO
$func$
diff --git a/server/channels/store/sqlstore/system_store.go b/server/channels/store/sqlstore/system_store.go
index 45b23defc57..f3842688993 100644
--- a/server/channels/store/sqlstore/system_store.go
+++ b/server/channels/store/sqlstore/system_store.go
@@ -114,7 +114,7 @@ func (s SqlSystemStore) PermanentDeleteByName(name string) (*model.System, error
// InsertIfExists inserts a given system value if it does not already exist. If a value
// already exists, it returns the old one, else returns the new one.
func (s SqlSystemStore) InsertIfExists(system *model.System) (_ *model.System, err error) {
- tx, err := s.GetMaster().BeginXWithIsolation(&sql.TxOptions{
+ tx, err := s.GetMaster().BeginWithIsolation(&sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
diff --git a/server/channels/store/sqlstore/team_store.go b/server/channels/store/sqlstore/team_store.go
index 10761edb20e..4d4631d4758 100644
--- a/server/channels/store/sqlstore/team_store.go
+++ b/server/channels/store/sqlstore/team_store.go
@@ -835,7 +835,7 @@ func (s SqlTeamStore) SaveMultipleMembers(members []*model.TeamMember, maxUsersP
}
}
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -1302,7 +1302,7 @@ func (s SqlTeamStore) GetTeamsByScheme(schemeId string, offset int, limit int) (
func (s SqlTeamStore) MigrateTeamMembers(fromTeamId string, fromUserId string) (_ map[string]string, err error) {
var transaction *sqlxTxWrapper
- if transaction, err = s.GetMaster().Beginx(); err != nil {
+ if transaction, err = s.GetMaster().Begin(); err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
@@ -1398,7 +1398,7 @@ func (s SqlTeamStore) ClearAllCustomRoleAssignments() (err error) {
var transaction *sqlxTxWrapper
var err error
- if transaction, err = s.GetMaster().Beginx(); err != nil {
+ if transaction, err = s.GetMaster().Begin(); err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
diff --git a/server/channels/store/sqlstore/temporary_post_store.go b/server/channels/store/sqlstore/temporary_post_store.go
index 32ef1155e9e..10358580b75 100644
--- a/server/channels/store/sqlstore/temporary_post_store.go
+++ b/server/channels/store/sqlstore/temporary_post_store.go
@@ -54,7 +54,7 @@ func (s *SqlTemporaryPostStore) Save(rctx request.CTX, post *model.TemporaryPost
}
var tx *sqlxTxWrapper
- tx, err = s.GetMaster().Beginx()
+ tx, err = s.GetMaster().Begin()
if err != nil {
return nil, err
}
diff --git a/server/channels/store/sqlstore/thread_store.go b/server/channels/store/sqlstore/thread_store.go
index f09bf8b18b1..136c02f2a08 100644
--- a/server/channels/store/sqlstore/thread_store.go
+++ b/server/channels/store/sqlstore/thread_store.go
@@ -836,7 +836,7 @@ func (s *SqlThreadStore) DeleteMembershipForUser(userId string, postId string) e
// - channel marked unread
// - user explicitly following a thread
func (s *SqlThreadStore) MaintainMembership(userID, postID string, opts store.ThreadMembershipOpts) (_ *model.ThreadMembership, err error) {
- trx, err := s.GetMaster().Beginx()
+ trx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -855,7 +855,7 @@ func (s *SqlThreadStore) MaintainMembership(userID, postID string, opts store.Th
}
func (s *SqlThreadStore) MaintainMultipleFromImport(memberships []*model.ThreadMembership) (_ []*model.ThreadMembership, err error) {
- trx, err := s.GetMaster().Beginx()
+ trx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
@@ -1081,7 +1081,7 @@ func (s *SqlThreadStore) SaveMultipleMemberships(memberships []*model.ThreadMemb
member.LastUpdated = model.GetMillis()
}
- tx, err := s.GetMaster().Beginx()
+ tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/user_access_token_store.go b/server/channels/store/sqlstore/user_access_token_store.go
index c4abb67e6e7..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")
@@ -59,7 +60,7 @@ func (s SqlUserAccessTokenStore) Save(token *model.UserAccessToken) (*model.User
}
func (s SqlUserAccessTokenStore) Delete(tokenId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -95,7 +96,7 @@ func (s SqlUserAccessTokenStore) deleteTokensById(transaction *sqlxTxWrapper, to
}
func (s SqlUserAccessTokenStore) DeleteAllForUser(userId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -215,7 +216,7 @@ func (s SqlUserAccessTokenStore) UpdateTokenEnable(tokenId string) error {
}
func (s SqlUserAccessTokenStore) UpdateTokenDisable(tokenId string) (err error) {
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -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/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go
index 523355432d7..cfdfb2c4d3a 100644
--- a/server/channels/store/sqlstore/user_store.go
+++ b/server/channels/store/sqlstore/user_store.go
@@ -426,6 +426,45 @@ func (us SqlUserStore) UpdateFailedPasswordAttempts(userId string, attempts int)
return nil
}
+// TryIncrementFailedPasswordAttempts atomically increments FailedAttempts by one
+// for the given user, only if FailedAttempts is strictly less than maxAttempts.
+// Returns true if the row was updated (a slot was claimed), false if the cap had
+// already been reached (or the user does not exist). The row lock taken by the
+// UPDATE serializes concurrent attempts on the same user, so the cap predicate
+// is enforced without any application-level locking.
+func (us SqlUserStore) TryIncrementFailedPasswordAttempts(userId string, maxAttempts int) (bool, error) {
+ res, err := us.GetMaster().Exec(
+ "UPDATE Users SET FailedAttempts = FailedAttempts + 1 WHERE Id = ? AND FailedAttempts < ?",
+ userId, maxAttempts,
+ )
+ if err != nil {
+ return false, errors.Wrapf(err, "failed to update User with userId=%s", userId)
+ }
+
+ rows, err := res.RowsAffected()
+ if err != nil {
+ return false, errors.Wrapf(err, "failed to read rows affected for userId=%s", userId)
+ }
+
+ return rows == 1, nil
+}
+
+// DecrementFailedPasswordAttempts atomically decrements FailedAttempts by one
+// for the given user, only if FailedAttempts is strictly greater than zero. It
+// is used to refund a slot previously claimed by TryIncrementFailedPasswordAttempts
+// when the in-flight authentication turns out not to be a credential-failure
+// event (e.g. a backend error or an MFA pre-flight probe).
+func (us SqlUserStore) DecrementFailedPasswordAttempts(userId string) error {
+ _, err := us.GetMaster().Exec(
+ "UPDATE Users SET FailedAttempts = FailedAttempts - 1 WHERE Id = ? AND FailedAttempts > 0",
+ userId,
+ )
+ if err != nil {
+ return errors.Wrapf(err, "failed to update User with userId=%s", userId)
+ }
+ return nil
+}
+
func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *string, email string, resetMfa bool) (string, error) {
updateAt := model.GetMillis()
@@ -1281,6 +1320,27 @@ func (us SqlUserStore) GetByRemoteID(remoteID string) (*model.User, error) {
return &user, nil
}
+func (us SqlUserStore) GetByAuthData(authData *string) (*model.User, error) {
+ if authData == nil || *authData == "" {
+ return nil, store.NewErrInvalidInput("User", "", "empty or nil")
+ }
+
+ query := us.usersQuery.Where("Users.AuthData = ?", authData)
+
+ queryString, args, err := query.ToSql()
+ if err != nil {
+ return nil, errors.Wrap(err, "get_by_auth_data_tosql")
+ }
+
+ user := model.User{}
+ if err := us.GetReplica().Get(&user, queryString, args...); err == sql.ErrNoRows {
+ return nil, store.NewErrNotFound("User", fmt.Sprintf("authData=%s", *authData))
+ } else if err != nil {
+ return nil, errors.Wrapf(err, "failed to find User with authData=%s", *authData)
+ }
+ return &user, nil
+}
+
func (us SqlUserStore) GetByAuth(authData *string, authService string) (*model.User, error) {
if authData == nil || *authData == "" {
return nil, store.NewErrInvalidInput("User", "", "empty or nil")
@@ -1884,7 +1944,7 @@ func (us SqlUserStore) ClearAllCustomRoleAssignments() (err error) {
var transaction *sqlxTxWrapper
var err error
- if transaction, err = us.GetMaster().Beginx(); err != nil {
+ if transaction, err = us.GetMaster().Begin(); err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
@@ -2131,7 +2191,7 @@ func applyViewRestrictionsFilter(query sq.SelectBuilder, restrictions *model.Vie
}
func (us SqlUserStore) PromoteGuestToUser(userId string) (err error) {
- transaction, err := us.GetMaster().Beginx()
+ transaction, err := us.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
@@ -2200,7 +2260,7 @@ func (us SqlUserStore) PromoteGuestToUser(userId string) (err error) {
}
func (us SqlUserStore) DemoteUserToGuest(userID string) (_ *model.User, err error) {
- transaction, err := us.GetMaster().Beginx()
+ transaction, err := us.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
diff --git a/server/channels/store/sqlstore/utils.go b/server/channels/store/sqlstore/utils.go
index 4ee4a4f4a21..4bac11d2b59 100644
--- a/server/channels/store/sqlstore/utils.go
+++ b/server/channels/store/sqlstore/utils.go
@@ -193,8 +193,15 @@ func trimInput(input string) string {
return input
}
+// rowScanner is the minimal interface needed to iterate over SQL result rows.
+type rowScanner interface {
+ Next() bool
+ Scan(dest ...any) error
+ Err() error
+}
+
// scanRowsIntoMap scans SQL rows into a map, using a provided scanner function to extract key-value pairs
-func scanRowsIntoMap[K comparable, V any](rows *sql.Rows, scanner func(rows *sql.Rows) (K, V, error), defaults map[K]V) (map[K]V, error) {
+func scanRowsIntoMap[K comparable, V any](rows rowScanner, scanner func(rows rowScanner) (K, V, error), defaults map[K]V) (map[K]V, error) {
results := make(map[K]V, len(defaults))
// Initialize with default values if provided
diff --git a/server/channels/store/sqlstore/utils_test.go b/server/channels/store/sqlstore/utils_test.go
index 4188a5a8843..44e39c47860 100644
--- a/server/channels/store/sqlstore/utils_test.go
+++ b/server/channels/store/sqlstore/utils_test.go
@@ -4,7 +4,6 @@
package sqlstore
import (
- "database/sql"
"testing"
"github.com/mattermost/mattermost/server/public/shared/request"
@@ -214,7 +213,7 @@ func TestScanRowsIntoMap(t *testing.T) {
defer rows.Close()
// Create scanner function
- scanner := func(rows *sql.Rows) (string, int, error) {
+ scanner := func(rows rowScanner) (string, int, error) {
var key string
var value int
return key, value, rows.Scan(&key, &value)
@@ -253,7 +252,7 @@ func TestScanRowsIntoMap(t *testing.T) {
defer rows.Close()
// Create scanner function
- scanner := func(rows *sql.Rows) (string, int, error) {
+ scanner := func(rows rowScanner) (string, int, error) {
var key string
var value int
return key, value, rows.Scan(&key, &value)
@@ -293,7 +292,7 @@ func TestScanRowsIntoMap(t *testing.T) {
defer rows.Close()
// Create scanner function
- scanner := func(rows *sql.Rows) (string, int, error) {
+ scanner := func(rows rowScanner) (string, int, error) {
var key string
var value int
return key, value, rows.Scan(&key, &value)
diff --git a/server/channels/store/sqlstore/view_store.go b/server/channels/store/sqlstore/view_store.go
index d343043c86c..5906885389b 100644
--- a/server/channels/store/sqlstore/view_store.go
+++ b/server/channels/store/sqlstore/view_store.go
@@ -206,7 +206,7 @@ func (s *SqlViewStore) UpdateSortOrder(viewID, channelID string, newIndex int64)
}
now := model.GetMillis()
- transaction, err := s.GetMaster().Beginx()
+ transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to begin transaction for UpdateSortOrder")
}
diff --git a/server/channels/store/sqlstore/webhook_store.go b/server/channels/store/sqlstore/webhook_store.go
index d0fc10b122c..5b3f4feca41 100644
--- a/server/channels/store/sqlstore/webhook_store.go
+++ b/server/channels/store/sqlstore/webhook_store.go
@@ -46,6 +46,7 @@ func newSqlWebhookStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface
"Username",
"IconURL",
"ChannelLocked",
+ "LastUsed",
).
From("IncomingWebhooks")
@@ -88,9 +89,9 @@ func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) (*model.In
}
if _, err := s.GetMaster().NamedExec(`INSERT INTO IncomingWebhooks
- (Id, CreateAt, UpdateAt, DeleteAt, UserId, ChannelId, TeamId, DisplayName, Description, Username, IconURL, ChannelLocked)
+ (Id, CreateAt, UpdateAt, DeleteAt, UserId, ChannelId, TeamId, DisplayName, Description, Username, IconURL, ChannelLocked, LastUsed)
VALUES
- (:Id, :CreateAt, :UpdateAt, :DeleteAt, :UserId, :ChannelId, :TeamId, :DisplayName, :Description, :Username, :IconURL, :ChannelLocked)`, webhook); err != nil {
+ (:Id, :CreateAt, :UpdateAt, :DeleteAt, :UserId, :ChannelId, :TeamId, :DisplayName, :Description, :Username, :IconURL, :ChannelLocked, :LastUsed)`, webhook); err != nil {
return nil, errors.Wrapf(err, "failed to save IncomingWebhook with id=%s", webhook.Id)
}
@@ -111,6 +112,19 @@ func (s SqlWebhookStore) UpdateIncoming(hook *model.IncomingWebhook) (*model.Inc
return hook, nil
}
+func (s SqlWebhookStore) UpdateIncomingLastUsed(webhookID string, lastUsed int64) error {
+ _, err := s.GetMaster().Exec(
+ `UPDATE IncomingWebhooks SET LastUsed = ? WHERE Id = ? AND DeleteAt = 0`,
+ lastUsed,
+ webhookID,
+ )
+ if err != nil {
+ return errors.Wrapf(err, "failed to update LastUsed for IncomingWebhook id=%s", webhookID)
+ }
+
+ return nil
+}
+
func (s SqlWebhookStore) GetIncoming(id string, allowFromCache bool) (*model.IncomingWebhook, error) {
var webhook model.IncomingWebhook
@@ -166,6 +180,7 @@ func (s SqlWebhookStore) GetIncomingListByUser(userId string, offset, limit int)
query := s.incomingWebhookSelectQuery.
Where(sq.Eq{"DeleteAt": 0}).
+ OrderBy("DisplayName", "Id").
Limit(uint64(limit)).
Offset(uint64(offset))
@@ -188,6 +203,7 @@ func (s SqlWebhookStore) GetIncomingByTeamByUser(teamId string, userId string, o
sq.Eq{"TeamId": teamId},
sq.Eq{"DeleteAt": 0},
}).
+ OrderBy("DisplayName", "Id").
Limit(uint64(limit)).
Offset(uint64(offset))
@@ -271,6 +287,7 @@ func (s SqlWebhookStore) GetOutgoingListByUser(userId string, offset, limit int)
Where(sq.And{
sq.Eq{"DeleteAt": 0},
}).
+ OrderBy("DisplayName", "Id").
Limit(uint64(limit)).
Offset(uint64(offset))
@@ -296,7 +313,8 @@ func (s SqlWebhookStore) GetOutgoingByChannelByUser(channelId string, userId str
Where(sq.And{
sq.Eq{"ChannelId": channelId},
sq.Eq{"DeleteAt": 0},
- })
+ }).
+ OrderBy("DisplayName", "Id")
if userId != "" {
query = query.Where(sq.Eq{"CreatorId": userId})
@@ -323,7 +341,8 @@ func (s SqlWebhookStore) GetOutgoingByTeamByUser(teamId string, userId string, o
Where(sq.And{
sq.Eq{"TeamId": teamId},
sq.Eq{"DeleteAt": 0},
- })
+ }).
+ OrderBy("DisplayName", "Id")
if userId != "" {
query = query.Where(sq.Eq{"CreatorId": userId})
diff --git a/server/channels/store/store.go b/server/channels/store/store.go
index 33068beef67..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
@@ -102,6 +104,7 @@ type Store interface {
Recap() RecapStore
ReadReceipt() ReadReceiptStore
TemporaryPost() TemporaryPostStore
+ ChannelJoinRequest() ChannelJoinRequestStore
}
type RetentionPolicyStore interface {
@@ -472,6 +475,7 @@ type UserStore interface {
GetByEmail(email string) (*model.User, error)
GetByRemoteID(remoteID string) (*model.User, error)
GetByAuth(authData *string, authService string) (*model.User, error)
+ GetByAuthData(authData *string) (*model.User, error)
GetAllUsingAuthService(authService string) ([]*model.User, error)
GetAllNotInAuthService(authServices []string) ([]*model.User, error)
GetByUsername(username string) (*model.User, error)
@@ -480,6 +484,8 @@ type UserStore interface {
GetEtagForAllProfiles() string
GetEtagForProfiles(teamID string) string
UpdateFailedPasswordAttempts(userID string, attempts int) error
+ TryIncrementFailedPasswordAttempts(userID string, maxAttempts int) (bool, error)
+ DecrementFailedPasswordAttempts(userID string) error
GetSystemAdminProfiles() (map[string]*model.User, error)
PermanentDelete(rctx request.CTX, userID string) error
AnalyticsActiveCount(timestamp int64, options model.UserCountOptions) (int64, error)
@@ -651,6 +657,7 @@ type WebhookStore interface {
GetIncomingByTeam(teamID string, offset, limit int) ([]*model.IncomingWebhook, error)
GetIncomingByTeamByUser(teamID string, userID string, offset, limit int) ([]*model.IncomingWebhook, error)
UpdateIncoming(webhook *model.IncomingWebhook) (*model.IncomingWebhook, error)
+ UpdateIncomingLastUsed(webhookID string, lastUsed int64) error
GetIncomingByChannel(channelID string) ([]*model.IncomingWebhook, error)
DeleteIncoming(webhookID string, timestamp int64) error
PermanentDeleteIncomingByChannel(channelID string) error
@@ -837,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
@@ -1070,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)
@@ -1149,6 +1173,7 @@ type PropertyFieldStore interface {
GetMany(ctx context.Context, groupID string, ids []string) ([]*model.PropertyField, error)
GetFieldByName(ctx context.Context, groupID, targetID, name string) (*model.PropertyField, error)
CountForGroup(groupID string, includeDeleted bool) (int64, error)
+ CountForGroupObjectType(groupID, objectType string, includeDeleted bool) (int64, error)
CountForTarget(groupID, targetType, targetID string, includeDeleted bool) (int64, error)
CountLinkedFields(fieldID string) (int64, error)
SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error)
@@ -1178,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 {
@@ -1331,6 +1370,19 @@ type ThreadMembershipImportData struct {
UnreadMentions int64
}
+// ChannelJoinRequestStore persists user requests to join discoverable private
+// channels. Rows are never deleted; status transitions are recorded with
+// reviewer and timestamps so the table doubles as an audit trail.
+type ChannelJoinRequestStore interface {
+ Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error)
+ Get(id string) (*model.ChannelJoinRequest, error)
+ GetPendingForChannelAndUser(channelId, userId string) (*model.ChannelJoinRequest, error)
+ GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error)
+ GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error)
+ Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error)
+ CountPending(channelId string) (int64, error)
+}
+
type RecapStore interface {
SaveRecap(recap *model.Recap) (*model.Recap, error)
UpdateRecap(recap *model.Recap) (*model.Recap, error)
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/attributes_store.go b/server/channels/store/storetest/attributes_store.go
index 7f24b1981b9..b455e89dff2 100644
--- a/server/channels/store/storetest/attributes_store.go
+++ b/server/channels/store/storetest/attributes_store.go
@@ -99,15 +99,19 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
groupID := group.ID
fieldA, err := ss.PropertyField().Create(&model.PropertyField{
- GroupID: groupID,
- Name: testPropertyA,
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: testPropertyA,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
fieldB, err := ss.PropertyField().Create(&model.PropertyField{
- GroupID: groupID,
- Name: testPropertyB,
- Type: model.PropertyFieldTypeText,
+ GroupID: groupID,
+ Name: testPropertyB,
+ Type: model.PropertyFieldTypeText,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
attrs := map[string]any{
@@ -117,10 +121,12 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
},
}
fieldC, err := ss.PropertyField().Create(&model.PropertyField{
- GroupID: groupID,
- Name: "test_property_c",
- Type: model.PropertyFieldTypeSelect,
- Attrs: attrs,
+ GroupID: groupID,
+ Name: "test_property_c",
+ Type: model.PropertyFieldTypeSelect,
+ Attrs: attrs,
+ ObjectType: model.PropertyFieldObjectTypeUser,
+ TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, 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/channel_join_request_store.go b/server/channels/store/storetest/channel_join_request_store.go
new file mode 100644
index 00000000000..de716535742
--- /dev/null
+++ b/server/channels/store/storetest/channel_join_request_store.go
@@ -0,0 +1,264 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package storetest
+
+import (
+ "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 TestChannelJoinRequestStore(t *testing.T, _ request.CTX, ss store.Store) {
+ t.Run("Save inserts a pending row", testChannelJoinRequestSave(ss))
+ t.Run("Save rejects duplicate pending row", testChannelJoinRequestSaveDuplicatePending(ss))
+ t.Run("Save allows another pending row after withdrawal", testChannelJoinRequestSaveAfterWithdraw(ss))
+ t.Run("Get returns NotFound for unknown id", testChannelJoinRequestGetNotFound(ss))
+ t.Run("GetPendingForChannelAndUser only returns pending rows", testChannelJoinRequestGetPending(ss))
+ t.Run("GetForChannel paginates and filters by status", testChannelJoinRequestGetForChannel(ss))
+ t.Run("GetForUser paginates and filters by status", testChannelJoinRequestGetForUser(ss))
+ t.Run("Update transitions status and stores reviewer", testChannelJoinRequestUpdate(ss))
+ t.Run("CountPending only counts pending rows", testChannelJoinRequestCountPending(ss))
+}
+
+func newPendingRequest(channelId, userId string) *model.ChannelJoinRequest {
+ return &model.ChannelJoinRequest{
+ ChannelId: channelId,
+ UserId: userId,
+ Message: "please let me in",
+ Status: model.ChannelJoinRequestStatusPending,
+ }
+}
+
+func testChannelJoinRequestSave(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+ userId := model.NewId()
+
+ req, err := ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.NoError(t, err)
+ require.NotEmpty(t, req.Id)
+ assert.Equal(t, channelId, req.ChannelId)
+ assert.Equal(t, userId, req.UserId)
+ assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
+ assert.NotZero(t, req.CreateAt)
+ assert.Equal(t, req.CreateAt, req.UpdateAt)
+
+ fetched, err := ss.ChannelJoinRequest().Get(req.Id)
+ require.NoError(t, err)
+ assert.Equal(t, req.Id, fetched.Id)
+ assert.Equal(t, req.Message, fetched.Message)
+ assert.Equal(t, req.Status, fetched.Status)
+ }
+}
+
+func testChannelJoinRequestSaveDuplicatePending(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+ userId := model.NewId()
+
+ _, err := ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.NoError(t, err)
+
+ _, err = ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.Error(t, err)
+ var conflict *store.ErrConflict
+ assert.ErrorAs(t, err, &conflict, "duplicate pending row must surface store.ErrConflict")
+ }
+}
+
+func testChannelJoinRequestSaveAfterWithdraw(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+ userId := model.NewId()
+
+ first, err := ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.NoError(t, err)
+
+ first.Status = model.ChannelJoinRequestStatusWithdrawn
+ _, err = ss.ChannelJoinRequest().Update(first)
+ require.NoError(t, err)
+
+ // Allow the millisecond-resolution UpdateAt to advance.
+ time.Sleep(2 * time.Millisecond)
+
+ second, err := ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.NoError(t, err, "a new pending row must be insertable once the previous one is no longer pending")
+ assert.NotEqual(t, first.Id, second.Id)
+ }
+}
+
+func testChannelJoinRequestGetNotFound(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ _, err := ss.ChannelJoinRequest().Get(model.NewId())
+ require.Error(t, err)
+ var nf *store.ErrNotFound
+ assert.ErrorAs(t, err, &nf)
+ }
+}
+
+func testChannelJoinRequestGetPending(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+ userId := model.NewId()
+
+ _, err := ss.ChannelJoinRequest().GetPendingForChannelAndUser(channelId, userId)
+ require.Error(t, err, "must return NotFound when no row exists")
+
+ _, err = ss.ChannelJoinRequest().Save(newPendingRequest(channelId, userId))
+ require.NoError(t, err)
+
+ got, err := ss.ChannelJoinRequest().GetPendingForChannelAndUser(channelId, userId)
+ require.NoError(t, err)
+ assert.Equal(t, channelId, got.ChannelId)
+ assert.Equal(t, userId, got.UserId)
+ assert.Equal(t, model.ChannelJoinRequestStatusPending, got.Status)
+
+ got.Status = model.ChannelJoinRequestStatusWithdrawn
+ _, err = ss.ChannelJoinRequest().Update(got)
+ require.NoError(t, err)
+
+ _, err = ss.ChannelJoinRequest().GetPendingForChannelAndUser(channelId, userId)
+ require.Error(t, err, "withdrawn row must not be considered pending")
+ }
+}
+
+func testChannelJoinRequestGetForChannel(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+
+ // Three pending requests across distinct users + one denied row for the
+ // same channel so we can prove the status filter actually filters.
+ for range 3 {
+ _, err := ss.ChannelJoinRequest().Save(newPendingRequest(channelId, model.NewId()))
+ require.NoError(t, err)
+ time.Sleep(2 * time.Millisecond)
+ }
+
+ denied := newPendingRequest(channelId, model.NewId())
+ saved, err := ss.ChannelJoinRequest().Save(denied)
+ require.NoError(t, err)
+ saved.Status = model.ChannelJoinRequestStatusDenied
+ saved.ReviewedBy = model.NewId()
+ saved.ReviewedAt = model.GetMillis()
+ saved.DenialReason = "policy mismatch"
+ _, err = ss.ChannelJoinRequest().Update(saved)
+ require.NoError(t, err)
+
+ rows, total, err := ss.ChannelJoinRequest().GetForChannel(channelId, model.GetChannelJoinRequestsOpts{PerPage: 10})
+ require.NoError(t, err)
+ assert.Len(t, rows, 3)
+ assert.Equal(t, int64(3), total)
+ for i := 1; i < len(rows); i++ {
+ assert.GreaterOrEqual(t, rows[i-1].CreateAt, rows[i].CreateAt, "list should be newest first")
+ }
+
+ rows, total, err = ss.ChannelJoinRequest().GetForChannel(channelId, model.GetChannelJoinRequestsOpts{Status: model.ChannelJoinRequestStatusDenied, PerPage: 10})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), total)
+ require.Len(t, rows, 1)
+ assert.Equal(t, "policy mismatch", rows[0].DenialReason)
+
+ rows, total, err = ss.ChannelJoinRequest().GetForChannel(channelId, model.GetChannelJoinRequestsOpts{PerPage: 2, Page: 0})
+ require.NoError(t, err)
+ assert.Len(t, rows, 2)
+ assert.Equal(t, int64(3), total, "TotalCount must not be truncated by paging")
+ }
+}
+
+func testChannelJoinRequestGetForUser(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ userId := model.NewId()
+
+ for range 2 {
+ _, err := ss.ChannelJoinRequest().Save(newPendingRequest(model.NewId(), userId))
+ require.NoError(t, err)
+ time.Sleep(2 * time.Millisecond)
+ }
+
+ denied := newPendingRequest(model.NewId(), userId)
+ saved, err := ss.ChannelJoinRequest().Save(denied)
+ require.NoError(t, err)
+ saved.Status = model.ChannelJoinRequestStatusDenied
+ saved.ReviewedBy = model.NewId()
+ saved.ReviewedAt = model.GetMillis()
+ _, err = ss.ChannelJoinRequest().Update(saved)
+ require.NoError(t, err)
+
+ rows, total, err := ss.ChannelJoinRequest().GetForUser(userId, model.GetChannelJoinRequestsOpts{PerPage: 10})
+ require.NoError(t, err)
+ assert.Len(t, rows, 2)
+ assert.Equal(t, int64(2), total)
+
+ rows, total, err = ss.ChannelJoinRequest().GetForUser(userId, model.GetChannelJoinRequestsOpts{Status: model.ChannelJoinRequestStatusDenied, PerPage: 10})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), total)
+ assert.Len(t, rows, 1)
+ }
+}
+
+func testChannelJoinRequestUpdate(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ req, err := ss.ChannelJoinRequest().Save(newPendingRequest(model.NewId(), model.NewId()))
+ require.NoError(t, err)
+ originalUpdateAt := req.UpdateAt
+
+ reviewerId := model.NewId()
+ reviewedAt := model.GetMillis() + 1
+ req.Status = model.ChannelJoinRequestStatusApproved
+ req.ReviewedBy = reviewerId
+ req.ReviewedAt = reviewedAt
+
+ // Allow UpdateAt to advance.
+ time.Sleep(2 * time.Millisecond)
+ updated, err := ss.ChannelJoinRequest().Update(req)
+ require.NoError(t, err)
+ assert.Equal(t, model.ChannelJoinRequestStatusApproved, updated.Status)
+ assert.Equal(t, reviewerId, updated.ReviewedBy)
+ assert.Equal(t, reviewedAt, updated.ReviewedAt)
+ assert.Greater(t, updated.UpdateAt, originalUpdateAt)
+
+ fetched, err := ss.ChannelJoinRequest().Get(req.Id)
+ require.NoError(t, err)
+ assert.Equal(t, model.ChannelJoinRequestStatusApproved, fetched.Status)
+ assert.Equal(t, reviewerId, fetched.ReviewedBy)
+ }
+}
+
+func testChannelJoinRequestCountPending(ss store.Store) func(*testing.T) {
+ return func(t *testing.T) {
+ channelId := model.NewId()
+
+ count, err := ss.ChannelJoinRequest().CountPending(channelId)
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), count)
+
+ for range 4 {
+ _, err = ss.ChannelJoinRequest().Save(newPendingRequest(channelId, model.NewId()))
+ require.NoError(t, err)
+ }
+
+ count, err = ss.ChannelJoinRequest().CountPending(channelId)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), count)
+
+ // Withdraw one — count should drop by 1.
+ reqs, _, err := ss.ChannelJoinRequest().GetForChannel(channelId, model.GetChannelJoinRequestsOpts{PerPage: 10})
+ require.NoError(t, err)
+ require.NotEmpty(t, reqs)
+ first := reqs[0]
+ first.Status = model.ChannelJoinRequestStatusWithdrawn
+ _, err = ss.ChannelJoinRequest().Update(first)
+ require.NoError(t, err)
+
+ count, err = ss.ChannelJoinRequest().CountPending(channelId)
+ require.NoError(t, err)
+ assert.Equal(t, int64(3), count)
+ }
+}
diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go
index 6456504685f..a1c6e25bfe3 100644
--- a/server/channels/store/storetest/channel_store.go
+++ b/server/channels/store/storetest/channel_store.go
@@ -14,7 +14,6 @@ import (
"testing"
"time"
- "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -37,9 +36,6 @@ type SqlXExecutor interface {
NamedExec(query string, arg any) (sql.Result, error)
Exec(query string, args ...any) (sql.Result, error)
ExecRaw(query string, args ...any) (sql.Result, error)
- NamedQuery(query string, arg any) (*sqlx.Rows, error)
- QueryRowX(query string, args ...any) *sqlx.Row
- QueryX(query string, args ...any) (*sqlx.Rows, error)
Select(dest any, query string, args ...any) error
}
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/ChannelJoinRequestStore.go b/server/channels/store/storetest/mocks/ChannelJoinRequestStore.go
new file mode 100644
index 00000000000..23d71a295a3
--- /dev/null
+++ b/server/channels/store/storetest/mocks/ChannelJoinRequestStore.go
@@ -0,0 +1,251 @@
+// Code generated by mockery v2.53.4. DO NOT EDIT.
+
+// Regenerate this file using `make store-mocks`.
+
+package mocks
+
+import (
+ model "github.com/mattermost/mattermost/server/public/model"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// ChannelJoinRequestStore is an autogenerated mock type for the ChannelJoinRequestStore type
+type ChannelJoinRequestStore struct {
+ mock.Mock
+}
+
+// CountPending provides a mock function with given fields: channelId
+func (_m *ChannelJoinRequestStore) CountPending(channelId string) (int64, error) {
+ ret := _m.Called(channelId)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CountPending")
+ }
+
+ var r0 int64
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (int64, error)); ok {
+ return rf(channelId)
+ }
+ if rf, ok := ret.Get(0).(func(string) int64); ok {
+ r0 = rf(channelId)
+ } else {
+ r0 = ret.Get(0).(int64)
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(channelId)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Get provides a mock function with given fields: id
+func (_m *ChannelJoinRequestStore) Get(id string) (*model.ChannelJoinRequest, error) {
+ ret := _m.Called(id)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Get")
+ }
+
+ var r0 *model.ChannelJoinRequest
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (*model.ChannelJoinRequest, error)); ok {
+ return rf(id)
+ }
+ if rf, ok := ret.Get(0).(func(string) *model.ChannelJoinRequest); ok {
+ r0 = rf(id)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(id)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetForChannel provides a mock function with given fields: channelId, opts
+func (_m *ChannelJoinRequestStore) GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ ret := _m.Called(channelId, opts)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetForChannel")
+ }
+
+ var r0 []*model.ChannelJoinRequest
+ var r1 int64
+ var r2 error
+ if rf, ok := ret.Get(0).(func(string, model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error)); ok {
+ return rf(channelId, opts)
+ }
+ if rf, ok := ret.Get(0).(func(string, model.GetChannelJoinRequestsOpts) []*model.ChannelJoinRequest); ok {
+ r0 = rf(channelId, opts)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string, model.GetChannelJoinRequestsOpts) int64); ok {
+ r1 = rf(channelId, opts)
+ } else {
+ r1 = ret.Get(1).(int64)
+ }
+
+ if rf, ok := ret.Get(2).(func(string, model.GetChannelJoinRequestsOpts) error); ok {
+ r2 = rf(channelId, opts)
+ } else {
+ r2 = ret.Error(2)
+ }
+
+ return r0, r1, r2
+}
+
+// GetForUser provides a mock function with given fields: userId, opts
+func (_m *ChannelJoinRequestStore) GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ ret := _m.Called(userId, opts)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetForUser")
+ }
+
+ var r0 []*model.ChannelJoinRequest
+ var r1 int64
+ var r2 error
+ if rf, ok := ret.Get(0).(func(string, model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error)); ok {
+ return rf(userId, opts)
+ }
+ if rf, ok := ret.Get(0).(func(string, model.GetChannelJoinRequestsOpts) []*model.ChannelJoinRequest); ok {
+ r0 = rf(userId, opts)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string, model.GetChannelJoinRequestsOpts) int64); ok {
+ r1 = rf(userId, opts)
+ } else {
+ r1 = ret.Get(1).(int64)
+ }
+
+ if rf, ok := ret.Get(2).(func(string, model.GetChannelJoinRequestsOpts) error); ok {
+ r2 = rf(userId, opts)
+ } else {
+ r2 = ret.Error(2)
+ }
+
+ return r0, r1, r2
+}
+
+// GetPendingForChannelAndUser provides a mock function with given fields: channelId, userId
+func (_m *ChannelJoinRequestStore) GetPendingForChannelAndUser(channelId string, userId string) (*model.ChannelJoinRequest, error) {
+ ret := _m.Called(channelId, userId)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetPendingForChannelAndUser")
+ }
+
+ var r0 *model.ChannelJoinRequest
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string, string) (*model.ChannelJoinRequest, error)); ok {
+ return rf(channelId, userId)
+ }
+ if rf, ok := ret.Get(0).(func(string, string) *model.ChannelJoinRequest); ok {
+ r0 = rf(channelId, userId)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string, string) error); ok {
+ r1 = rf(channelId, userId)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Save provides a mock function with given fields: req
+func (_m *ChannelJoinRequestStore) Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ ret := _m.Called(req)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Save")
+ }
+
+ var r0 *model.ChannelJoinRequest
+ var r1 error
+ if rf, ok := ret.Get(0).(func(*model.ChannelJoinRequest) (*model.ChannelJoinRequest, error)); ok {
+ return rf(req)
+ }
+ if rf, ok := ret.Get(0).(func(*model.ChannelJoinRequest) *model.ChannelJoinRequest); ok {
+ r0 = rf(req)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(*model.ChannelJoinRequest) error); ok {
+ r1 = rf(req)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Update provides a mock function with given fields: req
+func (_m *ChannelJoinRequestStore) Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ ret := _m.Called(req)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Update")
+ }
+
+ var r0 *model.ChannelJoinRequest
+ var r1 error
+ if rf, ok := ret.Get(0).(func(*model.ChannelJoinRequest) (*model.ChannelJoinRequest, error)); ok {
+ return rf(req)
+ }
+ if rf, ok := ret.Get(0).(func(*model.ChannelJoinRequest) *model.ChannelJoinRequest); ok {
+ r0 = rf(req)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.ChannelJoinRequest)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(*model.ChannelJoinRequest) error); ok {
+ r1 = rf(req)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// NewChannelJoinRequestStore creates a new instance of ChannelJoinRequestStore. 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 NewChannelJoinRequestStore(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *ChannelJoinRequestStore {
+ mock := &ChannelJoinRequestStore{}
+ 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/PropertyFieldStore.go b/server/channels/store/storetest/mocks/PropertyFieldStore.go
index 996d401df3e..e8f8ca7568f 100644
--- a/server/channels/store/storetest/mocks/PropertyFieldStore.go
+++ b/server/channels/store/storetest/mocks/PropertyFieldStore.go
@@ -72,6 +72,34 @@ func (_m *PropertyFieldStore) CountForGroup(groupID string, includeDeleted bool)
return r0, r1
}
+// CountForGroupObjectType provides a mock function with given fields: groupID, objectType, includeDeleted
+func (_m *PropertyFieldStore) CountForGroupObjectType(groupID string, objectType string, includeDeleted bool) (int64, error) {
+ ret := _m.Called(groupID, objectType, includeDeleted)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CountForGroupObjectType")
+ }
+
+ var r0 int64
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string, string, bool) (int64, error)); ok {
+ return rf(groupID, objectType, includeDeleted)
+ }
+ if rf, ok := ret.Get(0).(func(string, string, bool) int64); ok {
+ r0 = rf(groupID, objectType, includeDeleted)
+ } else {
+ r0 = ret.Get(0).(int64)
+ }
+
+ if rf, ok := ret.Get(1).(func(string, string, bool) error); ok {
+ r1 = rf(groupID, objectType, includeDeleted)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// CountForTarget provides a mock function with given fields: groupID, targetType, targetID, includeDeleted
func (_m *PropertyFieldStore) CountForTarget(groupID string, targetType string, targetID string, includeDeleted bool) (int64, error) {
ret := _m.Called(groupID, targetType, targetID, includeDeleted)
diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go
index 30f21da0292..16ed34d912f 100644
--- a/server/channels/store/storetest/mocks/Store.go
+++ b/server/channels/store/storetest/mocks/Store.go
@@ -5,16 +5,14 @@
package mocks
import (
- mlog "github.com/mattermost/mattermost/server/public/shared/mlog"
- mock "github.com/stretchr/testify/mock"
+ context "context"
+ sql "database/sql"
+ time "time"
model "github.com/mattermost/mattermost/server/public/model"
-
- sql "database/sql"
-
+ mlog "github.com/mattermost/mattermost/server/public/shared/mlog"
store "github.com/mattermost/mattermost/server/v8/channels/store"
-
- time "time"
+ mock "github.com/stretchr/testify/mock"
)
// Store is an autogenerated mock type for the Store type
@@ -162,6 +160,46 @@ 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()
+
+ if len(ret) == 0 {
+ panic("no return value specified for ChannelJoinRequest")
+ }
+
+ var r0 store.ChannelJoinRequestStore
+ if rf, ok := ret.Get(0).(func() store.ChannelJoinRequestStore); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(store.ChannelJoinRequestStore)
+ }
+ }
+
+ return r0
+}
+
// ChannelMemberHistory provides a mock function with no fields
func (_m *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore {
ret := _m.Called()
@@ -576,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 0845e1e23ab..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)
@@ -655,6 +671,36 @@ func (_m *UserStore) GetByAuth(authData *string, authService string) (*model.Use
return r0, r1
}
+// GetByAuthData provides a mock function with given fields: authData
+func (_m *UserStore) GetByAuthData(authData *string) (*model.User, error) {
+ ret := _m.Called(authData)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetByAuthData")
+ }
+
+ var r0 *model.User
+ var r1 error
+ if rf, ok := ret.Get(0).(func(*string) (*model.User, error)); ok {
+ return rf(authData)
+ }
+ if rf, ok := ret.Get(0).(func(*string) *model.User); ok {
+ r0 = rf(authData)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.User)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(*string) error); ok {
+ r1 = rf(authData)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// GetByEmail provides a mock function with given fields: email
func (_m *UserStore) GetByEmail(email string) (*model.User, error) {
ret := _m.Called(email)
@@ -2050,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)
diff --git a/server/channels/store/storetest/mocks/WebhookStore.go b/server/channels/store/storetest/mocks/WebhookStore.go
index 77dd12fd254..16f563681f7 100644
--- a/server/channels/store/storetest/mocks/WebhookStore.go
+++ b/server/channels/store/storetest/mocks/WebhookStore.go
@@ -668,6 +668,24 @@ func (_m *WebhookStore) UpdateIncoming(webhook *model.IncomingWebhook) (*model.I
return r0, r1
}
+// UpdateIncomingLastUsed provides a mock function with given fields: webhookID, lastUsed
+func (_m *WebhookStore) UpdateIncomingLastUsed(webhookID string, lastUsed int64) error {
+ ret := _m.Called(webhookID, lastUsed)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UpdateIncomingLastUsed")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, int64) error); ok {
+ r0 = rf(webhookID, lastUsed)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// UpdateOutgoing provides a mock function with given fields: hook
func (_m *WebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, error) {
ret := _m.Called(hook)
diff --git a/server/channels/store/storetest/property_field_store.go b/server/channels/store/storetest/property_field_store.go
index 714f5fbfab3..f1fe1cb4368 100644
--- a/server/channels/store/storetest/property_field_store.go
+++ b/server/channels/store/storetest/property_field_store.go
@@ -5,7 +5,6 @@ package storetest
import (
"context"
- "database/sql"
"fmt"
"testing"
"time"
@@ -342,7 +341,8 @@ func testGetFieldByName(t *testing.T, _ request.CTX, ss store.Store) {
t.Run("should fail on nonexisting field", func(t *testing.T) {
field, err := ss.PropertyField().GetFieldByName(context.Background(), "", "", "nonexistent-field-name")
require.Zero(t, field)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
groupID := model.NewId()
@@ -373,13 +373,15 @@ func testGetFieldByName(t *testing.T, _ request.CTX, ss store.Store) {
t.Run("should not be able to retrieve an existing field when specifying a different group ID", func(t *testing.T) {
field, err := ss.PropertyField().GetFieldByName(context.Background(), model.NewId(), targetID, "unique-field-name")
require.Zero(t, field)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
t.Run("should not be able to retrieve an existing field when specifying a different target ID", func(t *testing.T) {
field, err := ss.PropertyField().GetFieldByName(context.Background(), groupID, model.NewId(), "unique-field-name")
require.Zero(t, field)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
// Test with multiple fields with the same name but different groups
@@ -470,7 +472,8 @@ func testGetFieldByName(t *testing.T, _ request.CTX, ss store.Store) {
// Verify it can't be retrieved after deletion
field, err = ss.PropertyField().GetFieldByName(context.Background(), groupID, targetID, "to-be-deleted-field")
require.Zero(t, field)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
t.Run("should not retrieve fields with matching name but different DeleteAt status", func(t *testing.T) {
diff --git a/server/channels/store/storetest/property_value_store.go b/server/channels/store/storetest/property_value_store.go
index a8378293c98..c0b86f852e5 100644
--- a/server/channels/store/storetest/property_value_store.go
+++ b/server/channels/store/storetest/property_value_store.go
@@ -4,7 +4,6 @@
package storetest
import (
- "database/sql"
"encoding/json"
"fmt"
"testing"
@@ -350,7 +349,8 @@ func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store, s SqlStor
t.Run("should fail on nonexisting value", func(t *testing.T) {
value, err := ss.PropertyValue().Get("", model.NewId())
require.Zero(t, value)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
groupID := model.NewId()
@@ -381,7 +381,8 @@ func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store, s SqlStor
t.Run("should not be able to retrieve an existing value when specifying a different group ID", func(t *testing.T) {
value, err := ss.PropertyValue().Get(model.NewId(), newValue.ID)
require.Zero(t, value)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
t.Run("should be able to retrieve an existing property value with matching groupID", func(t *testing.T) {
@@ -418,7 +419,8 @@ func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store, s SqlStor
// Try to get the value with a different group ID
value, err := ss.PropertyValue().Get(model.NewId(), newValue.ID)
require.Zero(t, value)
- require.ErrorIs(t, err, sql.ErrNoRows)
+ var enf *store.ErrNotFound
+ require.ErrorAs(t, err, &enf)
})
t.Run("null columns, before createdBy and updatedBy migrations", func(t *testing.T) {
diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go
index be8bf0f1f14..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
@@ -75,6 +77,7 @@ type Store struct {
ReadReceiptStore mocks.ReadReceiptStore
TemporaryPostStore mocks.TemporaryPostStore
ViewStore mocks.ViewStore
+ ChannelJoinRequestStore mocks.ChannelJoinRequestStore
}
func (s *Store) Logger() mlog.LoggerIFace { return s.logger }
@@ -119,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 }
@@ -180,6 +184,9 @@ func (s *Store) ReadReceipt() store.ReadReceiptStore {
func (s *Store) TemporaryPost() store.TemporaryPostStore {
return &s.TemporaryPostStore
}
+func (s *Store) ChannelJoinRequest() store.ChannelJoinRequestStore {
+ return &s.ChannelJoinRequestStore
+}
func (s *Store) View() store.ViewStore {
return &s.ViewStore
}
@@ -189,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,
@@ -230,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,
@@ -239,5 +251,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
&s.ReadReceiptStore,
&s.TemporaryPostStore,
&s.ViewStore,
+ &s.ChannelJoinRequestStore,
)
}
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/storetest/user_store.go b/server/channels/store/storetest/user_store.go
index ec303b8c6c5..7520267b39f 100644
--- a/server/channels/store/storetest/user_store.go
+++ b/server/channels/store/storetest/user_store.go
@@ -9,11 +9,13 @@ import (
"fmt"
"sort"
"strings"
+ "sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/sync/errgroup"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
@@ -54,6 +56,8 @@ func TestUserStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("Update", func(t *testing.T) { testUserStoreUpdate(t, rctx, ss) })
t.Run("UpdateUpdateAt", func(t *testing.T) { testUserStoreUpdateUpdateAt(t, rctx, ss) })
t.Run("UpdateFailedPasswordAttempts", func(t *testing.T) { testUserStoreUpdateFailedPasswordAttempts(t, rctx, ss) })
+ t.Run("TryIncrementFailedPasswordAttempts", func(t *testing.T) { testUserStoreTryIncrementFailedPasswordAttempts(t, rctx, ss) })
+ t.Run("DecrementFailedPasswordAttempts", func(t *testing.T) { testUserStoreDecrementFailedPasswordAttempts(t, rctx, ss) })
t.Run("Get", func(t *testing.T) { testUserStoreGet(t, rctx, ss) })
t.Run("GetAllUsingAuthService", func(t *testing.T) { testGetAllUsingAuthService(t, rctx, ss) })
t.Run("GetAllProfiles", func(t *testing.T) { testUserStoreGetAllProfiles(t, rctx, ss) })
@@ -69,6 +73,7 @@ func TestUserStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("GetProfilesByUsernames", func(t *testing.T) { testUserStoreGetProfilesByUsernames(t, rctx, ss) })
t.Run("GetSystemAdminProfiles", func(t *testing.T) { testUserStoreGetSystemAdminProfiles(t, rctx, ss) })
t.Run("GetByEmail", func(t *testing.T) { testUserStoreGetByEmail(t, rctx, ss) })
+ t.Run("GetByAuth", func(t *testing.T) { testUserStoreGetByAuth(t, rctx, ss) })
t.Run("GetByAuthData", func(t *testing.T) { testUserStoreGetByAuthData(t, rctx, ss) })
t.Run("GetByUsername", func(t *testing.T) { testUserStoreGetByUsername(t, rctx, ss) })
t.Run("GetForLogin", func(t *testing.T) { testUserStoreGetForLogin(t, rctx, ss) })
@@ -349,6 +354,145 @@ func testUserStoreUpdateFailedPasswordAttempts(t *testing.T, rctx request.CTX, s
require.Equal(t, 3, user.FailedAttempts, "FailedAttempts not updated correctly")
}
+func testUserStoreTryIncrementFailedPasswordAttempts(t *testing.T, rctx request.CTX, ss store.Store) {
+ u1 := &model.User{}
+ u1.Email = MakeEmail()
+ _, err := ss.User().Save(rctx, u1)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u1.Id)) }()
+ _, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: model.NewId(), UserId: u1.Id}, -1)
+ require.NoError(t, nErr)
+
+ const maxAttempts = 3
+
+ t.Run("claims a slot when below cap", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, 0))
+
+ claimed, err := ss.User().TryIncrementFailedPasswordAttempts(u1.Id, maxAttempts)
+ require.NoError(t, err)
+ require.True(t, claimed)
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, 1, user.FailedAttempts)
+ })
+
+ t.Run("does not claim a slot when at cap", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, maxAttempts))
+
+ claimed, err := ss.User().TryIncrementFailedPasswordAttempts(u1.Id, maxAttempts)
+ require.NoError(t, err)
+ require.False(t, claimed)
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, maxAttempts, user.FailedAttempts, "counter must not advance past the cap")
+ })
+
+ t.Run("does not claim a slot when above cap", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, maxAttempts+5))
+
+ claimed, err := ss.User().TryIncrementFailedPasswordAttempts(u1.Id, maxAttempts)
+ require.NoError(t, err)
+ require.False(t, claimed)
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, maxAttempts+5, user.FailedAttempts)
+ })
+
+ t.Run("does not claim a slot for unknown user", func(t *testing.T) {
+ claimed, err := ss.User().TryIncrementFailedPasswordAttempts(model.NewId(), maxAttempts)
+ require.NoError(t, err)
+ require.False(t, claimed)
+ })
+
+ t.Run("concurrent attempts cap at maxAttempts", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, 0))
+
+ const goroutines = 50
+ var g errgroup.Group
+ var claimed atomic.Int64
+ start := make(chan struct{})
+ for range goroutines {
+ g.Go(func() error {
+ <-start
+ ok, err := ss.User().TryIncrementFailedPasswordAttempts(u1.Id, maxAttempts)
+ if err != nil {
+ return err
+ }
+ if ok {
+ claimed.Add(1)
+ }
+ return nil
+ })
+ }
+ close(start)
+ require.NoError(t, g.Wait())
+
+ require.Equal(t, int64(maxAttempts), claimed.Load(), "exactly maxAttempts goroutines must have claimed a slot")
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, maxAttempts, user.FailedAttempts)
+ })
+}
+
+func testUserStoreDecrementFailedPasswordAttempts(t *testing.T, rctx request.CTX, ss store.Store) {
+ u1 := &model.User{}
+ u1.Email = MakeEmail()
+ _, err := ss.User().Save(rctx, u1)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u1.Id)) }()
+ _, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: model.NewId(), UserId: u1.Id}, -1)
+ require.NoError(t, nErr)
+
+ t.Run("decrements when above zero", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, 3))
+
+ require.NoError(t, ss.User().DecrementFailedPasswordAttempts(u1.Id))
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, 2, user.FailedAttempts)
+ })
+
+ t.Run("does not go below zero", func(t *testing.T) {
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, 0))
+
+ require.NoError(t, ss.User().DecrementFailedPasswordAttempts(u1.Id))
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, 0, user.FailedAttempts)
+ })
+
+ t.Run("no-op for unknown user", func(t *testing.T) {
+ require.NoError(t, ss.User().DecrementFailedPasswordAttempts(model.NewId()))
+ })
+
+ t.Run("concurrent decrements never go below zero", func(t *testing.T) {
+ const initial = 10
+ const goroutines = 50
+ require.NoError(t, ss.User().UpdateFailedPasswordAttempts(u1.Id, initial))
+
+ var g errgroup.Group
+ start := make(chan struct{})
+ for range goroutines {
+ g.Go(func() error {
+ <-start
+ return ss.User().DecrementFailedPasswordAttempts(u1.Id)
+ })
+ }
+ close(start)
+ require.NoError(t, g.Wait())
+
+ user, err := ss.User().Get(context.Background(), u1.Id)
+ require.NoError(t, err)
+ require.Equal(t, 0, user.FailedAttempts, "decrement must clamp at zero under contention")
+ })
+}
+
func testUserStoreGet(t *testing.T, rctx request.CTX, ss store.Store) {
u1 := &model.User{
Email: MakeEmail(),
@@ -2104,7 +2248,7 @@ func testUserStoreGetByEmail(t *testing.T, rctx request.CTX, ss store.Store) {
})
}
-func testUserStoreGetByAuthData(t *testing.T, rctx request.CTX, ss store.Store) {
+func testUserStoreGetByAuth(t *testing.T, rctx request.CTX, ss store.Store) {
teamID := model.NewId()
auth1 := model.NewId()
auth3 := model.NewId()
@@ -2167,21 +2311,128 @@ func testUserStoreGetByAuthData(t *testing.T, rctx request.CTX, ss store.Store)
require.True(t, errors.As(err, &nfErr))
})
- t.Run("get by unknown auth, u1 service", func(t *testing.T) {
- unknownAuth := ""
+ t.Run("get by unknown non-empty auth, u1 service", func(t *testing.T) {
+ unknownAuth := model.NewId()
_, err := ss.User().GetByAuth(&unknownAuth, u1.AuthService)
require.Error(t, err)
+ var nfErr *store.ErrNotFound
+ require.True(t, errors.As(err, &nfErr))
+ })
+
+ t.Run("get by empty auth, u1 service", func(t *testing.T) {
+ emptyAuth := ""
+ _, err := ss.User().GetByAuth(&emptyAuth, u1.AuthService)
+ require.Error(t, err)
var invErr *store.ErrInvalidInput
require.True(t, errors.As(err, &invErr))
})
- t.Run("get by unknown auth, unknown service", func(t *testing.T) {
- unknownAuth := ""
- _, err := ss.User().GetByAuth(&unknownAuth, "unknown")
+ t.Run("get by nil auth, u1 service", func(t *testing.T) {
+ _, err := ss.User().GetByAuth(nil, u1.AuthService)
require.Error(t, err)
var invErr *store.ErrInvalidInput
require.True(t, errors.As(err, &invErr))
})
+
+ t.Run("get by unknown non-empty auth, unknown service", func(t *testing.T) {
+ unknownAuth := model.NewId()
+ _, err := ss.User().GetByAuth(&unknownAuth, "unknown")
+ require.Error(t, err)
+ var nfErr *store.ErrNotFound
+ require.True(t, errors.As(err, &nfErr))
+ })
+
+ t.Run("get by empty auth, unknown service", func(t *testing.T) {
+ emptyAuth := ""
+ _, err := ss.User().GetByAuth(&emptyAuth, "unknown")
+ require.Error(t, err)
+ var invErr *store.ErrInvalidInput
+ require.True(t, errors.As(err, &invErr))
+ })
+}
+
+func testUserStoreGetByAuthData(t *testing.T, rctx request.CTX, ss store.Store) {
+ teamID := model.NewId()
+ auth1 := model.NewId()
+ auth2 := model.NewId()
+
+ u1, err := ss.User().Save(rctx, &model.User{
+ Email: MakeEmail(),
+ Username: "u1" + model.NewId(),
+ AuthData: &auth1,
+ AuthService: "service",
+ })
+ require.NoError(t, err)
+ defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u1.Id)) }()
+ _, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: teamID, UserId: u1.Id}, -1)
+ require.NoError(t, nErr)
+
+ u2, err := ss.User().Save(rctx, &model.User{
+ Email: MakeEmail(),
+ Username: "u2" + model.NewId(),
+ AuthData: &auth2,
+ AuthService: "service2",
+ })
+ require.NoError(t, err)
+ defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u2.Id)) }()
+ _, nErr = ss.Team().SaveMember(rctx, &model.TeamMember{TeamId: teamID, UserId: u2.Id}, -1)
+ require.NoError(t, nErr)
+
+ t.Run("returns full user when auth data matches", func(t *testing.T) {
+ u, err := ss.User().GetByAuthData(u1.AuthData)
+ require.NoError(t, err)
+ assert.Equal(t, u1, u)
+ })
+
+ t.Run("matches regardless of auth service", func(t *testing.T) {
+ u, err := ss.User().GetByAuthData(u2.AuthData)
+ require.NoError(t, err)
+ assert.Equal(t, u2.Id, u.Id)
+ assert.Equal(t, "service2", u.AuthService)
+ })
+
+ t.Run("returns ErrNotFound for unknown auth data", func(t *testing.T) {
+ unknownAuth := model.NewId()
+ _, err := ss.User().GetByAuthData(&unknownAuth)
+ require.Error(t, err)
+ var nfErr *store.ErrNotFound
+ require.True(t, errors.As(err, &nfErr))
+ })
+
+ t.Run("returns ErrInvalidInput for nil auth data", func(t *testing.T) {
+ _, err := ss.User().GetByAuthData(nil)
+ require.Error(t, err)
+ var invErr *store.ErrInvalidInput
+ require.True(t, errors.As(err, &invErr))
+ })
+
+ t.Run("returns ErrInvalidInput for empty auth data", func(t *testing.T) {
+ emptyAuth := ""
+ _, err := ss.User().GetByAuthData(&emptyAuth)
+ require.Error(t, err)
+ var invErr *store.ErrInvalidInput
+ require.True(t, errors.As(err, &invErr))
+ })
+
+ t.Run("matches when auth data is an email-shaped value", func(t *testing.T) {
+ // ResetAuthDataToEmailForUsers sets AuthData = Email for whole batches of
+ // users, so email-shaped auth_data values are common in practice.
+ emailAuth := "u3-" + model.NewId() + "@example.com"
+ u3, err := ss.User().Save(rctx, &model.User{
+ Email: MakeEmail(),
+ Username: "u3" + model.NewId(),
+ AuthData: &emailAuth,
+ AuthService: "service",
+ })
+ require.NoError(t, err)
+ defer func() { require.NoError(t, ss.User().PermanentDelete(rctx, u3.Id)) }()
+
+ u, err := ss.User().GetByAuthData(&emailAuth)
+ require.NoError(t, err)
+ assert.Equal(t, u3.Id, u.Id)
+ require.NotNil(t, u.AuthData)
+ assert.Equal(t, emailAuth, *u.AuthData)
+ })
}
func testUserStoreGetByUsername(t *testing.T, rctx request.CTX, ss store.Store) {
diff --git a/server/channels/store/storetest/webhook_store.go b/server/channels/store/storetest/webhook_store.go
index 6de9ddbb1a8..a026cbf1d04 100644
--- a/server/channels/store/storetest/webhook_store.go
+++ b/server/channels/store/storetest/webhook_store.go
@@ -18,6 +18,8 @@ import (
func TestWebhookStore(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("SaveIncoming", func(t *testing.T) { testWebhookStoreSaveIncoming(t, rctx, ss) })
t.Run("UpdateIncoming", func(t *testing.T) { testWebhookStoreUpdateIncoming(t, rctx, ss) })
+ t.Run("UpdateIncomingPreservesLastUsed", func(t *testing.T) { testWebhookStoreUpdateIncomingPreservesLastUsed(t, rctx, ss) })
+ t.Run("UpdateIncomingLastUsed", func(t *testing.T) { testWebhookStoreUpdateIncomingLastUsed(t, rctx, ss) })
t.Run("GetIncoming", func(t *testing.T) { testWebhookStoreGetIncoming(t, rctx, ss) })
t.Run("GetIncomingList", func(t *testing.T) { testWebhookStoreGetIncomingList(t, rctx, ss) })
t.Run("GetIncomingListByUser", func(t *testing.T) { testWebhookStoreGetIncomingListByUser(t, rctx, ss) })
@@ -73,6 +75,45 @@ func testWebhookStoreUpdateIncoming(t *testing.T, rctx request.CTX, ss store.Sto
require.Equal(t, "TestHook", webhook.DisplayName, "display name is not updated")
}
+// testWebhookStoreUpdateIncomingPreservesLastUsed ensures the generic UpdateIncoming path does not
+// overwrite LastUsed; only UpdateIncomingLastUsed should change that column.
+func testWebhookStoreUpdateIncomingPreservesLastUsed(t *testing.T, rctx request.CTX, ss store.Store) {
+ o1 := buildIncomingWebhook()
+ saved, err := ss.Webhook().SaveIncoming(o1)
+ require.NoError(t, err)
+ require.Zero(t, saved.LastUsed, "new webhook should have LastUsed 0")
+
+ lastUsed := model.GetMillis()
+ err = ss.Webhook().UpdateIncomingLastUsed(saved.Id, lastUsed)
+ require.NoError(t, err)
+
+ withStaleLastUsed := *saved
+ withStaleLastUsed.DisplayName = "RenamedHook"
+ withStaleLastUsed.LastUsed = 0
+
+ _, err = ss.Webhook().UpdateIncoming(&withStaleLastUsed)
+ require.NoError(t, err)
+
+ fromDB, err := ss.Webhook().GetIncoming(saved.Id, false)
+ require.NoError(t, err)
+ require.Equal(t, lastUsed, fromDB.LastUsed, "UpdateIncoming must not clear LastUsed when struct has LastUsed 0")
+ require.Equal(t, "RenamedHook", fromDB.DisplayName)
+}
+
+func testWebhookStoreUpdateIncomingLastUsed(t *testing.T, rctx request.CTX, ss store.Store) {
+ o1 := buildIncomingWebhook()
+ o1, err := ss.Webhook().SaveIncoming(o1)
+ require.NoError(t, err)
+
+ lastUsed := model.GetMillis()
+ err = ss.Webhook().UpdateIncomingLastUsed(o1.Id, lastUsed)
+ require.NoError(t, err)
+
+ updated, err := ss.Webhook().GetIncoming(o1.Id, false)
+ require.NoError(t, err)
+ require.Equal(t, lastUsed, updated.LastUsed)
+}
+
func testWebhookStoreGetIncoming(t *testing.T, rctx request.CTX, ss store.Store) {
var err error
@@ -132,8 +173,8 @@ func testWebhookStoreGetIncomingListByUser(t *testing.T, rctx request.CTX, ss st
o1.UserId = model.NewId()
o1.TeamId = model.NewId()
- o1, err := ss.Webhook().SaveIncoming(o1)
- require.NoError(t, err)
+ o1, errSave := ss.Webhook().SaveIncoming(o1)
+ require.NoError(t, errSave)
t.Run("GetIncomingListByUser, known user filtered", func(t *testing.T) {
hooks, err := ss.Webhook().GetIncomingListByUser(o1.UserId, 0, 100)
@@ -147,6 +188,27 @@ func testWebhookStoreGetIncomingListByUser(t *testing.T, rctx request.CTX, ss st
require.NoError(t, err)
require.Equal(t, 0, len(hooks))
})
+
+ t.Run("GetIncomingListByUser, ordered alphabetically by display name", func(t *testing.T) {
+ userId := model.NewId()
+ hookC := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: model.NewId(), DisplayName: "Charlie"}
+ hookA := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: model.NewId(), DisplayName: "Alpha"}
+ hookB := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: model.NewId(), DisplayName: "Bravo"}
+
+ hookC, err := ss.Webhook().SaveIncoming(hookC)
+ require.NoError(t, err)
+ hookA, err = ss.Webhook().SaveIncoming(hookA)
+ require.NoError(t, err)
+ hookB, err = ss.Webhook().SaveIncoming(hookB)
+ require.NoError(t, err)
+
+ hooks, err := ss.Webhook().GetIncomingListByUser(userId, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, hooks, 3)
+ require.Equal(t, hookA.Id, hooks[0].Id, "first result should be Alpha (alphabetical order)")
+ require.Equal(t, hookB.Id, hooks[1].Id, "second result should be Bravo (alphabetical order)")
+ require.Equal(t, hookC.Id, hooks[2].Id, "third result should be Charlie (alphabetical order)")
+ })
}
func testWebhookStoreGetIncomingByTeam(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -166,16 +228,14 @@ func testWebhookStoreGetIncomingByTeam(t *testing.T, rctx request.CTX, ss store.
}
func TestWebhookStoreGetIncomingByTeamByUser(t *testing.T, rctx request.CTX, ss store.Store) {
- var err error
-
o1 := buildIncomingWebhook()
- o1, err = ss.Webhook().SaveIncoming(o1)
- require.NoError(t, err)
+ o1, errSave := ss.Webhook().SaveIncoming(o1)
+ require.NoError(t, errSave)
o2 := buildIncomingWebhook()
o2.TeamId = o1.TeamId //Set both to the same team
- o2, err = ss.Webhook().SaveIncoming(o2)
- require.NoError(t, err)
+ o2, errSave = ss.Webhook().SaveIncoming(o2)
+ require.NoError(t, errSave)
t.Run("GetIncomingByTeamByUser, no user filter", func(t *testing.T) {
hooks, err := ss.Webhook().GetIncomingByTeam(o1.TeamId, 0, 100)
@@ -195,6 +255,28 @@ func TestWebhookStoreGetIncomingByTeamByUser(t *testing.T, rctx request.CTX, ss
require.NoError(t, err)
require.Equal(t, len(hooks), 0)
})
+
+ t.Run("GetIncomingByTeamByUser, ordered alphabetically by display name", func(t *testing.T) {
+ teamId := model.NewId()
+ userId := model.NewId()
+ hookC := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: teamId, DisplayName: "Charlie"}
+ hookA := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: teamId, DisplayName: "Alpha"}
+ hookB := &model.IncomingWebhook{ChannelId: model.NewId(), UserId: userId, TeamId: teamId, DisplayName: "Bravo"}
+
+ hookC, err := ss.Webhook().SaveIncoming(hookC)
+ require.NoError(t, err)
+ hookA, err = ss.Webhook().SaveIncoming(hookA)
+ require.NoError(t, err)
+ hookB, err = ss.Webhook().SaveIncoming(hookB)
+ require.NoError(t, err)
+
+ hooks, err := ss.Webhook().GetIncomingByTeamByUser(teamId, userId, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, hooks, 3)
+ require.Equal(t, hookA.Id, hooks[0].Id, "first result should be Alpha (alphabetical order)")
+ require.Equal(t, hookB.Id, hooks[1].Id, "second result should be Bravo (alphabetical order)")
+ require.Equal(t, hookC.Id, hooks[2].Id, "third result should be Charlie (alphabetical order)")
+ })
}
func testWebhookStoreGetIncomingByChannel(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -332,6 +414,27 @@ func testWebhookStoreGetOutgoingListByUser(t *testing.T, rctx request.CTX, ss st
require.NoError(t, err)
require.Equal(t, 0, len(hooks))
})
+
+ t.Run("GetOutgoingListByUser, ordered alphabetically by display name", func(t *testing.T) {
+ creatorId := model.NewId()
+ hookC := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Charlie"}
+ hookA := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Alpha"}
+ hookB := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Bravo"}
+
+ hookC, err := ss.Webhook().SaveOutgoing(hookC)
+ require.NoError(t, err)
+ hookA, err = ss.Webhook().SaveOutgoing(hookA)
+ require.NoError(t, err)
+ hookB, err = ss.Webhook().SaveOutgoing(hookB)
+ require.NoError(t, err)
+
+ hooks, err := ss.Webhook().GetOutgoingListByUser(creatorId, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, hooks, 3)
+ require.Equal(t, hookA.Id, hooks[0].Id, "first result should be Alpha (alphabetical order)")
+ require.Equal(t, hookB.Id, hooks[1].Id, "second result should be Bravo (alphabetical order)")
+ require.Equal(t, hookC.Id, hooks[2].Id, "third result should be Charlie (alphabetical order)")
+ })
}
func testWebhookStoreGetOutgoingList(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -400,8 +503,8 @@ func testWebhookStoreGetOutgoingByChannelByUser(t *testing.T, rctx request.CTX,
o1.TeamId = model.NewId()
o1.CallbackURLs = []string{"http://nowhere.com/"}
- o1, err := ss.Webhook().SaveOutgoing(o1)
- require.NoError(t, err)
+ o1, errSave := ss.Webhook().SaveOutgoing(o1)
+ require.NoError(t, errSave)
o2 := &model.OutgoingWebhook{}
o2.ChannelId = o1.ChannelId
@@ -409,8 +512,8 @@ func testWebhookStoreGetOutgoingByChannelByUser(t *testing.T, rctx request.CTX,
o2.TeamId = model.NewId()
o2.CallbackURLs = []string{"http://nowhere.com/"}
- _, err = ss.Webhook().SaveOutgoing(o2)
- require.NoError(t, err)
+ _, errSave = ss.Webhook().SaveOutgoing(o2)
+ require.NoError(t, errSave)
t.Run("GetOutgoingByChannelByUser, no user filter", func(t *testing.T) {
hooks, err := ss.Webhook().GetOutgoingByChannel(o1.ChannelId, 0, 100)
@@ -430,6 +533,27 @@ func testWebhookStoreGetOutgoingByChannelByUser(t *testing.T, rctx request.CTX,
require.NoError(t, err)
require.Equal(t, 0, len(hooks))
})
+
+ t.Run("GetOutgoingByChannelByUser, ordered alphabetically by display name", func(t *testing.T) {
+ channelId := model.NewId()
+ hookC := &model.OutgoingWebhook{ChannelId: channelId, CreatorId: model.NewId(), TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Charlie"}
+ hookA := &model.OutgoingWebhook{ChannelId: channelId, CreatorId: model.NewId(), TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Alpha"}
+ hookB := &model.OutgoingWebhook{ChannelId: channelId, CreatorId: model.NewId(), TeamId: model.NewId(), CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Bravo"}
+
+ hookC, err := ss.Webhook().SaveOutgoing(hookC)
+ require.NoError(t, err)
+ hookA, err = ss.Webhook().SaveOutgoing(hookA)
+ require.NoError(t, err)
+ hookB, err = ss.Webhook().SaveOutgoing(hookB)
+ require.NoError(t, err)
+
+ hooks, err := ss.Webhook().GetOutgoingByChannel(channelId, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, hooks, 3)
+ require.Equal(t, hookA.Id, hooks[0].Id, "first result should be Alpha (alphabetical order)")
+ require.Equal(t, hookB.Id, hooks[1].Id, "second result should be Bravo (alphabetical order)")
+ require.Equal(t, hookC.Id, hooks[2].Id, "third result should be Charlie (alphabetical order)")
+ })
}
func testWebhookStoreGetOutgoingByTeam(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -451,16 +575,14 @@ func testWebhookStoreGetOutgoingByTeam(t *testing.T, rctx request.CTX, ss store.
}
func testWebhookStoreGetOutgoingByTeamByUser(t *testing.T, rctx request.CTX, ss store.Store) {
- var err error
-
o1 := &model.OutgoingWebhook{}
o1.ChannelId = model.NewId()
o1.CreatorId = model.NewId()
o1.TeamId = model.NewId()
o1.CallbackURLs = []string{"http://nowhere.com/"}
- o1, err = ss.Webhook().SaveOutgoing(o1)
- require.NoError(t, err)
+ o1, errSave := ss.Webhook().SaveOutgoing(o1)
+ require.NoError(t, errSave)
o2 := &model.OutgoingWebhook{}
o2.ChannelId = model.NewId()
@@ -468,8 +590,8 @@ func testWebhookStoreGetOutgoingByTeamByUser(t *testing.T, rctx request.CTX, ss
o2.TeamId = o1.TeamId
o2.CallbackURLs = []string{"http://nowhere.com/"}
- o2, err = ss.Webhook().SaveOutgoing(o2)
- require.NoError(t, err)
+ o2, errSave = ss.Webhook().SaveOutgoing(o2)
+ require.NoError(t, errSave)
t.Run("GetOutgoingByTeamByUser, no user filter", func(t *testing.T) {
hooks, err := ss.Webhook().GetOutgoingByTeam(o1.TeamId, 0, 100)
@@ -489,6 +611,28 @@ func testWebhookStoreGetOutgoingByTeamByUser(t *testing.T, rctx request.CTX, ss
require.NoError(t, err)
require.Equal(t, len(hooks), 0)
})
+
+ t.Run("GetOutgoingByTeamByUser, ordered alphabetically by display name", func(t *testing.T) {
+ teamId := model.NewId()
+ creatorId := model.NewId()
+ hookC := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: teamId, CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Charlie"}
+ hookA := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: teamId, CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Alpha"}
+ hookB := &model.OutgoingWebhook{ChannelId: model.NewId(), CreatorId: creatorId, TeamId: teamId, CallbackURLs: []string{"http://nowhere.com/"}, DisplayName: "Bravo"}
+
+ hookC, err := ss.Webhook().SaveOutgoing(hookC)
+ require.NoError(t, err)
+ hookA, err = ss.Webhook().SaveOutgoing(hookA)
+ require.NoError(t, err)
+ hookB, err = ss.Webhook().SaveOutgoing(hookB)
+ require.NoError(t, err)
+
+ hooks, err := ss.Webhook().GetOutgoingByTeamByUser(teamId, creatorId, 0, 100)
+ require.NoError(t, err)
+ require.Len(t, hooks, 3)
+ require.Equal(t, hookA.Id, hooks[0].Id, "first result should be Alpha (alphabetical order)")
+ require.Equal(t, hookB.Id, hooks[1].Id, "second result should be Bravo (alphabetical order)")
+ require.Equal(t, hookC.Id, hooks[2].Id, "third result should be Charlie (alphabetical order)")
+ })
}
func testWebhookStoreDeleteOutgoing(t *testing.T, rctx request.CTX, ss store.Store) {
diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go
index 82f7f583b7a..202bc98a7ce 100644
--- a/server/channels/store/timerlayer/timerlayer.go
+++ b/server/channels/store/timerlayer/timerlayer.go
@@ -26,6 +26,8 @@ type TimerLayer struct {
BotStore store.BotStore
ChannelStore store.ChannelStore
ChannelBookmarkStore store.ChannelBookmarkStore
+ ChannelGuardStore store.ChannelGuardStore
+ ChannelJoinRequestStore store.ChannelJoinRequestStore
ChannelMemberHistoryStore store.ChannelMemberHistoryStore
ClusterDiscoveryStore store.ClusterDiscoveryStore
CommandStore store.CommandStore
@@ -106,6 +108,14 @@ 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
+}
+
func (s *TimerLayer) ChannelMemberHistory() store.ChannelMemberHistoryStore {
return s.ChannelMemberHistoryStore
}
@@ -341,6 +351,16 @@ type TimerLayerChannelBookmarkStore struct {
Root *TimerLayer
}
+type TimerLayerChannelGuardStore struct {
+ store.ChannelGuardStore
+ Root *TimerLayer
+}
+
+type TimerLayerChannelJoinRequestStore struct {
+ store.ChannelJoinRequestStore
+ Root *TimerLayer
+}
+
type TimerLayerChannelMemberHistoryStore struct {
store.ChannelMemberHistoryStore
Root *TimerLayer
@@ -623,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()
@@ -1897,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()
@@ -2345,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()
@@ -3218,6 +3302,182 @@ 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()
+
+ result, err := s.ChannelJoinRequestStore.CountPending(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("ChannelJoinRequestStore.CountPending", success, elapsed)
+ }
+ return result, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) Get(id string) (*model.ChannelJoinRequest, error) {
+ start := time.Now()
+
+ result, err := s.ChannelJoinRequestStore.Get(id)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelJoinRequestStore.Get", success, elapsed)
+ }
+ return result, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ start := time.Now()
+
+ result, resultVar1, err := s.ChannelJoinRequestStore.GetForChannel(channelId, opts)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelJoinRequestStore.GetForChannel", success, elapsed)
+ }
+ return result, resultVar1, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
+ start := time.Now()
+
+ result, resultVar1, err := s.ChannelJoinRequestStore.GetForUser(userId, opts)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelJoinRequestStore.GetForUser", success, elapsed)
+ }
+ return result, resultVar1, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) GetPendingForChannelAndUser(channelId string, userId string) (*model.ChannelJoinRequest, error) {
+ start := time.Now()
+
+ result, err := s.ChannelJoinRequestStore.GetPendingForChannelAndUser(channelId, 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("ChannelJoinRequestStore.GetPendingForChannelAndUser", success, elapsed)
+ }
+ return result, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ start := time.Now()
+
+ result, err := s.ChannelJoinRequestStore.Save(req)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelJoinRequestStore.Save", success, elapsed)
+ }
+ return result, err
+}
+
+func (s *TimerLayerChannelJoinRequestStore) Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
+ start := time.Now()
+
+ result, err := s.ChannelJoinRequestStore.Update(req)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelJoinRequestStore.Update", success, elapsed)
+ }
+ return result, err
+}
+
func (s *TimerLayerChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (int64, error) {
start := time.Now()
@@ -3250,22 +3510,6 @@ func (s *TimerLayerChannelMemberHistoryStore) GetChannelsLeftSince(userID string
return result, err
}
-func (s *TimerLayerChannelMemberHistoryStore) GetEverMembersInChannel(channelID string, userIDs []string) ([]string, error) {
- start := time.Now()
-
- result, err := s.ChannelMemberHistoryStore.GetEverMembersInChannel(channelID, userIDs)
-
- elapsed := float64(time.Since(start)) / float64(time.Second)
- if s.Root.Metrics != nil {
- success := "false"
- if err == nil {
- success = "true"
- }
- s.Root.Metrics.ObserveStoreMethodDuration("ChannelMemberHistoryStore.GetEverMembersInChannel", success, elapsed)
- }
- return result, err
-}
-
func (s *TimerLayerChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
start := time.Now()
@@ -3282,6 +3526,22 @@ func (s *TimerLayerChannelMemberHistoryStore) GetChannelsWithActivityDuring(star
return result, err
}
+func (s *TimerLayerChannelMemberHistoryStore) GetEverMembersInChannel(channelID string, userIDs []string) ([]string, error) {
+ start := time.Now()
+
+ result, err := s.ChannelMemberHistoryStore.GetEverMembersInChannel(channelID, userIDs)
+
+ elapsed := float64(time.Since(start)) / float64(time.Second)
+ if s.Root.Metrics != nil {
+ success := "false"
+ if err == nil {
+ success = "true"
+ }
+ s.Root.Metrics.ObserveStoreMethodDuration("ChannelMemberHistoryStore.GetEverMembersInChannel", success, elapsed)
+ }
+ return result, err
+}
+
func (s *TimerLayerChannelMemberHistoryStore) GetMembershipChanges(channelID string, since int64, limit int) ([]*model.ChannelMemberHistory, error) {
start := time.Now()
@@ -7980,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()
@@ -12582,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()
@@ -12742,6 +13034,22 @@ func (s *TimerLayerUserStore) GetByAuth(authData *string, authService string) (*
return result, err
}
+func (s *TimerLayerUserStore) GetByAuthData(authData *string) (*model.User, error) {
+ start := time.Now()
+
+ result, err := s.UserStore.GetByAuthData(authData)
+
+ 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.GetByAuthData", success, elapsed)
+ }
+ return result, err
+}
+
func (s *TimerLayerUserStore) GetByEmail(email string) (*model.User, error) {
start := time.Now()
@@ -13587,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()
@@ -13795,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()
@@ -14541,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()
}
@@ -14558,6 +14918,8 @@ 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}
newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore}
diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go
index 261eac37136..c8fe7f14957 100644
--- a/server/channels/testlib/store.go
+++ b/server/channels/testlib/store.go
@@ -102,6 +102,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", model.MigrationKeyAccessControlPolicyV0_3).Return(&model.System{Name: model.MigrationKeyAccessControlPolicyV0_3, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddManageAgentPermissions).Return(&model.System{Name: model.MigrationKeyAddManageAgentPermissions, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddEditFileAttachmentPermission).Return(&model.System{Name: model.MigrationKeyAddEditFileAttachmentPermission, Value: "true"}, nil)
+ systemStore.On("GetByName", model.MigrationKeyAddDiscoverableChannelPermissions).Return(&model.System{Name: model.MigrationKeyAddDiscoverableChannelPermissions, Value: "true"}, nil)
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once()
systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil)
@@ -144,13 +145,18 @@ 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{}
- cpaGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.CustomProfileAttributesPropertyGroupName, Version: model.PropertyGroupVersionV1}
+ accessControlGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2}
+ contentFlaggingGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.ContentFlaggingGroupName, Version: model.PropertyGroupVersionV1}
managedCategoryGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.ManagedCategoryPropertyGroupName, Version: model.PropertyGroupVersionV2}
boardsGroup := &model.PropertyGroup{ID: model.NewId(), Name: model.BoardsPropertyGroupName, Version: model.PropertyGroupVersionV2}
- groupsByName[cpaGroup.Name] = cpaGroup
+ groupsByName[accessControlGroup.Name] = accessControlGroup
+ groupsByName[contentFlaggingGroup.Name] = contentFlaggingGroup
groupsByName[managedCategoryGroup.Name] = managedCategoryGroup
groupsByName[boardsGroup.Name] = boardsGroup
@@ -177,7 +183,8 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
return nil
},
)
- propertyGroupStore.On("Get", model.CustomProfileAttributesPropertyGroupName).Return(cpaGroup, nil)
+ propertyGroupStore.On("Get", model.AccessControlPropertyGroupName).Return(accessControlGroup, nil)
+ propertyGroupStore.On("Get", model.ContentFlaggingGroupName).Return(contentFlaggingGroup, nil)
propertyGroupStore.On("Get", model.ManagedCategoryPropertyGroupName).Return(managedCategoryGroup, nil)
propertyGroupStore.On("Get", model.BoardsPropertyGroupName).Return(boardsGroup, nil)
@@ -214,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/utils/license.go b/server/channels/utils/license.go
index d4f97410bad..74930e4895a 100644
--- a/server/channels/utils/license.go
+++ b/server/channels/utils/license.go
@@ -123,7 +123,7 @@ func GetAndValidateLicenseFileFromDisk(location string) (*model.License, []byte,
var license model.License
if jsonErr := json.Unmarshal([]byte(licenseStr), &license); jsonErr != nil {
- return nil, nil, fmt.Errorf("Found license key at %s but it appears to be invalid: %w", fileName, err)
+ return nil, nil, fmt.Errorf("Found license key at %s but it appears to be invalid: %w", fileName, jsonErr)
}
return &license, licenseBytes, nil
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/channels/web/webhook_test.go b/server/channels/web/webhook_test.go
index aab7cf79010..b7e33eb12e6 100644
--- a/server/channels/web/webhook_test.go
+++ b/server/channels/web/webhook_test.go
@@ -42,6 +42,10 @@ func TestIncomingWebhook(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
+ refreshed, appErr := th.App.GetIncomingWebhook(hook.Id)
+ require.Nil(t, appErr)
+ require.NotZero(t, refreshed.LastUsed)
+
payload = "payload={\"text\": \"\"}"
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
diff --git a/server/cmd/mattermost/commands/db.go b/server/cmd/mattermost/commands/db.go
index 17c96c0c45a..f7eb04d8ba2 100644
--- a/server/cmd/mattermost/commands/db.go
+++ b/server/cmd/mattermost/commands/db.go
@@ -329,6 +329,19 @@ func ConfigToFileBackendSettings(s *model.FileSettings, enableComplianceFeature
Directory: *s.Directory,
}
}
+ if *s.DriverName == model.ImageDriverAzure {
+ return filestore.FileBackendSettings{
+ DriverName: *s.DriverName,
+ AzureStorageAccount: *s.AzureStorageAccount,
+ AzureAccessKey: *s.AzureAccessKey,
+ AzureContainer: *s.AzureContainer,
+ AzurePathPrefix: *s.AzurePathPrefix,
+ AzureEndpoint: *s.AzureEndpoint,
+ AzureSSL: s.AzureSSL == nil || *s.AzureSSL,
+ AzureRequestTimeoutMilliseconds: *s.AzureRequestTimeoutMilliseconds,
+ SkipVerify: skipVerify,
+ }
+ }
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AmazonS3AccessKeyId: *s.AmazonS3AccessKeyId,
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/cmd/mmctl/commands/config_e2e_test.go b/server/cmd/mmctl/commands/config_e2e_test.go
index 4fcc9a1aa8f..e1ab559f995 100644
--- a/server/cmd/mmctl/commands/config_e2e_test.go
+++ b/server/cmd/mmctl/commands/config_e2e_test.go
@@ -51,7 +51,7 @@ func (s *MmctlE2ETestSuite) TestConfigPatchCmd() {
invalidFile, err := os.CreateTemp(os.TempDir(), "invalid_config_*.json")
s.Require().Nil(err)
- _, err = tmpFile.Write([]byte(configFilePayload))
+ _, err = tmpFile.WriteString(configFilePayload)
s.Require().Nil(err)
defer func() {
@@ -212,7 +212,7 @@ rm $1'old'`
defer func() {
os.Remove(file.Name())
}()
- _, err = file.Write([]byte(content))
+ _, err = file.WriteString(content)
s.Require().Nil(err)
s.Require().Nil(file.Close())
s.Require().Nil(os.Chmod(file.Name(), 0700))
diff --git a/server/cmd/mmctl/commands/config_test.go b/server/cmd/mmctl/commands/config_test.go
index f20a7cb44a2..b42dba1340b 100644
--- a/server/cmd/mmctl/commands/config_test.go
+++ b/server/cmd/mmctl/commands/config_test.go
@@ -600,10 +600,10 @@ func (s *MmctlUnitTestSuite) TestConfigPatchCmd() {
pluginFile, err := os.CreateTemp(os.TempDir(), "plugin_config_*.json")
s.Require().NoError(err)
- _, err = tmpFile.Write([]byte(configFilePayload))
+ _, err = tmpFile.WriteString(configFilePayload)
s.Require().NoError(err)
- _, err = pluginFile.Write([]byte(configFilePluginPayload))
+ _, err = pluginFile.WriteString(configFilePluginPayload)
s.Require().NoError(err)
defer func() {
diff --git a/server/cmd/mmctl/commands/permissions_test.go b/server/cmd/mmctl/commands/permissions_test.go
index 51cbd4f5332..671ba68b051 100644
--- a/server/cmd/mmctl/commands/permissions_test.go
+++ b/server/cmd/mmctl/commands/permissions_test.go
@@ -251,6 +251,8 @@ func (s *MmctlUnitTestSuite) TestResetPermissionsCmd() {
"manage_channel_access_rules",
"manage_public_channel_auto_translation",
"manage_private_channel_auto_translation",
+ "manage_private_channel_discoverability",
+ "manage_channel_join_requests",
}
expectedPatch := &model.RolePatch{
Permissions: &expectedPermissions,
diff --git a/server/cmd/mmctl/commands/user_attributes_field_e2e_test.go b/server/cmd/mmctl/commands/user_attributes_field_e2e_test.go
index 53fbfd3ceb3..77e5cc25bbc 100644
--- a/server/cmd/mmctl/commands/user_attributes_field_e2e_test.go
+++ b/server/cmd/mmctl/commands/user_attributes_field_e2e_test.go
@@ -4,6 +4,8 @@
package commands
import (
+ "context"
+
"github.com/mattermost/mattermost/server/public/model"
"github.com/spf13/cobra"
@@ -11,13 +13,52 @@ import (
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
+// createCPAField posts the given CPAField via the admin HTTP client and
+// returns the server response reshaped as a typed CPAField.
+func (s *MmctlE2ETestSuite) createCPAField(field *model.CPAField) *model.CPAField {
+ s.T().Helper()
+ created, _, err := s.th.SystemAdminClient.CreateCPAField(context.Background(), field.ToPropertyField())
+ s.Require().NoError(err)
+ cpa, err := model.NewCPAFieldFromPropertyField(created)
+ s.Require().NoError(err)
+ return cpa
+}
+
+// listCPAFields fetches all CPA fields via the admin HTTP client, returning
+// them as typed CPAFields.
+func (s *MmctlE2ETestSuite) listCPAFields() []*model.CPAField {
+ s.T().Helper()
+ fields, _, err := s.th.SystemAdminClient.ListCPAFields(context.Background())
+ s.Require().NoError(err)
+ out := make([]*model.CPAField, 0, len(fields))
+ for _, pf := range fields {
+ cpa, err := model.NewCPAFieldFromPropertyField(pf)
+ s.Require().NoError(err)
+ out = append(out, cpa)
+ }
+ return out
+}
+
+// getCPAField fetches a single CPA field by ID. There is no single-field HTTP
+// endpoint, so this filters the full list — sufficient for verifying updates
+// in tests with a clean fixture state.
+func (s *MmctlE2ETestSuite) getCPAField(id string) *model.CPAField {
+ s.T().Helper()
+ for _, f := range s.listCPAFields() {
+ if f.ID == id {
+ return f
+ }
+ }
+ s.T().Fatalf("CPA field %q not found", id)
+ return nil
+}
+
// cleanCPAFields removes all existing CPA fields to ensure clean test state
func (s *MmctlE2ETestSuite) cleanCPAFields() {
- existingFields, appErr := s.th.App.ListCPAFields(nil)
- s.Require().Nil(appErr)
- for _, field := range existingFields {
- appErr := s.th.App.DeleteCPAField(nil, field.ID)
- s.Require().Nil(appErr)
+ s.T().Helper()
+ for _, field := range s.listCPAFields() {
+ _, err := s.th.SystemAdminClient.DeleteCPAField(context.Background(), field.ID)
+ s.Require().NoError(err)
}
}
@@ -66,12 +107,10 @@ func (s *MmctlE2ETestSuite) TestCPAFieldListCmd() {
},
}
- createdTextField, appErr := s.th.App.CreateCPAField(nil, textField)
- s.Require().Nil(appErr)
+ createdTextField := s.createCPAField(textField)
s.Require().NotNil(createdTextField)
- createdSelectField, appErr := s.th.App.CreateCPAField(nil, selectField)
- s.Require().Nil(appErr)
+ createdSelectField := s.createCPAField(selectField)
s.Require().NotNil(createdSelectField)
// Now test the list command
@@ -114,8 +153,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldCreateCmd() {
s.Require().Contains(output, "Field Department correctly created")
// Verify field was actually created in the database
- fields, appErr := s.th.App.ListCPAFields(nil)
- s.Require().Nil(appErr)
+ fields := s.listCPAFields()
s.Require().Len(fields, 1)
s.Require().Equal("Department", fields[0].Name)
s.Require().Equal(model.PropertyFieldTypeText, fields[0].Type)
@@ -150,8 +188,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldCreateCmd() {
s.Require().Contains(output, "Field Skills correctly created")
// Verify field was actually created in the database with correct options
- fields, appErr := s.th.App.ListCPAFields(nil)
- s.Require().Nil(appErr)
+ fields := s.listCPAFields()
s.Require().Len(fields, 1)
s.Require().Equal("Skills", fields[0].Name)
s.Require().Equal(model.PropertyFieldTypeMultiselect, fields[0].Type)
@@ -210,8 +247,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
// Now edit the field
cmd := &cobra.Command{}
@@ -237,8 +273,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
s.Require().Contains(output, "Field Programming Languages successfully updated")
// Verify field was actually updated
- updatedField, appErr := s.th.App.GetCPAField(nil, createdField.ID)
- s.Require().Nil(appErr)
+ updatedField := s.getCPAField(createdField.ID)
s.Require().Equal("Programming Languages", updatedField.Name)
// Check options
@@ -268,8 +303,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
// Now edit the field with --managed flag
cmd := &cobra.Command{}
@@ -287,8 +321,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
s.Require().Len(printer.GetErrorLines(), 0)
// Verify field was actually updated
- updatedField, appErr := s.th.App.GetCPAField(nil, createdField.ID)
- s.Require().Nil(appErr)
+ updatedField := s.getCPAField(createdField.ID)
// Verify that managed flag was set correctly
s.Require().Equal("admin", updatedField.Attrs.Managed)
@@ -310,8 +343,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
// Now edit the field using its name instead of ID
cmd := &cobra.Command{}
@@ -336,8 +368,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
s.Require().Contains(output, "Field Team successfully updated")
// Verify field was actually updated by retrieving it
- updatedField, appErr := s.th.App.GetCPAField(nil, createdField.ID)
- s.Require().Nil(appErr)
+ updatedField := s.getCPAField(createdField.ID)
s.Require().Equal("Team", updatedField.Name)
// Check managed status
@@ -363,8 +394,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
// Get the original option IDs to verify they are preserved
s.Require().Len(createdField.Attrs.Options, 2)
@@ -406,8 +436,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldEditCmd() {
s.Require().Contains(output, "Field Programming Languages successfully updated")
// Verify field was actually updated and options are preserved correctly
- updatedField, appErr := s.th.App.GetCPAField(nil, createdField.ID)
- s.Require().Nil(appErr)
+ updatedField := s.getCPAField(createdField.ID)
// Check options
s.Require().Len(updatedField.Attrs.Options, 3)
@@ -456,8 +485,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldDeleteCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", false, "")
@@ -475,8 +503,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldDeleteCmd() {
s.Require().Contains(output, "Successfully deleted CPA field")
// Verify field was actually deleted by checking if it exists in the list
- fields, appErr := s.th.App.ListCPAFields(nil)
- s.Require().Nil(appErr)
+ fields := s.listCPAFields()
// Field should not be in the list anymore
fieldExists := false
@@ -502,8 +529,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldDeleteCmd() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, field)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(field)
cmd := &cobra.Command{}
cmd.Flags().Bool("confirm", false, "")
@@ -522,8 +548,7 @@ func (s *MmctlE2ETestSuite) TestCPAFieldDeleteCmd() {
s.Require().Contains(output, "Successfully deleted CPA field: Department")
// Verify field was actually deleted by checking if it exists in the list
- fields, appErr := s.th.App.ListCPAFields(nil)
- s.Require().Nil(appErr)
+ fields := s.listCPAFields()
// Field should not be in the list anymore
fieldExists := false
diff --git a/server/cmd/mmctl/commands/user_attributes_value_e2e_test.go b/server/cmd/mmctl/commands/user_attributes_value_e2e_test.go
index 4370d4af4fe..79f0cce2dbc 100644
--- a/server/cmd/mmctl/commands/user_attributes_value_e2e_test.go
+++ b/server/cmd/mmctl/commands/user_attributes_value_e2e_test.go
@@ -4,6 +4,7 @@
package commands
import (
+ "context"
"encoding/json"
"github.com/mattermost/mattermost/server/public/model"
@@ -12,21 +13,31 @@ import (
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
)
+// listCPAValuesForUser fetches the user's CPA values via the admin HTTP
+// client (field-id → raw-JSON map, same shape the command returns).
+func (s *MmctlE2ETestSuite) listCPAValuesForUser(userID string) map[string]json.RawMessage {
+ s.T().Helper()
+ values, _, err := s.th.SystemAdminClient.ListCPAValues(context.Background(), userID)
+ s.Require().NoError(err)
+ return values
+}
+
// cleanCPAValuesForUser removes all CPA values for a user
func (s *MmctlE2ETestSuite) cleanCPAValuesForUser(userID string) {
- existingValues, appErr := s.th.App.ListCPAValues(nil, userID)
- s.Require().Nil(appErr)
+ s.T().Helper()
+ existing := s.listCPAValuesForUser(userID)
+ if len(existing) == 0 {
+ return
+ }
// Clear all existing values by setting them to null
- updates := make(map[string]json.RawMessage)
- for _, value := range existingValues {
- updates[value.FieldID] = json.RawMessage("null")
+ updates := make(map[string]json.RawMessage, len(existing))
+ for fieldID := range existing {
+ updates[fieldID] = json.RawMessage("null")
}
- if len(updates) > 0 {
- _, appErr = s.th.App.PatchCPAValues(nil, userID, updates, false)
- s.Require().Nil(appErr)
- }
+ _, _, err := s.th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), userID, updates)
+ s.Require().NoError(err)
}
func (s *MmctlE2ETestSuite) TestCPAValueList() {
@@ -64,19 +75,18 @@ func (s *MmctlE2ETestSuite) TestCPAValueList() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, textField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(textField)
- // Set a text value using the app layer
+ // Seed a text value via the admin HTTP client.
updates := map[string]json.RawMessage{
createdField.ID: json.RawMessage(`"Engineering"`),
}
- _, appErr = s.th.App.PatchCPAValues(nil, s.th.BasicUser.Id, updates, false)
- s.Require().Nil(appErr)
+ _, _, err := s.th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), s.th.BasicUser.Id, updates)
+ s.Require().NoError(err)
// Test listing the values with plain format (human-readable)
printer.SetFormat(printer.FormatPlain)
- err := cpaValueListCmdF(c, &cobra.Command{}, []string{s.th.BasicUser.Email})
+ err = cpaValueListCmdF(c, &cobra.Command{}, []string{s.th.BasicUser.Email})
s.Require().Nil(err)
s.Require().Len(printer.GetLines(), 1)
s.Require().Len(printer.GetErrorLines(), 0)
@@ -122,8 +132,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, textField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(textField)
// Set a text value
cmd := &cobra.Command{}
@@ -136,11 +145,9 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// Verify the value was set
- values, appErr := s.th.App.ListCPAValues(nil, s.th.BasicUser.Id)
- s.Require().Nil(appErr)
+ values := s.listCPAValuesForUser(s.th.BasicUser.Id)
s.Require().Len(values, 1)
- s.Require().Equal(createdField.ID, values[0].FieldID)
- s.Require().Equal(`"Engineering"`, string(values[0].Value))
+ s.Require().Equal(`"Engineering"`, string(values[createdField.ID]))
})
s.Run("Set value for select type field", func() {
@@ -166,8 +173,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, selectField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(selectField)
// Set a select value using the option name
cmd := &cobra.Command{}
@@ -180,10 +186,8 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// Verify the value was set (should be stored as option ID)
- values, appErr := s.th.App.ListCPAValues(nil, s.th.BasicUser.Id)
- s.Require().Nil(appErr)
+ values := s.listCPAValuesForUser(s.th.BasicUser.Id)
s.Require().Len(values, 1)
- s.Require().Equal(createdField.ID, values[0].FieldID)
// Find the Senior option ID for verification
var seniorOptionID string
@@ -193,7 +197,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
break
}
}
- s.Require().Equal(`"`+seniorOptionID+`"`, string(values[0].Value))
+ s.Require().Equal(`"`+seniorOptionID+`"`, string(values[createdField.ID]))
})
s.Run("Set value for multiselect type field", func() {
@@ -220,8 +224,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, multiselectField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(multiselectField)
// Set multiple values using option names
cmd := &cobra.Command{}
@@ -239,10 +242,8 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// Verify the values were set (should be stored as option IDs)
- values, appErr := s.th.App.ListCPAValues(nil, s.th.BasicUser.Id)
- s.Require().Nil(appErr)
+ values := s.listCPAValuesForUser(s.th.BasicUser.Id)
s.Require().Len(values, 1)
- s.Require().Equal(createdField.ID, values[0].FieldID)
// Find the option IDs for verification
var goOptionID, reactOptionID, pythonOptionID string
@@ -259,7 +260,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// The multiselect values should be stored as an array of option IDs
// The JSON serialization may include spaces, so we need to compare the content, not exact string
- actualValue := string(values[0].Value)
+ actualValue := string(values[createdField.ID])
s.Require().Contains(actualValue, goOptionID)
s.Require().Contains(actualValue, reactOptionID)
s.Require().Contains(actualValue, pythonOptionID)
@@ -288,8 +289,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, multiselectField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(multiselectField)
// Set a single value using option name
cmd := &cobra.Command{}
@@ -303,10 +303,8 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// Verify the value was set (should be stored as an array with single option ID)
- values, appErr := s.th.App.ListCPAValues(nil, s.th.BasicUser.Id)
- s.Require().Nil(appErr)
+ values := s.listCPAValuesForUser(s.th.BasicUser.Id)
s.Require().Len(values, 1)
- s.Require().Equal(createdField.ID, values[0].FieldID)
// Find the option ID for verification
var pythonOptionID string
@@ -319,7 +317,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// The multiselect value should be stored as an array with single option ID
// Even for single value, multiselect fields store values as arrays
- actualValue := string(values[0].Value)
+ actualValue := string(values[createdField.ID])
s.Require().Contains(actualValue, pythonOptionID)
s.Require().Contains(actualValue, "[")
s.Require().Contains(actualValue, "]")
@@ -349,8 +347,7 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
},
}
- createdField, appErr := s.th.App.CreateCPAField(nil, userField)
- s.Require().Nil(appErr)
+ createdField := s.createCPAField(userField)
// Set a user value using the system admin user ID
cmd := &cobra.Command{}
@@ -363,10 +360,8 @@ func (s *MmctlE2ETestSuite) TestCPAValueSet() {
// Verify the value was set
- values, appErr := s.th.App.ListCPAValues(nil, s.th.BasicUser.Id)
- s.Require().Nil(appErr)
+ values := s.listCPAValuesForUser(s.th.BasicUser.Id)
s.Require().Len(values, 1)
- s.Require().Equal(createdField.ID, values[0].FieldID)
- s.Require().Equal(`"`+s.th.SystemAdminUser.Id+`"`, string(values[0].Value))
+ s.Require().Equal(`"`+s.th.SystemAdminUser.Id+`"`, string(values[createdField.ID]))
})
}
diff --git a/server/config/client.go b/server/config/client.go
index d56c9d7e70e..5b255f1af97 100644
--- a/server/config/client.go
+++ b/server/config/client.go
@@ -255,6 +255,20 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["AutoTranslationLanguages"] = ""
}
props["RestrictDMAndGMAutotranslation"] = strconv.FormatBool(*c.AutoTranslationSettings.RestrictDMAndGM)
+
+ if c.FeatureFlags.MobileEphemeralMode {
+ ephemeralEnabled := c.MobileEphemeralModeSettings.Enable != nil && *c.MobileEphemeralModeSettings.Enable
+ props["MobileEphemeralModeEnabled"] = strconv.FormatBool(ephemeralEnabled)
+ if c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds != nil {
+ props["MobileEphemeralModeDisconnectionTimeoutSeconds"] = strconv.Itoa(*c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds)
+ }
+ if c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours != nil {
+ props["MobileEphemeralModeOfflinePersistenceTimerHours"] = strconv.Itoa(*c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours)
+ }
+ if c.MobileEphemeralModeSettings.AutoCacheCleanupDays != nil {
+ props["MobileEphemeralModeAutoCacheCleanupDays"] = strconv.Itoa(*c.MobileEphemeralModeSettings.AutoCacheCleanupDays)
+ }
+ }
}
}
diff --git a/server/config/client_test.go b/server/config/client_test.go
index 159d80a8a03..53a9511fd86 100644
--- a/server/config/client_test.go
+++ b/server/config/client_test.go
@@ -20,6 +20,7 @@ func TestGetClientConfig(t *testing.T) {
telemetryID string
license *model.License
expectedFields map[string]string
+ absentFields []string
}{
{
"unlicensed",
@@ -48,6 +49,7 @@ func TestGetClientConfig(t *testing.T) {
"WebsocketPort": "80",
"WebsocketSecurePort": "443",
},
+ nil,
},
{
"licensed, but not for theme management",
@@ -71,6 +73,7 @@ func TestGetClientConfig(t *testing.T) {
"EmailNotificationContentsType": "full",
"AllowCustomThemes": "true",
},
+ nil,
},
{
"licensed for theme management",
@@ -93,6 +96,7 @@ func TestGetClientConfig(t *testing.T) {
"EmailNotificationContentsType": "full",
"AllowCustomThemes": "false",
},
+ nil,
},
{
"licensed for enforcement",
@@ -110,6 +114,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnforceMultifactorAuthentication": "true",
},
+ nil,
},
{
"default marketplace",
@@ -123,6 +128,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IsDefaultMarketplace": "true",
},
+ nil,
},
{
"non-default marketplace",
@@ -136,6 +142,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IsDefaultMarketplace": "false",
},
+ nil,
},
{
"enable ShowFullName prop",
@@ -149,6 +156,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ShowFullName": "true",
},
+ nil,
},
{
"enable UseAnonymousURLs prop",
@@ -162,6 +170,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"UseAnonymousURLs": "true",
},
+ nil,
},
{
"Custom groups professional license",
@@ -174,6 +183,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableCustomGroups": "true",
},
+ nil,
},
{
"Custom groups enterprise license",
@@ -186,6 +196,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableCustomGroups": "true",
},
+ nil,
},
{
"Custom groups other license",
@@ -198,6 +209,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableCustomGroups": "false",
},
+ nil,
},
{
"Shared channels other license",
@@ -216,6 +228,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ExperimentalSharedChannels": "false",
},
+ nil,
},
{
"licensed for shared channels",
@@ -234,6 +247,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ExperimentalSharedChannels": "true",
},
+ nil,
},
{
"Shared channels professional license",
@@ -252,6 +266,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ExperimentalSharedChannels": "true",
},
+ nil,
},
{
"disable EnableUserStatuses",
@@ -265,6 +280,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableUserStatuses": "false",
},
+ nil,
},
{
"Shared channels enterprise license",
@@ -283,6 +299,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ExperimentalSharedChannels": "true",
},
+ nil,
},
{
"Disable App Bar",
@@ -296,6 +313,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"DisableAppBar": "true",
},
+ nil,
},
{
"default EnableJoinLeaveMessage",
@@ -305,6 +323,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableJoinLeaveMessageByDefault": "true",
},
+ nil,
},
{
"disable EnableJoinLeaveMessage",
@@ -318,6 +337,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"EnableJoinLeaveMessageByDefault": "false",
},
+ nil,
},
{
"test key for GiphySdkKey",
@@ -331,6 +351,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"GiphySdkKey": model.ServiceSettingsDefaultGiphySdkKeyTest,
},
+ nil,
},
{
"report a problem values",
@@ -350,6 +371,7 @@ func TestGetClientConfig(t *testing.T) {
"ReportAProblemMail": "mail",
"AllowDownloadLogs": "true",
},
+ nil,
},
{
"access control settings enabled",
@@ -365,6 +387,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableAttributeBasedAccessControl": "true",
"EnableUserManagedAttributes": "true",
},
+ nil,
},
{
"access control settings disabled",
@@ -380,6 +403,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableAttributeBasedAccessControl": "false",
"EnableUserManagedAttributes": "false",
},
+ nil,
},
{
"access control settings default",
@@ -390,6 +414,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableAttributeBasedAccessControl": "false",
"EnableUserManagedAttributes": "false",
},
+ nil,
},
{
"burn on read enabled",
@@ -405,6 +430,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableBurnOnRead": "true",
"BurnOnReadDurationSeconds": "1800",
},
+ nil,
},
{
"burn on read disabled",
@@ -420,6 +446,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableBurnOnRead": "false",
"BurnOnReadDurationSeconds": "600",
},
+ nil,
},
{
"burn on read default",
@@ -430,6 +457,7 @@ func TestGetClientConfig(t *testing.T) {
"EnableBurnOnRead": "true",
"BurnOnReadDurationSeconds": "600", // 10 minutes in seconds
},
+ nil,
},
{
"mobile watermark uses experimental settings",
@@ -446,6 +474,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"ExperimentalEnableWatermark": "true",
},
+ nil,
},
{
"Intune MAM enabled with Enterprise Advanced license and Office365 AuthService",
@@ -466,6 +495,7 @@ func TestGetClientConfig(t *testing.T) {
"IntuneMAMEnabled": "true",
"IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost",
},
+ nil,
},
{
"Intune MAM disabled when not enabled",
@@ -485,6 +515,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IntuneMAMEnabled": "false",
},
+ nil,
},
{
"Intune MAM disabled when TenantId is missing",
@@ -504,6 +535,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IntuneMAMEnabled": "false",
},
+ nil,
},
{
"Intune MAM disabled when ClientId is missing",
@@ -523,6 +555,7 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IntuneMAMEnabled": "false",
},
+ nil,
},
{
"Intune MAM not exposed with lower license tier",
@@ -540,6 +573,7 @@ func TestGetClientConfig(t *testing.T) {
SkuShortName: model.LicenseShortSkuProfessional,
},
map[string]string{},
+ []string{"IntuneMAMEnabled", "IntuneScope"},
},
{
"Intune MAM not exposed without license",
@@ -554,6 +588,7 @@ func TestGetClientConfig(t *testing.T) {
"",
nil,
map[string]string{},
+ []string{"IntuneMAMEnabled", "IntuneScope"},
},
{
"Intune MAM enabled with Enterprise Advanced license and SAML AuthService",
@@ -578,6 +613,7 @@ func TestGetClientConfig(t *testing.T) {
"IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost",
"IntuneAuthService": "saml",
},
+ nil,
},
{
"Intune MAM disabled when AuthService is missing",
@@ -597,6 +633,100 @@ func TestGetClientConfig(t *testing.T) {
map[string]string{
"IntuneMAMEnabled": "false",
},
+ nil,
+ },
+ {
+ "Mobile Ephemeral Mode enabled with custom values",
+ &model.Config{
+ FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true},
+ MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{
+ Enable: model.NewPointer(true),
+ DisconnectionTimeoutSeconds: model.NewPointer(120),
+ OfflinePersistenceTimerHours: model.NewPointer(48),
+ AutoCacheCleanupDays: model.NewPointer(14),
+ },
+ },
+ "",
+ &model.License{
+ Features: &model.Features{},
+ SkuShortName: model.LicenseShortSkuEnterpriseAdvanced,
+ },
+ map[string]string{
+ "MobileEphemeralModeEnabled": "true",
+ "MobileEphemeralModeDisconnectionTimeoutSeconds": "120",
+ "MobileEphemeralModeOfflinePersistenceTimerHours": "48",
+ "MobileEphemeralModeAutoCacheCleanupDays": "14",
+ },
+ nil,
+ },
+ {
+ "Mobile Ephemeral Mode disabled still exposes parameters",
+ &model.Config{
+ FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true},
+ MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{
+ Enable: model.NewPointer(false),
+ DisconnectionTimeoutSeconds: model.NewPointer(60),
+ OfflinePersistenceTimerHours: model.NewPointer(24),
+ AutoCacheCleanupDays: model.NewPointer(7),
+ },
+ },
+ "",
+ &model.License{
+ Features: &model.Features{},
+ SkuShortName: model.LicenseShortSkuEnterpriseAdvanced,
+ },
+ map[string]string{
+ "MobileEphemeralModeEnabled": "false",
+ "MobileEphemeralModeDisconnectionTimeoutSeconds": "60",
+ "MobileEphemeralModeOfflinePersistenceTimerHours": "24",
+ "MobileEphemeralModeAutoCacheCleanupDays": "7",
+ },
+ nil,
+ },
+ {
+ "Mobile Ephemeral Mode not exposed when feature flag is off",
+ &model.Config{
+ FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: false},
+ MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{
+ Enable: model.NewPointer(true),
+ },
+ },
+ "",
+ &model.License{
+ Features: &model.Features{},
+ SkuShortName: model.LicenseShortSkuEnterpriseAdvanced,
+ },
+ map[string]string{},
+ []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"},
+ },
+ {
+ "Mobile Ephemeral Mode not exposed without license",
+ &model.Config{
+ FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true},
+ MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{
+ Enable: model.NewPointer(true),
+ },
+ },
+ "",
+ nil,
+ map[string]string{},
+ []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"},
+ },
+ {
+ "Mobile Ephemeral Mode not exposed with lower license tier",
+ &model.Config{
+ FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true},
+ MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{
+ Enable: model.NewPointer(true),
+ },
+ },
+ "",
+ &model.License{
+ Features: &model.Features{},
+ SkuShortName: model.LicenseShortSkuProfessional,
+ },
+ map[string]string{},
+ []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"},
},
}
@@ -616,6 +746,10 @@ func TestGetClientConfig(t *testing.T) {
assert.Equal(t, expectedValue, actualValue)
}
}
+ for _, absentField := range testCase.absentFields {
+ _, ok := configMap[absentField]
+ assert.False(t, ok, fmt.Sprintf("config should not contain %v", absentField))
+ }
})
}
}
diff --git a/server/config/diff.go b/server/config/diff.go
index 8e140eb0b22..ea1b8ed9e9d 100644
--- a/server/config/diff.go
+++ b/server/config/diff.go
@@ -40,6 +40,8 @@ var configSensitivePaths = map[string]bool{
"LdapSettings.BindPassword": true,
"FileSettings.PublicLinkSalt": true,
"FileSettings.AmazonS3SecretAccessKey": true,
+ "FileSettings.AzureAccessKey": true,
+ "FileSettings.ExportAzureAccessKey": true,
"SqlSettings.DataSource": true,
"SqlSettings.AtRestEncryptKey": true,
"SqlSettings.DataSourceReplicas": true,
diff --git a/server/config/utils.go b/server/config/utils.go
index 1577522564b..c2ecf25bb27 100644
--- a/server/config/utils.go
+++ b/server/config/utils.go
@@ -33,6 +33,15 @@ func desanitize(actual, target *model.Config) {
if *target.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting {
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
}
+ if target.FileSettings.ExportAmazonS3SecretAccessKey != nil && *target.FileSettings.ExportAmazonS3SecretAccessKey == model.FakeSetting {
+ target.FileSettings.ExportAmazonS3SecretAccessKey = actual.FileSettings.ExportAmazonS3SecretAccessKey
+ }
+ if target.FileSettings.AzureAccessKey != nil && *target.FileSettings.AzureAccessKey == model.FakeSetting {
+ target.FileSettings.AzureAccessKey = actual.FileSettings.AzureAccessKey
+ }
+ if target.FileSettings.ExportAzureAccessKey != nil && *target.FileSettings.ExportAzureAccessKey == model.FakeSetting {
+ target.FileSettings.ExportAzureAccessKey = actual.FileSettings.ExportAzureAccessKey
+ }
if *target.EmailSettings.SMTPPassword == model.FakeSetting {
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
@@ -89,6 +98,24 @@ func desanitize(actual, target *model.Config) {
*target.ServiceSettings.SplitKey = *actual.ServiceSettings.SplitKey
}
+ if target.ServiceSettings.GoogleDeveloperKey != nil && *target.ServiceSettings.GoogleDeveloperKey == model.FakeSetting {
+ target.ServiceSettings.GoogleDeveloperKey = actual.ServiceSettings.GoogleDeveloperKey
+ }
+
+ if target.ServiceSettings.GiphySdkKey != nil && *target.ServiceSettings.GiphySdkKey == model.FakeSetting {
+ target.ServiceSettings.GiphySdkKey = actual.ServiceSettings.GiphySdkKey
+ }
+
+ if target.CacheSettings.RedisPassword != nil && *target.CacheSettings.RedisPassword == model.FakeSetting {
+ target.CacheSettings.RedisPassword = actual.CacheSettings.RedisPassword
+ }
+
+ if target.AutoTranslationSettings.LibreTranslate != nil &&
+ target.AutoTranslationSettings.LibreTranslate.APIKey != nil &&
+ *target.AutoTranslationSettings.LibreTranslate.APIKey == model.FakeSetting {
+ target.AutoTranslationSettings.LibreTranslate.APIKey = actual.AutoTranslationSettings.LibreTranslate.APIKey
+ }
+
for id, settings := range target.PluginSettings.Plugins {
for k, v := range settings {
if v == model.FakeSetting {
diff --git a/server/config/utils_test.go b/server/config/utils_test.go
index c7929ca6fa5..ddff6960021 100644
--- a/server/config/utils_test.go
+++ b/server/config/utils_test.go
@@ -4,6 +4,8 @@
package config
import (
+ "fmt"
+ "reflect"
"testing"
"github.com/stretchr/testify/assert"
@@ -25,12 +27,15 @@ func TestDesanitize(t *testing.T) {
actual.LdapSettings.BindPassword = new("bind_password")
actual.FileSettings.PublicLinkSalt = new("public_link_salt")
actual.FileSettings.AmazonS3SecretAccessKey = new("amazon_s3_secret_access_key")
+ actual.FileSettings.ExportAmazonS3SecretAccessKey = new("export_amazon_s3_secret_access_key")
actual.EmailSettings.SMTPPassword = new("smtp_password")
actual.GitLabSettings.Secret = new("secret")
actual.OpenIdSettings.Secret = new("secret")
actual.SqlSettings.DataSource = new("data_source")
actual.SqlSettings.AtRestEncryptKey = new("at_rest_encrypt_key")
actual.ElasticsearchSettings.Password = new("password")
+ actual.ServiceSettings.GoogleDeveloperKey = new("google_developer_key")
+ actual.ServiceSettings.GiphySdkKey = new("giphy_sdk_key")
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica0")
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica1")
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica0")
@@ -53,12 +58,15 @@ func TestDesanitize(t *testing.T) {
target.LdapSettings.BindPassword = model.NewPointer(model.FakeSetting)
target.FileSettings.PublicLinkSalt = model.NewPointer(model.FakeSetting)
target.FileSettings.AmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
+ target.FileSettings.ExportAmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
target.EmailSettings.SMTPPassword = model.NewPointer(model.FakeSetting)
target.GitLabSettings.Secret = model.NewPointer(model.FakeSetting)
target.OpenIdSettings.Secret = model.NewPointer(model.FakeSetting)
target.SqlSettings.DataSource = model.NewPointer(model.FakeSetting)
target.SqlSettings.AtRestEncryptKey = model.NewPointer(model.FakeSetting)
target.ElasticsearchSettings.Password = model.NewPointer(model.FakeSetting)
+ target.ServiceSettings.GoogleDeveloperKey = model.NewPointer(model.FakeSetting)
+ target.ServiceSettings.GiphySdkKey = model.NewPointer(model.FakeSetting)
target.SqlSettings.DataSourceReplicas = []string{model.FakeSetting, model.FakeSetting}
target.SqlSettings.DataSourceSearchReplicas = []string{model.FakeSetting, model.FakeSetting}
target.PluginSettings.Plugins = map[string]map[string]any{
@@ -80,18 +88,104 @@ func TestDesanitize(t *testing.T) {
assert.Equal(t, *actual.LdapSettings.BindPassword, *target.LdapSettings.BindPassword)
assert.Equal(t, *actual.FileSettings.PublicLinkSalt, *target.FileSettings.PublicLinkSalt)
assert.Equal(t, *actual.FileSettings.AmazonS3SecretAccessKey, *target.FileSettings.AmazonS3SecretAccessKey)
+ assert.Equal(t, *actual.FileSettings.ExportAmazonS3SecretAccessKey, *target.FileSettings.ExportAmazonS3SecretAccessKey)
assert.Equal(t, *actual.EmailSettings.SMTPPassword, *target.EmailSettings.SMTPPassword)
assert.Equal(t, *actual.GitLabSettings.Secret, *target.GitLabSettings.Secret)
assert.Equal(t, *actual.OpenIdSettings.Secret, *target.OpenIdSettings.Secret)
assert.Equal(t, *actual.SqlSettings.DataSource, *target.SqlSettings.DataSource)
assert.Equal(t, *actual.SqlSettings.AtRestEncryptKey, *target.SqlSettings.AtRestEncryptKey)
assert.Equal(t, *actual.ElasticsearchSettings.Password, *target.ElasticsearchSettings.Password)
+ assert.Equal(t, *actual.ServiceSettings.GoogleDeveloperKey, *target.ServiceSettings.GoogleDeveloperKey)
+ assert.Equal(t, *actual.ServiceSettings.GiphySdkKey, *target.ServiceSettings.GiphySdkKey)
assert.Equal(t, actual.SqlSettings.DataSourceReplicas, target.SqlSettings.DataSourceReplicas)
assert.Equal(t, actual.SqlSettings.DataSourceSearchReplicas, target.SqlSettings.DataSourceSearchReplicas)
assert.Equal(t, actual.ServiceSettings.SplitKey, target.ServiceSettings.SplitKey)
assert.Equal(t, actual.PluginSettings.Plugins, target.PluginSettings.Plugins)
}
+// TestDesanitizeRemovesAllFakeSettings verifies that every field masked by
+// Sanitize has a corresponding entry in desanitize, so FakeSetting is never
+// written back to stored config. No manual field listing is required: all
+// string fields are pre-populated via reflection so Sanitize will mask any
+// secret regardless of its default value.
+func TestDesanitizeRemovesAllFakeSettings(t *testing.T) {
+ actual := &model.Config{}
+ actual.SetDefaults()
+ populateStrings(reflect.ValueOf(actual), "test-value")
+
+ sanitized := actual.Clone()
+ sanitized.Sanitize(nil, nil)
+
+ desanitize(actual, sanitized)
+
+ assertNoFakeSettings(t, reflect.ValueOf(*sanitized), "Config")
+}
+
+// populateStrings sets every empty string reachable from v to value so that
+// Sanitize will replace it if it is a secret field.
+func populateStrings(v reflect.Value, value string) {
+ switch v.Kind() {
+ case reflect.Pointer:
+ if v.IsNil() && v.CanSet() {
+ v.Set(reflect.New(v.Type().Elem()))
+ }
+ if !v.IsNil() {
+ if v.Elem().Kind() == reflect.String {
+ if v.Elem().String() == "" {
+ v.Elem().SetString(value)
+ }
+ } else {
+ populateStrings(v.Elem(), value)
+ }
+ }
+ case reflect.Struct:
+ for _, sf := range reflect.VisibleFields(v.Type()) {
+ field := v.FieldByIndex(sf.Index)
+ if field.CanSet() {
+ populateStrings(field, value)
+ }
+ }
+ case reflect.Slice:
+ for i := range v.Len() {
+ populateStrings(v.Index(i), value)
+ }
+ }
+}
+
+// assertNoFakeSettings walks v recursively and fails if any string field equals
+// model.FakeSetting, reporting the dotted path of the offending field.
+func assertNoFakeSettings(t *testing.T, v reflect.Value, path string) {
+ t.Helper()
+ switch v.Kind() {
+ case reflect.Pointer:
+ if !v.IsNil() {
+ assertNoFakeSettings(t, v.Elem(), path)
+ }
+ case reflect.Struct:
+ for i := range v.NumField() {
+ assertNoFakeSettings(t, v.Field(i), path+"."+v.Type().Field(i).Name)
+ }
+ case reflect.String:
+ assert.NotEqual(t, model.FakeSetting, v.String(), "FakeSetting persisted at %s after desanitize", path)
+ case reflect.Slice:
+ for i := range v.Len() {
+ assertNoFakeSettings(t, v.Index(i), fmt.Sprintf("%s[%d]", path, i))
+ }
+ case reflect.Map:
+ for _, key := range v.MapKeys() {
+ elem := v.MapIndex(key)
+ if elem.Kind() == reflect.Interface {
+ elem = elem.Elem()
+ }
+ assertNoFakeSettings(t, elem, fmt.Sprintf("%s[%v]", path, key))
+ }
+ case reflect.Interface:
+ if !v.IsNil() {
+ assertNoFakeSettings(t, v.Elem(), path)
+ }
+ }
+}
+
func TestFixInvalidLocales(t *testing.T) {
// utils.TranslationsPreInit errors when TestFixInvalidLocales is run as part of testing the package,
// but doesn't error when the test is run individually.
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 30812f572e9..94809010bc9 100644
--- a/server/enterprise/elasticsearch/opensearch/opensearch.go
+++ b/server/enterprise/elasticsearch/opensearch/opensearch.go
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"slices"
@@ -34,12 +35,20 @@ import (
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
)
-const opensearchMaxVersion = 2
+const opensearchMaxVersion = 3
var (
purgeIndexListAllowedIndexes = []string{common.IndexBaseChannels}
)
+// isIndexNotFound reports whether err is a 404 index_not_found_exception from
+// OpenSearch. This happens when an index has never been created (e.g. no
+// reindex has run yet) and should be treated as an empty result, not an error.
+func isIndexNotFound(err error) bool {
+ var osErr *opensearch.StructError
+ return errors.As(err, &osErr) && osErr.Status == http.StatusNotFound && osErr.Err.Type == "index_not_found_exception"
+}
+
type OpensearchInterfaceImpl struct {
client *opensearchapi.Client
mutex sync.RWMutex
@@ -840,6 +849,9 @@ func (os *OpensearchInterfaceImpl) DeleteChannelPosts(rctx request.CTX, channelI
if err != nil {
return model.NewAppError("Opensearch.DeleteChannelPosts", "ent.elasticsearch.delete_channel_posts.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
+ if len(postIndexes) == 0 {
+ return nil
+ }
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
@@ -881,6 +893,9 @@ func (os *OpensearchInterfaceImpl) UpdatePostsChannelTypeByChannelId(rctx reques
if err != nil {
return model.NewAppError("Opensearch.UpdatePostsChannelTypeByChannelId", "ent.elasticsearch.update_posts_channel_type.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
+ if len(postIndexes) == 0 {
+ return nil
+ }
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
@@ -943,6 +958,9 @@ func (os *OpensearchInterfaceImpl) BackfillPostsChannelType(rctx request.CTX, ch
if err != nil {
return model.NewAppError("Opensearch.BackfillPostsChannelType", "ent.elasticsearch.backfill_posts_channel_type.error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
}
+ if len(postIndexes) == 0 {
+ return nil
+ }
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
@@ -1014,6 +1032,9 @@ func (os *OpensearchInterfaceImpl) DeleteUserPosts(rctx request.CTX, userID stri
if err != nil {
return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
+ if len(postIndexes) == 0 {
+ return nil
+ }
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*os.Platform.Config().ElasticsearchSettings.RequestTimeoutSeconds)*time.Second)
defer cancel()
@@ -2127,6 +2148,9 @@ func (os *OpensearchInterfaceImpl) SearchFiles(channels model.ChannelList, searc
},
})
if err != nil {
+ if isIndexNotFound(err) {
+ return []string{}, nil
+ }
errorStr := "err=" + err.Error()
if *os.Platform.Config().ElasticsearchSettings.Trace == "error" {
errorStr = "Query=" + getJSONOrErrorStr(query) + ", " + errorStr
@@ -2210,6 +2234,9 @@ func (os *OpensearchInterfaceImpl) DeleteUserFiles(rctx request.CTX, userID stri
Body: bytes.NewReader(queryBuf),
})
if err != nil {
+ if isIndexNotFound(err) {
+ return nil
+ }
return model.NewAppError("Opensearch.DeleteUserFiles", "ent.elasticsearch.delete_user_files.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Info("User files deleted", mlog.String("user_id", userID), mlog.Int("deleted", response.Deleted))
@@ -2246,6 +2273,9 @@ func (os *OpensearchInterfaceImpl) DeletePostFiles(rctx request.CTX, postID stri
Body: bytes.NewReader(queryBuf),
})
if err != nil {
+ if isIndexNotFound(err) {
+ return nil
+ }
return model.NewAppError("Opensearch.DeletePostFiles", "ent.elasticsearch.delete_post_files.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Info("Post files deleted", mlog.String("post_id", postID), mlog.Int("deleted", response.Deleted))
@@ -2280,7 +2310,7 @@ func (os *OpensearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime, l
Query: query,
})
if err != nil {
- return model.NewAppError("Opensearch.DeleteUserFiles", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
+ return model.NewAppError("Opensearch.DeleteFilesBatch", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
response, err := os.client.Document.DeleteByQuery(ctx, opensearchapi.DocumentDeleteByQueryReq{
Indices: []string{*os.Platform.Config().ElasticsearchSettings.IndexPrefix + common.IndexBaseFiles},
@@ -2293,7 +2323,10 @@ func (os *OpensearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime, l
},
})
if err != nil {
- return model.NewAppError("Opensearch.DeleteUserPosts", "ent.elasticsearch.delete_user_posts.error", nil, "", http.StatusInternalServerError).Wrap(err)
+ if isIndexNotFound(err) {
+ return nil
+ }
+ return model.NewAppError("Opensearch.DeleteFilesBatch", "ent.elasticsearch.delete_files_batch.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Info("Files batch deleted", mlog.Int("end_time", endTime), mlog.Int("limit", limit), mlog.Int("deleted", response.Deleted))
@@ -2308,7 +2341,7 @@ func checkMaxVersion(ctx context.Context, client *opensearchapi.Client) (string,
major, _, _, esErr := common.GetVersionComponents(resp.Version.Number)
if esErr != nil {
- return "", 0, model.NewAppError("Opensearch.checkMaxVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(err)
+ return "", 0, model.NewAppError("Opensearch.checkMaxVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsOSBackend}, "", http.StatusInternalServerError).Wrap(esErr)
}
if major > opensearchMaxVersion {
diff --git a/server/enterprise/elasticsearch/opensearch/opensearch_test.go b/server/enterprise/elasticsearch/opensearch/opensearch_test.go
index ca5bebd059e..f9e0f5c045d 100644
--- a/server/enterprise/elasticsearch/opensearch/opensearch_test.go
+++ b/server/enterprise/elasticsearch/opensearch/opensearch_test.go
@@ -212,6 +212,59 @@ func (s *OpensearchInterfaceTestSuite) TestSyncBulkIndexChannels() {
})
}
+// TestNoIndexesGracefulHandling verifies that write and search operations
+// return nil/empty (not an error) when no indexes exist yet. This covers the
+// state before any reindex has run: the index templates are present but the
+// actual indexes have never been created.
+func (s *OpensearchInterfaceTestSuite) TestNoIndexesGracefulHandling() {
+ // SetupTest already calls PurgeIndexes, so there are no indexes at this point.
+ impl := s.CommonTestSuite.ESImpl
+ rctx := s.th.Context
+
+ s.Run("BackfillPostsChannelType", func() {
+ appErr := impl.BackfillPostsChannelType(rctx, []string{"channel1", "channel2"}, "O")
+ s.Nil(appErr)
+ })
+
+ s.Run("DeleteChannelPosts", func() {
+ appErr := impl.DeleteChannelPosts(rctx, s.th.BasicChannel.Id)
+ s.Nil(appErr)
+ })
+
+ s.Run("DeleteUserPosts", func() {
+ appErr := impl.DeleteUserPosts(rctx, s.th.BasicUser.Id)
+ s.Nil(appErr)
+ })
+
+ s.Run("UpdatePostsChannelTypeByChannelId", func() {
+ appErr := impl.UpdatePostsChannelTypeByChannelId(rctx, s.th.BasicChannel.Id, "O")
+ s.Nil(appErr)
+ })
+
+ s.Run("SearchFiles", func() {
+ channels := model.ChannelList{s.th.BasicChannel}
+ params := model.ParseSearchParams("test", 0)
+ fileIDs, appErr := impl.SearchFiles(channels, params, 0, 20)
+ s.Nil(appErr)
+ s.Empty(fileIDs)
+ })
+
+ s.Run("DeletePostFiles", func() {
+ appErr := impl.DeletePostFiles(rctx, s.th.BasicPost.Id)
+ s.Nil(appErr)
+ })
+
+ s.Run("DeleteUserFiles", func() {
+ appErr := impl.DeleteUserFiles(rctx, s.th.BasicUser.Id)
+ s.Nil(appErr)
+ })
+
+ s.Run("DeleteFilesBatch", func() {
+ appErr := impl.DeleteFilesBatch(rctx, model.GetMillis(), 1000)
+ s.Nil(appErr)
+ })
+}
+
func (s *OpensearchInterfaceTestSuite) TestTemplateCreationClientError() {
s.Run("Should handle error with CausedBy information from opensearch", func() {
// Invalid template request that will trigger an error with caused_by
diff --git a/server/go.mod b/server/go.mod
index 90e70cd2f3d..8e99d8f4073 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -1,30 +1,33 @@
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/Masterminds/semver/v3 v3.4.0
+ 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.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
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/golang/mock v1.6.0
+ github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/schema v1.4.1
@@ -36,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
@@ -65,84 +68,83 @@ 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.49.0
- golang.org/x/image v0.38.0
- golang.org/x/net v0.52.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.42.0
- golang.org/x/term v0.41.0
- golang.org/x/text v0.35.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
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052 // indirect
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/uuid v1.6.0 // 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
@@ -159,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
@@ -204,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 e7c99b8a9e3..1393f6f20b2 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -12,12 +12,24 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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=
@@ -30,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=
@@ -43,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=
@@ -93,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=
@@ -130,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=
@@ -140,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=
@@ -158,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=
@@ -188,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=
@@ -239,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=
@@ -282,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=
@@ -329,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=
@@ -361,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=
@@ -375,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=
@@ -394,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=
@@ -417,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=
@@ -439,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=
@@ -462,14 +472,16 @@ 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=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -506,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=
@@ -622,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=
@@ -650,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=
@@ -689,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.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/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=
@@ -705,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=
@@ -733,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.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=
@@ -785,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.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/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=
@@ -804,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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+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=
@@ -817,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.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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -834,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=
@@ -854,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=
@@ -901,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=
@@ -913,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 05abb45be52..b826e4c0f33 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."
@@ -2045,10 +2105,6 @@
"id": "api.custom_profile_attributes.invalid_field_patch",
"translation": "invalid User Attribute field patch"
},
- {
- "id": "api.custom_profile_attributes.license_error",
- "translation": "Your license does not support User Attributes."
- },
{
"id": "api.custom_status.disabled",
"translation": "Custom status feature has been disabled. Please contact your system administrator for details."
@@ -2376,17 +2432,17 @@
"id": "api.file.test_connection.app_error",
"translation": "Unable to access the file storage."
},
+ {
+ "id": "api.file.test_connection_auth.app_error",
+ "translation": "Unable to authenticate against the file storage backend. Verify your credentials and authentication settings."
+ },
{
"id": "api.file.test_connection_email_settings_nil.app_error",
- "translation": "Email settings has unset values."
+ "translation": "Email settings have unset values."
},
{
- "id": "api.file.test_connection_s3_auth.app_error",
- "translation": "Unable to connect to S3. Verify your Amazon S3 connection authorization parameters and authentication settings."
- },
- {
- "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_no_bucket.app_error",
+ "translation": "The configured bucket or container does not exist. Verify your file storage configuration and permissions."
},
{
"id": "api.file.test_connection_s3_settings_nil.app_error",
@@ -2944,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."
@@ -3182,10 +3246,6 @@
"id": "api.property_field.delete.no_permission.app_error",
"translation": "You do not have permission to delete this property field."
},
- {
- "id": "api.property_field.delete.protected_via_api.app_error",
- "translation": "Cannot delete a protected property field via API."
- },
{
"id": "api.property_field.get.invalid_target_type.app_error",
"translation": "A valid target_type (system, team, or channel) is required."
@@ -3202,26 +3262,10 @@
"id": "api.property_field.object_type_mismatch.app_error",
"translation": "Property field object type does not match URL."
},
- {
- "id": "api.property_field.patch.cannot_link_existing.app_error",
- "translation": "Cannot set linked_field_id on an existing field. It can only be set at creation time."
- },
{
"id": "api.property_field.patch.legacy_field.app_error",
"translation": "Cannot patch a v1 property field via this API."
},
- {
- "id": "api.property_field.patch.linked_field_change.app_error",
- "translation": "Cannot change link target. Unlink first, then create a new linked field."
- },
- {
- "id": "api.property_field.patch.linked_options_change.app_error",
- "translation": "Cannot modify options of a linked field. Options are inherited from the source."
- },
- {
- "id": "api.property_field.patch.linked_type_change.app_error",
- "translation": "Cannot modify type of a linked field. Type is inherited from the source."
- },
{
"id": "api.property_field.update.no_field_permission.app_error",
"translation": "You do not have permission to edit this property field."
@@ -3230,14 +3274,6 @@
"id": "api.property_field.update.no_options_permission.app_error",
"translation": "You do not have permission to manage options for this property field."
},
- {
- "id": "api.property_field.update.protected_via_api.app_error",
- "translation": "Cannot update a protected property field via API."
- },
- {
- "id": "api.property_value.field_object_type_mismatch.app_error",
- "translation": "One or more property fields do not match the route's object type."
- },
{
"id": "api.property_value.invalid_object_type.app_error",
"translation": "The provided object type is not valid."
@@ -3250,6 +3286,10 @@
"id": "api.property_value.patch.empty_body.app_error",
"translation": "Request body must contain at least one property value update."
},
+ {
+ "id": "api.property_value.patch.field_not_found.app_error",
+ "translation": "Property field {{.FieldID}} was not found in this group."
+ },
{
"id": "api.property_value.patch.invalid_field_id.app_error",
"translation": "One or more field IDs in the request are invalid."
@@ -3266,10 +3306,6 @@
"id": "api.property_value.system_use_dedicated_route.app_error",
"translation": "System values must use the dedicated system values endpoint."
},
- {
- "id": "api.property_value.target_user.forbidden.app_error",
- "translation": "You do not have permission to access property values for another user."
- },
{
"id": "api.property_value.template_no_values.app_error",
"translation": "Template fields cannot have values."
@@ -4686,6 +4722,10 @@
"id": "api.user.demote_user_to_guest.already_guest.app_error",
"translation": "Unable to convert the user to guest because is already a guest."
},
+ {
+ "id": "api.user.demote_user_to_guest.bot_not_allowed.app_error",
+ "translation": "Bot accounts cannot be converted to guest accounts."
+ },
{
"id": "api.user.email_to_ldap.not_available.app_error",
"translation": "AD/LDAP not available on this server."
@@ -5210,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."
@@ -5638,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."
@@ -5702,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."
@@ -5746,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."
@@ -5766,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."
@@ -5878,6 +5970,10 @@
"id": "app.compile_csv_chunks.header_error",
"translation": "Failed to write CSV headers."
},
+ {
+ "id": "app.compile_csv_chunks.write_error",
+ "translation": "Failed to write CSV data."
+ },
{
"id": "app.compile_report_chunks.unsupported_format",
"translation": "Unsupported report format."
@@ -5906,82 +6002,10 @@
"id": "app.custom_group.unique_name",
"translation": "group name is not unique"
},
- {
- "id": "app.custom_profile_attributes.count_property_fields.app_error",
- "translation": "Unable to count the number of fields for the User Attributes group"
- },
- {
- "id": "app.custom_profile_attributes.cpa_group_id.app_error",
- "translation": "Unable to retrieve the User Attributes property group."
- },
- {
- "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error",
- "translation": "Unable to delete User Attribute values for user"
- },
- {
- "id": "app.custom_profile_attributes.get_property_field.app_error",
- "translation": "Unable to get User Attribute field"
- },
- {
- "id": "app.custom_profile_attributes.get_property_value.app_error",
- "translation": "Unable to get User Attribute value"
- },
- {
- "id": "app.custom_profile_attributes.limit_reached.app_error",
- "translation": "User Attributes field limit reached"
- },
- {
- "id": "app.custom_profile_attributes.list_property_values.app_error",
- "translation": "Unable to get User Attribute values"
- },
- {
- "id": "app.custom_profile_attributes.patch_field.app_error",
- "translation": "Unable to patch User Attribute field"
- },
{
"id": "app.custom_profile_attributes.property_field_conversion.app_error",
"translation": "Unable to convert the property field to a User Attribute field"
},
- {
- "id": "app.custom_profile_attributes.property_field_delete.app_error",
- "translation": "Unable to delete User Attribute field"
- },
- {
- "id": "app.custom_profile_attributes.property_field_is_managed.app_error",
- "translation": "Cannot update value for an admin-managed User Attribute field"
- },
- {
- "id": "app.custom_profile_attributes.property_field_is_synced.app_error",
- "translation": "Cannot update value for a synced User Attribute field"
- },
- {
- "id": "app.custom_profile_attributes.property_field_not_found.app_error",
- "translation": "User Attribute field not found"
- },
- {
- "id": "app.custom_profile_attributes.property_field_update.app_error",
- "translation": "Unable to update User Attribute field"
- },
- {
- "id": "app.custom_profile_attributes.property_value_upsert.app_error",
- "translation": "Unable to upsert User Attribute fields"
- },
- {
- "id": "app.custom_profile_attributes.sanitize_and_validate.app_error",
- "translation": "Invalid property value attributes : {{.AttributeName}} ({{.Reason}})."
- },
- {
- "id": "app.custom_profile_attributes.sanitize_and_validate.display_name_too_long.app_error",
- "translation": "CPA field display_name exceeds the maximum length of {{.MaxRunes}} characters."
- },
- {
- "id": "app.custom_profile_attributes.search_property_fields.app_error",
- "translation": "Unable to search User Attribute fields"
- },
- {
- "id": "app.custom_profile_attributes.validate_value.app_error",
- "translation": "Failed to validate property value"
- },
{
"id": "app.data_spillage.assign_reviewer.no_reviewer_field.app_error",
"translation": "No Reviewer ID property field found."
@@ -6384,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."
@@ -7592,6 +7620,10 @@
"id": "app.pap.delete_policy.app_error",
"translation": "Unable to delete access control policy."
},
+ {
+ "id": "app.pap.delete_policy.masked_values",
+ "translation": "You cannot delete this policy because it contains attribute values you do not have permission to view."
+ },
{
"id": "app.pap.expression_to_visual_ast.app_error",
"translation": "Could not genereate visual AST from expression."
@@ -7628,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."
@@ -7636,6 +7672,10 @@
"id": "app.pap.is_ready.app_error",
"translation": "Access control service is not ready."
},
+ {
+ "id": "app.pap.merge_expression.app_error",
+ "translation": "Could not merge policy expression."
+ },
{
"id": "app.pap.missing_attribute.app_error",
"translation": "An attribute is missing from the expression."
@@ -7648,18 +7688,90 @@
"id": "app.pap.query_expression.app_error",
"translation": "Could not query for expression."
},
+ {
+ "id": "app.pap.save_policy.advanced_expression_blocked",
+ "translation": "This rule expression cannot be safely edited while restricted values are present."
+ },
{
"id": "app.pap.save_policy.app_error",
"translation": "Unable to save access control policy."
},
+ {
+ "id": "app.pap.save_policy.invalid_value",
+ "translation": "Invalid value."
+ },
+ {
+ "id": "app.pap.save_policy.masked_condition_deleted",
+ "translation": "You cannot remove a condition that contains attribute values you do not have permission to view."
+ },
+ {
+ "id": "app.pap.save_policy.masked_rule_deleted",
+ "translation": "You cannot remove a rule that contains attribute values you do not have permission to view."
+ },
{
"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."
@@ -7668,6 +7780,10 @@
"id": "app.pap.update_access_control_policies_active.app_error",
"translation": "Could not update active status of access control policies."
},
+ {
+ "id": "app.pap.validate_expression_values.app_error",
+ "translation": "Could not validate policy expression values."
+ },
{
"id": "app.pdp.access_evaluation.app_error",
"translation": "Failed evaluate access control policy."
@@ -7720,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."
@@ -8112,6 +8236,26 @@
"id": "app.prepackged-plugin.invalid_version.app_error",
"translation": "Prepackged plugin version could not be parsed."
},
+ {
+ "id": "app.property.access_denied.app_error",
+ "translation": "You do not have permission to perform this operation."
+ },
+ {
+ "id": "app.property.invalid_access_mode.app_error",
+ "translation": "The access_mode attribute is invalid."
+ },
+ {
+ "id": "app.property.license_error",
+ "translation": "Your license does not support this property group."
+ },
+ {
+ "id": "app.property.not_found.app_error",
+ "translation": "The specified property does not exist."
+ },
+ {
+ "id": "app.property.sync_lock.app_error",
+ "translation": "This property field is managed by external sync and cannot be modified directly."
+ },
{
"id": "app.property_field.count_for_group.app_error",
"translation": "Unable to count property fields for group."
@@ -8124,6 +8268,14 @@
"id": "app.property_field.create.app_error",
"translation": "Unable to create property field."
},
+ {
+ "id": "app.property_field.create.group_limit_reached.app_error",
+ "translation": "The maximum number of property fields for this group has been reached."
+ },
+ {
+ "id": "app.property_field.create.limit_reached.app_error",
+ "translation": "The maximum number of property fields for this object type has been reached."
+ },
{
"id": "app.property_field.create.linked_source_cross_group.app_error",
"translation": "Cannot link to a field in a different group."
@@ -8197,13 +8349,21 @@
"translation": "Unable to get property fields."
},
{
- "id": "app.property_field.get_many.fields_not_found.app_error",
- "translation": "One or more property field IDs were not found in the specified group."
+ "id": "app.property_field.invalid_attrs.app_error",
+ "translation": "Invalid property field attributes."
},
{
"id": "app.property_field.invalid_input.app_error",
"translation": "Invalid input provided."
},
+ {
+ "id": "app.property_field.managed_admin.permission.app_error",
+ "translation": "You do not have permission to mark this property field as admin-managed."
+ },
+ {
+ "id": "app.property_field.not_found.app_error",
+ "translation": "The specified property field does not exist."
+ },
{
"id": "app.property_field.search.app_error",
"translation": "Unable to search property fields."
@@ -8316,10 +8476,34 @@
"id": "app.property_value.upsert.app_error",
"translation": "Unable to upsert property values."
},
+ {
+ "id": "app.property_value.upsert.duplicate_field_id.app_error",
+ "translation": "Duplicate field ID in property value batch."
+ },
+ {
+ "id": "app.property_value.upsert.field_not_found.app_error",
+ "translation": "Property field {{.FieldID}} was not found."
+ },
+ {
+ "id": "app.property_value.upsert.invalid_field_id.app_error",
+ "translation": "Invalid property field ID."
+ },
+ {
+ "id": "app.property_value.upsert.mixed_groups.app_error",
+ "translation": "All property values in a batch must belong to the same property group."
+ },
+ {
+ "id": "app.property_value.upsert.object_type_mismatch.app_error",
+ "translation": "Property field object type does not match the request."
+ },
{
"id": "app.property_value.upsert_many.app_error",
"translation": "Unable to upsert property values."
},
+ {
+ "id": "app.property_value.validate.app_error",
+ "translation": "Property value failed validation."
+ },
{
"id": "app.reaction.bulk_get_for_post_ids.app_error",
"translation": "Unable to get reactions for post."
@@ -8626,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."
@@ -9330,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."
@@ -9806,6 +10002,10 @@
"id": "ent.elasticsearch.delete_file.error",
"translation": "Failed to delete file"
},
+ {
+ "id": "ent.elasticsearch.delete_files_batch.error",
+ "translation": "Failed to delete files batch"
+ },
{
"id": "ent.elasticsearch.delete_post.error",
"translation": "Failed to delete the post"
@@ -10562,6 +10762,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."
@@ -10574,6 +10778,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."
@@ -10594,6 +10806,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."
@@ -10778,6 +11002,10 @@
"id": "model.channel.is_valid.creator_id.app_error",
"translation": "Invalid creator id."
},
+ {
+ "id": "model.channel.is_valid.discoverable.app_error",
+ "translation": "Only private channels can be marked as discoverable."
+ },
{
"id": "model.channel.is_valid.display_name.app_error",
"translation": "Invalid display name."
@@ -10870,6 +11098,50 @@
"id": "model.channel_bookmark.is_valid.update_at.app_error",
"translation": "Update at must be a valid time."
},
+ {
+ "id": "model.channel_join_request.is_valid.channel_id.app_error",
+ "translation": "Invalid channel id."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.create_at.app_error",
+ "translation": "Create at must be a valid time."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.denial_reason.app_error",
+ "translation": "Denial reason is too long."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.denial_reason_status.app_error",
+ "translation": "Denial reason can only be set on a denied join request."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.id.app_error",
+ "translation": "Invalid Id."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.message.app_error",
+ "translation": "Join request message is too long."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.reviewed_by.app_error",
+ "translation": "Invalid reviewer id."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.reviewer.app_error",
+ "translation": "An approved or denied join request must record the reviewer and review time."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.status.app_error",
+ "translation": "Invalid join request status."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.update_at.app_error",
+ "translation": "Update at must be a valid time."
+ },
+ {
+ "id": "model.channel_join_request.is_valid.user_id.app_error",
+ "translation": "Invalid user id."
+ },
{
"id": "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error",
"translation": "Invalid channel-auto-follow-threads value."
@@ -11086,6 +11358,10 @@
"id": "model.config.is_valid.autotranslation.workers.app_error",
"translation": "Workers must be between 1 and 64."
},
+ {
+ "id": "model.config.is_valid.azure_timeout.app_error",
+ "translation": "Invalid timeout value {{.Value}}. Should be a positive number."
+ },
{
"id": "model.config.is_valid.cache_type.app_error",
"translation": "Cache type must be either lru or redis."
@@ -11162,6 +11438,10 @@
"id": "model.config.is_valid.directory.app_error",
"translation": "Invalid Local Storage Directory. Must be a non-empty string."
},
+ {
+ "id": "model.config.is_valid.directory_traversal.app_error",
+ "translation": "Path traversal sequences (\"..\") are not allowed in {{.Setting}}. Found \"{{.Value}}\"."
+ },
{
"id": "model.config.is_valid.directory_whitespace.app_error",
"translation": "Leading or trailing whitespace detected for {{.Setting}}. Found \"{{.Value}}\"."
@@ -11266,9 +11546,13 @@
"id": "model.config.is_valid.export.retention_days_too_low.app_error",
"translation": "Invalid value for RetentionDays. Value should be greater than 0"
},
+ {
+ "id": "model.config.is_valid.export_azure_timeout.app_error",
+ "translation": "Invalid timeout value {{.Value}}. Should be a positive number."
+ },
{
"id": "model.config.is_valid.file_driver.app_error",
- "translation": "Invalid driver name for file settings. Must be 'local' or 'amazons3'."
+ "translation": "Invalid driver name for file settings. Must be 'local', 'amazons3', or 'azureblob'."
},
{
"id": "model.config.is_valid.file_salt.app_error",
@@ -11486,6 +11770,18 @@
"id": "model.config.is_valid.minimum_desktop_app_version.app_error",
"translation": "Invalid version number. Must be a valid semantic version (e.g. 5.0.0)."
},
+ {
+ "id": "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error",
+ "translation": "Invalid Auto Cache Cleanup value. Must be between {{.Min}} and {{.Max}} days."
+ },
+ {
+ "id": "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error",
+ "translation": "Invalid Disconnection Timeout value. Must be between {{.Min}} and {{.Max}} seconds."
+ },
+ {
+ "id": "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error",
+ "translation": "Invalid Offline Persistence Timer value. Must be between {{.Min}} and {{.Max}} hours."
+ },
{
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
"translation": "Invalid domain for move thread settings"
@@ -12698,6 +12994,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."
@@ -12798,6 +13098,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/services/sharedchannel/sync_send.go b/server/platform/services/sharedchannel/sync_send.go
index fcb77c70ac6..c0f900146fb 100644
--- a/server/platform/services/sharedchannel/sync_send.go
+++ b/server/platform/services/sharedchannel/sync_send.go
@@ -6,6 +6,7 @@ package sharedchannel
import (
"context"
"fmt"
+ "slices"
"time"
"github.com/mattermost/mattermost/server/public/model"
@@ -529,8 +530,7 @@ func (scs *Service) notifyRemoteOffline(posts []*model.Post, rc *model.RemoteClu
// range the slice in reverse so the newest posts are visited first; this ensures an ephemeral
// get added where it is mostly likely to be seen.
- for i := len(posts) - 1; i >= 0; i-- {
- post := posts[i]
+ for _, post := range slices.Backward(posts) {
if didNotify := notified[post.UserId]; didNotify {
continue
}
diff --git a/server/platform/shared/filestore/azurestore.go b/server/platform/shared/filestore/azurestore.go
new file mode 100644
index 00000000000..043de68624c
--- /dev/null
+++ b/server/platform/shared/filestore/azurestore.go
@@ -0,0 +1,638 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package filestore
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob"
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
+ "github.com/google/uuid"
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/mlog"
+ pkgerr "github.com/pkg/errors"
+)
+
+// azureBlockSize is the chunk size used when staging block blob uploads.
+// Matches the Azure SDK's default block size for UploadStream and keeps each
+// StageBlock call well under the per-block REST limit (4000 MiB).
+const azureBlockSize = 4 * 1024 * 1024
+
+// AzureFileBackend stores files in Azure Blob Storage. Connections are
+// authenticated with a shared key today; Microsoft Entra ID is a follow-up.
+type AzureFileBackend struct {
+ client *azblob.Client
+ container string
+ pathPrefix string
+ timeout time.Duration
+}
+
+func NewAzureFileBackend(settings FileBackendSettings) (*AzureFileBackend, error) {
+ if err := settings.CheckMandatoryAzureFields(); err != nil {
+ return nil, err
+ }
+
+ credential, err := azblob.NewSharedKeyCredential(settings.AzureStorageAccount, settings.AzureAccessKey)
+ if err != nil {
+ return nil, pkgerr.Wrap(err, "failed to create azure shared key credential")
+ }
+
+ scheme := "https"
+ if !settings.AzureSSL {
+ scheme = "http"
+ }
+
+ var serviceURL string
+ if settings.AzureEndpoint == "" {
+ // vhost-style production endpoint (Azure commercial cloud).
+ serviceURL = fmt.Sprintf("%s://%s.blob.core.windows.net/", scheme, settings.AzureStorageAccount)
+ } else {
+ // Path-style endpoint where the account is part of the URL path
+ // rather than the hostname. This covers Azurite and custom hosts
+ // (reverse proxies, gateways) that expose Azure Blob Storage
+ // without per-account DNS. Sovereign clouds (Azure Government,
+ // Azure China) use vhost-style URLs and are not supported via
+ // this setting; they require their own endpoint plumbing.
+ serviceURL = fmt.Sprintf("%s://%s/%s/", scheme, strings.Trim(settings.AzureEndpoint, "/"), settings.AzureStorageAccount)
+ }
+
+ var clientOptions *azblob.ClientOptions
+ if settings.SkipVerify {
+ // Mirror the S3 backend: when the admin opts into skipping TLS
+ // verification, plumb a custom transport into the SDK so the toggle
+ // actually takes effect for Azure too.
+ clientOptions = &azblob.ClientOptions{
+ ClientOptions: azcore.ClientOptions{
+ Transport: &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
+ },
+ },
+ }
+ }
+
+ client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, clientOptions)
+ if err != nil {
+ return nil, pkgerr.Wrap(err, "failed to create azure blob client")
+ }
+
+ // Config.IsValid rejects non-positive timeouts before they reach this
+ // constructor, but direct callers (tests, library users that build a
+ // FileBackendSettings by hand) can still slip a zero or negative value
+ // in. Fall back to a sane default in that case, and log loudly enough
+ // for the substitution to show up if it ever happens in production.
+ timeout := time.Duration(settings.AzureRequestTimeoutMilliseconds) * time.Millisecond
+ if timeout <= 0 {
+ mlog.Warn("AzureRequestTimeoutMilliseconds is non-positive; falling back to 30s default",
+ mlog.Int("value", int(settings.AzureRequestTimeoutMilliseconds)))
+ timeout = 30 * time.Second
+ }
+
+ return &AzureFileBackend{
+ client: client,
+ container: settings.AzureContainer,
+ pathPrefix: settings.AzurePathPrefix,
+ timeout: timeout,
+ }, nil
+}
+
+func (b *AzureFileBackend) DriverName() string {
+ return driverAzure
+}
+
+// prefix joins the configured pathPrefix and the caller-supplied path.
+// Using a plain path.Join, a value like "foo/../../secret" can escape
+// the prefix entirely, so we compute the join and verify the result is
+// the prefix directory itself or a descendant of it. The descendant check
+// requires a path-separator boundary so a prefix of "mattermost" does not
+// match a sibling like "mattermost-evil/...". If the joined path escapes,
+// we fall back to joining the prefix with path.Base, which may drop any
+// intermediate directories the caller intended.
+func (b *AzureFileBackend) prefix(p string) string {
+ joined := path.Join(b.pathPrefix, p)
+ if b.pathPrefix == "" {
+ return joined
+ }
+
+ cleanPrefix := strings.TrimSuffix(path.Clean(b.pathPrefix), "/")
+ if joined == cleanPrefix || strings.HasPrefix(joined, cleanPrefix+"/") {
+ return joined
+ }
+ return path.Join(cleanPrefix, path.Base(p))
+}
+
+func (b *AzureFileBackend) newBlobClient(p string) *blob.Client {
+ return b.client.ServiceClient().NewContainerClient(b.container).NewBlobClient(b.prefix(p))
+}
+
+func (b *AzureFileBackend) newBlockBlobClient(p string) *blockblob.Client {
+ return b.client.ServiceClient().NewContainerClient(b.container).NewBlockBlobClient(b.prefix(p))
+}
+
+func (b *AzureFileBackend) newContainerClient() *container.Client {
+ return b.client.ServiceClient().NewContainerClient(b.container)
+}
+
+// TestConnection probes the configured container and reports the outcome
+// using the typed errors shared with the other backends. Container
+// creation is deliberately out of scope here - callers (Server.Start)
+// decide whether to provision a missing container via MakeContainer.
+// That separation keeps a typo in the System Console from silently
+// provisioning an unwanted container, and matches the S3 contract where
+// TestConnection returns FileBackendNoBucketError and MakeBucket is an
+// explicit call.
+func (b *AzureFileBackend) TestConnection() error {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ _, err := b.newContainerClient().GetProperties(ctx, nil)
+ if err == nil {
+ return nil
+ }
+ if bloberror.HasCode(err, bloberror.ContainerNotFound) {
+ return &FileBackendNoBucketError{Err: pkgerr.Wrapf(err, "azure container %q does not exist", b.container)}
+ }
+ if isAzureAuthError(err) {
+ return &FileBackendAuthError{Err: pkgerr.Wrap(err, "unable to authenticate against azure blob storage")}
+ }
+ return pkgerr.Wrap(err, "unable to connect to azure blob storage")
+}
+
+// MakeContainer creates the configured container. Mirrors S3FileBackend.MakeBucket
+// so callers can opt into container provisioning explicitly. An already-existing
+// container is treated as success so that concurrent boots (two nodes racing
+// through TestConnection plus MakeContainer) both converge cleanly.
+func (b *AzureFileBackend) MakeContainer() error {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ if _, err := b.newContainerClient().Create(ctx, nil); err != nil {
+ if bloberror.HasCode(err, bloberror.ContainerAlreadyExists) {
+ return nil
+ }
+ return pkgerr.Wrapf(err, "unable to create azure container %q", b.container)
+ }
+ return nil
+}
+
+func (b *AzureFileBackend) Reader(p string) (ReadCloseSeeker, error) {
+ // Arm the deadline *before* the first network call, then hand the same
+ // timer to the returned reader on success. The previous code only set up
+ // the timer on the happy path, which left GetProperties running against a
+ // no-deadline context.
+ ctx, cancel := context.WithCancel(context.Background())
+ timer := time.AfterFunc(b.timeout, cancel)
+ blobClient := b.newBlobClient(p)
+
+ props, err := blobClient.GetProperties(ctx, nil)
+ if err != nil {
+ timer.Stop()
+ cancel()
+ return nil, pkgerr.Wrapf(err, "unable to read file %q", p)
+ }
+ if props.ContentLength == nil {
+ timer.Stop()
+ cancel()
+ return nil, pkgerr.Errorf("missing content length for %q", p)
+ }
+
+ return &azureRangeReader{
+ ctx: ctx,
+ cancel: cancel,
+ timer: timer,
+ blobClient: blobClient,
+ size: *props.ContentLength,
+ }, nil
+}
+
+func (b *AzureFileBackend) ReadFile(p string) ([]byte, error) {
+ r, err := b.Reader(p)
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ return io.ReadAll(r)
+}
+
+func (b *AzureFileBackend) FileExists(p string) (bool, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ _, err := b.newBlobClient(p).GetProperties(ctx, nil)
+ if err != nil {
+ if bloberror.HasCode(err, bloberror.BlobNotFound) {
+ return false, nil
+ }
+ return false, pkgerr.Wrapf(err, "unable to check existence of %q", p)
+ }
+ return true, nil
+}
+
+func (b *AzureFileBackend) FileSize(p string) (int64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ props, err := b.newBlobClient(p).GetProperties(ctx, nil)
+ if err != nil {
+ return 0, pkgerr.Wrapf(err, "unable to get size of %q", p)
+ }
+
+ return model.SafeDereference(props.ContentLength), nil
+}
+
+func (b *AzureFileBackend) FileModTime(p string) (time.Time, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ props, err := b.newBlobClient(p).GetProperties(ctx, nil)
+ if err != nil {
+ return time.Time{}, pkgerr.Wrapf(err, "unable to get modification time of %q", p)
+ }
+
+ return model.SafeDereference(props.LastModified), nil
+}
+
+// CopyFile copies via StartCopyFromURL and polls the resulting blob's copy
+// status until it succeeds, matching the synchronous semantics that the
+// FileBackend interface (and the S3 driver via ComposeObject) provides.
+func (b *AzureFileBackend) CopyFile(oldPath, newPath string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ src := b.newBlobClient(oldPath).URL()
+ dst := b.newBlockBlobClient(newPath)
+ if _, err := dst.StartCopyFromURL(ctx, src, nil); err != nil {
+ return pkgerr.Wrapf(err, "unable to copy %q to %q", oldPath, newPath)
+ }
+
+ // Poll until the copy reports success. For server-to-server copies within
+ // the same account this is typically synchronous, but the API is
+ // asynchronous in general, so we wait.
+ for {
+ props, err := dst.GetProperties(ctx, nil)
+ if err != nil {
+ return pkgerr.Wrapf(err, "unable to read copy status for %q", newPath)
+ }
+ if props.CopyStatus == nil {
+ return nil
+ }
+ switch *props.CopyStatus {
+ case blob.CopyStatusTypeSuccess:
+ return nil
+ case blob.CopyStatusTypeFailed, blob.CopyStatusTypeAborted:
+ desc := model.SafeDereference(props.CopyStatusDescription)
+ return pkgerr.Errorf("azure copy from %q to %q ended in status %q: %q", oldPath, newPath, *props.CopyStatus, desc)
+ }
+ select {
+ case <-ctx.Done():
+ return pkgerr.Wrapf(ctx.Err(), "azure copy from %q to %q did not complete in time", oldPath, newPath)
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+}
+
+func (b *AzureFileBackend) MoveFile(oldPath, newPath string) error {
+ if err := b.CopyFile(oldPath, newPath); err != nil {
+ return err
+ }
+ return b.RemoveFile(oldPath)
+}
+
+func (b *AzureFileBackend) WriteFile(fr io.Reader, p string) (int64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+ return b.WriteFileContext(ctx, fr, p)
+}
+
+// stageBlocks reads fr in azureBlockSize chunks and stages each chunk as a
+// block under a fresh ID. Returns the IDs of the newly staged blocks (in
+// order) and the total byte count. The caller is responsible for committing
+// the block list.
+func (b *AzureFileBackend) stageBlocks(ctx context.Context, bb *blockblob.Client, fr io.Reader, p string) ([]string, int64, error) {
+ buf := make([]byte, azureBlockSize)
+ var ids []string
+ var total int64
+
+ for {
+ n, err := io.ReadFull(fr, buf)
+ if n > 0 {
+ id, idErr := newAzureBlockID()
+ if idErr != nil {
+ return nil, 0, pkgerr.Wrap(idErr, "failed to generate azure block id")
+ }
+ if _, sbErr := bb.StageBlock(ctx, id, &readSeekNopCloser{Reader: bytes.NewReader(buf[:n])}, nil); sbErr != nil {
+ return nil, 0, pkgerr.Wrapf(sbErr, "unable to stage block for %q", p)
+ }
+ ids = append(ids, id)
+ total += int64(n)
+ }
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ break
+ }
+ if err != nil {
+ return nil, 0, pkgerr.Wrap(err, "failed to read input")
+ }
+ }
+ return ids, total, nil
+}
+
+// WriteFileContext stages the body in fixed-size blocks and commits a fresh
+// block list. It deliberately does not use the SDK's UploadStream helper:
+// UploadStream's small-payload fast path falls back to single-shot PutBlob,
+// which leaves the resulting blob with no committed block list. A subsequent
+// AppendFile that calls CommitBlockList on that blob would then clobber its
+// content. Routing every WriteFile through StageBlock + CommitBlockList keeps
+// AppendFile correct regardless of payload size.
+//
+// The caller's context governs the entire upload - no inner timeout is added.
+// TryWriteFileContext (filesstore.go) relies on this to let long-running
+// callers like message-export bulk writes opt out of the per-operation
+// timeout that WriteFile applies by default.
+func (b *AzureFileBackend) WriteFileContext(ctx context.Context, fr io.Reader, p string) (int64, error) {
+ bb := b.newBlockBlobClient(p)
+ blockIDs, total, err := b.stageBlocks(ctx, bb, fr, p)
+ if err != nil {
+ return 0, err
+ }
+
+ if len(blockIDs) == 0 {
+ // Empty input - still need to materialize an empty blob with a
+ // committed block list so AppendFile can target it.
+ id, idErr := newAzureBlockID()
+ if idErr != nil {
+ return 0, pkgerr.Wrap(idErr, "failed to generate azure block id")
+ }
+ if _, sbErr := bb.StageBlock(ctx, id, &readSeekNopCloser{Reader: bytes.NewReader(nil)}, nil); sbErr != nil {
+ return 0, pkgerr.Wrapf(sbErr, "unable to stage empty block for %q", p)
+ }
+ blockIDs = append(blockIDs, id)
+ }
+
+ if _, err := bb.CommitBlockList(ctx, blockIDs, nil); err != nil {
+ return 0, pkgerr.Wrapf(err, "unable to commit block list for %q", p)
+ }
+ return total, nil
+}
+
+// AppendFile stages the new chunk as one or more blocks and commits the
+// existing committed block list plus the newly staged IDs. Each AppendFile
+// call uploads the new bytes exactly once - no re-download, no
+// re-concatenate, no re-upload of the prior contents. The S3-style contract
+// is preserved: returns an error if the target blob does not yet exist;
+// returns the number of bytes appended (not the resulting total size).
+//
+// Refuses to append to a blob that has content but no committed block list
+// (i.e. was uploaded via Put Blob by another tool - Azure portal, azcopy,
+// a migration script). Committing a new block list against such a blob
+// would replace the existing content with only the appended bytes, so
+// failing loud beats silent data loss.
+func (b *AzureFileBackend) AppendFile(fr io.Reader, p string) (int64, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ bb := b.newBlockBlobClient(p)
+
+ listResp, err := bb.GetBlockList(ctx, blockblob.BlockListTypeCommitted, nil)
+ if err != nil {
+ return 0, pkgerr.Wrapf(err, "unable to find file %q to append data", p)
+ }
+
+ var existingIDs []string
+ if listResp.BlockList.CommittedBlocks != nil {
+ for _, blk := range listResp.BlockList.CommittedBlocks {
+ if blk.Name != nil {
+ existingIDs = append(existingIDs, *blk.Name)
+ }
+ }
+ }
+
+ if len(existingIDs) == 0 {
+ props, propsErr := bb.GetProperties(ctx, nil)
+ if propsErr != nil {
+ return 0, pkgerr.Wrapf(propsErr, "unable to inspect %q before append", p)
+ }
+ if model.SafeDereference(props.ContentLength) > 0 {
+ return 0, pkgerr.Errorf("refusing to append to %q: blob has content but no committed block list (likely written via Put Blob by another tool)", p)
+ }
+ }
+
+ newIDs, total, err := b.stageBlocks(ctx, bb, fr, p)
+ if err != nil {
+ return 0, err
+ }
+
+ if _, err := bb.CommitBlockList(ctx, append(existingIDs, newIDs...), nil); err != nil {
+ return 0, pkgerr.Wrapf(err, "unable to commit block list for %q", p)
+ }
+ return total, nil
+}
+
+func (b *AzureFileBackend) RemoveFile(p string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ _, err := b.newBlobClient(p).Delete(ctx, nil)
+ if err != nil && !bloberror.HasCode(err, bloberror.BlobNotFound) {
+ return pkgerr.Wrapf(err, "unable to remove file %q", p)
+ }
+ return nil
+}
+
+func (b *AzureFileBackend) ListDirectory(p string) ([]string, error) {
+ prefix := b.prefix(p)
+ if prefix != "" && !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ pager := b.newContainerClient().NewListBlobsHierarchyPager("/", &container.ListBlobsHierarchyOptions{
+ Prefix: &prefix,
+ })
+
+ var entries []string
+ for pager.More() {
+ page, err := pager.NextPage(ctx)
+ if err != nil {
+ return nil, pkgerr.Wrapf(err, "unable to list directory %q", p)
+ }
+ for _, item := range page.Segment.BlobItems {
+ if item.Name == nil {
+ continue
+ }
+ name := strings.TrimPrefix(*item.Name, b.pathPrefix)
+ name = strings.TrimPrefix(name, "/")
+ entries = append(entries, name)
+ }
+ for _, item := range page.Segment.BlobPrefixes {
+ if item.Name == nil {
+ continue
+ }
+ name := strings.TrimPrefix(*item.Name, b.pathPrefix)
+ name = strings.TrimPrefix(name, "/")
+ name = strings.TrimSuffix(name, "/")
+ entries = append(entries, name)
+ }
+ }
+ return entries, nil
+}
+
+func (b *AzureFileBackend) ListDirectoryRecursively(p string) ([]string, error) {
+ prefix := b.prefix(p)
+ if prefix != "" && !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), b.timeout)
+ defer cancel()
+
+ pager := b.newContainerClient().NewListBlobsFlatPager(&container.ListBlobsFlatOptions{
+ Prefix: &prefix,
+ })
+
+ var entries []string
+ for pager.More() {
+ page, err := pager.NextPage(ctx)
+ if err != nil {
+ return nil, pkgerr.Wrapf(err, "unable to list directory %q recursively", p)
+ }
+ for _, item := range page.Segment.BlobItems {
+ if item.Name == nil {
+ continue
+ }
+ name := strings.TrimPrefix(*item.Name, b.pathPrefix)
+ name = strings.TrimPrefix(name, "/")
+ entries = append(entries, name)
+ }
+ }
+ return entries, nil
+}
+
+func (b *AzureFileBackend) RemoveDirectory(p string) error {
+ files, err := b.ListDirectoryRecursively(p)
+ if err != nil {
+ return err
+ }
+ for _, f := range files {
+ if err := b.RemoveFile(f); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (b *AzureFileBackend) ZipReader(p string, deflate bool) (io.ReadCloser, error) {
+ method := zip.Store
+ if deflate {
+ method = zip.Deflate
+ }
+
+ pr, pw := io.Pipe()
+ go func() {
+ zw := zip.NewWriter(pw)
+ err := b.writeZip(zw, p, method)
+ if cerr := zw.Close(); err == nil {
+ err = cerr
+ }
+ pw.CloseWithError(err)
+ }()
+ return pr, nil
+}
+
+func (b *AzureFileBackend) writeZip(zw *zip.Writer, p string, method uint16) error {
+ exists, err := b.FileExists(p)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return b.writeZipEntry(zw, p, path.Base(p), method)
+ }
+
+ files, err := b.ListDirectoryRecursively(p)
+ if err != nil {
+ return err
+ }
+ prefix := strings.TrimSuffix(p, "/") + "/"
+ for _, f := range files {
+ rel := strings.TrimPrefix(f, prefix)
+ if err := b.writeZipEntry(zw, f, rel, method); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (b *AzureFileBackend) writeZipEntry(zw *zip.Writer, blobPath, name string, method uint16) error {
+ r, err := b.Reader(blobPath)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ header := &zip.FileHeader{Name: name, Method: method}
+ header.SetMode(0644)
+ w, err := zw.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, r)
+ return err
+}
+
+// readSeekNopCloser adapts a Reader+Seeker into a ReadSeekCloser without
+// closing the underlying source. The Azure SDK's StageBlock signature
+// requires a ReadSeekCloser.
+type readSeekNopCloser struct {
+ io.Reader
+}
+
+func (r *readSeekNopCloser) Seek(offset int64, whence int) (int64, error) {
+ return r.Reader.(io.Seeker).Seek(offset, whence)
+}
+
+func (r *readSeekNopCloser) Close() error { return nil }
+
+// newAzureBlockID returns a fresh base64-encoded 16-byte random block ID,
+// generated with github.com/google/uuid - the same library azblob uses
+// internally for the block IDs it produces in UploadStream. All committed
+// blocks in a single blob must share the same decoded length, so callers
+// must use this for both WriteFile and AppendFile staging.
+//
+// Per https://learn.microsoft.com/en-us/rest/api/storageservices/put-block:
+//
+// For a given blob, all block IDs must be the same length. If a block is
+// uploaded with a block ID of a different length than the block IDs for any
+// existing uncommitted blocks, the service returns error response code 400
+// (Bad Request).
+func newAzureBlockID() (string, error) {
+ u, err := uuid.NewRandom()
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(u[:]), nil
+}
+
+func isAzureAuthError(err error) bool {
+ if err == nil {
+ return false
+ }
+ return bloberror.HasCode(err, bloberror.AuthenticationFailed) ||
+ bloberror.HasCode(err, bloberror.AuthorizationFailure) ||
+ bloberror.HasCode(err, bloberror.InvalidAuthenticationInfo)
+}
diff --git a/server/platform/shared/filestore/azurestore_rangereader.go b/server/platform/shared/filestore/azurestore_rangereader.go
new file mode 100644
index 00000000000..7e97a19d012
--- /dev/null
+++ b/server/platform/shared/filestore/azurestore_rangereader.go
@@ -0,0 +1,160 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package filestore
+
+import (
+ "context"
+ "io"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
+ pkgerr "github.com/pkg/errors"
+)
+
+// blobDownloader is the subset of *blob.Client used by azureRangeReader.
+// Defined as an interface so tests can substitute a fake without standing up
+// a real Azure client.
+type blobDownloader interface {
+ DownloadStream(ctx context.Context, opts *blob.DownloadStreamOptions) (blob.DownloadStreamResponse, error)
+}
+
+// azureRangeReader is a seekable reader over an Azure blob, backed by HTTP
+// Range requests. A stream is opened lazily on the first Read at the current
+// offset; Seek closes any open stream so the next Read re-opens it from the
+// new offset. The context is cancelled either by Close or by a timer set to
+// the backend's configured timeout, matching the S3 driver's behavior.
+//
+// Callers constructing this struct directly must set ctx, cancel and timer;
+// the methods below assume all three are non-nil.
+type azureRangeReader struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ timer *time.Timer
+ blobClient blobDownloader
+ size int64
+ offset int64
+ body io.ReadCloser
+}
+
+// Compile-time guarantees that azureRangeReader satisfies the interfaces the
+// app layer relies on. zip.NewReader requires io.ReaderAt for archive
+// readers (e.g. the bulk-import worker), and the import worker also
+// type-asserts to a CancelTimeout interface for long-running operations.
+var (
+ _ ReadCloseSeeker = (*azureRangeReader)(nil)
+ _ io.ReaderAt = (*azureRangeReader)(nil)
+)
+
+func (r *azureRangeReader) Read(p []byte) (int, error) {
+ if r.offset >= r.size {
+ return 0, io.EOF
+ }
+ if r.body == nil {
+ resp, err := r.blobClient.DownloadStream(r.ctx, &blob.DownloadStreamOptions{
+ Range: blob.HTTPRange{Offset: r.offset, Count: 0},
+ })
+ if err != nil {
+ return 0, pkgerr.Wrap(err, "failed to open azure range stream")
+ }
+ r.body = resp.Body
+ }
+ n, err := r.body.Read(p)
+ r.offset += int64(n)
+ if err == nil {
+ return n, nil
+ }
+ // Close+drop the body so the caller (or a retry) doesn't read more
+ // from a half-consumed stream, and so Close stays idempotent.
+ r.body.Close()
+ r.body = nil
+ if err == io.EOF && r.offset < r.size {
+ // The remote stream ended before we reached the blob's content
+ // length. Surface that as a truncation rather than a clean EOF
+ // so the caller doesn't accept a partial blob as complete.
+ return n, io.ErrUnexpectedEOF
+ }
+ return n, err
+}
+
+func (r *azureRangeReader) Seek(offset int64, whence int) (int64, error) {
+ var abs int64
+ switch whence {
+ case io.SeekStart:
+ abs = offset
+ case io.SeekCurrent:
+ abs = r.offset + offset
+ case io.SeekEnd:
+ abs = r.size + offset
+ default:
+ return 0, pkgerr.Errorf("invalid whence: %d", whence)
+ }
+ if abs < 0 {
+ return 0, pkgerr.Errorf("negative position: %d", abs)
+ }
+ if abs == r.offset {
+ return abs, nil
+ }
+ if r.body != nil {
+ r.body.Close()
+ r.body = nil
+ }
+ r.offset = abs
+ return abs, nil
+}
+
+// ReadAt reads len(p) bytes starting at offset off. Each call issues a
+// dedicated ranged DownloadStream - calls do not affect the cursor that Read
+// uses, matching the io.ReaderAt contract. This is what the bulk-import
+// worker needs to feed zip.NewReader on Azure-backed deployments.
+func (r *azureRangeReader) ReadAt(p []byte, off int64) (int, error) {
+ if off < 0 {
+ return 0, pkgerr.Errorf("negative offset: %d", off)
+ }
+ if off >= r.size {
+ return 0, io.EOF
+ }
+ count := int64(len(p))
+ if remaining := r.size - off; count > remaining {
+ count = remaining
+ }
+ resp, err := r.blobClient.DownloadStream(r.ctx, &blob.DownloadStreamOptions{
+ Range: blob.HTTPRange{Offset: off, Count: count},
+ })
+ if err != nil {
+ return 0, pkgerr.Wrap(err, "failed to open azure range stream")
+ }
+ defer resp.Body.Close()
+ n, err := io.ReadFull(resp.Body, p[:count])
+ // io.ReadFull returns ErrUnexpectedEOF when the stream terminates
+ // before count bytes arrive. Only collapse it to io.EOF when we
+ // actually filled the buffer and consumed the blob to the end -
+ // otherwise it is a real truncation that needs to surface so
+ // callers like zip.NewReader do not accept partial content.
+ if err == io.ErrUnexpectedEOF && int64(n) == count && off+int64(n) == r.size {
+ return n, io.EOF
+ }
+ if err == nil && off+int64(n) == r.size {
+ return n, io.EOF
+ }
+ return n, err
+}
+
+// CancelTimeout stops the timer that bounds this reader's lifetime, so
+// long-running consumers (e.g. the bulk-import worker, which can run far
+// past the default per-operation timeout) can opt out of the automatic
+// cancellation. Returns false if the timer has already fired.
+func (r *azureRangeReader) CancelTimeout() bool {
+ return r.timer.Stop()
+}
+
+func (r *azureRangeReader) Close() error {
+ if r.timer != nil {
+ r.timer.Stop()
+ }
+ r.cancel()
+ if r.body != nil {
+ return r.body.Close()
+ }
+ return nil
+}
diff --git a/server/platform/shared/filestore/azurestore_rangereader_test.go b/server/platform/shared/filestore/azurestore_rangereader_test.go
new file mode 100644
index 00000000000..8032fb8062c
--- /dev/null
+++ b/server/platform/shared/filestore/azurestore_rangereader_test.go
@@ -0,0 +1,361 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package filestore
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
+ "github.com/stretchr/testify/require"
+)
+
+// trackingReadCloser wraps a Reader and records whether Close was called.
+type trackingReadCloser struct {
+ io.Reader
+ closed bool
+}
+
+func (t *trackingReadCloser) Close() error {
+ t.closed = true
+ return nil
+}
+
+// fakeDownloader serves bytes from an in-memory blob, records every
+// DownloadStream call's Range, and hands out trackingReadClosers so tests
+// can assert close-on-Seek behavior. An optional err short-circuits responses.
+type fakeDownloader struct {
+ data []byte
+ calls []blob.HTTPRange
+ bodies []*trackingReadCloser
+ err error
+}
+
+func (f *fakeDownloader) DownloadStream(_ context.Context, opts *blob.DownloadStreamOptions) (blob.DownloadStreamResponse, error) {
+ if f.err != nil {
+ return blob.DownloadStreamResponse{}, f.err
+ }
+ var rng blob.HTTPRange
+ if opts != nil {
+ rng = opts.Range
+ }
+ f.calls = append(f.calls, rng)
+
+ start := min(max(rng.Offset, 0), int64(len(f.data)))
+ end := int64(len(f.data))
+ if rng.Count > 0 && start+rng.Count < end {
+ end = start + rng.Count
+ }
+ body := &trackingReadCloser{Reader: bytes.NewReader(f.data[start:end])}
+ f.bodies = append(f.bodies, body)
+
+ return blob.DownloadStreamResponse{
+ DownloadResponse: blob.DownloadResponse{Body: body},
+ }, nil
+}
+
+// newTestReader returns an azureRangeReader wired to the given fake, with a
+// long-lived timer so it never fires during the test. Caller must Close it.
+func newTestReader(t *testing.T, fake *fakeDownloader, size int64) *azureRangeReader {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ timer := time.AfterFunc(time.Hour, cancel)
+ return &azureRangeReader{
+ ctx: ctx,
+ cancel: cancel,
+ timer: timer,
+ blobClient: fake,
+ size: size,
+ }
+}
+
+func TestRead(t *testing.T) {
+ t.Run("returns EOF at end of blob without downloading", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("hello")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := r.Seek(0, io.SeekEnd)
+ require.NoError(t, err)
+
+ n, err := r.Read(make([]byte, 4))
+ require.Equal(t, 0, n)
+ require.Equal(t, io.EOF, err)
+ require.Empty(t, fake.calls, "no download should be issued past end of blob")
+ })
+
+ t.Run("opens stream at current offset", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("hello world")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := r.Seek(6, io.SeekStart)
+ require.NoError(t, err)
+
+ buf := make([]byte, 5)
+ n, err := io.ReadFull(r, buf)
+ require.NoError(t, err)
+ require.Equal(t, 5, n)
+ require.Equal(t, "world", string(buf))
+
+ require.Len(t, fake.calls, 1)
+ require.Equal(t, blob.HTTPRange{Offset: 6, Count: 0}, fake.calls[0])
+ })
+
+ t.Run("sequential reads reuse the open stream", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefghij")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ buf := make([]byte, 4)
+ _, err := io.ReadFull(r, buf)
+ require.NoError(t, err)
+ require.Equal(t, "abcd", string(buf))
+
+ _, err = io.ReadFull(r, buf)
+ require.NoError(t, err)
+ require.Equal(t, "efgh", string(buf))
+
+ require.Len(t, fake.calls, 1, "sequential reads must reuse the open stream")
+ })
+
+ t.Run("propagates download errors", func(t *testing.T) {
+ wantErr := errors.New("boom")
+ fake := &fakeDownloader{data: []byte("xyz"), err: wantErr}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := r.Read(make([]byte, 1))
+ require.ErrorIs(t, err, wantErr)
+ })
+
+ t.Run("surfaces truncation when stream EOFs before the blob ends", func(t *testing.T) {
+ // Promised size is larger than what the fake actually serves,
+ // so the body eventually returns io.EOF while r.offset < r.size.
+ // bytes.Reader returns its content + nil first, then 0 + EOF on
+ // the next call, so we drain the bytes before the truncation
+ // is observable.
+ fake := &fakeDownloader{data: []byte("hello")}
+ r := newTestReader(t, fake, int64(len(fake.data))+10)
+ defer r.Close()
+
+ buf := make([]byte, 16)
+ n, err := r.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 5, n)
+
+ // Second call hits EOF from the body before we've reached r.size,
+ // so the reader must surface that as a truncation.
+ n, err = r.Read(buf)
+ require.Equal(t, 0, n)
+ require.ErrorIs(t, err, io.ErrUnexpectedEOF)
+ require.Nil(t, r.body, "body must be released after a truncation error")
+ })
+}
+
+func TestReadAt(t *testing.T) {
+ t.Run("reads at the given offset without disturbing the cursor", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefghij")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ // Advance the streaming cursor first.
+ _, err := io.ReadFull(r, make([]byte, 3))
+ require.NoError(t, err)
+ require.Equal(t, int64(3), r.offset)
+
+ buf := make([]byte, 4)
+ n, err := r.ReadAt(buf, 5)
+ require.NoError(t, err)
+ require.Equal(t, 4, n)
+ require.Equal(t, "fghi", string(buf))
+ require.Equal(t, int64(3), r.offset, "ReadAt must not touch the streaming offset")
+ })
+
+ t.Run("returns io.EOF when the read lands exactly at the end of the blob", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefghij")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ buf := make([]byte, 3)
+ n, err := r.ReadAt(buf, 7)
+ require.Equal(t, io.EOF, err)
+ require.Equal(t, 3, n)
+ require.Equal(t, "hij", string(buf))
+ })
+
+ t.Run("returns io.EOF when off is past the size", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefghij")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ n, err := r.ReadAt(make([]byte, 4), 100)
+ require.Equal(t, 0, n)
+ require.Equal(t, io.EOF, err)
+ require.Empty(t, fake.calls, "no download should be issued past end of blob")
+ })
+
+ t.Run("rejects negative offsets", func(t *testing.T) {
+ r := newTestReader(t, &fakeDownloader{}, 10)
+ defer r.Close()
+
+ _, err := r.ReadAt(make([]byte, 1), -1)
+ require.Error(t, err)
+ })
+
+ t.Run("propagates download errors", func(t *testing.T) {
+ wantErr := errors.New("boom")
+ fake := &fakeDownloader{data: []byte("xyz"), err: wantErr}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := r.ReadAt(make([]byte, 1), 0)
+ require.ErrorIs(t, err, wantErr)
+ })
+
+ t.Run("surfaces truncation when stream falls short of the requested count", func(t *testing.T) {
+ // Promised size exceeds the fake's actual data so ReadFull
+ // sees the body terminate before count bytes arrived. That
+ // must surface as ErrUnexpectedEOF, not a clean EOF.
+ fake := &fakeDownloader{data: []byte("hello")}
+ r := newTestReader(t, fake, int64(len(fake.data))+5)
+ defer r.Close()
+
+ buf := make([]byte, 10)
+ n, err := r.ReadAt(buf, 0)
+ require.Equal(t, 5, n)
+ require.ErrorIs(t, err, io.ErrUnexpectedEOF)
+ })
+}
+
+func TestCancelTimeout(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abc")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ require.True(t, r.CancelTimeout(), "first stop should succeed")
+ require.False(t, r.CancelTimeout(), "second stop must report the timer was already stopped")
+}
+
+func TestSeek(t *testing.T) {
+ t.Run("absolute from start", func(t *testing.T) {
+ fake := &fakeDownloader{data: bytes.Repeat([]byte("x"), 32)}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ pos, err := r.Seek(10, io.SeekStart)
+ require.NoError(t, err)
+ require.Equal(t, int64(10), pos)
+ })
+
+ t.Run("relative to current position", func(t *testing.T) {
+ fake := &fakeDownloader{data: bytes.Repeat([]byte("x"), 32)}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := r.Seek(10, io.SeekStart)
+ require.NoError(t, err)
+
+ pos, err := r.Seek(5, io.SeekCurrent)
+ require.NoError(t, err)
+ require.Equal(t, int64(15), pos)
+ })
+
+ t.Run("relative to end", func(t *testing.T) {
+ fake := &fakeDownloader{data: bytes.Repeat([]byte("x"), 32)}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ pos, err := r.Seek(-4, io.SeekEnd)
+ require.NoError(t, err)
+ require.Equal(t, int64(28), pos)
+ })
+
+ t.Run("rejects invalid whence", func(t *testing.T) {
+ r := newTestReader(t, &fakeDownloader{}, 0)
+ defer r.Close()
+
+ _, err := r.Seek(0, 99)
+ require.Error(t, err)
+ })
+
+ t.Run("rejects negative absolute position", func(t *testing.T) {
+ r := newTestReader(t, &fakeDownloader{}, 10)
+ defer r.Close()
+
+ _, err := r.Seek(-1, io.SeekStart)
+ require.Error(t, err)
+
+ _, err = r.Seek(-20, io.SeekEnd)
+ require.Error(t, err)
+ })
+
+ t.Run("same offset leaves the open stream untouched", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefgh")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := io.ReadFull(r, make([]byte, 3))
+ require.NoError(t, err)
+ require.Len(t, fake.bodies, 1)
+ openBody := fake.bodies[0]
+
+ pos, err := r.Seek(3, io.SeekStart)
+ require.NoError(t, err)
+ require.Equal(t, int64(3), pos)
+ require.False(t, openBody.closed, "same-offset seek must not close the open stream")
+
+ _, err = io.ReadFull(r, make([]byte, 3))
+ require.NoError(t, err)
+ require.Len(t, fake.calls, 1, "same-offset seek must not trigger a new download")
+ })
+
+ t.Run("different offset closes the open stream and the next read reopens", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdefghij")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+ defer r.Close()
+
+ _, err := io.ReadFull(r, make([]byte, 2))
+ require.NoError(t, err)
+ require.Len(t, fake.bodies, 1)
+ firstBody := fake.bodies[0]
+
+ _, err = r.Seek(7, io.SeekStart)
+ require.NoError(t, err)
+ require.True(t, firstBody.closed, "seek to a new offset must close the open stream")
+
+ buf := make([]byte, 3)
+ _, err = io.ReadFull(r, buf)
+ require.NoError(t, err)
+ require.Equal(t, "hij", string(buf))
+
+ require.Len(t, fake.calls, 2)
+ require.Equal(t, int64(7), fake.calls[1].Offset)
+ })
+}
+
+func TestClose(t *testing.T) {
+ t.Run("cancels context and closes the open body", func(t *testing.T) {
+ fake := &fakeDownloader{data: []byte("abcdef")}
+ r := newTestReader(t, fake, int64(len(fake.data)))
+
+ _, err := io.ReadFull(r, make([]byte, 3))
+ require.NoError(t, err)
+ require.Len(t, fake.bodies, 1)
+
+ require.NoError(t, r.Close())
+ require.True(t, fake.bodies[0].closed)
+ require.ErrorIs(t, r.ctx.Err(), context.Canceled)
+ })
+
+ t.Run("works when no stream was opened", func(t *testing.T) {
+ r := newTestReader(t, &fakeDownloader{}, 10)
+ require.NoError(t, r.Close())
+ require.ErrorIs(t, r.ctx.Err(), context.Canceled)
+ })
+}
diff --git a/server/platform/shared/filestore/azurestore_test.go b/server/platform/shared/filestore/azurestore_test.go
new file mode 100644
index 00000000000..a6dea58aed5
--- /dev/null
+++ b/server/platform/shared/filestore/azurestore_test.go
@@ -0,0 +1,137 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package filestore
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/stretchr/testify/suite"
+)
+
+func TestAzureFileBackendPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ input string
+ expected string
+ }{
+ {name: "no prefix, plain path", prefix: "", input: "team/channel/file", expected: "team/channel/file"},
+ {name: "no prefix, with dot-dot", prefix: "", input: "../escape", expected: "../escape"},
+ {name: "prefix, plain path", prefix: "mattermost", input: "team/channel/file", expected: "mattermost/team/channel/file"},
+ {name: "prefix, exact root", prefix: "mattermost", input: "", expected: "mattermost"},
+ {name: "prefix, dot-dot escapes", prefix: "mattermost", input: "../escape", expected: "mattermost/escape"},
+ {name: "prefix, nested dot-dot escapes", prefix: "mattermost", input: "sub/../../escape", expected: "mattermost/escape"},
+ {name: "prefix, dot-dot in middle stays inside", prefix: "mattermost", input: "a/../b", expected: "mattermost/b"},
+ {name: "prefix with trailing slash, dot-dot escapes", prefix: "mattermost/", input: "../escape", expected: "mattermost/escape"},
+ {name: "prefix boundary collision must not escape", prefix: "mattermost", input: "../mattermost-evil/file", expected: "mattermost/file"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := &AzureFileBackend{pathPrefix: tt.prefix}
+ require.Equal(t, tt.expected, b.prefix(tt.input))
+ })
+ }
+}
+
+// azuriteWellKnownAccount and azuriteWellKnownKey are Azurite's published
+// development credentials. They are not secrets - they are documented in the
+// Azurite README and ship hardcoded in every Azurite distribution.
+const (
+ azuriteWellKnownAccount = "devstoreaccount1"
+ azuriteWellKnownKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
+)
+
+// TestAzureFileBackendAppendRefusesNonBlockBlob exercises the safety
+// check in AppendFile: when a blob exists with content but no committed
+// block list (i.e. it was uploaded via Put Blob by another tool), the
+// backend must refuse the append rather than silently destroy the
+// existing content.
+func TestAzureFileBackendAppendRefusesNonBlockBlob(t *testing.T) {
+ be := newAzuriteBackend(t)
+
+ path := "append-refusal-test.bin"
+ t.Cleanup(func() { _ = be.RemoveFile(path) })
+
+ // Write the blob via the high-level Upload helper, which calls the
+ // Put Blob REST endpoint and leaves the committed-block list empty.
+ original := []byte("planted-by-another-tool")
+ bb := be.newBlockBlobClient(path)
+ _, err := bb.Upload(context.Background(), nopReadSeekCloser{bytes.NewReader(original)}, nil)
+ require.NoError(t, err)
+
+ _, err = be.AppendFile(bytes.NewReader([]byte("would-overwrite")), path)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "no committed block list")
+
+ // The original content must still be intact.
+ got, err := be.ReadFile(path)
+ require.NoError(t, err)
+ require.Equal(t, original, got)
+}
+
+// TestAzureFileBackendMakeContainerIdempotent ensures that calling
+// MakeContainer twice on the same backend is a no-op the second time.
+// Two nodes can race through TestConnection plus MakeContainer at boot;
+// the loser must converge instead of returning an error.
+func TestAzureFileBackendMakeContainerIdempotent(t *testing.T) {
+ be := newAzuriteBackend(t)
+
+ require.NoError(t, be.MakeContainer())
+ require.NoError(t, be.MakeContainer())
+}
+
+type nopReadSeekCloser struct {
+ *bytes.Reader
+}
+
+func (nopReadSeekCloser) Close() error { return nil }
+
+// newAzuriteBackend builds an Azure backend pointed at the Azurite emulator
+// and ensures the container exists. Standalone Azure tests should use this
+// instead of calling NewAzureFileBackend + TestConnection directly; the
+// shared FileBackendTestSuite handles provisioning itself in SetupTest.
+func newAzuriteBackend(t *testing.T) *AzureFileBackend {
+ t.Helper()
+ be, err := NewAzureFileBackend(azuriteSettings(t))
+ require.NoError(t, err)
+
+ var noBucket *FileBackendNoBucketError
+ if err := be.TestConnection(); errors.As(err, &noBucket) {
+ require.NoError(t, be.MakeContainer())
+ } else {
+ require.NoError(t, err)
+ }
+ return be
+}
+
+func azuriteSettings(t *testing.T) FileBackendSettings {
+ t.Helper()
+ host := os.Getenv("CI_AZURITE_HOST")
+ if host == "" {
+ host = "localhost"
+ }
+ port := os.Getenv("CI_AZURITE_PORT")
+ if port == "" {
+ port = "10000"
+ }
+ return FileBackendSettings{
+ DriverName: driverAzure,
+ AzureStorageAccount: azuriteWellKnownAccount,
+ AzureAccessKey: azuriteWellKnownKey,
+ AzureContainer: "mattermost-test",
+ AzureEndpoint: fmt.Sprintf("%s:%s", host, port),
+ AzureSSL: false,
+ AzureRequestTimeoutMilliseconds: 30000,
+ }
+}
+
+func TestAzureFileBackendTestSuite(t *testing.T) {
+ suite.Run(t, &FileBackendTestSuite{settings: azuriteSettings(t)})
+}
diff --git a/server/platform/shared/filestore/errors.go b/server/platform/shared/filestore/errors.go
new file mode 100644
index 00000000000..6d034ca6cb4
--- /dev/null
+++ b/server/platform/shared/filestore/errors.go
@@ -0,0 +1,44 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package filestore
+
+// FileBackendAuthError is returned when testing a connection and authentication
+// against the file storage backend fails. Backends should wrap the underlying
+// auth failure in this type so the admin Test Connection flow can surface a
+// useful message regardless of which driver is configured.
+type FileBackendAuthError struct {
+ // Err is the underlying driver error, if any.
+ Err error
+ // DetailedError is a human-readable message describing the failure.
+ // Kept for compatibility with the previous S3-specific type.
+ DetailedError string
+}
+
+func (e *FileBackendAuthError) Error() string {
+ if e.DetailedError != "" {
+ return e.DetailedError
+ }
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+ return "authentication failed"
+}
+
+func (e *FileBackendAuthError) Unwrap() error { return e.Err }
+
+// FileBackendNoBucketError is returned when testing a connection and the
+// configured bucket / container does not exist.
+type FileBackendNoBucketError struct {
+ // Err is the underlying driver error, if any.
+ Err error
+}
+
+func (e *FileBackendNoBucketError) Error() string {
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+ return "no such bucket or container"
+}
+
+func (e *FileBackendNoBucketError) Unwrap() error { return e.Err }
diff --git a/server/platform/shared/filestore/filesstore.go b/server/platform/shared/filestore/filesstore.go
index 46116579a4d..c7eacb1bb84 100644
--- a/server/platform/shared/filestore/filesstore.go
+++ b/server/platform/shared/filestore/filesstore.go
@@ -15,6 +15,7 @@ import (
const (
driverS3 = "amazons3"
driverLocal = "local"
+ driverAzure = "azureblob"
)
type ReadCloseSeeker interface {
@@ -65,6 +66,13 @@ type FileBackendSettings struct {
AmazonS3PresignExpiresSeconds int64
AmazonS3UploadPartSizeBytes int64
AmazonS3StorageClass string
+ AzureStorageAccount string
+ AzureAccessKey string
+ AzureContainer string
+ AzurePathPrefix string
+ AzureEndpoint string
+ AzureSSL bool
+ AzureRequestTimeoutMilliseconds int64
}
func NewFileBackendSettingsFromConfig(fileSettings *model.FileSettings, enableComplianceFeature bool, skipVerify bool) FileBackendSettings {
@@ -74,6 +82,19 @@ func NewFileBackendSettingsFromConfig(fileSettings *model.FileSettings, enableCo
Directory: *fileSettings.Directory,
}
}
+ if *fileSettings.DriverName == model.ImageDriverAzure {
+ return FileBackendSettings{
+ DriverName: *fileSettings.DriverName,
+ AzureStorageAccount: *fileSettings.AzureStorageAccount,
+ AzureAccessKey: *fileSettings.AzureAccessKey,
+ AzureContainer: *fileSettings.AzureContainer,
+ AzurePathPrefix: *fileSettings.AzurePathPrefix,
+ AzureEndpoint: *fileSettings.AzureEndpoint,
+ AzureSSL: fileSettings.AzureSSL == nil || *fileSettings.AzureSSL,
+ AzureRequestTimeoutMilliseconds: *fileSettings.AzureRequestTimeoutMilliseconds,
+ SkipVerify: skipVerify,
+ }
+ }
return FileBackendSettings{
DriverName: *fileSettings.DriverName,
AmazonS3AccessKeyId: *fileSettings.AmazonS3AccessKeyId,
@@ -100,6 +121,19 @@ func NewExportFileBackendSettingsFromConfig(fileSettings *model.FileSettings, en
Directory: *fileSettings.ExportDirectory,
}
}
+ if *fileSettings.ExportDriverName == model.ImageDriverAzure {
+ return FileBackendSettings{
+ DriverName: *fileSettings.ExportDriverName,
+ AzureStorageAccount: *fileSettings.ExportAzureStorageAccount,
+ AzureAccessKey: *fileSettings.ExportAzureAccessKey,
+ AzureContainer: *fileSettings.ExportAzureContainer,
+ AzurePathPrefix: *fileSettings.ExportAzurePathPrefix,
+ AzureEndpoint: *fileSettings.ExportAzureEndpoint,
+ AzureSSL: fileSettings.ExportAzureSSL == nil || *fileSettings.ExportAzureSSL,
+ AzureRequestTimeoutMilliseconds: *fileSettings.ExportAzureRequestTimeoutMilliseconds,
+ SkipVerify: skipVerify,
+ }
+ }
return FileBackendSettings{
DriverName: *fileSettings.ExportDriverName,
AmazonS3AccessKeyId: *fileSettings.ExportAmazonS3AccessKeyId,
@@ -133,6 +167,19 @@ func (settings *FileBackendSettings) CheckMandatoryS3Fields() error {
return nil
}
+func (settings *FileBackendSettings) CheckMandatoryAzureFields() error {
+ if settings.AzureStorageAccount == "" {
+ return errors.New("missing azure storage account setting")
+ }
+ if settings.AzureContainer == "" {
+ return errors.New("missing azure container setting")
+ }
+ if settings.AzureAccessKey == "" {
+ return errors.New("missing azure access key setting")
+ }
+ return nil
+}
+
// NewFileBackend creates a new file backend
func NewFileBackend(settings FileBackendSettings) (FileBackend, error) {
return newFileBackend(settings, true)
@@ -159,6 +206,12 @@ func newFileBackend(settings FileBackendSettings, canBeCloud bool) (FileBackend,
return &LocalFileBackend{
directory: settings.Directory,
}, nil
+ case driverAzure:
+ backend, err := NewAzureFileBackend(settings)
+ if err != nil {
+ return nil, errors.Wrap(err, "unable to connect to the azure backend")
+ }
+ return backend, nil
}
return nil, errors.New("no valid filestorage driver found")
}
diff --git a/server/platform/shared/filestore/filesstore_test.go b/server/platform/shared/filestore/filesstore_test.go
index ad5b5b3aa5e..f56182e1258 100644
--- a/server/platform/shared/filestore/filesstore_test.go
+++ b/server/platform/shared/filestore/filesstore_test.go
@@ -123,11 +123,17 @@ func (s *FileBackendTestSuite) SetupTest() {
require.NoError(s.T(), err)
s.backend = backend
- // This is needed to create the bucket if it doesn't exist.
+ // This is needed to create the bucket / container if it doesn't exist.
err = s.backend.TestConnection()
- if _, ok := err.(*S3FileBackendNoBucketError); ok {
- s3Backend := s.backend.(*S3FileBackend)
- s.NoError(s3Backend.MakeBucket())
+ if _, ok := err.(*FileBackendNoBucketError); ok {
+ switch b := s.backend.(type) {
+ case *S3FileBackend:
+ s.NoError(b.MakeBucket())
+ case *AzureFileBackend:
+ s.NoError(b.MakeContainer())
+ default:
+ s.NoError(err)
+ }
} else {
s.NoError(err)
}
@@ -699,7 +705,7 @@ func BenchmarkFileStore(b *testing.B) {
// Create bucket if it doesn't exist
err = s3Backend.TestConnection()
- if _, ok := err.(*S3FileBackendNoBucketError); ok {
+ if _, ok := err.(*FileBackendNoBucketError); ok {
require.NoError(b, s3Backend.(*S3FileBackend).MakeBucket())
} else {
require.NoError(b, err)
@@ -851,7 +857,7 @@ func BenchmarkS3WriteFile(b *testing.B) {
// This is needed to create the bucket if it doesn't exist.
err = backend.TestConnection()
- if _, ok := err.(*S3FileBackendNoBucketError); ok {
+ if _, ok := err.(*FileBackendNoBucketError); ok {
require.NoError(b, backend.(*S3FileBackend).MakeBucket())
} else {
require.NoError(b, err)
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/platform/shared/filestore/s3store.go b/server/platform/shared/filestore/s3store.go
index 161a2cb3d05..420c6f890eb 100644
--- a/server/platform/shared/filestore/s3store.go
+++ b/server/platform/shared/filestore/s3store.go
@@ -50,12 +50,13 @@ type S3FileBackend struct {
storageClass string
}
-type S3FileBackendAuthError struct {
- DetailedError string
-}
-
-// S3FileBackendNoBucketError is returned when testing a connection and no S3 bucket is found
-type S3FileBackendNoBucketError struct{}
+// S3FileBackendAuthError and S3FileBackendNoBucketError are aliases for the
+// generic backend errors. They are kept so external code (plugins,
+// historically-typed consumers) continues to compile.
+type (
+ S3FileBackendAuthError = FileBackendAuthError
+ S3FileBackendNoBucketError = FileBackendNoBucketError
+)
const (
// This is not exported by minio. See: https://github.com/minio/minio-go/issues/1339
@@ -77,14 +78,6 @@ func getContentType(ext string) string {
return mimeType
}
-func (s *S3FileBackendAuthError) Error() string {
- return s.DetailedError
-}
-
-func (s *S3FileBackendNoBucketError) Error() string {
- return "no such bucket"
-}
-
// NewS3FileBackend returns an instance of an S3FileBackend and determine if we are in Mattermost cloud or not.
func NewS3FileBackend(settings FileBackendSettings) (*S3FileBackend, error) {
return newS3FileBackend(settings, os.Getenv("MM_CLOUD_FILESTORE_BIFROST") != "")
diff --git a/server/platform/shared/mail/inbucket.go b/server/platform/shared/mail/inbucket.go
index 851d1dd9a62..ccbdc87e68d 100644
--- a/server/platform/shared/mail/inbucket.go
+++ b/server/platform/shared/mail/inbucket.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "net"
"net/http"
"os"
"strings"
@@ -178,5 +179,5 @@ func getInbucketHost() (host string) {
if inbucket_port == "" {
inbucket_port = "9001"
}
- return fmt.Sprintf("http://%s:%s", inbucket_host, inbucket_port)
+ return "http://" + net.JoinHostPort(inbucket_host, inbucket_port)
}
diff --git a/server/platform/shared/mail/mail_test.go b/server/platform/shared/mail/mail_test.go
index 514a56d8a54..33555d4ee5a 100644
--- a/server/platform/shared/mail/mail_test.go
+++ b/server/platform/shared/mail/mail_test.go
@@ -254,13 +254,13 @@ func TestSendMailUsingConfigAdvanced(t *testing.T) {
file1, err := os.CreateTemp("", "*")
require.NoError(t, err)
defer os.Remove(file1.Name())
- file1.Write([]byte("hello world"))
+ file1.WriteString("hello world")
file1.Close()
file2, err := os.CreateTemp("", "*")
require.NoError(t, err)
defer os.Remove(file2.Name())
- file2.Write([]byte("foo bar"))
+ file2.WriteString("foo bar")
file2.Close()
embeddedFiles := map[string]io.Reader{
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/builtin.go b/server/public/model/builtin.go
index 5d52c72e180..1a0d3d55209 100644
--- a/server/public/model/builtin.go
+++ b/server/public/model/builtin.go
@@ -4,8 +4,6 @@
package model
// NewPointer returns a pointer to the object passed.
-//
-//go:fix inline
func NewPointer[T any](t T) *T { return new(t) }
// SafeDereference returns the zero value of T if t is nil.
diff --git a/server/public/model/channel.go b/server/public/model/channel.go
index 5226f160c97..0fa96cf1a29 100644
--- a/server/public/model/channel.go
+++ b/server/public/model/channel.go
@@ -81,33 +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"`
+ 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 {
@@ -129,8 +158,10 @@ 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,
}
}
@@ -160,6 +191,7 @@ type ChannelPatch struct {
AutoTranslation *bool `json:"autotranslation"`
ManagedCategoryName *string `json:"managed_category_name"`
DefaultCategoryName *string `json:"default_category_name"`
+ Discoverable *bool `json:"discoverable"`
}
func (c *ChannelPatch) Auditable() map[string]any {
@@ -169,6 +201,7 @@ func (c *ChannelPatch) Auditable() map[string]any {
"purpose": c.Purpose,
"default_category_name": c.DefaultCategoryName,
"managed_category_name": c.ManagedCategoryName,
+ "discoverable": c.Discoverable,
}
}
@@ -339,6 +372,10 @@ func (o *Channel) IsValid() *AppError {
}
}
+ if o.Discoverable && o.Type != ChannelTypePrivate {
+ return NewAppError("Channel.IsValid", "model.channel.is_valid.discoverable.app_error", nil, "id="+o.Id, http.StatusBadRequest)
+ }
+
return nil
}
@@ -459,6 +496,10 @@ func (o *Channel) Patch(patch *ChannelPatch) {
if patch.DefaultCategoryName != nil {
o.DefaultCategoryName = strings.TrimSpace(*patch.DefaultCategoryName)
}
+
+ if patch.Discoverable != nil {
+ o.Discoverable = *patch.Discoverable
+ }
}
func (o *Channel) MakeNonNil() {
diff --git a/server/public/model/channel_join_request.go b/server/public/model/channel_join_request.go
new file mode 100644
index 00000000000..38c0e6248dc
--- /dev/null
+++ b/server/public/model/channel_join_request.go
@@ -0,0 +1,165 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package model
+
+import (
+ "net/http"
+ "unicode/utf8"
+)
+
+const (
+ ChannelJoinRequestStatusPending = "pending"
+ ChannelJoinRequestStatusApproved = "approved"
+ ChannelJoinRequestStatusDenied = "denied"
+ ChannelJoinRequestStatusWithdrawn = "withdrawn"
+
+ ChannelJoinRequestMessageMaxRunes = 500
+ ChannelJoinRequestDenialReasonMaxRunes = 500
+)
+
+// ChannelJoinRequest records a user's request to join a discoverable private channel.
+//
+// Rows are append-only / status-mutating: a request transitions through
+// pending → approved | denied | withdrawn. Rows are never deleted so the full
+// audit history is preserved. A partial unique index in Postgres enforces at
+// most one active pending row per (ChannelId, UserId).
+type ChannelJoinRequest struct {
+ Id string `json:"id"`
+ ChannelId string `json:"channel_id"`
+ UserId string `json:"user_id"`
+ Message string `json:"message"`
+ Status string `json:"status"`
+ DenialReason string `json:"denial_reason"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ ReviewedBy string `json:"reviewed_by"`
+ ReviewedAt int64 `json:"reviewed_at"`
+}
+
+// ChannelJoinRequestList is the paginated response shape returned by list endpoints.
+type ChannelJoinRequestList struct {
+ Requests []*ChannelJoinRequest `json:"requests"`
+ TotalCount int64 `json:"total_count"`
+}
+
+// ChannelJoinRequestPatch represents the admin review action: approve or deny,
+// with an optional denial reason that is surfaced to the requester.
+type ChannelJoinRequestPatch struct {
+ Status string `json:"status"`
+ DenialReason *string `json:"denial_reason,omitempty"`
+}
+
+// GetChannelJoinRequestsOpts filters and paginates list queries on the store.
+// An empty Status means "pending".
+type GetChannelJoinRequestsOpts struct {
+ Status string
+ Page int
+ PerPage int
+}
+
+// IsValidChannelJoinRequestStatus reports whether the given status string is a
+// recognized lifecycle value for a ChannelJoinRequest.
+func IsValidChannelJoinRequestStatus(s string) bool {
+ switch s {
+ case ChannelJoinRequestStatusPending,
+ ChannelJoinRequestStatusApproved,
+ ChannelJoinRequestStatusDenied,
+ ChannelJoinRequestStatusWithdrawn:
+ return true
+ }
+ return false
+}
+
+func (r *ChannelJoinRequest) Auditable() map[string]any {
+ return map[string]any{
+ "id": r.Id,
+ "channel_id": r.ChannelId,
+ "user_id": r.UserId,
+ "status": r.Status,
+ "create_at": r.CreateAt,
+ "update_at": r.UpdateAt,
+ "reviewed_by": r.ReviewedBy,
+ "reviewed_at": r.ReviewedAt,
+ "has_message": r.Message != "",
+ "has_denial_reason": r.DenialReason != "",
+ }
+}
+
+func (r *ChannelJoinRequest) LogClone() any {
+ return r.Auditable()
+}
+
+func (r *ChannelJoinRequest) IsValid() *AppError {
+ if !IsValidId(r.Id) {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.id.app_error", nil, "", http.StatusBadRequest)
+ }
+
+ if !IsValidId(r.ChannelId) {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.channel_id.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if !IsValidId(r.UserId) {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.user_id.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if r.CreateAt == 0 {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.create_at.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if r.UpdateAt == 0 {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.update_at.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if !IsValidChannelJoinRequestStatus(r.Status) {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.status.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if utf8.RuneCountInString(r.Message) > ChannelJoinRequestMessageMaxRunes {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.message.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if utf8.RuneCountInString(r.DenialReason) > ChannelJoinRequestDenialReasonMaxRunes {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.denial_reason.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ // A denial reason is only meaningful on a denied request.
+ if r.DenialReason != "" && r.Status != ChannelJoinRequestStatusDenied {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.denial_reason_status.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ if r.ReviewedBy != "" && !IsValidId(r.ReviewedBy) {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.reviewed_by.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+
+ // Reviewer and reviewed-at must accompany a terminal review action.
+ switch r.Status {
+ case ChannelJoinRequestStatusApproved, ChannelJoinRequestStatusDenied:
+ if r.ReviewedBy == "" || r.ReviewedAt == 0 {
+ return NewAppError("ChannelJoinRequest.IsValid", "model.channel_join_request.is_valid.reviewer.app_error", nil, "id="+r.Id, http.StatusBadRequest)
+ }
+ }
+
+ return nil
+}
+
+func (r *ChannelJoinRequest) PreSave() {
+ if r.Id == "" {
+ r.Id = NewId()
+ }
+ if r.Status == "" {
+ r.Status = ChannelJoinRequestStatusPending
+ }
+ if r.CreateAt == 0 {
+ r.CreateAt = GetMillis()
+ }
+ r.UpdateAt = r.CreateAt
+ r.Message = SanitizeUnicode(r.Message)
+ r.DenialReason = SanitizeUnicode(r.DenialReason)
+}
+
+func (r *ChannelJoinRequest) PreUpdate() {
+ r.UpdateAt = GetMillis()
+ r.Message = SanitizeUnicode(r.Message)
+ r.DenialReason = SanitizeUnicode(r.DenialReason)
+}
diff --git a/server/public/model/channel_join_request_test.go b/server/public/model/channel_join_request_test.go
new file mode 100644
index 00000000000..78f354732c4
--- /dev/null
+++ b/server/public/model/channel_join_request_test.go
@@ -0,0 +1,114 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package model
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func validRequest() *ChannelJoinRequest {
+ return &ChannelJoinRequest{
+ Id: NewId(),
+ ChannelId: NewId(),
+ UserId: NewId(),
+ Status: ChannelJoinRequestStatusPending,
+ CreateAt: GetMillis(),
+ UpdateAt: GetMillis(),
+ }
+}
+
+func TestChannelJoinRequestPreSaveDefaults(t *testing.T) {
+ r := &ChannelJoinRequest{
+ ChannelId: NewId(),
+ UserId: NewId(),
+ }
+ r.PreSave()
+
+ assert.NotEmpty(t, r.Id, "PreSave must assign an Id when missing")
+ assert.Equal(t, ChannelJoinRequestStatusPending, r.Status, "PreSave must default Status to pending")
+ assert.NotZero(t, r.CreateAt)
+ assert.Equal(t, r.CreateAt, r.UpdateAt, "PreSave must align UpdateAt with CreateAt")
+}
+
+func TestChannelJoinRequestPreUpdateAdvancesUpdateAt(t *testing.T) {
+ r := validRequest()
+ originalCreate := r.CreateAt
+ // Seed UpdateAt to a known-old value so we can prove PreUpdate actually
+ // advanced it (the validRequest factory sets UpdateAt = GetMillis(), so
+ // a no-op PreUpdate could otherwise still pass a GreaterOrEqual check).
+ r.UpdateAt = 1
+ r.PreUpdate()
+
+ assert.Greater(t, r.UpdateAt, int64(1))
+ assert.Equal(t, originalCreate, r.CreateAt, "PreUpdate must not mutate CreateAt")
+}
+
+func TestChannelJoinRequestIsValid(t *testing.T) {
+ t.Run("happy path pending", func(t *testing.T) {
+ require.Nil(t, validRequest().IsValid())
+ })
+
+ t.Run("invalid id", func(t *testing.T) {
+ r := validRequest()
+ r.Id = "not-an-id"
+ err := r.IsValid()
+ require.NotNil(t, err)
+ assert.Equal(t, http.StatusBadRequest, err.StatusCode)
+ })
+
+ t.Run("rejects unknown status", func(t *testing.T) {
+ r := validRequest()
+ r.Status = "weird"
+ require.NotNil(t, r.IsValid())
+ })
+
+ t.Run("rejects message over rune limit", func(t *testing.T) {
+ r := validRequest()
+ r.Message = strings.Repeat("a", ChannelJoinRequestMessageMaxRunes+1)
+ require.NotNil(t, r.IsValid())
+ })
+
+ t.Run("rejects denial reason on non-denied request", func(t *testing.T) {
+ r := validRequest()
+ r.Status = ChannelJoinRequestStatusApproved
+ r.ReviewedBy = NewId()
+ r.ReviewedAt = GetMillis()
+ r.DenialReason = "nope"
+ require.NotNil(t, r.IsValid(), "denial reason must only be set on denied rows")
+ })
+
+ t.Run("requires reviewer info for terminal review", func(t *testing.T) {
+ r := validRequest()
+ r.Status = ChannelJoinRequestStatusApproved
+ require.NotNil(t, r.IsValid(), "approved without reviewer must be invalid")
+
+ r.ReviewedBy = NewId()
+ r.ReviewedAt = GetMillis()
+ require.Nil(t, r.IsValid())
+ })
+
+ t.Run("withdrawn does not require reviewer", func(t *testing.T) {
+ r := validRequest()
+ r.Status = ChannelJoinRequestStatusWithdrawn
+ require.Nil(t, r.IsValid(), "withdrawn is a self-service action, not a review")
+ })
+}
+
+func TestIsValidChannelJoinRequestStatus(t *testing.T) {
+ for _, s := range []string{
+ ChannelJoinRequestStatusPending,
+ ChannelJoinRequestStatusApproved,
+ ChannelJoinRequestStatusDenied,
+ ChannelJoinRequestStatusWithdrawn,
+ } {
+ assert.True(t, IsValidChannelJoinRequestStatus(s), "%q should be a valid status", s)
+ }
+ assert.False(t, IsValidChannelJoinRequestStatus(""))
+ assert.False(t, IsValidChannelJoinRequestStatus("approved "))
+}
diff --git a/server/public/model/channel_test.go b/server/public/model/channel_test.go
index 8d7a3b2ad09..21143f3b7f5 100644
--- a/server/public/model/channel_test.go
+++ b/server/public/model/channel_test.go
@@ -35,6 +35,64 @@ func TestChannelPatch(t *testing.T) {
require.Equal(t, *p.GroupConstrained, *o.GroupConstrained)
}
+func TestChannelPatchDiscoverable(t *testing.T) {
+ t.Run("applies discoverable when set", func(t *testing.T) {
+ on := true
+ p := &ChannelPatch{Discoverable: &on}
+ o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate}
+ o.Patch(p)
+ require.True(t, o.Discoverable)
+ })
+
+ t.Run("clears discoverable when set to false", func(t *testing.T) {
+ off := false
+ p := &ChannelPatch{Discoverable: &off}
+ o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate, Discoverable: true}
+ o.Patch(p)
+ require.False(t, o.Discoverable)
+ })
+
+ t.Run("nil discoverable leaves channel untouched", func(t *testing.T) {
+ o := Channel{Id: NewId(), Name: NewId(), Type: ChannelTypePrivate, Discoverable: true}
+ o.Patch(&ChannelPatch{})
+ require.True(t, o.Discoverable)
+ })
+}
+
+func TestChannelIsValidDiscoverable(t *testing.T) {
+ base := Channel{
+ Id: NewId(),
+ CreateAt: GetMillis(),
+ UpdateAt: GetMillis(),
+ DisplayName: "x",
+ Name: "valid-name",
+ Header: "h",
+ Purpose: "p",
+ }
+
+ t.Run("discoverable=false is valid on any type", func(t *testing.T) {
+ c := base
+ c.Type = ChannelTypeOpen
+ require.Nil(t, c.IsValid())
+ })
+
+ t.Run("discoverable=true requires private channel", func(t *testing.T) {
+ c := base
+ c.Type = ChannelTypeOpen
+ c.Discoverable = true
+ require.NotNil(t, c.IsValid(), "discoverable=true on public channel must be rejected")
+
+ c.Type = ChannelTypeDirect
+ require.NotNil(t, c.IsValid())
+
+ c.Type = ChannelTypeGroup
+ require.NotNil(t, c.IsValid())
+
+ c.Type = ChannelTypePrivate
+ require.Nil(t, c.IsValid())
+ })
+}
+
func TestChannelIsValid(t *testing.T) {
o := Channel{}
diff --git a/server/public/model/client4.go b/server/public/model/client4.go
index 179940aff96..c3eb8c76e43 100644
--- a/server/public/model/client4.go
+++ b/server/public/model/client4.go
@@ -1186,6 +1186,18 @@ func (c *Client4) GetUserByEmail(ctx context.Context, email, etag string) (*User
return DecodeJSONFromResponse[*User](r)
}
+// GetUserByAuthData returns a user by auth_data (external AuthData).
+func (c *Client4) GetUserByAuthData(ctx context.Context, authData, etag string) (*User, *Response, error) {
+ values := url.Values{}
+ values.Set("value", authData)
+ r, err := c.doAPIGetWithQuery(ctx, c.usersRoute().Join("auth_data"), values, etag)
+ if err != nil {
+ return nil, BuildResponse(r), err
+ }
+ defer closeBody(r)
+ return DecodeJSONFromResponse[*User](r)
+}
+
// AutocompleteUsersInTeam returns the users on a team based on search term.
func (c *Client4) AutocompleteUsersInTeam(ctx context.Context, teamId string, username string, limit int, etag string) (*UserAutocomplete, *Response, error) {
values := url.Values{}
diff --git a/server/public/model/config.go b/server/public/model/config.go
index a586861c079..d975f59c46b 100644
--- a/server/public/model/config.go
+++ b/server/public/model/config.go
@@ -36,6 +36,7 @@ const (
ImageDriverLocal = "local"
ImageDriverS3 = "amazons3"
+ ImageDriverAzure = "azureblob"
DatabaseDriverPostgres = "postgres"
@@ -137,6 +138,12 @@ const (
FileSettingsDefaultS3UploadPartSizeBytes = 5 * 1024 * 1024 // 5MB
FileSettingsDefaultS3ExportUploadPartSizeBytes = 100 * 1024 * 1024 // 100MB
+ // maxAzureRequestTimeoutMilliseconds caps the per-request timeout so a
+ // hung Azure call cannot keep a goroutine open indefinitely. Ten minutes
+ // is well beyond any realistic single-request workload and matches the
+ // upper end of Azure SDK retry guidance.
+ maxAzureRequestTimeoutMilliseconds = 10 * 60 * 1000
+
ImportSettingsDefaultDirectory = "./import"
ImportSettingsDefaultRetentionDays = 30
@@ -1795,6 +1802,13 @@ type FileSettings struct {
AmazonS3RequestTimeoutMilliseconds *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AmazonS3UploadPartSizeBytes *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AmazonS3StorageClass *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzureStorageAccount *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzureAccessKey *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzureContainer *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzurePathPrefix *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzureEndpoint *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
+ AzureSSL *bool `access:"environment_file_storage,write_restrictable,cloud_restrictable"`
+ AzureRequestTimeoutMilliseconds *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
// Export store settings
DedicatedExportStore *bool `access:"environment_file_storage,write_restrictable"`
ExportDriverName *string `access:"environment_file_storage,write_restrictable"`
@@ -1813,6 +1827,13 @@ type FileSettings struct {
ExportAmazonS3PresignExpiresSeconds *int64 `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAmazonS3UploadPartSizeBytes *int64 `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAmazonS3StorageClass *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzureStorageAccount *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzureAccessKey *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzureContainer *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzurePathPrefix *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzureEndpoint *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
+ ExportAzureSSL *bool `access:"environment_file_storage,write_restrictable"`
+ ExportAzureRequestTimeoutMilliseconds *int64 `access:"environment_file_storage,write_restrictable"` // telemetry: none
}
func (s *FileSettings) SetDefaults(isUpdate bool) {
@@ -1929,6 +1950,34 @@ func (s *FileSettings) SetDefaults(isUpdate bool) {
s.AmazonS3StorageClass = new("")
}
+ if s.AzureStorageAccount == nil {
+ s.AzureStorageAccount = NewPointer("")
+ }
+
+ if s.AzureAccessKey == nil {
+ s.AzureAccessKey = NewPointer("")
+ }
+
+ if s.AzureContainer == nil {
+ s.AzureContainer = NewPointer("")
+ }
+
+ if s.AzurePathPrefix == nil {
+ s.AzurePathPrefix = NewPointer("")
+ }
+
+ if s.AzureEndpoint == nil {
+ s.AzureEndpoint = NewPointer("")
+ }
+
+ if s.AzureSSL == nil {
+ s.AzureSSL = NewPointer(true)
+ }
+
+ if s.AzureRequestTimeoutMilliseconds == nil {
+ s.AzureRequestTimeoutMilliseconds = NewPointer(int64(30000))
+ }
+
if s.DedicatedExportStore == nil {
s.DedicatedExportStore = new(false)
}
@@ -1998,6 +2047,34 @@ func (s *FileSettings) SetDefaults(isUpdate bool) {
if s.ExportAmazonS3StorageClass == nil {
s.ExportAmazonS3StorageClass = new("")
}
+
+ if s.ExportAzureStorageAccount == nil {
+ s.ExportAzureStorageAccount = NewPointer("")
+ }
+
+ if s.ExportAzureAccessKey == nil {
+ s.ExportAzureAccessKey = NewPointer("")
+ }
+
+ if s.ExportAzureContainer == nil {
+ s.ExportAzureContainer = NewPointer("")
+ }
+
+ if s.ExportAzurePathPrefix == nil {
+ s.ExportAzurePathPrefix = NewPointer("")
+ }
+
+ if s.ExportAzureEndpoint == nil {
+ s.ExportAzureEndpoint = NewPointer("")
+ }
+
+ if s.ExportAzureSSL == nil {
+ s.ExportAzureSSL = NewPointer(true)
+ }
+
+ if s.ExportAzureRequestTimeoutMilliseconds == nil {
+ s.ExportAzureRequestTimeoutMilliseconds = NewPointer(int64(30000))
+ }
}
type EmailSettings struct {
@@ -3365,6 +3442,61 @@ func (s *DataRetentionSettings) GetFileRetentionHours() int {
return DataRetentionSettingsDefaultFileRetentionDays * 24
}
+const (
+ MobileEphemeralModeDefaultDisconnectionTimeoutSeconds = 60
+ MobileEphemeralModeDefaultOfflinePersistenceTimerHours = 24
+ MobileEphemeralModeDefaultAutoCacheCleanupDays = 7
+
+ MobileEphemeralModeMaxDisconnectionTimeoutSeconds = 600
+ MobileEphemeralModeMaxOfflinePersistenceTimerHours = 72
+ MobileEphemeralModeMaxAutoCacheCleanupDays = 60
+)
+
+type MobileEphemeralModeSettings struct {
+ Enable *bool `access:"environment_mobile_security"`
+ DisconnectionTimeoutSeconds *int `access:"environment_mobile_security"`
+ OfflinePersistenceTimerHours *int `access:"environment_mobile_security"`
+ AutoCacheCleanupDays *int `access:"environment_mobile_security"`
+}
+
+func (s *MobileEphemeralModeSettings) SetDefaults() {
+ if s.Enable == nil {
+ s.Enable = NewPointer(false)
+ }
+ if s.DisconnectionTimeoutSeconds == nil {
+ s.DisconnectionTimeoutSeconds = NewPointer(MobileEphemeralModeDefaultDisconnectionTimeoutSeconds)
+ }
+ if s.OfflinePersistenceTimerHours == nil {
+ s.OfflinePersistenceTimerHours = NewPointer(MobileEphemeralModeDefaultOfflinePersistenceTimerHours)
+ }
+ if s.AutoCacheCleanupDays == nil {
+ s.AutoCacheCleanupDays = NewPointer(MobileEphemeralModeDefaultAutoCacheCleanupDays)
+ }
+}
+
+func (s *MobileEphemeralModeSettings) isValid() *AppError {
+ if s.Enable == nil || !*s.Enable {
+ return nil
+ }
+
+ if s.DisconnectionTimeoutSeconds == nil || *s.DisconnectionTimeoutSeconds < 0 || *s.DisconnectionTimeoutSeconds > MobileEphemeralModeMaxDisconnectionTimeoutSeconds {
+ return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error",
+ map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxDisconnectionTimeoutSeconds}, "", http.StatusBadRequest)
+ }
+
+ if s.OfflinePersistenceTimerHours == nil || *s.OfflinePersistenceTimerHours < 0 || *s.OfflinePersistenceTimerHours > MobileEphemeralModeMaxOfflinePersistenceTimerHours {
+ return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error",
+ map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxOfflinePersistenceTimerHours}, "", http.StatusBadRequest)
+ }
+
+ if s.AutoCacheCleanupDays == nil || *s.AutoCacheCleanupDays < 0 || *s.AutoCacheCleanupDays > MobileEphemeralModeMaxAutoCacheCleanupDays {
+ return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error",
+ map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxAutoCacheCleanupDays}, "", http.StatusBadRequest)
+ }
+
+ return nil
+}
+
type JobSettings struct {
RunJobs *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none
RunScheduler *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none
@@ -4002,6 +4134,7 @@ type Config struct {
AnalyticsSettings AnalyticsSettings
ElasticsearchSettings ElasticsearchSettings
DataRetentionSettings DataRetentionSettings
+ MobileEphemeralModeSettings MobileEphemeralModeSettings
MessageExportSettings MessageExportSettings
JobSettings JobSettings
PluginSettings PluginSettings
@@ -4117,6 +4250,7 @@ func (o *Config) SetDefaults() {
o.NativeAppSettings.SetDefaults()
o.IntuneSettings.SetDefaults()
o.DataRetentionSettings.SetDefaults()
+ o.MobileEphemeralModeSettings.SetDefaults()
o.RateLimitSettings.SetDefaults()
o.LogSettings.SetDefaults()
o.ExperimentalAuditSettings.SetDefaults()
@@ -4296,6 +4430,10 @@ func (o *Config) IsValid() *AppError {
return appErr
}
+ if appErr := o.MobileEphemeralModeSettings.isValid(); appErr != nil {
+ return appErr
+ }
+
if appErr := o.GuestAccountsSettings.IsValid(); appErr != nil {
return appErr
}
@@ -4396,7 +4534,7 @@ func (s *FileSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.max_file_size.app_error", nil, "", http.StatusBadRequest)
}
- if !(*s.DriverName == ImageDriverLocal || *s.DriverName == ImageDriverS3) {
+ if !(*s.DriverName == ImageDriverLocal || *s.DriverName == ImageDriverS3 || *s.DriverName == ImageDriverAzure) {
return NewAppError("Config.IsValid", "model.config.is_valid.file_driver.app_error", nil, "", http.StatusBadRequest)
}
@@ -4421,6 +4559,10 @@ func (s *FileSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.amazons3_timeout.app_error", map[string]any{"Value": *s.MaxImageDecoderConcurrency}, "", http.StatusBadRequest)
}
+ if *s.AzureRequestTimeoutMilliseconds <= 0 || *s.AzureRequestTimeoutMilliseconds > maxAzureRequestTimeoutMilliseconds {
+ return NewAppError("Config.IsValid", "model.config.is_valid.azure_timeout.app_error", map[string]any{"Value": *s.AzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest)
+ }
+
if *s.AmazonS3StorageClass != "" && !slices.Contains([]string{StorageClassStandard, StorageClassReducedRedundancy, StorageClassStandardIA, StorageClassOnezoneIA, StorageClassIntelligentTiering, StorageClassGlacier, StorageClassDeepArchive, StorageClassOutposts, StorageClassGlacierIR, StorageClassSnow, StorageClassExpressOnezone}, *s.AmazonS3StorageClass) {
return NewAppError("Config.IsValid", "model.config.is_valid.storage_class.app_error", map[string]any{"Value": *s.AmazonS3StorageClass}, "", http.StatusBadRequest)
}
@@ -4429,14 +4571,34 @@ func (s *FileSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.AmazonS3PathPrefix", "Value": *s.AmazonS3PathPrefix}, "", http.StatusBadRequest)
}
+ if strings.TrimSpace(*s.AzurePathPrefix) != *s.AzurePathPrefix {
+ return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.AzurePathPrefix", "Value": *s.AzurePathPrefix}, "", http.StatusBadRequest)
+ }
+
+ if strings.Contains(*s.AzurePathPrefix, "..") {
+ return NewAppError("Config.IsValid", "model.config.is_valid.directory_traversal.app_error", map[string]any{"Setting": "FileSettings.AzurePathPrefix", "Value": *s.AzurePathPrefix}, "", http.StatusBadRequest)
+ }
+
if *s.ExportAmazonS3StorageClass != "" && !slices.Contains([]string{StorageClassStandard, StorageClassReducedRedundancy, StorageClassStandardIA, StorageClassOnezoneIA, StorageClassIntelligentTiering, StorageClassGlacier, StorageClassDeepArchive, StorageClassOutposts, StorageClassGlacierIR, StorageClassSnow, StorageClassExpressOnezone}, *s.ExportAmazonS3StorageClass) {
return NewAppError("Config.IsValid", "model.config.is_valid.storage_class.app_error", map[string]any{"Value": *s.ExportAmazonS3StorageClass}, "", http.StatusBadRequest)
}
+ if *s.ExportAzureRequestTimeoutMilliseconds <= 0 || *s.ExportAzureRequestTimeoutMilliseconds > maxAzureRequestTimeoutMilliseconds {
+ return NewAppError("Config.IsValid", "model.config.is_valid.export_azure_timeout.app_error", map[string]any{"Value": *s.ExportAzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest)
+ }
+
if strings.TrimSpace(*s.ExportAmazonS3PathPrefix) != *s.ExportAmazonS3PathPrefix {
return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.ExportAmazonS3PathPrefix", "Value": *s.ExportAmazonS3PathPrefix}, "", http.StatusBadRequest)
}
+ if strings.TrimSpace(*s.ExportAzurePathPrefix) != *s.ExportAzurePathPrefix {
+ return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.ExportAzurePathPrefix", "Value": *s.ExportAzurePathPrefix}, "", http.StatusBadRequest)
+ }
+
+ if strings.Contains(*s.ExportAzurePathPrefix, "..") {
+ return NewAppError("Config.IsValid", "model.config.is_valid.directory_traversal.app_error", map[string]any{"Setting": "FileSettings.ExportAzurePathPrefix", "Value": *s.ExportAzurePathPrefix}, "", http.StatusBadRequest)
+ }
+
if strings.TrimSpace(*s.ExportDirectory) != *s.ExportDirectory {
return NewAppError("Config.IsValid", "model.config.is_valid.directory_whitespace.app_error", map[string]any{"Setting": "FileSettings.ExportDirectory", "Value": *s.ExportDirectory}, "", http.StatusBadRequest)
}
@@ -5061,6 +5223,14 @@ func (o *Config) Sanitize(pluginManifests []*Manifest, opts *SanitizeOptions) {
*o.FileSettings.ExportAmazonS3SecretAccessKey = FakeSetting
}
+ if o.FileSettings.AzureAccessKey != nil && *o.FileSettings.AzureAccessKey != "" {
+ *o.FileSettings.AzureAccessKey = FakeSetting
+ }
+
+ if o.FileSettings.ExportAzureAccessKey != nil && *o.FileSettings.ExportAzureAccessKey != "" {
+ *o.FileSettings.ExportAzureAccessKey = FakeSetting
+ }
+
if o.EmailSettings.SMTPPassword != nil && *o.EmailSettings.SMTPPassword != "" {
*o.EmailSettings.SMTPPassword = FakeSetting
}
@@ -5093,10 +5263,6 @@ func (o *Config) Sanitize(pluginManifests []*Manifest, opts *SanitizeOptions) {
*o.ElasticsearchSettings.Password = FakeSetting
}
- if o.ElasticsearchSettings.ClientKey != nil && *o.ElasticsearchSettings.ClientKey != "" {
- *o.ElasticsearchSettings.ClientKey = FakeSetting
- }
-
for i := range o.SqlSettings.DataSourceReplicas {
o.SqlSettings.DataSourceReplicas[i] = sanitizeDataSourceField(o.SqlSettings.DataSourceReplicas[i], "SqlSettings.DataSourceReplicas")
}
@@ -5288,7 +5454,7 @@ func structToMapFilteredByTag(t any, typeOfTag, filterTag string) map[string]any
switch field.Kind() {
case reflect.Struct:
value = structToMapFilteredByTag(field.Interface(), typeOfTag, filterTag)
- case reflect.Ptr:
+ case reflect.Pointer:
indirectType := field.Elem()
if indirectType.Kind() == reflect.Struct {
value = structToMapFilteredByTag(indirectType.Interface(), typeOfTag, filterTag)
diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go
index 4a63f61c7a0..a0781e51a7d 100644
--- a/server/public/model/config_test.go
+++ b/server/public/model/config_test.go
@@ -32,7 +32,7 @@ func TestConfigDefaults(t *testing.T) {
t.Run("nowhere nil when partially initialized", func(t *testing.T) {
var recursivelyUninitialize func(*Config, string, reflect.Value)
recursivelyUninitialize = func(config *Config, name string, v reflect.Value) {
- if v.Type().Kind() == reflect.Ptr {
+ if v.Type().Kind() == reflect.Pointer {
// Ignoring these 2 settings.
// TODO: remove them completely in v8.0.
if name == "config.ElasticsearchSettings.BulkIndexingTimeWindowSeconds" ||
@@ -296,6 +296,59 @@ func TestFileSettingsDirectoryWhitespaceValidation(t *testing.T) {
}
}
+func TestFileSettingsAzureRequestTimeoutBounds(t *testing.T) {
+ cases := []struct {
+ name string
+ value int64
+ configSetter func(*Config, *int64)
+ errID string
+ }{
+ {"AzureRequestTimeoutMilliseconds zero", 0, func(cfg *Config, v *int64) { cfg.FileSettings.AzureRequestTimeoutMilliseconds = v }, "model.config.is_valid.azure_timeout.app_error"},
+ {"AzureRequestTimeoutMilliseconds negative", -1, func(cfg *Config, v *int64) { cfg.FileSettings.AzureRequestTimeoutMilliseconds = v }, "model.config.is_valid.azure_timeout.app_error"},
+ {"AzureRequestTimeoutMilliseconds above ceiling", maxAzureRequestTimeoutMilliseconds + 1, func(cfg *Config, v *int64) { cfg.FileSettings.AzureRequestTimeoutMilliseconds = v }, "model.config.is_valid.azure_timeout.app_error"},
+ {"ExportAzureRequestTimeoutMilliseconds zero", 0, func(cfg *Config, v *int64) { cfg.FileSettings.ExportAzureRequestTimeoutMilliseconds = v }, "model.config.is_valid.export_azure_timeout.app_error"},
+ {"ExportAzureRequestTimeoutMilliseconds above ceiling", maxAzureRequestTimeoutMilliseconds + 1, func(cfg *Config, v *int64) { cfg.FileSettings.ExportAzureRequestTimeoutMilliseconds = v }, "model.config.is_valid.export_azure_timeout.app_error"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &Config{}
+ cfg.SetDefaults()
+ tc.configSetter(cfg, NewPointer(tc.value))
+
+ err := cfg.FileSettings.isValid()
+ require.NotNil(t, err)
+ assert.Equal(t, tc.errID, err.Id)
+ })
+ }
+}
+
+func TestFileSettingsAzurePathPrefixTraversal(t *testing.T) {
+ cases := []struct {
+ name string
+ configSetter func(*Config, *string)
+ }{
+ {
+ "AzurePathPrefix",
+ func(cfg *Config, value *string) { cfg.FileSettings.AzurePathPrefix = value },
+ },
+ {
+ "ExportAzurePathPrefix",
+ func(cfg *Config, value *string) { cfg.FileSettings.ExportAzurePathPrefix = value },
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &Config{}
+ cfg.SetDefaults()
+ tc.configSetter(cfg, NewPointer("../escape"))
+
+ err := cfg.FileSettings.isValid()
+ require.NotNil(t, err)
+ assert.Equal(t, "model.config.is_valid.directory_traversal.app_error", err.Id)
+ })
+ }
+}
+
func TestConfigDefaultSignatureAlgorithm(t *testing.T) {
c1 := Config{}
c1.SetDefaults()
@@ -1596,7 +1649,6 @@ func TestConfigSanitize(t *testing.T) {
*c.OpenIdSettings.Secret = "secret"
*c.ServiceSettings.GoogleDeveloperKey = "google-api-key"
*c.ServiceSettings.GiphySdkKey = "giphy-sdk-key"
- *c.ElasticsearchSettings.ClientKey = "/path/to/client-key.pem"
*c.AutoTranslationSettings.LibreTranslate.APIKey = "libre-api-key"
c.SqlSettings.DataSourceReplicas = []string{"stuff"}
c.SqlSettings.DataSourceSearchReplicas = []string{"stuff"}
@@ -1619,7 +1671,6 @@ func TestConfigSanitize(t *testing.T) {
assert.Equal(t, FakeSetting, *c.SqlSettings.DataSource)
assert.Equal(t, FakeSetting, *c.SqlSettings.AtRestEncryptKey)
assert.Equal(t, FakeSetting, *c.ElasticsearchSettings.Password)
- assert.Equal(t, FakeSetting, *c.ElasticsearchSettings.ClientKey)
assert.Equal(t, FakeSetting, *c.ServiceSettings.GoogleDeveloperKey)
assert.Equal(t, FakeSetting, *c.ServiceSettings.GiphySdkKey)
assert.Equal(t, FakeSetting, c.SqlSettings.DataSourceReplicas[0])
@@ -2884,10 +2935,10 @@ func TestConfigAccessTagsMapToValidPermissions(t *testing.T) {
fieldPath := path + "." + field.Name
elemType := field.Type
- if elemType.Kind() == reflect.Ptr || elemType.Kind() == reflect.Slice {
+ if elemType.Kind() == reflect.Pointer || elemType.Kind() == reflect.Slice {
elemType = elemType.Elem()
}
- if elemType.Kind() == reflect.Ptr {
+ if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
@@ -2922,6 +2973,121 @@ func TestConfigAccessTagsMapToValidPermissions(t *testing.T) {
checkStruct(t, reflect.TypeFor[Config](), "Config")
}
+func TestMobileEphemeralModeSettingsDefaults(t *testing.T) {
+ c := Config{}
+ c.SetDefaults()
+
+ require.False(t, *c.MobileEphemeralModeSettings.Enable)
+ require.Equal(t, MobileEphemeralModeDefaultDisconnectionTimeoutSeconds, *c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds)
+ require.Equal(t, MobileEphemeralModeDefaultOfflinePersistenceTimerHours, *c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours)
+ require.Equal(t, MobileEphemeralModeDefaultAutoCacheCleanupDays, *c.MobileEphemeralModeSettings.AutoCacheCleanupDays)
+}
+
+func TestMobileEphemeralModeSettingsIsValid(t *testing.T) {
+ testCases := []struct {
+ name string
+ settings MobileEphemeralModeSettings
+ expectError bool
+ errorId string
+ }{
+ {
+ name: "disabled settings should be valid",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(false),
+ },
+ expectError: false,
+ },
+ {
+ name: "enabled with valid values",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(120),
+ OfflinePersistenceTimerHours: NewPointer(24),
+ AutoCacheCleanupDays: NewPointer(7),
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid disconnection timeout above max",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(MobileEphemeralModeMaxDisconnectionTimeoutSeconds + 1),
+ OfflinePersistenceTimerHours: NewPointer(0),
+ AutoCacheCleanupDays: NewPointer(0),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error",
+ },
+ {
+ name: "invalid offline persistence above max",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(60),
+ OfflinePersistenceTimerHours: NewPointer(MobileEphemeralModeMaxOfflinePersistenceTimerHours + 1),
+ AutoCacheCleanupDays: NewPointer(0),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error",
+ },
+ {
+ name: "invalid auto cache cleanup above max",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(60),
+ OfflinePersistenceTimerHours: NewPointer(0),
+ AutoCacheCleanupDays: NewPointer(MobileEphemeralModeMaxAutoCacheCleanupDays + 1),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error",
+ },
+ {
+ name: "invalid negative disconnection timeout",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(-1),
+ OfflinePersistenceTimerHours: NewPointer(0),
+ AutoCacheCleanupDays: NewPointer(0),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error",
+ },
+ {
+ name: "invalid negative offline persistence",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(60),
+ OfflinePersistenceTimerHours: NewPointer(-1),
+ AutoCacheCleanupDays: NewPointer(0),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error",
+ },
+ {
+ name: "invalid negative auto cache cleanup",
+ settings: MobileEphemeralModeSettings{
+ Enable: NewPointer(true),
+ DisconnectionTimeoutSeconds: NewPointer(60),
+ OfflinePersistenceTimerHours: NewPointer(0),
+ AutoCacheCleanupDays: NewPointer(-1),
+ },
+ expectError: true,
+ errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.settings.isValid()
+ if tc.expectError {
+ require.NotNil(t, err)
+ require.Equal(t, tc.errorId, err.Id)
+ } else {
+ require.Nil(t, err)
+ }
+ })
+ }
+}
+
func TestNativeAppSettingsIsValid(t *testing.T) {
t.Run("defaults are valid", func(t *testing.T) {
cfg := Config{}
diff --git a/server/public/model/content_flagging.go b/server/public/model/content_flagging.go
index 073a4b8c6c2..207aa0fb629 100644
--- a/server/public/model/content_flagging.go
+++ b/server/public/model/content_flagging.go
@@ -26,6 +26,11 @@ const (
ContentFlaggingStatusRetained = "Retained"
)
+const (
+ ContentFlaggingActionKeep = "keep"
+ ContentFlaggingActionRemove = "remove"
+)
+
type FlagContentRequest struct {
Reason string `json:"reason"`
Comment string `json:"comment,omitempty"`
@@ -53,6 +58,7 @@ func (f *FlagContentRequest) IsValid(commentRequired bool, validReasons []string
type FlagContentActionRequest struct {
Comment string `json:"comment,omitempty"`
+ Action string `json:"action,omitempty"`
}
func (f *FlagContentActionRequest) IsValid(commentRequired bool) *AppError {
diff --git a/server/public/model/content_flagging_report.go b/server/public/model/content_flagging_report.go
index 247566112a3..aaae6e650d2 100644
--- a/server/public/model/content_flagging_report.go
+++ b/server/public/model/content_flagging_report.go
@@ -74,6 +74,9 @@ type FlaggedPostReportContentReview struct {
ReviewerUsername string `yaml:"reviewer_username,omitempty"`
ReviewerComment string `yaml:"reviewer_comment,omitempty"`
ActionTime int64 `yaml:"action_time,omitempty"`
+ ActorDecision string `yaml:"actor_decision,omitempty"`
+ ActorUserId string `yaml:"actor_user_id,omitempty"`
+ ActorUsername string `yaml:"actor_username,omitempty"`
}
// FlaggedPostReportMetadata is the on-disk shape for report_metadata.yaml.
diff --git a/server/public/model/custom_profile_attributes.go b/server/public/model/custom_profile_attributes.go
index 9db88a2bdad..1f615f00f02 100644
--- a/server/public/model/custom_profile_attributes.go
+++ b/server/public/model/custom_profile_attributes.go
@@ -14,33 +14,33 @@ import (
"errors"
"fmt"
"net/http"
- "net/url"
"regexp"
- "strings"
- "unicode/utf8"
+ "sort"
)
-const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes"
-
+// CPA-prefixed aliases for the canonical PropertyField* constants in
+// property_field_attrs_validation.go. Aliasing (not redeclaring) keeps CPA
+// writes and property-hook reads keyed on the same string at compile time,
+// so a rename to one side cannot silently diverge from the other.
const (
// Attributes keys
- CustomProfileAttributesPropertyAttrsSortOrder = "sort_order"
- CustomProfileAttributesPropertyAttrsValueType = "value_type"
- CustomProfileAttributesPropertyAttrsVisibility = "visibility"
- CustomProfileAttributesPropertyAttrsLDAP = "ldap"
- CustomProfileAttributesPropertyAttrsSAML = "saml"
- CustomProfileAttributesPropertyAttrsManaged = "managed"
- CustomProfileAttributesPropertyAttrsDisplayName = "display_name"
+ CustomProfileAttributesPropertyAttrsSortOrder = PropertyFieldAttrSortOrder
+ CustomProfileAttributesPropertyAttrsValueType = PropertyFieldAttrValueType
+ CustomProfileAttributesPropertyAttrsVisibility = PropertyFieldAttrVisibility
+ CustomProfileAttributesPropertyAttrsLDAP = PropertyFieldAttrLDAP
+ CustomProfileAttributesPropertyAttrsSAML = PropertyFieldAttrSAML
+ CustomProfileAttributesPropertyAttrsManaged = PropertyFieldAttrManaged
+ CustomProfileAttributesPropertyAttrsDisplayName = PropertyFieldAttrDisplayName
// Value Types
- CustomProfileAttributesValueTypeEmail = "email"
- CustomProfileAttributesValueTypeURL = "url"
- CustomProfileAttributesValueTypePhone = "phone"
+ CustomProfileAttributesValueTypeEmail = PropertyFieldValueTypeEmail
+ CustomProfileAttributesValueTypeURL = PropertyFieldValueTypeURL
+ CustomProfileAttributesValueTypePhone = PropertyFieldValueTypePhone
// Visibility
- CustomProfileAttributesVisibilityHidden = "hidden"
- CustomProfileAttributesVisibilityWhenSet = "when_set"
- CustomProfileAttributesVisibilityAlways = "always"
+ CustomProfileAttributesVisibilityHidden = PropertyFieldVisibilityHidden
+ CustomProfileAttributesVisibilityWhenSet = PropertyFieldVisibilityWhenSet
+ CustomProfileAttributesVisibilityAlways = PropertyFieldVisibilityAlways
CustomProfileAttributesVisibilityDefault = CustomProfileAttributesVisibilityWhenSet
// CPA options
@@ -48,31 +48,9 @@ const (
CPAOptionColorMaxLength = 128
// CPA value constraints
- CPAValueTypeTextMaxLength = 64
+ CPAValueTypeTextMaxLength = PropertyFieldValueTypeTextMaxLength
)
-func IsKnownCPAValueType(valueType string) bool {
- switch valueType {
- case CustomProfileAttributesValueTypeEmail,
- CustomProfileAttributesValueTypeURL,
- CustomProfileAttributesValueTypePhone:
- return true
- }
-
- return false
-}
-
-func IsKnownCPAVisibility(visibility string) bool {
- switch visibility {
- case CustomProfileAttributesVisibilityHidden,
- CustomProfileAttributesVisibilityWhenSet,
- CustomProfileAttributesVisibilityAlways:
- return true
- }
-
- return false
-}
-
// CPAFieldNamePattern defines the character set allowed for CPA field names.
// Matches the CEL IDENTIFIER grammar (^[A-Za-z_][A-Za-z0-9_]*$) used by the
// ABAC engine (cel-go v0.27.0). Leading underscore is permitted — this is consistent
@@ -200,13 +178,6 @@ func (c *CPAField) IsAdminManaged() bool {
return c.Attrs.Managed == "admin"
}
-// SetDefaults sets default values for CPAField attributes
-func (c *CPAField) SetDefaults() {
- if c.Attrs.Visibility == "" {
- c.Attrs.Visibility = CustomProfileAttributesVisibilityDefault
- }
-}
-
// Patch applies a PropertyFieldPatch to the CPAField by converting to PropertyField,
// applying the patch, and converting back. This ensures we only maintain one patch logic path.
// Custom profile attributes doesn't use targets, so TargetID and TargetType are cleared.
@@ -253,101 +224,6 @@ func (c *CPAField) ToPropertyField() *PropertyField {
return &pf
}
-// SupportsOptions checks the CPAField type and determines if the type
-// supports the use of options
-func (c *CPAField) SupportsOptions() bool {
- return c.Type == PropertyFieldTypeSelect || c.Type == PropertyFieldTypeMultiselect
-}
-
-// SupportsSyncing checks the CPAField type and determines if it
-// supports syncing with external sources of truth
-func (c *CPAField) SupportsSyncing() bool {
- return c.Type == PropertyFieldTypeText
-}
-
-func (c *CPAField) SanitizeAndValidate() *AppError {
- c.SetDefaults()
-
- // first we clean unused attributes depending on the field type
- if !c.SupportsOptions() {
- c.Attrs.Options = nil
- }
- if !c.SupportsSyncing() {
- c.Attrs.LDAP = ""
- c.Attrs.SAML = ""
- }
-
- // Clear sync properties if managed is set (mutual exclusivity)
- if c.IsAdminManaged() {
- c.Attrs.LDAP = ""
- c.Attrs.SAML = ""
- }
-
- switch c.Type {
- case PropertyFieldTypeText:
- if valueType := strings.TrimSpace(c.Attrs.ValueType); valueType != "" {
- if !IsKnownCPAValueType(valueType) {
- return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
- "AttributeName": CustomProfileAttributesPropertyAttrsValueType,
- "Reason": "unknown value type",
- }, "", http.StatusUnprocessableEntity)
- }
- c.Attrs.ValueType = valueType
- }
-
- case PropertyFieldTypeSelect, PropertyFieldTypeMultiselect:
- options := c.Attrs.Options
-
- // add an ID to options with no ID
- for i := range options {
- if options[i].ID == "" {
- options[i].ID = NewId()
- }
- }
-
- if err := options.IsValid(); err != nil {
- return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
- "AttributeName": PropertyFieldAttributeOptions,
- "Reason": err.Error(),
- }, "", http.StatusUnprocessableEntity).Wrap(err)
- }
- c.Attrs.Options = options
- }
-
- // Validate visibility
- if visibilityAttr := strings.TrimSpace(c.Attrs.Visibility); visibilityAttr != "" {
- if !IsKnownCPAVisibility(visibilityAttr) {
- return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
- "AttributeName": CustomProfileAttributesPropertyAttrsVisibility,
- "Reason": "unknown visibility",
- }, "", http.StatusUnprocessableEntity)
- }
- c.Attrs.Visibility = visibilityAttr
- }
-
- // Validate managed field
- if managed := strings.TrimSpace(c.Attrs.Managed); managed != "" {
- if managed != "admin" {
- return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
- "AttributeName": CustomProfileAttributesPropertyAttrsManaged,
- "Reason": "unknown managed type",
- }, "", http.StatusBadRequest)
- }
- c.Attrs.Managed = managed
- }
-
- // Sanitize and validate display_name
- // Reuses PropertyFieldNameMaxRunes to keep the DisplayName cap aligned with the Name cap; do NOT introduce a separate constant.
- c.Attrs.DisplayName = strings.TrimSpace(c.Attrs.DisplayName)
- if utf8.RuneCountInString(c.Attrs.DisplayName) > PropertyFieldNameMaxRunes {
- return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.display_name_too_long.app_error", map[string]any{
- "MaxRunes": PropertyFieldNameMaxRunes,
- }, "", http.StatusUnprocessableEntity)
- }
-
- return nil
-}
-
func NewCPAFieldFromPropertyField(pf *PropertyField) (*CPAField, error) {
attrsJSON, err := json.Marshal(pf.Attrs)
if err != nil {
@@ -365,83 +241,27 @@ func NewCPAFieldFromPropertyField(pf *PropertyField) (*CPAField, error) {
Attrs: attrs,
}
- cpaField.SetDefaults()
-
return cpaField, nil
}
-// SanitizeAndValidatePropertyValue validates and sanitizes the given
-// property value based on the field type
-func SanitizeAndValidatePropertyValue(cpaField *CPAField, rawValue json.RawMessage) (json.RawMessage, error) {
- fieldType := cpaField.Type
-
- // build a list of existing options so we can check later if the values exist
- optionsMap := map[string]struct{}{}
- for _, v := range cpaField.Attrs.Options {
- optionsMap[v.ID] = struct{}{}
- }
-
- switch fieldType {
- case PropertyFieldTypeText, PropertyFieldTypeDate, PropertyFieldTypeSelect, PropertyFieldTypeUser:
- var value string
- if err := json.Unmarshal(rawValue, &value); err != nil {
+// CPAFieldsFromPropertyFields converts a slice of PropertyFields to CPAFields
+// and sorts the result by Attrs.SortOrder ascending.
+func CPAFieldsFromPropertyFields(pfs []*PropertyField) ([]*CPAField, error) {
+ cpaFields := make([]*CPAField, 0, len(pfs))
+ for _, pf := range pfs {
+ cpaField, err := NewCPAFieldFromPropertyField(pf)
+ if err != nil {
return nil, err
}
- value = strings.TrimSpace(value)
-
- if fieldType == PropertyFieldTypeText {
- if len(value) > CPAValueTypeTextMaxLength {
- return nil, fmt.Errorf("value too long")
- }
-
- if cpaField.Attrs.ValueType == CustomProfileAttributesValueTypeEmail && !IsValidEmail(value) {
- return nil, fmt.Errorf("invalid email")
- }
-
- if cpaField.Attrs.ValueType == CustomProfileAttributesValueTypeURL {
- _, err := url.Parse(value)
- if err != nil {
- return nil, fmt.Errorf("invalid url: %w", err)
- }
- }
- }
-
- if fieldType == PropertyFieldTypeSelect && value != "" {
- if _, ok := optionsMap[value]; !ok {
- return nil, fmt.Errorf("option \"%s\" does not exist", value)
- }
- }
-
- if fieldType == PropertyFieldTypeUser && value != "" && !IsValidId(value) {
- return nil, fmt.Errorf("invalid user id")
- }
- return json.Marshal(value)
-
- case PropertyFieldTypeMultiselect, PropertyFieldTypeMultiuser:
- var values []string
- if err := json.Unmarshal(rawValue, &values); err != nil {
- return nil, err
- }
- filteredValues := make([]string, 0, len(values))
- for _, v := range values {
- trimmed := strings.TrimSpace(v)
- if trimmed == "" {
- continue
- }
- if fieldType == PropertyFieldTypeMultiselect {
- if _, ok := optionsMap[v]; !ok {
- return nil, fmt.Errorf("option \"%s\" does not exist", v)
- }
- }
-
- if fieldType == PropertyFieldTypeMultiuser && !IsValidId(trimmed) {
- return nil, fmt.Errorf("invalid user id: %s", trimmed)
- }
- filteredValues = append(filteredValues, trimmed)
- }
- return json.Marshal(filteredValues)
-
- default:
- return nil, fmt.Errorf("unknown field type: %s", fieldType)
+ cpaFields = append(cpaFields, cpaField)
}
+
+ sort.Slice(cpaFields, func(i, j int) bool {
+ if cpaFields[i].Attrs.SortOrder != cpaFields[j].Attrs.SortOrder {
+ return cpaFields[i].Attrs.SortOrder < cpaFields[j].Attrs.SortOrder
+ }
+ return cpaFields[i].ID < cpaFields[j].ID
+ })
+
+ return cpaFields, nil
}
diff --git a/server/public/model/custom_profile_attributes_test.go b/server/public/model/custom_profile_attributes_test.go
index 4015c08d28f..85d651fd62e 100644
--- a/server/public/model/custom_profile_attributes_test.go
+++ b/server/public/model/custom_profile_attributes_test.go
@@ -4,7 +4,6 @@
package model
import (
- "encoding/json"
"fmt"
"strings"
"testing"
@@ -24,7 +23,7 @@ func TestNewCPAFieldFromPropertyField(t *testing.T) {
name: "valid property field with all attributes",
propertyField: &PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{
@@ -60,7 +59,7 @@ func TestNewCPAFieldFromPropertyField(t *testing.T) {
name: "valid property field with minimal attributes",
propertyField: &PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeText,
Attrs: StringInterface{
@@ -79,22 +78,20 @@ func TestNewCPAFieldFromPropertyField(t *testing.T) {
wantErr: false,
},
{
- name: "property field with empty attributes returns default values",
+ // Conversion is a pure data operation: empty PropertyField.Attrs
+ // produces empty CPAAttrs. The visibility default is applied at
+ // write time by AccessControlAttributeValidationHook, not at read time.
+ name: "property field with empty attributes returns empty CPAAttrs",
propertyField: &PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Empty Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
},
- wantAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityWhenSet, // Defaults are applied during conversion
- SortOrder: 0,
- ValueType: "",
- Options: nil,
- },
- wantErr: false,
+ wantAttrs: CPAAttrs{},
+ wantErr: false,
},
}
@@ -146,7 +143,7 @@ func TestCPAFieldToPropertyField(t *testing.T) {
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeSelect,
CreateAt: GetMillis(),
@@ -171,7 +168,7 @@ func TestCPAFieldToPropertyField(t *testing.T) {
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Test Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
@@ -188,7 +185,7 @@ func TestCPAFieldToPropertyField(t *testing.T) {
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Empty Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
@@ -238,7 +235,7 @@ func TestCPAFieldToPropertyField(t *testing.T) {
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Managed Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
@@ -256,7 +253,7 @@ func TestCPAFieldToPropertyField(t *testing.T) {
cpaField: &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "Non-managed Field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
@@ -390,565 +387,8 @@ func TestCustomProfileAttributeSelectOptionIsValid(t *testing.T) {
}
}
-func TestCPAField_SanitizeAndValidate(t *testing.T) {
- tests := []struct {
- name string
- field *CPAField
- expectError bool
- errorId string
- expectedAttrs CPAAttrs
- checkOptionsID bool
- }{
- {
- name: "valid text field with no value type",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: "when_set",
- },
- },
- {
- name: "valid text field with valid value type and whitespace",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- ValueType: " email ",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: "when_set",
- ValueType: CustomProfileAttributesValueTypeEmail,
- },
- },
- {
- name: "valid text field with visibility and whitespace",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Visibility: " hidden ",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityHidden,
- },
- },
- {
- name: "invalid text field with invalid value type",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- ValueType: "invalid_type",
- },
- },
- expectError: true,
- errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
- },
- {
- name: "valid select field with valid options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeSelect,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- Name: "Option 1",
- Color: "#123456",
- },
- {
- Name: "Option 2",
- Color: "#654321",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {Name: "Option 1", Color: "#123456"},
- {Name: "Option 2", Color: "#654321"},
- },
- },
- },
- {
- name: "valid select field with valid options with ids",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeSelect,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: "t9ceh651eir4zkhyh4m54s5r7w",
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {ID: "t9ceh651eir4zkhyh4m54s5r7w", Name: "Option 1", Color: "#123456"},
- },
- },
- checkOptionsID: true,
- },
- {
- name: "invalid select field with duplicate option names",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeSelect,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- Name: "Option 1",
- Color: "opt1",
- },
- {
- Name: "Option 1",
- Color: "opt2",
- },
- },
- },
- },
- expectError: true,
- errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
- },
- {
- name: "invalid field with unknown visibility",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Visibility: "unknown",
- },
- },
- expectError: true,
- errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
- },
-
- // Test options cleaning for types that don't support options
- {
- name: "text field with options should clean options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: nil, // Options should be cleaned
- },
- },
- {
- name: "date field with options should clean options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeDate,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: nil, // Options should be cleaned
- },
- },
- {
- name: "user field with options should clean options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeUser,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: nil, // Options should be cleaned
- },
- },
-
- // Test options preservation for types that support options
- {
- name: "select field with options should preserve options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeSelect,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {Name: "Option 1", Color: "#123456"},
- },
- },
- },
- {
- name: "multiselect field with options should preserve options",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeMultiselect,
- },
- Attrs: CPAAttrs{
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {Name: "Option 1", Color: "#123456"},
- },
- },
- },
-
- // Test syncing attributes cleaning for types that don't support syncing
- {
- name: "select field with LDAP and SAML should clean syncing attributes",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeSelect,
- },
- Attrs: CPAAttrs{
- LDAP: "ldap_attribute",
- SAML: "saml_attribute",
- Options: []*CustomProfileAttributesSelectOption{
- {
- ID: NewId(),
- Name: "Option 1",
- Color: "#123456",
- },
- },
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- LDAP: "", // Should be cleaned
- SAML: "", // Should be cleaned
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {Name: "Option 1", Color: "#123456"},
- },
- },
- },
- {
- name: "date field with LDAP and SAML should clean syncing attributes",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeDate,
- },
- Attrs: CPAAttrs{
- LDAP: "ldap_attribute",
- SAML: "saml_attribute",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- LDAP: "", // Should be cleaned
- SAML: "", // Should be cleaned
- },
- },
-
- // Test syncing attributes preservation for types that support syncing
- {
- name: "text field with LDAP and SAML should preserve syncing attributes",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- LDAP: "ldap_attribute",
- SAML: "saml_attribute",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- LDAP: "ldap_attribute", // Should be preserved
- SAML: "saml_attribute", // Should be preserved
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := tt.field.SanitizeAndValidate()
- if tt.expectError {
- require.NotNil(t, err)
- require.Equal(t, tt.errorId, err.Id)
- } else {
- var ogErr error
- if err != nil {
- ogErr = err.Unwrap()
- }
- require.Nilf(t, err, "unexpected error: %v, with original error: %v", err, ogErr)
-
- assert.Equal(t, tt.expectedAttrs.Visibility, tt.field.Attrs.Visibility)
- assert.Equal(t, tt.expectedAttrs.ValueType, tt.field.Attrs.ValueType)
-
- for i := range tt.expectedAttrs.Options {
- if tt.checkOptionsID {
- assert.Equal(t, tt.expectedAttrs.Options[i].ID, tt.field.Attrs.Options[i].ID)
- }
- assert.Equal(t, tt.expectedAttrs.Options[i].Name, tt.field.Attrs.Options[i].Name)
- assert.Equal(t, tt.expectedAttrs.Options[i].Color, tt.field.Attrs.Options[i].Color)
- }
- }
- })
- }
-
- // Test managed fields functionality
- t.Run("managed fields", func(t *testing.T) {
- managedTests := []struct {
- name string
- field *CPAField
- expectError bool
- errorId string
- expectedAttrs CPAAttrs
- }{
- {
- name: "valid managed field with admin value",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Managed: "admin",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Managed: "admin",
- },
- },
- {
- name: "managed field with whitespace should be trimmed",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Managed: " admin ",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Managed: "admin",
- },
- },
- {
- name: "field with empty managed should be allowed",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Managed: "",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Managed: "",
- },
- },
- {
- name: "field with invalid managed value should fail",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Managed: "invalid",
- },
- },
- expectError: true,
- errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
- },
- {
- name: "managed field should clear LDAP sync properties",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- Managed: "admin",
- LDAP: "ldap_attribute",
- SAML: "saml_attribute",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Managed: "admin",
- LDAP: "", // Should be cleared
- SAML: "", // Should be cleared
- },
- },
- {
- name: "managed field should clear sync properties even when field supports syncing",
- field: &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText, // Text fields support syncing
- },
- Attrs: CPAAttrs{
- Managed: "admin",
- LDAP: "ldap_attribute",
- },
- },
- expectError: false,
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- Managed: "admin",
- LDAP: "", // Should be cleared due to mutual exclusivity
- SAML: "",
- },
- },
- }
-
- for _, tt := range managedTests {
- t.Run(tt.name, func(t *testing.T) {
- err := tt.field.SanitizeAndValidate()
- if tt.expectError {
- require.NotNil(t, err)
- require.Equal(t, tt.errorId, err.Id)
- } else {
- require.Nil(t, err)
- assert.Equal(t, tt.expectedAttrs.Visibility, tt.field.Attrs.Visibility)
- assert.Equal(t, tt.expectedAttrs.Managed, tt.field.Attrs.Managed)
- assert.Equal(t, tt.expectedAttrs.LDAP, tt.field.Attrs.LDAP)
- assert.Equal(t, tt.expectedAttrs.SAML, tt.field.Attrs.SAML)
- }
- })
- }
- })
-
- t.Run("display_name sanitization", func(t *testing.T) {
- displayNameTests := []struct {
- name string
- displayName string
- expectError bool
- errorId string
- expectedValue string
- }{
- {
- name: "empty display_name is allowed",
- displayName: "",
- expectError: false,
- expectedValue: "",
- },
- {
- name: "display_name with surrounding whitespace is trimmed",
- displayName: " Department Head ",
- expectError: false,
- expectedValue: "Department Head",
- },
- {
- name: "all-whitespace display_name is trimmed to empty and allowed",
- displayName: " ",
- expectError: false,
- expectedValue: "",
- },
- {
- name: "display_name at exactly 255 runes is accepted",
- displayName: strings.Repeat("a", PropertyFieldNameMaxRunes),
- expectError: false,
- expectedValue: strings.Repeat("a", PropertyFieldNameMaxRunes),
- },
- {
- name: "display_name at 256 runes is rejected",
- displayName: strings.Repeat("a", PropertyFieldNameMaxRunes+1),
- expectError: true,
- errorId: "app.custom_profile_attributes.sanitize_and_validate.display_name_too_long.app_error",
- },
- }
-
- for _, tt := range displayNameTests {
- t.Run(tt.name, func(t *testing.T) {
- field := &CPAField{
- PropertyField: PropertyField{
- Type: PropertyFieldTypeText,
- },
- Attrs: CPAAttrs{
- DisplayName: tt.displayName,
- },
- }
- appErr := field.SanitizeAndValidate()
- if tt.expectError {
- require.NotNil(t, appErr)
- require.Equal(t, tt.errorId, appErr.Id)
- } else {
- require.Nil(t, appErr)
- assert.Equal(t, tt.expectedValue, field.Attrs.DisplayName,
- "DisplayName must be trimmed after SanitizeAndValidate")
- }
- })
- }
- })
-}
+// TestCPAField_SanitizeAndValidate removed: behavior moved into AccessControlAttributeValidationHook;
+// see TestAccessControlAttributeValidationHook in server/channels/app/properties/access_control_attribute_validation_test.go.
func TestValidateCPAFieldName(t *testing.T) {
tests := []struct {
@@ -1016,7 +456,7 @@ func TestCPAField_ToPropertyField_DisplayName(t *testing.T) {
original := &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "department",
Type: PropertyFieldTypeText,
},
@@ -1043,7 +483,7 @@ func TestCPAField_ToPropertyField_DisplayName(t *testing.T) {
field := &CPAField{
PropertyField: PropertyField{
ID: NewId(),
- GroupID: CustomProfileAttributesPropertyGroupName,
+ GroupID: AccessControlPropertyGroupName,
Name: "department",
Type: PropertyFieldTypeText,
},
@@ -1061,203 +501,6 @@ func TestCPAField_ToPropertyField_DisplayName(t *testing.T) {
})
}
-func TestSanitizeAndValidatePropertyValue(t *testing.T) {
- t.Run("text field type", func(t *testing.T) {
- t.Run("valid text", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeText}}, json.RawMessage(`"hello world"`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Equal(t, "hello world", value)
- })
-
- t.Run("empty text should be allowed", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeText}}, json.RawMessage(`""`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Empty(t, value)
- })
-
- t.Run("invalid JSON", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeText}}, json.RawMessage(`invalid`))
- require.Error(t, err)
- })
-
- t.Run("wrong type", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeText}}, json.RawMessage(`123`))
- require.Error(t, err)
- require.Contains(t, err.Error(), "json: cannot unmarshal number into Go value of type string")
- })
-
- t.Run("value too long", func(t *testing.T) {
- longValue := strings.Repeat("a", CPAValueTypeTextMaxLength+1)
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeText}}, json.RawMessage(fmt.Sprintf(`"%s"`, longValue)))
- require.Error(t, err)
- require.Equal(t, "value too long", err.Error())
- })
- })
-
- t.Run("date field type", func(t *testing.T) {
- t.Run("valid date", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeDate}}, json.RawMessage(`"2023-01-01"`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Equal(t, "2023-01-01", value)
- })
-
- t.Run("empty date should be allowed", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeDate}}, json.RawMessage(`""`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Empty(t, value)
- })
- })
-
- t.Run("select field type", func(t *testing.T) {
- t.Run("valid option", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{
- PropertyField: PropertyField{Type: PropertyFieldTypeSelect},
- Attrs: CPAAttrs{
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {ID: "option1"},
- },
- }}, json.RawMessage(`"option1"`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Equal(t, "option1", value)
- })
-
- t.Run("invalid option", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeSelect}}, json.RawMessage(`"option1"`))
- require.Error(t, err)
- })
-
- t.Run("empty option should be allowed", func(t *testing.T) {
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeSelect}}, json.RawMessage(`""`))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Empty(t, value)
- })
- })
-
- t.Run("user field type", func(t *testing.T) {
- t.Run("valid user ID", func(t *testing.T) {
- validID := NewId()
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeUser}}, json.RawMessage(fmt.Sprintf(`"%s"`, validID)))
- require.NoError(t, err)
- var value string
- require.NoError(t, json.Unmarshal(result, &value))
- require.Equal(t, validID, value)
- })
-
- t.Run("empty user ID should be allowed", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeUser}}, json.RawMessage(`""`))
- require.NoError(t, err)
- })
-
- t.Run("invalid user ID format", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeUser}}, json.RawMessage(`"invalid-id"`))
- require.Error(t, err)
- require.Equal(t, "invalid user id", err.Error())
- })
- })
-
- t.Run("multiselect field type", func(t *testing.T) {
- t.Run("valid options", func(t *testing.T) {
- option1ID := NewId()
- option2ID := NewId()
- option3ID := NewId()
- result, err := SanitizeAndValidatePropertyValue(&CPAField{
- PropertyField: PropertyField{Type: PropertyFieldTypeMultiselect},
- Attrs: CPAAttrs{
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {ID: option1ID},
- {ID: option2ID},
- {ID: option3ID},
- },
- }}, json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, option1ID, option2ID)))
- require.NoError(t, err)
- var values []string
- require.NoError(t, json.Unmarshal(result, &values))
- require.Equal(t, []string{option1ID, option2ID}, values)
- })
-
- t.Run("empty array", func(t *testing.T) {
- option1ID := NewId()
- option2ID := NewId()
- option3ID := NewId()
- _, err := SanitizeAndValidatePropertyValue(&CPAField{
- PropertyField: PropertyField{Type: PropertyFieldTypeMultiselect},
- Attrs: CPAAttrs{
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {ID: option1ID},
- {ID: option2ID},
- {ID: option3ID},
- },
- }}, json.RawMessage(`[]`))
- require.NoError(t, err)
- })
-
- t.Run("array with empty values should filter them out", func(t *testing.T) {
- option1ID := NewId()
- option2ID := NewId()
- option3ID := NewId()
- result, err := SanitizeAndValidatePropertyValue(&CPAField{
- PropertyField: PropertyField{Type: PropertyFieldTypeMultiselect},
- Attrs: CPAAttrs{
- Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
- {ID: option1ID},
- {ID: option2ID},
- {ID: option3ID},
- },
- }}, json.RawMessage(fmt.Sprintf(`["%s", "", "%s", " ", "%s"]`, option1ID, option2ID, option3ID)))
- require.NoError(t, err)
- var values []string
- require.NoError(t, json.Unmarshal(result, &values))
- require.Equal(t, []string{option1ID, option2ID, option3ID}, values)
- })
- })
-
- t.Run("multiuser field type", func(t *testing.T) {
- t.Run("valid user IDs", func(t *testing.T) {
- validID1 := NewId()
- validID2 := NewId()
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeMultiuser}}, json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, validID1, validID2)))
- require.NoError(t, err)
- var values []string
- require.NoError(t, json.Unmarshal(result, &values))
- require.Equal(t, []string{validID1, validID2}, values)
- })
-
- t.Run("empty array", func(t *testing.T) {
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeMultiuser}}, json.RawMessage(`[]`))
- require.NoError(t, err)
- })
-
- t.Run("array with empty strings should be filtered out", func(t *testing.T) {
- validID1 := NewId()
- validID2 := NewId()
- result, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeMultiuser}}, json.RawMessage(fmt.Sprintf(`["%s", "", " ", "%s"]`, validID1, validID2)))
- require.NoError(t, err)
- var values []string
- require.NoError(t, json.Unmarshal(result, &values))
- require.Equal(t, []string{validID1, validID2}, values)
- })
-
- t.Run("array with invalid ID should return error", func(t *testing.T) {
- validID1 := NewId()
- _, err := SanitizeAndValidatePropertyValue(&CPAField{PropertyField: PropertyField{Type: PropertyFieldTypeMultiuser}}, json.RawMessage(fmt.Sprintf(`["%s", "invalid-id"]`, validID1)))
- require.Error(t, err)
- require.Equal(t, "invalid user id: invalid-id", err.Error())
- })
- })
-}
-
func TestCPAField_IsAdminManaged(t *testing.T) {
tests := []struct {
name string
@@ -1308,71 +551,8 @@ func TestCPAField_IsAdminManaged(t *testing.T) {
}
}
-func TestCPAField_SetDefaults(t *testing.T) {
- testCases := []struct {
- name string
- field *CPAField
- expectedAttrs CPAAttrs
- }{
- {
- name: "field with empty visibility should set default",
- field: &CPAField{
- Attrs: CPAAttrs{
- Visibility: "",
- SortOrder: 5.0,
- },
- },
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- SortOrder: 5.0,
- },
- },
- {
- name: "field with existing visibility should not change",
- field: &CPAField{
- Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityAlways,
- SortOrder: 10.0,
- },
- },
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityAlways,
- SortOrder: 10.0,
- },
- },
- {
- name: "field with zero values should set visibility default, keep sort order zero",
- field: &CPAField{
- Attrs: CPAAttrs{},
- },
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityDefault,
- SortOrder: 0.0,
- },
- },
- {
- name: "field with hidden visibility should preserve it",
- field: &CPAField{
- Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityHidden,
- SortOrder: 3.5,
- },
- },
- expectedAttrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityHidden,
- SortOrder: 3.5,
- },
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- tc.field.SetDefaults()
- assert.Equal(t, tc.expectedAttrs.Visibility, tc.field.Attrs.Visibility)
- assert.Equal(t, tc.expectedAttrs.SortOrder, tc.field.Attrs.SortOrder)
- })
- }
-}
+// TestCPAField_SetDefaults removed: visibility default is now applied by AccessControlAttributeValidationHook
+// (see access_control_attribute_validation.go), exercised in TestAccessControlAttributeValidationHook.
func TestCPAField_Patch(t *testing.T) {
testCases := []struct {
@@ -1508,6 +688,10 @@ func TestCPAField_Patch(t *testing.T) {
expectError: false,
},
{
+ // Patch with non-nil Attrs replaces the whole Attrs map; visibility
+ // drops to "" because the patch doesn't include it. The visibility
+ // default is reapplied at write time by AccessControlAttributeValidationHook,
+ // not by Patch itself.
name: "patch sort order",
field: &CPAField{
PropertyField: PropertyField{
@@ -1534,8 +718,7 @@ func TestCPAField_Patch(t *testing.T) {
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityWhenSet,
- SortOrder: 10.5,
+ SortOrder: 10.5,
},
},
expectError: false,
@@ -1567,8 +750,7 @@ func TestCPAField_Patch(t *testing.T) {
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityWhenSet,
- Managed: "admin",
+ Managed: "admin",
},
},
expectError: false,
@@ -1599,8 +781,7 @@ func TestCPAField_Patch(t *testing.T) {
Type: PropertyFieldTypeText,
},
Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityWhenSet,
- LDAP: "ldap_attribute",
+ LDAP: "ldap_attribute",
},
},
expectError: false,
@@ -1637,7 +818,6 @@ func TestCPAField_Patch(t *testing.T) {
Type: PropertyFieldTypeSelect,
},
Attrs: CPAAttrs{
- Visibility: CustomProfileAttributesVisibilityWhenSet,
Options: []*CustomProfileAttributesSelectOption{
{ID: "opt1", Name: "Option 1"},
{ID: "opt2", Name: "Option 2"},
@@ -1785,3 +965,87 @@ func TestCPAField_Patch(t *testing.T) {
})
}
}
+
+func TestCPAFieldsFromPropertyFields(t *testing.T) {
+ mkField := func(name string, sortOrder float64) *PropertyField {
+ return &PropertyField{
+ ID: NewId(),
+ GroupID: AccessControlPropertyGroupName,
+ Name: name,
+ Type: PropertyFieldTypeText,
+ Attrs: StringInterface{
+ CustomProfileAttributesPropertyAttrsSortOrder: sortOrder,
+ },
+ }
+ }
+
+ t.Run("empty slice returns empty slice", func(t *testing.T) {
+ result, err := CPAFieldsFromPropertyFields(nil)
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+
+ t.Run("sorts by SortOrder ascending", func(t *testing.T) {
+ input := []*PropertyField{
+ mkField("c", 2),
+ mkField("a", 0),
+ mkField("b", 1),
+ }
+
+ result, err := CPAFieldsFromPropertyFields(input)
+ require.NoError(t, err)
+ require.Len(t, result, 3)
+ assert.Equal(t, "a", result[0].Name)
+ assert.Equal(t, "b", result[1].Name)
+ assert.Equal(t, "c", result[2].Name)
+ })
+
+ t.Run("preserves fields with equal SortOrder in encounter order", func(t *testing.T) {
+ input := []*PropertyField{
+ mkField("first", 0),
+ mkField("second", 0),
+ }
+
+ result, err := CPAFieldsFromPropertyFields(input)
+ require.NoError(t, err)
+ require.Len(t, result, 2)
+ // sort.Slice is not stable, but the test asserts both possible stable outcomes
+ // — we care that both fields are present, not stability.
+ names := []string{result[0].Name, result[1].Name}
+ assert.Contains(t, names, "first")
+ assert.Contains(t, names, "second")
+ })
+
+ t.Run("propagates conversion errors", func(t *testing.T) {
+ // options stored as an invalid JSON-marshallable type so that
+ // json.Marshal fails inside NewCPAFieldFromPropertyField
+ input := []*PropertyField{{
+ ID: NewId(),
+ GroupID: AccessControlPropertyGroupName,
+ Name: "bad",
+ Type: PropertyFieldTypeText,
+ Attrs: StringInterface{
+ PropertyFieldAttributeOptions: make(chan int),
+ },
+ }}
+
+ result, err := CPAFieldsFromPropertyFields(input)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ })
+
+ t.Run("preserves empty visibility from PropertyField (defaults are applied at write time by AccessControlAttributeValidationHook, not at read time)", func(t *testing.T) {
+ input := []*PropertyField{{
+ ID: NewId(),
+ GroupID: AccessControlPropertyGroupName,
+ Name: "no_visibility",
+ Type: PropertyFieldTypeText,
+ Attrs: StringInterface{},
+ }}
+
+ result, err := CPAFieldsFromPropertyFields(input)
+ require.NoError(t, err)
+ require.Len(t, result, 1)
+ assert.Empty(t, result[0].Attrs.Visibility)
+ })
+}
diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go
index ac8d06a3092..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
@@ -120,6 +139,14 @@ type FeatureFlags struct {
// ManagedChannelCategories enables server-side managed sidebar category enforcement (Enterprise).
ManagedChannelCategories bool
+
+ // FEATURE_FLAG_REMOVAL: DiscoverableChannels - Remove this when the feature is GA.
+ // Gates the per-channel Discoverable toggle and the channel-join-request flow that lets
+ // non-members find a private channel in Browse Channels and request to join it.
+ DiscoverableChannels bool
+
+ // Enable Mobile Ephemeral Mode for controlling data persistence on mobile devices
+ MobileEphemeralMode bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -150,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
@@ -176,6 +204,31 @@ func (f *FeatureFlags) SetDefaults() {
f.AggregatePluginMetrics = false
f.ManagedChannelCategories = false
+
+ f.DiscoverableChannels = false
+
+ 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
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/incoming_webhook.go b/server/public/model/incoming_webhook.go
index ac28a3260e2..2e2b3ded574 100644
--- a/server/public/model/incoming_webhook.go
+++ b/server/public/model/incoming_webhook.go
@@ -28,6 +28,7 @@ type IncomingWebhook struct {
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelLocked bool `json:"channel_locked"`
+ LastUsed int64 `json:"last_used"`
}
func (o *IncomingWebhook) Auditable() map[string]any {
@@ -44,6 +45,7 @@ func (o *IncomingWebhook) Auditable() map[string]any {
"username": o.Username,
"icon_url:": o.IconURL,
"channel_locked": o.ChannelLocked,
+ "last_used": o.LastUsed,
}
}
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/migration.go b/server/public/model/migration.go
index f29087a19f5..7ece8d257ba 100644
--- a/server/public/model/migration.go
+++ b/server/public/model/migration.go
@@ -65,4 +65,5 @@ const (
MigrationKeyAccessControlPolicyV0_3 = "access_control_policy_v0_3_migration"
MigrationKeyAddManageAgentPermissions = "add_manage_agent_permissions"
MigrationKeyAddEditFileAttachmentPermission = "add_edit_file_attachment_permission"
+ MigrationKeyAddDiscoverableChannelPermissions = "add_discoverable_channel_permissions"
)
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/permission.go b/server/public/model/permission.go
index 63ab17e5369..921d46ff894 100644
--- a/server/public/model/permission.go
+++ b/server/public/model/permission.go
@@ -49,6 +49,8 @@ var PermissionManagePublicChannelProperties *Permission
var PermissionManagePrivateChannelProperties *Permission
var PermissionManagePublicChannelAutoTranslation *Permission
var PermissionManagePrivateChannelAutoTranslation *Permission
+var PermissionManagePrivateChannelDiscoverability *Permission
+var PermissionManageChannelJoinRequests *Permission
var PermissionListPublicTeams *Permission
var PermissionJoinPublicTeams *Permission
var PermissionListPrivateTeams *Permission
@@ -568,6 +570,18 @@ func initializePermissions() {
"authentication.permissions.manage_private_channel_auto_translation.description",
PermissionScopeChannel,
}
+ PermissionManagePrivateChannelDiscoverability = &Permission{
+ "manage_private_channel_discoverability",
+ "authentication.permissions.manage_private_channel_discoverability.name",
+ "authentication.permissions.manage_private_channel_discoverability.description",
+ PermissionScopeChannel,
+ }
+ PermissionManageChannelJoinRequests = &Permission{
+ "manage_channel_join_requests",
+ "authentication.permissions.manage_channel_join_requests.name",
+ "authentication.permissions.manage_channel_join_requests.description",
+ PermissionScopeChannel,
+ }
PermissionListPublicTeams = &Permission{
"list_public_teams",
"authentication.permissions.list_public_teams.name",
@@ -2631,6 +2645,8 @@ func initializePermissions() {
PermissionManagePrivateChannelBanner,
PermissionManageChannelAccessRules,
PermissionEditFileAttachment,
+ PermissionManagePrivateChannelDiscoverability,
+ PermissionManageChannelJoinRequests,
}
GroupScopedPermissions := []*Permission{
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_access_control.go b/server/public/model/property_access_control.go
index 21bdea6d868..1bab63c96d4 100644
--- a/server/public/model/property_access_control.go
+++ b/server/public/model/property_access_control.go
@@ -11,6 +11,25 @@ type AccessControlContextKey string
// AccessControlCallerIDContextKey is the context key for access control caller ID.
const AccessControlCallerIDContextKey AccessControlContextKey = "access_control_caller_id"
+// Well-known caller IDs for internal services that need to write property
+// values on synced fields. These are set on the request context by the
+// respective sync services so that the access control hook can identify them.
+//
+// The "system:" prefix contains a colon, which is not a valid character in a
+// plugin ID (see IsValidPluginId). That guarantees these values cannot be
+// forged by a plugin whose manifest ID is used as its caller ID.
+//
+// CallerIDLocalAdmin marks a request as originating from a local-mode
+// (unrestricted) session, which has an empty Session.UserId but full admin
+// privileges. HTTP handlers tag the rctx with this caller ID when
+// Session().IsUnrestricted() is true, so the attribute validation hook's
+// permission checker can grant admin privileges without a user lookup.
+const (
+ CallerIDLDAPSync = "system:ldap_sync"
+ CallerIDSAMLSync = "system:saml_sync"
+ CallerIDLocalAdmin = "system:local_admin"
+)
+
// WithCallerID adds the caller ID to a context.Context for access control purposes.
func WithCallerID(ctx context.Context, callerID string) context.Context {
return context.WithValue(ctx, AccessControlCallerIDContextKey, callerID)
diff --git a/server/public/model/property_field.go b/server/public/model/property_field.go
index 25027c8e16c..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{
@@ -404,12 +411,8 @@ func (pf *PropertyField) Patch(patch *PropertyFieldPatch, mergeAttrs bool) {
// Legacy properties have an empty ObjectType and rely on simple TargetID uniqueness
// enforced by the idx_propertyfields_unique_legacy database constraint, rather than
// the hierarchical uniqueness model used by PSAv2 (ObjectType-based) properties.
-//
-// FIXME: treating template fields as PSAv1 is a temporary measure until the
-// CPA feature fully transitions to v2. Once that happens, remove the
-// PropertyFieldObjectTypeTemplate check.
func (pf *PropertyField) IsPSAv1() bool {
- return pf.ObjectType == "" || pf.ObjectType == PropertyFieldObjectTypeTemplate
+ return pf.ObjectType == ""
}
// IsPSAv2 returns true if this property field uses the PSAv2 schema.
diff --git a/server/public/model/property_field_attrs_validation.go b/server/public/model/property_field_attrs_validation.go
new file mode 100644
index 00000000000..2e2924455c7
--- /dev/null
+++ b/server/public/model/property_field_attrs_validation.go
@@ -0,0 +1,192 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package model
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// Attribute keys used across property groups. These are the canonical keys
+// stored in PropertyField.Attrs and referenced by hooks.
+const (
+ PropertyFieldAttrVisibility = "visibility"
+ PropertyFieldAttrSortOrder = "sort_order"
+ PropertyFieldAttrValueType = "value_type"
+ PropertyFieldAttrLDAP = "ldap"
+ PropertyFieldAttrSAML = "saml"
+ PropertyFieldAttrManaged = "managed"
+ PropertyFieldAttrDisplayName = "display_name"
+)
+
+// Valid visibility values for property fields.
+const (
+ PropertyFieldVisibilityHidden = "hidden"
+ PropertyFieldVisibilityWhenSet = "when_set"
+ PropertyFieldVisibilityAlways = "always"
+)
+
+// Valid value types for text property fields.
+const (
+ PropertyFieldValueTypeEmail = "email"
+ PropertyFieldValueTypeURL = "url"
+ PropertyFieldValueTypePhone = "phone"
+)
+
+// PropertyFieldValueTypeTextMaxLength is the maximum character length for text field values.
+const PropertyFieldValueTypeTextMaxLength = 64
+
+// IsValidPropertyFieldVisibility reports whether the given string is a known visibility value.
+func IsValidPropertyFieldVisibility(v string) bool {
+ switch v {
+ case PropertyFieldVisibilityHidden,
+ PropertyFieldVisibilityWhenSet,
+ PropertyFieldVisibilityAlways:
+ return true
+ default:
+ return false
+ }
+}
+
+// IsValidPropertyFieldValueType reports whether the given string is a known value type.
+func IsValidPropertyFieldValueType(v string) bool {
+ switch v {
+ case PropertyFieldValueTypeEmail,
+ PropertyFieldValueTypeURL,
+ PropertyFieldValueTypePhone:
+ return true
+ default:
+ return false
+ }
+}
+
+// ValidatePropertyFieldVisibility checks that the visibility attr on a
+// PropertyField is either empty or one of hidden/when_set/always.
+func ValidatePropertyFieldVisibility(field *PropertyField) error {
+ if field.Attrs == nil {
+ return nil
+ }
+
+ raw, ok := field.Attrs[PropertyFieldAttrVisibility]
+ if !ok {
+ return nil
+ }
+
+ v, ok := raw.(string)
+ if !ok {
+ return fmt.Errorf("visibility must be a string")
+ }
+
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return nil
+ }
+
+ if !IsValidPropertyFieldVisibility(v) {
+ return fmt.Errorf("invalid visibility %q: must be one of hidden, when_set, always", v)
+ }
+
+ return nil
+}
+
+// ValidatePropertyFieldSortOrder checks that the sort_order attr on a
+// PropertyField is numeric (float64 or json.Number) or absent.
+func ValidatePropertyFieldSortOrder(field *PropertyField) error {
+ if field.Attrs == nil {
+ return nil
+ }
+
+ raw, ok := field.Attrs[PropertyFieldAttrSortOrder]
+ if !ok {
+ return nil
+ }
+
+ switch raw.(type) {
+ case float64, json.Number, int, int64:
+ return nil
+ default:
+ return fmt.Errorf("sort_order must be numeric, got %T", raw)
+ }
+}
+
+// ValidatePropertyValueForValueType validates a raw JSON value against the
+// given value type constraint. This is called for text fields that have a
+// value_type attr (email, url, phone).
+func ValidatePropertyValueForValueType(valueType string, value json.RawMessage) error {
+ if valueType == "" {
+ return nil
+ }
+
+ var str string
+ if err := json.Unmarshal(value, &str); err != nil {
+ return fmt.Errorf("expected string value for value_type %q: %w", valueType, err)
+ }
+
+ str = strings.TrimSpace(str)
+ if str == "" {
+ return nil
+ }
+
+ switch valueType {
+ case PropertyFieldValueTypeEmail:
+ if !IsValidEmail(str) {
+ return fmt.Errorf("invalid email: %q", str)
+ }
+ case PropertyFieldValueTypeURL:
+ // ParseRequestURI rejects relative references (url.Parse accepts them),
+ // and we additionally require a non-empty Host so bare schemes like
+ // "http:" or "file:///..." without an authority are rejected.
+ u, err := url.ParseRequestURI(str)
+ if err != nil {
+ return fmt.Errorf("invalid url: %w", err)
+ }
+ if u.Scheme == "" || u.Host == "" {
+ return fmt.Errorf("invalid url: %q", str)
+ }
+ case PropertyFieldValueTypePhone:
+ // Phone values are accepted as-is; no structural validation.
+ default:
+ return fmt.Errorf("unknown value_type %q", valueType)
+ }
+
+ return nil
+}
+
+// GetPropertyFieldValueType extracts the value_type string from a
+// PropertyField's attrs. Returns empty string if not set.
+func GetPropertyFieldValueType(field *PropertyField) string {
+ if field.Attrs == nil {
+ return ""
+ }
+ v, _ := field.Attrs[PropertyFieldAttrValueType].(string)
+ return strings.TrimSpace(v)
+}
+
+// IsPropertyFieldSynced reports whether the field has an ldap or saml attr set,
+// meaning its values are managed by an external sync service.
+func IsPropertyFieldSynced(field *PropertyField) bool {
+ if field.Attrs == nil {
+ return false
+ }
+ ldap, _ := field.Attrs[PropertyFieldAttrLDAP].(string)
+ saml, _ := field.Attrs[PropertyFieldAttrSAML].(string)
+ return ldap != "" || saml != ""
+}
+
+// GetPropertyFieldSyncSource returns the sync source for a field: "ldap",
+// "saml", or empty string if not synced. If both are set, ldap takes priority.
+func GetPropertyFieldSyncSource(field *PropertyField) string {
+ if field.Attrs == nil {
+ return ""
+ }
+ if ldap, _ := field.Attrs[PropertyFieldAttrLDAP].(string); ldap != "" {
+ return "ldap"
+ }
+ if saml, _ := field.Attrs[PropertyFieldAttrSAML].(string); saml != "" {
+ return "saml"
+ }
+ return ""
+}
diff --git a/server/public/model/property_field_attrs_validation_test.go b/server/public/model/property_field_attrs_validation_test.go
new file mode 100644
index 00000000000..a90f4902fb7
--- /dev/null
+++ b/server/public/model/property_field_attrs_validation_test.go
@@ -0,0 +1,157 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package model
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidatePropertyFieldVisibility(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs StringInterface
+ wantErr bool
+ }{
+ {name: "nil attrs", attrs: nil},
+ {name: "no visibility key", attrs: StringInterface{"other": "val"}},
+ {name: "empty string", attrs: StringInterface{PropertyFieldAttrVisibility: ""}},
+ {name: "hidden", attrs: StringInterface{PropertyFieldAttrVisibility: "hidden"}},
+ {name: "when_set", attrs: StringInterface{PropertyFieldAttrVisibility: "when_set"}},
+ {name: "always", attrs: StringInterface{PropertyFieldAttrVisibility: "always"}},
+ {name: "invalid", attrs: StringInterface{PropertyFieldAttrVisibility: "public"}, wantErr: true},
+ {name: "non-string type", attrs: StringInterface{PropertyFieldAttrVisibility: 42}, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ field := &PropertyField{Attrs: tt.attrs}
+ err := ValidatePropertyFieldVisibility(field)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestValidatePropertyFieldSortOrder(t *testing.T) {
+ tests := []struct {
+ name string
+ attrs StringInterface
+ wantErr bool
+ }{
+ {name: "nil attrs", attrs: nil},
+ {name: "no sort_order key", attrs: StringInterface{"other": "val"}},
+ {name: "float64", attrs: StringInterface{PropertyFieldAttrSortOrder: float64(1.5)}},
+ {name: "int", attrs: StringInterface{PropertyFieldAttrSortOrder: 1}},
+ {name: "int64", attrs: StringInterface{PropertyFieldAttrSortOrder: int64(42)}},
+ {name: "json.Number", attrs: StringInterface{PropertyFieldAttrSortOrder: json.Number("3.14")}},
+ {name: "string", attrs: StringInterface{PropertyFieldAttrSortOrder: "not_a_number"}, wantErr: true},
+ {name: "bool", attrs: StringInterface{PropertyFieldAttrSortOrder: true}, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ field := &PropertyField{Attrs: tt.attrs}
+ err := ValidatePropertyFieldSortOrder(field)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestValidatePropertyValueForValueType(t *testing.T) {
+ tests := []struct {
+ name string
+ valueType string
+ value string
+ wantErr bool
+ }{
+ {name: "empty value type", valueType: "", value: `"anything"`},
+ {name: "valid email", valueType: "email", value: `"test@example.com"`},
+ {name: "invalid email", valueType: "email", value: `"not-an-email"`, wantErr: true},
+ {name: "empty email string", valueType: "email", value: `""`},
+ {name: "valid url", valueType: "url", value: `"https://example.com"`},
+ {name: "valid url with path", valueType: "url", value: `"https://example.com/path?q=1"`},
+ {name: "invalid url - plain string", valueType: "url", value: `"not a url"`, wantErr: true},
+ {name: "invalid url - relative path", valueType: "url", value: `"/relative/path"`, wantErr: true},
+ {name: "invalid url - missing host", valueType: "url", value: `"http://"`, wantErr: true},
+ {name: "invalid url - missing scheme", valueType: "url", value: `"example.com"`, wantErr: true},
+ {name: "phone (any string)", valueType: "phone", value: `"+1-555-0123"`},
+ {name: "unknown value type", valueType: "fax", value: `"test"`, wantErr: true},
+ {name: "non-string json", valueType: "email", value: `42`, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidatePropertyValueForValueType(tt.valueType, json.RawMessage(tt.value))
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestIsPropertyFieldSynced(t *testing.T) {
+ assert.False(t, IsPropertyFieldSynced(&PropertyField{}))
+ assert.False(t, IsPropertyFieldSynced(&PropertyField{Attrs: StringInterface{}}))
+ assert.True(t, IsPropertyFieldSynced(&PropertyField{Attrs: StringInterface{PropertyFieldAttrLDAP: "attr"}}))
+ assert.True(t, IsPropertyFieldSynced(&PropertyField{Attrs: StringInterface{PropertyFieldAttrSAML: "attr"}}))
+ assert.True(t, IsPropertyFieldSynced(&PropertyField{Attrs: StringInterface{PropertyFieldAttrLDAP: "a", PropertyFieldAttrSAML: "b"}}))
+}
+
+func TestGetPropertyFieldSyncSource(t *testing.T) {
+ assert.Equal(t, "", GetPropertyFieldSyncSource(&PropertyField{}))
+ assert.Equal(t, "ldap", GetPropertyFieldSyncSource(&PropertyField{Attrs: StringInterface{PropertyFieldAttrLDAP: "attr"}}))
+ assert.Equal(t, "saml", GetPropertyFieldSyncSource(&PropertyField{Attrs: StringInterface{PropertyFieldAttrSAML: "attr"}}))
+ // ldap takes priority
+ assert.Equal(t, "ldap", GetPropertyFieldSyncSource(&PropertyField{Attrs: StringInterface{PropertyFieldAttrLDAP: "a", PropertyFieldAttrSAML: "b"}}))
+}
+
+func TestIsValidPropertyFieldVisibility(t *testing.T) {
+ assert.True(t, IsValidPropertyFieldVisibility("hidden"))
+ assert.True(t, IsValidPropertyFieldVisibility("when_set"))
+ assert.True(t, IsValidPropertyFieldVisibility("always"))
+ assert.False(t, IsValidPropertyFieldVisibility(""))
+ assert.False(t, IsValidPropertyFieldVisibility("public"))
+}
+
+func TestIsValidPropertyFieldValueType(t *testing.T) {
+ assert.True(t, IsValidPropertyFieldValueType("email"))
+ assert.True(t, IsValidPropertyFieldValueType("url"))
+ assert.True(t, IsValidPropertyFieldValueType("phone"))
+ assert.False(t, IsValidPropertyFieldValueType(""))
+ assert.False(t, IsValidPropertyFieldValueType("fax"))
+}
+
+func TestGetPropertyFieldValueType(t *testing.T) {
+ assert.Equal(t, "", GetPropertyFieldValueType(&PropertyField{}))
+ assert.Equal(t, "", GetPropertyFieldValueType(&PropertyField{Attrs: StringInterface{}}))
+ assert.Equal(t, "email", GetPropertyFieldValueType(&PropertyField{Attrs: StringInterface{PropertyFieldAttrValueType: "email"}}))
+ assert.Equal(t, "email", GetPropertyFieldValueType(&PropertyField{Attrs: StringInterface{PropertyFieldAttrValueType: " email "}}))
+}
+
+func TestCallerIDConstants(t *testing.T) {
+ require.NotEmpty(t, CallerIDLDAPSync)
+ require.NotEmpty(t, CallerIDSAMLSync)
+ require.NotEqual(t, CallerIDLDAPSync, CallerIDSAMLSync)
+
+ // The sync caller IDs must not be valid plugin IDs, otherwise an
+ // admin-installed plugin could set its manifest ID to one of these
+ // values and bypass the sync-lock check for LDAP/SAML-managed fields.
+ require.False(t, IsValidPluginId(CallerIDLDAPSync),
+ "CallerIDLDAPSync must not be a valid plugin ID")
+ require.False(t, IsValidPluginId(CallerIDSAMLSync),
+ "CallerIDSAMLSync must not be a valid plugin ID")
+}
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/property_group.go b/server/public/model/property_group.go
index 0d6644902b7..9ed5cf0ed9e 100644
--- a/server/public/model/property_group.go
+++ b/server/public/model/property_group.go
@@ -8,6 +8,23 @@ import (
"regexp"
)
+const AccessControlPropertyGroupName = "access_control"
+
+// DeprecatedCPAPropertyGroupName is the old group name for custom profile attributes.
+// It was renamed to "access_control". The plugin API still accepts this name
+// for backward compatibility, but plugin authors should migrate to
+// AccessControlPropertyGroupName.
+const DeprecatedCPAPropertyGroupName = "custom_profile_attributes"
+
+// AccessControlGroupFieldLimit is the global cap on the number of
+// property fields that can exist in the access_control group across
+// all object types. Call sites read all fields/values in a single page
+// (PerPage = AccessControlGroupFieldLimit + 5) instead of paginating,
+// on the assumption that the result set is bounded by this limit. If the
+// limit is ever raised significantly or removed, every call site that uses
+// AccessControlGroupFieldLimit + 5 must be converted to paginate.
+const AccessControlGroupFieldLimit = 200
+
var validPropertyGroupNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9_]*$`)
const (
diff --git a/server/public/model/property_value.go b/server/public/model/property_value.go
index 43d50db4170..665e23ef483 100644
--- a/server/public/model/property_value.go
+++ b/server/public/model/property_value.go
@@ -6,6 +6,7 @@ package model
import (
"encoding/json"
"net/http"
+ "strings"
"unicode/utf8"
"github.com/pkg/errors"
@@ -151,3 +152,60 @@ type PropertyValuePatchItem struct {
FieldID string `json:"field_id"`
Value json.RawMessage `json:"value"`
}
+
+// SanitizePropertyValue normalizes a raw property value's JSON:
+// - a top-level JSON string has surrounding whitespace trimmed;
+// - a top-level JSON array of strings has each element trimmed and empty
+// entries dropped;
+// - any other shape (numbers, booleans, objects, nested arrays) passes
+// through unchanged.
+//
+// Returns the original bytes when no change is needed so callers can
+// compare by identity if they want to skip writes.
+func SanitizePropertyValue(raw json.RawMessage) json.RawMessage {
+ if len(raw) == 0 {
+ return raw
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ trimmed := strings.TrimSpace(s)
+ if trimmed == s {
+ return raw
+ }
+ out, err := json.Marshal(trimmed)
+ if err != nil {
+ return raw
+ }
+ return out
+ }
+
+ var arr []string
+ if err := json.Unmarshal(raw, &arr); err == nil {
+ filtered := make([]string, 0, len(arr))
+ changed := false
+ for _, v := range arr {
+ t := strings.TrimSpace(v)
+ if t != v {
+ changed = true
+ }
+ if t == "" {
+ if v != "" {
+ changed = true
+ }
+ continue
+ }
+ filtered = append(filtered, t)
+ }
+ if !changed && len(filtered) == len(arr) {
+ return raw
+ }
+ out, err := json.Marshal(filtered)
+ if err != nil {
+ return raw
+ }
+ return out
+ }
+
+ return raw
+}
diff --git a/server/public/model/property_value_test.go b/server/public/model/property_value_test.go
index f0bacdb13b0..382fefb907f 100644
--- a/server/public/model/property_value_test.go
+++ b/server/public/model/property_value_test.go
@@ -252,3 +252,38 @@ func TestPropertyValueSearchCursor_IsValid(t *testing.T) {
assert.Error(t, cursor.IsValid())
})
}
+
+func TestSanitizePropertyValue(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty bytes", "", ""},
+ {"string trimmed", `" hello "`, `"hello"`},
+ {"string unchanged", `"hello"`, `"hello"`},
+ {"string all whitespace", `" "`, `""`},
+ {"string already empty", `""`, `""`},
+ {"string array trimmed and filtered", `[" a ", "", " ", "b"]`, `["a","b"]`},
+ {"string array unchanged", `["a","b"]`, `["a","b"]`},
+ {"string array all empty", `["", " ", ""]`, `[]`},
+ {"number passthrough", `42`, `42`},
+ {"boolean passthrough", `true`, `true`},
+ {"null passthrough", `null`, `null`},
+ {"object passthrough", `{"key":" val "}`, `{"key":" val "}`},
+ {"nested array passthrough", `[["a","b"]]`, `[["a","b"]]`},
+ {"mixed array passthrough", `["a",1]`, `["a",1]`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := SanitizePropertyValue(json.RawMessage(tc.in))
+ assert.Equal(t, tc.want, string(got))
+ })
+ }
+
+ t.Run("returns identity when no change", func(t *testing.T) {
+ raw := json.RawMessage(`"hello"`)
+ got := SanitizePropertyValue(raw)
+ assert.Equal(t, &raw[0], &got[0], "expected same backing array when unchanged")
+ })
+}
diff --git a/server/public/model/role.go b/server/public/model/role.go
index e7b84a5d829..17f2807f3d7 100644
--- a/server/public/model/role.go
+++ b/server/public/model/role.go
@@ -778,25 +778,28 @@ func (r *Role) RolePatchFromChannelModerationsPatch(channelModerationsPatch []*C
return &RolePatch{Permissions: &patchPermissions}
}
-func (r *Role) IsValid() bool {
+func (r *Role) IsValid() error {
if !IsValidId(r.Id) {
- return false
+ return fmt.Errorf("invalid role id %q", r.Id)
}
return r.IsValidWithoutId()
}
-func (r *Role) IsValidWithoutId() bool {
+func (r *Role) IsValidWithoutId() error {
if !IsValidRoleName(r.Name) {
- return false
+ return fmt.Errorf("invalid role name %q", r.Name)
}
- if r.DisplayName == "" || len(r.DisplayName) > RoleDisplayNameMaxLength {
- return false
+ if r.DisplayName == "" {
+ return fmt.Errorf("role display name must not be empty")
+ }
+ if len(r.DisplayName) > RoleDisplayNameMaxLength {
+ return fmt.Errorf("role display name %q exceeds maximum length of %d", r.DisplayName, RoleDisplayNameMaxLength)
}
if len(r.Description) > RoleDescriptionMaxLength {
- return false
+ return fmt.Errorf("role description exceeds maximum length of %d", RoleDescriptionMaxLength)
}
check := func(perms []*Permission, permission string) bool {
@@ -808,13 +811,12 @@ func (r *Role) IsValidWithoutId() bool {
return false
}
for _, permission := range r.Permissions {
- permissionValidated := check(AllPermissions, permission) || check(DeprecatedPermissions, permission)
- if !permissionValidated {
- return false
+ if !check(AllPermissions, permission) && !check(DeprecatedPermissions, permission) {
+ return fmt.Errorf("unknown permission %q", permission)
}
}
- return true
+ return nil
}
func CleanRoleNames(roleNames []string) ([]string, bool) {
@@ -930,6 +932,8 @@ func MakeDefaultRoles() map[string]*Role {
PermissionManageChannelAccessRules.Id,
PermissionManagePublicChannelAutoTranslation.Id,
PermissionManagePrivateChannelAutoTranslation.Id,
+ PermissionManagePrivateChannelDiscoverability.Id,
+ PermissionManageChannelJoinRequests.Id,
},
SchemeManaged: true,
BuiltIn: true,
diff --git a/server/public/model/role_test.go b/server/public/model/role_test.go
index 9abf2c1c81e..0550509cbba 100644
--- a/server/public/model/role_test.go
+++ b/server/public/model/role_test.go
@@ -5,6 +5,7 @@ package model
import (
"slices"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -362,6 +363,106 @@ func TestManageAgentPermissionsDefinition(t *testing.T) {
}), "manage_others_agent should be in AllPermissions")
}
+func TestRoleIsValidWithoutId(t *testing.T) {
+ validRole := func() *Role {
+ return &Role{
+ Name: "test_role",
+ DisplayName: "Test Role",
+ Description: "A test role.",
+ Permissions: []string{PermissionCreatePost.Id},
+ }
+ }
+
+ t.Run("valid role returns nil", func(t *testing.T) {
+ assert.NoError(t, validRole().IsValidWithoutId())
+ })
+
+ t.Run("empty name", func(t *testing.T) {
+ r := validRole()
+ r.Name = ""
+ assert.ErrorContains(t, r.IsValidWithoutId(), "invalid role name")
+ })
+
+ t.Run("name too long", func(t *testing.T) {
+ r := validRole()
+ r.Name = strings.Repeat("a", RoleNameMaxLength+1)
+ assert.ErrorContains(t, r.IsValidWithoutId(), "invalid role name")
+ })
+
+ t.Run("name with invalid characters", func(t *testing.T) {
+ r := validRole()
+ r.Name = "invalid-name"
+ assert.ErrorContains(t, r.IsValidWithoutId(), "invalid role name")
+ })
+
+ t.Run("empty display name", func(t *testing.T) {
+ r := validRole()
+ r.DisplayName = ""
+ assert.ErrorContains(t, r.IsValidWithoutId(), "display name must not be empty")
+ })
+
+ t.Run("display name too long", func(t *testing.T) {
+ r := validRole()
+ r.DisplayName = strings.Repeat("a", RoleDisplayNameMaxLength+1)
+ err := r.IsValidWithoutId()
+ assert.ErrorContains(t, err, "display name")
+ assert.ErrorContains(t, err, "exceeds maximum length")
+ })
+
+ t.Run("description too long", func(t *testing.T) {
+ r := validRole()
+ r.Description = strings.Repeat("a", RoleDescriptionMaxLength+1)
+ assert.ErrorContains(t, r.IsValidWithoutId(), "description exceeds maximum length")
+ })
+
+ t.Run("unknown permission", func(t *testing.T) {
+ r := validRole()
+ r.Permissions = []string{"not_a_real_permission"}
+ err := r.IsValidWithoutId()
+ require.ErrorContains(t, err, "unknown permission")
+ assert.ErrorContains(t, err, "not_a_real_permission")
+ })
+
+ t.Run("no permissions is valid", func(t *testing.T) {
+ r := validRole()
+ r.Permissions = nil
+ assert.NoError(t, r.IsValidWithoutId())
+ })
+}
+
+func TestRoleIsValid(t *testing.T) {
+ validRole := func() *Role {
+ return &Role{
+ Id: NewId(),
+ Name: "test_role",
+ DisplayName: "Test Role",
+ Permissions: []string{PermissionCreatePost.Id},
+ }
+ }
+
+ t.Run("valid role returns nil", func(t *testing.T) {
+ assert.NoError(t, validRole().IsValid())
+ })
+
+ t.Run("empty id", func(t *testing.T) {
+ r := validRole()
+ r.Id = ""
+ assert.ErrorContains(t, r.IsValid(), "invalid role id")
+ })
+
+ t.Run("invalid id", func(t *testing.T) {
+ r := validRole()
+ r.Id = "not-a-valid-id!"
+ assert.ErrorContains(t, r.IsValid(), "invalid role id")
+ })
+
+ t.Run("propagates IsValidWithoutId error", func(t *testing.T) {
+ r := validRole()
+ r.DisplayName = ""
+ assert.ErrorContains(t, r.IsValid(), "display name must not be empty")
+ })
+}
+
func TestManageAgentPermissionsDefaultRoles(t *testing.T) {
roles := MakeDefaultRoles()
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/model/utils_test.go b/server/public/model/utils_test.go
index 67a39f1ccb4..4b04b26f92c 100644
--- a/server/public/model/utils_test.go
+++ b/server/public/model/utils_test.go
@@ -1087,7 +1087,7 @@ func checkNowhereNil(t *testing.T, name string, value any) bool {
v := reflect.ValueOf(value)
switch v.Type().Kind() {
- case reflect.Ptr:
+ case reflect.Pointer:
// Ignoring these 2 settings.
// TODO: remove them completely in v8.0.
if name == "config.ElasticsearchSettings.BulkIndexingTimeWindowSeconds" ||
diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go
index c816d3234a7..87f9ead3544 100644
--- a/server/public/model/websocket_message.go
+++ b/server/public/model/websocket_message.go
@@ -117,6 +117,8 @@ const (
WebsocketEventFileDownloadRejected WebsocketEventType = "file_download_rejected"
WebsocketEventShowToast WebsocketEventType = "show_toast"
WebsocketEventSharedChannelRemoteUpdated WebsocketEventType = "shared_channel_remote_updated"
+ WebsocketEventChannelJoinRequestCreated WebsocketEventType = "channel_join_request_created"
+ WebsocketEventChannelJoinRequestUpdated WebsocketEventType = "channel_join_request_updated"
WebSocketMsgTypeResponse = "response"
WebSocketMsgTypeEvent = "event"
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 c0c03f93df7..76ce6c8825d 100644
--- a/server/public/plugin/client_rpc.go
+++ b/server/public/plugin/client_rpc.go
@@ -1260,6 +1260,7 @@ func (s *apiRPCServer) ReceiveSharedChannelAttachmentSyncMsg(args *Z_ReceiveShar
defer dataReader.Close()
returns.A, returns.B = hook.ReceiveSharedChannelAttachmentSyncMsg(args.A, args.B, args.C, dataReader)
+ returns.B = encodableError(returns.B)
return nil
}
@@ -1459,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/client_rpc_test.go b/server/public/plugin/client_rpc_test.go
new file mode 100644
index 00000000000..179b392e0f8
--- /dev/null
+++ b/server/public/plugin/client_rpc_test.go
@@ -0,0 +1,46 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package plugin
+
+import (
+ "bytes"
+ "encoding/gob"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestReceiveSharedChannelAttachmentSyncMsgReturns_GobRoundTrip pins the fix
+// for the gob-encoding bug in apiRPCServer.ReceiveSharedChannelAttachmentSyncMsg.
+// The hook may return errors wrapped with fmt.Errorf("...%w", err), producing
+// values of the unexported type *fmt.wrapError that gob refuses to encode.
+// The RPC server must run the error through encodableError before assigning
+// it to the returns struct. Without that, the RPC connection breaks and
+// every subsequent plugin to server call returns zero values.
+func TestReceiveSharedChannelAttachmentSyncMsgReturns_GobRoundTrip(t *testing.T) {
+ wrapped := fmt.Errorf("attachment sync failed: %w", errors.New("upstream boom"))
+
+ t.Run("raw wrapped error fails to gob-encode (reproduces the bug)", func(t *testing.T) {
+ returns := Z_ReceiveSharedChannelAttachmentSyncMsgReturns{B: wrapped}
+
+ var buf bytes.Buffer
+ err := gob.NewEncoder(&buf).Encode(&returns)
+ require.Error(t, err, "raw *fmt.wrapError must not be gob-encodable; if this assertion ever fails the bug guarded by encodableError no longer exists")
+ require.Contains(t, err.Error(), "fmt.wrapError")
+ })
+
+ t.Run("encodableError-wrapped error round-trips through gob", func(t *testing.T) {
+ returns := Z_ReceiveSharedChannelAttachmentSyncMsgReturns{B: encodableError(wrapped)}
+
+ var buf bytes.Buffer
+ require.NoError(t, gob.NewEncoder(&buf).Encode(&returns))
+
+ var decoded Z_ReceiveSharedChannelAttachmentSyncMsgReturns
+ require.NoError(t, gob.NewDecoder(&buf).Decode(&decoded))
+ require.Error(t, decoded.B)
+ require.Equal(t, wrapped.Error(), decoded.B.Error())
+ })
+}
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/server/public/shared/markdown/inspect.go b/server/public/shared/markdown/inspect.go
index 151b9590244..b3eb2b6d5ee 100644
--- a/server/public/shared/markdown/inspect.go
+++ b/server/public/shared/markdown/inspect.go
@@ -3,6 +3,8 @@
package markdown
+import "slices"
+
const (
// Assuming 64k maxSize of a post which can be stored in DB.
// Allow scanning upto twice(arbitrary value) the post size.
@@ -58,20 +60,20 @@ func InspectBlock(block Block, f func(Block) bool) {
switch v := block.(type) {
case *Document:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *List:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *ListItem:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *BlockQuote:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
}
}
@@ -103,20 +105,20 @@ func InspectInline(inline Inline, f func(Inline) bool) {
switch v := inline.(type) {
case *InlineImage:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *InlineLink:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *ReferenceImage:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
case *ReferenceLink:
- for i := len(v.Children) - 1; i >= 0; i-- {
- stack = append(stack, v.Children[i])
+ for _, v0 := range slices.Backward(v.Children) {
+ stack = append(stack, v0)
}
}
}
diff --git a/server/public/shared/markdown/paragraph.go b/server/public/shared/markdown/paragraph.go
index aef01b5e151..5a2012d56de 100644
--- a/server/public/shared/markdown/paragraph.go
+++ b/server/public/shared/markdown/paragraph.go
@@ -4,6 +4,7 @@
package markdown
import (
+ "slices"
"strings"
)
@@ -51,8 +52,8 @@ func (b *Paragraph) Close() {
b.Text = remaining
}
- for i := len(b.Text) - 1; i >= 0; i-- {
- b.Text[i] = trimRightSpace(b.markdown, b.Text[i])
+ for i, v := range slices.Backward(b.Text) {
+ b.Text[i] = trimRightSpace(b.markdown, v)
if b.Text[i].Position < b.Text[i].End {
break
}
diff --git a/server/scripts/shard-split.js b/server/scripts/shard-split.js
index 198f6d3e40e..8bbe59740f8 100644
--- a/server/scripts/shard-split.js
+++ b/server/scripts/shard-split.js
@@ -40,6 +40,16 @@ const SHARD_INDEX = parseInt(process.env.SHARD_INDEX);
const SHARD_TOTAL = parseInt(process.env.SHARD_TOTAL);
const HEAVY_MS = 600000; // 600s (10 min): packages above this get test-level splitting
+// Packages that should always be split test-by-test, even on a cold cache.
+// Without timing data the splitter falls through to alphabetical round-robin,
+// which places these adjacent on the same runner and overwhelms postgres.
+// Forcing them heavy lets `go test -list` enumerate their tests so the
+// bin-packer can spread them across all shards.
+const KNOWN_HEAVY_PKGS = new Set([
+ "github.com/mattermost/mattermost/server/v8/channels/api4",
+ "github.com/mattermost/mattermost/server/v8/channels/app",
+]);
+
if (isNaN(SHARD_INDEX) || isNaN(SHARD_TOTAL) || SHARD_TOTAL < 1) {
console.error("ERROR: SHARD_INDEX and SHARD_TOTAL must be set");
process.exit(1);
@@ -107,19 +117,30 @@ const hasTimingData = Object.keys(pkgTimes).length > 0;
const hasTestTiming = Object.keys(testTimes).length > 0;
// ── Identify heavy packages ──
-// Only split at test level if we have per-test timing data
+// Split at test level for packages above HEAVY_MS (requires per-test timing)
+// AND for the KNOWN_HEAVY_PKGS list (which uses go test -list discovery
+// to enumerate tests when no timing cache exists).
+//
+// Both checks gate on allPkgs membership so stale entries from the cached
+// pkgTimes (renamed/deleted packages from a prior run) can't end up in
+// heavyPkgs — otherwise the post-discovery fallback would emit them as
+// whole-package items for nonexistent packages.
+const allPkgsSet = new Set(allPkgs);
const heavyPkgs = new Set();
if (hasTestTiming) {
for (const [pkg, ms] of Object.entries(pkgTimes)) {
- if (ms > HEAVY_MS) heavyPkgs.add(pkg);
+ if (ms > HEAVY_MS && allPkgsSet.has(pkg)) heavyPkgs.add(pkg);
}
}
+for (const pkg of allPkgs) {
+ if (KNOWN_HEAVY_PKGS.has(pkg)) heavyPkgs.add(pkg);
+}
if (heavyPkgs.size > 0) {
console.log("Heavy packages (test-level splitting):");
for (const p of heavyPkgs) {
- console.log(
- ` ${(pkgTimes[p] / 1000).toFixed(0)}s ${p.split("/").pop()}`,
- );
+ const t = pkgTimes[p];
+ const label = t ? `${(t / 1000).toFixed(0)}s` : "no-timing";
+ console.log(` ${label} ${p.split("/").pop()}`);
}
}
@@ -134,10 +155,10 @@ for (const pkg of allPkgs) {
.map(([k, ms]) => ({ ms, type: "T", pkg, test: k.split("::")[1] }));
if (tests.length > 0) {
items.push(...tests);
- } else {
- // Shouldn't happen, but fall back to whole package
- items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
+ // If no per-test timing exists, the discovery step below enumerates
+ // tests via `go test -list`. A final fallback to whole-package is
+ // added after discovery for packages where both lookups failed.
} else {
items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
@@ -186,6 +207,18 @@ if (heavyPkgs.size > 0) {
);
}
}
+ // Ensure every heavy package has at least one item. A package can reach
+ // this point with zero items if it has no per-test timing AND `go test
+ // -list` failed (e.g. sqlstore on a cold cache).
+ for (const pkg of heavyPkgs) {
+ const hasItems = items.some((it) => it.pkg === pkg);
+ if (!hasItems) {
+ console.log(
+ ` ${pkg.split("/").pop()}: no per-test data, running as whole package`,
+ );
+ items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
+ }
+ }
console.log("::endgroup::");
}
@@ -199,8 +232,11 @@ const shards = Array.from({ length: SHARD_TOTAL }, () => ({
heavy: {},
}));
-if (!hasTimingData) {
- // Round-robin fallback when no timing data exists
+if (!hasTimingData && heavyPkgs.size === 0) {
+ // Round-robin fallback only when we have *no* signal — no timing cache
+ // and no known-heavy packages to test-level-split. With heavyPkgs we
+ // can still bin-pack: discovered tests (ms=1000 each) drive the
+ // distribution and whole-package items (ms=1) fill in evenly.
console.log("No timing data — using round-robin");
allPkgs.forEach((pkg, i) => {
shards[i % SHARD_TOTAL].whole.push(pkg);
diff --git a/tools/mattermost-govet/Makefile b/tools/mattermost-govet/Makefile
index 05254a180c6..c05d7fd23a9 100644
--- a/tools/mattermost-govet/Makefile
+++ b/tools/mattermost-govet/Makefile
@@ -12,7 +12,7 @@ clean:
rm -rf dist
golangci-lint:
- $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
+ $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
$(GOBIN)/golangci-lint run ./...
check-style: golangci-lint
diff --git a/tools/mattermost-govet/apiAuditLogs/whitelist.go b/tools/mattermost-govet/apiAuditLogs/whitelist.go
index b79b3f6b7b5..9ec9502a30a 100644
--- a/tools/mattermost-govet/apiAuditLogs/whitelist.go
+++ b/tools/mattermost-govet/apiAuditLogs/whitelist.go
@@ -121,6 +121,7 @@ var whiteList = map[string]bool{
"getUserAccessToken": true,
"getUserAccessTokens": true,
"getUserAccessTokensForUser": true,
+ "getUserByAuthData": true,
"getUserByEmail": true,
"getUserByUsername": true,
"getUsers": true,
@@ -133,6 +134,7 @@ var whiteList = map[string]bool{
"getWebappPlugins": true,
"listAutocompleteCommands": true,
"listCommands": true,
+ "localGetUserByAuthData": true,
"openDialog": true,
"patchChannelModerations": true,
"pinPost": true,
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/command.ts b/webapp/channels/src/actions/command.ts
index 41a1b5f746e..3fb49056071 100644
--- a/webapp/channels/src/actions/command.ts
+++ b/webapp/channels/src/actions/command.ts
@@ -81,7 +81,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs
if (!channel) {
return {data: {silentFailureReason: new Error('cannot find current channel')}};
}
- if (channel.type === Constants.PRIVATE_CHANNEL) {
+ if (channel.type === Constants.PRIVATE_CHANNEL || channel.policy_enforced) {
dispatch(openModal({modalId: ModalIdentifiers.LEAVE_PRIVATE_CHANNEL_MODAL, dialogType: LeaveChannelModal, dialogProps: {channel}}));
return {data: {frontendHandled: true}};
}
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 843edb78a89..343cb4f0b0c 100644
--- a/webapp/channels/src/actions/websocket_actions.ts
+++ b/webapp/channels/src/actions/websocket_actions.ts
@@ -155,12 +155,11 @@ import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads';
import store from 'stores/redux_store';
import {
- GROUP_NAME,
- OBJECT_TYPE,
- TARGET_TYPE,
- TARGET_ID,
- LINKED_OBJECT_TYPE,
- SYSTEM_FIELD_TARGET_ID,
+ CLASSIFICATIONS_GROUP_NAME,
+ CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
+ CLASSIFICATIONS_SYSTEM_OBJECT_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_ID,
} from 'components/admin_console/classification_markings/utils';
import {EntityType, invalidateAccessControlAttributesCache} from 'components/common/hooks/useAccessControlAttributes';
import DialogRouter from 'components/dialog_router';
@@ -345,17 +344,22 @@ export function reconnect() {
// Refresh classification fields and values on reconnect when the feature flag is active
if (getFeatureFlagValue(state, 'ClassificationMarkings') === 'true') {
dispatch(
- fetchPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID),
+ fetchPropertyFields(
+ CLASSIFICATIONS_GROUP_NAME,
+ CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_ID,
+ ),
);
dispatch(
fetchPropertyFields(
- GROUP_NAME,
- LINKED_OBJECT_TYPE,
- TARGET_TYPE,
- SYSTEM_FIELD_TARGET_ID,
+ CLASSIFICATIONS_GROUP_NAME,
+ CLASSIFICATIONS_SYSTEM_OBJECT_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_TYPE,
+ CLASSIFICATIONS_FIELD_TARGET_ID,
),
);
- dispatch(fetchSystemPropertyValues(GROUP_NAME));
+ dispatch(fetchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME));
}
if (state.websocket.lastDisconnectAt) {
@@ -775,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/__snapshots__/color_setting.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap
index 3e35be13cfb..736ab5ba0c5 100644
--- a/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap
+++ b/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap
@@ -127,6 +127,18 @@ exports[`components/ColorSetting should match snapshot, disabled 1`] = `
type="text"
value="#fff"
/>
+
+
+
;
+
+ /**
+ * 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;
}
// TODO: this is just a sample schema for the editor, we need to get the actual schema from the server
@@ -94,6 +110,9 @@ function CELEditor({
teamId,
disabled = false,
userAttributes,
+ onTestClick,
+ testButtonLabel,
+ hasMaskedRows = false,
}: CELEditorProps): JSX.Element {
const intl = useIntl();
const [editorState, setEditorState] = useState({
@@ -257,12 +276,12 @@ function CELEditor({
};
}, []); // Only run once on mount
- // Update the editor's readOnly state when disabled prop changes
+ // Update the editor's readOnly state when disabled or hasMaskedRows changes
useEffect(() => {
if (monacoRef.current) {
- monacoRef.current.updateOptions({readOnly: disabled});
+ monacoRef.current.updateOptions({readOnly: disabled || hasMaskedRows});
}
- }, [disabled]);
+ }, [disabled, hasMaskedRows]);
// Helper function to determine current validation state
const getValidationState = useCallback(() => {
@@ -338,6 +357,19 @@ function CELEditor({