// 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") }) }