snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ownerField builds a CPA text field owned by the given plugin id for the given scopes.
func ownerField(groupID, name, pluginID string, scopes []string) *model.PropertyField {
return &model.PropertyField{
GroupID: groupID,
Name: name,
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsOwners: []model.PropertyOwner{
{ID: pluginID, Type: model.PropertyOwnerTypePlugin, Scopes: scopes},
},
},
}
}
// createOwnedField creates an owner-managed field via a human (admin) caller.
// Machine callers may not declare owners.
func createOwnedField(t *testing.T, th *TestHelper, name, pluginID string, scopes []string) *model.PropertyField {
t.Helper()
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
created, err := th.service.CreatePropertyField(rctxHuman, ownerField(th.CPAGroupID, name, pluginID, scopes))
require.NoError(t, err)
require.True(t, model.HasPropertyFieldOwners(created))
return created
}
func TestOwnerValueWriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner" || pluginID == "plugin-other"
})
created := createOwnedField(t, th, "Owned", "plugin-owner", []string{"entra"})
newValue := func() *model.PropertyValue {
return &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
}
t.Run("allows owner plugin writing with a matching scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "entra"})
v, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.NoError(t, upErr)
assert.NotNil(t, v)
})
t.Run("denies owner plugin writing with a case-mismatched scope", func(t *testing.T) {
// Scopes are matched verbatim: an owner scoped to "entra" is not
// writable by a caller acting as "Entra".
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "Entra"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("denies owner plugin writing with a non-matching scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "okta"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("denies owner plugin writing with no scope", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("denies a non-owner plugin even with the right scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-other", model.PropertyRequestOptions{ActingAsScope: "entra"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("denies a human caller (owner-managed values are authoritative to the owning integration)", func(t *testing.T) {
// A session user is not a machine caller. Unlike other fields, an
// owner-managed field rejects all human writes at the service hook —
// mirroring the ldap/saml sync lock — regardless of PermissionValues.
rctx := RequestContextWithCallerID(th.Context, model.NewId())
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
}
func TestOwnerFieldWriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner" || pluginID == "plugin-other"
})
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
t.Run("scopeless owner plugin may edit the field definition", func(t *testing.T) {
created := createOwnedField(t, th, "ScopelessOwned", "plugin-owner", nil)
existing, getErr := th.service.GetPropertyField(rctxHuman, th.CPAGroupID, created.ID)
require.NoError(t, getErr)
existing.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityAlways
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
updated, _, upErr := th.service.UpdatePropertyField(rctx, th.CPAGroupID, existing)
require.NoError(t, upErr)
assert.Equal(t, model.PropertyFieldVisibilityAlways, updated.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
})
t.Run("scoped owner plugin may edit the field definition", func(t *testing.T) {
created := createOwnedField(t, th, "ScopedOwned", "plugin-owner", []string{"entra"})
existing, getErr := th.service.GetPropertyField(rctxHuman, th.CPAGroupID, created.ID)
require.NoError(t, getErr)
existing.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityHidden
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
updated, _, upErr := th.service.UpdatePropertyField(rctx, th.CPAGroupID, existing)
require.NoError(t, upErr)
assert.Equal(t, model.PropertyFieldVisibilityHidden, updated.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
})
t.Run("a non-owner plugin may not edit an owner-managed field", func(t *testing.T) {
created := createOwnedField(t, th, "OwnedByOther", "plugin-owner", nil)
existing, getErr := th.service.GetPropertyField(rctxHuman, th.CPAGroupID, created.ID)
require.NoError(t, getErr)
existing.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityHidden
rctxOther := RequestContextWithCallerID(th.Context, "plugin-other")
_, _, upErr := th.service.UpdatePropertyField(rctxOther, th.CPAGroupID, existing)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
t.Run("human may edit an owner-managed field definition", func(t *testing.T) {
created := createOwnedField(t, th, "HumanEditable", "plugin-owner", []string{"entra"})
created.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityAlways
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, created)
require.NoError(t, upErr)
assert.Equal(t, model.PropertyFieldVisibilityAlways, updated.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
})
t.Run("listed owner plugin may delete an owner-managed field", func(t *testing.T) {
created := createOwnedField(t, th, "OwnerDelete", "plugin-owner", nil)
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
require.NoError(t, th.service.DeletePropertyField(rctx, th.CPAGroupID, created.ID))
})
t.Run("non-owner plugin may not delete an owner-managed field", func(t *testing.T) {
created := createOwnedField(t, th, "NoDelete", "plugin-owner", nil)
rctxOther := RequestContextWithCallerID(th.Context, "plugin-other")
delErr := th.service.DeletePropertyField(rctxOther, th.CPAGroupID, created.ID)
require.Error(t, delErr)
assert.ErrorIs(t, delErr, ErrAccessDenied)
})
t.Run("human may delete an owner-managed field", func(t *testing.T) {
created := createOwnedField(t, th, "HumanDelete", "plugin-owner", []string{"entra"})
require.NoError(t, th.service.DeletePropertyField(rctxHuman, th.CPAGroupID, created.ID))
})
}
func TestOwnerListManagedByAdminOnly(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner" || pluginID == "plugin-other"
})
rctxOwner := RequestContextWithCallerID(th.Context, "plugin-owner")
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
t.Run("rejects plugin create that declares owners", func(t *testing.T) {
_, err := th.service.CreatePropertyField(rctxOwner, ownerField(th.CPAGroupID, "PluginOwned", "plugin-owner", []string{"entra"}))
require.Error(t, err)
assert.ErrorIs(t, err, ErrAccessDenied)
})
t.Run("allows human create with owners", func(t *testing.T) {
created, err := th.service.CreatePropertyField(rctxHuman, ownerField(th.CPAGroupID, "HumanOwned", "plugin-owner", []string{"entra"}))
require.NoError(t, err)
require.True(t, model.HasPropertyFieldOwners(created))
})
t.Run("rejects plugin update that adds owners", func(t *testing.T) {
plain, createErr := th.service.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "NoOwnersYet",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, createErr)
plain.Attrs = model.StringInterface{
model.PropertyAttrsOwners: []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
},
}
_, _, upErr := th.service.UpdatePropertyField(rctxOwner, th.CPAGroupID, plain)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
t.Run("allows human update that adds owners", func(t *testing.T) {
plain, createErr := th.service.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "AdminAddsOwners",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, createErr)
plain.Attrs = model.StringInterface{
model.PropertyAttrsOwners: []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, plain)
require.NoError(t, upErr)
require.True(t, model.HasPropertyFieldOwners(updated))
})
t.Run("allows listed owner plugin update that changes owners", func(t *testing.T) {
created := createOwnedField(t, th, "MutableOwners", "plugin-owner", nil)
created.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxOwner, th.CPAGroupID, created)
require.NoError(t, upErr)
owners := model.GetPropertyFieldOwners(updated)
require.Len(t, owners, 1)
assert.Equal(t, []string{"entra"}, owners[0].Scopes)
})
t.Run("rejects non-owner plugin update that changes owners", func(t *testing.T) {
created := createOwnedField(t, th, "ImmutableToNonOwner", "plugin-owner", nil)
created.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
{ID: "plugin-other", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"okta"}},
}
rctxOther := RequestContextWithCallerID(th.Context, "plugin-other")
_, _, upErr := th.service.UpdatePropertyField(rctxOther, th.CPAGroupID, created)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
t.Run("allows human update that changes owners", func(t *testing.T) {
created := createOwnedField(t, th, "HumanMutableOwners", "plugin-owner", []string{"entra"})
created.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra", "okta"}},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, created)
require.NoError(t, upErr)
owners := model.GetPropertyFieldOwners(updated)
require.Len(t, owners, 1)
assert.ElementsMatch(t, []string{"entra", "okta"}, owners[0].Scopes)
})
t.Run("allows human update that removes owners", func(t *testing.T) {
created := createOwnedField(t, th, "RemovableOwners", "plugin-owner", []string{"entra"})
delete(created.Attrs, model.PropertyAttrsOwners)
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, created)
require.NoError(t, upErr)
assert.False(t, model.HasPropertyFieldOwners(updated))
})
}
func TestOwnerSupersedesLegacyAndSyncLockUnaffected(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner"
})
t.Run("legacy sync-locked field with no owners is still gated by the sync lock", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "LdapSynced",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsLDAP: "employeeID",
},
}
created, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
require.False(t, model.HasPropertyFieldOwners(created))
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
// LDAP sync caller allowed through the legacy sync-lock path.
_, upErr := th.service.UpsertPropertyValue(rctx, value)
require.NoError(t, upErr)
// A plugin caller is rejected by the sync lock.
rctxPlugin := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "entra"})
_, upErr = th.service.UpsertPropertyValue(rctxPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
})
require.Error(t, upErr)
})
}
func TestOwnerValueWriteWithImplicitSyncOwners(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner" || pluginID == "plugin-other"
})
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
created, err := th.service.CreatePropertyField(rctxHuman, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "SamlAndScim",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsOwners: []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra", "okta"}},
},
model.CustomProfileAttributesPropertyAttrsSAML: "department",
},
})
require.NoError(t, err)
newValue := func() *model.PropertyValue {
return &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
}
t.Run("SAML sync caller can write via implicit service owner", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.NoError(t, upErr)
})
t.Run("owner plugin can write with each listed scope", func(t *testing.T) {
for _, scope := range []string{"entra", "okta"} {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: scope})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.NoError(t, upErr, "scope %q", scope)
}
})
t.Run("owner plugin is denied for an unlisted scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "keycloak"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("LDAP sync caller is denied without attrs.ldap", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("unlisted plugin is denied", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-other", model.PropertyRequestOptions{ActingAsScope: "entra"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
}
func TestMultipleDistinctPluginOwners(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-a" || pluginID == "plugin-b" || pluginID == "plugin-other"
})
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
coOwned, err := th.service.CreatePropertyField(rctxHuman, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "CoOwnedByAdmin",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsOwners: []model.PropertyOwner{
{ID: "plugin-a", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-a"}},
{ID: "plugin-b", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-b"}},
},
},
})
require.NoError(t, err)
require.Len(t, model.GetPropertyFieldOwners(coOwned), 2)
newValue := func() *model.PropertyValue {
return &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: coOwned.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
}
t.Run("listed plugin can write with its scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-a", model.PropertyRequestOptions{ActingAsScope: "scope-a"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.NoError(t, upErr)
})
t.Run("plugin cannot use another owner's scope", func(t *testing.T) {
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-a", model.PropertyRequestOptions{ActingAsScope: "scope-b"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue())
require.Error(t, upErr)
})
t.Run("listed co-owner may change the owners list on a co-owned field", func(t *testing.T) {
rctxA := RequestContextWithCallerID(th.Context, "plugin-a")
existing, getErr := th.service.GetPropertyField(rctxA, th.CPAGroupID, coOwned.ID)
require.NoError(t, getErr)
existing.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-a", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-a", "scope-a2"}},
{ID: "plugin-b", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-b"}},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxA, th.CPAGroupID, existing)
require.NoError(t, upErr)
owners := model.GetPropertyFieldOwners(updated)
require.Len(t, owners, 2)
for _, owner := range owners {
if owner.ID == "plugin-a" {
assert.ElementsMatch(t, []string{"scope-a", "scope-a2"}, owner.Scopes)
}
}
})
t.Run("non-owner plugin may not change the owners list on a co-owned field", func(t *testing.T) {
rctxOther := RequestContextWithCallerID(th.Context, "plugin-other")
existing, getErr := th.service.GetPropertyField(rctxHuman, th.CPAGroupID, coOwned.ID)
require.NoError(t, getErr)
existing.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-a", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-a"}},
{ID: "plugin-b", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-b"}},
{ID: "plugin-other", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"scope-other"}},
}
_, _, upErr := th.service.UpdatePropertyField(rctxOther, th.CPAGroupID, existing)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
}
func TestOwnerSyncBidirectionalTransitions(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-owner"
})
rctxHuman := RequestContextWithCallerID(th.Context, model.NewId())
newValue := func(fieldID string) *model.PropertyValue {
return &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: fieldID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
}
assertCombinedWrites := func(t *testing.T, fieldID string) {
t.Helper()
rctxSAML := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
_, upErr := th.service.UpsertPropertyValue(rctxSAML, newValue(fieldID))
require.NoError(t, upErr)
rctxPlugin := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "entra"})
_, upErr = th.service.UpsertPropertyValue(rctxPlugin, newValue(fieldID))
require.NoError(t, upErr)
}
t.Run("SCIM-first then link SAML", func(t *testing.T) {
created := createOwnedField(t, th, "ScimFirst", "plugin-owner", []string{"entra"})
created.Attrs[model.CustomProfileAttributesPropertyAttrsSAML] = "department"
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, created)
require.NoError(t, upErr)
require.Equal(t, "department", updated.Attrs[model.CustomProfileAttributesPropertyAttrsSAML])
assertCombinedWrites(t, created.ID)
})
t.Run("SAML-first then add plugin owner", func(t *testing.T) {
created, err := th.service.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "SamlFirst",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsSAML: "department",
},
})
require.NoError(t, err)
require.False(t, model.HasPropertyFieldOwners(created))
created.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxHuman, th.CPAGroupID, created)
require.NoError(t, upErr)
require.True(t, model.HasPropertyFieldOwners(updated))
assertCombinedWrites(t, created.ID)
})
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// rankOptions returns a four-rung classification ladder for rank-field tests.
func rankOptions() []any {
return []any{
map[string]any{"id": "opt_public", "name": "Public", "rank": 1},
map[string]any{"id": "opt_confidential", "name": "Confidential", "rank": 2},
map[string]any{"id": "opt_secret", "name": "Secret", "rank": 3},
map[string]any{"id": "opt_topsecret", "name": "TopSecret", "rank": 4},
}
}
func optionIDsOf(t *testing.T, field *model.PropertyField) []string {
t.Helper()
raw, ok := field.Attrs[model.PropertyFieldAttributeOptions].([]any)
require.True(t, ok, "options should be a slice")
ids := make([]string, 0, len(raw))
for _, opt := range raw {
ids = append(ids, opt.(map[string]any)["id"].(string))
}
return ids
}
// TestRankSharedOnly_FieldOptions verifies that a shared_only rank field
// exposes every option at or below the caller's own rank ("everything at your
// rank and lower"), rather than only the exact option the caller holds.
func TestRankSharedOnly_FieldOptions(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "test-plugin"
})
rctxSource := RequestContextWithCallerID(th.Context, "test-plugin")
newRankField := func(t *testing.T, name string) *model.PropertyField {
t.Helper()
field, err := th.service.CreatePropertyField(rctxSource, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: name,
Type: model.PropertyFieldTypeRank,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: rankOptions(),
},
})
require.NoError(t, err)
return field
}
assignRank := func(t *testing.T, fieldID, userID, optionID string) {
t.Helper()
v, jsonErr := json.Marshal(optionID)
require.NoError(t, jsonErr)
_, err := th.service.CreatePropertyValue(rctxSource, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: fieldID,
TargetType: "user",
TargetID: userID,
Value: v,
})
require.NoError(t, err)
}
t.Run("caller at a middle rank sees their rank and below", func(t *testing.T) {
field := newRankField(t, "clearance-middle")
userID := model.NewId()
assignRank(t, field.ID, userID, "opt_secret") // rank 2
retrieved, err := th.service.GetPropertyField(RequestContextWithCallerID(th.Context, userID), th.CPAGroupID, field.ID)
require.NoError(t, err)
ids := optionIDsOf(t, retrieved)
assert.ElementsMatch(t, []string{"opt_public", "opt_confidential", "opt_secret"}, ids,
"caller at Secret should see Public, Confidential, Secret but not TopSecret")
})
t.Run("caller at the top rank sees every option", func(t *testing.T) {
field := newRankField(t, "clearance-top")
userID := model.NewId()
assignRank(t, field.ID, userID, "opt_topsecret") // rank 3
retrieved, err := th.service.GetPropertyField(RequestContextWithCallerID(th.Context, userID), th.CPAGroupID, field.ID)
require.NoError(t, err)
ids := optionIDsOf(t, retrieved)
assert.ElementsMatch(t, []string{"opt_public", "opt_confidential", "opt_secret", "opt_topsecret"}, ids)
})
t.Run("caller at the lowest rank sees only that rank", func(t *testing.T) {
field := newRankField(t, "clearance-bottom")
userID := model.NewId()
assignRank(t, field.ID, userID, "opt_public") // rank 1
retrieved, err := th.service.GetPropertyField(RequestContextWithCallerID(th.Context, userID), th.CPAGroupID, field.ID)
require.NoError(t, err)
ids := optionIDsOf(t, retrieved)
assert.ElementsMatch(t, []string{"opt_public"}, ids)
})
t.Run("caller with no value sees no options", func(t *testing.T) {
field := newRankField(t, "clearance-none")
userID := model.NewId() // never assigned a value
retrieved, err := th.service.GetPropertyField(RequestContextWithCallerID(th.Context, userID), th.CPAGroupID, field.ID)
require.NoError(t, err)
assert.Empty(t, optionIDsOf(t, retrieved))
})
t.Run("source plugin sees all options regardless of rank", func(t *testing.T) {
field := newRankField(t, "clearance-source")
retrieved, err := th.service.GetPropertyField(rctxSource, th.CPAGroupID, field.ID)
require.NoError(t, err)
ids := optionIDsOf(t, retrieved)
assert.ElementsMatch(t, []string{"opt_public", "opt_confidential", "opt_secret", "opt_topsecret"}, ids)
})
}
// TestRankSharedOnly_Value verifies that a shared_only rank field exposes a
// target's value to a caller whose own rank is at or above the target's rank,
// rather than only on an exact rank match.
func TestRankSharedOnly_Value(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "test-plugin"
})
rctxSource := RequestContextWithCallerID(th.Context, "test-plugin")
field, err := th.service.CreatePropertyField(rctxSource, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "clearance-value",
Type: model.PropertyFieldTypeRank,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: rankOptions(),
},
})
require.NoError(t, err)
assignRank := func(t *testing.T, userID, optionID string) *model.PropertyValue {
t.Helper()
v, jsonErr := json.Marshal(optionID)
require.NoError(t, jsonErr)
pv, createErr := th.service.CreatePropertyValue(rctxSource, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID,
Value: v,
})
require.NoError(t, createErr)
return pv
}
// The caller holds Secret (rank 2).
callerID := model.NewId()
assignRank(t, callerID, "opt_secret")
rctxCaller := RequestContextWithCallerID(th.Context, callerID)
t.Run("target below caller's rank is visible", func(t *testing.T) {
target := assignRank(t, model.NewId(), "opt_confidential") // rank 1
retrieved, getErr := th.service.GetPropertyValue(rctxCaller, th.CPAGroupID, target.ID)
require.NoError(t, getErr)
require.NotNil(t, retrieved)
assert.Equal(t, target.Value, retrieved.Value)
})
t.Run("target at caller's rank is visible", func(t *testing.T) {
target := assignRank(t, model.NewId(), "opt_secret") // rank 2
retrieved, getErr := th.service.GetPropertyValue(rctxCaller, th.CPAGroupID, target.ID)
require.NoError(t, getErr)
require.NotNil(t, retrieved)
assert.Equal(t, target.Value, retrieved.Value)
})
t.Run("target above caller's rank is clamped to the caller's rank", func(t *testing.T) {
target := assignRank(t, model.NewId(), "opt_topsecret") // rank 3
retrieved, getErr := th.service.GetPropertyValue(rctxCaller, th.CPAGroupID, target.ID)
require.NoError(t, getErr)
require.NotNil(t, retrieved)
expected, jsonErr := json.Marshal("opt_secret") // caller holds Secret (rank 2)
require.NoError(t, jsonErr)
assert.Equal(t, json.RawMessage(expected), retrieved.Value,
"caller at Secret viewing a TopSecret target should see the value clamped to Secret, the highest rank they share")
})
t.Run("caller with no value of their own sees nothing", func(t *testing.T) {
target := assignRank(t, model.NewId(), "opt_public") // rank 1, the lowest
noRankCaller := RequestContextWithCallerID(th.Context, model.NewId())
retrieved, getErr := th.service.GetPropertyValue(noRankCaller, th.CPAGroupID, target.ID)
require.NoError(t, getErr)
assert.Nil(t, retrieved)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
var (
ErrFieldLimitReached = errors.New("per-object-type field limit reached")
ErrGroupFieldLimitReached = errors.New("group field limit reached")
)
// FieldLimitConfig defines limits for a specific property group.
type FieldLimitConfig struct {
// PerObjectType maps ObjectType values to their maximum field count.
// For example: {"user": 20} means at most 20 fields with ObjectType="user".
PerObjectType map[string]int64
// GlobalLimit is the maximum total number of fields across the entire group,
// regardless of ObjectType. Zero means no global limit.
GlobalLimit int64
}
// FieldLimitHook enforces per-group field creation limits. It checks both
// per-object-type limits and global group limits before allowing a field
// to be created. The hook only applies to groups that have been configured
// with limits.
type FieldLimitHook struct {
BasePropertyHook
propertyService *PropertyService
limits map[string]*FieldLimitConfig // groupID -> config
}
var _ PropertyHook = (*FieldLimitHook)(nil)
// NewFieldLimitHook creates a hook that enforces field limits. Call
// AddGroupLimit to configure limits for specific groups.
func NewFieldLimitHook(ps *PropertyService) *FieldLimitHook {
return &FieldLimitHook{
propertyService: ps,
limits: make(map[string]*FieldLimitConfig),
}
}
// AddGroupLimit registers a limit configuration for the given group ID.
func (h *FieldLimitHook) AddGroupLimit(groupID string, config *FieldLimitConfig) {
h.limits[groupID] = config
}
func (h *FieldLimitHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
config, ok := h.limits[field.GroupID]
if !ok {
return field, nil
}
// Check per-object-type limit
if field.ObjectType != "" {
if limit, hasLimit := config.PerObjectType[field.ObjectType]; hasLimit {
count, err := h.propertyService.countActivePropertyFieldsForGroupObjectType(field.GroupID, field.ObjectType)
if err != nil {
return nil, fmt.Errorf("failed to count fields: %w", err)
}
if count >= limit {
return nil, fmt.Errorf("limit_reached: field limit of %d reached for object type %q: %w", limit, field.ObjectType, ErrFieldLimitReached)
}
}
}
// Check global group limit
if config.GlobalLimit > 0 {
count, err := h.propertyService.countActivePropertyFieldsForGroup(field.GroupID)
if err != nil {
return nil, fmt.Errorf("failed to count group fields: %w", err)
}
if count >= config.GlobalLimit {
return nil, fmt.Errorf("group_limit_reached: global field limit of %d reached for group: %w", config.GlobalLimit, ErrGroupFieldLimitReached)
}
}
return field, nil
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFieldLimitHook(t *testing.T) {
th := Setup(t)
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_field_limit", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
hook := NewFieldLimitHook(th.service)
hook.AddGroupLimit(group.ID, &FieldLimitConfig{
PerObjectType: map[string]int64{
"user": 3,
},
GlobalLimit: 5,
})
th.service.AddHook(hook)
makeField := func(objectType string) *model.PropertyField {
return &model.PropertyField{
GroupID: group.ID,
Name: "field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: objectType,
}
}
t.Run("allows fields up to per-object-type limit", func(t *testing.T) {
for range 3 {
_, createErr := th.service.CreatePropertyField(th.Context, makeField("user"))
require.NoError(t, createErr)
}
})
t.Run("rejects field at per-object-type limit", func(t *testing.T) {
_, createErr := th.service.CreatePropertyField(th.Context, makeField("user"))
require.Error(t, createErr)
assert.Contains(t, createErr.Error(), "limit_reached")
})
t.Run("allows fields for different object type", func(t *testing.T) {
_, createErr := th.service.CreatePropertyField(th.Context, makeField("post"))
require.NoError(t, createErr)
})
t.Run("rejects at global limit", func(t *testing.T) {
// We have 3 user + 1 post = 4 fields. One more should succeed.
_, createErr := th.service.CreatePropertyField(th.Context, makeField("post"))
require.NoError(t, createErr)
// Now at 5, should hit global limit
_, createErr = th.service.CreatePropertyField(th.Context, makeField("post"))
require.Error(t, createErr)
assert.Contains(t, createErr.Error(), "group_limit_reached")
})
t.Run("skips limit check for unregistered groups", func(t *testing.T) {
otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_no_limits", Version: model.PropertyGroupVersionV2})
require.NoError(t, groupErr)
for range 10 {
field := &model.PropertyField{
GroupID: otherGroup.ID,
Name: "field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
}
_, createErr := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, createErr)
}
})
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/require"
)
type TestHelper struct {
service *PropertyService
dbStore store.Store
Context *request.Context
CPAGroupID string
}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore := mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
mainHelper.PreloadMigrations()
return setupTestHelper(dbStore, tb)
}
func setupTestHelper(s store.Store, tb testing.TB) *TestHelper {
logger := mlog.CreateConsoleTestLogger(tb)
service, err := New(ServiceConfig{
PropertyGroupStore: s.PropertyGroup(),
PropertyFieldStore: s.PropertyField(),
PropertyValueStore: s.PropertyValue(),
CallerIDExtractor: func(rctx request.CTX) string {
if rctx == nil {
return ""
}
callerID, _ := model.CallerIDFromContext(rctx.Context())
return callerID
},
RequestOptionsExtractor: func(rctx request.CTX) model.PropertyRequestOptions {
if rctx == nil {
return model.PropertyRequestOptions{}
}
return model.PropertyRequestOptionsFromContext(rctx.Context())
},
})
require.NoError(tb, err)
tb.Cleanup(func() {
s.Close()
})
return &TestHelper{
service: service,
dbStore: s,
Context: request.EmptyContext(logger),
}
}
// RequestContextWithCallerID adds the caller ID to a request.CTX for access control purposes.
func RequestContextWithCallerID(rctx request.CTX, callerID string) request.CTX {
ctx := model.WithCallerID(rctx.Context(), callerID)
return rctx.WithContext(ctx)
}
// RequestContextWithCallerIDAndOptions adds the caller ID and per-call
// declarations to a request.CTX for access control purposes.
func RequestContextWithCallerIDAndOptions(rctx request.CTX, callerID string, options model.PropertyRequestOptions) request.CTX {
ctx := model.WithCallerID(rctx.Context(), callerID)
ctx = model.WithPropertyRequestOptions(ctx, options)
return rctx.WithContext(ctx)
}
// setPluginCheckerForTests sets the plugin checker on the AccessControlHook for testing.
func (ps *PropertyService) setPluginCheckerForTests(pluginChecker PluginChecker) {
for _, hook := range ps.hooks {
if ach, ok := hook.(*AccessControlHook); ok {
ach.setPluginCheckerForTests(pluginChecker)
}
}
}
func (h *AccessControlHook) setPluginCheckerForTests(pluginChecker PluginChecker) {
h.pluginChecker = pluginChecker
}
func (th *TestHelper) RegisterCPAPropertyGroup(tb testing.TB) *TestHelper {
// Register the CPA group so requiresAccessControl can always look it up
group, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2})
require.NoError(tb, groupErr)
th.CPAGroupID = group.ID
// Create and register the access control hook now that the group ID is known
hook := NewAccessControlHook(th.service, nil, group.ID)
th.service.AddHook(hook)
return th
}
// RegisterPropertyGroup registers a new property group with the given version and a unique name.
func (th *TestHelper) RegisterPropertyGroup(tb testing.TB, version int) *model.PropertyGroup {
tb.Helper()
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{
Name: model.NewId(),
Version: version,
})
require.NoError(tb, err)
return group
}
// CreateTeam creates a team for testing hierarchy
func (th *TestHelper) CreateTeam(tb testing.TB) *model.Team {
team := &model.Team{
DisplayName: "Test Team " + model.NewId(),
Name: "team" + model.NewId(),
Type: model.TeamOpen,
}
team, err := th.dbStore.Team().Save(team)
require.NoError(tb, err)
return team
}
// CreateChannel creates a channel in the given team
func (th *TestHelper) CreateChannel(tb testing.TB, teamID string) *model.Channel {
channel := &model.Channel{
TeamId: teamID,
DisplayName: "Test Channel " + model.NewId(),
Name: "channel" + model.NewId(),
Type: model.ChannelTypeOpen,
}
channel, err := th.dbStore.Channel().Save(th.Context, channel, 10000)
require.NoError(tb, err)
return channel
}
// CreateDMChannel creates a DM channel (no team association)
func (th *TestHelper) CreateDMChannel(tb testing.TB) *model.Channel {
// Create two users for the DM
user1 := th.CreateUser(tb)
user2 := th.CreateUser(tb)
channel, err := th.dbStore.Channel().CreateDirectChannel(th.Context, user1, user2)
require.NoError(tb, err)
return channel
}
// CreateUser creates a user for testing
func (th *TestHelper) CreateUser(tb testing.TB) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
user, err := th.dbStore.User().Save(th.Context, user)
require.NoError(tb, err)
return user
}
// CreatePropertyField creates a property field using the service (with access control routing)
func (th *TestHelper) CreatePropertyField(tb testing.TB, rctx request.CTX, field *model.PropertyField) *model.PropertyField {
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(tb, err)
return result
}
// CreatePropertyFieldDirect creates a property field directly via store (bypasses conflict check and access control)
func (th *TestHelper) CreatePropertyFieldDirect(tb testing.TB, field *model.PropertyField) *model.PropertyField {
result, err := th.dbStore.PropertyField().Create(field)
require.NoError(tb, err)
return result
}
// CreatePropertyValue creates a property value using the service (with access control routing)
func (th *TestHelper) CreatePropertyValue(tb testing.TB, rctx request.CTX, value *model.PropertyValue) *model.PropertyValue {
result, err := th.service.CreatePropertyValue(rctx, value)
require.NoError(tb, err)
return result
}

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// errNilHookResult is returned when a pre-hook returns a nil result without an
// error. This catches buggy hook implementations early rather than letting a
// nil propagate into the store layer.
var (
errNilHookResult = errors.New("property hook returned nil result")
errFieldCardinalityBroken = errors.New("PostGetPropertyFields hook returned fewer fields than it received")
)
// PropertyHook defines an interface for hooks that run before and after property
// service operations. Hooks can inspect and modify inputs (pre-hooks) or filter
// outputs (post-hooks). A pre-hook returns an error to block the operation; a
// post-hook returns an error to suppress the result. Returning nil means the
// hook has no objection and the operation may proceed.
//
// Pre-hooks receive the operation's input parameters and may return modified
// versions. Post-hooks receive the operation's results and may return filtered
// or modified versions.
//
// Multiple hooks are called in registration order. Each hook receives the
// output of the previous hook (or the original input for the first hook).
type PropertyHook interface {
// Field pre-hooks (write operations)
PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error)
PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error)
PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error)
PreDeletePropertyField(rctx request.CTX, groupID string, id string) error
// PostUpdatePropertyFields runs after a successful field update (including
// the linked-field propagation pass). It receives the pre-update state of
// the requested fields (parallel to requested), the post-update requested
// fields, and the post-update propagated fields. Hooks may transform attrs
// on either bucket (e.g. redact information for the caller); the
// dispatcher enforces cardinality preservation on both buckets so a buggy
// hook that drops fields surfaces an error rather than silently truncating
// the broadcast. Returns the IDs of fields whose dependent property values
// were cleared as a side effect (e.g. type-change cleanup); the caller
// publishes the corresponding WS events. Errors are best-effort: the
// dispatcher logs and continues, the update is not rolled back.
PostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) (newRequested, newPropagated []*model.PropertyField, clearedFieldIDs []string, err error)
// Field pre-hook for count operations. Count operations return only a
// scalar so there is no post-hook — access control applied to per-row
// data does not apply, but license/group-level gating still does.
// Return an error to block the count.
PreCountPropertyFields(rctx request.CTX, groupID string) error
// Field post-hooks (read operations)
//
// PostGetPropertyField is called after retrieving a single field (by ID or by name).
// Implementations must return a non-nil field; returning nil is treated as a
// hook bug and the dispatcher surfaces errNilHookResult. To block a caller
// from seeing a field, return a sentinel error instead.
PostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error)
// PostGetPropertyFields is called after retrieving multiple fields (by IDs or search).
// Implementations must preserve slice length — the dispatcher enforces this and will
// return an error if a hook returns fewer fields than it received.
PostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error)
// Value pre-hooks (write operations)
PreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
PreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
PreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error)
PreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error)
PreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
PreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
PreDeletePropertyValue(rctx request.CTX, groupID string, id string) error
PreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error
PreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error
// Value post-hooks (write operations)
//
// These run after a successful store write. They are best-effort: the
// dispatcher logs and continues on a hook error, and the write is never
// rolled back; hooks must not mutate the values. Delete hooks receive the
// delete selector and, when available, the value's pre-delete snapshot.
PostCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) error
PostCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) error
PostUpdatePropertyValue(rctx request.CTX, value *model.PropertyValue) error
PostUpdatePropertyValues(rctx request.CTX, values []*model.PropertyValue) error
PostUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) error
PostUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) error
PostDeletePropertyValue(rctx request.CTX, groupID, valueID string, deleted *model.PropertyValue) error
PostDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error
PostDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error
// Value post-hooks (read operations)
//
// PostGetPropertyValue is called after retrieving a single value.
// Return nil value to indicate the value is not accessible.
PostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error)
// PostGetPropertyValues is called after retrieving multiple values (by IDs or search).
// Implementations may remove entries from the returned slice.
PostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error)
}
// BasePropertyHook provides default passthrough implementations for every
// PropertyHook method. Embed it in concrete hooks to only override the
// methods you care about.
type BasePropertyHook struct{}
func (BasePropertyHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
return field, nil
}
func (BasePropertyHook) PreUpdatePropertyField(_ request.CTX, _ string, field *model.PropertyField) (*model.PropertyField, error) {
return field, nil
}
func (BasePropertyHook) PreUpdatePropertyFields(_ request.CTX, _ string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
return fields, nil
}
func (BasePropertyHook) PreDeletePropertyField(_ request.CTX, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PostUpdatePropertyFields(_ request.CTX, _ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
return requested, propagated, nil, nil
}
func (BasePropertyHook) PreCountPropertyFields(_ request.CTX, _ string) error {
return nil
}
func (BasePropertyHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
return field, nil
}
func (BasePropertyHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
return fields, nil
}
func (BasePropertyHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, nil
}
func (BasePropertyHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, nil
}
func (BasePropertyHook) PreUpdatePropertyValue(_ request.CTX, _ string, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, nil
}
func (BasePropertyHook) PreUpdatePropertyValues(_ request.CTX, _ string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, nil
}
func (BasePropertyHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, nil
}
func (BasePropertyHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, nil
}
func (BasePropertyHook) PreDeletePropertyValue(_ request.CTX, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PreDeletePropertyValuesForTarget(_ request.CTX, _ string, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PreDeletePropertyValuesForField(_ request.CTX, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PostCreatePropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostCreatePropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostUpdatePropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostUpdatePropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostUpsertPropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostUpsertPropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostDeletePropertyValue(_ request.CTX, _, _ string, _ *model.PropertyValue) error {
return nil
}
func (BasePropertyHook) PostDeletePropertyValuesForTarget(_ request.CTX, _ string, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PostDeletePropertyValuesForField(_ request.CTX, _ string, _ string) error {
return nil
}
func (BasePropertyHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, nil
}
func (BasePropertyHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, nil
}
// AddHook registers a hook with the property service. Hooks are called in
// registration order for each operation.
func (ps *PropertyService) AddHook(hook PropertyHook) {
ps.hooks = append(ps.hooks, hook)
}
// runPreCreatePropertyField runs all registered pre-hooks for CreatePropertyField.
func (ps *PropertyService) runPreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
var err error
for _, hook := range ps.hooks {
field, err = hook.PreCreatePropertyField(rctx, field)
if err != nil {
return nil, err
}
if field == nil {
return nil, errNilHookResult
}
}
return field, nil
}
// runPreUpdatePropertyField runs all registered pre-hooks for UpdatePropertyField.
func (ps *PropertyService) runPreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
var err error
for _, hook := range ps.hooks {
field, err = hook.PreUpdatePropertyField(rctx, groupID, field)
if err != nil {
return nil, err
}
if field == nil {
return nil, errNilHookResult
}
}
return field, nil
}
// runPreUpdatePropertyFields runs all registered pre-hooks for UpdatePropertyFields.
func (ps *PropertyService) runPreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
var err error
for _, hook := range ps.hooks {
fields, err = hook.PreUpdatePropertyFields(rctx, groupID, fields)
if err != nil {
return nil, err
}
if fields == nil {
return nil, errNilHookResult
}
}
return fields, nil
}
// runPostUpdatePropertyFields runs all registered post-hooks for
// UpdatePropertyFields. Each hook may transform the requested and propagated
// buckets in place (e.g. redaction); the dispatcher chains the transformed
// slices through subsequent hooks and enforces cardinality preservation on
// both buckets so a buggy hook that drops fields surfaces an error rather
// than silently truncating the broadcast. The cleared field IDs returned by
// each hook are deduped into a single slice. Best-effort: hook errors and
// cardinality violations are logged and skipped (the offending hook's
// transform is dropped for the chain, but the update itself is not rolled
// back).
func (ps *PropertyService) runPostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string) {
seen := map[string]struct{}{}
var cleared []string
for _, hook := range ps.hooks {
newRequested, newPropagated, ids, err := hook.PostUpdatePropertyFields(rctx, groupID, prev, requested, propagated)
if err != nil {
rctx.Logger().Error("PostUpdatePropertyFields hook failed",
mlog.String("group_id", groupID),
mlog.Err(err),
)
continue
}
if len(newRequested) != len(requested) || len(newPropagated) != len(propagated) {
rctx.Logger().Error("PostUpdatePropertyFields hook returned wrong-length slice",
mlog.String("group_id", groupID),
mlog.Err(errFieldCardinalityBroken),
)
continue
}
requested = newRequested
propagated = newPropagated
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleared = append(cleared, id)
}
}
return requested, propagated, cleared
}
// runPreDeletePropertyField runs all registered pre-hooks for DeletePropertyField.
func (ps *PropertyService) runPreDeletePropertyField(rctx request.CTX, groupID string, id string) error {
for _, hook := range ps.hooks {
if err := hook.PreDeletePropertyField(rctx, groupID, id); err != nil {
return err
}
}
return nil
}
// runPreCountPropertyFields runs all registered pre-hooks for the public
// CountProperty* methods.
func (ps *PropertyService) runPreCountPropertyFields(rctx request.CTX, groupID string) error {
for _, hook := range ps.hooks {
if err := hook.PreCountPropertyFields(rctx, groupID); err != nil {
return err
}
}
return nil
}
// runPostGetPropertyField runs all registered post-hooks for single field retrieval.
func (ps *PropertyService) runPostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if field == nil {
return nil, nil
}
var err error
for _, hook := range ps.hooks {
field, err = hook.PostGetPropertyField(rctx, field)
if err != nil {
return nil, err
}
if field == nil {
return nil, errNilHookResult
}
}
return field, nil
}
// runPostGetPropertyFields runs all registered post-hooks for multi-field retrieval.
// It enforces that hooks preserve slice length — a hook that drops fields is a bug.
func (ps *PropertyService) runPostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
var err error
for _, hook := range ps.hooks {
n := len(fields)
fields, err = hook.PostGetPropertyFields(rctx, fields)
if err != nil {
return nil, err
}
if len(fields) != n {
return nil, errFieldCardinalityBroken
}
}
return fields, nil
}
// runPreCreatePropertyValue runs all registered pre-hooks for CreatePropertyValue.
func (ps *PropertyService) runPreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
value, err = hook.PreCreatePropertyValue(rctx, value)
if err != nil {
return nil, err
}
if value == nil {
return nil, errNilHookResult
}
}
return value, nil
}
// runPreCreatePropertyValues runs all registered pre-hooks for CreatePropertyValues.
func (ps *PropertyService) runPreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
values, err = hook.PreCreatePropertyValues(rctx, values)
if err != nil {
return nil, err
}
if values == nil {
return nil, errNilHookResult
}
}
return values, nil
}
// runPreUpdatePropertyValue runs all registered pre-hooks for UpdatePropertyValue.
func (ps *PropertyService) runPreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
value, err = hook.PreUpdatePropertyValue(rctx, groupID, value)
if err != nil {
return nil, err
}
if value == nil {
return nil, errNilHookResult
}
}
return value, nil
}
// runPreUpdatePropertyValues runs all registered pre-hooks for UpdatePropertyValues.
func (ps *PropertyService) runPreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
values, err = hook.PreUpdatePropertyValues(rctx, groupID, values)
if err != nil {
return nil, err
}
if values == nil {
return nil, errNilHookResult
}
}
return values, nil
}
// runPreUpsertPropertyValue runs all registered pre-hooks for UpsertPropertyValue.
func (ps *PropertyService) runPreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
value, err = hook.PreUpsertPropertyValue(rctx, value)
if err != nil {
return nil, err
}
if value == nil {
return nil, errNilHookResult
}
}
return value, nil
}
// runPreUpsertPropertyValues runs all registered pre-hooks for UpsertPropertyValues.
func (ps *PropertyService) runPreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
values, err = hook.PreUpsertPropertyValues(rctx, values)
if err != nil {
return nil, err
}
if values == nil {
return nil, errNilHookResult
}
}
return values, nil
}
// runPreDeletePropertyValue runs all registered pre-hooks for DeletePropertyValue.
func (ps *PropertyService) runPreDeletePropertyValue(rctx request.CTX, groupID string, id string) error {
for _, hook := range ps.hooks {
if err := hook.PreDeletePropertyValue(rctx, groupID, id); err != nil {
return err
}
}
return nil
}
// runPreDeletePropertyValuesForTarget runs all registered pre-hooks for DeletePropertyValuesForTarget.
func (ps *PropertyService) runPreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
for _, hook := range ps.hooks {
if err := hook.PreDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
return err
}
}
return nil
}
// runPreDeletePropertyValuesForField runs all registered pre-hooks for DeletePropertyValuesForField.
func (ps *PropertyService) runPreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error {
for _, hook := range ps.hooks {
if err := hook.PreDeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
return err
}
}
return nil
}
// runPostCreatePropertyValue runs all registered post-hooks for CreatePropertyValue.
// Best-effort.
func (ps *PropertyService) runPostCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostCreatePropertyValue(rctx, value); err != nil {
rctx.Logger().Error("PostCreatePropertyValue hook failed", mlog.Err(err))
}
}
}
// runPostCreatePropertyValues runs all registered post-hooks for CreatePropertyValues.
// Best-effort.
func (ps *PropertyService) runPostCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostCreatePropertyValues(rctx, values); err != nil {
rctx.Logger().Error("PostCreatePropertyValues hook failed", mlog.Err(err))
}
}
}
// runPostUpdatePropertyValue runs all registered post-hooks for UpdatePropertyValue.
// Best-effort.
func (ps *PropertyService) runPostUpdatePropertyValue(rctx request.CTX, value *model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostUpdatePropertyValue(rctx, value); err != nil {
rctx.Logger().Error("PostUpdatePropertyValue hook failed", mlog.Err(err))
}
}
}
// runPostUpdatePropertyValues runs all registered post-hooks for UpdatePropertyValues.
// Best-effort.
func (ps *PropertyService) runPostUpdatePropertyValues(rctx request.CTX, values []*model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostUpdatePropertyValues(rctx, values); err != nil {
rctx.Logger().Error("PostUpdatePropertyValues hook failed", mlog.Err(err))
}
}
}
// runPostUpsertPropertyValue runs all registered post-hooks for UpsertPropertyValue.
// Best-effort: hook errors are logged and skipped; the write is not rolled back.
func (ps *PropertyService) runPostUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostUpsertPropertyValue(rctx, value); err != nil {
rctx.Logger().Error("PostUpsertPropertyValue hook failed", mlog.Err(err))
}
}
}
// runPostUpsertPropertyValues runs all registered post-hooks for UpsertPropertyValues.
// Best-effort.
func (ps *PropertyService) runPostUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostUpsertPropertyValues(rctx, values); err != nil {
rctx.Logger().Error("PostUpsertPropertyValues hook failed", mlog.Err(err))
}
}
}
// runPostDeletePropertyValue runs all registered post-hooks for DeletePropertyValue.
// deleted is the pre-delete snapshot. Best-effort.
func (ps *PropertyService) runPostDeletePropertyValue(rctx request.CTX, groupID, valueID string, deleted *model.PropertyValue) {
for _, hook := range ps.hooks {
if err := hook.PostDeletePropertyValue(rctx, groupID, valueID, deleted); err != nil {
rctx.Logger().Error("PostDeletePropertyValue hook failed", mlog.Err(err))
}
}
}
// runPostDeletePropertyValuesForTarget runs all registered post-hooks for DeletePropertyValuesForTarget.
// Best-effort.
func (ps *PropertyService) runPostDeletePropertyValuesForTarget(rctx request.CTX, groupID, targetType, targetID string) {
for _, hook := range ps.hooks {
if err := hook.PostDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
rctx.Logger().Error("PostDeletePropertyValuesForTarget hook failed", mlog.Err(err))
}
}
}
// runPostDeletePropertyValuesForField runs all registered post-hooks for DeletePropertyValuesForField.
// Best-effort.
func (ps *PropertyService) runPostDeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) {
for _, hook := range ps.hooks {
if err := hook.PostDeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
rctx.Logger().Error("PostDeletePropertyValuesForField hook failed", mlog.Err(err))
}
}
}
// runPostGetPropertyValue runs all registered post-hooks for single value retrieval.
func (ps *PropertyService) runPostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if value == nil {
return nil, nil
}
var err error
for _, hook := range ps.hooks {
value, err = hook.PostGetPropertyValue(rctx, value)
if err != nil {
return nil, err
}
if value == nil {
return nil, nil
}
}
return value, nil
}
// runPostGetPropertyValues runs all registered post-hooks for multi-value retrieval.
func (ps *PropertyService) runPostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
var err error
for _, hook := range ps.hooks {
values, err = hook.PostGetPropertyValues(rctx, values)
if err != nil {
return nil, err
}
}
return values, nil
}

