Files
team/server/channels/app/properties/access_control_field_test.go
9d109f98d4 Mm 68506 fe abac mask fe table editor cel and e2e (#36517)
* MM-68501 - implement GetMaskedVisualAST and wire API handler

* add missing test and fix style issues

* fix styles

* implement coderabbit feedback

* MM-68501 - PR review: split masking file, model-level access mode, reject contradictory config

* MM-68501 - apply shared_only filter to non-option field values (binary masking)

* MM-68501 - consolidate masking flag check and log corrupt text value during masking

* MM-68503 - add CEL utilities, write-path validation, and merge helpers

Combined set of helpers consumed by BE-5's save path:

CEL construction / serialization
  - extractStringValues, buildCELFromConditions, conditionToCEL,
    celStringLiteral, celValueLiteral. Used to rebuild a CEL string from a
    VisualExpression, including for GetMaskedExpression on the read-side
    of policy GET / search responses.

Merge-on-save helpers
  - getHiddenValues (per-condition, with pre-fetched fields map for N+1
    avoidance) — finds which stored values are not visible to the caller.
  - mergeConditionValues — re-injects the hidden values into a submitted
    condition without duplicates.
  - Together, these let BE-5 preserve attribute values the caller cannot
    see while still letting them edit the visible parts of a policy.

Write-path value-hold validation
  - validatePolicyExpressionValues, invalidValueError, validateConditionValues.
  - Generic "Invalid value." error on every rejection — no signal about
    whether the value exists or is merely not held (prevents enumeration).
  - Rejects the masked-token sentinel "--------" if submitted as a literal.

These all live in access_control_masking.go alongside the masking primitives
that BE-2 introduced. i18n entries added for the two new error IDs
(app.pap.save_policy.invalid_value, app.pap.validate_expression_values.app_error).

* MM-68503 - handle the masked-token sentinel in validation and merge

When the GET /policies endpoint returns a policy via MaskPolicyExpressions,
the raw expression contains the masked-token sentinel "--------" in place
of hidden values. If the frontend round-trips that expression unchanged
back to the server (e.g., the admin only modified channel assignment, not
the rules), the sentinel reaches the save path.

The previous code in validateConditionValues rejected the sentinel as
"Invalid value." This blocks the legitimate round-trip case.

Fix:
  - validateConditionValues: treat the sentinel as a placeholder and skip
    it during visibility / source-only / unknown-mode checks. Other values
    are still validated normally.
  - mergeConditionValues: strip the sentinel from submitted values before
    appending hidden values, so it never propagates to the stored result.
    Both array and single-value forms (string == "--------") are handled.

TestMaskedTokenRejection (which asserted the old rejection behavior) is
replaced by TestMaskedTokenConstant which only verifies the sentinel
string itself.

* MM-68504 - integrate save-path masking: 403 block on delete, merge-on-save, response masking

Save path (CreateOrUpdateAccessControlPolicy):
  * validatePolicyExpressionValues runs on the submitted expression before
    merge so re-injected hidden values are never validated against the
    caller's holdings.
  * mergeStoredPolicyExpressions re-injects hidden values from the stored
    policy and blocks (HTTP 403) any attempt to remove a condition that
    contained values the caller cannot see — closes the row-deletion gap
    in classified environments.
  * mergeExpressionWithMaskedValues unwraps single-element arrays for scalar
    operators after restoring the stored operator (avoids "attr == [val]"
    invalid CEL when the frontend submits "attr in []" as the masked-row
    placeholder for an originally-scalar condition).
  * checkSelfInclusion is bypassed for system admins (they may legitimately
    write conditions for values they do not hold); masking and value-hold
    validation still apply to system admins.

Delete path (DeleteAccessControlPolicy):
  * Same masked-values 403 block — a caller with masked values cannot delete
    the policy at all (UI Delete button is also disabled in FE-3).

Response masking:
  * createAccessControlPolicy and setAccessControlPolicyActiveStatus run
    MaskPolicyExpressions on the response so even a save reply doesn't
    leak the values the caller does not hold.

GetMaskedExpression, maskConditionValuesWithToken, replaceHiddenValuesWithToken,
MaskPolicyExpressions live alongside the rest of the masking helpers in
access_control_masking.go.

team_access_control.go: corrects ValidateChannelEligibilityForAccessControl
call site (drops the spurious receiver and rctx; it's a package-level helper
that only takes channel).

* MM-68503 - address PR review: batch field fetches, propagate errors, fail-closed write path

* MM-68503 - restore team-admin api4 tests accidentally dropped during BE-5 rebuild

* MM-68503 - address review and CodeRabbit feedback on save-path masking

* add tests for delete masking, self-inclusion, GET mask

* add assertions to strengten tests

* MM-68505 - add has_masked_values type and MaskedChip component

* MM-68506 - add masking support to TableEditor and team settings modal

TableEditor (table_editor.tsx, table_editor.scss):
  - hasMaskedValues plumbed through rows; lock operator/attribute selectors on
    masked rows.
  - Row remove (trash) button disabled on masked rows; disabled-state CSS so
    the icon doesn't show the destructive hover colour or a pointer cursor.
  - Test Rules button disabled when any row has masked values, with tooltip.
  - onMaskedStateChange callback to notify the parent for cross-component
    states (CEL editor read-only, Save disabled, banners).

Value selectors (single_value_selector_menu.tsx, multi_value_selector_menu.tsx,
selector_menus.scss, value_selector_menu.tsx):
  - Append MaskedChip after visible chips on multi-value rows.
  - Render MaskedChip as the sole value on single-value rows where the caller
    holds no visible value.

Policy details (policy_details.tsx, .scss, .test.tsx):
  - Track hasMaskedRows state; receive from TableEditor via onMaskedStateChange.
  - Show masked-values warning banner above the editor when present.
  - Same banner on the Delete confirmation modal so admins understand why
    deletion is consequential.

Team settings modal (team_policy_editor.tsx, .scss):
  - Same masked-values plumbing; delete button uses the disabled state when
    a policy has masked values, regardless of whether channels are assigned.
  - Pre-save check no longer treats "in []" as an incomplete rule — that
    placeholder comes from fully-masked rows that merge-on-save will fill in.

i18n entries added for the new strings.

* MM-68506 - fix hook order in SingleValueSelector when masked state changes

The early return for `hasMaskedValues && !value` sat between useState
and useCallback declarations, so when a parent re-render flipped the
masked state (e.g. after deleting a sibling rule) React saw a different
hook count and crashed with "Rendered fewer hooks than expected".

Move the read-only short-circuit after all hook declarations so the hook
order stays stable across renders.

* MM-68507 - CEL editor read-only when masked + system console wiring

CEL editor (editor.tsx, editor.scss):
  - hasMaskedRows prop: when true, Monaco is set to read-only and a banner
    explains why ("This expression contains restricted values. Switch to
    Simple mode to edit the values you have access to, or delete the entire
    rule.").
  - Test Rules button disabled in CEL mode when hasMaskedRows is true.

Policy details (policy_details.tsx, .scss):
  - hasMaskedRows state plumbed to CELEditor, TableEditor, and the Save /
    Delete buttons.
  - Save button disabled while masked rows are present (kept after the
    save-allowed-with-masked-values change in BE-5? — no, here we keep Save
    enabled so admins can add/modify rules; only row removal of masked rows
    is blocked).
  - Delete Policy button disabled when hasMaskedRows; a SectionNotice above
    the Delete card explains why ("This policy contains restricted values
    - Deletion not allowed").
  - New save error messages: invalid_value and self_exclusion are surfaced
    from the server's generic responses.

Policies list (policies.tsx): minor wiring change for the new state plumbing.

Table editor (table_editor.tsx): cross-component coordination — emits
onMaskedStateChange and respects the disabled-for-masked-row policy.

* MM-68508 - E2E suite for attribute-value masking

Covers the full read+write masking flow against a real server:
  - Masked chip rendering, operator/attribute lock, Test Rules disabled.
  - System admin subject to masking like any other caller (no role bypass).
  - Save with masked values: hidden values preserved by merge-on-save.
  - Trash button disabled on masked rows; server returns 403 on direct
    API attempt to remove a masked condition.
  - Delete Policy button disabled + server 403 when policy has masked values
    (both system console and team settings modal paths).
  - Self-inclusion failure only fires when the caller holds full visibility.
  - CEL editor read-only with banner when masked rows present.
  - Direct API validation: non-held values and the masked-token sentinel
    rejected with a generic "Invalid value." error.
  - Feature-flag-off path: no masking, all values visible.
  - Text-field shared_only masking (binary) with `in` and `==` operators.

A pluggable DB-setup helper marks specific CPA fields as shared_only for
the duration of a test (with per-test cleanup) since the API blocks setting
access_mode=shared_only without a source_plugin_id.

* MM-68506 - fix lint, jest mock factory, and unreachable delete-modal test

* MM-68506 - localize masked-condition-deleted save error

* MM-68506 - fix masked-policy delete warning detection and localize masked_rule_deleted

* fix linter issues

* MM-68506 - surface delete error, lock value selector on masked rows, drop dead remove-modal

* fix linter, add translations, adjust specs

* import wittoltip from shared

* fix linter and use the correct button variant

* MM-68506 - drop dangling rationale comment in access_control_field_test

* fix linter, translation and e2e tests

* use pg ts types and dependencies for e2e types mocks

* adjust switch mode persistance restriction

* fix team settings style buttons

* fail-closed guard for advanced expressions in  merge-on-save, plus  helper unit tests, and FF/test-helper cleanups

* MM-68505 - add has_masked_values type and MaskedChip component

* MM-68506 - add masking support to TableEditor and team settings modal

TableEditor (table_editor.tsx, table_editor.scss):
  - hasMaskedValues plumbed through rows; lock operator/attribute selectors on
    masked rows.
  - Row remove (trash) button disabled on masked rows; disabled-state CSS so
    the icon doesn't show the destructive hover colour or a pointer cursor.
  - Test Rules button disabled when any row has masked values, with tooltip.
  - onMaskedStateChange callback to notify the parent for cross-component
    states (CEL editor read-only, Save disabled, banners).

Value selectors (single_value_selector_menu.tsx, multi_value_selector_menu.tsx,
selector_menus.scss, value_selector_menu.tsx):
  - Append MaskedChip after visible chips on multi-value rows.
  - Render MaskedChip as the sole value on single-value rows where the caller
    holds no visible value.

Policy details (policy_details.tsx, .scss, .test.tsx):
  - Track hasMaskedRows state; receive from TableEditor via onMaskedStateChange.
  - Show masked-values warning banner above the editor when present.
  - Same banner on the Delete confirmation modal so admins understand why
    deletion is consequential.

Team settings modal (team_policy_editor.tsx, .scss):
  - Same masked-values plumbing; delete button uses the disabled state when
    a policy has masked values, regardless of whether channels are assigned.
  - Pre-save check no longer treats "in []" as an incomplete rule — that
    placeholder comes from fully-masked rows that merge-on-save will fill in.

i18n entries added for the new strings.

* MM-68506 - fix hook order in SingleValueSelector when masked state changes

The early return for `hasMaskedValues && !value` sat between useState
and useCallback declarations, so when a parent re-render flipped the
masked state (e.g. after deleting a sibling rule) React saw a different
hook count and crashed with "Rendered fewer hooks than expected".

Move the read-only short-circuit after all hook declarations so the hook
order stays stable across renders.

* MM-68507 - CEL editor read-only when masked + system console wiring

CEL editor (editor.tsx, editor.scss):
  - hasMaskedRows prop: when true, Monaco is set to read-only and a banner
    explains why ("This expression contains restricted values. Switch to
    Simple mode to edit the values you have access to, or delete the entire
    rule.").
  - Test Rules button disabled in CEL mode when hasMaskedRows is true.

Policy details (policy_details.tsx, .scss):
  - hasMaskedRows state plumbed to CELEditor, TableEditor, and the Save /
    Delete buttons.
  - Save button disabled while masked rows are present (kept after the
    save-allowed-with-masked-values change in BE-5? — no, here we keep Save
    enabled so admins can add/modify rules; only row removal of masked rows
    is blocked).
  - Delete Policy button disabled when hasMaskedRows; a SectionNotice above
    the Delete card explains why ("This policy contains restricted values
    - Deletion not allowed").
  - New save error messages: invalid_value and self_exclusion are surfaced
    from the server's generic responses.

Policies list (policies.tsx): minor wiring change for the new state plumbing.

Table editor (table_editor.tsx): cross-component coordination — emits
onMaskedStateChange and respects the disabled-for-masked-row policy.

* MM-68508 - E2E suite for attribute-value masking

Covers the full read+write masking flow against a real server:
  - Masked chip rendering, operator/attribute lock, Test Rules disabled.
  - System admin subject to masking like any other caller (no role bypass).
  - Save with masked values: hidden values preserved by merge-on-save.
  - Trash button disabled on masked rows; server returns 403 on direct
    API attempt to remove a masked condition.
  - Delete Policy button disabled + server 403 when policy has masked values
    (both system console and team settings modal paths).
  - Self-inclusion failure only fires when the caller holds full visibility.
  - CEL editor read-only with banner when masked rows present.
  - Direct API validation: non-held values and the masked-token sentinel
    rejected with a generic "Invalid value." error.
  - Feature-flag-off path: no masking, all values visible.
  - Text-field shared_only masking (binary) with `in` and `==` operators.

A pluggable DB-setup helper marks specific CPA fields as shared_only for
the duration of a test (with per-test cleanup) since the API blocks setting
access_mode=shared_only without a source_plugin_id.

* MM-68506 - fix lint, jest mock factory, and unreachable delete-modal test

* MM-68506 - localize masked-condition-deleted save error

* MM-68506 - fix masked-policy delete warning detection and localize masked_rule_deleted

* fix linter issues

* MM-68506 - surface delete error, lock value selector on masked rows, drop dead remove-modal

* fix linter, add translations, adjust specs

* import wittoltip from shared

* fix linter and use the correct button variant

* MM-68506 - drop dangling rationale comment in access_control_field_test

* fix linter, translation and e2e tests

* use pg ts types and dependencies for e2e types mocks

* adjust switch mode persistance restriction

* fix team settings style buttons

* fail-closed guard for advanced expressions in  merge-on-save, plus  helper unit tests, and FF/test-helper cleanups

* Refactor access control methods to use GetPropertyGroup for CPA group ID retrieval

* fix styles

* disable delete on masked policies in list view and remove dead modal warnings

* fix unit tests

* preserve hasAnyOf operator display for fully-masked multiselect conditions

* address PR feedback: lock Actions on masked save, filter source/shared_only from /attributes, add unit tests and e2e tests

* fix e2e tests

* comment out e2e to isolate issue

* completely remove the files to pass linter

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-19 21:25:14 +02:00

1509 lines
57 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package properties
import (
"encoding/json"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetPropertyFieldReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
// only "plugin-1" and "test-plugin" will be checked as installed
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID1 := "plugin-1"
pluginID2 := "plugin-2"
userID := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
rctx2 := RequestContextWithCallerID(th.Context, pluginID2)
rctxUser := RequestContextWithCallerID(th.Context, userID)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
t.Run("public field - any caller can read without filtering", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Plugin 1 can read
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
// Plugin 2 can read
retrieved, err = th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
// User can read
retrieved, err = th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
// Anonymous caller can read
retrieved, err = th.service.GetPropertyField(rctxAnon, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
})
t.Run("source_only field - source plugin gets all options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option 1"},
map[string]any{"id": "opt2", "value": "Secret Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Source plugin can see all options
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
})
t.Run("source_only field - other plugin gets empty options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-2",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option 1"},
map[string]any{"id": "opt2", "value": "Secret Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Other plugin gets field but with empty options
retrieved, err := th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
t.Run("source_only field - user gets empty options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-3",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option 1"},
map[string]any{"id": "opt2", "value": "Secret Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// User gets field but with empty options
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
t.Run("source_only field - anonymous caller gets empty options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field-4",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option 1"},
map[string]any{"id": "opt2", "value": "Secret Option 2"},
},
},
}
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Anonymous caller gets field but with empty options
retrieved, err := th.service.GetPropertyField(rctxAnon, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
t.Run("shared_only field - caller with values sees filtered options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-field",
Type: model.PropertyFieldTypeMultiselect,
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"},
},
},
}
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// Create values for the caller (userID has opt1 and opt2)
value1, jsonErr := json.Marshal([]string{"opt1", "opt2"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: created.ID,
TargetType: "user",
TargetID: userID,
Value: value1,
})
require.NoError(t, err)
// User should only see opt1 and opt2
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
options := retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, options, 2)
// Verify the options are opt1 and opt2
optionIDs := make([]string, 0, len(options))
for _, opt := range options {
optMap := opt.(map[string]any)
optionIDs = append(optionIDs, optMap["id"].(string))
}
assert.Contains(t, optionIDs, "opt1")
assert.Contains(t, optionIDs, "opt2")
assert.NotContains(t, optionIDs, "opt3")
})
t.Run("shared_only field - caller with no values sees empty options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-field-2",
Type: model.PropertyFieldTypeMultiselect,
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"},
},
},
}
created, err := th.service.CreatePropertyField(rctxTestPlugin, field)
require.NoError(t, err)
// User has no values for this field
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
t.Run("shared_only field - source plugin gets all options", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-field-source",
Type: model.PropertyFieldTypeMultiselect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsSourcePluginID: pluginID1,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
map[string]any{"id": "opt3", "value": "Option 3"},
},
},
}
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Source plugin can see all options even without having any values
retrieved, err := th.service.GetPropertyField(rctx1, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
options := retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, options, 3)
// Other plugin with no values sees empty options
retrieved, err = th.service.GetPropertyField(rctx2, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
t.Run("non-CPA group routes directly to PropertyService without filtering", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_routing_read", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "routing-test-non-cpa-source-only",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: pluginID1,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
map[string]any{"id": "opt2", "value": "Option 2"},
},
},
}
rctx1 := RequestContextWithCallerID(th.Context, pluginID1)
created, err := th.service.CreatePropertyField(rctx1, field)
require.NoError(t, err)
// Other plugin sees ALL options (no filtering, goes directly to PropertyService)
rctx2 := RequestContextWithCallerID(th.Context, "plugin-2")
retrieved, err := th.service.GetPropertyField(rctx2, nonCpaGroup.ID, created.ID)
require.NoError(t, err)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 2)
})
t.Run("field with no attrs defaults to public", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "no-attrs-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: nil,
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Any caller can read
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
})
t.Run("field with empty access_mode defaults to public", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "empty-access-mode-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{},
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Any caller can read
retrieved, err := th.service.GetPropertyField(rctxUser, th.CPAGroupID, created.ID)
require.NoError(t, err)
assert.Equal(t, created.ID, retrieved.ID)
})
t.Run("field with invalid access_mode is rejected", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "invalid-access-mode-field",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: "invalid-mode",
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Option 1"},
},
},
}
_, err := th.service.CreatePropertyField(rctxAnon, field)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid access mode")
})
}
func TestGetPropertyFieldsReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID := "plugin-1"
userID := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
rctxUser := RequestContextWithCallerID(th.Context, userID)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
// Create multiple fields with different access modes
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-field",
Type: model.PropertyFieldTypeText,
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)
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-only-field",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option"},
},
},
}
sourceOnlyField, err = th.service.CreatePropertyField(rctxPlugin, sourceOnlyField)
require.NoError(t, err)
sharedOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-only-field",
Type: model.PropertyFieldTypeMultiselect,
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"},
},
},
}
sharedOnlyField, err = th.service.CreatePropertyField(rctxTestPlugin, sharedOnlyField)
require.NoError(t, err)
// Create a value for userID on the shared field (opt1)
value, jsonErr := json.Marshal([]string{"opt1"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedOnlyField.ID,
TargetType: "user",
TargetID: userID,
Value: value,
})
require.NoError(t, err)
t.Run("source plugin sees all fields with full options", func(t *testing.T) {
fields, err := th.service.GetPropertyFields(rctxPlugin, th.CPAGroupID, []string{publicField.ID, sourceOnlyField.ID, sharedOnlyField.ID})
require.NoError(t, err)
require.Len(t, fields, 3)
// Find each field and verify
for _, field := range fields {
if field.ID == sourceOnlyField.ID {
// Source plugin sees all options
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
}
}
})
t.Run("user sees all fields with filtered options", func(t *testing.T) {
fields, err := th.service.GetPropertyFields(rctxUser, th.CPAGroupID, []string{publicField.ID, sourceOnlyField.ID, sharedOnlyField.ID})
require.NoError(t, err)
require.Len(t, fields, 3)
// Find each field and verify
for _, field := range fields {
if field.ID == sourceOnlyField.ID {
// User sees empty options for source_only
assert.Empty(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any))
} else if field.ID == sharedOnlyField.ID {
// User sees filtered options (only opt1)
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
}
}
})
}
func TestSearchPropertyFieldsReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "test-plugin"
})
pluginID := "plugin-1"
userID := model.NewId()
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
rctxUser := RequestContextWithCallerID(th.Context, userID)
rctxTestPlugin := RequestContextWithCallerID(th.Context, "test-plugin")
// Create fields with different access modes
publicField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "public-search-field",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModePublic,
},
}
_, err := th.service.CreatePropertyField(rctxAnon, publicField)
require.NoError(t, err)
sourceOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "source-search-field",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret"},
},
},
}
_, err = th.service.CreatePropertyField(rctxPlugin, sourceOnlyField)
require.NoError(t, err)
sharedOnlyField := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "shared-search-field",
Type: model.PropertyFieldTypeMultiselect,
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"},
},
},
}
sharedOnlyField, err = th.service.CreatePropertyField(rctxTestPlugin, sharedOnlyField)
require.NoError(t, err)
// Create value for userID (opt1)
value, jsonErr := json.Marshal([]string{"opt1"})
require.NoError(t, jsonErr)
_, err = th.service.CreatePropertyValue(rctxTestPlugin, &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: sharedOnlyField.ID,
TargetType: "user",
TargetID: userID,
Value: value,
})
require.NoError(t, err)
t.Run("search returns all fields with appropriate filtering", func(t *testing.T) {
// User search
results, err := th.service.SearchPropertyFields(rctxUser, th.CPAGroupID, model.PropertyFieldSearchOpts{
PerPage: 100,
})
require.NoError(t, err)
assert.GreaterOrEqual(t, len(results), 3)
// Verify filtering
for _, field := range results {
if field.Name == "source-search-field" {
// User sees empty options for source_only
assert.Empty(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any))
} else if field.Name == "shared-search-field" {
// User sees filtered options (only opt1)
options := field.Attrs[model.PropertyFieldAttributeOptions].([]any)
assert.Len(t, options, 1)
}
}
})
t.Run("source plugin search sees unfiltered options", func(t *testing.T) {
results, err := th.service.SearchPropertyFields(rctxPlugin, th.CPAGroupID, model.PropertyFieldSearchOpts{
PerPage: 100,
})
require.NoError(t, err)
// Verify source plugin sees all options
for _, field := range results {
if field.Name == "source-search-field" {
assert.Len(t, field.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
}
}
})
}
func TestGetPropertyFieldByNameReadAccess(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1"
})
pluginID := "plugin-1"
userID := model.NewId()
rctxPlugin := RequestContextWithCallerID(th.Context, pluginID)
rctxUser := RequestContextWithCallerID(th.Context, userID)
t.Run("source_only field by name - filters options for non-source", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "byname-source-only",
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsProtected: true,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin, field)
require.NoError(t, err)
// Source plugin can see options
retrieved, err := th.service.GetPropertyFieldByName(rctxPlugin, th.CPAGroupID, "", created.Name)
require.NoError(t, err)
assert.Len(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any), 1)
// User sees empty options
retrieved, err = th.service.GetPropertyFieldByName(rctxUser, th.CPAGroupID, "", created.Name)
require.NoError(t, err)
assert.Empty(t, retrieved.Attrs[model.PropertyFieldAttributeOptions].([]any))
})
}
// TestCreatePropertyField_AccessControl tests access control for field creation based on caller type
func TestCreatePropertyField_AccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "security-plugin"
})
rctxAnon := RequestContextWithCallerID(th.Context, "")
rctxUser1 := RequestContextWithCallerID(th.Context, "user-1")
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxSecurityPlugin := RequestContextWithCallerID(th.Context, "security-plugin")
// Non-plugin caller tests
t.Run("non-plugin caller can create field without source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxUser1, field)
require.NoError(t, err)
assert.NotNil(t, created)
// source_plugin_id is not set for non-plugin callers
assert.Empty(t, created.Attrs[model.PropertyAttrsSourcePluginID])
})
t.Run("non-plugin caller cannot set source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "plugin-1",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "user-id-123"), field)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "source_plugin_id can only be set by a plugin")
})
t.Run("non-plugin caller cannot set protected", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxUser1, field)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "protected can only be set by a plugin")
})
t.Run("anonymous caller cannot set source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "plugin-1",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "source_plugin_id can only be set by a plugin")
})
t.Run("anonymous caller cannot set protected", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.Error(t, err)
assert.Nil(t, created)
assert.Contains(t, err.Error(), "protected can only be set by a plugin")
})
t.Run("anonymous caller can create field with empty source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
assert.NotNil(t, created)
})
// Plugin caller tests
t.Run("plugin caller auto-sets source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
assert.NotNil(t, created)
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
})
t.Run("plugin caller overwrites any pre-set source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsSourcePluginID: "malicious-plugin",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
assert.NotNil(t, created)
assert.Equal(t, "plugin-1", created.Attrs[model.PropertyAttrsSourcePluginID])
})
t.Run("plugin caller can create protected field with source_only access mode", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: model.NewId(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": "opt1", "value": "Secret Option"},
},
},
}
created, err := th.service.CreatePropertyField(rctxSecurityPlugin, field)
require.NoError(t, err)
assert.NotNil(t, created)
assert.Equal(t, "security-plugin", created.Attrs[model.PropertyAttrsSourcePluginID])
assert.True(t, created.Attrs[model.PropertyAttrsProtected].(bool))
assert.Equal(t, model.PropertyAccessModeSourceOnly, created.Attrs[model.PropertyAttrsAccessMode])
})
t.Run("non-CPA group routes directly to PropertyService without setting source_plugin_id", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_create", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
rctx := RequestContextWithCallerID(th.Context, "plugin-2")
created, err := th.service.CreatePropertyField(rctx, field)
require.NoError(t, err)
assert.NotNil(t, created)
assert.Nil(t, created.Attrs[model.PropertyAttrsSourcePluginID])
})
}
// TestUpdatePropertyField_WriteAccessControl tests write access control for field updates
func TestUpdatePropertyField_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
rctxAnon := RequestContextWithCallerID(th.Context, "")
t.Run("allows update of unprotected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Original Name",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Updated Name"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, "Updated Name", updated.Name)
})
t.Run("allows source plugin to update protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected Field",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Updated Protected Field"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
assert.Equal(t, "Updated Protected Field", updated.Name)
})
t.Run("denies non-source plugin updating protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected Field",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Attempted Update"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
assert.Contains(t, err.Error(), "plugin-1")
})
t.Run("denies empty callerID updating protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected Field Empty Caller",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
created.Name = "Attempted Update"
updated, _, err := th.service.UpdatePropertyField(rctxAnon, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
})
t.Run("prevents changing source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Field",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Try to change source_plugin_id
created.Attrs[model.PropertyAttrsSourcePluginID] = "plugin-2"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "immutable")
})
t.Run("prevents setting protected=true without source_plugin_id", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Field Without Source Plugin",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxAnon, field)
require.NoError(t, err)
// Try to set protected=true without having a source_plugin_id
created.Attrs[model.PropertyAttrsProtected] = true
updated, _, err := th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
assert.Contains(t, err.Error(), "source_plugin_id")
})
t.Run("prevents non-source plugin from setting protected=true", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Field With Source Plugin",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
// Create field via plugin-1 (sets source_plugin_id automatically)
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
assert.False(t, model.IsPropertyFieldProtected(created))
// Try to set protected=true by a different plugin (plugin-2)
created.Attrs[model.PropertyAttrsProtected] = true
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
assert.Contains(t, err.Error(), "plugin-1")
// Verify the source plugin (plugin-1) CAN set protected=true
updated, _, err = th.service.UpdatePropertyField(rctxPlugin1, th.CPAGroupID, created)
require.NoError(t, err)
assert.True(t, model.IsPropertyFieldProtected(updated))
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_update", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "Non-CPA Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Update with different plugin - should be allowed (no access control)
created.Name = "Updated by Plugin2"
updated, _, err := th.service.UpdatePropertyField(rctxPlugin2, nonCpaGroup.ID, created)
require.NoError(t, err)
assert.NotNil(t, updated)
assert.Equal(t, "Updated by Plugin2", updated.Name)
})
}
// TestUpdatePropertyFields_BulkWriteAccessControl tests bulk field updates with atomic access checking
func TestUpdatePropertyFields_BulkWriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1" || pluginID == "plugin-2"
})
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows bulk update of unprotected fields", func(t *testing.T) {
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, 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)
created1.Name = "Updated Field1"
created2.Name = "Updated Field2"
updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.NoError(t, err)
assert.Len(t, updated, 2)
})
t.Run("fails atomically when one protected field in batch", func(t *testing.T) {
// Create unprotected field
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText, 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)
// Try to update both with plugin2 (should fail atomically)
created1.Name = "Updated Unprotected"
created2.Name = "Updated Protected"
updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin2, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
// Verify neither was updated
check1, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created1.ID)
require.NoError(t, err)
assert.Equal(t, "Unprotected", check1.Name)
check2, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created2.ID)
require.NoError(t, err)
assert.Equal(t, "Protected", check2.Name)
})
t.Run("fails atomically when trying to set protected=true without source_plugin_id in batch", func(t *testing.T) {
rctxAnon := RequestContextWithCallerID(th.Context, "")
// Create two unprotected fields without source_plugin_id
field1 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field1", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
field2 := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Field2", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{}, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created1, err := th.service.CreatePropertyField(rctxAnon, field1)
require.NoError(t, err)
created2, err := th.service.CreatePropertyField(rctxAnon, field2)
require.NoError(t, err)
// Try to set protected=true on field2 without source_plugin_id
created1.Name = "Updated Field1"
created2.Attrs[model.PropertyAttrsProtected] = true
updated, _, _, err := th.service.UpdatePropertyFields(rctxPlugin1, th.CPAGroupID, []*model.PropertyField{created1, created2})
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "cannot set protected=true")
assert.Contains(t, err.Error(), "source_plugin_id")
// Verify neither was updated (atomic failure)
check1, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created1.ID)
require.NoError(t, err)
assert.Equal(t, "Field1", check1.Name)
check2, err := th.service.GetPropertyField(rctxPlugin1, th.CPAGroupID, created2.ID)
require.NoError(t, err)
assert.False(t, model.IsPropertyFieldProtected(check2))
})
}
// TestDeletePropertyField_WriteAccessControl tests write access control for field deletion
func TestDeletePropertyField_WriteAccessControl(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
rctxPlugin1 := RequestContextWithCallerID(th.Context, "plugin-1")
rctxPlugin2 := RequestContextWithCallerID(th.Context, "plugin-2")
t.Run("allows deletion of unprotected field", func(t *testing.T) {
field := &model.PropertyField{GroupID: th.CPAGroupID, Name: "Unprotected", Type: model.PropertyFieldTypeText, ObjectType: model.PropertyFieldObjectTypeUser, TargetType: string(model.PropertyFieldTargetLevelSystem)}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
err = th.service.DeletePropertyField(rctxPlugin2, th.CPAGroupID, created.ID)
require.NoError(t, err)
})
t.Run("allows source plugin to delete protected field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
err = th.service.DeletePropertyField(rctxPlugin1, th.CPAGroupID, created.ID)
require.NoError(t, err)
})
t.Run("denies non-source plugin deleting protected field", func(t *testing.T) {
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "plugin-1"
})
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
err = th.service.DeletePropertyField(rctxPlugin2, th.CPAGroupID, created.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
})
t.Run("non-CPA group routes directly to PropertyService without access control", func(t *testing.T) {
nonCpaGroup, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{Name: "other_group_delete", Version: model.PropertyGroupVersionV2})
require.NoError(t, err)
field := &model.PropertyField{
GroupID: nonCpaGroup.ID,
Name: "Non-CPA Delete Protected",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsSourcePluginID: "plugin-1",
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
// Delete with different plugin - should be allowed (no access control)
err = th.service.DeletePropertyField(rctxPlugin2, nonCpaGroup.ID, created.ID)
require.NoError(t, err)
})
}
func TestDeletePropertyField_OrphanedFieldDeletion(t *testing.T) {
th := Setup(t).RegisterCPAPropertyGroup(t)
th.service.setPluginCheckerForTests(func(pluginID string) bool { return pluginID == "plugin-1" })
t.Run("allows deletion of orphaned protected field when plugin is uninstalled", func(t *testing.T) {
// Create the field while the plugin is still installed
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "removed-plugin"
})
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Orphaned Protected Field",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
require.NoError(t, err)
// Simulate plugin uninstall
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return false
})
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created.ID)
require.NoError(t, err)
})
t.Run("blocks deletion of protected field when plugin is still installed", func(t *testing.T) {
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "installed-plugin"
})
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Active Protected Field",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "installed-plugin"), field)
require.NoError(t, err)
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "protected")
assert.Contains(t, err.Error(), "installed-plugin")
err = th.service.DeletePropertyField(RequestContextWithCallerID(th.Context, "installed-plugin"), th.CPAGroupID, created.ID)
require.NoError(t, err)
})
t.Run("blocks update of orphaned protected field even when plugin is uninstalled", func(t *testing.T) {
// Create the field while the plugin is still installed
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return pluginID == "removed-plugin"
})
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "Orphaned Field For Update",
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
},
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}
created, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "removed-plugin"), field)
require.NoError(t, err)
// Simulate plugin uninstall
th.service.setPluginCheckerForTests(func(pluginID string) bool {
return false
})
created.Name = "Updated Orphaned Field"
updated, _, err := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), th.CPAGroupID, created)
require.Error(t, err)
assert.Nil(t, updated)
assert.Contains(t, err.Error(), "protected")
})
}
func TestLinkedPropertyField_SecurityInheritance(t *testing.T) {
t.Skip("Skip until CPA is fully migrated to v2: this test creates linked fields with ObjectType and permissions on a v1 group, which is rejected by the version mismatch check")
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")
rctxUser := RequestContextWithCallerID(th.Context, "user-1")
sysadminLevel := model.PermissionLevelSysadmin
// Helper: create a protected template in the CPA group owned by plugin-1
createProtectedTemplate := func(t *testing.T, name string, accessMode string) *model.PropertyField {
t.Helper()
attrs := model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: accessMode,
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "Option A"},
map[string]any{"id": model.NewId(), "name": "Option B"},
},
}
field := &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: name,
Attrs: attrs,
PermissionField: &sysadminLevel,
PermissionValues: &sysadminLevel,
PermissionOptions: &sysadminLevel,
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
return created
}
// Helper: create an unprotected template in the CPA group
createUnprotectedTemplate := func(t *testing.T, name string) *model.PropertyField {
t.Helper()
field := &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Type: model.PropertyFieldTypeSelect,
Name: name,
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: []any{
map[string]any{"id": model.NewId(), "name": "Option A"},
},
},
}
created, err := th.service.CreatePropertyField(rctxPlugin1, field)
require.NoError(t, err)
return created
}
t.Run("source plugin can create linked field from protected template", func(t *testing.T) {
source := createProtectedTemplate(t, "ProtectedSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
linked, err := th.service.CreatePropertyField(rctxPlugin1, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
// Verify security attrs inherited
assert.True(t, model.IsPropertyFieldProtected(linked))
assert.Equal(t, "plugin-1", linked.Attrs[model.PropertyAttrsSourcePluginID])
assert.Equal(t, model.PropertyAccessModeSourceOnly, linked.Attrs[model.PropertyAttrsAccessMode])
})
t.Run("different plugin cannot create linked field from protected template", func(t *testing.T) {
source := createProtectedTemplate(t, "ProtectedSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
_, err := th.service.CreatePropertyField(rctxPlugin2, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.Error(t, err)
var appErr *model.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.DetailedError, "only the source plugin")
})
t.Run("non-plugin caller cannot create linked field from protected template", func(t *testing.T) {
source := createProtectedTemplate(t, "ProtectedSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
_, err := th.service.CreatePropertyField(rctxUser, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "only the source plugin")
})
t.Run("linked field inherits Protected and attrs from source", func(t *testing.T) {
source := createProtectedTemplate(t, "InheritSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
linked, err := th.service.CreatePropertyField(rctxPlugin1, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
assert.Equal(t, source.Protected, linked.Protected)
assert.Equal(t, source.Attrs[model.PropertyAttrsProtected], linked.Attrs[model.PropertyAttrsProtected])
assert.Equal(t, source.Attrs[model.PropertyAttrsSourcePluginID], linked.Attrs[model.PropertyAttrsSourcePluginID])
assert.Equal(t, source.Attrs[model.PropertyAttrsAccessMode], linked.Attrs[model.PropertyAttrsAccessMode])
})
t.Run("linked field inherits permission levels from source", func(t *testing.T) {
source := createProtectedTemplate(t, "PermSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
linked, err := th.service.CreatePropertyField(rctxPlugin1, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
require.NotNil(t, linked.PermissionField)
require.NotNil(t, linked.PermissionValues)
require.NotNil(t, linked.PermissionOptions)
assert.Equal(t, *source.PermissionField, *linked.PermissionField)
assert.Equal(t, *source.PermissionValues, *linked.PermissionValues)
assert.Equal(t, *source.PermissionOptions, *linked.PermissionOptions)
})
t.Run("linked field inherits source access_mode even if caller supplies different value", func(t *testing.T) {
source := createProtectedTemplate(t, "MismatchSource-"+model.NewId(), model.PropertyAccessModeSourceOnly)
linked, err := th.service.CreatePropertyField(rctxPlugin1, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
Attrs: model.StringInterface{
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
},
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
// Caller supplied shared_only but source's source_only is inherited
assert.Equal(t, model.PropertyAccessModeSourceOnly, linked.Attrs[model.PropertyAttrsAccessMode])
})
t.Run("linked field from unprotected template does not inherit protection", func(t *testing.T) {
source := createUnprotectedTemplate(t, "UnprotectedSource-"+model.NewId())
linked, err := th.service.CreatePropertyField(rctxPlugin1, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
assert.False(t, model.IsPropertyFieldProtected(linked))
})
t.Run("non-plugin caller can create linked field from unprotected template", func(t *testing.T) {
source := createUnprotectedTemplate(t, "UnprotectedSource-"+model.NewId())
linked, err := th.service.CreatePropertyField(rctxUser, &model.PropertyField{
GroupID: th.CPAGroupID,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Name: "Linked-" + model.NewId(),
Type: model.PropertyFieldTypeText,
LinkedFieldID: &source.ID,
})
require.NoError(t, err)
assert.False(t, model.IsPropertyFieldProtected(linked))
})
}