mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Refactor property system with app layer routing and access control separation Establish the app layer as the primary entry point for property operations with intelligent routing based on group type. This architecture separates access-controlled operations (CPA groups) from standard operations, improving performance and code clarity. Architecture Changes: - App layer now routes operations based on group type: - CPA groups -> PropertyAccessService (enforces access control) - Non-CPA groups -> PropertyService (direct, no access control) - PropertyAccessService simplified to handle only CPA operations - Eliminated redundant group type checks throughout the codebase * Move access control routing into PropertyService This change makes the PropertyService the main entrypoint for property related operations, and adds a routing mechanism to decide if extra behaviors or checks should run for each operation, in this case, the property access service logic. To add specific payloads that pluggable checks and operations may need, we use the request context. When the request comes from the API, the endpoints are in charge of adding the caller ID to the payload, and in the case of the plugin API, on receiving a request, the server automatically tags the context with the plugin ID so the property service can react accordingly. Finally, the new design enforces all these checks migrating the actual property logic to internal, non-exposed methods, so any caller from the App layer needs to go through the service checks that decide if pluggable logic is needed, avoiding any possibility of a bypass. * Fix i18n * Fix bad error string * Added nil guards to property methods * Add check for multiple group IDs on value operations * Add nil guard to the plugin checker * Fix build error * Update value tests * Fix linter * Adds early return when content flaggin a thread with no replies * Fix mocks * Clean the state of plugin property tests before each run * Do not wrap appErr on API response and fix i18n * Fix create property field test * Remove the need to cache cpaGroupID as part of the property service * Split the property.go file into multiple * Not found group doesn't bypass access control check * Unexport SetPluginCheckerForTests * Rename plugin context getter to be more PSA specific --------- Co-authored-by: Miguel de la Cruz <[email protected]>
1238 lines
44 KiB
Go
1238 lines
44 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package properties
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGetPropertyFieldReadAccess(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
// only "plugin-1" and "test-plugin" will be checked as installed
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "test-plugin"
|
|
})
|
|
|
|
pluginID1 := "plugin-1"
|
|
pluginID2 := "plugin-2"
|
|
userID := model.NewId()
|
|
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
|
|
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
|
|
rctxUser := RequestContextWithCallerID(th.Context, userID)
|
|
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
|
|
|
|
t.Run("public field - any caller can read without filtering", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "public-field",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.NoError(t, err)
|
|
|
|
// Plugin 1 can read
|
|
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
|
|
// Plugin 2 can read
|
|
retrieved, err = th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
|
|
// User can read
|
|
retrieved, err = th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
|
|
// Anonymous caller can read
|
|
retrieved, err = th.service.GetPropertyField(rctxAnon, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
})
|
|
|
|
t.Run("source_only field - source plugin gets all options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-only-field",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Secret Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Source plugin can see all options
|
|
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
})
|
|
|
|
t.Run("source_only field - other plugin gets empty options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-only-field-2",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Secret Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Other plugin gets field but with empty options
|
|
retrieved, err := th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
|
|
t.Run("source_only field - user gets empty options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-only-field-3",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Secret Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// User gets field but with empty options
|
|
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
|
|
t.Run("source_only field - anonymous caller gets empty options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-only-field-4",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Secret Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Anonymous caller gets field but with empty options
|
|
retrieved, err := th.service.GetPropertyField(rctxAnon, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
|
|
t.Run("shared_only field - caller with values sees filtered options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "shared-only-field",
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
map[string]any{"id": "opt3", "value": "Option 3"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
|
|
require.NoError(t, err)
|
|
|
|
// Create values for the caller (userID has opt1 and opt2)
|
|
value1, jsonErr := json.Marshal([]string{"opt1", "opt2"})
|
|
require.NoError(t, jsonErr)
|
|
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
|
|
GroupID: th.CPAGroupID,
|
|
FieldID: created.ID,
|
|
TargetType: "user",
|
|
TargetID: userID,
|
|
Value: value1,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// User should only see opt1 and opt2
|
|
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
options := retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
assert.Len(t, options, 2)
|
|
|
|
// Verify the options are opt1 and opt2
|
|
optionIDs := make([]string, 0, len(options))
|
|
for _, opt := range options {
|
|
optMap := opt.(map[string]any)
|
|
optionIDs = append(optionIDs, optMap["id"].(string))
|
|
}
|
|
assert.Contains(t, optionIDs, "opt1")
|
|
assert.Contains(t, optionIDs, "opt2")
|
|
assert.NotContains(t, optionIDs, "opt3")
|
|
})
|
|
|
|
t.Run("shared_only field - caller with no values sees empty options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "shared-only-field-2",
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
|
|
require.NoError(t, err)
|
|
|
|
// User has no values for this field
|
|
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
|
|
t.Run("shared_only field - source plugin gets all options", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "shared-only-field-source",
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
|
|
model.PropertyAttrsSourcePluginID: pluginID1,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
map[string]any{"id": "opt3", "value": "Option 3"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Source plugin can see all options even without having any values
|
|
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
options := retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
assert.Len(t, options, 3)
|
|
|
|
// Other plugin with no values sees empty options
|
|
retrieved, err = th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
|
|
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
|
|
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-routing-read")
|
|
require.NoError(t, err)
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: nonCpaGroup.ID,
|
|
Name: "routing-test-non-cpa-source-only",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyAttrsSourcePluginID: pluginID1,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
},
|
|
},
|
|
}
|
|
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
|
|
created, err := th.service.CreatePropertyField(rctx1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Other plugin sees ALL options (no filtering, goes directly to PropertyService)
|
|
rctx2 := RequestContextWithCallerID(th.Context, "plugin-2")
|
|
retrieved, err := th.service.GetPropertyField(rctx2, nonCpaGroup.ID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
|
|
})
|
|
|
|
t.Run("field with no attrs defaults to public", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "no-attrs-field",
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "user",
|
|
Attrs: nil,
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.NoError(t, err)
|
|
|
|
// Any caller can read
|
|
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
})
|
|
|
|
t.Run("field with empty access_mode defaults to public", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "empty-access-mode-field",
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.NoError(t, err)
|
|
|
|
// Any caller can read
|
|
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, retrieved.ID)
|
|
})
|
|
|
|
t.Run("field with invalid access_mode is rejected", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "invalid-access-mode-field",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: "invalid-mode",
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
},
|
|
},
|
|
}
|
|
_, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid access mode")
|
|
})
|
|
}
|
|
|
|
func TestGetPropertyFieldsReadAccess(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "test-plugin"
|
|
})
|
|
|
|
pluginID := "plugin-1"
|
|
userID := model.NewId()
|
|
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
|
|
rctxUser := RequestContextWithCallerID(th.Context, userID)
|
|
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
|
|
|
|
// Create multiple fields with different access modes
|
|
publicField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "public-field",
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
|
|
},
|
|
}
|
|
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
|
|
require.NoError(t, err)
|
|
|
|
sourceOnlyField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-only-field",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option"},
|
|
},
|
|
},
|
|
}
|
|
sourceOnlyField, err = th.service.CreatePropertyField(rctxPlugin, sourceOnlyField)
|
|
require.NoError(t, err)
|
|
|
|
sharedOnlyField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "shared-only-field",
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
},
|
|
},
|
|
}
|
|
sharedOnlyField, err = th.service.CreatePropertyField(rctxTestPlugin, sharedOnlyField)
|
|
require.NoError(t, err)
|
|
|
|
// Create a value for userID on the shared field (opt1)
|
|
value, jsonErr := json.Marshal([]string{"opt1"})
|
|
require.NoError(t, jsonErr)
|
|
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
|
|
GroupID: th.CPAGroupID,
|
|
FieldID: sharedOnlyField.ID,
|
|
TargetType: "user",
|
|
TargetID: userID,
|
|
Value: value,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("source plugin sees all fields with full options", func(t *testing.T) {
|
|
fields, err := th.service.GetPropertyFields(rctxPlugin, th.CPAGroupID, []string{publicField.ID, sourceOnlyField.ID, sharedOnlyField.ID})
|
|
require.NoError(t, err)
|
|
require.Len(t, fields, 3)
|
|
|
|
// Find each field and verify
|
|
for _, field := range fields {
|
|
if field.ID == sourceOnlyField.ID {
|
|
// Source plugin sees all options
|
|
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("user sees all fields with filtered options", func(t *testing.T) {
|
|
fields, err := th.service.GetPropertyFields(rctxUser, th.CPAGroupID, []string{publicField.ID, sourceOnlyField.ID, sharedOnlyField.ID})
|
|
require.NoError(t, err)
|
|
require.Len(t, fields, 3)
|
|
|
|
// Find each field and verify
|
|
for _, field := range fields {
|
|
if field.ID == sourceOnlyField.ID {
|
|
// User sees empty options for source_only
|
|
assert.Empty(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
} else if field.ID == sharedOnlyField.ID {
|
|
// User sees filtered options (only opt1)
|
|
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSearchPropertyFieldsReadAccess(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "test-plugin"
|
|
})
|
|
|
|
pluginID := "plugin-1"
|
|
userID := model.NewId()
|
|
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
|
|
rctxUser := RequestContextWithCallerID(th.Context, userID)
|
|
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
|
|
|
|
// Create fields with different access modes
|
|
publicField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "public-search-field",
|
|
Type: model.PropertyFieldTypeText,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
|
|
},
|
|
}
|
|
_, err := th.service.CreatePropertyField(rctxAnon, publicField)
|
|
require.NoError(t, err)
|
|
|
|
sourceOnlyField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "source-search-field",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret"},
|
|
},
|
|
},
|
|
}
|
|
_, err = th.service.CreatePropertyField(rctxPlugin, sourceOnlyField)
|
|
require.NoError(t, err)
|
|
|
|
sharedOnlyField := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "shared-search-field",
|
|
Type: model.PropertyFieldTypeMultiselect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Option 1"},
|
|
map[string]any{"id": "opt2", "value": "Option 2"},
|
|
},
|
|
},
|
|
}
|
|
sharedOnlyField, err = th.service.CreatePropertyField(rctxTestPlugin, sharedOnlyField)
|
|
require.NoError(t, err)
|
|
|
|
// Create value for userID (opt1)
|
|
value, jsonErr := json.Marshal([]string{"opt1"})
|
|
require.NoError(t, jsonErr)
|
|
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
|
|
GroupID: th.CPAGroupID,
|
|
FieldID: sharedOnlyField.ID,
|
|
TargetType: "user",
|
|
TargetID: userID,
|
|
Value: value,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("search returns all fields with appropriate filtering", func(t *testing.T) {
|
|
// User search
|
|
results, err := th.service.SearchPropertyFields(rctxUser, th.CPAGroupID, model.PropertyFieldSearchOpts{
|
|
PerPage: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(results), 3)
|
|
|
|
// Verify filtering
|
|
for _, field := range results {
|
|
if field.Name == "source-search-field" {
|
|
// User sees empty options for source_only
|
|
assert.Empty(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
} else if field.Name == "shared-search-field" {
|
|
// User sees filtered options (only opt1)
|
|
options := field.Attrs[model.PropertyFieldAttributeOptions].([]any)
|
|
assert.Len(t, options, 1)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("source plugin search sees unfiltered options", func(t *testing.T) {
|
|
results, err := th.service.SearchPropertyFields(rctxPlugin, th.CPAGroupID, model.PropertyFieldSearchOpts{
|
|
PerPage: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify source plugin sees all options
|
|
for _, field := range results {
|
|
if field.Name == "source-search-field" {
|
|
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetPropertyFieldByNameReadAccess(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1"
|
|
})
|
|
|
|
pluginID := "plugin-1"
|
|
userID := model.NewId()
|
|
targetID := model.NewId()
|
|
|
|
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
|
|
rctxUser := RequestContextWithCallerID(th.Context, userID)
|
|
|
|
t.Run("source_only field by name - filters options for non-source", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "byname-source-only",
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
TargetID: targetID,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret"},
|
|
},
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxPlugin, field)
|
|
require.NoError(t, err)
|
|
|
|
// Source plugin can see options
|
|
retrieved, err := th.service.GetPropertyFieldByName(rctxPlugin, th.CPAGroupID, targetID, created.Name)
|
|
require.NoError(t, err)
|
|
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
|
|
|
|
// User sees empty options
|
|
retrieved, err = th.service.GetPropertyFieldByName(rctxUser, th.CPAGroupID, targetID, created.Name)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
|
|
})
|
|
}
|
|
|
|
// TestCreatePropertyField_AccessControl tests access control for field creation based on caller type
|
|
func TestCreatePropertyField_AccessControl(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "security-plugin"
|
|
})
|
|
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
rctxUser1 := RequestContextWithCallerID(th.Context, "user-1")
|
|
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
|
|
rctxSecurityPlugin := RequestContextWithCallerID(th.Context, "security-plugin")
|
|
|
|
// Non-plugin caller tests
|
|
|
|
t.Run("non-plugin caller can create field without source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxUser1, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
// source_plugin_id is not set for non-plugin callers
|
|
assert.Empty(t, created.Attrs[model.PropertyAttrsSourcePluginID])
|
|
})
|
|
|
|
t.Run("non-plugin caller cannot set source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsSourcePluginID: "plugin-1",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "user-id-123"), field)
|
|
require.Error(t, err)
|
|
assert.Nil(t, created)
|
|
assert.Contains(t, err.Error(), "source_plugin_id can only be set by a plugin")
|
|
})
|
|
|
|
t.Run("non-plugin caller cannot set protected", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxUser1, field)
|
|
require.Error(t, err)
|
|
assert.Nil(t, created)
|
|
assert.Contains(t, err.Error(), "protected can only be set by a plugin")
|
|
})
|
|
|
|
t.Run("anonymous caller cannot set source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsSourcePluginID: "plugin-1",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.Error(t, err)
|
|
assert.Nil(t, created)
|
|
assert.Contains(t, err.Error(), "source_plugin_id can only be set by a plugin")
|
|
})
|
|
|
|
t.Run("anonymous caller cannot set protected", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.Error(t, err)
|
|
assert.Nil(t, created)
|
|
assert.Contains(t, err.Error(), "protected can only be set by a plugin")
|
|
})
|
|
|
|
t.Run("anonymous caller can create field with empty source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsSourcePluginID: "",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
})
|
|
|
|
// Plugin caller tests
|
|
|
|
t.Run("plugin caller auto-sets source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
|
|
})
|
|
|
|
t.Run("plugin caller overwrites any pre-set source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsSourcePluginID: "malicious-plugin",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
|
|
})
|
|
|
|
t.Run("plugin caller can create protected field with source_only access mode", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeSelect,
|
|
TargetType: "user",
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
|
|
model.PropertyFieldAttributeOptions: []any{
|
|
map[string]any{"id": "opt1", "value": "Secret Option"},
|
|
},
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxSecurityPlugin, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
assert.Equal(t, "security-plugin", created.Attrs[model.PropertyAttrsSourcePluginID])
|
|
assert.True(t, created.Attrs[model.PropertyAttrsProtected].(bool))
|
|
assert.Equal(t, model.PropertyAccessModeSourceOnly, created.Attrs[model.PropertyAttrsAccessMode])
|
|
})
|
|
|
|
t.Run("non-CPA group routes directly to PropertyService without setting source_plugin_id", func(t *testing.T) {
|
|
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-create")
|
|
require.NoError(t, err)
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: nonCpaGroup.ID,
|
|
Name: model.NewId(),
|
|
Type: model.PropertyFieldTypeText,
|
|
}
|
|
|
|
rctx := RequestContextWithCallerID(th.Context, "plugin-2")
|
|
created, err := th.service.CreatePropertyField(rctx, field)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, created)
|
|
assert.Nil(t, created.Attrs[model.PropertyAttrsSourcePluginID])
|
|
})
|
|
}
|
|
|
|
// TestUpdatePropertyField_WriteAccessControl tests write access control for field updates
|
|
func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "plugin-2"
|
|
})
|
|
|
|
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
|
|
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
|
|
t.Run("allows update of unprotected field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Original Name",
|
|
Type: model.PropertyFieldTypeText,
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
created.Name = "Updated Name"
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated Name", updated.Name)
|
|
})
|
|
|
|
t.Run("allows source plugin to update protected field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected Field",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
created.Name = "Updated Protected Field"
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated Protected Field", updated.Name)
|
|
})
|
|
|
|
t.Run("denies non-source plugin updating protected field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected Field",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
created.Name = "Attempted Update"
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
assert.Contains(t, err.Error(), "plugin-1")
|
|
})
|
|
|
|
t.Run("denies empty callerID updating protected field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected Field Empty Caller",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
created.Name = "Attempted Update"
|
|
updated, err := th.service.UpdatePropertyField(rctxAnon, th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
})
|
|
|
|
t.Run("prevents changing source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Field",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Try to change source_plugin_id
|
|
created.Attrs[model.PropertyAttrsSourcePluginID] = "plugin-2"
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "immutable")
|
|
})
|
|
|
|
t.Run("prevents setting protected=true without source_plugin_id", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Field Without Source Plugin",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxAnon, field)
|
|
require.NoError(t, err)
|
|
|
|
// Try to set protected=true without having a source_plugin_id
|
|
created.Attrs[model.PropertyAttrsProtected] = true
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "cannot set protected=true")
|
|
assert.Contains(t, err.Error(), "source_plugin_id")
|
|
})
|
|
|
|
t.Run("prevents non-source plugin from setting protected=true", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Field With Source Plugin",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{},
|
|
}
|
|
|
|
// Create field via plugin-1 (sets source_plugin_id automatically)
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
assert.False(t, model.IsPropertyFieldProtected(created))
|
|
|
|
// Try to set protected=true by a different plugin (plugin-2)
|
|
created.Attrs[model.PropertyAttrsProtected] = true
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "cannot set protected=true")
|
|
assert.Contains(t, err.Error(), "plugin-1")
|
|
|
|
// Verify the source plugin (plugin-1) CAN set protected=true
|
|
updated, err = th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
|
|
require.NoError(t, err)
|
|
assert.True(t, model.IsPropertyFieldProtected(updated))
|
|
})
|
|
|
|
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
|
|
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-update")
|
|
require.NoError(t, err)
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: nonCpaGroup.ID,
|
|
Name: "Non-CPA Protected",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyAttrsSourcePluginID: "plugin-1",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Update with different plugin - should be allowed (no access control)
|
|
created.Name = "Updated by Plugin2"
|
|
updated, err := th.service.UpdatePropertyField(rctxPlugin2, nonCpaGroup.ID, created)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, updated)
|
|
assert.Equal(t, "Updated by Plugin2", updated.Name)
|
|
})
|
|
}
|
|
|
|
// TestUpdatePropertyFields_BulkWriteAccessControl tests bulk field updates with atomic access checking
|
|
func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1" || pluginID == "plugin-2"
|
|
})
|
|
|
|
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
|
|
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
|
|
|
|
t.Run("allows bulk update of unprotected fields", func(t *testing.T) {
|
|
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText}
|
|
field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText}
|
|
|
|
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
|
|
require.NoError(t, err)
|
|
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
|
|
require.NoError(t, err)
|
|
|
|
created1.Name = "Updated Field1"
|
|
created2.Name = "Updated Field2"
|
|
|
|
updated, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
|
|
require.NoError(t, err)
|
|
assert.Len(t, updated, 2)
|
|
})
|
|
|
|
t.Run("fails atomically when one protected field in batch", func(t *testing.T) {
|
|
// Create unprotected field
|
|
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText}
|
|
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
|
|
require.NoError(t, err)
|
|
|
|
// Create protected field
|
|
field2 := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
|
|
require.NoError(t, err)
|
|
|
|
// Try to update both with plugin2 (should fail atomically)
|
|
created1.Name = "Updated Unprotected"
|
|
created2.Name = "Updated Protected"
|
|
|
|
updated, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
|
|
// Verify neither was updated
|
|
check1, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created1.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Unprotected", check1.Name)
|
|
|
|
check2, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created2.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Protected", check2.Name)
|
|
})
|
|
|
|
t.Run("fails atomically when trying to set protected=true without source_plugin_id in batch", func(t *testing.T) {
|
|
rctxAnon := RequestContextWithCallerID(th.Context, "")
|
|
|
|
// Create two unprotected fields without source_plugin_id
|
|
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}}
|
|
field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}}
|
|
|
|
created1, err := th.service.CreatePropertyField(rctxAnon, field1)
|
|
require.NoError(t, err)
|
|
created2, err := th.service.CreatePropertyField(rctxAnon, field2)
|
|
require.NoError(t, err)
|
|
|
|
// Try to set protected=true on field2 without source_plugin_id
|
|
created1.Name = "Updated Field1"
|
|
created2.Attrs[model.PropertyAttrsProtected] = true
|
|
|
|
updated, err := th.service.UpdatePropertyFields(rctxPlugin1, th.CPAGroupID, []*model.PropertyField{created1, created2})
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "cannot set protected=true")
|
|
assert.Contains(t, err.Error(), "source_plugin_id")
|
|
|
|
// Verify neither was updated (atomic failure)
|
|
check1, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created1.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Field1", check1.Name)
|
|
|
|
check2, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created2.ID)
|
|
require.NoError(t, err)
|
|
assert.False(t, model.IsPropertyFieldProtected(check2))
|
|
})
|
|
}
|
|
|
|
// TestDeletePropertyField_WriteAccessControl tests write access control for field deletion
|
|
func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
|
|
|
|
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
|
|
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
|
|
|
|
t.Run("allows deletion of unprotected field", func(t *testing.T) {
|
|
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText}
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
err = th.service.DeletePropertyField(rctxPlugin2, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("allows source plugin to delete protected field", func(t *testing.T) {
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
err = th.service.DeletePropertyField(rctxPlugin1, th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("denies non-source plugin deleting protected field", func(t *testing.T) {
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "plugin-1"
|
|
})
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Protected",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
err = th.service.DeletePropertyField(rctxPlugin2, th.CPAGroupID, created.ID)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
})
|
|
|
|
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
|
|
nonCpaGroup, err := th.service.RegisterPropertyGroup("other-group-delete")
|
|
require.NoError(t, err)
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: nonCpaGroup.ID,
|
|
Name: "Non-CPA Delete Protected",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
model.PropertyAttrsSourcePluginID: "plugin-1",
|
|
},
|
|
}
|
|
|
|
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
|
|
require.NoError(t, err)
|
|
|
|
// Delete with different plugin - should be allowed (no access control)
|
|
err = th.service.DeletePropertyField(rctxPlugin2, nonCpaGroup.ID, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
|
|
th := Setup(t).RegisterCPAPropertyGroup(t)
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
|
|
|
|
t.Run("allows deletion of orphaned protected field when plugin is uninstalled", func(t *testing.T) {
|
|
// Create the field while the plugin is still installed
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "removed-plugin"
|
|
})
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Orphaned Protected Field",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate plugin uninstall
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return false
|
|
})
|
|
|
|
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("blocks deletion of protected field when plugin is still installed", func(t *testing.T) {
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "installed-plugin"
|
|
})
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Active Protected Field",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "installed-plugin"), field)
|
|
require.NoError(t, err)
|
|
|
|
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created.ID)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
assert.Contains(t, err.Error(), "installed-plugin")
|
|
|
|
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "installed-plugin"), th.CPAGroupID, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("blocks update of orphaned protected field even when plugin is uninstalled", func(t *testing.T) {
|
|
// Create the field while the plugin is still installed
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return pluginID == "removed-plugin"
|
|
})
|
|
|
|
field := &model.PropertyField{
|
|
GroupID: th.CPAGroupID,
|
|
Name: "Orphaned Field For Update",
|
|
Type: model.PropertyFieldTypeText,
|
|
Attrs: model.StringInterface{
|
|
model.PropertyAttrsProtected: true,
|
|
},
|
|
}
|
|
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate plugin uninstall
|
|
th.service.setPluginCheckerForTests(func(pluginID string) bool {
|
|
return false
|
|
})
|
|
|
|
created.Name = "Updated Orphaned Field"
|
|
updated, err := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created)
|
|
require.Error(t, err)
|
|
assert.Nil(t, updated)
|
|
assert.Contains(t, err.Error(), "protected")
|
|
})
|
|
}
|