View File

@@ -0,0 +1,637 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"fmt"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testHook is a configurable PropertyHook implementation for testing hook
// registration, ordering, chaining, and blocking behavior. It embeds
// BasePropertyHook for default passthrough behavior and only overrides
// methods where a test-specific function is set.
type testHook struct {
BasePropertyHook
preCreateFieldFn func(*model.PropertyField) (*model.PropertyField, error)
preUpdateFieldFn func(string, *model.PropertyField) (*model.PropertyField, error)
preUpdateFieldsFn func(string, []*model.PropertyField) ([]*model.PropertyField, error)
preDeleteFieldFn func(string, string) error
postUpdateFieldsFn func(string, []*model.PropertyField, []*model.PropertyField, []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error)
postGetFieldFn func(*model.PropertyField) (*model.PropertyField, error)
postGetFieldsFn func([]*model.PropertyField) ([]*model.PropertyField, error)
preUpsertValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
preUpsertValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
postGetValueFn func(*model.PropertyValue) (*model.PropertyValue, error)
postGetValuesFn func([]*model.PropertyValue) ([]*model.PropertyValue, error)
}
func (h *testHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if h.preCreateFieldFn != nil {
return h.preCreateFieldFn(field)
}
return field, nil
}
func (h *testHook) PreUpdatePropertyField(_ request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
if h.preUpdateFieldFn != nil {
return h.preUpdateFieldFn(groupID, field)
}
return field, nil
}
func (h *testHook) PreUpdatePropertyFields(_ request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if h.preUpdateFieldsFn != nil {
return h.preUpdateFieldsFn(groupID, fields)
}
return fields, nil
}
func (h *testHook) PreDeletePropertyField(_ request.CTX, groupID string, id string) error {
if h.preDeleteFieldFn != nil {
return h.preDeleteFieldFn(groupID, id)
}
return nil
}
func (h *testHook) PostUpdatePropertyFields(_ request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
if h.postUpdateFieldsFn != nil {
return h.postUpdateFieldsFn(groupID, prev, requested, propagated)
}
return requested, propagated, nil, nil
}
func (h *testHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if h.postGetFieldFn != nil {
return h.postGetFieldFn(field)
}
return field, nil
}
func (h *testHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if h.postGetFieldsFn != nil {
return h.postGetFieldsFn(fields)
}
return fields, nil
}
func (h *testHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if h.preUpsertValueFn != nil {
return h.preUpsertValueFn(value)
}
return value, nil
}
func (h *testHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if h.preUpsertValuesFn != nil {
return h.preUpsertValuesFn(values)
}
return values, nil
}
func (h *testHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if h.postGetValueFn != nil {
return h.postGetValueFn(value)
}
return value, nil
}
func (h *testHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if h.postGetValuesFn != nil {
return h.postGetValuesFn(values)
}
return values, nil
}
func TestHookRegistration(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("service starts with no hooks", func(t *testing.T) {
service, err := New(ServiceConfig{
PropertyGroupStore: th.dbStore.PropertyGroup(),
PropertyFieldStore: th.dbStore.PropertyField(),
PropertyValueStore: th.dbStore.PropertyValue(),
})
require.NoError(t, err)
assert.Empty(t, service.hooks)
})
t.Run("AddHook appends hooks in order", func(t *testing.T) {
service, err := New(ServiceConfig{
PropertyGroupStore: th.dbStore.PropertyGroup(),
PropertyFieldStore: th.dbStore.PropertyField(),
PropertyValueStore: th.dbStore.PropertyValue(),
})
require.NoError(t, err)
hook1 := &testHook{}
hook2 := &testHook{}
service.AddHook(hook1)
service.AddHook(hook2)
assert.Len(t, service.hooks, 2)
})
}
func TestPreHookBlocking(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook error blocks CreatePropertyField", func(t *testing.T) {
hook := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
return nil, fmt.Errorf("blocked by hook")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "blocked-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
_, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.Contains(t, err.Error(), "blocked by hook")
})
t.Run("pre-hook error blocks DeletePropertyField", func(t *testing.T) {
hook := &testHook{
preDeleteFieldFn: func(gid string, id string) error {
return fmt.Errorf("delete blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
err := th.service.DeletePropertyField(rctx, groupID, model.NewId())
require.Error(t, err)
assert.Contains(t, err.Error(), "delete blocked")
})
t.Run("pre-hook error blocks UpsertPropertyValue", func(t *testing.T) {
hook := &testHook{
preUpsertValueFn: func(value *model.PropertyValue) (*model.PropertyValue, error) {
return nil, fmt.Errorf("upsert blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
value := &model.PropertyValue{
GroupID: groupID,
FieldID: model.NewId(),
}
_, err := th.service.UpsertPropertyValue(rctx, value)
require.Error(t, err)
assert.Contains(t, err.Error(), "upsert blocked")
})
}
func TestPreHookInputModification(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook modifies field before creation", func(t *testing.T) {
hook := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
// Modify the field name
field.Name = "modified-" + field.Name
return field, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "original",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.Equal(t, "modified-original", result.Name)
})
}
func TestPostHookFiltering(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("post-hook returning nil field without error surfaces errNilHookResult", func(t *testing.T) {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "nil-return-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
hook := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
return nil, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
require.ErrorIs(t, err, errNilHookResult)
assert.Nil(t, result)
})
t.Run("post-hook that drops fields from list returns error", func(t *testing.T) {
field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "keep-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "remove-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
hook := &testHook{
postGetFieldsFn: func(fields []*model.PropertyField) ([]*model.PropertyField, error) {
filtered := []*model.PropertyField{}
for _, f := range fields {
if f.ID == field1.ID {
filtered = append(filtered, f)
}
}
return filtered, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
_, err := th.service.GetPropertyFields(rctx, groupID, []string{field1.ID, field2.ID})
require.Error(t, err)
assert.Contains(t, err.Error(), "fewer fields")
})
}
func TestMultipleHooksChaining(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("multiple pre-hooks chain modifications in order", func(t *testing.T) {
order := []string{}
hook1 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
order = append(order, "hook1")
field.Name = field.Name + "-h1"
return field, nil
},
}
hook2 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
order = append(order, "hook2")
field.Name = field.Name + "-h2"
return field, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "base",
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
result, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.Equal(t, "base-h1-h2", result.Name)
assert.Equal(t, []string{"hook1", "hook2"}, order)
})
t.Run("first hook error prevents second hook from running", func(t *testing.T) {
hook2Called := false
hook1 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
return nil, fmt.Errorf("hook1 blocked")
},
}
hook2 := &testHook{
preCreateFieldFn: func(field *model.PropertyField) (*model.PropertyField, error) {
hook2Called = true
return field, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
field := &model.PropertyField{
GroupID: groupID,
Name: "should-fail-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
}
_, err := th.service.CreatePropertyField(rctx, field)
require.Error(t, err)
assert.False(t, hook2Called, "second hook should not have been called")
})
t.Run("multiple post-hooks chain in order", func(t *testing.T) {
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "chain-post-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{"step": "0"},
})
hook1 := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
if f.Attrs == nil {
f.Attrs = make(model.StringInterface)
}
f.Attrs["hook1"] = true
return f, nil
},
}
hook2 := &testHook{
postGetFieldFn: func(f *model.PropertyField) (*model.PropertyField, error) {
if f.Attrs == nil {
f.Attrs = make(model.StringInterface)
}
f.Attrs["hook2"] = true
return f, nil
},
}
th.service.AddHook(hook1)
th.service.AddHook(hook2)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-2] }()
result, err := th.service.GetPropertyField(rctx, groupID, field.ID)
require.NoError(t, err)
assert.Equal(t, true, result.Attrs["hook1"])
assert.Equal(t, true, result.Attrs["hook2"])
})
}
func TestAccessControlHookGroupScoping(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("access control enforced for managed group (CPA)", func(t *testing.T) {
// Create a protected field in the CPA group via the source plugin
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-managed-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
// Another plugin should NOT be able to update it (protected)
created.Attrs[model.PropertyAttrsProtected] = true
updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
updated.Name = "attempt-update"
_, _, err = th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, updated)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
})
t.Run("access control NOT enforced for unmanaged group", func(t *testing.T) {
unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_scoping_test", Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
// Create a protected field in an unmanaged group
field := &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "protected-unmanaged-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Another plugin CAN update it (no access control for this group)
created.Name = "updated-by-plugin2"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, unmanagedGroup.ID, created)
require.NoError(t, err)
assert.Equal(t, "updated-by-plugin2", updated.Name)
})
t.Run("read filtering applied for managed group", func(t *testing.T) {
// Create a source-only protected field in the CPA group
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-managed-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Source plugin sees all options
result, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created.ID)
require.NoError(t, err)
opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts, 2)
// Other caller sees empty options
result2, err := th.service.GetPropertyField(rctx, th.CPAGroupID, created.ID)
require.NoError(t, err)
opts2 := result2.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts2, 0)
})
t.Run("read filtering NOT applied for unmanaged group", func(t *testing.T) {
unmanagedGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "unmanaged_read_test", Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
// Create a source-only field in an unmanaged group
field := &model.PropertyField{
GroupID: unmanagedGroup.ID,
Name: "source-only-unmanaged-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
TargetType: "user",
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsSourcePluginID: "plugin-1",
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Non-source caller sees ALL options (no filtering for unmanaged groups)
result, err := th.service.GetPropertyField(rctx, unmanagedGroup.ID, created.ID)
require.NoError(t, err)
opts := result.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, opts, 2)
})
}
func TestPreUpdatePropertyFieldsHook(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("pre-hook error blocks batch UpdatePropertyFields", func(t *testing.T) {
hook := &testHook{
preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
return nil, fmt.Errorf("batch update blocked")
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-block-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "updated"
_, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.Error(t, err)
assert.Contains(t, err.Error(), "batch update blocked")
})
t.Run("pre-hook modifies fields in batch update", func(t *testing.T) {
hook := &testHook{
preUpdateFieldsFn: func(gid string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
for _, f := range fields {
f.Name = "modified-" + f.Name
}
return fields, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field1 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-a-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field2 := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "batch-b-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field1.Name = "a"
field2.Name = "b"
results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field1, field2})
require.NoError(t, err)
require.Len(t, results, 2)
assert.Equal(t, "modified-a", results[0].Name)
assert.Equal(t, "modified-b", results[1].Name)
})
}
func TestPostUpdatePropertyFieldsHook(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
rctx := th.Context
groupID := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV1).ID
t.Run("post-hook transforms requested attrs and surfaces cleared IDs", func(t *testing.T) {
hook := &testHook{
postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
for _, f := range requested {
if f.Attrs == nil {
f.Attrs = model.StringInterface{}
}
f.Attrs["redacted"] = true
}
ids := make([]string, 0, len(requested))
for _, f := range requested {
ids = append(ids, f.ID)
}
return requested, propagated, ids, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "post-transform-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "post-transform-renamed-" + model.NewId()
results, _, cleared, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, true, results[0].Attrs["redacted"], "post-hook attr transform must reach caller")
assert.Equal(t, []string{field.ID}, cleared, "cleared IDs from post-hook must be surfaced")
})
t.Run("post-hook returning wrong-length requested slice is skipped", func(t *testing.T) {
hook := &testHook{
postUpdateFieldsFn: func(_ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
// Drop a field — cardinality guard must reject this transform.
return requested[:0], propagated, nil, nil
},
}
th.service.AddHook(hook)
defer func() { th.service.hooks = th.service.hooks[:len(th.service.hooks)-1] }()
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: groupID,
Name: "post-cardinality-" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "user",
})
field.Name = "post-cardinality-renamed-" + model.NewId()
results, _, _, err := th.service.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field})
require.NoError(t, err)
assert.Len(t, results, 1, "wrong-length transform must be discarded; original requested must survive")
})
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
var ErrLicenseRequired = errors.New("license_error: an Enterprise license is required")
// LicenseProvider is a function that returns the current license.
type LicenseProvider func() *model.License
// LicenseCheckHook enforces license requirements for property operations on
// specific groups. Operations on groups without a license requirement pass
// through without checks.
type LicenseCheckHook struct {
BasePropertyHook
licenseProvider LicenseProvider
managedGroupIDs map[string]struct{}
}
var _ PropertyHook = (*LicenseCheckHook)(nil)
// NewLicenseCheckHook creates a hook that requires an Enterprise license for
// all field and value operations on the given property groups.
func NewLicenseCheckHook(licenseProvider LicenseProvider, managedGroupIDs ...string) *LicenseCheckHook {
ids := make(map[string]struct{}, len(managedGroupIDs))
for _, id := range managedGroupIDs {
ids[id] = struct{}{}
}
return &LicenseCheckHook{
licenseProvider: licenseProvider,
managedGroupIDs: ids,
}
}
// requireLicense returns ErrLicenseRequired when groupID is in the managed set
// and no Enterprise license is active. Unmanaged groups and licensed calls
// return nil.
func (h *LicenseCheckHook) requireLicense(groupID string) error {
if _, managed := h.managedGroupIDs[groupID]; !managed {
return nil
}
if !model.MinimumEnterpriseLicense(h.licenseProvider()) {
return ErrLicenseRequired
}
return nil
}
// Field pre-hooks
func (h *LicenseCheckHook) PreCreatePropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
return field, h.requireLicense(field.GroupID)
}
func (h *LicenseCheckHook) PreUpdatePropertyField(_ request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
return field, h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreUpdatePropertyFields(_ request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
return fields, h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreDeletePropertyField(_ request.CTX, groupID string, _ string) error {
return h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreCountPropertyFields(_ request.CTX, groupID string) error {
return h.requireLicense(groupID)
}
// Field post-hooks
func (h *LicenseCheckHook) PostGetPropertyField(_ request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
return field, h.requireLicense(field.GroupID)
}
func (h *LicenseCheckHook) PostGetPropertyFields(_ request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if len(fields) == 0 {
return fields, nil
}
return fields, h.requireLicense(fields[0].GroupID)
}
// Value pre-hooks
func (h *LicenseCheckHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.requireLicense(value.GroupID)
}
func (h *LicenseCheckHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
return values, h.requireLicense(values[0].GroupID)
}
func (h *LicenseCheckHook) PreUpdatePropertyValue(_ request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreUpdatePropertyValues(_ request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.requireLicense(value.GroupID)
}
func (h *LicenseCheckHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
return values, h.requireLicense(values[0].GroupID)
}
func (h *LicenseCheckHook) PreDeletePropertyValue(_ request.CTX, groupID string, _ string) error {
return h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreDeletePropertyValuesForTarget(_ request.CTX, groupID string, _ string, _ string) error {
return h.requireLicense(groupID)
}
func (h *LicenseCheckHook) PreDeletePropertyValuesForField(_ request.CTX, groupID string, _ string) error {
return h.requireLicense(groupID)
}
// Value post-hooks
func (h *LicenseCheckHook) PostGetPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if value == nil {
return value, nil
}
return value, h.requireLicense(value.GroupID)
}
func (h *LicenseCheckHook) PostGetPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
return values, h.requireLicense(values[0].GroupID)
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLicenseCheckHook(t *testing.T) {
th := Setup(t)
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_license_check", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
var currentLicense *model.License
hook := NewLicenseCheckHook(func() *model.License {
return currentLicense
}, group.ID)
th.service.AddHook(hook)
enterpriseLicense := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
makeField := func() *model.PropertyField {
return &model.PropertyField{
GroupID: group.ID,
Name: "field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
}
}
t.Run("blocks field create without license", func(t *testing.T) {
currentLicense = nil
_, createErr := th.service.CreatePropertyField(th.Context, makeField())
require.Error(t, createErr)
assert.Contains(t, createErr.Error(), "license_error")
})
t.Run("allows field create with license, blocks read after license loss", func(t *testing.T) {
currentLicense = enterpriseLicense
created, createErr := th.service.CreatePropertyField(th.Context, makeField())
require.NoError(t, createErr)
assert.NotEmpty(t, created.ID)
currentLicense = nil
_, getErr := th.service.GetPropertyField(th.Context, group.ID, created.ID)
require.Error(t, getErr)
assert.Contains(t, getErr.Error(), "license_error")
})
t.Run("blocks value upsert without license", func(t *testing.T) {
currentLicense = enterpriseLicense
field := th.CreatePropertyFieldDirect(t, makeField())
currentLicense = nil
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: field.ID,
TargetID: model.NewId(),
TargetType: "user",
Value: json.RawMessage(`"hello"`),
}
_, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
require.Error(t, upsertErr)
assert.Contains(t, upsertErr.Error(), "license_error")
})
t.Run("allows operations on unmanaged groups without license", func(t *testing.T) {
currentLicense = nil
otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_no_license_needed", Version: model.PropertyGroupVersionV2})
require.NoError(t, groupErr)
field := &model.PropertyField{
GroupID: otherGroup.ID,
Name: "field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
}
created, createErr := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, createErr)
assert.NotEmpty(t, created.ID)
})
countCalls := []struct {
name string
call func(groupID string) error
}{
{"CountActivePropertyFieldsForGroup", func(id string) error {
_, err := th.service.CountActivePropertyFieldsForGroup(th.Context, id)
return err
}},
{"CountAllPropertyFieldsForGroup", func(id string) error {
_, err := th.service.CountAllPropertyFieldsForGroup(th.Context, id)
return err
}},
{"CountActivePropertyFieldsForTarget", func(id string) error {
_, err := th.service.CountActivePropertyFieldsForTarget(th.Context, id, "user", model.NewId())
return err
}},
{"CountAllPropertyFieldsForTarget", func(id string) error {
_, err := th.service.CountAllPropertyFieldsForTarget(th.Context, id, "user", model.NewId())
return err
}},
}
t.Run("blocks field counts without license on managed group", func(t *testing.T) {
currentLicense = enterpriseLicense
th.CreatePropertyFieldDirect(t, makeField())
currentLicense = nil
for _, c := range countCalls {
err := c.call(group.ID)
require.Error(t, err, c.name)
assert.Contains(t, err.Error(), "license_error", c.name)
}
})
t.Run("allows field counts with license on managed group", func(t *testing.T) {
currentLicense = enterpriseLicense
for _, c := range countCalls {
require.NoError(t, c.call(group.ID), c.name)
}
})
t.Run("allows field counts without license on unmanaged group", func(t *testing.T) {
currentLicense = nil
otherGroup, groupErr := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "count_no_license_needed", Version: model.PropertyGroupVersionV2})
require.NoError(t, groupErr)
for _, c := range countCalls {
require.NoError(t, c.call(otherGroup.ID), c.name)
}
})
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// MigrateBackfillCPADisplayName backfills the CPA display_name attribute on
// every CPA PropertyField that is missing one (absent key or empty string).
//
// This is the only public entry point that performs writes to CPA fields
// without going through the access-control layer. It does so deliberately:
// the backfill is a one-shot system migration and the access-control layer
// would otherwise reject writes against fields whose source plugin is not the
// caller (e.g. UAS-managed CPA fields with attrs["protected"]=true). Confining
// the bypass to this single, named, side-effect-bounded method avoids
// introducing a general "skip access control" surface that other code could
// reach for.
//
// The method is idempotent at the field level: fields that already have a
// non-empty display_name are skipped. The caller (app/migrations.go) is
// responsible for the System-key idempotency wrapper that prevents the whole
// migration from running twice.
//
// Returns the number of fields that were backfilled and the number that were
// skipped, so the caller can log a summary.
func (ps *PropertyService) MigrateBackfillCPADisplayName(rctx request.CTX) (backfilled int, skipped int, err error) {
group, err := ps.Group(model.AccessControlPropertyGroupName)
if err != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to get CPA property group: %w", err)
}
groupID := group.ID
const cpaFieldLimit = 20
var fieldsToUpdate []*model.PropertyField
// Use the unexported searchPropertyFields to bypass access control.
// AC would filter out (or strip options from) protected fields when
// the caller is not the source plugin, which would corrupt the
// fields we then try to write back. CPA creation is capped at 20
// active fields, so a single page covers the full migration scope.
fields, searchErr := ps.searchPropertyFields(groupID, model.PropertyFieldSearchOpts{
PerPage: cpaFieldLimit,
})
if searchErr != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to search CPA fields: %w", searchErr)
}
for _, pf := range fields {
cpaField, convErr := model.NewCPAFieldFromPropertyField(pf)
if convErr != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to convert property field %q: %w", pf.ID, convErr)
}
// Backfill if display_name is absent OR empty-string. This covers
// fields created before display_name existed, fields created after
// without an explicit display_name (stored as ""), and fields
// patched with display_name="".
if cpaField.Attrs.DisplayName != "" {
skipped++
continue
}
cpaField.Attrs.DisplayName = cpaField.Name
fieldsToUpdate = append(fieldsToUpdate, cpaField.ToPropertyField())
}
if len(fieldsToUpdate) > 0 {
// Use the unexported updatePropertyFields for the same reason as
// searchPropertyFields above: the AC layer rejects writes from the
// system to fields owned by a source plugin.
if _, _, _, updateErr := ps.updatePropertyFields(rctx, groupID, fieldsToUpdate); updateErr != nil {
return 0, 0, fmt.Errorf("MigrateBackfillCPADisplayName: failed to update CPA fields: %w", updateErr)
}
}
return len(fieldsToUpdate), skipped, nil
}

