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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
This commit is contained in:
497
server/channels/app/property_field.go
Normal file
497
server/channels/app/property_field.go
Normal file
@@ -0,0 +1,497 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
// propertyFieldOptionsEqual reports whether two values from
|
||||
// PropertyField.Attrs[options] are equivalent. Used to detect a no-op options
|
||||
// patch on a linked field — see UpdatePropertyFields' linked-field invariants.
|
||||
// Both nil/zero forms compare equal; otherwise reflect.DeepEqual handles the
|
||||
// nested map/slice shape produced by JSON unmarshalling.
|
||||
func propertyFieldOptionsEqual(a, b any) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func propertyFieldBroadcastParams(rctx request.CTX, field *model.PropertyField) (teamID, channelID string, ok bool) {
|
||||
switch field.TargetType {
|
||||
case "team":
|
||||
return field.TargetID, "", true
|
||||
case "channel":
|
||||
return "", field.TargetID, true
|
||||
case "system":
|
||||
return "", "", true
|
||||
default:
|
||||
rctx.Logger().Warn(
|
||||
"Unrecognized property field TargetType, skipping broadcast",
|
||||
mlog.String("target_type", field.TargetType),
|
||||
mlog.String("field_id", field.ID),
|
||||
)
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) publishPropertyFieldEvent(rctx request.CTX, eventType model.WebsocketEventType, field *model.PropertyField, connectionID string) {
|
||||
if field == nil || field.IsPSAv1() {
|
||||
return
|
||||
}
|
||||
teamID, channelID, ok := propertyFieldBroadcastParams(rctx, field)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fieldJSON, err := json.Marshal(field)
|
||||
if err != nil {
|
||||
rctx.Logger().Warn("Failed to encode property field to JSON", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
message := model.NewWebSocketEvent(eventType, teamID, channelID, "", nil, connectionID)
|
||||
message.Add("property_field", string(fieldJSON))
|
||||
message.Add("object_type", field.ObjectType)
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
// rankPropertyFieldGate blocks the user-facing "rank" custom profile attribute
|
||||
// type while the PropertyFieldRank feature flag is disabled. Consolidated here
|
||||
// so that both CreatePropertyField (which blocks creating a rank field) and
|
||||
// UpdatePropertyFields (which blocks converting an existing field to rank)
|
||||
// share a single check.
|
||||
//
|
||||
// The gate is scoped to user-object fields, because that is the only place the
|
||||
// user-facing rank type can originate: createCPAField forces ObjectType=user
|
||||
// before reaching CreatePropertyField. Rank fields on other object types are
|
||||
// intentionally exempt — in particular the classification-markings fields
|
||||
// (template/system/channel in the access_control group) legitimately use the
|
||||
// rank type and ship behind the separate, GA-by-default ClassificationMarkings
|
||||
// flag. Gating those here would break the classification admin panel (create
|
||||
// and edit alike) whenever PropertyFieldRank is off.
|
||||
func (a *App) rankPropertyFieldGate(where string, field *model.PropertyField) *model.AppError {
|
||||
if field == nil || field.Type != model.PropertyFieldTypeRank {
|
||||
return nil
|
||||
}
|
||||
if field.ObjectType != model.PropertyFieldObjectTypeUser {
|
||||
return nil
|
||||
}
|
||||
if a.Config().FeatureFlags.PropertyFieldRank {
|
||||
return nil
|
||||
}
|
||||
return model.NewAppError(
|
||||
where,
|
||||
"app.property_field.rank_disabled.app_error",
|
||||
nil,
|
||||
"rank property fields are not enabled",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
// CreatePropertyField creates a new property field.
|
||||
func (a *App) CreatePropertyField(rctx request.CTX, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, *model.AppError) {
|
||||
if field == nil {
|
||||
return nil, model.NewAppError("CreatePropertyField", "app.property_field.invalid_input.app_error", nil, "property field is required", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Intrinsic invariants (apply to every caller — HTTP, plugin, internal).
|
||||
CanonicalizeSystemObjectField(field)
|
||||
field.Name = strings.TrimSpace(field.Name)
|
||||
|
||||
if appErr := a.rankPropertyFieldGate("CreatePropertyField", field); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if !bypassProtectedCheck && field.Protected {
|
||||
return nil, model.NewAppError(
|
||||
"CreatePropertyField",
|
||||
"app.property_field.create.protected.app_error",
|
||||
nil,
|
||||
"cannot create protected field",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
createdField, err := a.Srv().propertyService.CreatePropertyField(rctx, field)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("CreatePropertyField", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("CreatePropertyField", "app.property_field.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldCreated, createdField, connectionID)
|
||||
|
||||
return createdField, nil
|
||||
}
|
||||
|
||||
// GetPropertyField retrieves a property field by group ID and field ID.
|
||||
func (a *App) GetPropertyField(rctx request.CTX, groupID, fieldID string) (*model.PropertyField, *model.AppError) {
|
||||
field, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, fieldID)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("GetPropertyField", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("GetPropertyField", "app.property_field.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// GetPropertyFields retrieves multiple property fields by their IDs.
|
||||
func (a *App) GetPropertyFields(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, *model.AppError) {
|
||||
fields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("GetPropertyFields", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("GetPropertyFields", "app.property_field.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// GetPropertyFieldByName retrieves a property 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 scope the
|
||||
// lookup to a single object type and get a deterministic result.
|
||||
func (a *App) GetPropertyFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, *model.AppError) {
|
||||
field, err := a.Srv().propertyService.GetPropertyFieldByName(rctx, groupID, targetID, name)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("GetPropertyFieldByName", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("GetPropertyFieldByName", "app.property_field.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// GetPropertyFieldByNameForObjectType retrieves a property field by name within
|
||||
// a group and target, scoped to a single object type. Prefer this over
|
||||
// GetPropertyFieldByName when a name may be shared across object types within a
|
||||
// group (e.g. the access_control "classification" fields).
|
||||
func (a *App) GetPropertyFieldByNameForObjectType(rctx request.CTX, groupID, targetID, objectType, name string) (*model.PropertyField, *model.AppError) {
|
||||
field, err := a.Srv().propertyService.GetPropertyFieldByNameForObjectType(rctx, groupID, targetID, objectType, name)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("GetPropertyFieldByNameForObjectType", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("GetPropertyFieldByNameForObjectType", "app.property_field.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// SearchPropertyFields searches for property fields matching the given options.
|
||||
func (a *App) SearchPropertyFields(rctx request.CTX, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, *model.AppError) {
|
||||
fields, err := a.Srv().propertyService.SearchPropertyFields(rctx, groupID, opts)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("SearchPropertyFields", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("SearchPropertyFields", "app.property_field.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// GetPropertyFieldsForGroup retrieves all active property fields for a group.
|
||||
func (a *App) GetPropertyFieldsForGroup(rctx request.CTX, groupID string) ([]*model.PropertyField, *model.AppError) {
|
||||
fields, err := a.Srv().propertyService.GetPropertyFieldsForGroup(rctx, groupID)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("GetPropertyFieldsForGroup", err); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, model.NewAppError("GetPropertyFieldsForGroup", "app.property_field.get_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// CountPropertyFieldsForGroup counts property fields for a group.
|
||||
func (a *App) CountPropertyFieldsForGroup(rctx request.CTX, groupID string, includeDeleted bool) (int64, *model.AppError) {
|
||||
var count int64
|
||||
var err error
|
||||
if includeDeleted {
|
||||
count, err = a.Srv().propertyService.CountAllPropertyFieldsForGroup(rctx, groupID)
|
||||
} else {
|
||||
count, err = a.Srv().propertyService.CountActivePropertyFieldsForGroup(rctx, groupID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("CountPropertyFieldsForGroup", err); appErr != nil {
|
||||
return 0, appErr
|
||||
}
|
||||
return 0, model.NewAppError("CountPropertyFieldsForGroup", "app.property_field.count_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountPropertyFieldsForTarget counts property fields for a specific target.
|
||||
func (a *App) CountPropertyFieldsForTarget(rctx request.CTX, groupID, targetType, targetID string, includeDeleted bool) (int64, *model.AppError) {
|
||||
var count int64
|
||||
var err error
|
||||
if includeDeleted {
|
||||
count, err = a.Srv().propertyService.CountAllPropertyFieldsForTarget(rctx, groupID, targetType, targetID)
|
||||
} else {
|
||||
count, err = a.Srv().propertyService.CountActivePropertyFieldsForTarget(rctx, groupID, targetType, targetID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("CountPropertyFieldsForTarget", err); appErr != nil {
|
||||
return 0, appErr
|
||||
}
|
||||
return 0, model.NewAppError("CountPropertyFieldsForTarget", "app.property_field.count_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UpdatePropertyField updates an existing property field. The second return
|
||||
// value lists the IDs of fields whose dependent property values were cleared
|
||||
// as a side effect (e.g. by TypeChangeValueCleanupHook on a type change).
|
||||
// Hooks may cascade clears to other fields, so the slice is not necessarily
|
||||
// limited to the updated field's own ID.
|
||||
func (a *App) UpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField, bypassProtectedCheck bool, connectionID string) (*model.PropertyField, []string, *model.AppError) {
|
||||
fields, clearedIDs, err := a.UpdatePropertyFields(rctx, groupID, []*model.PropertyField{field}, bypassProtectedCheck, connectionID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return fields[0], clearedIDs, nil
|
||||
}
|
||||
|
||||
// UpdatePropertyFields updates multiple property fields. The second return
|
||||
// value lists the IDs of fields whose dependent property values were cleared
|
||||
// as a side effect.
|
||||
func (a *App) UpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField, bypassProtectedCheck bool, connectionID string) ([]*model.PropertyField, []string, *model.AppError) {
|
||||
if len(fields) == 0 {
|
||||
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.invalid_input.app_error", nil, "property fields are required", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Intrinsic invariants — apply to every caller (HTTP, plugin, internal).
|
||||
// Service returns DB-order, not input-order, so we'll build a lookup map
|
||||
// keyed by ID below; collect IDs in this same pass.
|
||||
ids := make([]string, len(fields))
|
||||
for i, f := range fields {
|
||||
f.Name = strings.TrimSpace(f.Name)
|
||||
ids[i] = f.ID
|
||||
}
|
||||
|
||||
// Load existing fields once. Used for: protected-check (gated by
|
||||
// bypassProtectedCheck), PSAv1 reject (always-on), linked-field diff
|
||||
// invariants (always-on).
|
||||
|
||||
existingFields, err := a.Srv().propertyService.GetPropertyFields(rctx, groupID, ids)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
|
||||
return nil, nil, appErr
|
||||
}
|
||||
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
existingByID := make(map[string]*model.PropertyField, len(existingFields))
|
||||
for _, ex := range existingFields {
|
||||
existingByID[ex.ID] = ex
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
existing, ok := existingByID[f.ID]
|
||||
if !ok {
|
||||
// Service-level GetPropertyFields returns an ErrResultsMismatch when
|
||||
// any input ID is missing, so this branch is defensive.
|
||||
continue
|
||||
}
|
||||
|
||||
// Rank-type gate: block converting a field to rank while the feature
|
||||
// flag is off (shares the create-path check).
|
||||
if appErr := a.rankPropertyFieldGate("UpdatePropertyFields", f); appErr != nil {
|
||||
return nil, nil, appErr
|
||||
}
|
||||
|
||||
// Linked-field diff invariants. "Linked" = LinkedFieldID != nil &&
|
||||
// *LinkedFieldID != "". Unlink (nil or "") is always allowed when
|
||||
// existing was linked.
|
||||
existingLinked := existing.LinkedFieldID != nil && *existing.LinkedFieldID != ""
|
||||
incomingLinked := f.LinkedFieldID != nil && *f.LinkedFieldID != ""
|
||||
|
||||
if existingLinked {
|
||||
if f.Type != existing.Type {
|
||||
return nil, nil, model.NewAppError(
|
||||
"UpdatePropertyFields",
|
||||
"app.property_field.update.linked_type_change.app_error",
|
||||
map[string]any{"FieldID": existing.ID},
|
||||
"cannot modify type of a linked field",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
// Compare the options portion of Attrs.
|
||||
var existingOpts, incomingOpts any
|
||||
if existing.Attrs != nil {
|
||||
existingOpts = existing.Attrs[model.PropertyFieldAttributeOptions]
|
||||
}
|
||||
if f.Attrs != nil {
|
||||
incomingOpts = f.Attrs[model.PropertyFieldAttributeOptions]
|
||||
}
|
||||
if !propertyFieldOptionsEqual(existingOpts, incomingOpts) {
|
||||
return nil, nil, model.NewAppError(
|
||||
"UpdatePropertyFields",
|
||||
"app.property_field.update.linked_options_change.app_error",
|
||||
map[string]any{"FieldID": existing.ID},
|
||||
"cannot modify options of a linked field",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
if incomingLinked && *f.LinkedFieldID != *existing.LinkedFieldID {
|
||||
return nil, nil, model.NewAppError(
|
||||
"UpdatePropertyFields",
|
||||
"app.property_field.update.cannot_change_link_target.app_error",
|
||||
map[string]any{"FieldID": existing.ID},
|
||||
"cannot change link target",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
} else if incomingLinked {
|
||||
return nil, nil, model.NewAppError(
|
||||
"UpdatePropertyFields",
|
||||
"app.property_field.update.cannot_link_existing.app_error",
|
||||
map[string]any{"FieldID": existing.ID},
|
||||
"linked_field_id can only be set at creation time",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
// Protected-check is the only invariant gated on the caller's opt-out.
|
||||
if !bypassProtectedCheck && existing.Protected {
|
||||
return nil, nil, model.NewAppError(
|
||||
"UpdatePropertyFields",
|
||||
"app.property_field.update.protected.app_error",
|
||||
map[string]any{"FieldID": existing.ID},
|
||||
"cannot update protected field",
|
||||
http.StatusForbidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
updated, propagated, clearedFieldIDs, err := a.Srv().propertyService.UpdatePropertyFields(rctx, groupID, fields)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("UpdatePropertyFields", err); appErr != nil {
|
||||
return nil, nil, appErr
|
||||
}
|
||||
return nil, nil, model.NewAppError("UpdatePropertyFields", "app.property_field.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Notify the access control service so any per-field metadata it
|
||||
// caches (e.g. the rank-by-name lookup used by the live evaluator)
|
||||
// and any compiled-policy cache entries that depend on this field
|
||||
// are dropped. This runs before the websocket broadcast so a client
|
||||
// reacting to the event never re-reads stale cached metadata (mirrors
|
||||
// the ordering in DeletePropertyField).
|
||||
if acs := a.Srv().ch.AccessControl; acs != nil {
|
||||
for _, field := range updated {
|
||||
acs.OnPropertyFieldOptionsChanged(rctx, field.ID)
|
||||
}
|
||||
for _, field := range propagated {
|
||||
acs.OnPropertyFieldOptionsChanged(rctx, field.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming or re-ranking an option rewrites what the matview materializes, and a type change
|
||||
// drops dependent values, none of it touching the rows the per-user epoch is computed from.
|
||||
if anyUserObjectType(updated) || anyUserObjectType(propagated) {
|
||||
a.invalidateAllUserAttributeCaches()
|
||||
}
|
||||
|
||||
// Broadcast websocket events for both requested and propagated fields
|
||||
for _, field := range updated {
|
||||
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldUpdated, field, connectionID)
|
||||
}
|
||||
for _, field := range propagated {
|
||||
a.publishPropertyFieldEvent(rctx, model.WebsocketEventPropertyFieldUpdated, field, "")
|
||||
}
|
||||
|
||||
// For each field whose dependent values were cleared as a side effect of
|
||||
// the update (e.g. a type change handled by TypeChangeValueCleanupHook),
|
||||
// publish the generic property_values_updated event so subscribers refresh
|
||||
// their local caches. Mirrors App.DeletePropertyValuesForField's wire shape.
|
||||
for _, fieldID := range clearedFieldIDs {
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, "", "", "", nil, "")
|
||||
message.Add("field_id", fieldID)
|
||||
message.Add("values", "[]")
|
||||
a.Publish(message)
|
||||
}
|
||||
|
||||
return updated, clearedFieldIDs, nil
|
||||
}
|
||||
|
||||
// anyUserObjectType reports whether any field is one the AttributeView materializes, and that ABAC
|
||||
// policies can therefore match on.
|
||||
func anyUserObjectType(fields []*model.PropertyField) bool {
|
||||
for _, f := range fields {
|
||||
if f != nil && f.ObjectType == model.PropertyFieldObjectTypeUser {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DeletePropertyField deletes a property field.
|
||||
func (a *App) DeletePropertyField(rctx request.CTX, groupID, id string, bypassProtectedCheck bool, connectionID string) *model.AppError {
|
||||
existing, err := a.Srv().propertyService.GetPropertyField(rctx, groupID, id)
|
||||
if err != nil {
|
||||
if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
return model.NewAppError("DeletePropertyField", "app.property_field.delete.get_existing.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if existing == nil {
|
||||
return model.NewAppError("DeletePropertyField", "app.property_field.delete.not_found.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
if !bypassProtectedCheck && existing.Protected {
|
||||
return model.NewAppError(
|
||||
"DeletePropertyField",
|
||||
"app.property_field.delete.protected.app_error",
|
||||
nil,
|
||||
"cannot delete protected field",
|
||||
http.StatusForbidden,
|
||||
)
|
||||
}
|
||||
|
||||
if err := a.Srv().propertyService.DeletePropertyField(rctx, groupID, id); err != nil {
|
||||
if appErr := mapPropertyServiceError("DeletePropertyField", err); appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
return model.NewAppError("DeletePropertyField", "app.property_field.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Notify the access control service so any per-field metadata it caches
|
||||
// (e.g. the rank-by-name lookup used by the live evaluator) and any
|
||||
// compiled-policy cache entries that depend on this field are dropped
|
||||
// cluster-wide. Without this a deleted rank field's stale options would
|
||||
// linger in the per-node cache until restart.
|
||||
// The matview filters out soft-deleted fields, so this attribute disappears from every subject
|
||||
// while PropertyValues stays untouched and the per-user epoch cannot see it.
|
||||
if existing.ObjectType == model.PropertyFieldObjectTypeUser {
|
||||
a.invalidateAllUserAttributeCaches()
|
||||
}
|
||||
|
||||
if acs := a.Srv().ch.AccessControl; acs != nil {
|
||||
acs.OnPropertyFieldOptionsChanged(rctx, existing.ID)
|
||||
}
|
||||
|
||||
if existing.IsPSAv2() {
|
||||
teamID, channelID, ok := propertyFieldBroadcastParams(rctx, existing)
|
||||
if ok {
|
||||
message := model.NewWebSocketEvent(model.WebsocketEventPropertyFieldDeleted, teamID, channelID, "", nil, connectionID)
|
||||
message.Add("field_id", existing.ID)
|
||||
message.Add("object_type", existing.ObjectType)
|
||||
a.Publish(message)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user