// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package app import ( "encoding/json" "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" ) func (a *App) resolveValueBroadcastParams(rctx request.CTX, objectType, targetID string) (teamID, channelID string, err *model.AppError) { switch objectType { case model.PropertyFieldObjectTypePost: post, appErr := a.GetSinglePost(rctx, targetID, false) if appErr != nil { return "", "", appErr } return "", post.ChannelId, nil case model.PropertyFieldObjectTypeChannel: return "", targetID, nil case model.PropertyFieldObjectTypeUser: return "", "", nil // system-wide case model.PropertyFieldObjectTypeSystem: return "", "", nil // system-wide default: return "", "", model.NewAppError( "resolveValueBroadcastParams", "app.property_value.resolve_broadcast_params.unknown_object_type.app_error", map[string]any{"ObjectType": objectType}, "unrecognized object type", http.StatusBadRequest, ) } } // invalidateUserPropertyValuesEpochs marks the AttributeView stale and drops the cached CPA epoch // for every distinct user the values target. It keys off each value's own TargetType/TargetID // rather than a caller-supplied object type, to stay correct for plugin callers that pass none. // // Both halves must move together: the epoch alone forces a refetch that renders from a stale view // and then caches that under the new ETag, and the view alone leaves the client 304ing on content // sanitized under the old attributes. func (a *App) invalidateUserPropertyValuesEpochs(values []*model.PropertyValue) { seen := make(map[string]bool, len(values)) for _, v := range values { if v == nil || v.TargetType != model.PropertyFieldObjectTypeUser || seen[v.TargetID] { continue } seen[v.TargetID] = true } if len(seen) == 0 { return } a.invalidateAttributeViewCache() // Only the epoch half is gated: nothing reads it while ABAC is off, and each invalidation // costs a cluster message. A flip clears both caches wholesale, so nothing skipped here // survives as a stale entry. if !a.attributeBasedAccessControlEnabled() { return } for targetID := range seen { a.Srv().Store().Attributes().InvalidateUserPropertyValuesEpoch(targetID) } } // invalidateUserPropertyValuesEpoch is the single-target form, for delete paths that have a target // ID rather than a set of values. Same split gate as above. func (a *App) invalidateUserPropertyValuesEpoch(targetType, targetID string) { if targetType != model.PropertyFieldObjectTypeUser { return } a.invalidateAttributeViewCache() if !a.attributeBasedAccessControlEnabled() { return } a.Srv().Store().Attributes().InvalidateUserPropertyValuesEpoch(targetID) } // invalidateAllUserAttributeCaches marks the AttributeView stale and drops every cached CPA epoch. // For field mutations, which change what every subject resolves to without touching a single // PropertyValues row, and so are invisible to an epoch computed from that table. func (a *App) invalidateAllUserAttributeCaches() { a.invalidateAttributeViewCache() if !a.attributeBasedAccessControlEnabled() { return } a.Srv().Store().Attributes().ClearUserPropertyValuesEpochCache() } // CreatePropertyValue creates a new property value. func (a *App) CreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) { if value == nil { return nil, model.NewAppError("CreatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest) } value.Value = model.SanitizePropertyValue(value.Value) createdValue, err := a.Srv().propertyService.CreatePropertyValue(rctx, value) if err != nil { if appErr := mapPropertyServiceError("CreatePropertyValue", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("CreatePropertyValue", "app.property_value.create.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{createdValue}) return createdValue, nil } // CreatePropertyValues creates multiple property values. func (a *App) CreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, *model.AppError) { if len(values) == 0 { return nil, model.NewAppError("CreatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest) } for _, v := range values { v.Value = model.SanitizePropertyValue(v.Value) } createdValues, err := a.Srv().propertyService.CreatePropertyValues(rctx, values) if err != nil { if appErr := mapPropertyServiceError("CreatePropertyValues", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("CreatePropertyValues", "app.property_value.create_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs(createdValues) return createdValues, nil } // GetPropertyValue retrieves a property value by group ID and value ID. func (a *App) GetPropertyValue(rctx request.CTX, groupID, valueID string) (*model.PropertyValue, *model.AppError) { value, err := a.Srv().propertyService.GetPropertyValue(rctx, groupID, valueID) if err != nil { if appErr := mapPropertyServiceError("GetPropertyValue", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("GetPropertyValue", "app.property_value.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return value, nil } // GetPropertyValues retrieves multiple property values by their IDs. func (a *App) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, *model.AppError) { values, err := a.Srv().propertyService.GetPropertyValues(rctx, groupID, ids) if err != nil { if appErr := mapPropertyServiceError("GetPropertyValues", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("GetPropertyValues", "app.property_value.get_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return values, nil } // SearchPropertyValues searches for property values matching the given options. func (a *App) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, *model.AppError) { values, err := a.Srv().propertyService.SearchPropertyValues(rctx, groupID, opts) if err != nil { if appErr := mapPropertyServiceError("SearchPropertyValues", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("SearchPropertyValues", "app.property_value.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } return values, nil } // UpdatePropertyValue updates an existing property value. func (a *App) UpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) { if value == nil { return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest) } value.Value = model.SanitizePropertyValue(value.Value) updatedValue, err := a.Srv().propertyService.UpdatePropertyValue(rctx, groupID, value) if err != nil { if appErr := mapPropertyServiceError("UpdatePropertyValue", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("UpdatePropertyValue", "app.property_value.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{updatedValue}) return updatedValue, nil } // UpdatePropertyValues updates multiple property values. func (a *App) UpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, *model.AppError) { if len(values) == 0 { return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest) } for _, v := range values { v.Value = model.SanitizePropertyValue(v.Value) } updatedValues, err := a.Srv().propertyService.UpdatePropertyValues(rctx, groupID, values) if err != nil { if appErr := mapPropertyServiceError("UpdatePropertyValues", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("UpdatePropertyValues", "app.property_value.update_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs(updatedValues) return updatedValues, nil } // UpsertPropertyValue creates or updates a property value. func (a *App) UpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, *model.AppError) { if value == nil { return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.invalid_input.app_error", nil, "property value is required", http.StatusBadRequest) } value.Value = model.SanitizePropertyValue(value.Value) upsertedValue, err := a.Srv().propertyService.UpsertPropertyValue(rctx, value) if err != nil { if appErr := mapPropertyServiceError("UpsertPropertyValue", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("UpsertPropertyValue", "app.property_value.upsert.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs([]*model.PropertyValue{upsertedValue}) return upsertedValue, nil } // UpsertPropertyValues creates or updates multiple property values. // When objectType is non-empty, WebSocket events are broadcast to notify // clients of the updated values, and every referenced field is required // to have a matching ObjectType. func (a *App) UpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue, objectType, targetID, connectionID string) ([]*model.PropertyValue, *model.AppError) { if len(values) == 0 { return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "property values are required", http.StatusBadRequest) } // Intrinsic invariants — apply to every caller (HTTP, plugin, internal). // Single-group invariant must run before the bulk-load below, since // GetPropertyFields takes a single groupID. Guard values[0] explicitly // because the per-element nil check inside the loop would otherwise be // reached after the values[0].GroupID dereference. if values[0] == nil { return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest) } groupID := values[0].GroupID seenIDs := make(map[string]bool, len(values)) fieldIDs := make([]string, 0, len(values)) for _, v := range values { if v == nil { return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.invalid_input.app_error", nil, "nil property value in batch", http.StatusBadRequest) } if v.GroupID != groupID { return nil, model.NewAppError( "UpsertPropertyValues", "app.property_value.upsert.mixed_groups.app_error", nil, "all values in a batch must belong to the same group", http.StatusBadRequest, ) } if !model.IsValidId(v.FieldID) { return nil, model.NewAppError( "UpsertPropertyValues", "app.property_value.upsert.invalid_field_id.app_error", map[string]any{"FieldID": v.FieldID}, "invalid field ID", http.StatusBadRequest, ) } if seenIDs[v.FieldID] { return nil, model.NewAppError( "UpsertPropertyValues", "app.property_value.upsert.duplicate_field_id.app_error", map[string]any{"FieldID": v.FieldID}, "duplicate field ID in batch", http.StatusBadRequest, ) } seenIDs[v.FieldID] = true fieldIDs = append(fieldIDs, v.FieldID) v.Value = model.SanitizePropertyValue(v.Value) } // ObjectType-mismatch check is gated on a non-empty objectType argument. // Plugin API today always passes objectType="" and keeps its loose // contract on this specific check. if objectType != "" { fields, fieldsErr := a.GetPropertyFields(rctx, groupID, fieldIDs) if fieldsErr != nil { return nil, fieldsErr } fieldByID := make(map[string]*model.PropertyField, len(fields)) for _, f := range fields { fieldByID[f.ID] = f } for _, v := range values { f, ok := fieldByID[v.FieldID] if !ok { return nil, model.NewAppError( "UpsertPropertyValues", "app.property_value.upsert.field_not_found.app_error", map[string]any{"FieldID": v.FieldID}, "field not found", http.StatusNotFound, ) } if f.ObjectType != objectType { // 404 matches the shape of a non-existent field so callers // cannot distinguish "no such field" from "field exists but // in a different object-type bucket". return nil, model.NewAppError( "UpsertPropertyValues", "app.property_value.upsert.object_type_mismatch.app_error", map[string]any{"FieldID": v.FieldID}, "object type mismatch", http.StatusNotFound, ) } } } result, err := a.Srv().propertyService.UpsertPropertyValues(rctx, values) if err != nil { if appErr := mapPropertyServiceError("UpsertPropertyValues", err); appErr != nil { return nil, appErr } return nil, model.NewAppError("UpsertPropertyValues", "app.property_value.upsert_many.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpochs(result) // Only publish websocket events for PSAv2 properties (those with an ObjectType) if objectType != "" { teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, objectType, targetID) if appErr != nil { rctx.Logger().Warn("Failed to resolve broadcast params for property values", mlog.Err(appErr)) } else { valuesJSON, jsonErr := json.Marshal(result) if jsonErr != nil { rctx.Logger().Warn("Failed to encode property values to JSON", mlog.Err(jsonErr)) } else { message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, connectionID) message.Add("object_type", objectType) message.Add("target_id", targetID) message.Add("values", string(valuesJSON)) a.Publish(message) } } } return result, nil } // DeletePropertyValue deletes a property value and broadcasts a property_values_updated event. func (a *App) DeletePropertyValue(rctx request.CTX, groupID, valueID string) *model.AppError { value, appErr := a.GetPropertyValue(rctx, groupID, valueID) if appErr != nil { return appErr } if err := a.Srv().propertyService.DeletePropertyValue(rctx, groupID, valueID); err != nil { if mappedErr := mapPropertyServiceError("DeletePropertyValue", err); mappedErr != nil { return mappedErr } return model.NewAppError("DeletePropertyValue", "app.property_value.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpoch(value.TargetType, value.TargetID) teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, value.TargetType, value.TargetID) if appErr != nil { rctx.Logger().Warn("Failed to resolve broadcast params for property value deletion", mlog.Err(appErr)) return nil } deleted := &model.PropertyValue{ TargetID: value.TargetID, TargetType: value.TargetType, GroupID: value.GroupID, FieldID: value.FieldID, } valuesJSON, jsonErr := json.Marshal([]*model.PropertyValue{deleted}) if jsonErr != nil { rctx.Logger().Warn("Failed to encode deleted property value to JSON", mlog.Err(jsonErr)) return nil } message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, "") message.Add("object_type", value.TargetType) message.Add("target_id", value.TargetID) message.Add("values", string(valuesJSON)) a.Publish(message) return nil } // DeletePropertyValuesForTarget deletes all property values for a target and broadcasts a property_values_updated event. func (a *App) DeletePropertyValuesForTarget(rctx request.CTX, groupID, targetType, targetID string) *model.AppError { if err := a.Srv().propertyService.DeletePropertyValuesForTarget(rctx, groupID, targetType, targetID); err != nil { if appErr := mapPropertyServiceError("DeletePropertyValuesForTarget", err); appErr != nil { return appErr } return model.NewAppError("DeletePropertyValuesForTarget", "app.property_value.delete_for_target.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } a.invalidateUserPropertyValuesEpoch(targetType, targetID) teamID, channelID, appErr := a.resolveValueBroadcastParams(rctx, targetType, targetID) if appErr != nil { rctx.Logger().Warn("Failed to resolve broadcast params for property value deletion", mlog.Err(appErr)) return nil } message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, teamID, channelID, "", nil, "") message.Add("object_type", targetType) message.Add("target_id", targetID) message.Add("values", "[]") a.Publish(message) return nil } // DeletePropertyValuesForField deletes all property values for a field and broadcasts a property_values_updated event. func (a *App) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) *model.AppError { if err := a.Srv().propertyService.DeletePropertyValuesForField(rctx, groupID, fieldID); err != nil { if appErr := mapPropertyServiceError("DeletePropertyValuesForField", err); appErr != nil { return appErr } return model.NewAppError("DeletePropertyValuesForField", "app.property_value.delete_for_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } // A field delete can drop values for many users at once, so clear the whole epoch cache // rather than trying to enumerate the affected users. a.invalidateAllUserAttributeCaches() message := model.NewWebSocketEvent(model.WebsocketEventPropertyValuesUpdated, "", "", "", nil, "") message.Add("field_id", fieldID) message.Add("values", "[]") a.Publish(message) return nil }