View File

@@ -0,0 +1,697 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"fmt"
"net/http"
"reflect"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// enforceFieldGroupVersionMatch checks that the field and group versions are
// compatible. It returns an error if the field is PSAv1 on a PSAv2 group or
// vice versa. The lookup uses GroupByID which checks the cache first and falls
// back to the database.
func (ps *PropertyService) enforceFieldGroupVersionMatch(caller string, groupID string, field *model.PropertyField) error {
group, err := ps.GroupByID(groupID)
if err != nil {
return fmt.Errorf("%s: failed to look up group for version check: %w", caller, err)
}
if group.IsPSAv1() && field.IsPSAv1() {
return nil
}
if group.IsPSAv2() && field.IsPSAv2() {
return nil
}
return model.NewAppError(caller, "app.property_field.version_mismatch.app_error", nil,
"field and group version mismatch", http.StatusBadRequest)
}
// Private implementation methods (database access)
// callerPinnedValues reports whether the request itself carried
// permission_values, decided before the hooks default-fill it.
func (ps *PropertyService) createPropertyField(rctx request.CTX, field *model.PropertyField, callerPinnedValues bool) (*model.PropertyField, error) {
// Enforce version match between field and group
if err := ps.enforceFieldGroupVersionMatch("CreatePropertyField", field.GroupID, field); err != nil {
return nil, err
}
// Legacy properties (PSAv1) skip the conflict check.
if field.IsPSAv1() {
return ps.fieldStore.Create(field)
}
// If this field links to a source, validate the source and copy its schema
if field.LinkedFieldID != nil && *field.LinkedFieldID != "" {
// Templates are definition-only and cannot themselves be linked
if field.ObjectType == model.PropertyFieldObjectTypeTemplate {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.template_cannot_be_linked.app_error",
nil,
"template fields cannot have a linked_field_id",
http.StatusBadRequest,
)
}
source, err := ps.fieldStore.Get(store.RequestContextWithMaster(rctx), "", *field.LinkedFieldID)
if err != nil {
if store.IsErrNotFound(err) {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_source_not_found.app_error",
nil,
fmt.Sprintf("linked source field %q not found", *field.LinkedFieldID),
http.StatusBadRequest,
)
}
return nil, fmt.Errorf("failed to get linked source field %q: %w", *field.LinkedFieldID, err)
}
// Cross-group linking is not supported
if source.GroupID != field.GroupID {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_source_cross_group.app_error",
nil,
fmt.Sprintf("cannot link to field %q in group %q: source must be in the same group %q", *field.LinkedFieldID, source.GroupID, field.GroupID),
http.StatusBadRequest,
)
}
if source.DeleteAt != 0 {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_source_deleted.app_error",
nil,
fmt.Sprintf("linked source field %q is deleted", *field.LinkedFieldID),
http.StatusBadRequest,
)
}
// Only template fields can be link sources
if source.ObjectType != model.PropertyFieldObjectTypeTemplate {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_source_not_template.app_error",
nil,
"can only link to template fields",
http.StatusBadRequest,
)
}
// Linked field's TargetType must match the source template's TargetType
if field.TargetType != source.TargetType {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_target_type_mismatch.app_error",
nil,
fmt.Sprintf("linked field target_type %q must match source template target_type %q", field.TargetType, source.TargetType),
http.StatusBadRequest,
)
}
// Prevent chains: source must not itself be linked
if source.LinkedFieldID != nil && *source.LinkedFieldID != "" {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.linked_source_is_linked.app_error",
nil,
"cannot link to a field that is itself linked (no chains allowed)",
http.StatusBadRequest,
)
}
// Copy type, options, and sync source from source. The sync source
// (ldap/saml) must match the template's, since it defines where the
// value comes from -- a linked field can't independently claim a
// different sync source than the definition it links to.
field.Type = source.Type
if field.Attrs == nil {
field.Attrs = make(model.StringInterface)
}
if source.Attrs != nil {
if opts, ok := source.Attrs[model.PropertyFieldAttributeOptions]; ok {
field.Attrs[model.PropertyFieldAttributeOptions] = opts
}
if ldap, ok := source.Attrs[model.PropertyFieldAttrLDAP]; ok {
field.Attrs[model.PropertyFieldAttrLDAP] = ldap
}
if saml, ok := source.Attrs[model.PropertyFieldAttrSAML]; ok {
field.Attrs[model.PropertyFieldAttrSAML] = saml
}
}
// Inherit permission levels from source template
if source.PermissionField != nil {
field.PermissionField = source.PermissionField
}
// A caller pin wins over the template: a linked field may be writable
// at a lower level than the template it inherits its schema from.
if source.PermissionValues != nil && !callerPinnedValues {
field.PermissionValues = source.PermissionValues
}
if source.PermissionOptions != nil {
field.PermissionOptions = source.PermissionOptions
}
}
// Check for hierarchical name conflicts
conflictLevel, err := ps.fieldStore.CheckPropertyNameConflict(field, "")
if err != nil {
return nil, fmt.Errorf("failed to check property name conflict: %w", err)
}
if conflictLevel != "" {
return nil, model.NewAppError(
"CreatePropertyField",
"app.property_field.create.name_conflict.app_error",
map[string]any{"Name": field.Name, "ConflictLevel": string(conflictLevel)},
fmt.Sprintf("property name %q conflicts with existing %s-level property", field.Name, string(conflictLevel)),
http.StatusConflict,
)
}
return ps.fieldStore.Create(field)
}
func (ps *PropertyService) getPropertyField(rctx request.CTX, groupID, id string) (*model.PropertyField, error) {
return ps.fieldStore.Get(rctx, groupID, id)
}
func (ps *PropertyService) getPropertyFieldFromMaster(rctx request.CTX, groupID, id string) (*model.PropertyField, error) {
return ps.fieldStore.Get(store.RequestContextWithMaster(rctx), groupID, id)
}
func (ps *PropertyService) getPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, error) {
fields, err := ps.fieldStore.GetMany(rctx, groupID, ids)
if err != nil {
var resultsMismatchErr *store.ErrResultsMismatch
if errors.As(err, &resultsMismatchErr) {
return nil, fmt.Errorf("%w: %w", ErrFieldNotFound, err)
}
return nil, err
}
return fields, nil
}
func (ps *PropertyService) getPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, error) {
return ps.fieldStore.GetFieldByName(rctx, groupID, targetID, name)
}
func (ps *PropertyService) getPropertyFieldByNameForObjectType(rctx request.CTX, groupID, targetID, objectType, name string) (*model.PropertyField, error) {
return ps.fieldStore.GetFieldByNameForObjectType(rctx, groupID, targetID, objectType, name)
}
func (ps *PropertyService) countActivePropertyFieldsForGroup(groupID string) (int64, error) {
return ps.fieldStore.CountForGroup(groupID, false)
}
func (ps *PropertyService) countAllPropertyFieldsForGroup(groupID string) (int64, error) {
return ps.fieldStore.CountForGroup(groupID, true)
}
func (ps *PropertyService) countActivePropertyFieldsForGroupObjectType(groupID, objectType string) (int64, error) {
return ps.fieldStore.CountForGroupObjectType(groupID, objectType, false)
}
func (ps *PropertyService) countActivePropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
return ps.fieldStore.CountForTarget(groupID, targetType, targetID, false)
}
func (ps *PropertyService) countAllPropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
return ps.fieldStore.CountForTarget(groupID, targetType, targetID, true)
}
func (ps *PropertyService) searchPropertyFields(groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
// groupID is part of the search method signature to
// incentivize the use of the database indexes in searches
opts.GroupID = groupID
return ps.fieldStore.SearchPropertyFields(opts)
}
func (ps *PropertyService) updatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, []string, error) {
fields, _, clearedIDs, err := ps.updatePropertyFields(rctx, groupID, []*model.PropertyField{field})
if err != nil {
return nil, nil, err
}
return fields[0], clearedIDs, nil
}
func (ps *PropertyService) updatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, clearedFieldIDs []string, err error) {
if len(fields) == 0 {
return nil, nil, nil, nil
}
// Fetch existing fields to compare for changes that require conflict check
ids := make([]string, len(fields))
for i, f := range fields {
if f == nil {
return nil, nil, nil, fmt.Errorf("field at index %d is nil", i)
}
ids[i] = f.ID
}
// Read from master to avoid replication lag between this read and the
// subsequent UPDATE (which also runs against master). This closes the
// TOCTOU window that a replica read would leave open.
existingFields, err := ps.fieldStore.GetMany(store.RequestContextWithMaster(rctx), groupID, ids)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get existing fields for update: %w", err)
}
// Build a map of existing fields by ID for quick lookup
existingByID := make(map[string]*model.PropertyField, len(existingFields))
for _, ef := range existingFields {
existingByID[ef.ID] = ef
}
// Enforce version match between field and group for each field
for _, field := range fields {
if err := ps.enforceFieldGroupVersionMatch("UpdatePropertyFields", groupID, field); err != nil {
return nil, nil, nil, err
}
}
// Check each field for changes that require conflict validation and linked field restrictions
for _, field := range fields {
existing, ok := existingByID[field.ID]
if !ok {
continue
}
// Legacy properties (PSAv1) skip the conflict check.
if field.IsPSAv1() {
continue
}
// Block type changes on linked fields
if existing.LinkedFieldID != nil && *existing.LinkedFieldID != "" && field.Type != existing.Type {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.linked_type_change.app_error",
nil,
"cannot modify type of a linked field",
http.StatusBadRequest,
)
}
// Block options changes on linked fields
if existing.LinkedFieldID != nil && *existing.LinkedFieldID != "" && optionsChanged(existing.Attrs, field.Attrs) {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.linked_options_change.app_error",
nil,
"cannot modify options of a linked field",
http.StatusBadRequest,
)
}
// Block setting LinkedFieldID on a field that wasn't linked at creation.
// LinkedFieldID can only be set during CreatePropertyField, which copies
// the source's type and options. Allowing it on update would create a
// link without that schema copy, causing type/options mismatches.
// Canonicalize empty-string LinkedFieldID to nil (unlink).
// Empty string is the JSON signal for "clear this field"; convert to
// nil before persistence to avoid a third state distinct from NULL.
if field.LinkedFieldID != nil && *field.LinkedFieldID == "" {
field.LinkedFieldID = nil
}
existingIsLinked := existing.LinkedFieldID != nil && *existing.LinkedFieldID != ""
newIsLinked := field.LinkedFieldID != nil
if !existingIsLinked && newIsLinked {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.cannot_link_existing.app_error",
nil,
"linked_field_id can only be set at creation time",
http.StatusBadRequest,
)
}
// Block changing link target. To re-link, unlink first then create a
// new linked field.
if existingIsLinked && newIsLinked && *field.LinkedFieldID != *existing.LinkedFieldID {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.cannot_change_link_target.app_error",
nil,
"cannot change link target; unlink first then create a new linked field",
http.StatusBadRequest,
)
}
// Block type changes on source fields with active linked dependents
if field.Type != existing.Type {
count, cErr := ps.fieldStore.CountLinkedFields(field.ID)
if cErr != nil {
return nil, nil, nil, fmt.Errorf("failed to count linked fields: %w", cErr)
}
if count > 0 {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.type_change_with_dependents.app_error",
nil,
"cannot change type of a field with active linked dependents",
http.StatusConflict,
)
}
}
// Any change to Name or identity fields (TargetType/TargetID/ObjectType)
// can shift the uniqueness domain. The DB unique index catches same-level
// collisions, but cross-level hierarchy conflicts (system ↔ team ↔ channel)
// are only caught here.
if existing.Name != field.Name ||
existing.TargetType != field.TargetType ||
existing.TargetID != field.TargetID ||
existing.ObjectType != field.ObjectType {
conflictLevel, cErr := ps.fieldStore.CheckPropertyNameConflict(field, field.ID)
if cErr != nil {
return nil, nil, nil, fmt.Errorf("failed to check property name conflict: %w", cErr)
}
if conflictLevel != "" {
return nil, nil, nil, model.NewAppError(
"UpdatePropertyFields",
"app.property_field.update.name_conflict.app_error",
map[string]any{"Name": field.Name, "ConflictLevel": string(conflictLevel)},
fmt.Sprintf("property name %q conflicts with existing %s-level property", field.Name, string(conflictLevel)),
http.StatusConflict,
)
}
}
}
// Build expected UpdateAt map for optimistic concurrency control.
// This closes the TOCTOU window: if any field was modified between the
// GetMany above and the UPDATE below, the store will reject the write.
expectedUpdateAts := make(map[string]int64, len(existingByID))
for id, ef := range existingByID {
expectedUpdateAts[id] = ef.UpdateAt
}
// Update fields atomically. The store handles propagation of type and
// options to linked dependents automatically via a JOIN-based UPDATE.
all, uErr := ps.fieldStore.Update(groupID, fields, expectedUpdateAts)
if uErr != nil {
return nil, nil, nil, uErr
}
// Partition the returned fields into requested vs propagated by matching
// against the IDs we submitted. This avoids assuming anything about the
// ordering the store uses in its return value.
requestedIDs := make(map[string]struct{}, len(fields))
for _, f := range fields {
requestedIDs[f.ID] = struct{}{}
}
requested = make([]*model.PropertyField, 0, len(fields))
propagated = make([]*model.PropertyField, 0, len(all)-len(fields))
for _, f := range all {
if _, ok := requestedIDs[f.ID]; ok {
requested = append(requested, f)
} else {
propagated = append(propagated, f)
}
}
// Run post-hooks. prev is parallel to requested. Hooks may transform
// either the requested or propagated bucket (e.g. attr redaction); the
// dispatcher enforces cardinality preservation on both buckets so a buggy
// hook that drops fields surfaces an error rather than silently truncating
// the broadcast. cleared IDs are unioned across hooks.
prev := make([]*model.PropertyField, 0, len(requested))
for _, r := range requested {
prev = append(prev, existingByID[r.ID])
}
requested, propagated, clearedFieldIDs = ps.runPostUpdatePropertyFields(rctx, groupID, prev, requested, propagated)
return requested, propagated, clearedFieldIDs, nil
}
func (ps *PropertyService) deletePropertyField(rctx request.CTX, groupID, id string) error {
// if groupID is not empty, we need to check first that the field belongs to the group
if groupID != "" {
if _, err := ps.getPropertyField(rctx, groupID, id); err != nil {
return fmt.Errorf("error getting property field %q for group %q: %w", id, groupID, err)
}
}
// Deletion protection: cannot delete a field that has active linked dependents
count, err := ps.fieldStore.CountLinkedFields(id)
if err != nil {
return fmt.Errorf("failed to count linked fields: %w", err)
}
if count > 0 {
return model.NewAppError(
"DeletePropertyField",
"app.property_field.delete.has_linked_dependents.app_error",
nil,
"cannot delete field with active linked dependents; unlink or delete dependent fields first",
http.StatusConflict,
)
}
if err := ps.valueStore.DeleteForField(groupID, id); err != nil {
return err
}
return ps.fieldStore.Delete(groupID, id)
}
// Public methods
func (ps *PropertyService) CreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
// Captured before the hooks run: they default-fill PermissionValues, so
// afterwards an explicit pin is indistinguishable from a server default.
callerPinnedValues := field.PermissionValues != nil
field, err := ps.runPreCreatePropertyField(rctx, field)
if err != nil {
return nil, fmt.Errorf("CreatePropertyField: %w", err)
}
return ps.createPropertyField(rctx, field, callerPinnedValues)
}
func (ps *PropertyService) GetPropertyField(rctx request.CTX, groupID, id string) (*model.PropertyField, error) {
field, err := ps.getPropertyField(rctx, groupID, id)
if err != nil {
return nil, fmt.Errorf("GetPropertyField: %w", err)
}
return ps.runPostGetPropertyField(rctx, field)
}
func (ps *PropertyService) GetPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, error) {
fields, err := ps.getPropertyFields(rctx, groupID, ids)
if err != nil {
return nil, fmt.Errorf("GetPropertyFields: %w", err)
}
return ps.runPostGetPropertyFields(rctx, fields)
}
func (ps *PropertyService) GetPropertyFieldsForGroup(rctx request.CTX, groupID string) ([]*model.PropertyField, error) {
fields, err := ps.fieldStore.GetForGroup(rctx, groupID)
if err != nil {
return nil, fmt.Errorf("GetPropertyFieldsForGroup: %w", err)
}
return ps.runPostGetPropertyFields(rctx, fields)
}
// GetPropertyFieldByName looks up a field by name within a group and target.
//
// Deprecated: name is not unique within a group when fields of different object
// types share a name. Use GetPropertyFieldByNameForObjectType to disambiguate.
func (ps *PropertyService) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, error) {
field, err := ps.getPropertyFieldByName(rctx, groupID, targetID, name)
if err != nil {
return nil, fmt.Errorf("GetPropertyFieldByName: %w", err)
}
return ps.runPostGetPropertyField(rctx, field)
}
func (ps *PropertyService) GetPropertyFieldByNameForObjectType(rctx request.CTX, groupID, targetID, objectType, name string) (*model.PropertyField, error) {
field, err := ps.getPropertyFieldByNameForObjectType(rctx, groupID, targetID, objectType, name)
if err != nil {
return nil, fmt.Errorf("GetPropertyFieldByNameForObjectType: %w", err)
}
return ps.runPostGetPropertyField(rctx, field)
}
func (ps *PropertyService) CountActivePropertyFieldsForGroup(rctx request.CTX, groupID string) (int64, error) {
if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountActivePropertyFieldsForGroup: %w", err)
}
return ps.countActivePropertyFieldsForGroup(groupID)
}
func (ps *PropertyService) CountAllPropertyFieldsForGroup(rctx request.CTX, groupID string) (int64, error) {
if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountAllPropertyFieldsForGroup: %w", err)
}
return ps.countAllPropertyFieldsForGroup(groupID)
}
func (ps *PropertyService) CountActivePropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string) (int64, error) {
if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountActivePropertyFieldsForTarget: %w", err)
}
return ps.countActivePropertyFieldsForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) CountAllPropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string) (int64, error) {
if err := ps.runPreCountPropertyFields(rctx, groupID); err != nil {
return 0, fmt.Errorf("CountAllPropertyFieldsForTarget: %w", err)
}
return ps.countAllPropertyFieldsForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) SearchPropertyFields(rctx request.CTX, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
fields, err := ps.searchPropertyFields(groupID, opts)
if err != nil {
return nil, fmt.Errorf("SearchPropertyFields: %w", err)
}
return ps.runPostGetPropertyFields(rctx, fields)
}
// UpdatePropertyField updates a single field. It returns the updated field and
// the IDs of fields whose dependent property values were cleared as a side
// effect (e.g. by TypeChangeValueCleanupHook on a type change). Hooks may
// cascade clears to other fields, so the slice is not necessarily limited to
// the updated field's own ID. The caller is expected to publish any
// value-cleanup WS events.
func (ps *PropertyService) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, []string, error) {
field, err := ps.runPreUpdatePropertyField(rctx, groupID, field)
if err != nil {
return nil, nil, fmt.Errorf("UpdatePropertyField: %w", err)
}
return ps.updatePropertyField(rctx, groupID, field)
}
// UpdatePropertyFields updates a batch of fields and returns the requested set,
// any linked-property propagated fields, and the IDs of fields whose dependent
// property values were cleared as a side effect. The caller is expected to
// publish any value-cleanup WS events.
func (ps *PropertyService) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) (requested []*model.PropertyField, propagated []*model.PropertyField, clearedFieldIDs []string, err error) {
fields, err = ps.runPreUpdatePropertyFields(rctx, groupID, fields)
if err != nil {
return nil, nil, nil, fmt.Errorf("UpdatePropertyFields: %w", err)
}
return ps.updatePropertyFields(rctx, groupID, fields)
}
func (ps *PropertyService) DeletePropertyField(rctx request.CTX, groupID, id string) error {
if err := ps.runPreDeletePropertyField(rctx, groupID, id); err != nil {
return fmt.Errorf("DeletePropertyField: %w", err)
}
return ps.deletePropertyField(rctx, groupID, id)
}
// asOptionSlice extracts the options from an attrs map as []map[string]any
// via direct type assertion. By the time options reach the service layer,
// they are always []any containing map[string]any elements (from JSON
// deserialization, EnsureOptionIDs, or AccessControlAttributeValidationHook.
// sanitizeAndValidateOptions — all of which normalize to this shape).
func asOptionSlice(attrs model.StringInterface) []map[string]any {
if attrs == nil {
return nil
}
raw, ok := attrs[model.PropertyFieldAttributeOptions]
if !ok || raw == nil {
return nil
}
slice, ok := raw.([]any)
if !ok {
return nil
}
result := make([]map[string]any, 0, len(slice))
for _, item := range slice {
if m, ok := item.(map[string]any); ok {
result = append(result, m)
}
}
return result
}
// optionsChanged compares the options in two attrs maps and returns true if they differ.
// Compares by building a map keyed on option ID and using reflect.DeepEqual for
// value comparison, which correctly handles nested structures (maps, slices).
func optionsChanged(oldAttrs, newAttrs model.StringInterface) bool {
oldOpts := asOptionSlice(oldAttrs)
newOpts := asOptionSlice(newAttrs)
if len(oldOpts) != len(newOpts) {
return true
}
// Both nil/empty means no change
if len(oldOpts) == 0 {
return false
}
// Build map of new options keyed by ID for lookup
newByID := make(map[string]map[string]any, len(newOpts))
for _, opt := range newOpts {
if id, _ := opt["id"].(string); id != "" {
newByID[id] = opt
}
}
for _, oldOpt := range oldOpts {
id, _ := oldOpt["id"].(string)
newOpt, exists := newByID[id]
if !exists {
return true
}
if !reflect.DeepEqual(oldOpt, newOpt) {
return true
}
}
return false
}
// extractOptionIDList extracts the "id" field from each option in the given options value
// using direct type assertions (no JSON marshaling).
func extractOptionIDList(options any) []string {
if options == nil {
return nil
}
slice, ok := options.([]any)
if !ok {
return nil
}
ids := make([]string, 0, len(slice))
for _, item := range slice {
if m, ok := item.(map[string]any); ok {
if id, ok := m["id"].(string); ok && id != "" {
ids = append(ids, id)
}
}
}
return ids
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
// RegisterBuiltinGroups registers a set of property groups at startup
// and populates the cache so that subsequent Group calls are free of
// database round-trips.
func (ps *PropertyService) RegisterBuiltinGroups(groups []*model.PropertyGroup) error {
for _, group := range groups {
if _, err := ps.RegisterPropertyGroup(group); err != nil {
return fmt.Errorf("failed to register builtin property group %q: %w", group.Name, err)
}
}
return nil
}
// Group returns the cached PropertyGroup for a given name. If the
// group is not in the cache (e.g. a plugin-registered group), it
// falls back to a database lookup and caches the result.
func (ps *PropertyService) Group(name string) (*model.PropertyGroup, error) {
if cached, ok := ps.groupCache.Load(name); ok {
return cached.(*model.PropertyGroup), nil
}
group, err := ps.groupStore.Get(name)
if err != nil {
return nil, fmt.Errorf("property group %q not found: %w", name, err)
}
ps.groupCache.Store(group.Name, group)
ps.groupIDCache.Store(group.ID, group)
return group, nil
}
// GroupByID returns the PropertyGroup for a given ID. It first checks
// the in-memory cache and falls back to a database lookup on cache
// miss, caching the result for subsequent calls.
func (ps *PropertyService) GroupByID(id string) (*model.PropertyGroup, error) {
if cached, ok := ps.groupIDCache.Load(id); ok {
return cached.(*model.PropertyGroup), nil
}
group, err := ps.groupStore.GetByID(id)
if err != nil {
return nil, fmt.Errorf("property group with id %q not found: %w", id, err)
}
ps.groupCache.Store(group.Name, group)
ps.groupIDCache.Store(group.ID, group)
return group, nil
}
func (ps *PropertyService) RegisterPropertyGroup(group *model.PropertyGroup) (*model.PropertyGroup, error) {
registered, err := ps.groupStore.Register(group)
if err != nil {
return nil, err
}
ps.groupCache.Store(registered.Name, registered)
ps.groupIDCache.Store(registered.ID, registered)
return registered, nil
}
func (ps *PropertyService) GetPropertyGroup(name string) (*model.PropertyGroup, error) {
return ps.groupStore.Get(name)
}

View File

@@ -0,0 +1,344 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// rejectTemplateValues checks that none of the given values target a template
// field. Template fields are definition-only and must never hold values.
// This is enforced at the service layer to cover all entry points (API,
// CPA endpoints, plugin API).
func (ps *PropertyService) rejectTemplateValues(rctx request.CTX, values []*model.PropertyValue) error {
// Collect unique field IDs
seen := make(map[string]struct{}, len(values))
for _, v := range values {
if v == nil {
continue
}
seen[v.FieldID] = struct{}{}
}
if len(seen) == 0 {
return nil
}
fieldIDs := make([]string, 0, len(seen))
for id := range seen {
fieldIDs = append(fieldIDs, id)
}
// Batch lookup from master to avoid replication lag
fields, err := ps.fieldStore.GetMany(store.RequestContextWithMaster(rctx), "", fieldIDs)
if err != nil {
return fmt.Errorf("failed to look up fields for template check: %w", err)
}
for _, field := range fields {
if field.ObjectType == model.PropertyFieldObjectTypeTemplate {
return model.NewAppError(
"PropertyService",
"app.property_value.template_no_values.app_error",
nil,
fmt.Sprintf("template field %q cannot have values", field.ID),
http.StatusBadRequest,
)
}
}
return nil
}
// Private implementation methods (database access)
func (ps *PropertyService) createPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if err := ps.rejectTemplateValues(rctx, []*model.PropertyValue{value}); err != nil {
return nil, err
}
return ps.valueStore.Create(value)
}
func (ps *PropertyService) createPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if err := ps.rejectTemplateValues(rctx, values); err != nil {
return nil, err
}
return ps.valueStore.CreateMany(values)
}
func (ps *PropertyService) getPropertyValue(groupID, id string) (*model.PropertyValue, error) {
return ps.valueStore.Get(groupID, id)
}
func (ps *PropertyService) getPropertyValues(groupID string, ids []string) ([]*model.PropertyValue, error) {
return ps.valueStore.GetMany(groupID, ids)
}
func (ps *PropertyService) searchPropertyValues(groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
// groupID is part of the search method signature to
// incentivize the use of the database indexes in searches
opts.GroupID = groupID
return ps.valueStore.SearchPropertyValues(opts)
}
func (ps *PropertyService) updatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
values, err := ps.updatePropertyValues(rctx, groupID, []*model.PropertyValue{value})
if err != nil {
return nil, err
}
return values[0], nil
}
func (ps *PropertyService) updatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if err := ps.rejectTemplateValues(rctx, values); err != nil {
return nil, err
}
return ps.valueStore.Update(groupID, values)
}
func (ps *PropertyService) upsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
values, err := ps.upsertPropertyValues(rctx, []*model.PropertyValue{value})
if err != nil {
return nil, err
}
return values[0], nil
}
func (ps *PropertyService) upsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if err := ps.rejectTemplateValues(rctx, values); err != nil {
return nil, err
}
return ps.valueStore.Upsert(values)
}
func (ps *PropertyService) deletePropertyValue(groupID, id string) error {
return ps.valueStore.Delete(groupID, id)
}
func (ps *PropertyService) deletePropertyValuesForTarget(groupID string, targetType string, targetID string) error {
return ps.valueStore.DeleteForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) deletePropertyValuesForField(groupID, fieldID string) error {
return ps.valueStore.DeleteForField(groupID, fieldID)
}
// Public methods
func (ps *PropertyService) CreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if value == nil {
return nil, fmt.Errorf("CreatePropertyValue: value cannot be nil")
}
processed, err := ps.runPreCreatePropertyValue(rctx, value)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
}
created, err := ps.createPropertyValue(rctx, processed)
if err != nil {
return nil, err
}
ps.runPostCreatePropertyValue(rctx, created)
return created, nil
}
func (ps *PropertyService) CreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
for i, v := range values {
if v == nil {
return nil, fmt.Errorf("CreatePropertyValues: nil element at index %d", i)
}
if v.GroupID != values[0].GroupID {
return nil, fmt.Errorf("CreatePropertyValues: mixed group IDs in batch")
}
}
processed, err := ps.runPreCreatePropertyValues(rctx, values)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValues: %w", err)
}
created, err := ps.createPropertyValues(rctx, processed)
if err != nil {
return nil, err
}
ps.runPostCreatePropertyValues(rctx, created)
return created, nil
}
func (ps *PropertyService) GetPropertyValue(rctx request.CTX, groupID, id string) (*model.PropertyValue, error) {
value, err := ps.getPropertyValue(groupID, id)
if err != nil {
return nil, fmt.Errorf("GetPropertyValue: %w", err)
}
return ps.runPostGetPropertyValue(rctx, value)
}
func (ps *PropertyService) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, error) {
values, err := ps.getPropertyValues(groupID, ids)
if err != nil {
return nil, fmt.Errorf("GetPropertyValues: %w", err)
}
return ps.runPostGetPropertyValues(rctx, values)
}
func (ps *PropertyService) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
values, err := ps.searchPropertyValues(groupID, opts)
if err != nil {
return nil, fmt.Errorf("SearchPropertyValues: %w", err)
}
return ps.runPostGetPropertyValues(rctx, values)
}
func (ps *PropertyService) UpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
processed, err := ps.runPreUpdatePropertyValue(rctx, groupID, value)
if err != nil {
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
}
updated, err := ps.updatePropertyValue(rctx, groupID, processed)
if err != nil {
return nil, err
}
ps.runPostUpdatePropertyValue(rctx, updated)
return updated, nil
}
func (ps *PropertyService) UpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
// Hooks gate on values[0].GroupID for batch operations, so enforce
// single-group batches at the public boundary — otherwise a mixed
// batch could silently bypass per-group hook logic (license,
// validation, access control).
for i, v := range values {
if v == nil {
return nil, fmt.Errorf("UpdatePropertyValues: nil element at index %d", i)
}
if v.GroupID != values[0].GroupID {
return nil, fmt.Errorf("UpdatePropertyValues: mixed group IDs in batch")
}
}
processed, err := ps.runPreUpdatePropertyValues(rctx, groupID, values)
if err != nil {
return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
}
updated, err := ps.updatePropertyValues(rctx, groupID, processed)
if err != nil {
return nil, err
}
ps.runPostUpdatePropertyValues(rctx, updated)
return updated, nil
}
func (ps *PropertyService) UpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
if value == nil {
return nil, fmt.Errorf("UpsertPropertyValue: value cannot be nil")
}
processed, err := ps.runPreUpsertPropertyValue(rctx, value)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
}
upserted, err := ps.upsertPropertyValue(rctx, processed)
if err != nil {
return nil, err
}
ps.runPostUpsertPropertyValue(rctx, upserted)
return upserted, nil
}
func (ps *PropertyService) UpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return values, nil
}
for i, v := range values {
if v == nil {
return nil, fmt.Errorf("UpsertPropertyValues: nil element at index %d", i)
}
if v.GroupID != values[0].GroupID {
return nil, fmt.Errorf("UpsertPropertyValues: mixed group IDs in batch")
}
}
processed, err := ps.runPreUpsertPropertyValues(rctx, values)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
}
upserted, err := ps.upsertPropertyValues(rctx, processed)
if err != nil {
return nil, err
}
ps.runPostUpsertPropertyValues(rctx, upserted)
return upserted, nil
}
func (ps *PropertyService) DeletePropertyValue(rctx request.CTX, groupID, id string) error {
// Snapshot before the gates so post-hooks have the target/field metadata
// the row ID alone does not carry, and so a denied delete is observable.
// A genuine miss (ErrNotFound) yields a nil snapshot; a real read failure
// (e.g. replica lag or a transient error) is logged so it does not silently
// suppress the delete audit with incomplete metadata.
deleted, snapshotErr := ps.getPropertyValue(groupID, id)
if snapshotErr != nil && !store.IsErrNotFound(snapshotErr) {
rctx.Logger().Warn("DeletePropertyValue: failed to snapshot value before delete; audit metadata may be incomplete",
mlog.String("group_id", groupID),
mlog.String("value_id", id),
mlog.Err(snapshotErr),
)
}
if err := ps.runPreDeletePropertyValue(rctx, groupID, id); err != nil {
return fmt.Errorf("DeletePropertyValue: %w", err)
}
if err := ps.deletePropertyValue(groupID, id); err != nil {
return err
}
ps.runPostDeletePropertyValue(rctx, groupID, id, deleted)
return nil
}
func (ps *PropertyService) DeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
if err := ps.runPreDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil {
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
}
if err := ps.deletePropertyValuesForTarget(groupID, targetType, targetID); err != nil {
return err
}
ps.runPostDeletePropertyValuesForTarget(rctx, groupID, targetType, targetID)
return nil
}
func (ps *PropertyService) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) error {
if err := ps.runPreDeletePropertyValuesForField(rctx, groupID, fieldID); err != nil {
return fmt.Errorf("DeletePropertyValuesForField: %w", err)
}
if err := ps.deletePropertyValuesForField(groupID, fieldID); err != nil {
return err
}
ps.runPostDeletePropertyValuesForField(rctx, groupID, fieldID)
return nil
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// CallerIDExtractor is a function type that extracts the caller ID from a request context.
// This avoids circular dependency between the properties and app packages.
type CallerIDExtractor func(rctx request.CTX) string
// RequestOptionsExtractor extracts the caller's per-call PropertyRequestOptions
// from a request context.
type RequestOptionsExtractor func(rctx request.CTX) model.PropertyRequestOptions
type PropertyService struct {
groupStore store.PropertyGroupStore
fieldStore store.PropertyFieldStore
valueStore store.PropertyValueStore
hooks []PropertyHook
callerIDExtractor CallerIDExtractor
requestOptionsExtractor RequestOptionsExtractor
groupCache sync.Map // name -> *model.PropertyGroup
groupIDCache sync.Map // id -> *model.PropertyGroup
}
type ServiceConfig struct {
PropertyGroupStore store.PropertyGroupStore
PropertyFieldStore store.PropertyFieldStore
PropertyValueStore store.PropertyValueStore
CallerIDExtractor CallerIDExtractor
RequestOptionsExtractor RequestOptionsExtractor
}
func New(c ServiceConfig) (*PropertyService, error) {
if err := c.validate(); err != nil {
return nil, err
}
return &PropertyService{
groupStore: c.PropertyGroupStore,
fieldStore: c.PropertyFieldStore,
valueStore: c.PropertyValueStore,
callerIDExtractor: c.CallerIDExtractor,
requestOptionsExtractor: c.RequestOptionsExtractor,
}, nil
}
func (c *ServiceConfig) validate() error {
if c.PropertyGroupStore == nil || c.PropertyFieldStore == nil || c.PropertyValueStore == nil {
return errors.New("required parameters are not provided")
}
return nil
}
// extractCallerID gets the caller ID from a request context using the configured extractor.
func (ps *PropertyService) extractCallerID(rctx request.CTX) string {
if ps.callerIDExtractor == nil || rctx == nil {
return ""
}
return ps.callerIDExtractor(rctx)
}
// extractRequestOptions gets the caller's per-call options from a request context
// using the configured extractor.
func (ps *PropertyService) extractRequestOptions(rctx request.CTX) model.PropertyRequestOptions {
if ps.requestOptionsExtractor == nil || rctx == nil {
return model.PropertyRequestOptions{}
}
return ps.requestOptionsExtractor(rctx)
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"context"
"net/http"
"reflect"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// SessionAttributesHook keeps the seeded session_attributes schema fixed: fields
// can't be created or deleted through the public API, and updates are limited to
// the tunable Attrs (enabled, ttl_seconds, grace_period_seconds). The fields are
// not Protected (that is reserved for plugin-owned fields), so this hook is the
// enforcement instead.
type SessionAttributesHook struct {
BasePropertyHook
propertyService *PropertyService
groupID string
}
var _ PropertyHook = (*SessionAttributesHook)(nil)
func NewSessionAttributesHook(ps *PropertyService, groupID string) *SessionAttributesHook {
return &SessionAttributesHook{propertyService: ps, groupID: groupID}
}
func (h *SessionAttributesHook) manages(groupID string) bool {
return groupID == h.groupID
}
type systemCallerKey struct{}
func SystemCallerContext(rctx request.CTX) request.CTX {
return rctx.WithContext(context.WithValue(rctx.Context(), systemCallerKey{}, true))
}
func isSystemCaller(rctx request.CTX) bool {
if rctx == nil {
return false
}
isSystemCaller, _ := rctx.Context().Value(systemCallerKey{}).(bool)
return isSystemCaller
}
func (h *SessionAttributesHook) PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
if field.GroupID != h.groupID || isSystemCaller(rctx) {
return field, nil
}
return nil, h.immutableErr("created")
}
func (h *SessionAttributesHook) PreDeletePropertyField(rctx request.CTX, groupID string, _ string) error {
if !h.manages(groupID) || isSystemCaller(rctx) {
return nil
}
return h.immutableErr("deleted")
}
func (h *SessionAttributesHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
if !h.manages(groupID) || isSystemCaller(rctx) {
return field, nil
}
if err := h.validateUpdate(rctx, field); err != nil {
return nil, err
}
return field, nil
}
func (h *SessionAttributesHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
if !h.manages(groupID) || isSystemCaller(rctx) {
return fields, nil
}
for _, field := range fields {
if err := h.validateUpdate(rctx, field); err != nil {
return nil, err
}
}
return fields, nil
}
func (h *SessionAttributesHook) validateUpdate(rctx request.CTX, incoming *model.PropertyField) error {
existing, err := h.propertyService.getPropertyFieldFromMaster(rctx, h.groupID, incoming.ID)
if err != nil {
return err
}
if sessionAttributeIdentityChanged(existing, incoming) || !sessionAttributeAttrsEditAllowed(existing.Attrs, incoming.Attrs) {
return h.immutableErr("changed")
}
return nil
}
func (h *SessionAttributesHook) immutableErr(verb string) *model.AppError {
return model.NewAppError("SessionAttributesHook", "app.session_attributes.field_immutable.app_error",
nil, "session attribute field "+verb, http.StatusForbidden)
}
func sessionAttributeIdentityChanged(existing, incoming *model.PropertyField) bool {
if existing.Name != incoming.Name ||
existing.Type != incoming.Type ||
existing.TargetID != incoming.TargetID ||
existing.TargetType != incoming.TargetType ||
existing.ObjectType != incoming.ObjectType {
return true
}
if (existing.LinkedFieldID == nil) != (incoming.LinkedFieldID == nil) {
return true
}
return existing.LinkedFieldID != nil && *existing.LinkedFieldID != *incoming.LinkedFieldID
}
func sessionAttributeAttrsEditAllowed(existing, incoming model.StringInterface) bool {
tunable := map[string]bool{
model.SAAttrEnabled: true,
model.SAAttrTTLSeconds: true,
model.SAAttrGracePeriodSeconds: true,
}
for key, existingValue := range existing {
if tunable[key] {
continue
}
incomingValue, ok := incoming[key]
if !ok || !reflect.DeepEqual(existingValue, incomingValue) {
return false
}
}
for key := range incoming {
if !tunable[key] {
if _, ok := existing[key]; !ok {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSessionAttributesHook(t *testing.T) {
th := Setup(t)
group := th.RegisterPropertyGroup(t, model.PropertyGroupVersionV2)
th.service.AddHook(NewSessionAttributesHook(th.service, group.ID))
systemContext := SystemCallerContext(th.Context)
createField := func() *model.PropertyField {
f, err := th.service.CreatePropertyField(systemContext, &model.PropertyField{
GroupID: group.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeSession,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.SAAttrEnabled: false,
model.SAAttrTTLSeconds: 15,
model.SAAttrGracePeriodSeconds: 15,
},
})
require.NoError(t, err)
return f
}
t.Run("blocks create from a normal caller", func(t *testing.T) {
_, err := th.service.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeSession,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.Error(t, err)
})
t.Run("allows tuning enabled/ttl/grace from a normal caller", func(t *testing.T) {
f := createField()
current, err := th.service.GetPropertyField(th.Context, group.ID, f.ID)
require.NoError(t, err)
current.Attrs[model.SAAttrEnabled] = true
current.Attrs[model.SAAttrTTLSeconds] = 60
current.Attrs[model.SAAttrGracePeriodSeconds] = 60
_, _, _, err = th.service.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{current})
require.NoError(t, err)
})
t.Run("blocks renaming from a normal caller", func(t *testing.T) {
f := createField()
current, err := th.service.GetPropertyField(th.Context, group.ID, f.ID)
require.NoError(t, err)
current.Name = "renamed"
_, _, _, err = th.service.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{current})
require.Error(t, err)
})
t.Run("blocks introducing a non-tunable attr from a normal caller", func(t *testing.T) {
f := createField()
current, err := th.service.GetPropertyField(th.Context, group.ID, f.ID)
require.NoError(t, err)
current.Attrs[model.SAAttrDisplayName] = "Hacked"
_, _, _, err = th.service.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{current})
require.Error(t, err)
})
t.Run("blocks delete from a normal caller", func(t *testing.T) {
f := createField()
err := th.service.DeletePropertyField(th.Context, group.ID, f.ID)
require.Error(t, err)
})
t.Run("allows delete from the system caller", func(t *testing.T) {
f := createField()
err := th.service.DeletePropertyField(systemContext, group.ID, f.ID)
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// TypeChangeValueCleanupHook deletes a field's dependent property values when
// the field's Type changes on update. The Type column is part of the schema
// contract for stored values (e.g. select-option IDs are only valid against a
// matching select field), so leaving values behind across a type change leaves
// the field functionally broken until callers manually reset the values.
//
// The hook runs in PostUpdatePropertyFields. Earlier hooks
// (linked-property checks at the store layer) already reject the type-change
// cases that would corrupt linked state, so by the time this hook runs the
// only remaining type changes are on standalone fields where cleanup is the
// expected behavior. Cleanup failures are logged and skipped — the field
// update is not rolled back — to keep the operation atomic from the caller's
// perspective.
type TypeChangeValueCleanupHook struct {
BasePropertyHook
propertyService *PropertyService
}
var _ PropertyHook = (*TypeChangeValueCleanupHook)(nil)
// NewTypeChangeValueCleanupHook constructs the hook. The PropertyService
// reference is used to delete dependent values via the unexported
// deletePropertyValuesForField path so the hook does not re-enter the public
// hook chain (which would deadlock on its own pre-hook gating).
func NewTypeChangeValueCleanupHook(ps *PropertyService) *TypeChangeValueCleanupHook {
return &TypeChangeValueCleanupHook{propertyService: ps}
}
// isSelectRankTransition reports whether a type change is between select and
// rank. Both types store values as a single option-ID string, so existing
// values remain valid after the transition and do not need to be cleared.
func isSelectRankTransition(from, to model.PropertyFieldType) bool {
return (from == model.PropertyFieldTypeSelect || from == model.PropertyFieldTypeRank) &&
(to == model.PropertyFieldTypeSelect || to == model.PropertyFieldTypeRank)
}
// PostUpdatePropertyFields returns the IDs of fields whose dependent values
// were cleared. The caller publishes the corresponding WS events. Linked-
// property propagation cannot trigger a type change (blocked upstream), so
// the propagated bucket is passed through unchanged.
func (h *TypeChangeValueCleanupHook) PostUpdatePropertyFields(rctx request.CTX, groupID string, prev, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) {
var cleared []string
for i, u := range requested {
if i >= len(prev) || prev[i] == nil || u == nil {
continue
}
if prev[i].Type == u.Type {
continue
}
if isSelectRankTransition(prev[i].Type, u.Type) {
continue
}
if err := h.propertyService.deletePropertyValuesForField(groupID, u.ID); err != nil {
rctx.Logger().Error("type-change value cleanup failed",
mlog.String("group_id", groupID),
mlog.String("field_id", u.ID),
mlog.String("from_type", string(prev[i].Type)),
mlog.String("to_type", string(u.Type)),
mlog.Err(err),
)
continue
}
cleared = append(cleared, u.ID)
}
return requested, propagated, cleared, nil
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTypeChangeValueCleanupHook verifies the post-update hook detects a Type
// change and deletes the field's dependent property values, surfacing the
// cleared field IDs to the caller.
func TestTypeChangeValueCleanupHook(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.AddHook(NewTypeChangeValueCleanupHook(th.service))
t.Run("type change deletes values and reports cleared field id", func(t *testing.T) {
// Create a select field with two options.
optionAID := model.NewId()
optionBID := model.NewId()
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "select-field-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []map[string]any{
{"id": optionAID, "name": "Option A"},
{"id": optionBID, "name": "Option B"},
},
},
}
created, err := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, err)
// Seed a value referencing one of the options.
userID := model.NewId()
raw, err := json.Marshal(optionAID)
require.NoError(t, err)
_, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetID: userID,
TargetType: model.PropertyValueTargetTypeUser,
Value: raw,
})
require.NoError(t, err)
// Confirm the value exists pre-patch.
preValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
require.Len(t, preValues, 1)
// Patch to type=text. AccessControlAttributeValidationHook strips the now-invalid
// options attr; TypeChangeValueCleanupHook deletes the dependent value.
created.Type = model.PropertyFieldTypeText
_, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, []string{created.ID}, clearedIDs, "expected post-hook to report the type-changed field as cleared")
// Confirm the value is gone.
postValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
assert.Empty(t, postValues, "expected dependent values to be cleared")
})
t.Run("multiselect type change deletes values and reports cleared field id", func(t *testing.T) {
// Same shape as the select case above, but for multiselect.
optionAID := model.NewId()
optionBID := model.NewId()
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "multiselect-field-" + model.NewId(),
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []map[string]any{
{"id": optionAID, "name": "Option A"},
{"id": optionBID, "name": "Option B"},
},
},
}
created, err := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, err)
// Multiselect value is a JSON array of option IDs.
userID := model.NewId()
raw, err := json.Marshal([]string{optionAID, optionBID})
require.NoError(t, err)
_, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetID: userID,
TargetType: model.PropertyValueTargetTypeUser,
Value: raw,
})
require.NoError(t, err)
preValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
require.Len(t, preValues, 1)
created.Type = model.PropertyFieldTypeText
_, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, []string{created.ID}, clearedIDs, "expected post-hook to report the type-changed field as cleared")
postValues, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
assert.Empty(t, postValues, "expected dependent values to be cleared")
})
t.Run("same-type patch is a no-op for cleanup", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "text-field-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, err)
raw, err := json.Marshal("hello")
require.NoError(t, err)
_, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
Value: raw,
})
require.NoError(t, err)
// Rename only — no Type change.
created.Name = "text-field-renamed-" + model.NewId()
_, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
require.NoError(t, err)
assert.Empty(t, clearedIDs, "rename without type change must not clear values")
values, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
assert.Len(t, values, 1, "value must survive a rename")
})
t.Run("select<->rank transition preserves values", func(t *testing.T) {
optionAID := model.NewId()
optionBID := model.NewId()
for _, tc := range []struct {
name string
fromType model.PropertyFieldType
toType model.PropertyFieldType
}{
{"select->rank", model.PropertyFieldTypeSelect, model.PropertyFieldTypeRank},
{"rank->select", model.PropertyFieldTypeRank, model.PropertyFieldTypeSelect},
} {
t.Run(tc.name, func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: tc.name + "-" + model.NewId(),
Type: tc.fromType,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []map[string]any{
{"id": optionAID, "name": "Option A"},
{"id": optionBID, "name": "Option B"},
},
},
}
created, err := th.service.CreatePropertyField(th.Context, field)
require.NoError(t, err)
raw, err := json.Marshal(optionAID)
require.NoError(t, err)
_, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
Value: raw,
})
require.NoError(t, err)
created.Type = tc.toType
_, clearedIDs, err := th.service.UpdatePropertyField(th.Context, th.CPAGroupID, created)
require.NoError(t, err)
assert.Empty(t, clearedIDs, "select<->rank transition must not clear values")
values, err := th.service.SearchPropertyValues(th.Context, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 10,
})
require.NoError(t, err)
assert.Len(t, values, 1, "value must survive a select<->rank type change")
})
}
})
t.Run("plural batch reports cleared ids per affected field", func(t *testing.T) {
// Field 1: select with a value, will be patched to text → cleanup expected.
optID := model.NewId()
f1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "batch-select-" + model.NewId(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []map[string]any{
{"id": optID, "name": "Only Option"},
},
},
}
created1, err := th.service.CreatePropertyField(th.Context, f1)
require.NoError(t, err)
// Field 2: text, will be renamed only → no cleanup expected.
f2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "batch-text-" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created2, err := th.service.CreatePropertyField(th.Context, f2)
require.NoError(t, err)
raw, err := json.Marshal(optID)
require.NoError(t, err)
_, err = th.service.UpsertPropertyValue(th.Context, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetID: model.NewId(),
TargetType: model.PropertyValueTargetTypeUser,
Value: raw,
})
require.NoError(t, err)
// Mutate both: f1 changes Type, f2 changes Name only.
created1.Type = model.PropertyFieldTypeText
created2.Name = "batch-text-renamed-" + model.NewId()
_, _, clearedIDs, err := th.service.UpdatePropertyFields(th.Context, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.NoError(t, err)
assert.Equal(t, []string{created1.ID}, clearedIDs, "only the type-changed field should be in clearedIDs")
})
}

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// Value audit actions, recorded as the "action" meta on the audit record
// emitted by a group's ValueAuditSink. Exported so group-specific sinks can
// branch on the action type.
const (
ValueAuditActionCreate = "create"
ValueAuditActionUpdate = "update"
ValueAuditActionUpsert = "upsert"
ValueAuditActionDelete = "delete"
ValueAuditActionDeleteForTarget = "delete_for_target"
ValueAuditActionDeleteForField = "delete_for_field"
)
// ValueAuditEvent describes one successful property value change passed to a
// group's ValueAuditSink. Prev and Current are nil for bulk-delete actions
// (delete_for_target, delete_for_field). Each group decides which fields to
// include in its audit record.
type ValueAuditEvent struct {
Action string
TargetType string
TargetID string
FieldID string
ValueID string
Prev *model.PropertyValue
Current *model.PropertyValue
}
// ValueAuditSink emits one audit record for a successful property value change.
// Each property group registers its own sink so the properties package stays
// independent of the audit subsystem.
type ValueAuditSink func(rctx request.CTX, e ValueAuditEvent)
// PropertyValueAuditHook audits value writes for registered property groups.
// Every write path funnels through the generic value write path, so auditing
// lives here rather than in each API handler. Post-hooks run after the store
// write. Groups without a registered sink are not audited.
type PropertyValueAuditHook struct {
BasePropertyHook
sinks map[string]ValueAuditSink
}
var _ PropertyHook = (*PropertyValueAuditHook)(nil)
// NewPropertyValueAuditHook creates a value audit hook. Call RegisterGroup to
// opt groups in with a sink callback.
func NewPropertyValueAuditHook() *PropertyValueAuditHook {
return &PropertyValueAuditHook{
sinks: make(map[string]ValueAuditSink),
}
}
// RegisterGroup registers an audit sink for the given property group ID.
func (h *PropertyValueAuditHook) RegisterGroup(groupID string, sink ValueAuditSink) {
h.sinks[groupID] = sink
}
func (h *PropertyValueAuditHook) sinkFor(groupID string) ValueAuditSink {
return h.sinks[groupID]
}
func (h *PropertyValueAuditHook) emit(rctx request.CTX, groupID string, e ValueAuditEvent) {
sink := h.sinkFor(groupID)
if sink == nil {
return
}
sink(rctx, e)
}
// auditWrite emits an audit record for a successful write.
func (h *PropertyValueAuditHook) auditWrite(rctx request.CTX, action string, value *model.PropertyValue) {
if value == nil {
return
}
h.emit(rctx, value.GroupID, ValueAuditEvent{
Action: action,
TargetType: value.TargetType,
TargetID: value.TargetID,
FieldID: value.FieldID,
Current: value,
})
}
// auditCreate emits a create audit record. A create always introduces a new
// value (a conflicting create fails), so there is no no-op to suppress.
func (h *PropertyValueAuditHook) auditCreate(rctx request.CTX, value *model.PropertyValue) {
if value == nil {
return
}
h.emit(rctx, value.GroupID, ValueAuditEvent{
Action: ValueAuditActionCreate,
TargetType: value.TargetType,
TargetID: value.TargetID,
FieldID: value.FieldID,
Current: value,
})
}
func (h *PropertyValueAuditHook) PostCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) error {
h.auditCreate(rctx, value)
return nil
}
func (h *PropertyValueAuditHook) PostCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) error {
for _, v := range values {
h.auditCreate(rctx, v)
}
return nil
}
func (h *PropertyValueAuditHook) PostUpdatePropertyValue(rctx request.CTX, value *model.PropertyValue) error {
h.auditWrite(rctx, ValueAuditActionUpdate, value)
return nil
}
func (h *PropertyValueAuditHook) PostUpdatePropertyValues(rctx request.CTX, values []*model.PropertyValue) error {
for _, v := range values {
h.auditWrite(rctx, ValueAuditActionUpdate, v)
}
return nil
}
func (h *PropertyValueAuditHook) PostUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) error {
h.auditWrite(rctx, ValueAuditActionUpsert, value)
return nil
}
func (h *PropertyValueAuditHook) PostUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) error {
for _, v := range values {
h.auditWrite(rctx, ValueAuditActionUpsert, v)
}
return nil
}
func (h *PropertyValueAuditHook) PostDeletePropertyValue(rctx request.CTX, groupID, valueID string, deleted *model.PropertyValue) error {
event := ValueAuditEvent{
Action: ValueAuditActionDelete,
ValueID: valueID,
}
emitGroupID := groupID
if deleted != nil {
event.TargetType = deleted.TargetType
event.TargetID = deleted.TargetID
event.FieldID = deleted.FieldID
event.Prev = deleted
emitGroupID = deleted.GroupID
}
h.emit(rctx, emitGroupID, event)
return nil
}
func (h *PropertyValueAuditHook) PostDeletePropertyValuesForTarget(rctx request.CTX, groupID, targetType, targetID string) error {
h.emit(rctx, groupID, ValueAuditEvent{
Action: ValueAuditActionDeleteForTarget,
TargetType: targetType,
TargetID: targetID,
})
return nil
}
func (h *PropertyValueAuditHook) PostDeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) error {
h.emit(rctx, groupID, ValueAuditEvent{
Action: ValueAuditActionDeleteForField,
FieldID: fieldID,
})
return nil
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type recordingSink struct {
calls []struct {
rctx request.CTX
event ValueAuditEvent
}
}
func (r *recordingSink) sink() ValueAuditSink {
return func(_ request.CTX, e ValueAuditEvent) {
r.calls = append(r.calls, struct {
rctx request.CTX
event ValueAuditEvent
}{event: e})
}
}
func newRegisteredAuditHook(groupID string, sink ValueAuditSink) *PropertyValueAuditHook {
h := NewPropertyValueAuditHook()
h.RegisterGroup(groupID, sink)
return h
}
func registerCPAGroup(tb testing.TB, th *TestHelper) string {
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: model.AccessControlPropertyGroupName, Version: model.PropertyGroupVersionV2})
require.NoError(tb, err)
return group.ID
}
func TestPropertyValueAuditHook_PostCreate(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
newValue := func() *model.PropertyValue {
return &model.PropertyValue{GroupID: managed, TargetType: "user", TargetID: "u1", FieldID: "f1", Value: []byte(`"v"`)}
}
t.Run("audits a successful create unconditionally", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
v := newValue()
require.NoError(t, hook.PostCreatePropertyValue(th.Context, v))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionCreate, rec.calls[0].event.Action)
assert.Nil(t, rec.calls[0].event.Prev)
assert.Equal(t, v, rec.calls[0].event.Current)
})
t.Run("audits each value in a batch and ignores unregistered groups", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
other := newValue()
other.GroupID = "other"
require.NoError(t, hook.PostCreatePropertyValues(th.Context, []*model.PropertyValue{newValue(), other, newValue()}))
require.Len(t, rec.calls, 2)
})
}
func TestPropertyValueAuditHook_PostUpdate(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
newValue := func() *model.PropertyValue {
return &model.PropertyValue{GroupID: managed, TargetType: "user", TargetID: "u1", FieldID: "f1", Value: []byte(`"v"`)}
}
t.Run("audits a changed value", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
next := newValue()
next.Value = []byte(`"changed"`)
require.NoError(t, hook.PostUpdatePropertyValue(th.Context, next))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionUpdate, rec.calls[0].event.Action)
})
t.Run("audits an unchanged value", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
require.NoError(t, hook.PostUpdatePropertyValue(th.Context, newValue()))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionUpdate, rec.calls[0].event.Action)
})
t.Run("audits each value in a batch", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
changed := newValue()
changed.Value = []byte(`"changed"`)
values := []*model.PropertyValue{newValue(), changed}
require.NoError(t, hook.PostUpdatePropertyValues(th.Context, values))
require.Len(t, rec.calls, 2)
assert.Equal(t, ValueAuditActionUpdate, rec.calls[0].event.Action)
assert.Equal(t, ValueAuditActionUpdate, rec.calls[1].event.Action)
})
}
func TestPropertyValueAuditHook_PostUpsert(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
newValue := func() *model.PropertyValue {
return &model.PropertyValue{GroupID: managed, TargetType: "user", TargetID: "u1", FieldID: "f1", Value: []byte(`"v"`)}
}
t.Run("audits a new value", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
require.NoError(t, hook.PostUpsertPropertyValue(th.Context, newValue()))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionUpsert, rec.calls[0].event.Action)
assert.Equal(t, "f1", rec.calls[0].event.FieldID)
})
t.Run("audits an unchanged value", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
require.NoError(t, hook.PostUpsertPropertyValue(th.Context, newValue()))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionUpsert, rec.calls[0].event.Action)
})
t.Run("audits a changed value", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
next := newValue()
next.Value = []byte(`"changed"`)
require.NoError(t, hook.PostUpsertPropertyValue(th.Context, next))
require.Len(t, rec.calls, 1)
})
t.Run("ignores values in an unregistered group", func(t *testing.T) {
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
v := newValue()
v.GroupID = "other"
require.NoError(t, hook.PostUpsertPropertyValue(th.Context, v))
assert.Empty(t, rec.calls)
})
}
func TestPropertyValueAuditHook_PostDelete(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
require.NoError(t, hook.PostDeletePropertyValue(th.Context, managed, "v1", &model.PropertyValue{GroupID: managed, TargetType: "user", TargetID: "u1", FieldID: "f1"}))
require.NoError(t, hook.PostDeletePropertyValuesForTarget(th.Context, managed, "user", "u1"))
require.NoError(t, hook.PostDeletePropertyValuesForField(th.Context, managed, "f1"))
// Unregistered group is ignored on every delete variant.
require.NoError(t, hook.PostDeletePropertyValue(th.Context, "other", "v3", &model.PropertyValue{GroupID: "other"}))
require.NoError(t, hook.PostDeletePropertyValuesForTarget(th.Context, "other", "user", "u1"))
require.NoError(t, hook.PostDeletePropertyValuesForField(th.Context, "other", "f1"))
require.Len(t, rec.calls, 3)
assert.Equal(t, ValueAuditActionDelete, rec.calls[0].event.Action)
assert.Equal(t, "v1", rec.calls[0].event.ValueID)
assert.Equal(t, ValueAuditActionDeleteForTarget, rec.calls[1].event.Action)
assert.Equal(t, ValueAuditActionDeleteForField, rec.calls[2].event.Action)
}
func TestPropertyValueAuditHook_PostDeleteSparseSnapshot(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
rec := &recordingSink{}
hook := newRegisteredAuditHook(managed, rec.sink())
t.Run("audits a successful delete without a snapshot", func(t *testing.T) {
require.NoError(t, hook.PostDeletePropertyValue(th.Context, managed, "value-id", nil))
require.Len(t, rec.calls, 1)
assert.Equal(t, ValueAuditActionDelete, rec.calls[0].event.Action)
assert.Equal(t, "value-id", rec.calls[0].event.ValueID)
})
}
// TestPropertyValueAuditHook_ServiceUpsert exercises the full service path:
// every write, including an identical re-upsert, is audited.
func TestPropertyValueAuditHook_ServiceUpsert(t *testing.T) {
th := Setup(t)
managed := registerCPAGroup(t, th)
rec := &recordingSink{}
valueAuditHook := NewPropertyValueAuditHook()
valueAuditHook.RegisterGroup(managed, rec.sink())
th.service.AddHook(valueAuditHook)
field := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: managed,
Name: "attr_" + model.NewId()[:8],
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
value := &model.PropertyValue{
GroupID: managed,
FieldID: field.ID,
TargetType: model.PropertyFieldObjectTypeUser,
TargetID: model.NewId(),
Value: []byte(`"synced"`),
}
_, err := th.service.UpsertPropertyValue(rctx, value)
require.NoError(t, err)
require.Len(t, rec.calls, 1, "first write should audit")
rec.calls = nil
same := &model.PropertyValue{
GroupID: managed,
FieldID: field.ID,
TargetType: model.PropertyFieldObjectTypeUser,
TargetID: value.TargetID,
Value: []byte(`"synced"`),
}
_, err = th.service.UpsertPropertyValue(rctx, same)
require.NoError(t, err)
require.Len(t, rec.calls, 1, "identical re-write should audit")
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"errors"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/require"
)
type rejectingValueWriteHook struct {
BasePropertyHook
err error
}
func (h *rejectingValueWriteHook) PreCreatePropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.err
}
func (h *rejectingValueWriteHook) PreCreatePropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, h.err
}
func (h *rejectingValueWriteHook) PreUpdatePropertyValue(_ request.CTX, _ string, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.err
}
func (h *rejectingValueWriteHook) PreUpdatePropertyValues(_ request.CTX, _ string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, h.err
}
func (h *rejectingValueWriteHook) PreUpsertPropertyValue(_ request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) {
return value, h.err
}
func (h *rejectingValueWriteHook) PreUpsertPropertyValues(_ request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return values, h.err
}
func (h *rejectingValueWriteHook) PreDeletePropertyValue(_ request.CTX, _, _ string) error {
return h.err
}
func (h *rejectingValueWriteHook) PreDeletePropertyValuesForTarget(_ request.CTX, _, _, _ string) error {
return h.err
}
func (h *rejectingValueWriteHook) PreDeletePropertyValuesForField(_ request.CTX, _, _ string) error {
return h.err
}
type recordingValueWriteHook struct {
BasePropertyHook
operation string
}
func (h *recordingValueWriteHook) record(operation string) error {
h.operation = operation
return nil
}
func (h *recordingValueWriteHook) PostCreatePropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return h.record("create")
}
func (h *recordingValueWriteHook) PostCreatePropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return h.record("create_batch")
}
func (h *recordingValueWriteHook) PostUpdatePropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return h.record("update")
}
func (h *recordingValueWriteHook) PostUpdatePropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return h.record("update_batch")
}
func (h *recordingValueWriteHook) PostUpsertPropertyValue(_ request.CTX, _ *model.PropertyValue) error {
return h.record("upsert")
}
func (h *recordingValueWriteHook) PostUpsertPropertyValues(_ request.CTX, _ []*model.PropertyValue) error {
return h.record("upsert_batch")
}
func (h *recordingValueWriteHook) PostDeletePropertyValue(_ request.CTX, _, _ string, _ *model.PropertyValue) error {
return h.record("delete")
}
func (h *recordingValueWriteHook) PostDeletePropertyValuesForTarget(_ request.CTX, _, _, _ string) error {
return h.record("delete_target")
}
func (h *recordingValueWriteHook) PostDeletePropertyValuesForField(_ request.CTX, _, _ string) error {
return h.record("delete_field")
}
func TestValueWritePostHooksDoNotRunAfterPreHookErrors(t *testing.T) {
th := Setup(t)
rejectedErr := errors.New("write rejected")
recorder := &recordingValueWriteHook{}
th.service.AddHook(&rejectingValueWriteHook{err: rejectedErr})
th.service.AddHook(recorder)
groupID := model.NewId()
valueID := model.NewId()
value := &model.PropertyValue{GroupID: groupID}
tests := []struct {
name string
run func() error
}{
{
name: "create",
run: func() error {
_, err := th.service.CreatePropertyValue(th.Context, value)
return err
},
},
{
name: "create batch",
run: func() error {
_, err := th.service.CreatePropertyValues(th.Context, []*model.PropertyValue{value})
return err
},
},
{
name: "update",
run: func() error {
_, err := th.service.UpdatePropertyValue(th.Context, groupID, value)
return err
},
},
{
name: "update batch",
run: func() error {
_, err := th.service.UpdatePropertyValues(th.Context, groupID, []*model.PropertyValue{value})
return err
},
},
{
name: "upsert",
run: func() error {
_, err := th.service.UpsertPropertyValue(th.Context, value)
return err
},
},
{
name: "upsert batch",
run: func() error {
_, err := th.service.UpsertPropertyValues(th.Context, []*model.PropertyValue{value})
return err
},
},
{
name: "delete",
run: func() error {
return th.service.DeletePropertyValue(th.Context, groupID, valueID)
},
},
{
name: "delete target",
run: func() error {
return th.service.DeletePropertyValuesForTarget(th.Context, groupID, "user", model.NewId())
},
},
{
name: "delete field",
run: func() error {
return th.service.DeletePropertyValuesForField(th.Context, groupID, model.NewId())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder.operation = ""
err := tt.run()
require.ErrorIs(t, err, rejectedErr)
require.Empty(t, recorder.operation)
})
}
}