diff --git a/.cursor/README.md b/.cursor/README.md
index 93596cb5686..192cc60c907 100644
--- a/.cursor/README.md
+++ b/.cursor/README.md
@@ -17,7 +17,7 @@ The Docker build context is `.cursor/` only. The Dockerfile intentionally does n
## Runtime Hooks
- `cloud-agent-install.sh` runs after Cursor checks out the repo. It refreshes nvm, installs agent-browser browsers, verifies Cursor's multi-repo `mattermost/enterprise` checkout, runs `server` Go dependency hydration, installs webapp dependencies, and runs Playwright `npm ci`.
-- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, then starts Docker and waits until `docker info` and `docker compose version` succeed.
+- `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, starts Docker, waits until `docker info` and `docker compose version` succeed, then logs in to Docker Hub when credentials are configured.
The environment declares `github.com/mattermost/enterprise` in `repositoryDependencies` so Cursor can provide it as part of the multi-repo workspace. Cursor currently clones the repositories as siblings, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, which matches `server/Makefile`'s default `../../enterprise` path. The install hook does not clone, pull, or symlink enterprise.
@@ -33,4 +33,7 @@ Set these environment variables to `true` to shorten startup for narrow tasks:
## Expected Secrets
-- AWS uploads use the standard AWS CLI environment variables provided to the Cloud Agent: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
+Configure these in the [Cursor Cloud Agents dashboard](https://cursor.com/dashboard/cloud-agents) as environment-scoped secrets for the Mattermost Cloud Agent environment.
+
+- AWS uploads use the standard AWS CLI environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_S3_BUCKET_NAME`. The image only supplies the `aws` binary.
+- Docker Hub pulls use the same variable names as CI: `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. The start hook runs `docker login` after `dockerd` is ready. Mark `DOCKERHUB_TOKEN` as **redacted** in the dashboard. When both are set, agents can pull the full default `make start-docker` image set without hitting anonymous rate limits.
diff --git a/.cursor/cursor.md b/.cursor/cursor.md
index 895f1f0db19..e9fa3332185 100644
--- a/.cursor/cursor.md
+++ b/.cursor/cursor.md
@@ -58,7 +58,8 @@ The Mattermost server is expected at `http://localhost:8065`. The webapp dev ser
```
- When the server starts and `MM_LICENSE` is present in the environment, the server applies that license automatically. If `MM_LICENSE` is not set, starting the server automatically applies an Entry license, which provides nearly all functionality needed for development.
-- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. This is useful in Cloud when Docker Hub rate limits block the default `make start-docker` dependency set.
+- When `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` are configured as Cloud Agent secrets, `cloud-agent-start.sh` logs in to Docker Hub and the full default `make start-docker` dependency set can be used without trimming services.
+- `ENABLED_DOCKER_SERVICES='postgres redis'` avoids optional local-dev services such as Prometheus, Grafana, Loki, Minio, Azurite, and OpenLDAP. Use this fallback when Docker Hub credentials are unavailable and anonymous pulls hit rate limits.
- If the first-user signup UI is flaky but the server is already healthy, seed local state with `mmctl` and then log in through the browser:
```bash
diff --git a/.cursor/scripts/cloud-agent-start.sh b/.cursor/scripts/cloud-agent-start.sh
index 268888e5d85..f1478521bf0 100755
--- a/.cursor/scripts/cloud-agent-start.sh
+++ b/.cursor/scripts/cloud-agent-start.sh
@@ -34,6 +34,21 @@ ensure_docker_socket_access() {
fi
}
+docker_login_if_configured() {
+ if [ -z "${DOCKERHUB_USERNAME:-}" ] || [ -z "${DOCKERHUB_TOKEN:-}" ]; then
+ log "Docker Hub credentials not configured; anonymous pulls may hit rate limits."
+ return 0
+ fi
+
+ log "Logging in to Docker Hub as ${DOCKERHUB_USERNAME}."
+ if echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USERNAME}" --password-stdin >/tmp/docker-login.log 2>&1; then
+ log "Docker Hub login succeeded."
+ else
+ log "Docker Hub login failed; see /tmp/docker-login.log."
+ tail -n 20 /tmp/docker-login.log >&2 || true
+ fi
+}
+
if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null 2>&1 || \
log "Could not relax AppArmor user namespace restriction; openldap-based tests may need a larger host profile."
@@ -44,6 +59,7 @@ ensure_docker_socket_access
if docker info >/dev/null 2>&1; then
log "Docker is already running."
docker compose version
+ docker_login_if_configured
exit 0
fi
@@ -64,6 +80,7 @@ for _ in {1..60}; do
log "Docker is ready."
docker version
docker compose version
+ docker_login_if_configured
exit 0
fi
diff --git a/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/app/remote_cluster.go b/server/channels/app/remote_cluster.go
index 4f7b144b2fe..b87ba525eaf 100644
--- a/server/channels/app/remote_cluster.go
+++ b/server/channels/app/remote_cluster.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
+ "time"
"github.com/pkg/errors"
@@ -19,6 +20,34 @@ import (
"github.com/mattermost/mattermost/server/public/shared/request"
)
+// pluginRemoteInitialPingDelay is how long the framework waits after
+// RegisterPluginForSharedChannels returns before firing the first ping to
+// the newly created or restored plugin remote. The delay gives the
+// calling plugin a chance to record the returned RemoteId in its own
+// state, so the synchronous OnSharedChannelsPing hook the framework
+// invokes can resolve the remote. Without the delay, the first ping for
+// every freshly registered SiteURL fails and the remote stays offline
+// until the periodic pingLoop refreshes it (up to PingFreq, default 1
+// minute). Declared as a var, not const, so tests can shorten it.
+var pluginRemoteInitialPingDelay = 5 * time.Second
+
+// schedulePluginRemoteInitialPing schedules a single deferred ping for a
+// freshly registered or restored plugin remote. The goroutine is launched
+// via Server.Go so it cannot outlive the server. The remote is re-read
+// before the ping fires because the plugin may have unregistered it
+// inside the delay window; pinging a soft-deleted row is harmless but
+// produces a stray "ping failed" warning.
+func (a *App) schedulePluginRemoteInitialPing(rcService remotecluster.RemoteClusterServiceIFace, rc *model.RemoteCluster) {
+ a.Srv().Go(func() {
+ time.Sleep(pluginRemoteInitialPingDelay)
+ current, err := a.Srv().Store().RemoteCluster().Get(rc.RemoteId, true)
+ if err != nil || current.DeleteAt != 0 {
+ return
+ }
+ rcService.PingNow(current)
+ })
+}
+
func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.RegisterPluginOpts) (remoteID string, err error) {
// When SiteURL is empty, fall back to the legacy single-remote behavior
// using the "plugin_" prefix. This preserves compatibility for older plugins
@@ -59,6 +88,18 @@ func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.Regis
if _, err = a.Srv().Store().RemoteCluster().Update(rc); err != nil {
return "", err
}
+
+ // Ping the restored plugin remote so its LastPingAt is refreshed
+ // before sync attempts. Deferred via a goroutine (see
+ // schedulePluginRemoteInitialPing) so the caller has a chance
+ // to record the returned RemoteId before the synchronous
+ // OnSharedChannelsPing hook fires. Without this the restored
+ // remote stays offline until the next pingLoop iteration (up to
+ // PingFreq), causing transient sync failures.
+ rcService, _ := a.GetRemoteClusterService()
+ if rcService != nil {
+ a.schedulePluginRemoteInitialPing(rcService, rc)
+ }
return rc.RemoteId, nil
}
@@ -86,13 +127,19 @@ func (a *App) RegisterPluginForSharedChannels(rctx request.CTX, opts model.Regis
mlog.String("site_url", opts.SiteURL),
)
- // Ping the plugin remote immediately if the service is running.
- // If the service is not available the ping will happen once the
- // service starts. This is expected since plugins start before the
- // service.
+ // Ping the new plugin remote, deferred via a goroutine so the
+ // calling plugin has a chance to record the returned RemoteId
+ // before the synchronous OnSharedChannelsPing hook fires for the
+ // first time. A synchronous ping here would invoke the hook
+ // before the caller's return statement, the plugin would fail to
+ // resolve the new RemoteId, the ping would be recorded as failed,
+ // and the remote would stay offline until the next pingLoop
+ // iteration (up to PingFreq, default 1 minute). If the service is
+ // not yet running the ping will fire from the periodic pingLoop
+ // once the service starts.
rcService, _ := a.GetRemoteClusterService()
if rcService != nil {
- rcService.PingNow(rcSaved)
+ a.schedulePluginRemoteInitialPing(rcService, rcSaved)
}
return rcSaved.RemoteId, nil
diff --git a/server/channels/app/remote_cluster_test.go b/server/channels/app/remote_cluster_test.go
index f4b5ec604dd..4a5637d4a1d 100644
--- a/server/channels/app/remote_cluster_test.go
+++ b/server/channels/app/remote_cluster_test.go
@@ -6,13 +6,39 @@ package app
import (
"strings"
"testing"
+ "time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
+ "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster"
)
+// Shorten the deferred initial ping for tests so RegisterPluginForSharedChannels
+// teardown does not block on a 5s goroutine. No test in this package needs the
+// production headroom. The value is large enough that even a slow runner where
+// RegisterPluginForSharedChannels takes a couple hundred milliseconds still
+// has comfortable margin before the deferred goroutine fires.
+func init() {
+ pluginRemoteInitialPingDelay = 500 * time.Millisecond
+}
+
+// pingTrackingRCService wraps a real RemoteClusterServiceIFace and records the
+// time of every PingNow call without forwarding it. Embedding the interface
+// satisfies the other methods by delegation.
+type pingTrackingRCService struct {
+ remotecluster.RemoteClusterServiceIFace
+ pings chan time.Time
+}
+
+func (p *pingTrackingRCService) PingNow(rc *model.RemoteCluster) {
+ select {
+ case p.pings <- time.Now():
+ default:
+ }
+}
+
func setupRemoteCluster(tb testing.TB) *TestHelper {
return SetupConfig(tb, func(cfg *model.Config) {
*cfg.ConnectedWorkspacesSettings.EnableRemoteClusterService = true
@@ -221,6 +247,46 @@ func TestRegisterPluginForSharedChannels(t *testing.T) {
require.Equal(t, id1, id2)
})
+ t.Run("re-registering a soft-deleted SiteURL restores the row and pings the remote (MM-68838)", func(t *testing.T) {
+ pluginID := "com.test.restore-" + model.NewId()
+ siteURL := "nats://restore-" + model.NewId()
+
+ // 1. Initial registration.
+ id1, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore test plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+
+ // 2. Unregister soft-deletes the row.
+ require.NoError(t, th.App.UnregisterPluginRemoteForSharedChannels(pluginID, id1))
+
+ rcDeleted, err := th.App.Srv().Store().RemoteCluster().Get(id1, true)
+ require.NoError(t, err)
+ require.NotZero(t, rcDeleted.DeleteAt, "row should be soft-deleted after unregister")
+
+ // 3. Re-register the same SiteURL. The restore path must run.
+ id2, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore test plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ require.Equal(t, id1, id2, "restore path must reuse the existing remoteID")
+
+ // 4. The row must be restored (DeleteAt cleared). PingNow is called
+ // inside the restore branch; the actual ping fails in unit tests
+ // because no plugin process is loaded to answer OnSharedChannelsPing,
+ // so we cannot assert on LastPingAt here. The presence of the call
+ // is what fixes MM-68838 (offline-for-PingFreq window on restart).
+ rcRestored, err := th.App.Srv().Store().RemoteCluster().Get(id2, false)
+ require.NoError(t, err)
+ require.Zero(t, rcRestored.DeleteAt, "row should be restored after re-register")
+ })
+
t.Run("multi-remote registration returns distinct remoteIDs", func(t *testing.T) {
pluginID := "com.test.multi-" + model.NewId()
@@ -322,3 +388,117 @@ func TestUnregisterPluginForSharedChannelsBulk(t *testing.T) {
require.NoError(t, err)
require.Empty(t, remotes)
}
+
+// TestRegisterPluginForSharedChannelsPingIsDeferred guards the race fix.
+// A synchronous PingNow inside RegisterPluginForSharedChannels invoked the
+// plugin's OnSharedChannelsPing hook before the calling plugin could record
+// the returned RemoteId, so the very first ping always failed and the remote
+// stayed offline for ~PingFreq (1 minute). The fix is to defer the initial
+// ping to a goroutine. Both the new-row branch and the soft-delete-restore
+// branch must defer.
+func TestRegisterPluginForSharedChannelsPingIsDeferred(t *testing.T) {
+ mainHelper.Parallel(t)
+ th := setupRemoteCluster(t).InitBasic(t)
+
+ tracker := &pingTrackingRCService{
+ RemoteClusterServiceIFace: th.Server.remoteClusterService,
+ pings: make(chan time.Time, 8),
+ }
+ original := th.Server.remoteClusterService
+ th.Server.remoteClusterService = tracker
+ t.Cleanup(func() { th.Server.remoteClusterService = original })
+
+ // Generous upper bound on real wall-time variance under load: the
+ // production delay is 5s; init() overrides to 100ms; we wait up to
+ // delay + 2s for the ping to actually arrive.
+ const arrivalGrace = 2 * time.Second
+ delay := pluginRemoteInitialPingDelay
+
+ // drain consumes any pending ping timestamps so a later sub-case does
+ // not see a stale one from an earlier sub-case.
+ drain := func(ch <-chan time.Time) {
+ for {
+ select {
+ case <-ch:
+ default:
+ return
+ }
+ }
+ }
+
+ assertDeferred := func(t *testing.T, registerStart time.Time) {
+ t.Helper()
+ // Phase 1: no ping in the first half of the delay (proves async).
+ var prematurePing bool
+ select {
+ case <-tracker.pings:
+ prematurePing = true
+ case <-time.After(delay / 2):
+ }
+ require.False(t, prematurePing, "PingNow fired synchronously inside RegisterPluginForSharedChannels; the deferral is broken")
+ // Phase 2: a ping arrives within delay + grace, and not before delay.
+ var pingAt time.Time
+ var pingArrived bool
+ select {
+ case pingAt = <-tracker.pings:
+ pingArrived = true
+ case <-time.After(delay + arrivalGrace):
+ }
+ require.True(t, pingArrived, "expected PingNow to fire within delay + grace, but it never did")
+ elapsed := pingAt.Sub(registerStart)
+ require.GreaterOrEqual(t, elapsed, delay,
+ "PingNow fired %s after Register returned, before the configured delay of %s", elapsed, delay)
+ }
+
+ t.Run("new-row branch defers the initial ping", func(t *testing.T) {
+ drain(tracker.pings)
+
+ start := time.Now()
+ _, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "deferred ping plugin",
+ PluginID: "com.test.deferred-" + model.NewId(),
+ CreatorID: th.BasicUser.Id,
+ SiteURL: "nats://deferred-" + model.NewId(),
+ })
+ require.NoError(t, err)
+ assertDeferred(t, start)
+ })
+
+ t.Run("soft-delete restore branch defers the ping (MM-68838)", func(t *testing.T) {
+ drain(tracker.pings)
+
+ pluginID := "com.test.restore-defer-" + model.NewId()
+ siteURL := "nats://restore-defer-" + model.NewId()
+
+ // Initial register to create the row; consume its deferred ping.
+ id1, err := th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore defer plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ var initialPingArrived bool
+ select {
+ case <-tracker.pings:
+ initialPingArrived = true
+ case <-time.After(delay + arrivalGrace):
+ }
+ require.True(t, initialPingArrived, "initial register's deferred ping never arrived")
+
+ // Unregister soft-deletes the row.
+ require.NoError(t, th.App.UnregisterPluginRemoteForSharedChannels(pluginID, id1))
+ drain(tracker.pings)
+
+ // Re-register: the restore branch must also defer.
+ start := time.Now()
+ _, err = th.App.RegisterPluginForSharedChannels(th.Context, model.RegisterPluginOpts{
+ Displayname: "restore defer plugin",
+ PluginID: pluginID,
+ CreatorID: th.BasicUser.Id,
+ SiteURL: siteURL,
+ })
+ require.NoError(t, err)
+ assertDeferred(t, start)
+ })
+}
diff --git a/server/channels/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/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())
+ })
+}
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({