Files
team/server/channels/app/properties/property_value.go
de986ae735 Add property system app layer architecture (#35157)
* Refactor property system with app layer routing and access control separation

Establish the app layer as the primary entry point for property operations
with intelligent routing based on group type. This architecture separates
access-controlled operations (CPA groups) from standard operations,
improving performance and code clarity.

Architecture Changes:
- App layer now routes operations based on group type:
  - CPA groups -> PropertyAccessService (enforces access control)
  - Non-CPA groups -> PropertyService (direct, no access control)
- PropertyAccessService simplified to handle only CPA operations
- Eliminated redundant group type checks throughout the codebase

* Move access control routing into PropertyService

This change makes the PropertyService the main entrypoint for property
related operations, and adds a routing mechanism to decide if extra
behaviors or checks should run for each operation, in this case, the
property access service logic.

To add specific payloads that pluggable checks and operations may
need, we use the request context. When the request comes from the API,
the endpoints are in charge of adding the caller ID to the payload,
and in the case of the plugin API, on receiving a request, the server
automatically tags the context with the plugin ID so the property
service can react accordingly.

Finally, the new design enforces all these checks migrating the actual
property logic to internal, non-exposed methods, so any caller from
the App layer needs to go through the service checks that decide if
pluggable logic is needed, avoiding any possibility of a bypass.

* Fix i18n

* Fix bad error string

* Added nil guards to property methods

* Add check for multiple group IDs on value operations

* Add nil guard to the plugin checker

* Fix build error

* Update value tests

* Fix linter

* Adds early return when content flaggin a thread with no replies

* Fix mocks

* Clean the state of plugin property tests before each run

* Do not wrap appErr on API response and fix i18n

* Fix create property field test

* Remove the need to cache cpaGroupID as part of the property service

* Split the property.go file into multiple

* Not found group doesn't bypass access control check

* Unexport SetPluginCheckerForTests

* Rename plugin context getter to be more PSA specific

---------

Co-authored-by: Miguel de la Cruz <[email protected]>
2026-03-26 07:54:50 +00:00

279 lines
8.5 KiB
Go

// 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"
)
// Private implementation methods (database access)
func (ps *PropertyService) createPropertyValue(value *model.PropertyValue) (*model.PropertyValue, error) {
return ps.valueStore.Create(value)
}
func (ps *PropertyService) createPropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
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(groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
values, err := ps.updatePropertyValues(groupID, []*model.PropertyValue{value})
if err != nil {
return nil, err
}
return values[0], nil
}
func (ps *PropertyService) updatePropertyValues(groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return ps.valueStore.Update(groupID, values)
}
func (ps *PropertyService) upsertPropertyValue(value *model.PropertyValue) (*model.PropertyValue, error) {
values, err := ps.upsertPropertyValues([]*model.PropertyValue{value})
if err != nil {
return nil, err
}
return values[0], nil
}
func (ps *PropertyService) upsertPropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
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 routing 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")
}
requiresAC, err := ps.requiresAccessControl(value.GroupID)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.CreatePropertyValue(callerID, value)
}
return ps.createPropertyValue(value)
}
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")
}
}
requiresAC, err := ps.requiresAccessControl(values[0].GroupID)
if err != nil {
return nil, fmt.Errorf("CreatePropertyValues: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.CreatePropertyValues(callerID, values)
}
return ps.createPropertyValues(values)
}
func (ps *PropertyService) GetPropertyValue(rctx request.CTX, groupID, id string) (*model.PropertyValue, error) {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return nil, fmt.Errorf("GetPropertyValue: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.GetPropertyValue(callerID, groupID, id)
}
return ps.getPropertyValue(groupID, id)
}
func (ps *PropertyService) GetPropertyValues(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyValue, error) {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return nil, fmt.Errorf("GetPropertyValues: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.GetPropertyValues(callerID, groupID, ids)
}
return ps.getPropertyValues(groupID, ids)
}
func (ps *PropertyService) SearchPropertyValues(rctx request.CTX, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return nil, fmt.Errorf("SearchPropertyValues: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.SearchPropertyValues(callerID, groupID, opts)
}
return ps.searchPropertyValues(groupID, opts)
}
func (ps *PropertyService) UpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.UpdatePropertyValue(callerID, groupID, value)
}
return ps.updatePropertyValue(groupID, value)
}
func (ps *PropertyService) UpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.UpdatePropertyValues(callerID, groupID, values)
}
return ps.updatePropertyValues(groupID, values)
}
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")
}
requiresAC, err := ps.requiresAccessControl(value.GroupID)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.UpsertPropertyValue(callerID, value)
}
return ps.upsertPropertyValue(value)
}
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")
}
}
requiresAC, err := ps.requiresAccessControl(values[0].GroupID)
if err != nil {
return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.UpsertPropertyValues(callerID, values)
}
return ps.upsertPropertyValues(values)
}
func (ps *PropertyService) DeletePropertyValue(rctx request.CTX, groupID, id string) error {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return fmt.Errorf("DeletePropertyValue: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.DeletePropertyValue(callerID, groupID, id)
}
return ps.deletePropertyValue(groupID, id)
}
func (ps *PropertyService) DeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.DeletePropertyValuesForTarget(callerID, groupID, targetType, targetID)
}
return ps.deletePropertyValuesForTarget(groupID, targetType, targetID)
}
func (ps *PropertyService) DeletePropertyValuesForField(rctx request.CTX, groupID, fieldID string) error {
requiresAC, err := ps.requiresAccessControl(groupID)
if err != nil {
return fmt.Errorf("DeletePropertyValuesForField: %w", err)
}
if requiresAC {
callerID := ps.extractCallerID(rctx)
return ps.propertyAccess.DeletePropertyValuesForField(callerID, groupID, fieldID)
}
return ps.deletePropertyValuesForField(groupID, fieldID)
}