Files
team/server/channels/app/properties/access_control_value_test.go
d01cc82c4e Fix flaky TestUpdatePropertyValues_WriteAccessControl (#36784)
* Fix flaky TestUpdatePropertyValues_WriteAccessControl

GetPropertyValues delegates to PropertyValueStore.GetMany, which does not
preserve the caller's ID slice order. Subtests asserted on retrieved[0] and
retrieved[1] by position, so a planner that returns rows sorted by ID or
UpdateAt (e.g. with idx_propertyvalues_groupid_updateat_id) could swap
values and fail intermittently.

Look up returned values by ID before comparing JSON payloads.

Tests-only change. Verified with `go test -run '^TestUpdatePropertyValues_WriteAccessControl$' -race -count=100` locally.

Co-authored-by: mattermost-code <[email protected]>

* Refactor property value test helper

Co-authored-by: Miguel de la Cruz <[email protected]>

* Remove redundant property test nil checks

Co-authored-by: Miguel de la Cruz <[email protected]>

---------

Co-authored-by: mattermost-code <[email protected]>
Co-authored-by: Miguel de la Cruz <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-29 08:53:34 +00:00

2527 lines
86 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"
)
// TestCreatePropertyValue_WriteAccessControl tests write access control for value creation
func TestCreatePropertyValue_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")
t.Run("allows creating value for public field", func(t *testing.T) {
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
t.Run("allows source plugin to create value for source_only field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "SourceOnly",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"secret value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
t.Run("denies creating value for protected field by non-source", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.Error(t, err)
assert.Nil(t, createdValue)
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(&model.PropertyGroup{Name: "other_group_value_create", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "Non-CPA Value Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create value with different plugin - should be allowed (no access control)
value := &model.PropertyValue{
GroupID: nonCpaGroup.ID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test value"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin2, value)
require.NoError(t, err)
assert.NotNil(t, createdValue)
})
}
// TestDeletePropertyValue_WriteAccessControl tests write access control for value deletion
func TestDeletePropertyValue_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")
t.Run("allows deleting value for public field", func(t *testing.T) {
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
err = th.service.DeletePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue.ID)
require.NoError(t, err)
})
t.Run("denies non-source deleting value for protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"test"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
err = th.service.DeletePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
})
}
// TestDeletePropertyValuesForTarget_WriteAccessControl tests bulk deletion with access control
func TestDeletePropertyValuesForTarget_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")
t.Run("allows deleting all values when caller has write access to all fields", func(t *testing.T) {
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxPlugin1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created1.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v1"`)}
value2 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created2.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v2"`)}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value1)
require.NoError(t, err)
_, err = th.service.CreatePropertyValue(rctxPlugin1, value2)
require.NoError(t, err)
err = th.service.DeletePropertyValuesForTarget(rctxPlugin2, th.CPAGroupID, "user", targetID)
require.NoError(t, err)
})
t.Run("fails atomically when caller lacks access to one field", func(t *testing.T) {
// Create public field
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Public", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
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,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created2, err := th.service.CreatePropertyField(rctxPlugin1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created1.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v1"`)}
value2 := &model.PropertyValue{GroupID: th.CPAGroupID, FieldID: created2.ID, TargetType: "user", TargetID: targetID, Value: json.RawMessage(`"v2"`)}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value1)
require.NoError(t, err)
_, err = th.service.CreatePropertyValue(rctxPlugin1, value2)
require.NoError(t, err)
// Try to delete with plugin2 (should fail)
err = th.service.DeletePropertyValuesForTarget(rctxPlugin2, th.CPAGroupID, "user", targetID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
// Verify values still exist
values, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 10,
})
require.NoError(t, err)
assert.Len(t, values, 2)
})
}
func TestGetPropertyValueReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID1 := model.NewId()
userID2 := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser2 := RequestContextWithCallerID(th.Context, userID2)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
t.Run("public field value - any caller can read", func(t *testing.T) {
// Create public field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("test value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctxAnon, value)
require.NoError(t, err)
// Plugin 1 can read
retrieved, err := th.service.GetPropertyValue(rctx1, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(textValue), retrieved.Value)
// Plugin 2 can read
retrieved, err = th.service.GetPropertyValue(rctx2, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
// User can read
retrieved, err = th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
// Anonymous caller can read
retrieved, err = th.service.GetPropertyValue(rctxAnon, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
})
t.Run("source_only field value - only source plugin can read", func(t *testing.T) {
// Create source_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("secret value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctx1, value)
require.NoError(t, err)
// Source plugin can read
retrieved, err := th.service.GetPropertyValue(rctx1, th.CPAGroupID, value.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(textValue), retrieved.Value)
})
t.Run("source_only field value - other plugin gets nil", func(t *testing.T) {
// Create source_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Create value
textValue, jsonErr := json.Marshal("secret value")
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: textValue,
}
value, err = th.service.CreatePropertyValue(rctx1, value)
require.NoError(t, err)
// Other plugin gets nil
retrieved, err := th.service.GetPropertyValue(rctx2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
// User gets nil
retrieved, err = th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
// Anonymous caller gets nil
retrieved, err = th.service.GetPropertyValue(rctxAnon, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only single-select - return value only if caller has same", func(t *testing.T) {
// Create shared_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-single-select",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
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"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// User 1 has opt1
user1Value, jsonErr := json.Marshal("opt1")
require.NoError(t, jsonErr)
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
}
value1, err = th.service.CreatePropertyValue(rctxTestPlugin, value1)
require.NoError(t, err)
// User 2 also has opt1
user2Value, jsonErr := json.Marshal("opt1")
require.NoError(t, jsonErr)
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID2,
Value: user2Value,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, value2)
require.NoError(t, err)
// User 2 can see user 1's value (both have opt1)
retrieved, err := th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value1.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, value1.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(user1Value), retrieved.Value)
// Create another user with opt2
userID3 := model.NewId()
user3Value, jsonErr := json.Marshal("opt2")
require.NoError(t, jsonErr)
value3 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID3,
Value: user3Value,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, value3)
require.NoError(t, err)
// User 3 cannot see user 1's value (different options, no intersection)
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, userID3), th.CPAGroupID, value1.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only multi-select - return intersection of arrays", func(t *testing.T) {
// Create shared_only multiselect field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-multi-select",
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Hiking"},
map[string]any{"id": "opt2", "value": "Cooking"},
map[string]any{"id": "opt3", "value": "Gaming"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// Alice has ["opt1", "opt2"] (hiking, cooking)
aliceID := model.NewId()
aliceValue, jsonErr := json.Marshal([]string{"opt1", "opt2"})
require.NoError(t, jsonErr)
alicePropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: aliceID,
Value: aliceValue,
}
alicePropertyValue, err = th.service.CreatePropertyValue(rctxTestPlugin, alicePropertyValue)
require.NoError(t, err)
// Bob has ["opt1", "opt3"] (hiking, gaming)
bobID := model.NewId()
bobValue, jsonErr := json.Marshal([]string{"opt1", "opt3"})
require.NoError(t, jsonErr)
bobPropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: bobID,
Value: bobValue,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, bobPropertyValue)
require.NoError(t, err)
// Bob views Alice - should only see ["opt1"] (intersection)
retrieved, err := th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, bobID), th.CPAGroupID, alicePropertyValue.ID)
require.NoError(t, err)
require.NotNil(t, retrieved)
assert.Equal(t, alicePropertyValue.ID, retrieved.ID)
var retrievedOptions []string
jsonErr = json.Unmarshal(retrieved.Value, &retrievedOptions)
require.NoError(t, jsonErr)
assert.Len(t, retrievedOptions, 1)
assert.Contains(t, retrievedOptions, "opt1")
// Create user with no overlapping values
charlieID := model.NewId()
charlieValue, jsonErr := json.Marshal([]string{"opt3"})
require.NoError(t, jsonErr)
charliePropertyValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: charlieID,
Value: charlieValue,
}
_, err = th.service.CreatePropertyValue(rctxTestPlugin, charliePropertyValue)
require.NoError(t, err)
// Charlie views Alice - should get nil (no intersection)
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, charlieID), th.CPAGroupID, alicePropertyValue.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("shared_only text - binary masking: caller sees value only if it matches their own", func(t *testing.T) {
// Create shared_only text field — the LDAP/SAML codename use case.
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-text",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// User 1 has "TF-Zulu"
zuluValue, jsonErr := json.Marshal("TF-Zulu")
require.NoError(t, jsonErr)
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: zuluValue,
}
value1, err = th.service.CreatePropertyValue(rctxTestPlugin, value1)
require.NoError(t, err)
// User 2 also has "TF-Zulu" — should see user 1's value.
zuluValue2, jsonErr := json.Marshal("TF-Zulu")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID2,
Value: zuluValue2,
})
require.NoError(t, err)
retrieved, err := th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value1.ID)
require.NoError(t, err)
require.NotNil(t, retrieved, "caller holding the same text value must see the target's value")
assert.Equal(t, value1.ID, retrieved.ID)
assert.Equal(t, json.RawMessage(zuluValue), retrieved.Value)
// User 3 has "TF-Alpha" — must NOT see user 1's "TF-Zulu".
userID3 := model.NewId()
alphaValue, jsonErr := json.Marshal("TF-Alpha")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID3,
Value: alphaValue,
})
require.NoError(t, err)
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, userID3), th.CPAGroupID, value1.ID)
require.NoError(t, err)
assert.Nil(t, retrieved, "caller with a different text value must not see the target's value (binary masking)")
// User 4 has no stored value — must NOT see user 1's value.
userID4 := model.NewId()
retrieved, err = th.service.GetPropertyValue(RequestContextWithCallerID(th.Context, userID4), th.CPAGroupID, value1.ID)
require.NoError(t, err)
assert.Nil(t, retrieved, "caller with no stored value must not see the target's value")
})
t.Run("shared_only value - caller with no values sees nothing", func(t *testing.T) {
// Create shared_only field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-no-values",
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
},
},
}
field, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// Create value for user 1
user1Value, jsonErr := json.Marshal([]string{"opt1"})
require.NoError(t, jsonErr)
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: field.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
}
value, err = th.service.CreatePropertyValue(rctxTestPlugin, value)
require.NoError(t, err)
// User 2 has no values for this field
retrieved, err := th.service.GetPropertyValue(rctxUser2, th.CPAGroupID, value.ID)
require.NoError(t, err)
assert.Nil(t, retrieved)
})
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_value_read", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-value-source-only",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: pluginID1,
},
}
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
targetID := model.NewId()
value := &model.PropertyValue{
GroupID: nonCpaGroup.ID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"visible"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxTestPlugin, value)
require.NoError(t, err)
// Other plugin can read (no filtering, goes directly to PropertyService)
rctx2Local := RequestContextWithCallerID(th.Context, "plugin-2")
retrieved, err := th.service.GetPropertyValue(rctx2Local, nonCpaGroup.ID, createdValue.ID)
require.NoError(t, err)
assert.NotNil(t, retrieved)
// User can also read (no filtering, goes directly to PropertyService)
rctxUser := RequestContextWithCallerID(th.Context, model.NewId())
retrievedByUser, err := th.service.GetPropertyValue(rctxUser, nonCpaGroup.ID, createdValue.ID)
require.NoError(t, err)
assert.NotNil(t, retrievedByUser)
})
}
func TestGetPropertyValuesReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
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)
t.Run("mixed access modes - bulk read respects per-field access control", func(t *testing.T) {
// Create public field
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-bulk",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create source_only field
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-bulk",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
sourceOnlyField, err = th.service.CreatePropertyField(rctx1, sourceOnlyField)
require.NoError(t, err)
// Create values
publicValue, jsonErr := json.Marshal("public")
require.NoError(t, jsonErr)
publicPropValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: userID,
Value: publicValue,
}
publicPropValue, err = th.service.CreatePropertyValue(rctxAnon, publicPropValue)
require.NoError(t, err)
sourceOnlyValue, jsonErr := json.Marshal("secret")
require.NoError(t, jsonErr)
sourceOnlyPropValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sourceOnlyField.ID,
TargetType: "user",
TargetID: userID,
Value: sourceOnlyValue,
}
sourceOnlyPropValue, err = th.service.CreatePropertyValue(rctx1, sourceOnlyPropValue)
require.NoError(t, err)
// Plugin 1 (source) sees both values
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 2)
// Plugin 2 sees only public value
retrieved, err = th.service.GetPropertyValues(rctx2, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
assert.Equal(t, publicPropValue.ID, retrieved[0].ID)
// User sees only public value
retrieved, err = th.service.GetPropertyValues(rctxUser, th.CPAGroupID, []string{publicPropValue.ID, sourceOnlyPropValue.ID})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
assert.Equal(t, publicPropValue.ID, retrieved[0].ID)
})
}
func TestSearchPropertyValuesReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID1 := model.NewId()
userID2 := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser2 := RequestContextWithCallerID(th.Context, userID2)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
t.Run("search filters based on field access", func(t *testing.T) {
// Create public field
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-search",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create source_only field
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-search",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
sourceOnlyField, err = th.service.CreatePropertyField(rctx1, sourceOnlyField)
require.NoError(t, err)
// Create values for both fields
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxAnon, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: userID1,
Value: publicValue,
})
require.NoError(t, err)
sourceOnlyValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctx1, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sourceOnlyField.ID,
TargetType: "user",
TargetID: userID1,
Value: sourceOnlyValue,
})
require.NoError(t, err)
// Source plugin sees both values
results, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, results, 2)
// Other plugin sees only public value
results, err = th.service.SearchPropertyValues(rctx2, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, publicField.ID, results[0].FieldID)
})
t.Run("search shared_only values show intersection", func(t *testing.T) {
// Create shared_only field
sharedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-field-search",
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
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"},
},
},
}
sharedField, err := th.service.CreatePropertyField(rctxTestPlugin, sharedField)
require.NoError(t, err)
// User 1 has ["opt1", "opt2"]
user1Value, jsonErr := json.Marshal([]string{"opt1", "opt2"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedField.ID,
TargetType: "user",
TargetID: userID1,
Value: user1Value,
})
require.NoError(t, err)
// User 2 has ["opt1", "opt3"]
user2Value, jsonErr := json.Marshal([]string{"opt1", "opt3"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedField.ID,
TargetType: "user",
TargetID: userID2,
Value: user2Value,
})
require.NoError(t, err)
// User 2 searches for user 1's values - should see only ["opt1"]
results, err := th.service.SearchPropertyValues(rctxUser2, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{userID1},
FieldID: sharedField.ID,
PerPage: 100,
})
require.NoError(t, err)
require.Len(t, results, 1)
var retrievedOptions []string
jsonErr = json.Unmarshal(results[0].Value, &retrievedOptions)
require.NoError(t, jsonErr)
assert.Len(t, retrievedOptions, 1)
assert.Contains(t, retrievedOptions, "opt1")
})
}
// TestCreatePropertyValues_WriteAccessControl tests write access control for bulk value creation
func TestCreatePropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
t.Run("allows creating values for public fields", func(t *testing.T) {
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field1, err := th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field2, err = th.service.CreatePropertyField(rctxAnon, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, v1err := json.Marshal("value1")
require.NoError(t, v1err)
value2, v2err := json.Marshal("value2")
require.NoError(t, v2err)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: th.CPAGroupID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, cerr := th.service.CreatePropertyValues(rctx2, values)
require.NoError(t, cerr)
assert.Len(t, created, 2)
})
t.Run("allows source plugin to create values for protected fields", func(t *testing.T) {
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
field2, err = th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, v1err := json.Marshal("secret1")
require.NoError(t, v1err)
value2, v2err := json.Marshal("secret2")
require.NoError(t, v2err)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: th.CPAGroupID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, cerr := th.service.CreatePropertyValues(rctx1, values)
require.NoError(t, cerr)
assert.Len(t, created, 2)
})
t.Run("fails atomically when one protected field in batch", func(t *testing.T) {
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-batch",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-batch",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
protectedField, err = th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
targetID := model.NewId()
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
protectedValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: targetID,
Value: publicValue,
},
{
GroupID: th.CPAGroupID,
FieldID: protectedField.ID,
TargetType: "user",
TargetID: targetID,
Value: protectedValue,
},
}
// Plugin 2 should fail to create both values atomically
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "protected")
// Verify neither value was created
results, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, results)
})
t.Run("rejects values across multiple groups", func(t *testing.T) {
// Register a second group
group2, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_2", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create fields in both groups
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-group1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field1, err = th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
field2 := &model.PropertyField{
GroupID: group2.ID,
Name: "field-group2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
field2, err = th.service.CreatePropertyField(rctxAnon, field2)
require.NoError(t, err)
targetID := model.NewId()
value1, jsonErr := json.Marshal("data from group 1")
require.NoError(t, jsonErr)
value2, jsonErr := json.Marshal("data from group 2")
require.NoError(t, jsonErr)
// Creating values for fields from different groups in a single call should fail
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: field1.ID,
TargetType: "user",
TargetID: targetID,
Value: value1,
},
{
GroupID: group2.ID,
FieldID: field2.ID,
TargetType: "user",
TargetID: targetID,
Value: value2,
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
t.Run("rejects mixed groups before checking access control", func(t *testing.T) {
// Register a third group
group3, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_group_create_values_3", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create public field in CPA group
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-multigroup",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
publicField, err = th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create protected field in group 3
protectedField := &model.PropertyField{
GroupID: group3.ID,
Name: "protected-field-multigroup",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
},
}
protectedField, err = th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
targetID := model.NewId()
publicValue, jsonErr := json.Marshal("public data")
require.NoError(t, jsonErr)
protectedValue, jsonErr := json.Marshal("secret data")
require.NoError(t, jsonErr)
// Mixed-group batch is rejected before access control is even checked
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: publicField.ID,
TargetType: "user",
TargetID: targetID,
Value: publicValue,
},
{
GroupID: group3.ID,
FieldID: protectedField.ID,
TargetType: "user",
TargetID: targetID,
Value: protectedValue,
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
// Register a non-CPA group
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_bulk", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create two fields in non-CPA group
field1 := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
field2 := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-bulk-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values for both fields with different plugin - should be allowed (no access control)
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: nonCpaGroup.ID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
},
{
GroupID: nonCpaGroup.ID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
},
}
createdValues, err := th.service.CreatePropertyValues(rctx2, values)
require.NoError(t, err)
assert.Len(t, createdValues, 2)
})
t.Run("mixed CPA and non-CPA groups are rejected before access control", func(t *testing.T) {
// Register a non-CPA group
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_mixed", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
// Create protected field in CPA group via plugin API
cpaField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "cpa-protected-mixed",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
cpaField, err = th.service.CreatePropertyField(rctx1, cpaField)
require.NoError(t, err)
// Create field in non-CPA group (no access control attributes)
nonCpaField := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "non-cpa-field-mixed",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
nonCpaField, err = th.service.CreatePropertyField(rctx1, nonCpaField)
require.NoError(t, err)
// Try to bulk create values for BOTH groups — rejected by group-consistency check
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: cpaField.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"cpa data"`),
},
{
GroupID: nonCpaGroup.ID,
FieldID: nonCpaField.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"non-cpa data"`),
},
}
created, err := th.service.CreatePropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "mixed group IDs in batch")
})
}
func TestUpdatePropertyValue_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")
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can update values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-for-update",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Source plugin can update the value
createdValue.Value = json.RawMessage(`"updated"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin1, th.CPAGroupID, createdValue)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, `"updated"`, string(updated.Value))
})
t.Run("non-source plugin cannot update values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "protected-field-for-update-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value with plugin1
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin cannot update
createdValue.Value = json.RawMessage(`"hacked"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
})
t.Run("any caller can update values for non-protected field", func(t *testing.T) {
// Create a non-protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-for-update",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"original"`),
}
createdValue, err := th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin can update
createdValue.Value = json.RawMessage(`"updated by plugin2"`)
updated, err := th.service.UpdatePropertyValue(rctxPlugin2, th.CPAGroupID, createdValue)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, `"updated by plugin2"`, string(updated.Value))
})
}
func TestUpdatePropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can update multiple values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Update both values
createdValues[0].Value = json.RawMessage(`"updated1"`)
createdValues[1].Value = json.RawMessage(`"updated2"`)
updated, err := th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, createdValues)
require.NoError(t, err)
assert.Len(t, updated, 2)
})
t.Run("non-source plugin cannot update values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-update-fail-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Create values
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value2"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Try to update both values with different plugin - should fail atomically
createdValues[0].Value = json.RawMessage(`"hacked1"`)
createdValues[1].Value = json.RawMessage(`"hacked2"`)
updated, err := th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, createdValues)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify values were NOT updated
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValues[0].ID, createdValues[1].ID})
require.NoError(t, err)
require.Len(t, retrieved, 2)
retrievedValue1 := findValueByID(t, retrieved, createdValues[0].ID)
assert.Equal(t, `"value1"`, string(retrievedValue1.Value))
retrievedValue2 := findValueByID(t, retrieved, createdValues[1].ID)
assert.Equal(t, `"value2"`, string(retrievedValue2.Value))
})
t.Run("mixed protected and non-protected fields - enforces access control only on protected fields", func(t *testing.T) {
// Create one protected field and one non-protected field
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-update-protected-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-update-public-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
createdPublic, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Create values for both fields with plugin1
targetID := model.NewId()
protectedValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdProtected.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"protected value"`),
}
publicValue := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdPublic.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"public value"`),
}
createdValues, err := th.service.CreatePropertyValues(rctx1, []*model.PropertyValue{protectedValue, publicValue})
require.NoError(t, err)
require.Len(t, createdValues, 2)
// Try to update both values with plugin2 - should fail atomically
createdValues[0].Value = json.RawMessage(`"hacked protected"`)
createdValues[1].Value = json.RawMessage(`"hacked public"`)
updated, err := th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, createdValues)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify NO values were updated (atomic failure)
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValues[0].ID, createdValues[1].ID})
require.NoError(t, err)
require.Len(t, retrieved, 2)
retrievedProtectedValue := findValueByID(t, retrieved, createdValues[0].ID)
assert.Equal(t, `"protected value"`, string(retrievedProtectedValue.Value))
retrievedPublicValue := findValueByID(t, retrieved, createdValues[1].ID)
assert.Equal(t, `"public value"`, string(retrievedPublicValue.Value))
// Now try with source plugin - should succeed for both
createdValues[0].Value = json.RawMessage(`"updated protected"`)
createdValues[1].Value = json.RawMessage(`"updated public"`)
updated, err = th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, createdValues)
require.NoError(t, err)
assert.Len(t, updated, 2)
assert.Equal(t, `"updated protected"`, string(updated[0].Value))
assert.Equal(t, `"updated public"`, string(updated[1].Value))
})
t.Run("multiple protected fields with different owners - enforces access control atomically", func(t *testing.T) {
// Create two protected fields, each owned by a different plugin
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "multi-owner-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "multi-owner-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
createdField1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
createdField2, err := th.service.CreatePropertyField(rctx2, field2)
require.NoError(t, err)
// Create values for both fields (each plugin creates its own)
targetID := model.NewId()
value1 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin1"`),
}
value2 := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin2"`),
}
createdValue1, err := th.service.CreatePropertyValue(rctx1, value1)
require.NoError(t, err)
createdValue2, err := th.service.CreatePropertyValue(rctx2, value2)
require.NoError(t, err)
// Try to update both values with plugin1 - should fail because it doesn't own field2
createdValue1.Value = json.RawMessage(`"updated by plugin1"`)
createdValue2.Value = json.RawMessage(`"hacked by plugin1"`)
updated, err := th.service.UpdatePropertyValues(rctx1, th.CPAGroupID, []*model.PropertyValue{createdValue1, createdValue2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify NO values were updated (atomic failure)
retrieved, err := th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValue1.ID, createdValue2.ID})
require.NoError(t, err)
require.Len(t, retrieved, 2)
retrievedValue1 := findValueByID(t, retrieved, createdValue1.ID)
assert.Equal(t, `"value from plugin1"`, string(retrievedValue1.Value))
retrievedValue2 := findValueByID(t, retrieved, createdValue2.ID)
assert.Equal(t, `"value from plugin2"`, string(retrievedValue2.Value))
// Try to update both values with plugin2 - should also fail because it doesn't own field1
createdValue1.Value = json.RawMessage(`"hacked by plugin2"`)
createdValue2.Value = json.RawMessage(`"updated by plugin2"`)
updated, err = th.service.UpdatePropertyValues(rctx2, th.CPAGroupID, []*model.PropertyValue{createdValue1, createdValue2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify still NO values were updated
retrieved, err = th.service.GetPropertyValues(rctx1, th.CPAGroupID, []string{createdValue1.ID, createdValue2.ID})
require.NoError(t, err)
require.Len(t, retrieved, 2)
retrievedValue1 = findValueByID(t, retrieved, createdValue1.ID)
assert.Equal(t, `"value from plugin1"`, string(retrievedValue1.Value))
retrievedValue2 = findValueByID(t, retrieved, createdValue2.ID)
assert.Equal(t, `"value from plugin2"`, string(retrievedValue2.Value))
// Each plugin can update its own value individually
createdValue1.Value = json.RawMessage(`"plugin-1 updated its own"`)
updated1, err := th.service.UpdatePropertyValue(rctx1, th.CPAGroupID, createdValue1)
require.NoError(t, err)
assert.Equal(t, `"plugin-1 updated its own"`, string(updated1.Value))
createdValue2.Value = json.RawMessage(`"plugin-2 updated its own"`)
updated2, err := th.service.UpdatePropertyValue(rctx2, th.CPAGroupID, createdValue2)
require.NoError(t, err)
assert.Equal(t, `"plugin-2 updated its own"`, string(updated2.Value))
})
}
func findValueByID(t *testing.T, values []*model.PropertyValue, id string) *model.PropertyValue {
t.Helper()
for _, value := range values {
if value.ID == id {
return value
}
}
require.FailNow(t, "missing property value", "missing property value %s", id)
return nil
}
func TestUpsertPropertyValue_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("source plugin can upsert value for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-protected-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Upsert value (create)
targetID := model.NewId()
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"first"`),
}
upserted, err := th.service.UpsertPropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.NotNil(t, upserted)
// Upsert again (update)
value.Value = json.RawMessage(`"second"`)
upserted2, err := th.service.UpsertPropertyValue(rctxPlugin1, value)
require.NoError(t, err)
assert.Equal(t, `"second"`, string(upserted2.Value))
})
t.Run("non-source plugin cannot upsert value for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-protected-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Try to upsert value with different plugin
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"unauthorized"`),
}
upserted, err := th.service.UpsertPropertyValue(rctxPlugin2, value)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
})
}
func TestUpsertPropertyValues_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can bulk upsert values for protected fields", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Bulk upsert values
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"upsert1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"upsert2"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx1, values)
require.NoError(t, err)
assert.Len(t, upserted, 2)
})
t.Run("non-source plugin cannot bulk upsert values atomically", func(t *testing.T) {
// Create two protected fields
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "bulk-upsert-fail-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctx1, field2)
require.NoError(t, err)
// Try to bulk upsert with different plugin
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"unauthorized1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"unauthorized2"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
t.Run("mixed protected and non-protected fields - enforces access control only on protected fields", func(t *testing.T) {
// Create one protected field and one non-protected field
protectedField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-protected-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "mixed-public-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
createdProtected, err := th.service.CreatePropertyField(rctx1, protectedField)
require.NoError(t, err)
createdPublic, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
// Try to upsert values for both fields with plugin2
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: createdProtected.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"protected value"`),
},
{
GroupID: th.CPAGroupID,
FieldID: createdPublic.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"public value"`),
},
}
// Should fail atomically - plugin2 cannot upsert value for protected field
upserted, err := th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created (atomic failure)
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Now try with source plugin - should succeed for both
upserted, err = th.service.UpsertPropertyValues(rctx1, values)
require.NoError(t, err)
assert.Len(t, upserted, 2)
})
t.Run("multiple protected fields with different owners - enforces access control atomically", func(t *testing.T) {
// Create two protected fields, each owned by a different plugin
field1 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-1",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
field2 := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "upsert-multi-owner-field-2",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
createdField1, err := th.service.CreatePropertyField(rctx1, field1)
require.NoError(t, err)
createdField2, err := th.service.CreatePropertyField(rctx2, field2)
require.NoError(t, err)
// Try to upsert values for both fields with plugin1 - should fail because it doesn't own field2
targetID := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"value from plugin1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"hacked by plugin1"`),
},
}
upserted, err := th.service.UpsertPropertyValues(rctx1, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify no values were created (atomic failure)
retrieved, err := th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Try to upsert both values with plugin2 - should also fail because it doesn't own field1
values[0].Value = json.RawMessage(`"hacked by plugin2"`)
values[1].Value = json.RawMessage(`"value from plugin2"`)
upserted, err = th.service.UpsertPropertyValues(rctx2, values)
require.Error(t, err)
assert.Nil(t, upserted)
assert.Contains(t, err.Error(), "protected")
// Verify still no values were created
retrieved, err = th.service.SearchPropertyValues(rctx1, th.CPAGroupID, model.PropertyValueSearchOpts{
TargetIDs: []string{targetID},
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
// Each plugin can upsert its own value individually
upserted1, err := th.service.UpsertPropertyValue(rctx1, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField1.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"plugin-1 upserted its own"`),
})
require.NoError(t, err)
assert.Equal(t, `"plugin-1 upserted its own"`, string(upserted1.Value))
upserted2, err := th.service.UpsertPropertyValue(rctx2, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: createdField2.ID,
TargetType: "user",
TargetID: targetID,
Value: json.RawMessage(`"plugin-2 upserted its own"`),
})
require.NoError(t, err)
assert.Equal(t, `"plugin-2 upserted its own"`, string(upserted2.Value))
})
}
func TestUpsertPropertyValue_SyncLock(t *testing.T) {
th := Setup(t)
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "test_sync_lock", Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
hook := NewAccessControlHook(th.service, nil, group.ID)
th.service.AddHook(hook)
ldapField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
Name: "ldap_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
Attrs: model.StringInterface{model.PropertyFieldAttrLDAP: "cn"},
})
samlField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
Name: "saml_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
Attrs: model.StringInterface{model.PropertyFieldAttrSAML: "displayName"},
})
nonSyncedField := th.CreatePropertyFieldDirect(t, &model.PropertyField{
GroupID: group.ID,
Name: "normal_field_" + model.NewId(),
Type: model.PropertyFieldTypeText,
TargetType: "system",
ObjectType: "user",
})
targetID := model.NewId()
t.Run("blocks upsert on LDAP-synced field without caller ID", func(t *testing.T) {
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: ldapField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"test"`),
}
_, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
require.Error(t, upsertErr)
assert.Contains(t, upsertErr.Error(), "ldap sync")
})
t.Run("allows LDAP sync service to upsert LDAP-synced field", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: ldapField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"John Doe"`),
}
result, upsertErr := th.service.UpsertPropertyValue(rctx, value)
require.NoError(t, upsertErr)
assert.NotEmpty(t, result.ID)
})
t.Run("blocks SAML sync service from writing LDAP-synced field", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: ldapField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"wrong caller"`),
}
_, upsertErr := th.service.UpsertPropertyValue(rctx, value)
require.Error(t, upsertErr)
assert.Contains(t, upsertErr.Error(), "ldap sync")
})
t.Run("allows SAML sync service to upsert SAML-synced field", func(t *testing.T) {
rctx := RequestContextWithCallerID(th.Context, model.CallerIDSAMLSync)
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: samlField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"Jane Doe"`),
}
result, upsertErr := th.service.UpsertPropertyValue(rctx, value)
require.NoError(t, upsertErr)
assert.NotEmpty(t, result.ID)
})
t.Run("blocks regular user from writing SAML-synced field", func(t *testing.T) {
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: samlField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"sneaky"`),
}
_, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
require.Error(t, upsertErr)
assert.Contains(t, upsertErr.Error(), "saml sync")
})
t.Run("allows regular user to upsert non-synced field", func(t *testing.T) {
value := &model.PropertyValue{
GroupID: group.ID,
FieldID: nonSyncedField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"hello"`),
}
result, upsertErr := th.service.UpsertPropertyValue(th.Context, value)
require.NoError(t, upsertErr)
assert.NotEmpty(t, result.ID)
})
t.Run("sync lock applies to batch upsert", func(t *testing.T) {
values := []*model.PropertyValue{
{
GroupID: group.ID,
FieldID: ldapField.ID,
TargetID: targetID,
TargetType: "user",
Value: json.RawMessage(`"batch test"`),
},
}
_, upsertErr := th.service.UpsertPropertyValues(th.Context, values)
require.Error(t, upsertErr)
assert.Contains(t, upsertErr.Error(), "ldap sync")
// Same batch with the right caller should succeed
rctx := RequestContextWithCallerID(th.Context, model.CallerIDLDAPSync)
results, upsertErr := th.service.UpsertPropertyValues(rctx, values)
require.NoError(t, upsertErr)
assert.Len(t, results, 1)
})
}
func TestDeletePropertyValuesForField_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")
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("source plugin can delete all values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-delete-values",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create multiple values
targetID1 := model.NewId()
targetID2 := model.NewId()
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID1,
Value: json.RawMessage(`"value1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: targetID2,
Value: json.RawMessage(`"value2"`),
},
}
_, err = th.service.CreatePropertyValues(rctxPlugin1, values)
require.NoError(t, err)
// Source plugin can delete all values for the field
err = th.service.DeletePropertyValuesForField(rctxPlugin1, th.CPAGroupID, created.ID)
require.NoError(t, err)
// Verify values are deleted
retrieved, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
t.Run("non-source plugin cannot delete values for protected field", func(t *testing.T) {
// Create a protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "field-delete-values-fail",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Create a value
value := &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"protected value"`),
}
_, err = th.service.CreatePropertyValue(rctxPlugin1, value)
require.NoError(t, err)
// Different plugin cannot delete values
err = th.service.DeletePropertyValuesForField(rctxPlugin2, th.CPAGroupID, created.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
// Verify value still exists
retrieved, err := th.service.SearchPropertyValues(rctxPlugin1, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Len(t, retrieved, 1)
})
t.Run("any caller can delete values for non-protected field", func(t *testing.T) {
// Create a non-protected field
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field-delete-values",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Create values with plugin1
values := []*model.PropertyValue{
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value1"`),
},
{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"value2"`),
},
}
_, err = th.service.CreatePropertyValues(rctxPlugin1, values)
require.NoError(t, err)
// Different plugin can delete values
err = th.service.DeletePropertyValuesForField(rctxPlugin2, th.CPAGroupID, created.ID)
require.NoError(t, err)
// Verify values are deleted
retrieved, err := th.service.SearchPropertyValues(rctxPlugin2, th.CPAGroupID, model.PropertyValueSearchOpts{
FieldID: created.ID,
PerPage: 100,
})
require.NoError(t, err)
assert.Empty(t, retrieved)
})
}
func TestCreatePropertyValue(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil value returns error", func(t *testing.T) {
_, err := th.service.CreatePropertyValue(th.Context, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValue: value cannot be nil")
})
}
func TestCreatePropertyValues(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil element returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
nil,
}
_, err := th.service.CreatePropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValues: nil element at index 1")
})
t.Run("mixed group IDs returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
{GroupID: model.NewId()},
}
_, err := th.service.CreatePropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "CreatePropertyValues: mixed group IDs in batch")
})
}
func TestUpsertPropertyValue(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil value returns error", func(t *testing.T) {
_, err := th.service.UpsertPropertyValue(th.Context, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValue: value cannot be nil")
})
}
func TestUpsertPropertyValues(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
t.Run("nil element returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
nil,
}
_, err := th.service.UpsertPropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValues: nil element at index 1")
})
t.Run("mixed group IDs returns error", func(t *testing.T) {
values := []*model.PropertyValue{
{GroupID: model.NewId()},
{GroupID: model.NewId()},
}
_, err := th.service.UpsertPropertyValues(th.Context, values)
require.Error(t, err)
assert.Contains(t, err.Error(), "UpsertPropertyValues: mixed group IDs in batch")
})
}