mirror of
https://github.com/mattermost/mattermost.git
synced 2026-04-14 05:28:31 -04:00
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* 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 <miguel@ctrlz.es>
1124 lines
40 KiB
Go
1124 lines
40 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package properties
|
|
|
|
// This file implements access control for property fields and values using three key mechanisms:
|
|
//
|
|
// 1. Protected Fields (protected attribute):
|
|
// - Protected fields can only be modified by their source plugin (identified by source_plugin_id)
|
|
// - Non-protected fields can be modified by any caller with appropriate access
|
|
//
|
|
// 2. Access Mode (access_mode attribute):
|
|
// - Controls read access to field metadata (like options) and values
|
|
// - Three modes:
|
|
// * Public (empty string, default): Everyone can read all data
|
|
// * Source-only: Only the source plugin can read full field options and values; others see empty options and no values
|
|
// * Shared-only: Callers can only see field options and values they share with the target
|
|
// (Example: If Alice selected Apples and Bananas, and Bob selected Bananas and Oranges,
|
|
// then Alice querying Bob's values would only see Bananas)
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
)
|
|
|
|
const (
|
|
// propertyAccessPaginationPageSize is the default page size for pagination when fetching property values
|
|
propertyAccessPaginationPageSize = 100
|
|
// propertyAccessMaxPaginationIterations is the maximum number of pagination iterations before returning an error
|
|
propertyAccessMaxPaginationIterations = 10
|
|
)
|
|
|
|
// PluginChecker is a function type that checks if a plugin is installed.
|
|
// Returns true if the plugin exists and is installed, false otherwise.
|
|
type PluginChecker func(pluginID string) bool
|
|
|
|
// PropertyAccessService is a layer around PropertyService that enforces access
|
|
// control based on caller identity. All property operations go through this
|
|
// service to ensure consistent access control enforcement.
|
|
type PropertyAccessService struct {
|
|
propertyService *PropertyService
|
|
pluginChecker PluginChecker
|
|
}
|
|
|
|
// NewPropertyAccessService creates a new PropertyAccessService.
|
|
// It receives the PropertyService to call private methods for database operations.
|
|
// The pluginChecker function is used to verify plugin installation status when checking access
|
|
// to protected fields. Pass nil if plugin checking is not needed (e.g., in tests).
|
|
func NewPropertyAccessService(ps *PropertyService, pluginChecker PluginChecker) *PropertyAccessService {
|
|
return &PropertyAccessService{
|
|
propertyService: ps,
|
|
pluginChecker: pluginChecker,
|
|
}
|
|
}
|
|
|
|
func (pas *PropertyAccessService) setPluginCheckerForTests(pluginChecker PluginChecker) {
|
|
pas.pluginChecker = pluginChecker
|
|
}
|
|
|
|
// Property Field Methods
|
|
|
|
// isCallerPlugin checks whether the callerID corresponds to an installed plugin.
|
|
func (pas *PropertyAccessService) isCallerPlugin(callerID string) bool {
|
|
return callerID != "" && pas.pluginChecker != nil && pas.pluginChecker(callerID)
|
|
}
|
|
|
|
// CreatePropertyField creates a new property field with access control.
|
|
// When the caller is an installed plugin, source_plugin_id is automatically set
|
|
// to the callerID and the protected attribute is allowed.
|
|
// When the caller is not a plugin, source_plugin_id and protected are rejected
|
|
// to prevent unauthorized field ownership claims.
|
|
func (pas *PropertyAccessService) CreatePropertyField(callerID string, field *model.PropertyField) (*model.PropertyField, error) {
|
|
if pas.isCallerPlugin(callerID) {
|
|
// Caller is a plugin — auto-set source_plugin_id
|
|
if field.Attrs == nil {
|
|
field.Attrs = make(model.StringInterface)
|
|
}
|
|
field.Attrs[model.PropertyAttrsSourcePluginID] = callerID
|
|
} else {
|
|
// Non-plugin caller — reject source_plugin_id and protected
|
|
if pas.getSourcePluginID(field) != "" {
|
|
return nil, fmt.Errorf("CreatePropertyField: source_plugin_id can only be set by a plugin")
|
|
}
|
|
if model.IsPropertyFieldProtected(field) {
|
|
return nil, fmt.Errorf("CreatePropertyField: protected can only be set by a plugin")
|
|
}
|
|
}
|
|
|
|
// Validate access mode
|
|
if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyField: %w", err)
|
|
}
|
|
|
|
result, err := pas.propertyService.createPropertyField(field)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyField: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPropertyField retrieves a property field by group and field ID.
|
|
// Field details are filtered based on the caller's access permissions.
|
|
func (pas *PropertyAccessService) GetPropertyField(callerID string, groupID, id string) (*model.PropertyField, error) {
|
|
field, err := pas.propertyService.getPropertyField(groupID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyField: %w", err)
|
|
}
|
|
|
|
return pas.applyFieldReadAccessControl(field, callerID), nil
|
|
}
|
|
|
|
// GetPropertyFields retrieves multiple property fields by their IDs.
|
|
// Field details are filtered based on the caller's access permissions.
|
|
func (pas *PropertyAccessService) GetPropertyFields(callerID string, groupID string, ids []string) ([]*model.PropertyField, error) {
|
|
fields, err := pas.propertyService.getPropertyFields(groupID, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyFields: %w", err)
|
|
}
|
|
|
|
return pas.applyFieldReadAccessControlToList(fields, callerID), nil
|
|
}
|
|
|
|
// GetPropertyFieldByName retrieves a property field by name.
|
|
// Field details are filtered based on the caller's access permissions.
|
|
func (pas *PropertyAccessService) GetPropertyFieldByName(callerID string, groupID, targetID, name string) (*model.PropertyField, error) {
|
|
field, err := pas.propertyService.getPropertyFieldByName(groupID, targetID, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyFieldByName: %w", err)
|
|
}
|
|
|
|
return pas.applyFieldReadAccessControl(field, callerID), nil
|
|
}
|
|
|
|
// CountActivePropertyFieldsForGroup counts active property fields for a group.
|
|
func (pas *PropertyAccessService) CountActivePropertyFieldsForGroup(groupID string) (int64, error) {
|
|
return pas.propertyService.countActivePropertyFieldsForGroup(groupID)
|
|
}
|
|
|
|
// CountAllPropertyFieldsForGroup counts all property fields (including deleted) for a group.
|
|
func (pas *PropertyAccessService) CountAllPropertyFieldsForGroup(groupID string) (int64, error) {
|
|
return pas.propertyService.countAllPropertyFieldsForGroup(groupID)
|
|
}
|
|
|
|
// CountActivePropertyFieldsForTarget counts active property fields for a specific target.
|
|
func (pas *PropertyAccessService) CountActivePropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
|
|
return pas.propertyService.countActivePropertyFieldsForTarget(groupID, targetType, targetID)
|
|
}
|
|
|
|
// CountAllPropertyFieldsForTarget counts all property fields (including deleted) for a specific target.
|
|
func (pas *PropertyAccessService) CountAllPropertyFieldsForTarget(groupID, targetType, targetID string) (int64, error) {
|
|
return pas.propertyService.countAllPropertyFieldsForTarget(groupID, targetType, targetID)
|
|
}
|
|
|
|
// SearchPropertyFields searches for property fields based on the given options.
|
|
// Field details are filtered based on the caller's access permissions.
|
|
func (pas *PropertyAccessService) SearchPropertyFields(callerID string, groupID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
|
|
fields, err := pas.propertyService.searchPropertyFields(groupID, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SearchPropertyFields: %w", err)
|
|
}
|
|
|
|
return pas.applyFieldReadAccessControlToList(fields, callerID), nil
|
|
}
|
|
|
|
// UpdatePropertyField updates a property field.
|
|
// Checks write access and ensures source_plugin_id is not changed.
|
|
func (pas *PropertyAccessService) UpdatePropertyField(callerID string, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
|
|
// Get existing field to check access
|
|
existingField, existsErr := pas.propertyService.getPropertyField(groupID, field.ID)
|
|
if existsErr != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", existsErr)
|
|
}
|
|
|
|
// Check write access
|
|
if err := pas.checkFieldWriteAccess(existingField, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", err)
|
|
}
|
|
|
|
// Ensure source_plugin_id hasn't changed
|
|
if err := pas.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", err)
|
|
}
|
|
|
|
// Validate protected field update
|
|
if err := pas.validateProtectedFieldUpdate(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", err)
|
|
}
|
|
|
|
// Validate access mode
|
|
if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", err)
|
|
}
|
|
|
|
result, err := pas.propertyService.updatePropertyField(groupID, field)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyField: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpdatePropertyFields updates multiple property fields.
|
|
// Checks write access for all fields atomically before updating any.
|
|
func (pas *PropertyAccessService) UpdatePropertyFields(callerID string, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
|
|
if len(fields) == 0 {
|
|
return fields, nil
|
|
}
|
|
|
|
// Get field IDs
|
|
fieldIDs := make([]string, len(fields))
|
|
for i, field := range fields {
|
|
fieldIDs[i] = field.ID
|
|
}
|
|
|
|
// Fetch existing fields
|
|
existingFields, existsErr := pas.propertyService.getPropertyFields(groupID, fieldIDs)
|
|
if existsErr != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: %w", existsErr)
|
|
}
|
|
|
|
// Build map for easy lookup
|
|
existingFieldMap := make(map[string]*model.PropertyField, len(existingFields))
|
|
for _, field := range existingFields {
|
|
existingFieldMap[field.ID] = field
|
|
}
|
|
|
|
// Check write access for all fields before updating any
|
|
for _, field := range fields {
|
|
existingField, exists := existingFieldMap[field.ID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("field %s not found", field.ID)
|
|
}
|
|
|
|
// Check write access
|
|
if err := pas.checkFieldWriteAccess(existingField, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
|
|
}
|
|
|
|
// Ensure source_plugin_id hasn't changed
|
|
if err := pas.ensureSourcePluginIDUnchanged(existingField, field); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
|
|
}
|
|
|
|
// Validate protected field update
|
|
if err := pas.validateProtectedFieldUpdate(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
|
|
}
|
|
|
|
// Validate access mode
|
|
if err := model.ValidatePropertyFieldAccessMode(field); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: field %s: %w", field.ID, err)
|
|
}
|
|
}
|
|
|
|
// All checks passed - proceed with update
|
|
result, err := pas.propertyService.updatePropertyFields(groupID, fields)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyFields: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DeletePropertyField deletes a property field and all its values.
|
|
// Checks delete access before allowing deletion.
|
|
func (pas *PropertyAccessService) DeletePropertyField(callerID string, groupID, id string) error {
|
|
// Get existing field to check access
|
|
existingField, err := pas.propertyService.getPropertyField(groupID, id)
|
|
if err != nil {
|
|
return fmt.Errorf("DeletePropertyField: %w", err)
|
|
}
|
|
|
|
// Check delete access
|
|
if err := pas.checkFieldDeleteAccess(existingField, callerID); err != nil {
|
|
return fmt.Errorf("DeletePropertyField: %w", err)
|
|
}
|
|
|
|
if err := pas.propertyService.deletePropertyField(groupID, id); err != nil {
|
|
return fmt.Errorf("DeletePropertyField: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Property Value Methods
|
|
|
|
// CreatePropertyValue creates a new property value.
|
|
// Checks write access before allowing the creation.
|
|
func (pas *PropertyAccessService) CreatePropertyValue(callerID string, value *model.PropertyValue) (*model.PropertyValue, error) {
|
|
// Get the associated field to check access
|
|
field, err := pas.propertyService.getPropertyField(value.GroupID, value.FieldID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
|
|
}
|
|
|
|
// Check write access
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
|
|
}
|
|
|
|
result, err := pas.propertyService.createPropertyValue(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValue: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CreatePropertyValues creates multiple property values.
|
|
// Checks write access for all fields atomically before creating any values.
|
|
func (pas *PropertyAccessService) CreatePropertyValues(callerID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
|
fieldMap, err := pas.getFieldsForValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValues: %w", err)
|
|
}
|
|
|
|
// Check write access for all fields before creating any values
|
|
for _, value := range values {
|
|
field, exists := fieldMap[value.FieldID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("CreatePropertyValues: field %s not found", value.FieldID)
|
|
}
|
|
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValues: field %s: %w", value.FieldID, err)
|
|
}
|
|
}
|
|
|
|
// All checks passed - proceed with creation
|
|
result, err := pas.propertyService.createPropertyValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreatePropertyValues: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPropertyValue retrieves a property value by ID.
|
|
// Returns (nil, nil) if the value exists but the caller doesn't have access.
|
|
func (pas *PropertyAccessService) GetPropertyValue(callerID string, groupID, id string) (*model.PropertyValue, error) {
|
|
value, err := pas.propertyService.getPropertyValue(groupID, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyValue: %w", err)
|
|
}
|
|
|
|
// Apply access control filtering
|
|
filtered, err := pas.applyValueReadAccessControl([]*model.PropertyValue{value}, callerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyValue: %w", err)
|
|
}
|
|
|
|
// If the value was filtered out, return nil
|
|
if len(filtered) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return filtered[0], nil
|
|
}
|
|
|
|
// GetPropertyValues retrieves multiple property values by their IDs.
|
|
// Values the caller doesn't have access to are silently filtered out.
|
|
func (pas *PropertyAccessService) GetPropertyValues(callerID string, groupID string, ids []string) ([]*model.PropertyValue, error) {
|
|
values, err := pas.propertyService.getPropertyValues(groupID, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyValues: %w", err)
|
|
}
|
|
|
|
// Apply access control filtering
|
|
filtered, err := pas.applyValueReadAccessControl(values, callerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetPropertyValues: %w", err)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
// SearchPropertyValues searches for property values based on the given options.
|
|
// Values the caller doesn't have access to are silently filtered out.
|
|
func (pas *PropertyAccessService) SearchPropertyValues(callerID string, groupID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
|
|
values, err := pas.propertyService.searchPropertyValues(groupID, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SearchPropertyValues: %w", err)
|
|
}
|
|
|
|
// Apply access control filtering
|
|
filtered, err := pas.applyValueReadAccessControl(values, callerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SearchPropertyValues: %w", err)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
// UpdatePropertyValue updates a property value.
|
|
// Checks write access before allowing the update.
|
|
func (pas *PropertyAccessService) UpdatePropertyValue(callerID string, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) {
|
|
// Get the associated field to check access
|
|
field, err := pas.propertyService.getPropertyField(groupID, value.FieldID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
|
|
}
|
|
|
|
// Check write access
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
|
|
}
|
|
|
|
result, err := pas.propertyService.updatePropertyValue(groupID, value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValue: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpdatePropertyValues updates multiple property values.
|
|
// Checks write access for all fields atomically before updating any values.
|
|
func (pas *PropertyAccessService) UpdatePropertyValues(callerID string, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
|
if len(values) == 0 {
|
|
return values, nil
|
|
}
|
|
|
|
fieldMap, err := pas.getFieldsForValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
|
|
}
|
|
|
|
// Check write access for all fields before updating any values
|
|
for _, value := range values {
|
|
field, exists := fieldMap[value.FieldID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("UpdatePropertyValues: field %s not found", value.FieldID)
|
|
}
|
|
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValues: field %s: %w", value.FieldID, err)
|
|
}
|
|
}
|
|
|
|
// All checks passed - proceed with update
|
|
result, err := pas.propertyService.updatePropertyValues(groupID, values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpdatePropertyValues: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpsertPropertyValue creates or updates a property value.
|
|
// Checks write access before allowing the upsert.
|
|
func (pas *PropertyAccessService) UpsertPropertyValue(callerID string, value *model.PropertyValue) (*model.PropertyValue, error) {
|
|
// Get the associated field to check access
|
|
field, err := pas.propertyService.getPropertyField(value.GroupID, value.FieldID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
|
|
}
|
|
|
|
// Check write access (works for both create and update)
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
|
|
}
|
|
|
|
result, err := pas.propertyService.upsertPropertyValue(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValue: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpsertPropertyValues creates or updates multiple property values.
|
|
// Checks write access for all fields atomically before upserting any values.
|
|
func (pas *PropertyAccessService) UpsertPropertyValues(callerID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) {
|
|
if len(values) == 0 {
|
|
return values, nil
|
|
}
|
|
|
|
fieldMap, err := pas.getFieldsForValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
|
|
}
|
|
|
|
// Check write access for all fields before upserting any values
|
|
for _, value := range values {
|
|
field, exists := fieldMap[value.FieldID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("UpsertPropertyValues: field %s not found", value.FieldID)
|
|
}
|
|
|
|
if err = pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValues: field %s: %w", value.FieldID, err)
|
|
}
|
|
}
|
|
|
|
// All checks passed - proceed with upsert
|
|
result, err := pas.propertyService.upsertPropertyValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("UpsertPropertyValues: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DeletePropertyValue deletes a property value.
|
|
// Checks write access before allowing deletion.
|
|
func (pas *PropertyAccessService) DeletePropertyValue(callerID string, groupID, id string) error {
|
|
// Get the value to find its field ID
|
|
value, err := pas.propertyService.getPropertyValue(groupID, id)
|
|
if err != nil {
|
|
// Value doesn't exist - return nil to match original behavior
|
|
return nil
|
|
}
|
|
|
|
// Get the associated field to check access
|
|
field, err := pas.propertyService.getPropertyField(groupID, value.FieldID)
|
|
if err != nil {
|
|
return fmt.Errorf("DeletePropertyValue: %w", err)
|
|
}
|
|
|
|
// Check write access
|
|
if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return fmt.Errorf("DeletePropertyValue: %w", err)
|
|
}
|
|
|
|
if err := pas.propertyService.deletePropertyValue(groupID, id); err != nil {
|
|
return fmt.Errorf("DeletePropertyValue: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeletePropertyValuesForTarget deletes all property values for a specific target.
|
|
// Checks write access for all affected fields atomically before deleting.
|
|
func (pas *PropertyAccessService) DeletePropertyValuesForTarget(callerID string, groupID string, targetType string, targetID string) error {
|
|
// Collect unique field IDs across all values without loading all values into memory
|
|
fieldIDs := make(map[string]struct{})
|
|
var cursor model.PropertyValueSearchCursor
|
|
iterations := 0
|
|
|
|
for {
|
|
iterations++
|
|
if iterations > propertyAccessMaxPaginationIterations {
|
|
return fmt.Errorf("DeletePropertyValuesForTarget: exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
|
|
}
|
|
|
|
opts := model.PropertyValueSearchOpts{
|
|
TargetType: targetType,
|
|
TargetIDs: []string{targetID},
|
|
PerPage: propertyAccessPaginationPageSize,
|
|
}
|
|
|
|
if !cursor.IsEmpty() {
|
|
opts.Cursor = cursor
|
|
}
|
|
|
|
values, err := pas.propertyService.searchPropertyValues(groupID, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
|
|
}
|
|
|
|
// Extract field IDs from this batch
|
|
for _, value := range values {
|
|
fieldIDs[value.FieldID] = struct{}{}
|
|
}
|
|
|
|
// If we got fewer results than the page size, we're done
|
|
if len(values) < propertyAccessPaginationPageSize {
|
|
break
|
|
}
|
|
|
|
// Update cursor for next page
|
|
lastValue := values[len(values)-1]
|
|
cursor = model.PropertyValueSearchCursor{
|
|
PropertyValueID: lastValue.ID,
|
|
CreateAt: lastValue.CreateAt,
|
|
}
|
|
}
|
|
|
|
if len(fieldIDs) == 0 {
|
|
// No values to delete - return nil to match original behavior
|
|
return nil
|
|
}
|
|
|
|
// Convert map to slice
|
|
fieldIDSlice := make([]string, 0, len(fieldIDs))
|
|
for fieldID := range fieldIDs {
|
|
fieldIDSlice = append(fieldIDSlice, fieldID)
|
|
}
|
|
|
|
// Fetch all fields
|
|
fields, err := pas.propertyService.getPropertyFields(groupID, fieldIDSlice)
|
|
if err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
|
|
}
|
|
|
|
// Check write access for all fields before deleting any values
|
|
for _, field := range fields {
|
|
if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForTarget: field %s: %w", field.ID, err)
|
|
}
|
|
}
|
|
|
|
// All checks passed - proceed with deletion
|
|
if err := pas.propertyService.deletePropertyValuesForTarget(groupID, targetType, targetID); err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForTarget: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeletePropertyValuesForField deletes all property values for a specific field.
|
|
// Checks write access before allowing deletion.
|
|
func (pas *PropertyAccessService) DeletePropertyValuesForField(callerID string, groupID, fieldID string) error {
|
|
// Get the field to check access
|
|
field, err := pas.propertyService.getPropertyField(groupID, fieldID)
|
|
if err != nil {
|
|
// Field doesn't exist - return nil to match original behavior
|
|
return nil
|
|
}
|
|
|
|
// Check write access
|
|
if err := pas.checkFieldWriteAccess(field, callerID); err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForField: %w", err)
|
|
}
|
|
|
|
if err := pas.propertyService.deletePropertyValuesForField(groupID, fieldID); err != nil {
|
|
return fmt.Errorf("DeletePropertyValuesForField: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Access Control Helper Methods
|
|
|
|
// getSourcePluginID extracts the source_plugin_id from a PropertyField's attrs.
|
|
// Returns empty string if not set.
|
|
func (pas *PropertyAccessService) getSourcePluginID(field *model.PropertyField) string {
|
|
if field.Attrs == nil {
|
|
return ""
|
|
}
|
|
sourcePluginID, _ := field.Attrs[model.PropertyAttrsSourcePluginID].(string)
|
|
return sourcePluginID
|
|
}
|
|
|
|
// getAccessMode extracts the access_mode from a PropertyField's attrs.
|
|
// Returns empty string (public access mode) if not set (default).
|
|
func (pas *PropertyAccessService) getAccessMode(field *model.PropertyField) string {
|
|
if field.Attrs == nil {
|
|
return model.PropertyAccessModePublic
|
|
}
|
|
accessMode, ok := field.Attrs[model.PropertyAttrsAccessMode].(string)
|
|
if !ok {
|
|
return model.PropertyAccessModePublic
|
|
}
|
|
return accessMode
|
|
}
|
|
|
|
// checkUnrestrictedFieldReadAccess checks if the given caller can read a PropertyField without restrictions.
|
|
// Returns true if the caller has unrestricted read access (public field or source plugin).
|
|
// Returns an error if access requires filtering or should be denied entirely.
|
|
func (pas *PropertyAccessService) hasUnrestrictedFieldReadAccess(field *model.PropertyField, callerID string) bool {
|
|
accessMode := pas.getAccessMode(field)
|
|
|
|
// Public fields are readable by everyone without restrictions
|
|
if accessMode == model.PropertyAccessModePublic {
|
|
return true
|
|
}
|
|
|
|
// Source plugin always has unrestricted access to fields they created
|
|
sourcePluginID := pas.getSourcePluginID(field)
|
|
if sourcePluginID != "" && sourcePluginID == callerID {
|
|
return true
|
|
}
|
|
|
|
// All other cases require filtering or access denial
|
|
return false
|
|
}
|
|
|
|
// ensureSourcePluginIDUnchanged checks that the source_plugin_id attribute hasn't changed between fields.
|
|
// Used during field updates to ensure source_plugin_id is immutable.
|
|
// Returns nil if unchanged, or an error if source_plugin_id was modified.
|
|
func (pas *PropertyAccessService) ensureSourcePluginIDUnchanged(existingField, updatedField *model.PropertyField) error {
|
|
existingSourcePluginID := pas.getSourcePluginID(existingField)
|
|
updatedSourcePluginID := pas.getSourcePluginID(updatedField)
|
|
|
|
if existingSourcePluginID != updatedSourcePluginID {
|
|
return fmt.Errorf("source_plugin_id is immutable and cannot be changed from '%s' to '%s'", existingSourcePluginID, updatedSourcePluginID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateProtectedFieldUpdate validates that a field can be updated to protected=true.
|
|
// Prevents creating orphaned protected fields (protected=true but no source_plugin_id).
|
|
// Also ensures only the source plugin can set protected=true on fields with a source_plugin_id.
|
|
// Returns nil if the update is valid, or an error if it should be rejected.
|
|
func (pas *PropertyAccessService) validateProtectedFieldUpdate(updatedField *model.PropertyField, callerID string) error {
|
|
if !model.IsPropertyFieldProtected(updatedField) {
|
|
return nil
|
|
}
|
|
|
|
sourcePluginID := pas.getSourcePluginID(updatedField)
|
|
if sourcePluginID == "" {
|
|
return fmt.Errorf("cannot set protected=true on a field without a source_plugin_id")
|
|
}
|
|
|
|
if sourcePluginID != callerID {
|
|
return fmt.Errorf("cannot set protected=true: only source plugin '%s' can modify this field", sourcePluginID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkFieldWriteAccess checks if the given caller can modify a PropertyField.
|
|
// IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller.
|
|
// Returns nil if modification is allowed, or an error if denied.
|
|
func (pas *PropertyAccessService) checkFieldWriteAccess(field *model.PropertyField, callerID string) error {
|
|
// Check if field is protected
|
|
if !model.IsPropertyFieldProtected(field) {
|
|
return nil
|
|
}
|
|
|
|
// Protected fields can only be modified by the source plugin
|
|
sourcePluginID := pas.getSourcePluginID(field)
|
|
if sourcePluginID == "" {
|
|
return fmt.Errorf("field %s is protected, but has no associated source plugin", field.ID)
|
|
}
|
|
|
|
if sourcePluginID != callerID {
|
|
return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s'", field.ID, sourcePluginID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkFieldDeleteAccess checks if the given caller can delete a PropertyField.
|
|
// IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller.
|
|
// Returns nil if deletion is allowed, or an error if denied.
|
|
func (pas *PropertyAccessService) checkFieldDeleteAccess(field *model.PropertyField, callerID string) error {
|
|
// Check if field is protected
|
|
if !model.IsPropertyFieldProtected(field) {
|
|
return nil
|
|
}
|
|
|
|
// Protected fields can only be deleted by the source plugin
|
|
sourcePluginID := pas.getSourcePluginID(field)
|
|
if sourcePluginID == "" {
|
|
// Protected field with no source plugin - allow deletion
|
|
return nil
|
|
}
|
|
|
|
// Check if the source plugin is still installed
|
|
if pas.pluginChecker != nil && !pas.pluginChecker(sourcePluginID) {
|
|
// Plugin has been uninstalled - allow deletion of orphaned field
|
|
return nil
|
|
}
|
|
|
|
if sourcePluginID != callerID {
|
|
return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s'", field.ID, sourcePluginID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getCallerValuesForField retrieves all property values for the caller on a specific field.
|
|
// This is used internally for shared_only filtering.
|
|
// Returns an empty slice if callerID is empty or if there are no values.
|
|
func (pas *PropertyAccessService) getCallerValuesForField(groupID, fieldID, callerID string) ([]*model.PropertyValue, error) {
|
|
if callerID == "" {
|
|
return []*model.PropertyValue{}, nil
|
|
}
|
|
|
|
allValues := []*model.PropertyValue{}
|
|
var cursor model.PropertyValueSearchCursor
|
|
iterations := 0
|
|
|
|
for {
|
|
iterations++
|
|
if iterations > propertyAccessMaxPaginationIterations {
|
|
return nil, fmt.Errorf("getCallerValuesForField: exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations)
|
|
}
|
|
|
|
opts := model.PropertyValueSearchOpts{
|
|
FieldID: fieldID,
|
|
TargetIDs: []string{callerID},
|
|
PerPage: propertyAccessPaginationPageSize,
|
|
}
|
|
|
|
if !cursor.IsEmpty() {
|
|
opts.Cursor = cursor
|
|
}
|
|
|
|
values, err := pas.propertyService.searchPropertyValues(groupID, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get caller values for field: %w", err)
|
|
}
|
|
|
|
allValues = append(allValues, values...)
|
|
|
|
// If we got fewer results than the page size, we're done
|
|
if len(values) < propertyAccessPaginationPageSize {
|
|
break
|
|
}
|
|
|
|
// Update cursor for next page
|
|
lastValue := values[len(values)-1]
|
|
cursor = model.PropertyValueSearchCursor{
|
|
PropertyValueID: lastValue.ID,
|
|
CreateAt: lastValue.CreateAt,
|
|
}
|
|
}
|
|
|
|
return allValues, nil
|
|
}
|
|
|
|
// extractOptionIDsFromValue parses a JSON value and extracts option IDs into a set.
|
|
// For select fields: returns a set with one option ID
|
|
// For multiselect fields: returns a set with multiple option IDs
|
|
// Returns nil if value is empty, or an error if field type is not select/multiselect.
|
|
func (pas *PropertyAccessService) extractOptionIDsFromValue(fieldType model.PropertyFieldType, value []byte) (map[string]struct{}, error) {
|
|
if len(value) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
optionIDs := make(map[string]struct{})
|
|
|
|
switch fieldType {
|
|
case model.PropertyFieldTypeSelect:
|
|
var optionID string
|
|
if err := json.Unmarshal(value, &optionID); err != nil {
|
|
return nil, err
|
|
}
|
|
if optionID != "" {
|
|
optionIDs[optionID] = struct{}{}
|
|
}
|
|
|
|
case model.PropertyFieldTypeMultiselect:
|
|
var ids []string
|
|
if err := json.Unmarshal(value, &ids); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
if id != "" {
|
|
optionIDs[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
default:
|
|
return nil, fmt.Errorf("extractOptionIDsFromValue only supports select and multiselect field types, got: %s", fieldType)
|
|
}
|
|
|
|
return optionIDs, nil
|
|
}
|
|
|
|
// copyPropertyField creates a deep copy of a PropertyField, including its Attrs map.
|
|
func (pas *PropertyAccessService) copyPropertyField(field *model.PropertyField) *model.PropertyField {
|
|
copied := *field
|
|
copied.Attrs = make(model.StringInterface)
|
|
if field.Attrs != nil {
|
|
maps.Copy(copied.Attrs, field.Attrs)
|
|
}
|
|
return &copied
|
|
}
|
|
|
|
// getCallerOptionIDsForField retrieves the caller's values for a field and extracts all option IDs.
|
|
// This is used for shared_only filtering to determine which options the caller has.
|
|
// Returns an empty set if callerID is empty, if there are no values, or on error.
|
|
func (pas *PropertyAccessService) getCallerOptionIDsForField(groupID, fieldID, callerID string, fieldType model.PropertyFieldType) (map[string]struct{}, error) {
|
|
callerValues, err := pas.getCallerValuesForField(groupID, fieldID, callerID)
|
|
if err != nil {
|
|
return make(map[string]struct{}), err
|
|
}
|
|
|
|
if len(callerValues) == 0 {
|
|
return make(map[string]struct{}), nil
|
|
}
|
|
|
|
// Extract option IDs from caller's values
|
|
callerOptionIDs := make(map[string]struct{})
|
|
for _, val := range callerValues {
|
|
optionIDs, err := pas.extractOptionIDsFromValue(fieldType, val.Value)
|
|
if err == nil && optionIDs != nil {
|
|
for optionID := range optionIDs {
|
|
callerOptionIDs[optionID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
return callerOptionIDs, nil
|
|
}
|
|
|
|
// filterSharedOnlyFieldOptions filters a field's options to only include those the caller has values for.
|
|
// Returns a new PropertyField with filtered options in the attrs.
|
|
// If the caller has no values, returns a field with empty options.
|
|
func (pas *PropertyAccessService) filterSharedOnlyFieldOptions(field *model.PropertyField, callerID string) *model.PropertyField {
|
|
// Only applies to select and multiselect fields
|
|
if field.Type != model.PropertyFieldTypeSelect && field.Type != model.PropertyFieldTypeMultiselect {
|
|
return field
|
|
}
|
|
|
|
// Get caller's option IDs for this field
|
|
callerOptionIDs, err := pas.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
|
|
if err != nil || len(callerOptionIDs) == 0 {
|
|
// If no values or error, return field with empty options
|
|
filteredField := pas.copyPropertyField(field)
|
|
filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{}
|
|
return filteredField
|
|
}
|
|
|
|
// Get current options from field attrs
|
|
if field.Attrs == nil {
|
|
return field
|
|
}
|
|
optionsArr, ok := field.Attrs[model.PropertyFieldAttributeOptions]
|
|
if !ok {
|
|
return field
|
|
}
|
|
|
|
// Convert to slice of maps (generic option representation)
|
|
optionsSlice, ok := optionsArr.([]any)
|
|
if !ok {
|
|
return field
|
|
}
|
|
|
|
// Filter options
|
|
filteredOptions := []any{}
|
|
for _, opt := range optionsSlice {
|
|
optMap, ok := opt.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
optID, ok := optMap["id"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := callerOptionIDs[optID]; exists {
|
|
filteredOptions = append(filteredOptions, opt)
|
|
}
|
|
}
|
|
|
|
// Create a new field with filtered options
|
|
filteredField := pas.copyPropertyField(field)
|
|
filteredField.Attrs[model.PropertyFieldAttributeOptions] = filteredOptions
|
|
return filteredField
|
|
}
|
|
|
|
// filterSharedOnlyValue computes the intersection of caller and target values for shared_only fields.
|
|
// Returns the filtered value or nil if there's no intersection.
|
|
// For single-select: returns value only if both have the same value.
|
|
// For multi-select: returns the intersection of arrays.
|
|
func (pas *PropertyAccessService) filterSharedOnlyValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue {
|
|
// Only applies to select and multiselect fields
|
|
if field.Type != model.PropertyFieldTypeSelect && field.Type != model.PropertyFieldTypeMultiselect {
|
|
return value
|
|
}
|
|
|
|
// Get caller's option IDs for this field
|
|
callerOptionIDs, err := pas.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type)
|
|
if err != nil || len(callerOptionIDs) == 0 {
|
|
// No intersection possible
|
|
return nil
|
|
}
|
|
|
|
// Extract option IDs from target value
|
|
targetOptionIDs, err := pas.extractOptionIDsFromValue(field.Type, value.Value)
|
|
if err != nil || targetOptionIDs == nil || len(targetOptionIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Find intersection
|
|
intersection := []string{}
|
|
for targetID := range targetOptionIDs {
|
|
if _, exists := callerOptionIDs[targetID]; exists {
|
|
intersection = append(intersection, targetID)
|
|
}
|
|
}
|
|
|
|
// If no intersection, return nil
|
|
if len(intersection) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Create filtered value based on field type
|
|
filteredValue := *value
|
|
|
|
switch field.Type {
|
|
case model.PropertyFieldTypeSelect:
|
|
// For single-select, return the single matching value
|
|
jsonValue, err := json.Marshal(intersection[0])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
filteredValue.Value = jsonValue
|
|
return &filteredValue
|
|
|
|
case model.PropertyFieldTypeMultiselect:
|
|
// For multi-select, return the array of matching values
|
|
jsonValue, err := json.Marshal(intersection)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
filteredValue.Value = jsonValue
|
|
return &filteredValue
|
|
|
|
default:
|
|
// Should never reach here due to check at function start
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// applyFieldReadAccessControl applies read access control to a single field.
|
|
// Returns the field with options filtered based on the caller's access permissions.
|
|
// - Public fields: returned as-is
|
|
// - Source-only fields: returned with empty options if caller is not the source plugin
|
|
// - Shared-only fields: returned with options filtered using filterSharedOnlyFieldOptions
|
|
// - Unknown access modes: treated as source-only (secure default)
|
|
func (pas *PropertyAccessService) applyFieldReadAccessControl(field *model.PropertyField, callerID string) *model.PropertyField {
|
|
// Check if caller has unrestricted access (public field or source plugin for source_only)
|
|
if pas.hasUnrestrictedFieldReadAccess(field, callerID) {
|
|
// Unrestricted access - return as-is
|
|
return field
|
|
}
|
|
|
|
// Access requires filtering
|
|
accessMode := pas.getAccessMode(field)
|
|
|
|
// Shared-only fields: use existing helper to filter options
|
|
if accessMode == model.PropertyAccessModeSharedOnly {
|
|
return pas.filterSharedOnlyFieldOptions(field, callerID)
|
|
}
|
|
|
|
// Source-only or unknown: return with empty options (secure default)
|
|
filteredField := pas.copyPropertyField(field)
|
|
if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
|
|
filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{}
|
|
}
|
|
return filteredField
|
|
}
|
|
|
|
// applyFieldReadAccessControlToList applies read access control to a list of fields.
|
|
// Returns a new list with each field's options filtered based on the caller's access permissions.
|
|
func (pas *PropertyAccessService) applyFieldReadAccessControlToList(fields []*model.PropertyField, callerID string) []*model.PropertyField {
|
|
if len(fields) == 0 {
|
|
return fields
|
|
}
|
|
|
|
filtered := make([]*model.PropertyField, 0, len(fields))
|
|
for _, field := range fields {
|
|
filtered = append(filtered, pas.applyFieldReadAccessControl(field, callerID))
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// getFieldsForValues fetches all unique fields associated with the given values.
|
|
// Returns a map of fieldID -> PropertyField.
|
|
// Returns an error if any field cannot be fetched.
|
|
func (pas *PropertyAccessService) getFieldsForValues(values []*model.PropertyValue) (map[string]*model.PropertyField, error) {
|
|
if len(values) == 0 {
|
|
return make(map[string]*model.PropertyField), nil
|
|
}
|
|
|
|
// Get unique field IDs and group ID
|
|
groupAndFieldIDs := make(map[string]map[string]struct{})
|
|
for _, value := range values {
|
|
if groupAndFieldIDs[value.GroupID] == nil {
|
|
groupAndFieldIDs[value.GroupID] = make(map[string]struct{})
|
|
}
|
|
groupAndFieldIDs[value.GroupID][value.FieldID] = struct{}{}
|
|
}
|
|
|
|
fieldMap := make(map[string]*model.PropertyField)
|
|
for groupID, fieldIDs := range groupAndFieldIDs {
|
|
// Convert field map to slice
|
|
fieldIDSlice := make([]string, 0, len(fieldIDs))
|
|
for fieldID := range fieldIDs {
|
|
fieldIDSlice = append(fieldIDSlice, fieldID)
|
|
}
|
|
|
|
// Fetch all fields
|
|
fields, err := pas.propertyService.getPropertyFields(groupID, fieldIDSlice)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch fields for values: %w", err)
|
|
}
|
|
|
|
// Build map for easy lookup
|
|
for _, field := range fields {
|
|
fieldMap[field.ID] = field
|
|
}
|
|
}
|
|
|
|
return fieldMap, nil
|
|
}
|
|
|
|
// applyValueReadAccessControl applies read access control to a list of values.
|
|
// Returns a new list containing only the values the caller can access, with shared_only values filtered.
|
|
// Values are silently filtered out if the caller doesn't have access.
|
|
func (pas *PropertyAccessService) applyValueReadAccessControl(values []*model.PropertyValue, callerID string) ([]*model.PropertyValue, error) {
|
|
if len(values) == 0 {
|
|
return values, nil
|
|
}
|
|
|
|
// Fetch all associated fields
|
|
fieldMap, err := pas.getFieldsForValues(values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("applyValueReadAccessControl: %w", err)
|
|
}
|
|
|
|
// Filter values based on field access
|
|
filtered := make([]*model.PropertyValue, 0, len(values))
|
|
for _, value := range values {
|
|
field, exists := fieldMap[value.FieldID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("applyValueReadAccessControl: field not found for value %s", value.ID)
|
|
}
|
|
|
|
accessMode := pas.getAccessMode(field)
|
|
|
|
// Check if caller can read this value
|
|
if pas.hasUnrestrictedFieldReadAccess(field, callerID) {
|
|
// Caller has unrestricted access (public or source plugin) - include as-is
|
|
filtered = append(filtered, value)
|
|
} else if accessMode == model.PropertyAccessModeSharedOnly {
|
|
// Shared-only mode: apply filtering
|
|
filteredValue := pas.filterSharedOnlyValue(field, value, callerID)
|
|
if filteredValue != nil {
|
|
filtered = append(filtered, filteredValue)
|
|
}
|
|
// If filteredValue is nil, skip this value (no intersection)
|
|
}
|
|
// For source_only mode where caller is not the source, skip the value
|
|
}
|
|
|
|
return filtered, nil
|
|
}
|