From 5566604e030b8e3085c0b1a708e739557a0c394e Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Tue, 19 May 2026 10:12:00 -0400 Subject: [PATCH 1/3] MM-68838: Ping a restored plugin remote immediately on re-register (#36592) * MM-68838: ping restored plugin remote immediately on re-register RegisterPluginForSharedChannels' restore branch updated the row but did not call PingNow, leaving the restored remote offline until the next pingLoop tick (up to PingFreq, default 1 minute). The new-connection branch already calls PingNow; the restore branch now mirrors it so sync attempts immediately after a plugin restart no longer fail with "offline remote cluster". * MM-68838: gob-encode error returns in apiRPCServer.ReceiveSharedChannelAttachmentSyncMsg The apiRPCServer wrapper for ReceiveSharedChannelAttachmentSyncMsg assigned the hook's error return directly to the gob-encoded response struct. When the framework's App.ReceiveSharedChannelAttachmentSyncMsg returned an error wrapped with %w (*fmt.wrapError, an unexported type), gob refused to encode it and the RPC server broke the connection with "type not registered for interface: fmt.wrapError". Every subsequent plugin/server RPC call then returned the zero-value response struct, causing plugins that dereferenced the nil returns to crash. Apply the existing encodableError() helper so the returned error becomes a gob-safe ErrorString, matching every other apiRPCServer method in this file. --- server/channels/app/remote_cluster.go | 57 ++++++- server/channels/app/remote_cluster_test.go | 180 +++++++++++++++++++++ server/public/plugin/client_rpc.go | 1 + server/public/plugin/client_rpc_test.go | 46 ++++++ 4 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 server/public/plugin/client_rpc_test.go 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/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index c0c03f93df7..196b43ed2b3 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 } 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()) + }) +} From 1ffa4d89941e9b5cdbe50466081e5149f83415c2 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 19 May 2026 15:06:58 -0400 Subject: [PATCH 2/3] Add Docker Hub login to Cloud Agent start hook. (#36632) Authenticate DinD pulls at runtime using Cursor dashboard secrets so agents avoid anonymous Docker Hub rate limits. Co-authored-by: Cursor --- .cursor/README.md | 7 +++++-- .cursor/cursor.md | 3 ++- .cursor/scripts/cloud-agent-start.sh | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) 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 From 345a0b76a6d1a3c4dfc467b4b10700ee30a08ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Tue, 19 May 2026 21:25:14 +0200 Subject: [PATCH 3/3] Mm 68506 fe abac mask fe table editor cel and e2e (#36517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MM-68501 - implement GetMaskedVisualAST and wire API handler Co-Authored-By: Claude Opus 4.6 (1M context) * add missing test and fix style issues * fix styles * implement coderabbit feedback * MM-68501 - PR review: split masking file, model-level access mode, reject contradictory config Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68501 - apply shared_only filter to non-option field values (binary masking) * MM-68501 - consolidate masking flag check and log corrupt text value during masking * MM-68503 - add CEL utilities, write-path validation, and merge helpers Combined set of helpers consumed by BE-5's save path: CEL construction / serialization - extractStringValues, buildCELFromConditions, conditionToCEL, celStringLiteral, celValueLiteral. Used to rebuild a CEL string from a VisualExpression, including for GetMaskedExpression on the read-side of policy GET / search responses. Merge-on-save helpers - getHiddenValues (per-condition, with pre-fetched fields map for N+1 avoidance) — finds which stored values are not visible to the caller. - mergeConditionValues — re-injects the hidden values into a submitted condition without duplicates. - Together, these let BE-5 preserve attribute values the caller cannot see while still letting them edit the visible parts of a policy. Write-path value-hold validation - validatePolicyExpressionValues, invalidValueError, validateConditionValues. - Generic "Invalid value." error on every rejection — no signal about whether the value exists or is merely not held (prevents enumeration). - Rejects the masked-token sentinel "--------" if submitted as a literal. These all live in access_control_masking.go alongside the masking primitives that BE-2 introduced. i18n entries added for the two new error IDs (app.pap.save_policy.invalid_value, app.pap.validate_expression_values.app_error). Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68503 - handle the masked-token sentinel in validation and merge When the GET /policies endpoint returns a policy via MaskPolicyExpressions, the raw expression contains the masked-token sentinel "--------" in place of hidden values. If the frontend round-trips that expression unchanged back to the server (e.g., the admin only modified channel assignment, not the rules), the sentinel reaches the save path. The previous code in validateConditionValues rejected the sentinel as "Invalid value." This blocks the legitimate round-trip case. Fix: - validateConditionValues: treat the sentinel as a placeholder and skip it during visibility / source-only / unknown-mode checks. Other values are still validated normally. - mergeConditionValues: strip the sentinel from submitted values before appending hidden values, so it never propagates to the stored result. Both array and single-value forms (string == "--------") are handled. TestMaskedTokenRejection (which asserted the old rejection behavior) is replaced by TestMaskedTokenConstant which only verifies the sentinel string itself. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68504 - integrate save-path masking: 403 block on delete, merge-on-save, response masking Save path (CreateOrUpdateAccessControlPolicy): * validatePolicyExpressionValues runs on the submitted expression before merge so re-injected hidden values are never validated against the caller's holdings. * mergeStoredPolicyExpressions re-injects hidden values from the stored policy and blocks (HTTP 403) any attempt to remove a condition that contained values the caller cannot see — closes the row-deletion gap in classified environments. * mergeExpressionWithMaskedValues unwraps single-element arrays for scalar operators after restoring the stored operator (avoids "attr == [val]" invalid CEL when the frontend submits "attr in []" as the masked-row placeholder for an originally-scalar condition). * checkSelfInclusion is bypassed for system admins (they may legitimately write conditions for values they do not hold); masking and value-hold validation still apply to system admins. Delete path (DeleteAccessControlPolicy): * Same masked-values 403 block — a caller with masked values cannot delete the policy at all (UI Delete button is also disabled in FE-3). Response masking: * createAccessControlPolicy and setAccessControlPolicyActiveStatus run MaskPolicyExpressions on the response so even a save reply doesn't leak the values the caller does not hold. GetMaskedExpression, maskConditionValuesWithToken, replaceHiddenValuesWithToken, MaskPolicyExpressions live alongside the rest of the masking helpers in access_control_masking.go. team_access_control.go: corrects ValidateChannelEligibilityForAccessControl call site (drops the spurious receiver and rctx; it's a package-level helper that only takes channel). Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68503 - address PR review: batch field fetches, propagate errors, fail-closed write path * MM-68503 - restore team-admin api4 tests accidentally dropped during BE-5 rebuild * MM-68503 - address review and CodeRabbit feedback on save-path masking * add tests for delete masking, self-inclusion, GET mask * add assertions to strengten tests * MM-68505 - add has_masked_values type and MaskedChip component Co-Authored-By: Claude Opus 4.6 (1M context) * MM-68506 - add masking support to TableEditor and team settings modal TableEditor (table_editor.tsx, table_editor.scss): - hasMaskedValues plumbed through rows; lock operator/attribute selectors on masked rows. - Row remove (trash) button disabled on masked rows; disabled-state CSS so the icon doesn't show the destructive hover colour or a pointer cursor. - Test Rules button disabled when any row has masked values, with tooltip. - onMaskedStateChange callback to notify the parent for cross-component states (CEL editor read-only, Save disabled, banners). Value selectors (single_value_selector_menu.tsx, multi_value_selector_menu.tsx, selector_menus.scss, value_selector_menu.tsx): - Append MaskedChip after visible chips on multi-value rows. - Render MaskedChip as the sole value on single-value rows where the caller holds no visible value. Policy details (policy_details.tsx, .scss, .test.tsx): - Track hasMaskedRows state; receive from TableEditor via onMaskedStateChange. - Show masked-values warning banner above the editor when present. - Same banner on the Delete confirmation modal so admins understand why deletion is consequential. Team settings modal (team_policy_editor.tsx, .scss): - Same masked-values plumbing; delete button uses the disabled state when a policy has masked values, regardless of whether channels are assigned. - Pre-save check no longer treats "in []" as an incomplete rule — that placeholder comes from fully-masked rows that merge-on-save will fill in. i18n entries added for the new strings. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68506 - fix hook order in SingleValueSelector when masked state changes The early return for `hasMaskedValues && !value` sat between useState and useCallback declarations, so when a parent re-render flipped the masked state (e.g. after deleting a sibling rule) React saw a different hook count and crashed with "Rendered fewer hooks than expected". Move the read-only short-circuit after all hook declarations so the hook order stays stable across renders. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68507 - CEL editor read-only when masked + system console wiring CEL editor (editor.tsx, editor.scss): - hasMaskedRows prop: when true, Monaco is set to read-only and a banner explains why ("This expression contains restricted values. Switch to Simple mode to edit the values you have access to, or delete the entire rule."). - Test Rules button disabled in CEL mode when hasMaskedRows is true. Policy details (policy_details.tsx, .scss): - hasMaskedRows state plumbed to CELEditor, TableEditor, and the Save / Delete buttons. - Save button disabled while masked rows are present (kept after the save-allowed-with-masked-values change in BE-5? — no, here we keep Save enabled so admins can add/modify rules; only row removal of masked rows is blocked). - Delete Policy button disabled when hasMaskedRows; a SectionNotice above the Delete card explains why ("This policy contains restricted values - Deletion not allowed"). - New save error messages: invalid_value and self_exclusion are surfaced from the server's generic responses. Policies list (policies.tsx): minor wiring change for the new state plumbing. Table editor (table_editor.tsx): cross-component coordination — emits onMaskedStateChange and respects the disabled-for-masked-row policy. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68508 - E2E suite for attribute-value masking Covers the full read+write masking flow against a real server: - Masked chip rendering, operator/attribute lock, Test Rules disabled. - System admin subject to masking like any other caller (no role bypass). - Save with masked values: hidden values preserved by merge-on-save. - Trash button disabled on masked rows; server returns 403 on direct API attempt to remove a masked condition. - Delete Policy button disabled + server 403 when policy has masked values (both system console and team settings modal paths). - Self-inclusion failure only fires when the caller holds full visibility. - CEL editor read-only with banner when masked rows present. - Direct API validation: non-held values and the masked-token sentinel rejected with a generic "Invalid value." error. - Feature-flag-off path: no masking, all values visible. - Text-field shared_only masking (binary) with `in` and `==` operators. A pluggable DB-setup helper marks specific CPA fields as shared_only for the duration of a test (with per-test cleanup) since the API blocks setting access_mode=shared_only without a source_plugin_id. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68506 - fix lint, jest mock factory, and unreachable delete-modal test * MM-68506 - localize masked-condition-deleted save error * MM-68506 - fix masked-policy delete warning detection and localize masked_rule_deleted * fix linter issues * MM-68506 - surface delete error, lock value selector on masked rows, drop dead remove-modal * fix linter, add translations, adjust specs * import wittoltip from shared * fix linter and use the correct button variant * MM-68506 - drop dangling rationale comment in access_control_field_test * fix linter, translation and e2e tests * use pg ts types and dependencies for e2e types mocks * adjust switch mode persistance restriction * fix team settings style buttons * fail-closed guard for advanced expressions in merge-on-save, plus helper unit tests, and FF/test-helper cleanups * MM-68505 - add has_masked_values type and MaskedChip component Co-Authored-By: Claude Opus 4.6 (1M context) * MM-68506 - add masking support to TableEditor and team settings modal TableEditor (table_editor.tsx, table_editor.scss): - hasMaskedValues plumbed through rows; lock operator/attribute selectors on masked rows. - Row remove (trash) button disabled on masked rows; disabled-state CSS so the icon doesn't show the destructive hover colour or a pointer cursor. - Test Rules button disabled when any row has masked values, with tooltip. - onMaskedStateChange callback to notify the parent for cross-component states (CEL editor read-only, Save disabled, banners). Value selectors (single_value_selector_menu.tsx, multi_value_selector_menu.tsx, selector_menus.scss, value_selector_menu.tsx): - Append MaskedChip after visible chips on multi-value rows. - Render MaskedChip as the sole value on single-value rows where the caller holds no visible value. Policy details (policy_details.tsx, .scss, .test.tsx): - Track hasMaskedRows state; receive from TableEditor via onMaskedStateChange. - Show masked-values warning banner above the editor when present. - Same banner on the Delete confirmation modal so admins understand why deletion is consequential. Team settings modal (team_policy_editor.tsx, .scss): - Same masked-values plumbing; delete button uses the disabled state when a policy has masked values, regardless of whether channels are assigned. - Pre-save check no longer treats "in []" as an incomplete rule — that placeholder comes from fully-masked rows that merge-on-save will fill in. i18n entries added for the new strings. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68506 - fix hook order in SingleValueSelector when masked state changes The early return for `hasMaskedValues && !value` sat between useState and useCallback declarations, so when a parent re-render flipped the masked state (e.g. after deleting a sibling rule) React saw a different hook count and crashed with "Rendered fewer hooks than expected". Move the read-only short-circuit after all hook declarations so the hook order stays stable across renders. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68507 - CEL editor read-only when masked + system console wiring CEL editor (editor.tsx, editor.scss): - hasMaskedRows prop: when true, Monaco is set to read-only and a banner explains why ("This expression contains restricted values. Switch to Simple mode to edit the values you have access to, or delete the entire rule."). - Test Rules button disabled in CEL mode when hasMaskedRows is true. Policy details (policy_details.tsx, .scss): - hasMaskedRows state plumbed to CELEditor, TableEditor, and the Save / Delete buttons. - Save button disabled while masked rows are present (kept after the save-allowed-with-masked-values change in BE-5? — no, here we keep Save enabled so admins can add/modify rules; only row removal of masked rows is blocked). - Delete Policy button disabled when hasMaskedRows; a SectionNotice above the Delete card explains why ("This policy contains restricted values - Deletion not allowed"). - New save error messages: invalid_value and self_exclusion are surfaced from the server's generic responses. Policies list (policies.tsx): minor wiring change for the new state plumbing. Table editor (table_editor.tsx): cross-component coordination — emits onMaskedStateChange and respects the disabled-for-masked-row policy. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68508 - E2E suite for attribute-value masking Covers the full read+write masking flow against a real server: - Masked chip rendering, operator/attribute lock, Test Rules disabled. - System admin subject to masking like any other caller (no role bypass). - Save with masked values: hidden values preserved by merge-on-save. - Trash button disabled on masked rows; server returns 403 on direct API attempt to remove a masked condition. - Delete Policy button disabled + server 403 when policy has masked values (both system console and team settings modal paths). - Self-inclusion failure only fires when the caller holds full visibility. - CEL editor read-only with banner when masked rows present. - Direct API validation: non-held values and the masked-token sentinel rejected with a generic "Invalid value." error. - Feature-flag-off path: no masking, all values visible. - Text-field shared_only masking (binary) with `in` and `==` operators. A pluggable DB-setup helper marks specific CPA fields as shared_only for the duration of a test (with per-test cleanup) since the API blocks setting access_mode=shared_only without a source_plugin_id. Co-Authored-By: Claude Opus 4.7 (1M context) * MM-68506 - fix lint, jest mock factory, and unreachable delete-modal test * MM-68506 - localize masked-condition-deleted save error * MM-68506 - fix masked-policy delete warning detection and localize masked_rule_deleted * fix linter issues * MM-68506 - surface delete error, lock value selector on masked rows, drop dead remove-modal * fix linter, add translations, adjust specs * import wittoltip from shared * fix linter and use the correct button variant * MM-68506 - drop dangling rationale comment in access_control_field_test * fix linter, translation and e2e tests * use pg ts types and dependencies for e2e types mocks * adjust switch mode persistance restriction * fix team settings style buttons * fail-closed guard for advanced expressions in merge-on-save, plus helper unit tests, and FF/test-helper cleanups * Refactor access control methods to use GetPropertyGroup for CPA group ID retrieval * fix styles * disable delete on masked policies in list view and remove dead modal warnings * fix unit tests * preserve hasAnyOf operator display for fully-masked multiselect conditions * address PR feedback: lock Actions on masked save, filter source/shared_only from /attributes, add unit tests and e2e tests * fix e2e tests * comment out e2e to isolate issue * completely remove the files to pass linter --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mattermost Build --- server/channels/app/access_control.go | 49 ++- server/channels/app/access_control_masking.go | 54 ++- .../channels/app/access_control_merge_test.go | 57 ++++ server/channels/app/access_control_test.go | 310 ++++++++++++++++++ .../properties/access_control_field_test.go | 4 - .../sqlstore/access_control_policy_store.go | 8 +- .../editors/cel_editor/editor.scss | 18 + .../editors/cel_editor/editor.tsx | 31 +- .../editors/table_editor/masked_chip.test.tsx | 45 +++ .../editors/table_editor/masked_chip.tsx | 40 +++ .../multi_value_selector_menu.tsx | 16 +- .../editors/table_editor/selector_menus.scss | 14 +- .../single_value_selector_menu.tsx | 19 ++ .../editors/table_editor/table_editor.scss | 9 +- .../table_editor/table_editor.test.tsx | 88 +++++ .../editors/table_editor/table_editor.tsx | 104 ++++-- .../table_editor/value_selector_menu.tsx | 5 +- .../access_control/policies.test.tsx | 82 +++++ .../admin_console/access_control/policies.tsx | 108 +++++- .../policy_details.test.tsx.snap | 190 +---------- .../policy_details/policy_details.scss | 8 + .../policy_details/policy_details.test.tsx | 162 ++++++++- .../policy_details/policy_details.tsx | 65 +++- .../team_policy_editor.scss | 4 + .../team_policy_editor.tsx | 37 ++- .../team_info_tab/team_picture_section.scss | 2 +- .../modals/components/save_changes_panel.tsx | 4 +- webapp/channels/src/i18n/en.json | 16 + webapp/platform/types/src/access_control.ts | 1 + 29 files changed, 1276 insertions(+), 274 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.test.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.tsx diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index 99df3abc372..de36aa68401 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -211,7 +211,8 @@ func (a *App) mergeStoredPolicyExpressions(rctx request.CTX, policy *model.Acces if i >= len(existingPolicy.Rules) { continue } - storedExpr := existingPolicy.Rules[i].Expression + storedRule := existingPolicy.Rules[i] + storedExpr := storedRule.Expression if storedExpr == "" || storedExpr == "true" { continue } @@ -220,6 +221,15 @@ func (a *App) mergeStoredPolicyExpressions(rctx request.CTX, policy *model.Acces return appErr } policy.Rules[i].Expression = mergedExpr + // If hidden values were re-injected into the expression, the caller was + // working from a masked view of this rule. Lock Actions to the stored + // value too — without this, a caller who sees "--------" could swap the + // action type (e.g., "membership" → "upload_file_attachment") and the + // merge would restore the hidden CEL value while silently removing the + // original access restriction. + if mergedExpr != rule.Expression { + policy.Rules[i].Actions = storedRule.Actions + } } // Any stored rules beyond the submitted set were dropped by the caller. If any of those @@ -387,8 +397,16 @@ func (a *App) mergeExpressionWithMaskedValues(rctx request.CTX, policyID, submit // 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 arr, ok := merged.Value.([]any); ok { + 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 { @@ -645,6 +663,33 @@ 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 } diff --git a/server/channels/app/access_control_masking.go b/server/channels/app/access_control_masking.go index f82c9128374..2faca231918 100644 --- a/server/channels/app/access_control_masking.go +++ b/server/channels/app/access_control_masking.go @@ -362,9 +362,11 @@ func mergeConditionValues(submitted model.Condition, hiddenValues []string) mode merged.Value = result case string: - // Empty string and the masked-token sentinel both mean "no real value - // submitted here"; restore from hidden values. - if (v == "" || v == maskedTokenValue) && len(hiddenValues) > 0 { + // 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] } @@ -545,6 +547,13 @@ func conditionToCEL(cond model.Condition) string { 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, " || ") + ")" @@ -750,8 +759,14 @@ func (a *App) GetMaskedExpression(rctx request.CTX, expression string, callerID rctxWithCaller := RequestContextWithCallerID(rctx, callerID) fieldsByName := a.fetchConditionFields(rctxWithCaller, visualAST.Conditions, cpaGroupID) + hasMasked := false for i := range visualAST.Conditions { - a.maskConditionValuesWithToken(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByName) + if a.maskConditionValuesWithToken(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByName) { + hasMasked = true + } + } + if !hasMasked { + return expression, nil } return buildCELFromConditions(visualAST.Conditions), nil @@ -761,27 +776,30 @@ func (a *App) GetMaskedExpression(rctx request.CTX, expression string, callerID // 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). -func (a *App) maskConditionValuesWithToken(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) { +// 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 + return false } fieldName := extractFieldName(condition.Attribute) if fieldName == "" { - return + return false } field, ok := fieldsByName[fieldName] if !ok { condition.Value = maskedTokenValue // fail closed - return + return true } switch field.GetAccessMode() { case model.PropertyAccessModePublic: - return + 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 { @@ -789,15 +807,17 @@ func (a *App) maskConditionValuesWithToken(rctx request.CTX, callerID string, co } else { visibleNames = a.getCallerTextValues(rctx, callerID, field, cpaGroupID) } - replaceHiddenValuesWithToken(condition, visibleNames) + 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. -func replaceHiddenValuesWithToken(condition *model.Condition, visibleNames map[string]struct{}) { +// 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 @@ -817,11 +837,14 @@ func replaceHiddenValuesWithToken(condition *model.Condition, visibleNames map[s result = append(result, maskedTokenValue) } condition.Value = result + return hasMasked case string: if _, visible := visibleNames[v]; !visible { condition.Value = maskedTokenValue + return true } } + return false } // MaskPolicyExpressions masks non-held literal values in all policy rule expressions, in place. @@ -867,9 +890,14 @@ func (a *App) MaskPolicyExpressions(rctx request.CTX, policy *model.AccessContro if ast == nil { continue } + hasMasked := false for j := range ast.Conditions { - a.maskConditionValuesWithToken(rctxWithCaller, callerID, &ast.Conditions[j], cpaGroupID, fieldsByName) + if a.maskConditionValuesWithToken(rctxWithCaller, callerID, &ast.Conditions[j], cpaGroupID, fieldsByName) { + hasMasked = true + } + } + if hasMasked { + policy.Rules[i].Expression = buildCELFromConditions(ast.Conditions) } - policy.Rules[i].Expression = buildCELFromConditions(ast.Conditions) } } diff --git a/server/channels/app/access_control_merge_test.go b/server/channels/app/access_control_merge_test.go index 4f21749378d..28cc6ae161b 100644 --- a/server/channels/app/access_control_merge_test.go +++ b/server/channels/app/access_control_merge_test.go @@ -89,6 +89,24 @@ func TestBuildCELFromConditions(t *testing.T) { 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{ { @@ -424,6 +442,45 @@ func TestMergeConditionValues(t *testing.T) { 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) { diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go index fa5e6ca210c..151d107fe1c 100644 --- a/server/channels/app/access_control_test.go +++ b/server/channels/app/access_control_test.go @@ -2232,3 +2232,313 @@ func TestGetRecommendedPublicChannelsForUser(t *testing.T) { mockACS.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. +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() + + storedExpr := `user.attributes.` + fieldName + ` == "TopSecret"` + maskedExpr := `user.attributes.` + fieldName + ` == "--------"` + + storedPolicy := &model.AccessControlPolicy{ + ID: policyID, + Type: model.AccessControlPolicyTypeParent, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionMembership}, Expression: storedExpr}, + }, + } + + // Attacker submits the masked expression unchanged but swaps the action. + submittedPolicy := &model.AccessControlPolicy{ + ID: policyID, + Type: model.AccessControlPolicyTypeParent, + Rules: []model.AccessControlPolicyRule{ + {Actions: []string{model.AccessControlPolicyActionUploadFileAttachment}, 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.AccessControlPolicyActionMembership}, 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/properties/access_control_field_test.go b/server/channels/app/properties/access_control_field_test.go index fbb4e597605..e940a3a830e 100644 --- a/server/channels/app/properties/access_control_field_test.go +++ b/server/channels/app/properties/access_control_field_test.go @@ -1506,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/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go index 0ae4983afeb..d2324f6f1ea 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, @@ -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' diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss index f90ee84f51e..b84f0b3e328 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.scss @@ -1,6 +1,24 @@ .cel-editor { margin-bottom: 24px; + &__masked-banner { + display: flex; + align-items: center; + padding: 10px 16px; + border-radius: 4px; + margin-bottom: 8px; + background-color: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 13px; + gap: 8px; + line-height: 20px; + + .icon { + color: var(--dnd-indicator); + font-size: 18px; + } + } + &__container { position: relative; display: flex; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx index bdfe077bc8b..9326d718682 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx @@ -80,6 +80,7 @@ interface CELEditorProps { attribute: string; values: string[]; }>; + hasMaskedRows?: boolean; } // TODO: this is just a sample schema for the editor, we need to get the actual schema from the server @@ -94,6 +95,7 @@ function CELEditor({ teamId, disabled = false, userAttributes, + hasMaskedRows = false, }: CELEditorProps): JSX.Element { const intl = useIntl(); const [editorState, setEditorState] = useState({ @@ -257,12 +259,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 +340,19 @@ function CELEditor({
+ {hasMaskedRows && ( +
+ + +
+ )} +
setEditorState((prev) => ({...prev, showTestResults: true}))} - disabled={disabled || !editorState.expression || !editorState.isValid || editorState.isValidating} + disabled={disabled || hasMaskedRows || !editorState.expression || !editorState.isValid || editorState.isValidating} + disabledTooltip={ + hasMaskedRows ? + intl.formatMessage({ + id: 'admin.access_control.cel_editor.masked_values_tooltip', + defaultMessage: 'Test is unavailable because this policy contains restricted attribute values.', + }) : + undefined + } />
{editorState.showTestResults && ( diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.test.tsx new file mode 100644 index 00000000000..c657c435e1d --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.test.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import MaskedChip from './masked_chip'; + +describe('MaskedChip', () => { + test('renders the masked token text', () => { + renderWithContext(); + expect(screen.getByText('••••••••')).toBeInTheDocument(); + }); + + test('has role="img" for accessibility', () => { + renderWithContext(); + const chip = screen.getByRole('img'); + expect(chip).toBeInTheDocument(); + }); + + test('has correct aria-label', () => { + renderWithContext(); + const chip = screen.getByRole('img'); + expect(chip).toHaveAttribute('aria-label', 'Hidden values that you do not have permission to view'); + }); + + test('does not have aria-readonly (invalid for role="img")', () => { + renderWithContext(); + const chip = screen.getByRole('img'); + expect(chip).not.toHaveAttribute('aria-readonly'); + }); + + test('does not render a close/remove button', () => { + renderWithContext(); + const removeButtons = document.querySelectorAll('.select__multi-value__remove'); + expect(removeButtons).toHaveLength(0); + }); + + test('has the masked CSS class', () => { + renderWithContext(); + const chip = screen.getByRole('img'); + expect(chip).toHaveClass('select__multi-value--masked'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.tsx new file mode 100644 index 00000000000..ed16e875d6f --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/masked_chip.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import {WithTooltip} from '@mattermost/shared/components/tooltip'; + +import './selector_menus.scss'; + +/** Non-interactive chip indicating hidden attribute values exist in this condition. */ +const MaskedChip = (): JSX.Element => { + const {formatMessage} = useIntl(); + + const tooltipText = formatMessage({ + id: 'admin.access_control.masked_chip.tooltip', + defaultMessage: 'One or more restricted values', + }); + + const ariaLabel = formatMessage({ + id: 'admin.access_control.masked_chip.aria_label', + defaultMessage: 'Hidden values that you do not have permission to view', + }); + + return ( + +
+
+ {'••••••••'} +
+
+
+ ); +}; + +export default MaskedChip; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx index e3d1b4f8294..9fc32e46b4a 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx @@ -12,6 +12,8 @@ import * as Menu from 'components/menu'; import './selector_menus.scss'; +import MaskedChip from './masked_chip'; + // MultiValueSelector handles selection of multiple values (operator 'in') const MultiValueSelector = ({ values, @@ -20,6 +22,7 @@ const MultiValueSelector = ({ options = [], allowCreateValue = false, placeholder, + hasMaskedValues = false, }: { values: string[]; disabled: boolean; @@ -27,6 +30,7 @@ const MultiValueSelector = ({ options?: PropertyFieldOption[]; allowCreateValue?: boolean; placeholder?: string; + hasMaskedValues?: boolean; }) => { const {formatMessage} = useIntl(); const [filter, setFilter] = useState(''); @@ -92,6 +96,15 @@ const MultiValueSelector = ({ // Memoize cell contents to prevent unnecessary re-renders const cellContents = useMemo(() => { if (values.length === 0) { + // When no visible values exist but the row has masked ones, show only the masked chip. + if (hasMaskedValues) { + return ( +
+ +
+ ); + } + let visualPlaceholderText = defaultMultiPlaceholder; if (actualAllowCreateForMenu && options.length === 0) { visualPlaceholderText = defaultCreatePlaceholder; @@ -131,9 +144,10 @@ const MultiValueSelector = ({ )}
))} + {hasMaskedValues && } ); - }, [values, disabled]); + }, [values, disabled, hasMaskedValues]); return (
diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss index 830b5935523..29b90eb25b0 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss @@ -86,12 +86,24 @@ .select__multi-value__remove { padding: 0 4px; cursor: pointer; - + &:hover { background-color: rgba(var(--center-channel-color-rgb), 0.16); } } + .select__multi-value--masked { + background-color: rgba(var(--center-channel-color-rgb), 0.08); + cursor: default; + user-select: none; + + .select__multi-value__label { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-family: monospace; + letter-spacing: 1px; + } + } + &__simple-input { width: 100%; height: 40px; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx index aafacab1877..47058012ef3 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx @@ -12,6 +12,8 @@ import * as Menu from 'components/menu'; import Constants from 'utils/constants'; +import MaskedChip from './masked_chip'; + import './selector_menus.scss'; // SingleValueSelector handles selection of a single value (operators like 'is', 'contains', etc.) @@ -22,6 +24,7 @@ const SingleValueSelector = ({ options = [], allowCreateValue = false, placeholder, + hasMaskedValues = false, }: { value: string; disabled: boolean; @@ -29,6 +32,7 @@ const SingleValueSelector = ({ options?: PropertyFieldOption[]; allowCreateValue?: boolean; placeholder?: string; + hasMaskedValues?: boolean; }) => { const {formatMessage} = useIntl(); const [filter, setFilter] = useState(''); @@ -95,6 +99,21 @@ const SingleValueSelector = ({ } }, [allowCreateValue, filter, handleCreateValue]); + // When masked values are present and the caller holds no visible value, + // the row is effectively read-only — show only the masked chip. + // Placed AFTER hook declarations so hook order stays stable when the + // masked state changes between renders (e.g., parent re-renders after + // a sibling rule is deleted). + if (hasMaskedValues && !value) { + return ( +
+
+ +
+
+ ); + } + if (!hasOptions) { // For attributes without options, show simple input field return ( diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss index 910a25a7198..755a4045666 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss @@ -46,10 +46,15 @@ background: none; color: rgba(var(--center-channel-color-rgb), 0.56); cursor: pointer; - - &:hover { + + &:hover:not(:disabled) { color: var(--error-text); } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } } &__actions-row { diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx index 25e59f1fc6d..1c4f07d5b24 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx @@ -27,6 +27,7 @@ describe('parseExpression', () => { operator: 'is', values: ['Engineering'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -50,6 +51,7 @@ describe('parseExpression', () => { operator: 'in', values: ['US', 'CA'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -73,6 +75,7 @@ describe('parseExpression', () => { operator: 'is not', values: ['guest'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -96,6 +99,7 @@ describe('parseExpression', () => { operator: 'starts with', values: ['admin'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -126,12 +130,14 @@ describe('parseExpression', () => { operator: 'starts with', values: ['admin'], attribute_type: 'text', + hasMaskedValues: false, }, { attribute: 'department', operator: 'is', values: ['Engineering'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -155,6 +161,7 @@ describe('parseExpression', () => { operator: 'is', values: ['foo'], attribute_type: 'text', + hasMaskedValues: false, }, ]); }); @@ -164,6 +171,44 @@ describe('parseExpression', () => { expect(parseExpression(undefined as any)).toEqual([]); expect(parseExpression({conditions: []})).toEqual([]); }); + + test('sets hasMaskedValues=true when condition has has_masked_values flag', () => { + const ast: AccessControlVisualAST = { + conditions: [ + { + attribute: 'user.attributes.program', + operator: 'in', + value: ['Alpha'], + value_type: 0, + attribute_type: 'text', + has_masked_values: true, + }, + { + attribute: 'user.attributes.clearance', + operator: 'in', + value: [], + value_type: 0, + attribute_type: 'text', + has_masked_values: true, + }, + { + attribute: 'user.attributes.department', + operator: '==', + value: 'Engineering', + value_type: 0, + attribute_type: 'text', + }, + ], + }; + + const rows = parseExpression(ast); + expect(rows[0].hasMaskedValues).toBe(true); // partial: caller holds Alpha + expect(rows[0].values).toEqual(['Alpha']); + expect(rows[1].hasMaskedValues).toBe(true); // fully masked: caller holds nothing + expect(rows[1].values).toEqual([]); + expect(rows[2].hasMaskedValues).toBe(false); // no masking + expect(rows[2].values).toEqual(['Engineering']); + }); }); describe('parseExpression with multiselect attributes', () => { @@ -186,6 +231,7 @@ describe('parseExpression with multiselect attributes', () => { operator: 'has all of', values: ['JavaScript', 'Python'], attribute_type: 'multiselect', + hasMaskedValues: false, }, ]); }); @@ -209,6 +255,7 @@ describe('parseExpression with multiselect attributes', () => { operator: 'has any of', values: ['Dragon', 'Phoenix'], attribute_type: 'multiselect', + hasMaskedValues: false, }, ]); }); @@ -232,6 +279,7 @@ describe('parseExpression with multiselect attributes', () => { operator: 'has all of', values: ['JavaScript'], attribute_type: 'multiselect', + hasMaskedValues: false, }, ]); }); @@ -348,6 +396,7 @@ describe('rowToCEL', () => { operator: 'has any of', values: ['Dragon', 'Phoenix'], attribute_type: 'multiselect', + hasMaskedValues: false, }); expect(cel).toBe('("Dragon" in user.attributes.programs || "Phoenix" in user.attributes.programs)'); }); @@ -358,6 +407,7 @@ describe('rowToCEL', () => { operator: 'has all of', values: ['Dragon', 'Phoenix'], attribute_type: 'multiselect', + hasMaskedValues: false, }); expect(cel).toBe('"Dragon" in user.attributes.programs && "Phoenix" in user.attributes.programs'); }); @@ -368,6 +418,7 @@ describe('rowToCEL', () => { operator: 'has any of', values: ['Dragon'], attribute_type: 'multiselect', + hasMaskedValues: false, }); expect(cel).toBe('"Dragon" in user.attributes.programs'); }); @@ -378,6 +429,7 @@ describe('rowToCEL', () => { operator: 'has all of', values: ['admin'], attribute_type: 'multiselect', + hasMaskedValues: false, }); expect(cel).toBe('"admin" in user.attributes.tags'); }); @@ -388,6 +440,7 @@ describe('rowToCEL', () => { operator: 'in', values: ['Eng', 'Ops'], attribute_type: 'select', + hasMaskedValues: false, }); expect(cel).toBe('user.attributes.department in ["Eng", "Ops"]'); }); @@ -398,6 +451,7 @@ describe('rowToCEL', () => { operator: 'is', values: ['TopSecret'], attribute_type: 'text', + hasMaskedValues: false, }); expect(cel).toBe('user.attributes.clearance == "TopSecret"'); }); @@ -408,6 +462,7 @@ describe('rowToCEL', () => { operator: 'contains', values: ['@example.com'], attribute_type: 'text', + hasMaskedValues: false, }); expect(cel).toBe('user.attributes.email.contains("@example.com")'); }); @@ -418,9 +473,42 @@ describe('rowToCEL', () => { operator: 'is', values: ['O\'Brien\'s "Team"'], attribute_type: 'text', + hasMaskedValues: false, }); expect(cel).toBe('user.attributes.team == "O\'Brien\'s \\"Team\\""'); }); + + // --- Masking-related tests --- + + test('fully-masked row (hasMaskedValues=true, values=[]) emits "in []" placeholder regardless of operator', () => { + // The placeholder is needed so the backend merge can locate this condition + // by attribute and re-inject the hidden values. The operator is irrelevant + // because the backend always overrides it from the stored expression. + const operators = ['in', 'is', 'has all of', 'has any of', 'contains', 'starts with']; + for (const operator of operators) { + const cel = rowToCEL({ + attribute: 'program', + operator, + values: [], + attribute_type: 'text', + hasMaskedValues: true, + }); + expect(cel).toBe('user.attributes.program in []'); + } + }); + + test('partially-masked row (hasMaskedValues=true, values non-empty) uses normal CEL path', () => { + // The caller holds "Alpha"; "Bravo" and "Charlie" are hidden. + // The row should emit the visible value normally — backend merge appends the rest. + const cel = rowToCEL({ + attribute: 'program', + operator: 'in', + values: ['Alpha'], + attribute_type: 'text', + hasMaskedValues: true, + }); + expect(cel).toBe('user.attributes.program in ["Alpha"]'); + }); }); describe('isSimpleExpression', () => { diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx index aef6181c239..f7de92128ea 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useEffect, useCallback} from 'react'; +import React, {useState, useEffect, useCallback, useMemo} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import type {AccessControlVisualAST} from '@mattermost/types/access_control'; @@ -28,6 +28,16 @@ export function celStringLiteral(val: string): string { } export function rowToCEL(row: TableRow): string { + // A fully-masked row has no visible values on the client side. Emit a + // placeholder "in []" expression so the backend merge can locate this + // condition by attribute and re-inject the hidden values before persisting. + // Without this guard the condition would be filtered out by updateExpression, + // the empty expression would be sent to the server, and buildCELFromConditions + // would return "true" — making the policy wide-open (security regression). + if (row.hasMaskedValues && row.values.length === 0) { + return `user.attributes.${row.attribute} in []`; + } + const attributeExpr = `user.attributes.${row.attribute}`; const config = OPERATOR_CONFIG[row.operator]; @@ -82,6 +92,9 @@ interface TableEditorProps { // Props for user self-exclusion detection isSystemAdmin?: boolean; validateExpressionAgainstRequester?: (expression: string) => Promise>; + + // Callback to notify parent when masked state changes (for CEL editor integration) + onMaskedStateChange?: (hasMasked: boolean) => void; } // Finds the first available (non-disabled) attribute from a list of user attributes. @@ -131,8 +144,10 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] = let values; if (Array.isArray(node.value)) { values = node.value; - } else { + } else if (node.value !== null && node.value !== undefined) { values = [node.value]; + } else { + values = []; } tableRows.push({ @@ -140,12 +155,33 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] = operator: op, values, attribute_type: node.attribute_type, + hasMaskedValues: node.has_masked_values === true, }); } return tableRows; }; +function getTestButtonTooltip( + hasMaskedRows: boolean, + userWouldBeExcluded: boolean, + formatMessage: ReturnType['formatMessage'], +): string | undefined { + if (hasMaskedRows) { + return formatMessage({ + id: 'admin.access_control.table_editor.masked_values_tooltip', + defaultMessage: 'Test is unavailable because this policy contains restricted attribute values.', + }); + } + if (userWouldBeExcluded) { + return formatMessage({ + id: 'admin.access_control.table_editor.user_excluded_tooltip', + defaultMessage: 'You cannot test access rules that would exclude you from the channel', + }); + } + return undefined; +} + // TableEditor provides a user-friendly table interface for constructing and editing // CEL (Common Expression Language) expressions based on user attributes. // It parses incoming CEL expressions into rows and reconstructs the expression upon changes. @@ -164,6 +200,7 @@ function TableEditor({ actions, isSystemAdmin = false, validateExpressionAgainstRequester, + onMaskedStateChange, }: TableEditorProps): JSX.Element { const {formatMessage} = useIntl(); @@ -175,10 +212,18 @@ function TableEditor({ // State for user self-exclusion detection (only applies to non-system-admins) const [userWouldBeExcluded, setUserWouldBeExcluded] = useState(false); - // Effect to parse the incoming CEL expression string (value prop) - // and update the internal rows state. Handles errors during parsing. + // Derived state: whether any row has masked values + const hasMaskedRows = useMemo(() => rows.some((r) => r.hasMaskedValues), [rows]); + + // Prevents getVisualAST re-parse when expression change is from internal row editing. + const isInternalChange = React.useRef(false); + useEffect(() => { - // Skip parsing if no expression to avoid unnecessary API calls + if (isInternalChange.current) { + isInternalChange.current = false; + return; + } + if (!value || value.trim() === '') { setRows([]); return; @@ -209,10 +254,8 @@ function TableEditor({ }); }, [value]); - // Effect to check if user would be excluded by their own rules useEffect(() => { const checkUserSelfExclusion = async () => { - // Only check for non-system admins when there's an expression and validation function if (isSystemAdmin || !value.trim() || !validateExpressionAgainstRequester) { setUserWouldBeExcluded(false); return; @@ -221,8 +264,7 @@ function TableEditor({ try { const result = await validateExpressionAgainstRequester(value); setUserWouldBeExcluded(!result.data?.requester_matches); - } catch (error) { - // If validation fails, assume they would not be excluded (to allow testing) + } catch { setUserWouldBeExcluded(false); } }; @@ -230,28 +272,30 @@ function TableEditor({ checkUserSelfExclusion(); }, [value, isSystemAdmin, validateExpressionAgainstRequester]); - // Converts the internal rows state back into a CEL expression string - // and calls the onChange and onValidate props. + useEffect(() => { + onMaskedStateChange?.(hasMaskedRows); + }, [hasMaskedRows, onMaskedStateChange]); + const updateExpression = useCallback((newRows: TableRow[]) => { - const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute && row.values.length > 0); + // Include masked rows with no visible values: rowToCEL will emit an "in []" + // placeholder so the backend merge can restore the hidden values on save. + const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute && (row.values.length > 0 || row.hasMaskedValues)); const expr = rowsThatCanFormExpressions.map((row) => rowToCEL(row)).join(' && '); + isInternalChange.current = true; onChange(expr); if (onValidate) { onValidate(expr === '' || rowsThatCanFormExpressions.length > 0); } }, [onChange, onValidate]); - // Helper function to find the first available (non-disabled) attribute const findFirstAvailableAttribute = useCallback(() => { return findFirstAvailableAttributeFromList(userAttributes, enableUserManagedAttributes); }, [userAttributes, enableUserManagedAttributes]); - // Row Manipulation Handlers const addRow = useCallback(() => { if (userAttributes.length === 0) { - // Show a helpful message instead of silently failing onParseError('No user attributes available. Please ensure ABAC is properly configured and you have the necessary permissions.'); return; } @@ -263,11 +307,12 @@ function TableEditor({ } setRows((currentRows) => { - const newRow = { + const newRow: TableRow = { attribute: firstAvailableAttribute.name, operator: firstAvailableAttribute.type === 'multiselect' ? OperatorLabel.HAS_ANY_OF : OperatorLabel.IS, values: [], attribute_type: firstAvailableAttribute.type || '', + hasMaskedValues: false, }; const newRows = [...currentRows, newRow]; updateExpression(newRows); // Ensure expression is updated immediately @@ -284,6 +329,12 @@ function TableEditor({ }); }, [updateExpression]); + const requestRemoveRow = useCallback((index: number) => { + // Masked rows have their remove button disabled — the row is read-only + // because the server would 403 on a delete that strips hidden values. + removeRow(index); + }, [removeRow]); + const updateRowAttribute = useCallback((index: number, attribute: string) => { setRows((currentRows) => { const newRows = [...currentRows]; @@ -406,7 +457,7 @@ function TableEditor({ updateRowAttribute(index, attribute)} menuId={`attribute-selector-menu-${index}`} buttonId={`attribute-selector-button-${index}`} @@ -418,7 +469,7 @@ function TableEditor({ updateRowOperator(index, operator)} attributeType={userAttributes.find((attr) => attr.name === row.attribute)?.type} /> @@ -426,7 +477,7 @@ function TableEditor({ updateRowValues(index, values)} options={row.attribute ? userAttributes.find((attr) => attr.name === row.attribute)?.attrs?.options || [] : []} /> @@ -435,8 +486,8 @@ function TableEditor({
diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx index 4d8aec425e3..ec3426ea596 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx @@ -15,6 +15,7 @@ export interface TableRow { operator: string; values: string[]; attribute_type: string; + hasMaskedValues: boolean; } export interface ValueSelectorMenuProps { @@ -26,7 +27,6 @@ export interface ValueSelectorMenuProps { placeholder?: string; } -// Main ValueSelectorMenu component that delegates to the appropriate selector const ValueSelectorMenu = ({ row, disabled, @@ -46,11 +46,11 @@ const ValueSelectorMenu = ({ options={options} allowCreateValue={allowCreateValue} placeholder={placeholder} + hasMaskedValues={row.hasMaskedValues} /> ); } - // For single-value operators return ( ); }; diff --git a/webapp/channels/src/components/admin_console/access_control/policies.test.tsx b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx index c36f7a30298..c4ffb43c115 100644 --- a/webapp/channels/src/components/admin_console/access_control/policies.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx @@ -143,6 +143,88 @@ describe('components/admin_console/access_control/PolicyList', () => { expect(screen.getByText('Delete')).toBeInTheDocument(); }); + test('Delete menu item is disabled when a policy contains masked values', async () => { + // The "--------" sentinel in a rule expression means the caller can't + // see at least one referenced value. Server enforces a 403 on delete + // in that case, so the menu item must be disabled to avoid a useless + // confirmation modal → 403 round-trip. + mockSearchPolicies.mockResolvedValue({ + data: { + policies: [{ + id: 'masked-policy', + name: 'Masked Policy', + rules: [{actions: ['*'], expression: 'user.attributes.program in ["Alpha", "--------"]'}], + } as unknown as AccessControlPolicy], + total: 1, + }, + } as ActionResult); + renderWithContext(); + await waitFor(() => { + expect(screen.getByText('Masked Policy')).toBeInTheDocument(); + }); + + const menuButton = document.getElementById('policy-menu-masked-policy')!; + await userEvent.click(menuButton); + + const deleteItem = document.getElementById('policy-menu-delete-masked-policy')!; + expect(deleteItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test('Delete menu item is enabled for a clean policy with no channels', async () => { + // Sanity: a policy that has neither child channels nor masked values + // must keep Delete enabled. + mockSearchPolicies.mockResolvedValue({ + data: { + policies: [{ + id: 'clean-policy', + name: 'Clean Policy', + rules: [{actions: ['*'], expression: 'user.attributes.program in ["Alpha"]'}], + } as unknown as AccessControlPolicy], + total: 1, + }, + } as ActionResult); + renderWithContext(); + await waitFor(() => { + expect(screen.getByText('Clean Policy')).toBeInTheDocument(); + }); + + const menuButton = document.getElementById('policy-menu-clean-policy')!; + await userEvent.click(menuButton); + + const deleteItem = document.getElementById('policy-menu-delete-clean-policy')!; + expect(deleteItem).not.toHaveAttribute('aria-disabled', 'true'); + }); + + test('Delete confirmation modal no longer surfaces the masked-values warning', async () => { + // The inner-modal "This policy contains restricted values" notice was + // removed once we started disabling the Delete menu item upstream. + // Open the modal on a clean policy and assert the warning text is gone. + mockSearchPolicies.mockResolvedValue({ + data: { + policies: [{ + id: 'clean-policy', + name: 'Clean Policy', + rules: [{actions: ['*'], expression: 'user.attributes.program in ["Alpha"]'}], + } as unknown as AccessControlPolicy], + total: 1, + }, + } as ActionResult); + renderWithContext(); + await waitFor(() => { + expect(screen.getByText('Clean Policy')).toBeInTheDocument(); + }); + + const menuButton = document.getElementById('policy-menu-clean-policy')!; + await userEvent.click(menuButton); + await userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(screen.getByText('Confirm Policy Deletion')).toBeInTheDocument(); + }); + expect(screen.queryByText(/This policy includes attribute values that are hidden from you/)).not.toBeInTheDocument(); + expect(screen.queryByText('This policy contains restricted values')).not.toBeInTheDocument(); + }); + test('should get columns correctly', async () => { mockSearchPolicies.mockResolvedValue({data: {policies: [], total: 0}} as ActionResult); renderWithContext(); diff --git a/webapp/channels/src/components/admin_console/access_control/policies.tsx b/webapp/channels/src/components/admin_console/access_control/policies.tsx index 0452dd9909a..795566a585a 100644 --- a/webapp/channels/src/components/admin_console/access_control/policies.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policies.tsx @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useEffect, useMemo} from 'react'; +import React, {useState, useEffect, useMemo, useCallback} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import {Button} from '@mattermost/shared/components/button'; import type {AccessControlPolicy} from '@mattermost/types/access_control'; @@ -12,11 +13,23 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import type {Row, Column} from 'components/admin_console/data_grid/data_grid'; import DataGrid from 'components/admin_console/data_grid/data_grid'; import * as Menu from 'components/menu'; +import SectionNotice from 'components/section_notice'; import {getHistory} from 'utils/browser_history'; import './policies.scss'; +// The server emits the eight-dash masked-token sentinel inside raw CEL expressions +// when masking values the caller cannot see (e.g. `attr == "--------"`). The full +// visual AST carries a typed `has_masked_values` flag per condition, but on the +// policies list page we only have the raw expression strings — so we detect masking +// by the quoted token substring. +const MASKED_VALUE_TOKEN_LITERAL = '"--------"'; + +function policyHasMaskedValues(policy: AccessControlPolicy): boolean { + return policy.rules?.some((rule) => rule.expression?.includes(MASKED_VALUE_TOKEN_LITERAL)) ?? false; +} + type Props = { onPolicySelected?: (policy: AccessControlPolicy) => void; onPoliciesLoaded?: (count: number) => void; @@ -41,6 +54,8 @@ export default function PolicyList(props: Props): JSX.Element { const [searchErrored, setSearchErrored] = useState(false); const [cursorHistory, setCursorHistory] = useState([]); const [total, setTotal] = useState(0); + const [pendingDeletePolicy, setPendingDeletePolicy] = useState(null); + const [deleteError, setDeleteError] = useState(null); const intl = useIntl(); const history = useMemo(() => getHistory(), []); @@ -157,10 +172,38 @@ export default function PolicyList(props: Props): JSX.Element { ); }; - const handleDelete = async (policyId: string) => { - await props.actions.deletePolicy(policyId); + const initiateDelete = useCallback((policy: AccessControlPolicy) => { + setPendingDeletePolicy(policy); + setDeleteError(null); + }, []); + + const confirmDelete = useCallback(async () => { + if (!pendingDeletePolicy) { + return; + } + const result = await props.actions.deletePolicy(pendingDeletePolicy.id); + if (result?.error) { + // The server enforces masked-policy / permission rejections (403). Surface + // the message in the modal so the user sees why deletion failed instead of + // a silent close + stale list refresh. + const errorId = result.error.server_error_id; + if (errorId === 'app.pap.delete_policy.masked_values') { + setDeleteError(intl.formatMessage({ + id: 'admin.access_control.delete_policy.masked_values', + defaultMessage: 'You cannot delete this policy because it contains attribute values you do not have permission to view.', + })); + } else { + setDeleteError(result.error.message || intl.formatMessage({ + id: 'admin.access_control.delete_policy.generic_error', + defaultMessage: 'Failed to delete the policy.', + })); + } + return; + } + setPendingDeletePolicy(null); + setDeleteError(null); fetchPolicies(search); - }; + }, [pendingDeletePolicy, search, intl]); const getRows = (): Row[] => { return policies.map((policy: AccessControlPolicy) => { @@ -223,7 +266,7 @@ export default function PolicyList(props: Props): JSX.Element { {!props.hideDeleteAction && ( handleDelete(policy.id)} + onClick={() => initiateDelete(policy)} leadingElement={} labels={ } isDestructive={true} - disabled={Boolean(policy.props?.child_ids?.length)} + + // Also disable when the policy contains values masked + // for this caller. Mirrors the policy-details Delete + // guard — server returns 403, so otherwise the modal + // flow would just round-trip an error. + disabled={Boolean(policy.props?.child_ids?.length) || policyHasMaskedValues(policy)} /> )} @@ -404,6 +452,54 @@ export default function PolicyList(props: Props): JSX.Element { ) : undefined} /> + {pendingDeletePolicy && ( + { + setPendingDeletePolicy(null); + setDeleteError(null); + }} + handleConfirm={confirmDelete} + handleCancel={() => { + setPendingDeletePolicy(null); + setDeleteError(null); + }} + modalHeaderText={ + + } + confirmButtonText={ + + } + confirmButtonVariant='destructive' + compassDesign={true} + > + <> + + {deleteError && ( +
+ + } + text={deleteError} + /> +
+ )} + +
+ )} ); } diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap index 7c9f4c8860e..b9e4021c4f8 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap @@ -87,99 +87,8 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh style="height: 0px;" >
- - - - - - - - - - - - - - - - - - -
- Attribute - - Operator - - - Values - - -
- - Select a user attribute and values to create a rule - -
- -
-
-
-

- Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator ( - - - && - - - ). -

-
- -
-
+ data-testid="table-editor" + />
- - - - - - - - - - - - - - - - - - -
- Attribute - - Operator - - - Values - - -
- - Select a user attribute and values to create a rule - -
- -
-
-
-

- Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator ( - - - && - - - ). -

-
- -
-
+ data-testid="table-editor" + />
({ }), })); +// Mock TableEditor so tests can control onMaskedStateChange callbacks. +// jest.mock factory may not reference out-of-scope variables, so React is required inline. +jest.mock('../editors/table_editor/table_editor', () => { + const reactLib = require('react'); + return jest.fn(({onMaskedStateChange}: any) => { + reactLib.useEffect(() => { + onMaskedStateChange?.(false); + }, []); + return reactLib.createElement('div', {'data-testid': 'table-editor'}); + }); +}); + +// Mock CELEditor — its real implementation boots Monaco on mount, which is +// not available in JSDOM. The mode-toggle tests only care that switching to +// Advanced/Simple flips state in the parent, not how Monaco renders. +jest.mock('../editors/cel_editor/editor', () => { + const reactLib = require('react'); + return jest.fn(() => reactLib.createElement('div', {'data-testid': 'cel-editor'})); +}); + // Mock the useChannelAccessControlActions hook jest.mock('hooks/useChannelAccessControlActions', () => ({ useChannelAccessControlActions: jest.fn(), @@ -151,11 +171,9 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails', ...defaultProps.actions, fetchPolicy: jest.fn().mockResolvedValue({ data: { - policy: { - id: 'policy1', - name: 'Policy 1', - rules: [{expression: 'true'}], - }, + id: 'policy1', + name: 'Policy 1', + rules: [{expression: 'true'}], }, }), }, @@ -164,6 +182,140 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails', expect(container).toMatchSnapshot(); }); + test('should show masked values warning banner when policy has masked rows', async () => { + // hasMaskedRows is derived in policy_details from the presence of the + // "--------" sentinel in the loaded expression — drive the test via a + // fetched policy carrying a masked rule. + const props = { + ...defaultProps, + actions: { + ...defaultProps.actions, + fetchPolicy: jest.fn().mockResolvedValue({ + data: { + id: 'policy1', + name: 'Policy 1', + rules: [{ + actions: ['*'], + expression: 'user.attributes.program in ["Alpha", "--------"]', + }], + }, + }), + }, + }; + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('This policy contains restricted values')).toBeInTheDocument(); + }); + expect(screen.getByText(/Some rules include attribute values you cannot see/)).toBeInTheDocument(); + }); + + test('should not show masked values warning banner when no masked rows', async () => { + renderWithContext(); + + await waitFor(() => { + expect(screen.queryByText('This policy contains restricted values')).not.toBeInTheDocument(); + }); + }); + + test('hasMaskedRows derivation survives Simple → Advanced → Simple mode toggles', async () => { + // Regression guard: hasMaskedRows must come from the expression itself, + // not from a TableEditor lifecycle callback. Toggling editor modes + // remounts TableEditor; if the parent reset hasMaskedRows on remount, + // the warning banner would flicker off and the CEL/delete gates would + // briefly open. Deriving from the "--------" sentinel in the expression + // is the only source of truth that's lifecycle-independent. + + // The mode-toggle button is disabled while no usable attributes are + // available, so the test needs at least one to actually exercise the + // Simple → Advanced → Simple round-trip. + mockGetAccessControlFields.mockResolvedValue({data: [{name: 'program', attrs: {ldap: true}}]}); + + const props = { + ...defaultProps, + actions: { + ...defaultProps.actions, + fetchPolicy: jest.fn().mockResolvedValue({ + data: { + id: 'policy1', + name: 'Policy 1', + rules: [{ + actions: ['*'], + expression: 'user.attributes.program in ["Alpha", "--------"]', + }], + }, + }), + }, + }; + renderWithContext(); + + // Banner present after initial load (Simple mode). + await waitFor(() => { + expect(screen.getByText('This policy contains restricted values')).toBeInTheDocument(); + }); + + // Switch to Advanced mode — banner must remain (it lives outside the + // editor swap, gated by hasMaskedRows which is expression-derived). + const toAdvanced = screen.getByText('Switch to Advanced Mode'); + await userEvent.click(toAdvanced); + expect(screen.getByText('This policy contains restricted values')).toBeInTheDocument(); + + // Switch back to Simple mode — banner must STILL be there. Before the + // fix, the TableEditor remount transiently flipped hasMaskedRows to + // false and the banner disappeared. + const toSimple = screen.getByText('Switch to Simple Mode'); + await userEvent.click(toSimple); + expect(screen.getByText('This policy contains restricted values')).toBeInTheDocument(); + }); + + test('hasMaskedRows stays false for a policy without the masked-token sentinel', async () => { + // Negative case: a normal policy expression must not trip the + // masked-rows banner. + const props = { + ...defaultProps, + actions: { + ...defaultProps.actions, + fetchPolicy: jest.fn().mockResolvedValue({ + data: { + id: 'policy1', + name: 'Policy 1', + rules: [{ + actions: ['*'], + expression: 'user.attributes.program in ["Alpha", "Bravo"]', + }], + }, + }), + }, + }; + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Delete policy')).toBeInTheDocument(); + }); + expect(screen.queryByText('This policy contains restricted values')).not.toBeInTheDocument(); + }); + + // Note: when hasMaskedRows is true the Delete button is disabled (policy_details.tsx), + // so the masked-warning inside the confirmation modal is defense-in-depth and not + // reachable through normal UI flow. Test only the no-masked-rows path here. + + test('should not show masked values warning in delete confirmation modal when no masked rows', async () => { + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Delete policy')).toBeInTheDocument(); + }); + + const deleteButtons = screen.getAllByText('Delete'); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => { + expect(screen.getByText('Confirm Policy Deletion')).toBeInTheDocument(); + }); + + expect(screen.queryByText(/This policy includes attribute values that are hidden from you/)).not.toBeInTheDocument(); + }); + test('should handle delete policy', async () => { const props = { ...defaultProps, diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx index d278b278563..8dc765642a2 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx @@ -81,6 +81,16 @@ function PolicyDetails({ const [serverError, setServerError] = useState(undefined); const [addChannelOpen, setAddChannelOpen] = useState(false); const [editorMode, setEditorMode] = useState<'cel' | 'table'>('table'); + + // Derive masked-rows state directly from the expression rather than relying + // on a callback from TableEditor: TableEditor unmounts on mode switches, and + // on remount its rows-derived flag flickers false-then-true while the async + // AST round-trip is in flight, briefly opening gates (CEL read-only, delete, + // banner) that should stay closed. The "--------" sentinel is what the + // server emits in raw CEL for any value the caller can't see, so its + // presence in the expression is a stable signal independent of editor + // lifecycle. + const hasMaskedRows = useMemo(() => expression.includes('"--------"'), [expression]); const [channelChanges, setChannelChanges] = useState({ removed: {}, added: {}, @@ -197,6 +207,14 @@ function PolicyDetails({ if (result.error) { if (result.error.server_error_id === 'app.pap.save_policy.name_exists.app_error') { setServerError(formatMessage({id: 'admin.access_control.edit_policy.name_exists', defaultMessage: 'A policy with this name already exists. Please choose a different name.'})); + } else if (result.error.server_error_id === 'app.pap.save_policy.invalid_value') { + setServerError(formatMessage({id: 'admin.access_control.edit_policy.invalid_value', defaultMessage: 'Invalid value.'})); + } else if (result.error.server_error_id === 'app.pap.save_policy.self_exclusion') { + setServerError(formatMessage({id: 'admin.access_control.edit_policy.self_exclusion', defaultMessage: 'You do not satisfy one or more conditions in this policy. Contact a System Admin for assistance.'})); + } else if (result.error.server_error_id === 'app.pap.save_policy.masked_condition_deleted') { + setServerError(formatMessage({id: 'admin.access_control.edit_policy.masked_condition_deleted', defaultMessage: 'You cannot remove a condition that contains attribute values you do not have permission to view.'})); + } else if (result.error.server_error_id === 'app.pap.save_policy.masked_rule_deleted') { + setServerError(formatMessage({id: 'admin.access_control.edit_policy.masked_rule_deleted', defaultMessage: 'You cannot remove a rule that contains attribute values you do not have permission to view.'})); } else { setServerError(result.error.message); } @@ -506,6 +524,23 @@ function PolicyDetails({ /> + {hasMaskedRows && ( +
+ + } + text={formatMessage({ + id: 'admin.access_control.policy.edit_policy.masked_values_warning.text', + defaultMessage: 'Some rules include attribute values you cannot see. Editing or deleting these rules may change who has access in ways you cannot fully anticipate.', + })} + /> +
+ )} {editorMode === 'cel' ? ( {}} disabled={noUsableAttributes} + hasMaskedRows={hasMaskedRows} userAttributes={autocompleteResult. filter((attr) => { if (accessControlSettings.EnableUserManagedAttributes) { @@ -613,6 +649,23 @@ function PolicyDetails({ expanded={true} className={'console delete-policy'} > + {hasMaskedRows && ( +
+ + } + text={formatMessage({ + id: 'admin.access_control.policy.edit_policy.delete_policy.masked_values_warning.text', + defaultMessage: 'Removing this policy could affect access for users you cannot fully account for.', + })} + /> +
+ )} @@ -698,10 +751,12 @@ function PolicyDetails({ confirmButtonVariant='destructive' compassDesign={true} > - + <> + + )} diff --git a/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.scss b/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.scss index e521b0a2c12..9338a9808c1 100644 --- a/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.scss +++ b/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.scss @@ -2,6 +2,10 @@ padding-bottom: 80px; margin-top: -8px; + &__masked-values-warning { + margin-bottom: 16px; + } + &__header { margin-bottom: 0; } diff --git a/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.tsx b/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.tsx index ecb7b71cea1..e23aec85cea 100644 --- a/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.tsx +++ b/webapp/channels/src/components/team_settings/team_access_policies_tab/team_policy_editor.tsx @@ -107,6 +107,7 @@ export default function TeamPolicyEditor({ const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [backClicked, setBackClicked] = useState(false); + const [hasMaskedRows, setHasMaskedRows] = useState(false); const noUsableAttributes = attributesLoaded && !hasUsableAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes); @@ -262,7 +263,7 @@ export default function TeamPolicyEditor({ setSaveChangesPanelState(SAVE_RESULT_ERROR); return false; } - if (expression.includes('== ""') || expression.includes("== ''") || expression.includes('in []')) { + if (expression.includes('== ""') || expression.includes("== ''")) { setFormError(formatMessage({id: 'team_settings.policy_editor.error.incomplete_rule', defaultMessage: 'Please complete all attribute rules with a value'})); setSaveChangesPanelState(SAVE_RESULT_ERROR); return false; @@ -510,6 +511,23 @@ export default function TeamPolicyEditor({

+ {hasMaskedRows && ( +
+ + } + text={formatMessage({ + id: 'admin.access_control.policy.edit_policy.masked_values_warning.text', + defaultMessage: 'Some rules include attribute values you cannot see. Editing or deleting these rules may change who has access in ways you cannot fully anticipate.', + })} + /> +
+ )} @@ -617,7 +636,7 @@ export default function TeamPolicyEditor({