// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package properties import ( "cmp" "encoding/json" "errors" "fmt" "math" "net/http" "slices" "strings" "unicode/utf8" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" ) var ( ErrInvalidFieldAttrs = errors.New("invalid field attrs") ErrInvalidValue = errors.New("invalid property value") ErrAdminRequired = errors.New("admin privileges required") ) // PermissionChecker checks whether a user has a specific permission. // This avoids a circular dependency between the properties and app packages. type PermissionChecker func(rctx request.CTX, userID string, permission *model.Permission) bool // AccessControlAttributeValidationHook validates and sanitizes property field attributes // and values for managed property groups. It owns the full attr pipeline // for these groups: // // - validates field Name against the CEL-safe identifier rules // ([model.ValidateCPAFieldName]); on update this fires only when Name // actually changes, so pre-existing fields with non-conforming names // remain editable on all other attrs (lenient grandfather) // - trims whitespace on string attrs // - applies the visibility default when unset // - clears attrs that don't apply to the field type (options on non-select, // ldap/saml on non-text fields) // - auto-assigns IDs to options that lack one and validates option shape // - validates visibility, value_type, managed, display_name, and sort_order // - validates required and editable, removing either when explicitly unset // - validates and canonicalizes actions, the render-placement allow-list // shared by the classification banner and channel labels; on update this // fires only when actions actually change, so a field carrying a value // that is no longer valid stays editable on all other attrs (lenient // grandfather, as for Name) // - validates property values for text fields against value_type // constraints (email, url, phone) // - enforces that managed="admin" can only be set by callers with // PermissionManageSystem, and keeps PermissionValues in sync with the // managed attribute // // The hook only applies to groups whose IDs are in managedGroupIDs. type AccessControlAttributeValidationHook struct { BasePropertyHook propertyService *PropertyService managedGroupIDs map[string]struct{} permissionChecker PermissionChecker } var _ PropertyHook = (*AccessControlAttributeValidationHook)(nil) // NewAccessControlAttributeValidationHook creates a hook that validates field attributes and // values for the given property groups. func NewAccessControlAttributeValidationHook(ps *PropertyService, permChecker PermissionChecker, managedGroupIDs ...string) *AccessControlAttributeValidationHook { ids := make(map[string]struct{}, len(managedGroupIDs)) for _, id := range managedGroupIDs { ids[id] = struct{}{} } return &AccessControlAttributeValidationHook{ propertyService: ps, managedGroupIDs: ids, permissionChecker: permChecker, } } func (h *AccessControlAttributeValidationHook) isGroupManaged(groupID string) bool { _, ok := h.managedGroupIDs[groupID] return ok } // sanitizeAndValidateFieldAttrs trims string attrs, applies the visibility // default, clears attrs that don't apply to the field type, validates each // attr, and auto-IDs+validates options for select-shaped fields. Mutates // field.Attrs in place. prevType is the field's type before this operation. // prevType is empty on creation of a new field. prevActions is the field's // attrs["actions"] before this operation, nil on creation. func (h *AccessControlAttributeValidationHook) sanitizeAndValidateFieldAttrs(field *model.PropertyField, prevType model.PropertyFieldType, prevActions any) error { if field.Attrs == nil { field.Attrs = model.StringInterface{} } for _, key := range trimmedFieldAttrKeys { if v, ok := field.Attrs[key].(string); ok { field.Attrs[key] = strings.TrimSpace(v) } } if v, _ := field.Attrs[model.PropertyFieldAttrVisibility].(string); v == "" { field.Attrs[model.PropertyFieldAttrVisibility] = model.PropertyFieldVisibilityWhenSet } // Type-based attr clearing: select-shaped fields keep options, only text // supports external sync, and admin-managed fields can never be synced // (mutual exclusivity). isSelect := field.Type.SupportsOptions() isText := field.Type == model.PropertyFieldTypeText managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string) if !isSelect { delete(field.Attrs, model.PropertyFieldAttributeOptions) } if !isText { delete(field.Attrs, model.PropertyFieldAttrLDAP) delete(field.Attrs, model.PropertyFieldAttrSAML) } if err := model.ValidatePropertyFieldVisibility(field); err != nil { return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs) } if isText { if vt, _ := field.Attrs[model.PropertyFieldAttrValueType].(string); vt != "" && !model.IsValidPropertyFieldValueType(vt) { return fmt.Errorf("invalid value_type %q: %w", vt, ErrInvalidFieldAttrs) } } if managed != "" && managed != "admin" { return fmt.Errorf("invalid managed %q (must be empty or %q): %w", managed, "admin", ErrInvalidFieldAttrs) } if err := h.sanitizeAndValidateOwners(field); err != nil { return err } if dn, _ := field.Attrs[model.PropertyFieldAttrDisplayName].(string); utf8.RuneCountInString(dn) > model.PropertyFieldNameMaxRunes { return fmt.Errorf("display_name exceeds max length of %d runes: %w", model.PropertyFieldNameMaxRunes, ErrInvalidFieldAttrs) } if isSelect { if err := h.sanitizeAndValidateOptions(field, prevType); err != nil { return err } } if err := model.ValidatePropertyFieldSortOrder(field); err != nil { return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs) } for _, key := range boolFieldAttrKeys { if err := model.SanitizeAndValidatePropertyFieldBoolAttr(field, key); err != nil { return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs) } } if err := model.SanitizeAndValidatePropertyFieldChangePolicy(field); err != nil { return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs) } // Lenient grandfather, same rationale as Name: a PATCH merges attrs, so a // field carrying an actions value that predates (or has since fallen out of) // the allow-list would otherwise be unpatchable on every other attr, with // delete/recreate the only way out. Only a caller actually changing actions // gets the strict check; an untouched value rides along as-is. if err := model.SanitizeAndValidatePropertyFieldActions(field); err != nil { if !sameFieldActions(field.Attrs[model.PropertyFieldAttrActions], prevActions) { return fmt.Errorf("%s: %w", err.Error(), ErrInvalidFieldAttrs) } } return nil } // sameFieldActions reports whether two raw attrs["actions"] values carry the // same list. Anything that isn't a list of strings compares unequal, so a // caller sending a malformed value is still rejected rather than grandfathered. func sameFieldActions(a, b any) bool { as, aOK := fieldActionsAsStrings(a) bs, bOK := fieldActionsAsStrings(b) return aOK && bOK && slices.Equal(as, bs) } func fieldActionsAsStrings(raw any) ([]string, bool) { switch v := raw.(type) { case nil: return nil, true case []string: return v, true case []any: out := make([]string, 0, len(v)) for _, elem := range v { s, ok := elem.(string) if !ok { return nil, false } out = append(out, s) } return out, true } return nil, false } // trimmedFieldAttrKeys lists the string-valued attrs the hook trims on the // way in. Listed explicitly rather than iterating Attrs to avoid touching // keys this hook doesn't own (e.g. plugin-set attrs). var trimmedFieldAttrKeys = []string{ model.PropertyFieldAttrVisibility, model.PropertyFieldAttrValueType, model.PropertyFieldAttrManaged, model.PropertyFieldAttrLDAP, model.PropertyFieldAttrSAML, model.PropertyFieldAttrDisplayName, } // boolFieldAttrKeys lists the boolean-valued attrs the hook validates. No // object_type branch: scoping comes from the field's ObjectType, as for every // other key. var boolFieldAttrKeys = []string{ model.PropertyFieldAttrRequired, model.PropertyFieldAttrEditable, } // sanitizeAndValidateOptions auto-assigns IDs to options without one, // validates the resulting shape, and writes the options back in the // canonical attrs form ([]any of map[string]any) that the rest of the // codebase expects (see PropertyField.EnsureOptionIDs). The typed slice // is used internally for validation only; persisting it would force // every downstream reader of attrs["options"] to handle two shapes. func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOptions(field *model.PropertyField, prevType model.PropertyFieldType) error { rawOptions, ok := field.Attrs[model.PropertyFieldAttributeOptions] if !ok || rawOptions == nil { return nil } data, err := json.Marshal(rawOptions) if err != nil { return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs) } var options model.PropertyOptions[*model.CustomProfileAttributesSelectOption] if err = json.Unmarshal(data, &options); err != nil { return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs) } // Rank handling. // // For non-rank fields, rank is not meaningful, but we keep any values that // are present instead of stripping them: that way a rank->select->rank // round-trip preserves the option ordering. The values ride along // untouched and unvalidated (zero, negative, and duplicate ranks are all // fine here); a later conversion into rank normalizes whatever it finds. // // For rank fields, a conversion from another type first repairs the // incoming ranks: they come from an arbitrary source (plain select options, // API-set values) and may be missing, duplicated, or non-sequential, so we // renumber them to a clean, gap-free 1..N sequence preserving relative // order rather than rejecting the request. Authoring a rank field directly // (create, or a rank->rank edit) does not repair — the caller owns the // values. // // validateRankOptions then runs on every rank path and is the single source // of truth for what a persisted rank field may contain. For direct // authoring it rejects the caller's mistake; after a normalize it is a // postcondition check that fails closed if normalization and validation // ever diverge. That belt-and-suspenders matters here because invalid rank // data fails open — it silently denies access at read time (see the matview // and buildRankMap) instead of erroring — so we must never persist it. if field.Type == model.PropertyFieldTypeRank { if prevType != "" && prevType != model.PropertyFieldTypeRank { normalizeOptionRanks(options) } if err = validateRankOptions(options); err != nil { return err } } for i := range options { if options[i].ID == "" { options[i].ID = model.NewId() } } if err = options.IsValid(); err != nil { return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs) } normalized, err := json.Marshal(options) if err != nil { return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs) } var canonical []any if err = json.Unmarshal(normalized, &canonical); err != nil { return fmt.Errorf("invalid options: %s: %w", err, ErrInvalidFieldAttrs) } field.Attrs[model.PropertyFieldAttributeOptions] = canonical return nil } // sanitizeAndValidateOwners normalizes and validates the owners attr on a // field. Each entry is trimmed and must be well-formed ({id, type, scopes} // with a recognized type); scopes are trimmed and deduped; duplicate owner // entries (same type+id) are merged. Owners may not be combined with // managed="admin". An empty or absent list is removed so HasPropertyFieldOwners // stays false. The normalized list is written back in canonical form // ([]any of maps) so downstream readers see a single shape. // // Defensive bounds (see the Property Owner* constants) cap the id and scope // lengths and the number of owners/scopes so a buggy or hostile owner cannot // bloat the Attrs blob. They are deliberately far above real usage. // // An unrecognized owner type is rejected here, but an owner that simply does // not correspond to any real installed plugin/service is intentionally // accepted: it is a harmless no-op (the field stays locked to its real owners), // so the system fails safe and access is checked at the moment of action. func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOwners(field *model.PropertyField) error { raw, ok := field.Attrs[model.PropertyAttrsOwners] if !ok || raw == nil { return nil } data, err := json.Marshal(raw) if err != nil { return fmt.Errorf("invalid owners: %s: %w", err, ErrInvalidFieldAttrs) } var owners []model.PropertyOwner if err = json.Unmarshal(data, &owners); err != nil { return fmt.Errorf("invalid owners: %s: %w", err, ErrInvalidFieldAttrs) } if len(owners) == 0 { delete(field.Attrs, model.PropertyAttrsOwners) return nil } if managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string); managed == "admin" { return fmt.Errorf("owners cannot be combined with managed=admin: %w", ErrInvalidFieldAttrs) } normalized := make([]model.PropertyOwner, 0, len(owners)) indexByKey := make(map[string]int, len(owners)) for _, owner := range owners { owner.ID = strings.TrimSpace(owner.ID) owner.Type = strings.TrimSpace(owner.Type) if owner.ID == "" { return fmt.Errorf("invalid owners: owner id cannot be empty: %w", ErrInvalidFieldAttrs) } if utf8.RuneCountInString(owner.ID) > model.PropertyOwnerIDMaxRunes { return fmt.Errorf("invalid owners: owner id exceeds max length of %d runes: %w", model.PropertyOwnerIDMaxRunes, ErrInvalidFieldAttrs) } if !model.IsValidPropertyOwnerType(owner.Type) { return fmt.Errorf("invalid owners: unknown owner type %q: %w", owner.Type, ErrInvalidFieldAttrs) } scopes := make([]string, 0, len(owner.Scopes)) for _, scope := range owner.Scopes { scope = strings.TrimSpace(scope) if scope == "" || slices.Contains(scopes, scope) { continue } if utf8.RuneCountInString(scope) > model.PropertyOwnerScopeMaxRunes { return fmt.Errorf("invalid owners: scope exceeds max length of %d runes: %w", model.PropertyOwnerScopeMaxRunes, ErrInvalidFieldAttrs) } if !model.IsValidPropertyOwnerScope(scope) { return fmt.Errorf("invalid owners: scope %q contains invalid characters: %w", scope, ErrInvalidFieldAttrs) } scopes = append(scopes, scope) } owner.Scopes = scopes key := owner.Type + "\x00" + owner.ID if idx, dup := indexByKey[key]; dup { for _, scope := range owner.Scopes { if !slices.Contains(normalized[idx].Scopes, scope) { normalized[idx].Scopes = append(normalized[idx].Scopes, scope) } } continue } indexByKey[key] = len(normalized) normalized = append(normalized, owner) } if len(normalized) > model.PropertyOwnersMaxPerField { return fmt.Errorf("invalid owners: too many owners (%d), max is %d: %w", len(normalized), model.PropertyOwnersMaxPerField, ErrInvalidFieldAttrs) } for _, owner := range normalized { if len(owner.Scopes) > model.PropertyOwnerScopesMax { return fmt.Errorf("invalid owners: owner %q has too many scopes (%d), max is %d: %w", owner.ID, len(owner.Scopes), model.PropertyOwnerScopesMax, ErrInvalidFieldAttrs) } } out, err := json.Marshal(normalized) if err != nil { return fmt.Errorf("invalid owners: %s: %w", err, ErrInvalidFieldAttrs) } var canonical []any if err = json.Unmarshal(out, &canonical); err != nil { return fmt.Errorf("invalid owners: %s: %w", err, ErrInvalidFieldAttrs) } field.Attrs[model.PropertyAttrsOwners] = canonical return nil } // validateRankOptions enforces that every option on a rank field carries a // positive, unique rank. It is the single source of truth for rank validity // and runs on every rank path: it rejects a directly-authored field (create or // rank->rank edit) whose ranks the caller got wrong, and double-checks the // output of normalizeOptionRanks on a conversion (which should always pass, so // a failure there signals the two have diverged and must not be persisted). func validateRankOptions(options model.PropertyOptions[*model.CustomProfileAttributesSelectOption]) error { ranks := make(map[int]struct{}, len(options)) for i, opt := range options { if opt.Rank == nil { return fmt.Errorf("invalid options: option at index %d is missing rank for rank field: %w", i, ErrInvalidFieldAttrs) } rank := *opt.Rank if rank <= 0 { return fmt.Errorf("invalid options: option rank must be a positive integer, got %d at index %d: %w", rank, i, ErrInvalidFieldAttrs) } if _, exists := ranks[rank]; exists { return fmt.Errorf("invalid options: duplicate option rank %d at index %d: %w", rank, i, ErrInvalidFieldAttrs) } ranks[rank] = struct{}{} } return nil } // normalizeOptionRanks rewrites every option's rank to a contiguous 1..N // sequence, preserving relative order, when an arbitrary set of options is // converted into a rank field. Options are stable-sorted by their current rank // so that existing order is kept and duplicate ranks break ties by array // position; options with no rank sort last (a newly-added option never // silently outranks the existing hierarchy). The result is the gap-free, // duplicate-free numbering the rank UI expects, and is idempotent: an already // 1..N field re-normalizes to itself. Mutates the options in place. func normalizeOptionRanks(options model.PropertyOptions[*model.CustomProfileAttributesSelectOption]) { order := make([]int, len(options)) for i := range options { order[i] = i } slices.SortStableFunc(order, func(a, b int) int { return cmp.Compare(rankSortKey(options[a].Rank), rankSortKey(options[b].Rank)) }) for seq, idx := range order { rank := seq + 1 options[idx].Rank = &rank } } // rankSortKey maps an option's rank to its sort key, treating a missing rank as // the largest possible value so rank-less options sort after ranked ones. func rankSortKey(rank *int) int { if rank == nil { return math.MaxInt } return *rank } // enforceGroupPermissions pins schema-edit permissions for fields in // managed groups and applies the managed=admin upgrade to PermissionValues: // - PermissionField and PermissionOptions are always set to sysadmin so // that only admins can modify field definitions and options. // - When managed="admin", PermissionValues is set to sysadmin. This is // gated on PermissionManageSystem; callers without an identifiable // caller ID (e.g. internal callers with no session on rctx) are // treated as non-admin and rejected. // - When the field is owner-managed, PermissionValues is pinned to sysadmin. // Human value writes are already blocked authoritatively by // checkOwnerValueWriteAccess in the property-service hook, but pinning // sysadmin here is the safe fallback: if the owners list is ever dropped, // the field defaults to admin-only rather than becoming writable by every // member. // - Otherwise, PermissionValues is left as-is when set, and default-filled // by ObjectType when nil (member for user fields, sysadmin for system // and template). Caller pins are never downgraded. func (h *AccessControlAttributeValidationHook) enforceGroupPermissions(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) { sysadmin := model.PermissionLevelSysadmin if managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string); managed == "admin" { // Verify the caller has admin privileges. Default-deny if the // permission checker isn't wired up or if the caller is // unidentifiable — we never silently promote to sysadmin. if h.permissionChecker == nil { return nil, fmt.Errorf("missing permission to set managed=admin: no permission checker configured: %w", ErrAdminRequired) } callerID := h.propertyService.extractCallerID(rctx) if callerID == "" || !h.permissionChecker(rctx, callerID, model.PermissionManageSystem) { return nil, fmt.Errorf("missing permission to set managed=admin: only system admins can set managed=admin: %w", ErrAdminRequired) } field.PermissionValues = &sysadmin } else if model.HasPropertyFieldOwners(field) { field.PermissionValues = &sysadmin } else if field.PermissionValues == nil { defaultLevel := defaultPermissionValuesForObjectType(field.ObjectType) field.PermissionValues = &defaultLevel } // Fields in managed groups always require sysadmin for field/options edits. field.PermissionField = &sysadmin field.PermissionOptions = &sysadmin return field, nil } // defaultPermissionValuesForObjectType returns the PermissionValues level a // field should default to when the caller doesn't pin one. User fields are // member-writable so users can set their own values; system and template // fields attach to admin-owned scopes and require sysadmin. Channel fields // default to admin: unlike a user field, a channel attribute is shared state // that any member reading the channel would otherwise be able to overwrite, // and these values are expected to feed access-control policy decisions, so // the safer default is channel-admin (or sysadmin) rather than membership. func defaultPermissionValuesForObjectType(objectType string) model.PermissionLevel { switch objectType { case model.PropertyFieldObjectTypeSystem, model.PropertyFieldObjectTypeTemplate: return model.PermissionLevelSysadmin case model.PropertyFieldObjectTypeChannel: return model.PermissionLevelAdmin default: return model.PermissionLevelMember } } func (h *AccessControlAttributeValidationHook) PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) { if !h.isGroupManaged(field.GroupID) { return field, nil } // Names in managed groups are referenced from ABAC policy expressions // (user.attributes.), so they must satisfy the CEL grammar and // avoid CEL reserved words. Returning the AppError directly preserves // its specific i18n key through the HTTP layer's mapPropertyServiceError // fallback (no sentinel wrap). if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil { return nil, appErr } // Create: no prior type or actions, so a rank field here is authored directly // and its ranks are validated strictly rather than repaired, and actions get // the strict check with nothing to grandfather. if err := h.sanitizeAndValidateFieldAttrs(field, "", nil); err != nil { return nil, err } return h.enforceGroupPermissions(rctx, field) } func (h *AccessControlAttributeValidationHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) { if !h.isGroupManaged(groupID) { return field, nil } // Lenient grandfather: only validate Name against CEL rules when it // actually changes, so pre-existing fields whose names predate this // validation remain editable on all other attrs. existing, err := h.propertyService.getPropertyField(rctx, groupID, field.ID) if err != nil { return nil, err } if existing.Name != field.Name { if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil { return nil, appErr } } if err := h.sanitizeAndValidateFieldAttrs(field, existing.Type, existing.Attrs[model.PropertyFieldAttrActions]); err != nil { return nil, err } return h.enforceGroupPermissions(rctx, field) } func (h *AccessControlAttributeValidationHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) { if len(fields) == 0 || !h.isGroupManaged(groupID) { return fields, nil } // Single batched read for the lenient-grandfather name check; a missing // ID falls through to the store, which surfaces the not-found error. fieldIDs := make([]string, len(fields)) for i, f := range fields { fieldIDs[i] = f.ID } existingFields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDs) if err != nil { return nil, err } existingByID := make(map[string]*model.PropertyField, len(existingFields)) for _, ex := range existingFields { existingByID[ex.ID] = ex } for i, field := range fields { existing := existingByID[field.ID] if existing != nil && existing.Name != field.Name { if appErr := model.ValidateCPAFieldName(field.Name); appErr != nil { return nil, fmt.Errorf("field %s: %w", field.ID, appErr) } } // prevType stays empty when the field isn't found (the store surfaces // the not-found error later); strict rank validation is the safe // default for that path. var prevType model.PropertyFieldType var prevActions any if existing != nil { prevType = existing.Type prevActions = existing.Attrs[model.PropertyFieldAttrActions] } if err := h.sanitizeAndValidateFieldAttrs(field, prevType, prevActions); err != nil { return nil, fmt.Errorf("field %s: %w", field.ID, err) } updated, err := h.enforceGroupPermissions(rctx, field) if err != nil { return nil, fmt.Errorf("field %s: %w", field.ID, err) } fields[i] = updated } return fields, nil } // extractOptionIDs extracts the set of valid option IDs from a // select or multiselect PropertyField's attrs. Returns nil if the // field has no options. func extractOptionIDs(field *model.PropertyField) (map[string]struct{}, error) { if field.Attrs == nil { return nil, nil } rawOptions, ok := field.Attrs[model.PropertyFieldAttributeOptions] if !ok || rawOptions == nil { return nil, nil } data, err := json.Marshal(rawOptions) if err != nil { return nil, fmt.Errorf("failed to marshal options: %w", err) } var options []struct { ID string `json:"id"` } if err := json.Unmarshal(data, &options); err != nil { return nil, fmt.Errorf("invalid options format: %w", err) } ids := make(map[string]struct{}, len(options)) for _, opt := range options { if opt.ID != "" { ids[opt.ID] = struct{}{} } } return ids, nil } // validateValueAgainstField checks a property value against field-type // constraints: // - text: max length, value_type format (email, url, phone) // - select: option ID must exist in the field's options // - multiselect: all option IDs must exist // - user: value must be a valid Mattermost ID // - multiuser: all values must be valid Mattermost IDs func (h *AccessControlAttributeValidationHook) validateValueAgainstField(field *model.PropertyField, value *model.PropertyValue) error { switch field.Type { case model.PropertyFieldTypeText: var str string if err := json.Unmarshal(value.Value, &str); err != nil { return fmt.Errorf("expected string value: %w", err) } if len(strings.TrimSpace(str)) > model.PropertyFieldValueTypeTextMaxLength { return fmt.Errorf("text value exceeds maximum length of %d characters", model.PropertyFieldValueTypeTextMaxLength) } valueType := model.GetPropertyFieldValueType(field) if valueType == "" { return nil } return model.ValidatePropertyValueForValueType(valueType, value.Value) case model.PropertyFieldTypeSelect, model.PropertyFieldTypeRank: var str string if err := json.Unmarshal(value.Value, &str); err != nil { return fmt.Errorf("expected string value for select field: %w", err) } if str == "" { return nil } optionIDs, err := extractOptionIDs(field) if err != nil { return fmt.Errorf("failed to extract options: %w", err) } if _, ok := optionIDs[str]; !ok { return fmt.Errorf("option %q does not exist", str) } case model.PropertyFieldTypeMultiselect: var values []string if err := json.Unmarshal(value.Value, &values); err != nil { return fmt.Errorf("expected string array value for multiselect field: %w", err) } optionIDs, err := extractOptionIDs(field) if err != nil { return fmt.Errorf("failed to extract options: %w", err) } for _, v := range values { if _, ok := optionIDs[v]; !ok { return fmt.Errorf("option %q does not exist", v) } } case model.PropertyFieldTypeUser: var str string if err := json.Unmarshal(value.Value, &str); err != nil { return fmt.Errorf("expected string value for user field: %w", err) } if str != "" && !model.IsValidId(str) { return fmt.Errorf("invalid user id") } case model.PropertyFieldTypeMultiuser: var values []string if err := json.Unmarshal(value.Value, &values); err != nil { return fmt.Errorf("expected string array value for multiuser field: %w", err) } for _, v := range values { if !model.IsValidId(v) { return fmt.Errorf("invalid user id: %s", v) } } } return nil } func (h *AccessControlAttributeValidationHook) validateValues(rctx request.CTX, values []*model.PropertyValue) error { if len(values) == 0 { return nil } groupID := values[0].GroupID if !h.isGroupManaged(groupID) { return nil } // Collect unique field IDs fieldIDSet := make(map[string]struct{}) for _, v := range values { fieldIDSet[v.FieldID] = struct{}{} } fieldIDs := make([]string, 0, len(fieldIDSet)) for id := range fieldIDSet { fieldIDs = append(fieldIDs, id) } fields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDs) if err != nil { return fmt.Errorf("failed to fetch fields for validation: %w", err) } fieldMap := make(map[string]*model.PropertyField, len(fields)) for _, f := range fields { fieldMap[f.ID] = f } for _, value := range values { field, ok := fieldMap[value.FieldID] if !ok { return fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound) } if err := h.validateValueAgainstField(field, value); err != nil { return fmt.Errorf("field %s: %s: %w", value.FieldID, err.Error(), ErrInvalidValue) } if model.IsPropertyFieldRequired(field) && model.IsEmptyPropertyValue(value.Value) { return newRequiredValueError(field) } } return h.validateChangePolicy(groupID, values, fieldMap) } // validateChangePolicy enforces attrs.change_policy: a value may be set once, // and after that may only move in the direction its policy allows. It runs on // every write path into the group, so a direct API call is refused the same way // the UI refuses to offer the edit. // // Only channel targets are covered. The same group holds user attributes, which // LDAP/SAML sync rewrites on login; locking those would break the sync. Other // object types are a separate decision. // // This is a read-then-write with no transaction around it, so two concurrent // first writes can both pass. That is the window the rest of the property system // already lives with, and the loser overwrites a value set milliseconds earlier. func (h *AccessControlAttributeValidationHook) validateChangePolicy(groupID string, values []*model.PropertyValue, fieldMap map[string]*model.PropertyField) error { // Grouped by target so one search covers every governed field on a channel: // PropertyValueSearchOpts carries a single FieldID, so a per-field lookup // would cost a round trip each. "any" is the default and the common case, // so an ordinary write does no query at all. governed := map[string][]*model.PropertyValue{} for _, value := range values { if value.TargetType != model.PropertyValueTargetTypeChannel { continue } field, ok := fieldMap[value.FieldID] if !ok || model.GetPropertyFieldChangePolicy(field) == model.PropertyFieldChangePolicyAny { continue } governed[value.TargetID] = append(governed[value.TargetID], value) } for targetID, pending := range governed { existing, err := h.getValuesForTarget(groupID, model.PropertyValueTargetTypeChannel, targetID) if err != nil { return err } existingByFieldID := make(map[string]*model.PropertyValue, len(existing)) for _, value := range existing { existingByFieldID[value.FieldID] = value } for _, value := range pending { old, ok := existingByFieldID[value.FieldID] // A user-initiated clear leaves a null-valued row behind rather than // deleting it, so the presence of a row is not proof of a set value. if !ok || model.IsEmptyPropertyValue(old.Value) { continue } if err := checkValueMove(fieldMap[value.FieldID], old.Value, value.Value); err != nil { return err } } } return nil } // checkValueMove decides whether a field's policy permits replacing old with // next. Both are known non-empty on the old side; next may be empty, which is a // clear. func checkValueMove(field *model.PropertyField, old, next json.RawMessage) error { policy := model.GetPropertyFieldChangePolicy(field) if policy == model.PropertyFieldChangePolicyNever { return newChangePolicyError(field, policy) } // Clearing is the largest downgrade there is, and re-setting afterwards // would launder a value straight past a directional policy. if model.IsEmptyPropertyValue(next) { return newChangePolicyError(field, policy) } ranks := buildOptionRankMap(field) oldRank, oldOK := singleOptionRank(field.Type, old, ranks) newRank, newOK := singleOptionRank(field.Type, next, ranks) // Fail closed: an option deleted from the field leaves a comparison that // cannot be made, and an unresolvable comparison on a marking must not // default to permitted. if !oldOK || !newOK { return newChangePolicyError(field, policy) } // Higher rank is higher, matching filterSharedOnlyRankFieldOptions, which // exposes every option at or below the caller's own rank. switch policy { case model.PropertyFieldChangePolicyRaiseOnly: if newRank > oldRank { return nil } case model.PropertyFieldChangePolicyLowerOnly: if newRank < oldRank { return nil } } return newChangePolicyError(field, policy) } // singleOptionRank resolves the rank of the one option a rank value carries. A // rank field is select-shaped, so anything other than exactly one known option // is unresolvable. func singleOptionRank(fieldType model.PropertyFieldType, raw json.RawMessage, ranks map[string]int) (int, bool) { optionIDs, err := extractOptionIDsFromValue(fieldType, raw) if err != nil || len(optionIDs) != 1 { return 0, false } for optionID := range optionIDs { rank, ok := ranks[optionID] return rank, ok } return 0, false } // newChangePolicyError returns the refusal for a policy. The AppError is // returned rather than a sentinel so its specific i18n key survives the HTTP // layer's mapPropertyServiceError fallback, the same way the CEL name errors do. // // Each key is written inline: i18n-extract only sees IDs passed literally to // NewAppError, and drops any it cannot find from en.json. func newChangePolicyError(field *model.PropertyField, policy string) error { details := fmt.Sprintf("field %s: change policy %q does not permit this change", field.ID, policy) switch policy { case model.PropertyFieldChangePolicyRaiseOnly: return model.NewAppError("UpsertPropertyValues", "app.property_value.change_policy.raise_only.app_error", nil, details, http.StatusForbidden) case model.PropertyFieldChangePolicyLowerOnly: return model.NewAppError("UpsertPropertyValues", "app.property_value.change_policy.lower_only.app_error", nil, details, http.StatusForbidden) default: return model.NewAppError("UpsertPropertyValues", "app.property_value.change_policy.never.app_error", nil, details, http.StatusForbidden) } } // newRequiredValueError returns the refusal for writing or deleting an empty // value on a required field. func newRequiredValueError(field *model.PropertyField) error { details := fmt.Sprintf("field %s: value is required", field.ID) return model.NewAppError("UpsertPropertyValues", "app.property_value.required.app_error", nil, details, http.StatusBadRequest) } // getValuesForTarget loads every value stored against one target, paging with // the same bounds as the access-control lookups. func (h *AccessControlAttributeValidationHook) getValuesForTarget(groupID, targetType, targetID string) ([]*model.PropertyValue, error) { allValues := []*model.PropertyValue{} var cursor model.PropertyValueSearchCursor for iterations := 0; ; iterations++ { if iterations >= propertyAccessMaxPaginationIterations { return nil, fmt.Errorf("exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations) } opts := model.PropertyValueSearchOpts{ TargetType: targetType, TargetIDs: []string{targetID}, PerPage: propertyAccessPaginationPageSize, } if !cursor.IsEmpty() { opts.Cursor = cursor } values, err := h.propertyService.searchPropertyValues(groupID, opts) if err != nil { return nil, fmt.Errorf("failed to load existing values for target %s: %w", targetID, err) } allValues = append(allValues, values...) if len(values) < propertyAccessPaginationPageSize { break } lastValue := values[len(values)-1] cursor = model.PropertyValueSearchCursor{ PropertyValueID: lastValue.ID, CreateAt: lastValue.CreateAt, } } return allValues, nil } func (h *AccessControlAttributeValidationHook) PreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) { if err := h.validateValues(rctx, []*model.PropertyValue{value}); err != nil { return nil, err } return value, nil } func (h *AccessControlAttributeValidationHook) PreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if err := h.validateValues(rctx, values); err != nil { return nil, err } return values, nil } func (h *AccessControlAttributeValidationHook) PreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) { if err := h.validateValues(rctx, []*model.PropertyValue{value}); err != nil { return nil, err } return value, nil } func (h *AccessControlAttributeValidationHook) PreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if err := h.validateValues(rctx, values); err != nil { return nil, err } return values, nil } func (h *AccessControlAttributeValidationHook) PreUpdatePropertyValue(rctx request.CTX, _ string, value *model.PropertyValue) (*model.PropertyValue, error) { if err := h.validateValues(rctx, []*model.PropertyValue{value}); err != nil { return nil, err } return value, nil } func (h *AccessControlAttributeValidationHook) PreUpdatePropertyValues(rctx request.CTX, _ string, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if err := h.validateValues(rctx, values); err != nil { return nil, err } return values, nil } // PreDeletePropertyValue closes the gap a literal delete would otherwise leave in // change_policy: a delete is indistinguishable from a clear, which every // non-"any" policy already refuses on the upsert path. Without this, deleting a // locked value and writing a fresh one would launder it past its policy. func (h *AccessControlAttributeValidationHook) PreDeletePropertyValue(rctx request.CTX, groupID string, id string) error { if !h.isGroupManaged(groupID) { return nil } value, err := h.propertyService.getPropertyValue(groupID, id) if err != nil { // Nothing to enforce against a value that cannot be found; the delete // itself will fail downstream with the appropriate not-found error. return nil } return h.refuseGovernedDelete(rctx, groupID, []*model.PropertyValue{value}) } // PreDeletePropertyValuesForTarget covers the same gap for a target-wide delete // (e.g. via the plugin API). Other target types are untouched -- see // validateChangePolicy for why enforcement is scoped to channels. func (h *AccessControlAttributeValidationHook) PreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error { if targetType != model.PropertyValueTargetTypeChannel || !h.isGroupManaged(groupID) { return nil } values, err := h.getValuesForTarget(groupID, targetType, targetID) if err != nil { return err } return h.refuseGovernedDelete(rctx, groupID, values) } // refuseGovernedDelete refuses to delete any set channel value that is required, // or governed by a non-"any" change policy. func (h *AccessControlAttributeValidationHook) refuseGovernedDelete(rctx request.CTX, groupID string, values []*model.PropertyValue) error { if !h.isGroupManaged(groupID) { return nil } setValues := make([]*model.PropertyValue, 0, len(values)) for _, v := range values { if !model.IsEmptyPropertyValue(v.Value) { setValues = append(setValues, v) } } if len(setValues) == 0 { return nil } fieldIDSet := make(map[string]struct{}) for _, v := range setValues { fieldIDSet[v.FieldID] = struct{}{} } fieldIDs := make([]string, 0, len(fieldIDSet)) for id := range fieldIDSet { fieldIDs = append(fieldIDs, id) } fields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDs) if err != nil { return fmt.Errorf("failed to fetch fields for delete validation: %w", err) } fieldMap := make(map[string]*model.PropertyField, len(fields)) for _, f := range fields { fieldMap[f.ID] = f } for _, v := range setValues { field, ok := fieldMap[v.FieldID] if !ok { continue } // Required is enforced for every target type, matching the write path // in validateValues. change_policy stays channel-only -- see // validateChangePolicy for why. if model.IsPropertyFieldRequired(field) { return newRequiredValueError(field) } if v.TargetType == model.PropertyValueTargetTypeChannel && model.GetPropertyFieldChangePolicy(field) != model.PropertyFieldChangePolicyAny { return newChangePolicyError(field, model.GetPropertyFieldChangePolicy(field)) } } return nil }