// 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 ( "bytes" "encoding/json" "errors" "fmt" "maps" "net/http" "slices" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) var ( ErrAccessDenied = errors.New("access denied") ErrSyncLocked = errors.New("field is managed by external sync") ErrInvalidAccessMode = errors.New("invalid access_mode") ErrFieldNotFound = errors.New("property field not found") ) const ( propertyAccessPaginationPageSize = 100 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 // AccessControlHook implements the PropertyHook interface to enforce access // control based on caller identity. It checks protected fields, plugin // ownership, and access modes (public, source-only, shared-only). // // The hook only applies to groups whose IDs are in managedGroupIDs. Operations // on other groups pass through without access control checks. type AccessControlHook struct { BasePropertyHook propertyService *PropertyService pluginChecker PluginChecker managedGroupIDs map[string]struct{} } // Compile-time check that AccessControlHook implements PropertyHook. var _ PropertyHook = (*AccessControlHook)(nil) // NewAccessControlHook creates a new AccessControlHook. // It receives the PropertyService to call private methods for database lookups // needed during access control checks. The pluginChecker function is used to // verify plugin installation status when checking access to protected fields. // Pass nil for pluginChecker if plugin checking is not needed (e.g., in tests). // managedGroupIDs lists the property group IDs that this hook enforces access // control for. Operations on groups not in this list are passed through. func NewAccessControlHook(ps *PropertyService, pluginChecker PluginChecker, managedGroupIDs ...string) *AccessControlHook { ids := make(map[string]struct{}, len(managedGroupIDs)) for _, id := range managedGroupIDs { ids[id] = struct{}{} } return &AccessControlHook{ propertyService: ps, pluginChecker: pluginChecker, managedGroupIDs: ids, } } // isGroupManaged checks whether the given group ID is managed by this hook. func (h *AccessControlHook) isGroupManaged(groupID string) bool { _, ok := h.managedGroupIDs[groupID] return ok } // Field Pre-Hooks // PreCreatePropertyField enforces access control on field creation. // When the caller is an installed plugin, source_plugin_id is automatically set. // When the caller is not a plugin, source_plugin_id and protected are rejected. // When linking to a source template, security attributes are validated and // inherited from the source. func (h *AccessControlHook) PreCreatePropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) { if !h.isGroupManaged(field.GroupID) { return field, nil } callerID := h.extractCallerID(rctx) if h.isCallerPlugin(callerID) { if field.Attrs == nil { field.Attrs = make(model.StringInterface) } field.Attrs[model.PropertyAttrsSourcePluginID] = callerID } else { if h.getSourcePluginID(field) != "" { return nil, fmt.Errorf("source_plugin_id can only be set by a plugin: %w", ErrAccessDenied) } if model.IsPropertyFieldProtected(field) { return nil, fmt.Errorf("protected can only be set by a plugin: %w", ErrAccessDenied) } } // Owners are managed only by administrators via the REST API. Machine // callers (plugins/sync) may not declare owners when creating a field. if h.isMachineCaller(callerID) && model.HasPropertyFieldOwners(field) { return nil, fmt.Errorf("owners can only be set by an administrator: %w", ErrAccessDenied) } if field.LinkedFieldID != nil && *field.LinkedFieldID != "" { if err := h.validateAndInheritLinkedFieldSecurity(rctx, callerID, field); err != nil { return nil, fmt.Errorf("PreCreatePropertyField: %w", err) } } if err := model.ValidatePropertyFieldAccessMode(field); err != nil { return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidAccessMode) } return field, nil } // validateAndInheritLinkedFieldSecurity enforces that linked fields inherit // the source template's security posture. If the source is protected, only // the source plugin may create linked fields. Security attrs (protected, // source_plugin_id, access_mode) are copied from the source onto the field. func (h *AccessControlHook) validateAndInheritLinkedFieldSecurity(rctx request.CTX, callerID string, field *model.PropertyField) error { source, err := h.propertyService.getPropertyFieldFromMaster(rctx, "", *field.LinkedFieldID) if err != nil { if store.IsErrNotFound(err) { return model.NewAppError( "CreatePropertyField", "app.property_field.create.linked_source_not_found.app_error", nil, fmt.Sprintf("linked source field %q not found", *field.LinkedFieldID), http.StatusBadRequest, ) } return fmt.Errorf("failed to get linked source field %q: %w", *field.LinkedFieldID, err) } if source.Attrs == nil || !model.IsPropertyFieldProtected(source) { return nil } sourcePluginID := h.getSourcePluginID(source) if sourcePluginID == "" || callerID != sourcePluginID { return model.NewAppError( "CreatePropertyField", "app.property_field.create.linked_source_protected.app_error", nil, "only the source plugin can create linked fields from a protected template", http.StatusForbidden, ) } if field.Attrs == nil { field.Attrs = make(model.StringInterface) } field.Attrs[model.PropertyAttrsProtected] = true field.Attrs[model.PropertyAttrsSourcePluginID] = sourcePluginID if v, ok := source.Attrs[model.PropertyAttrsAccessMode]; ok { field.Attrs[model.PropertyAttrsAccessMode] = v } return nil } // PreUpdatePropertyField enforces access control on field updates. // Checks write access and ensures source_plugin_id is not changed. func (h *AccessControlHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) { if !h.isGroupManaged(groupID) { return field, nil } callerID := h.extractCallerID(rctx) existingField, err := h.propertyService.getPropertyField(rctx, groupID, field.ID) if err != nil { return nil, err } if err := h.enforceFieldUpdateAccess(existingField, field, callerID); err != nil { return nil, err } if err := h.ensureSourcePluginIDUnchanged(existingField, field); err != nil { return nil, err } if err := h.validateProtectedFieldUpdate(field, callerID); err != nil { return nil, err } if err := model.ValidatePropertyFieldAccessMode(field); err != nil { return nil, fmt.Errorf("%s: %w", err.Error(), ErrInvalidAccessMode) } return field, nil } // PreUpdatePropertyFields enforces access control on batch field updates. // Checks write access for all fields atomically before allowing any updates. func (h *AccessControlHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) { if len(fields) == 0 || !h.isGroupManaged(groupID) { return fields, nil } callerID := h.extractCallerID(rctx) // Get field IDs fieldIDs := make([]string, len(fields)) for i, field := range fields { fieldIDs[i] = field.ID } existingFields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDs) if err != nil { return nil, err } existingFieldMap := make(map[string]*model.PropertyField, len(existingFields)) for _, field := range existingFields { existingFieldMap[field.ID] = field } for _, field := range fields { existingField, exists := existingFieldMap[field.ID] if !exists { return nil, fmt.Errorf("field %s: %w", field.ID, ErrFieldNotFound) } if err := h.enforceFieldUpdateAccess(existingField, field, callerID); err != nil { return nil, fmt.Errorf("field %s: %w", field.ID, err) } if err := h.ensureSourcePluginIDUnchanged(existingField, field); err != nil { return nil, fmt.Errorf("field %s: %w", field.ID, err) } if err := h.validateProtectedFieldUpdate(field, callerID); err != nil { return nil, fmt.Errorf("field %s: %w", field.ID, err) } if err := model.ValidatePropertyFieldAccessMode(field); err != nil { return nil, fmt.Errorf("field %s: %s: %w", field.ID, err.Error(), ErrInvalidAccessMode) } } return fields, nil } // PreCountPropertyFields is a no-op — counts don't expose per-row metadata, // so access control doesn't apply. License gating happens in LicenseCheckHook. func (h *AccessControlHook) PreCountPropertyFields(_ request.CTX, _ string) error { return nil } // PreDeletePropertyField enforces access control on field deletion. func (h *AccessControlHook) PreDeletePropertyField(rctx request.CTX, groupID string, id string) error { if !h.isGroupManaged(groupID) { return nil } callerID := h.extractCallerID(rctx) existingField, err := h.propertyService.getPropertyField(rctx, groupID, id) if err != nil { return err } return h.checkFieldDeleteAccess(existingField, callerID) } // PostUpdatePropertyFields is a no-op for access control; cleanup of dependent // values is handled by TypeChangeValueCleanupHook. func (h *AccessControlHook) PostUpdatePropertyFields(_ request.CTX, _ string, _, requested, propagated []*model.PropertyField) ([]*model.PropertyField, []*model.PropertyField, []string, error) { return requested, propagated, nil, nil } // Field Post-Hooks // PostGetPropertyField applies read access control to a single field. func (h *AccessControlHook) PostGetPropertyField(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) { if !h.isGroupManaged(field.GroupID) { return field, nil } callerID := h.extractCallerID(rctx) return h.applyFieldReadAccessControl(field, callerID), nil } // PostGetPropertyFields applies read access control to a list of fields. // All fields in a batch share the same GroupID (enforced by the public API). func (h *AccessControlHook) PostGetPropertyFields(rctx request.CTX, fields []*model.PropertyField) ([]*model.PropertyField, error) { if len(fields) == 0 { return fields, nil } if !h.isGroupManaged(fields[0].GroupID) { return fields, nil } callerID := h.extractCallerID(rctx) return h.applyFieldReadAccessControlToList(fields, callerID), nil } // Value Pre-Hooks // PreCreatePropertyValue enforces write access and sync locking on the value's field before creation. func (h *AccessControlHook) PreCreatePropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) { if !h.isGroupManaged(value.GroupID) { return value, nil } callerID := h.extractCallerID(rctx) field, err := h.propertyService.getPropertyField(rctx, value.GroupID, value.FieldID) if err != nil { return nil, err } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, err } return value, nil } // PreCreatePropertyValues enforces write access and sync locking for all fields atomically before creation. // All values in a batch share the same GroupID (enforced by the public API). func (h *AccessControlHook) PreCreatePropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) { return values, nil } callerID := h.extractCallerID(rctx) fieldMap, err := h.getFieldsForValues(rctx, values) if err != nil { return nil, err } for _, value := range values { field, exists := fieldMap[value.FieldID] if !exists { return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound) } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, fmt.Errorf("field %s: %w", value.FieldID, err) } } return values, nil } // PreUpdatePropertyValue enforces write access and sync locking on the value's field before update. func (h *AccessControlHook) PreUpdatePropertyValue(rctx request.CTX, groupID string, value *model.PropertyValue) (*model.PropertyValue, error) { if !h.isGroupManaged(groupID) { return value, nil } callerID := h.extractCallerID(rctx) field, err := h.propertyService.getPropertyField(rctx, groupID, value.FieldID) if err != nil { return nil, err } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, err } return value, nil } // PreUpdatePropertyValues enforces write access and sync locking for all fields atomically before update. // All values in a batch share the same GroupID (enforced by the public API). func (h *AccessControlHook) PreUpdatePropertyValues(rctx request.CTX, groupID string, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if len(values) == 0 || !h.isGroupManaged(groupID) { return values, nil } callerID := h.extractCallerID(rctx) fieldMap, err := h.getFieldsForValues(rctx, values) if err != nil { return nil, err } for _, value := range values { field, exists := fieldMap[value.FieldID] if !exists { return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound) } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, fmt.Errorf("field %s: %w", value.FieldID, err) } } return values, nil } // PreUpsertPropertyValue enforces write access and sync locking on the value's field before upsert. func (h *AccessControlHook) PreUpsertPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) { if !h.isGroupManaged(value.GroupID) { return value, nil } callerID := h.extractCallerID(rctx) field, err := h.propertyService.getPropertyField(rctx, value.GroupID, value.FieldID) if err != nil { return nil, err } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, err } return value, nil } // PreUpsertPropertyValues enforces write access and sync locking for all fields atomically before upsert. // All values in a batch share the same GroupID (enforced by the public API). func (h *AccessControlHook) PreUpsertPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) { return values, nil } callerID := h.extractCallerID(rctx) fieldMap, err := h.getFieldsForValues(rctx, values) if err != nil { return nil, err } for _, value := range values { field, exists := fieldMap[value.FieldID] if !exists { return nil, fmt.Errorf("field %s: %w", value.FieldID, ErrFieldNotFound) } if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return nil, fmt.Errorf("field %s: %w", value.FieldID, err) } } return values, nil } // PreDeletePropertyValue enforces write access before deleting a value. func (h *AccessControlHook) PreDeletePropertyValue(rctx request.CTX, groupID string, id string) error { if !h.isGroupManaged(groupID) { return nil } callerID := h.extractCallerID(rctx) value, err := h.propertyService.getPropertyValue(groupID, id) if err != nil { return err } field, err := h.propertyService.getPropertyField(rctx, groupID, value.FieldID) if err != nil { return err } return h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)) } // PreDeletePropertyValuesForTarget enforces write access for all affected fields // before deleting all values for a target. func (h *AccessControlHook) PreDeletePropertyValuesForTarget(rctx request.CTX, groupID string, targetType string, targetID string) error { if !h.isGroupManaged(groupID) { return nil } callerID := h.extractCallerID(rctx) // 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("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 err } for _, value := range values { fieldIDs[value.FieldID] = struct{}{} } if len(values) < propertyAccessPaginationPageSize { break } lastValue := values[len(values)-1] cursor = model.PropertyValueSearchCursor{ PropertyValueID: lastValue.ID, CreateAt: lastValue.CreateAt, } } if len(fieldIDs) == 0 { return nil } fieldIDSlice := make([]string, 0, len(fieldIDs)) for fieldID := range fieldIDs { fieldIDSlice = append(fieldIDSlice, fieldID) } fields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDSlice) if err != nil { return err } for _, field := range fields { if err := h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)); err != nil { return fmt.Errorf("field %s: %w", field.ID, err) } } return nil } // PreDeletePropertyValuesForField enforces write access before deleting all values for a field. func (h *AccessControlHook) PreDeletePropertyValuesForField(rctx request.CTX, groupID string, fieldID string) error { if !h.isGroupManaged(groupID) { return nil } callerID := h.extractCallerID(rctx) field, err := h.propertyService.getPropertyField(rctx, groupID, fieldID) if err != nil { return err } return h.checkValueWriteAccess(field, callerID, h.extractActingAsScope(rctx)) } // Value Post-Hooks // PostGetPropertyValue applies read access control to a single value. // Returns nil if the caller doesn't have access. func (h *AccessControlHook) PostGetPropertyValue(rctx request.CTX, value *model.PropertyValue) (*model.PropertyValue, error) { if value == nil { return nil, nil } if !h.isGroupManaged(value.GroupID) { return value, nil } callerID := h.extractCallerID(rctx) filtered, err := h.applyValueReadAccessControl(rctx, []*model.PropertyValue{value}, callerID) if err != nil { return nil, err } if len(filtered) == 0 { return nil, nil } return filtered[0], nil } // PostGetPropertyValues applies read access control to a list of values. // Values the caller doesn't have access to are silently filtered out. // All values in a batch share the same GroupID (enforced by the public API). func (h *AccessControlHook) PostGetPropertyValues(rctx request.CTX, values []*model.PropertyValue) ([]*model.PropertyValue, error) { if len(values) == 0 || !h.isGroupManaged(values[0].GroupID) { return values, nil } callerID := h.extractCallerID(rctx) return h.applyValueReadAccessControl(rctx, values, callerID) } // Access Control Helper Methods // extractCallerID gets the caller ID from a request context using the property service's extractor. func (h *AccessControlHook) extractCallerID(rctx request.CTX) string { return h.propertyService.extractCallerID(rctx) } // extractActingAsScope gets the caller's acting-as scope from a request context // using the property service's extractor. func (h *AccessControlHook) extractActingAsScope(rctx request.CTX) string { return h.propertyService.extractRequestOptions(rctx).ActingAsScope } // isCallerPlugin checks whether the callerID corresponds to an installed plugin. func (h *AccessControlHook) isCallerPlugin(callerID string) bool { return callerID != "" && h.pluginChecker != nil && h.pluginChecker(callerID) } // isMachineCaller reports whether the caller is a machine actor (an installed // plugin or a built-in sync service) rather than a human. Owner-list // enforcement applies only to machine callers; human callers (session users // and local admins) are governed by the API-layer permission levels. func (h *AccessControlHook) isMachineCaller(callerID string) bool { return h.isCallerPlugin(callerID) || callerID == model.CallerIDLDAPSync || callerID == model.CallerIDSAMLSync } // callerOwnerIdentity maps a machine caller (and its acting-as scope) to the // owner identity it would match in a field's owners list. A built-in sync // service is a singleton (one LDAP, one SAML), so its owner type is "service" // and it carries no scope; for a plugin the manifest ID is the owner ID and the // scope is whatever the plugin declared on the request context. func (h *AccessControlHook) callerOwnerIdentity(callerID, scope string) (ownerID, ownerType, effectiveScope string) { switch callerID { case model.CallerIDLDAPSync: return model.PropertyFieldAttrLDAP, model.PropertyOwnerTypeService, "" case model.CallerIDSAMLSync: return model.PropertyFieldAttrSAML, model.PropertyOwnerTypeService, "" default: return callerID, model.PropertyOwnerTypePlugin, scope } } // effectiveOwners returns the owners list used for value-write access checks on // an owner-managed field. Explicit owners from the attrs blob are augmented // with implicit service owners derived from attrs.ldap / attrs.saml so a field // can be written by both a listed plugin/scope and its legacy sync source. // Implicit service owners are only added when explicit owners are present; // legacy synced-only fields continue through checkSyncLock instead. func (h *AccessControlHook) effectiveOwners(field *model.PropertyField) []model.PropertyOwner { owners := model.GetPropertyFieldOwners(field) if len(owners) == 0 || field.Attrs == nil { return owners } if ldap, _ := field.Attrs[model.PropertyFieldAttrLDAP].(string); ldap != "" { owners = append(owners, model.PropertyOwner{ ID: model.PropertyFieldAttrLDAP, Type: model.PropertyOwnerTypeService, }) } if saml, _ := field.Attrs[model.PropertyFieldAttrSAML].(string); saml != "" { owners = append(owners, model.PropertyOwner{ ID: model.PropertyFieldAttrSAML, Type: model.PropertyOwnerTypeService, }) } return owners } // checkOwnerValueWriteAccess enforces a field's owners list on a value write. // A machine caller is allowed only if it is a listed owner (matching ID and // type) whose scopes contain the caller's acting-as scope. An owner with an // empty scopes list is not restricted by scope and may write for any scope. // // Human callers are always rejected: an owner-managed field's values are // authoritative to the owning integration, so no session user — including // sysadmins — may overwrite them. This mirrors checkSyncLock for ldap/saml // fields and is the sole write authority for owner-managed fields (their // PermissionValues are left at the normal default and are not consulted here). func (h *AccessControlHook) checkOwnerValueWriteAccess(field *model.PropertyField, callerID, scope string) error { if !h.isMachineCaller(callerID) { return fmt.Errorf("field %s is owner-managed and cannot be modified by human caller %q: %w", field.ID, callerID, ErrAccessDenied) } ownerID, ownerType, effectiveScope := h.callerOwnerIdentity(callerID, scope) for _, owner := range h.effectiveOwners(field) { if owner.Type == ownerType && owner.ID == ownerID && (len(owner.Scopes) == 0 || slices.Contains(owner.Scopes, effectiveScope)) { return nil } } return fmt.Errorf("field %s is owner-managed and caller %q acting as scope %q is not an owner with a matching scope: %w", field.ID, callerID, effectiveScope, ErrAccessDenied) } // isListedOwner reports whether the machine caller matches an explicit owner // entry on the field (by id and type). Scope is not consulted: being a listed // owner is what authorizes managing the field; scope only gates value writes. func (h *AccessControlHook) isListedOwner(field *model.PropertyField, callerID string) bool { ownerID, ownerType, _ := h.callerOwnerIdentity(callerID, "") for _, owner := range model.GetPropertyFieldOwners(field) { if owner.Type == ownerType && owner.ID == ownerID { return true } } return false } // enforceFieldUpdateAccess gates a field-definition update. // // Owner-managed fields allow a machine caller only if it is a listed owner // (checked against the stored owners, so a non-owner cannot add itself). A // listed owner may edit the whole definition including the owners attr. Human // callers pass through to the API-layer sysadmin pin. // // For non-owner-managed fields, machine callers may not add owners, and legacy // protected / source_plugin_id rules continue to apply. func (h *AccessControlHook) enforceFieldUpdateAccess(existing, updated *model.PropertyField, callerID string) error { if model.HasPropertyFieldOwners(existing) { if h.isMachineCaller(callerID) && !h.isListedOwner(existing, callerID) { return fmt.Errorf("field %s is owner-managed and can only be modified by an administrator or a listed owner: %w", existing.ID, ErrAccessDenied) } return nil } if h.isMachineCaller(callerID) && model.HasPropertyFieldOwners(updated) { return fmt.Errorf("owners can only be set by an administrator: %w", ErrAccessDenied) } return h.checkLegacyFieldWriteAccess(existing, callerID) } // getSourcePluginID extracts the source_plugin_id from a PropertyField's attrs. func (h *AccessControlHook) 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. func (h *AccessControlHook) 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 } // hasUnrestrictedFieldReadAccess 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). func (h *AccessControlHook) hasUnrestrictedFieldReadAccess(field *model.PropertyField, callerID string) bool { accessMode := h.getAccessMode(field) if accessMode == model.PropertyAccessModePublic { return true } sourcePluginID := h.getSourcePluginID(field) if sourcePluginID != "" && sourcePluginID == callerID { return true } return false } // ensureSourcePluginIDUnchanged checks that the source_plugin_id attribute hasn't changed between fields. func (h *AccessControlHook) ensureSourcePluginIDUnchanged(existingField, updatedField *model.PropertyField) error { existingSourcePluginID := h.getSourcePluginID(existingField) updatedSourcePluginID := h.getSourcePluginID(updatedField) if existingSourcePluginID != updatedSourcePluginID { return fmt.Errorf("source_plugin_id is immutable and cannot be changed from '%s' to '%s': %w", existingSourcePluginID, updatedSourcePluginID, ErrAccessDenied) } return nil } // validateProtectedFieldUpdate validates that a field can be updated to protected=true. func (h *AccessControlHook) validateProtectedFieldUpdate(updatedField *model.PropertyField, callerID string) error { if !model.IsPropertyFieldProtected(updatedField) { return nil } sourcePluginID := h.getSourcePluginID(updatedField) if sourcePluginID == "" { return fmt.Errorf("cannot set protected=true on a field without a source_plugin_id: %w", ErrAccessDenied) } if sourcePluginID != callerID { return fmt.Errorf("cannot set protected=true: only source plugin '%s' can modify this field: %w", sourcePluginID, ErrAccessDenied) } return nil } // checkLegacyFieldWriteAccess enforces the protected / source_plugin_id rules on // a non-owner-managed field. Owner-managed fields are gated by // enforceFieldUpdateAccess; callers must confirm the field has no owners before // calling this. // IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller. func (h *AccessControlHook) checkLegacyFieldWriteAccess(field *model.PropertyField, callerID string) error { if !model.IsPropertyFieldProtected(field) { return nil } sourcePluginID := h.getSourcePluginID(field) if sourcePluginID == "" { return fmt.Errorf("field %s is protected, but has no associated source plugin: %w", field.ID, ErrAccessDenied) } if sourcePluginID != callerID { return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s': %w", field.ID, sourcePluginID, ErrAccessDenied) } 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. func (h *AccessControlHook) checkFieldDeleteAccess(field *model.PropertyField, callerID string) error { if model.HasPropertyFieldOwners(field) { if h.isMachineCaller(callerID) && !h.isListedOwner(field, callerID) { return fmt.Errorf("field %s is owner-managed and can only be deleted by an administrator or a listed owner: %w", field.ID, ErrAccessDenied) } return nil } if !model.IsPropertyFieldProtected(field) { return nil } sourcePluginID := h.getSourcePluginID(field) if sourcePluginID == "" { return nil } if h.pluginChecker != nil && !h.pluginChecker(sourcePluginID) { return nil } if sourcePluginID != callerID { return fmt.Errorf("field %s is protected and can only be modified by source plugin '%s': %w", field.ID, sourcePluginID, ErrAccessDenied) } return nil } // checkSyncLock checks whether the caller is allowed to write values for a // synced field. Synced fields have an ldap or saml attr set, and only the // corresponding sync service (identified by well-known caller IDs) may write // their values. func (h *AccessControlHook) checkSyncLock(field *model.PropertyField, callerID string) error { syncSource := model.GetPropertyFieldSyncSource(field) if syncSource == "" { return nil } // Map sync source to the expected caller ID var expectedCallerID string switch syncSource { case "ldap": expectedCallerID = model.CallerIDLDAPSync case "saml": expectedCallerID = model.CallerIDSAMLSync default: return fmt.Errorf("field %s has unknown sync source %q: %w", field.ID, syncSource, ErrInvalidFieldAttrs) } if callerID != expectedCallerID { return fmt.Errorf("field %s is managed by %s sync and cannot be modified by caller %q: %w", field.ID, syncSource, callerID, ErrSyncLocked) } return nil } // checkValueWriteAccess gates a value write. When the field declares an owners // list, the owner check (with scope matching) supersedes the legacy // protected-field and sync-lock checks. Otherwise it falls back to today's // behaviour. func (h *AccessControlHook) checkValueWriteAccess(field *model.PropertyField, callerID, scope string) error { if model.HasPropertyFieldOwners(field) { return h.checkOwnerValueWriteAccess(field, callerID, scope) } if err := h.checkLegacyFieldWriteAccess(field, callerID); err != nil { return err } return h.checkSyncLock(field, callerID) } // getCallerValuesForField retrieves all property values for the caller on a specific field. func (h *AccessControlHook) 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("exceeded maximum pagination iterations (%d)", propertyAccessMaxPaginationIterations) } opts := model.PropertyValueSearchOpts{ FieldID: fieldID, TargetIDs: []string{callerID}, PerPage: propertyAccessPaginationPageSize, } if !cursor.IsEmpty() { opts.Cursor = cursor } values, err := h.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 len(values) < propertyAccessPaginationPageSize { break } 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. func 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, model.PropertyFieldTypeRank: 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, multiselect and rank field types, got: %s", fieldType) } return optionIDs, nil } // copyPropertyField returns a copy of a PropertyField with a fresh Attrs map. // The Attrs copy is shallow: nested slices/maps (notably Attrs["options"]) // share backing storage with the original. That is safe today because // filterSharedOnlyFieldOptions replaces Attrs["options"] wholesale rather // than mutating in place. A future hook that mutates a nested value in the // returned copy would also mutate the caller's original — deep-copy those // entries if that changes. func (h *AccessControlHook) 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. func (h *AccessControlHook) getCallerOptionIDsForField(groupID, fieldID, callerID string, fieldType model.PropertyFieldType) (map[string]struct{}, error) { callerValues, err := h.getCallerValuesForField(groupID, fieldID, callerID) if err != nil { return make(map[string]struct{}), err } if len(callerValues) == 0 { return make(map[string]struct{}), nil } callerOptionIDs := make(map[string]struct{}) for _, val := range callerValues { optionIDs, err := 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. // // Rank fields are the exception: rather than exact-match membership, a rank // field exposes every option at or below the caller's own rank ("everything at // your rank and lower"), so a higher-cleared caller sees the full ladder up to // their level. See filterSharedOnlyRankFieldOptions. func (h *AccessControlHook) filterSharedOnlyFieldOptions(field *model.PropertyField, callerID string) *model.PropertyField { if !field.Type.SupportsOptions() { return field } if field.Type == model.PropertyFieldTypeRank { return h.filterSharedOnlyRankFieldOptions(field, callerID) } callerOptionIDs, err := h.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type) if err != nil || len(callerOptionIDs) == 0 { filteredField := h.copyPropertyField(field) filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{} return filteredField } if field.Attrs == nil { return field } optionsArr, ok := field.Attrs[model.PropertyFieldAttributeOptions] if !ok { return field } optionsSlice, ok := optionsArr.([]any) if !ok { return field } 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) } } filteredField := h.copyPropertyField(field) filteredField.Attrs[model.PropertyFieldAttributeOptions] = filteredOptions return filteredField } // filterSharedOnlyRankFieldOptions filters a rank field's options to those at // or below the caller's own rank, rather than the exact-match intersection // used for select/multiselect. A caller who holds no value for the field (and // therefore has no rank) sees no options. func (h *AccessControlHook) filterSharedOnlyRankFieldOptions(field *model.PropertyField, callerID string) *model.PropertyField { // Bail out before building the rank map or the caller-rank store lookup when // there are no options to filter: an absent or malformed options array has // nothing to hide, so the field is returned untouched. if field.Attrs == nil { return field } optionsArr, ok := field.Attrs[model.PropertyFieldAttributeOptions] if !ok { return field } optionsSlice, ok := optionsArr.([]any) if !ok { return field } rankByID := buildOptionRankMap(field) callerRank, ok := h.callerRankForField(field, callerID, rankByID) if !ok { filteredField := h.copyPropertyField(field) filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{} return filteredField } filteredOptions := []any{} for _, opt := range optionsSlice { optMap, ok := opt.(map[string]any) if !ok { continue } optID, ok := optMap["id"].(string) if !ok { continue } rank, ok := rankByID[optID] if !ok { continue } if rank <= callerRank { filteredOptions = append(filteredOptions, opt) } } filteredField := h.copyPropertyField(field) filteredField.Attrs[model.PropertyFieldAttributeOptions] = filteredOptions return filteredField } // callerRankForField returns the rank the caller holds for a rank field, using // the already-built option-ID-to-rank map. A rank field is select-shaped (a // single value per user), so the caller has at most one option; we take it. // ok is false when the caller has no value for the field or the option carries // no rank, in which case the caller has no clearance and sees nothing. func (h *AccessControlHook) callerRankForField(field *model.PropertyField, callerID string, rankByID map[string]int) (int, bool) { callerOptionIDs, err := h.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type) if err != nil || len(callerOptionIDs) == 0 { return 0, false } var callerOptionID string for id := range callerOptionIDs { callerOptionID = id break } rank, ok := rankByID[callerOptionID] return rank, ok } // buildOptionRankMap returns a map of option ID to rank for a rank field. // Options without a rank are skipped. func buildOptionRankMap(field *model.PropertyField) map[string]int { out := map[string]int{} if field.Attrs == nil { return out } rawOpts, ok := field.Attrs[model.PropertyFieldAttributeOptions] if !ok { return out } opts, err := model.NewPropertyOptionsFromFieldAttrs[*model.CustomProfileAttributesSelectOption](rawOpts) if err != nil { return out } for _, o := range opts { if o.Rank == nil { continue } out[o.ID] = *o.Rank } return out } // filterSharedOnlyValue computes the intersection of caller and target values for shared_only fields. // Returns the filtered value or nil if there's no intersection. // - rank: clearance-style. The caller sees the option at the highest rank they share with the // target — the target's own value when its rank is at or below the caller's, otherwise the // value clamped down to the option at the caller's own rank. See filterSharedOnlyRankValue. // - select / multiselect: per-value intersection (a multi-value field may return a subset). // - text / date / user / any other primitive type: binary — visible only if the caller's // stored value equals the target's value exactly. Otherwise nil. // // The binary path is what protects scenarios like LDAP/SAML-synced text codenames whose // existence is itself controlled information: a caller who doesn't hold the same value // must not see the target's value through any read endpoint. func (h *AccessControlHook) filterSharedOnlyValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue { if field.Type == model.PropertyFieldTypeRank { return h.filterSharedOnlyRankValue(field, value, callerID) } if field.Type != model.PropertyFieldTypeSelect && field.Type != model.PropertyFieldTypeMultiselect { return h.filterSharedOnlyScalarValue(field, value, callerID) } callerOptionIDs, err := h.getCallerOptionIDsForField(field.GroupID, field.ID, callerID, field.Type) if err != nil || len(callerOptionIDs) == 0 { return nil } targetOptionIDs, err := extractOptionIDsFromValue(field.Type, value.Value) if err != nil || targetOptionIDs == nil || len(targetOptionIDs) == 0 { return nil } intersection := []string{} for targetID := range targetOptionIDs { if _, exists := callerOptionIDs[targetID]; exists { intersection = append(intersection, targetID) } } if len(intersection) == 0 { return nil } filteredValue := *value switch field.Type { case model.PropertyFieldTypeSelect: jsonValue, err := json.Marshal(intersection[0]) if err != nil { return nil } filteredValue.Value = jsonValue return &filteredValue case model.PropertyFieldTypeMultiselect: jsonValue, err := json.Marshal(intersection) if err != nil { return nil } filteredValue.Value = jsonValue return &filteredValue default: return nil } } // filterSharedOnlyRankValue returns the target's value clamped to the highest // rank the caller shares with the target: the target's own value when its rank // is at or below the caller's, otherwise the value rewritten to the option at // the caller's own rank. The caller therefore always learns the highest level // they have in common ("what can we talk about, and at what level") rather than // seeing nothing when the target outranks them, but never sees a rank above // their own. This differs from select/multiselect, which require an exact // option match. A caller who holds no value of their own (and therefore has no // rank) sees nothing. A rank field is select-shaped, so the target has at most // one option. func (h *AccessControlHook) filterSharedOnlyRankValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue { rankByID := buildOptionRankMap(field) callerRank, ok := h.callerRankForField(field, callerID, rankByID) if !ok { return nil } targetOptionIDs, err := extractOptionIDsFromValue(field.Type, value.Value) if err != nil || len(targetOptionIDs) == 0 { return nil } for targetID := range targetOptionIDs { targetRank, ok := rankByID[targetID] if !ok { continue } if targetRank <= callerRank { filtered := *value return &filtered } // The target outranks the caller: clamp the value down to the option at // the caller's own rank, the highest rung they share with the target. return h.clampRankValueToRank(value, rankByID, callerRank) } return nil } // clampRankValueToRank returns a copy of value rewritten to the rank field's // option at the given rank. Ranks are unique per field, so exactly one option // matches. Returns nil if no option carries the rank (not expected, since the // rank is taken from an existing option) or the rewrite fails. func (h *AccessControlHook) clampRankValueToRank(value *model.PropertyValue, rankByID map[string]int, rank int) *model.PropertyValue { var optionID string for id, r := range rankByID { if r == rank { optionID = id break } } if optionID == "" { return nil } jsonValue, err := json.Marshal(optionID) if err != nil { return nil } clamped := *value clamped.Value = jsonValue return &clamped } // filterSharedOnlyScalarValue applies binary masking to a non-option field's value: // returns the value as-is if the caller's own stored value for the same field equals // the target's value, otherwise nil. Caller and target may legitimately store nothing, // in which case the value is hidden. func (h *AccessControlHook) filterSharedOnlyScalarValue(field *model.PropertyField, value *model.PropertyValue, callerID string) *model.PropertyValue { if value == nil || len(value.Value) == 0 { return nil } callerValues, err := h.getCallerValuesForField(field.GroupID, field.ID, callerID) if err != nil || len(callerValues) == 0 { return nil } for _, cv := range callerValues { if bytes.Equal(cv.Value, value.Value) { filtered := *value return &filtered } } 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 // - Any access mode when the caller is the field's source plugin: returned as-is // - Shared-only fields: returned with options filtered using filterSharedOnlyFieldOptions // - Source-only or unknown access modes: returned with empty options (secure default) func (h *AccessControlHook) applyFieldReadAccessControl(field *model.PropertyField, callerID string) *model.PropertyField { if h.hasUnrestrictedFieldReadAccess(field, callerID) { return field } accessMode := h.getAccessMode(field) if accessMode == model.PropertyAccessModeSharedOnly { return h.filterSharedOnlyFieldOptions(field, callerID) } // Source-only or unknown: return with empty options (secure default) filteredField := h.copyPropertyField(field) if field.Type.SupportsOptions() { filteredField.Attrs[model.PropertyFieldAttributeOptions] = []any{} } return filteredField } // applyFieldReadAccessControlToList applies read access control to a list of fields. func (h *AccessControlHook) 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, h.applyFieldReadAccessControl(field, callerID)) } return filtered } // getFieldsForValues fetches all unique fields associated with the given values. func (h *AccessControlHook) getFieldsForValues(rctx request.CTX, values []*model.PropertyValue) (map[string]*model.PropertyField, error) { if len(values) == 0 { return make(map[string]*model.PropertyField), nil } 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 { fieldIDSlice := make([]string, 0, len(fieldIDs)) for fieldID := range fieldIDs { fieldIDSlice = append(fieldIDSlice, fieldID) } fields, err := h.propertyService.getPropertyFields(rctx, groupID, fieldIDSlice) if err != nil { return nil, fmt.Errorf("failed to fetch fields for values: %w", err) } for _, field := range fields { fieldMap[field.ID] = field } } return fieldMap, nil } // applyValueReadAccessControl applies read access control to a list of values. func (h *AccessControlHook) applyValueReadAccessControl(rctx request.CTX, values []*model.PropertyValue, callerID string) ([]*model.PropertyValue, error) { if len(values) == 0 { return values, nil } fieldMap, err := h.getFieldsForValues(rctx, values) if err != nil { return nil, fmt.Errorf("applyValueReadAccessControl: %w", err) } 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 := h.getAccessMode(field) if h.hasUnrestrictedFieldReadAccess(field, callerID) { filtered = append(filtered, value) } else if accessMode == model.PropertyAccessModeSharedOnly { filteredValue := h.filterSharedOnlyValue(field, value, callerID) if filteredValue != nil { filtered = append(filtered, filteredValue) } } // For source_only mode where caller is not the source, skip the value } return filtered, nil }