[MM-70086] Compare user attributes against channel attributes in access rules (#37755)

This commit is contained in:
David Krauser
2026-08-25 09:55:41 -04:00
committed by GitHub
parent e7360779e0
commit c3a5a087d7
81 changed files with 5286 additions and 480 deletions
+1
View File
@@ -70,6 +70,7 @@ services:
MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS: "true"
MM_FEATUREFLAGS_INTEGRATEDBOARDS: "true"
MM_FEATUREFLAGS_PROPERTYFIELDRANK: "true"
MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES: "true"
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: "true"
MM_FEATUREFLAGS_WYSIWYGEDITOR: "true"
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: "true"
@@ -22,6 +22,7 @@ export const SERVER_ENV_BASELINE: Record<string, string> = {
MM_FEATUREFLAGS_PERMISSIONPOLICIES: 'true',
MM_FEATUREFLAGS_PROPERTYFIELDRANK: 'true',
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: 'true',
MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES: 'true',
MM_FEATUREFLAGS_TEAMMEMBERSHIPACCESSCONTROL: 'true',
MM_FEATUREFLAGS_WYSIWYGEDITOR: 'true',
};
@@ -165,7 +165,7 @@ export async function setUserAttribute(adminClient: Client4, userId: string, fie
*
* Why this exists: ABAC queries (validateExpressionAgainstRequester,
* calculateMembershipChanges) read from a Postgres materialized view
* (`AttributeView`). The enterprise access-control service refreshes that view
* (`UserAttributeView`). The enterprise access-control service refreshes that view
* at most once every 30 seconds — so freshly-written CPA values are not visible
* until the next refresh tick. A test that writes a brand-new CPA value and
* then immediately clicks "Save" on a rule referencing that value will hit
@@ -206,7 +206,7 @@ export async function waitForAttributeViewToInclude(
}
const missing = expectedUserIds.filter((id) => !lastSeen.has(id));
throw new Error(
`AttributeView did not include users [${missing.join(', ')}] for expression "${expression}" within ${timeoutMs}ms`,
`UserAttributeView did not include users [${missing.join(', ')}] for expression "${expression}" within ${timeoutMs}ms`,
);
}
@@ -240,7 +240,7 @@ export async function waitForAttributeViewToExclude(
}
const stillPresent = excludedUserIds.filter((id) => lastSeen.has(id));
throw new Error(
`AttributeView still includes users [${stillPresent.join(', ')}] for expression "${expression}" after ${timeoutMs}ms`,
`UserAttributeView still includes users [${stillPresent.join(', ')}] for expression "${expression}" after ${timeoutMs}ms`,
);
}
@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {enableTeamMembershipPolicies} from '../teams/helpers';
import {createChannelTextField, createParentPolicyViaAPI, expectAssignTeamsDenied} from './helpers';
/**
* Authoring round-trip for resource.attributes.* over the real HTTP boundary
* (Playwright drives a live server with the enterprise engine, so SavePolicy /
* cel-check validation runs for real — the api4 Go tests mock the engine and
* cannot).
*
* One representative case per rule; the exhaustive save-validation matrix
* (multiselect reject, rank scale-match, permission-policy accept) lives in the
* enterprise engine unit tests.
*/
test.describe('ABAC resource.attributes - authoring', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('accepts a parent policy mixing user and resource attributes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Save succeeds and returns a policy id — the round-trip accepts a
// mixed user/resource expression on a parent policy.
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Accept Resource ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
expect(policyId).toBeTruthy();
});
test('rejects has(resource.attributes.*) at check time', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Absence is handled by deny-on-miss, so has() guards on resource
// attributes are rejected. cel/check surfaces the error to the editor.
// Assert the reason, not just that validation failed: a bare count also
// passes on an unrelated compile or engine error, and would have kept
// passing while the feature flag denied every resource reference.
const errors = await adminClient.checkAccessControlExpression(`has(resource.attributes.${attr})`);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].message).toContain('has() is not supported on resource attributes');
});
test('rejects assigning a resource parent to a team', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await enableTeamMembershipPolicies(adminClient);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Team Boundary ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
// A team's resource is a team, which has no CPA attributes, so a parent
// that references resource.attributes.* must not be importable by a team.
await expectAssignTeamsDenied(adminClient, policyId, [team.id]);
});
});
@@ -0,0 +1,295 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, type Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
/**
* Helpers for exercising resource.attributes.* (channel custom profile
* attributes) end to end. The subject of a policy stays the user; these helpers
* provision the *resource* side — channel-object-type CPA fields in the
* access_control group and per-channel values — plus API-driven policy authoring
* so a spec can exercise the resource side without also driving the policy
* editor UI.
*
* The server under test must run with MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES
* set: the feature is flag-gated, and a feature flag cannot be turned on through
* the config API (the config store restores flags on write), so no amount of
* patchConfig in a spec substitutes for the environment variable. Both CI paths
* set it — SERVER_ENV_BASELINE for the testcontainers stack the Playwright suite
* runs on, and e2e-tests/.ci/server.generate.sh for the docker-compose one — and
* every spec here guards with skipIfFeatureFlagNotSet so a server without it
* skips rather than failing on a policy save the server refuses.
*/
const PROPERTY_GROUP = 'access_control';
const CHANNEL_OBJECT_TYPE = 'channel';
const USER_OBJECT_TYPE = 'user';
const TEMPLATE_OBJECT_TYPE = 'template';
/**
* Create a channel-object-type text CPA field in the access_control group. The
* ABAC materialized view surfaces it as resource.attributes.<name>. Marked
* admin-managed so SavePolicy's name normalization accepts a reference to it
* (channel fields have no user-managed-attributes toggle). Returns the field id.
*/
export async function createChannelTextField(adminClient: Client4, name: string): Promise<string> {
const field = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name,
type: 'text',
target_type: 'system',
target_id: '',
attrs: {managed: 'admin'},
} as Parameters<Client4['createPropertyField']>[2]);
return field.id;
}
/**
* Set a single channel's value for a channel CPA field. A refresh-before-read
* is not needed for the sync lane (the sync job reads a freshly refreshed
* matview), but note the matview is refreshed on a timer, so set values before
* triggering the sync job.
*/
export async function setChannelAttributeValue(
adminClient: Client4,
channelId: string,
fieldId: string,
value: string,
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [{field_id: fieldId, value}]);
}
type ParentPolicyOptions = {
name: string;
expression: string;
version?: string;
};
/**
* Create a parent membership policy via the REST API. A parent is the reusable
* rule-carrier: assign channels to it (assignChannelsToPolicy) to put those
* channels under its rules. Returns the created policy id.
*/
export async function createParentPolicyViaAPI(adminClient: Client4, opts: ParentPolicyOptions): Promise<string> {
// The version sent here is advisory: CreateOrUpdateAccessControlPolicy
// overwrites it (v0.3, bumped to v0.4 only for permission-action rules), and
// no version gates resource.attributes.* — that's the
// ResourceAttributesInPolicies flag plus the "not a team policy" rule.
const policy = await adminClient.updateOrCreateAccessControlPolicy({
id: '',
name: opts.name,
type: 'parent',
version: opts.version ?? 'v0.3',
revision: 0,
active: true,
rules: [{expression: opts.expression, actions: ['membership']}],
});
return policy.id;
}
/**
* Assign channels to a parent policy (creates the per-channel child policy that
* imports the parent and flips enforcement on). Mirrors the System Console
* "Add channels" flow.
*/
export async function assignChannelsToPolicy(
adminClient: Client4,
policyId: string,
channelIds: string[],
): Promise<void> {
// The /assign endpoint returns 200 with an empty body but a JSON content
// type, which Client4.doFetch chokes on ("Unexpected end of JSON input").
// Use a raw request and check status, matching the pattern the rest of the
// ABAC e2e helpers use for these no-body endpoints.
const res = await fetch(`${adminClient.getBaseRoute()}/access_control_policies/${policyId}/assign`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${adminClient.getToken()}`},
body: JSON.stringify({channel_ids: channelIds}),
});
if (!res.ok) {
throw new Error(`assign channels failed: ${res.status} ${await res.text()}`);
}
}
/**
* Trigger an access_control_sync job. Pass a policyId to target the channels
* governed by that policy; omit it for a global sweep. Returns the job id.
*/
export async function triggerSyncJob(adminClient: Client4, policyId?: string): Promise<string> {
const job = await adminClient.createAccessControlSyncJob(policyId ? {policy_id: policyId} : {});
return job.id;
}
export type MultiselectScale = {
templateId: string;
userFieldId: string;
userFieldName: string;
channelFieldId: string;
channelFieldName: string;
// Server-assigned option ids keyed by option name. Both the user and channel
// fields inherit these exact ids from the template (that's what "shared option
// scale" means), so hasAnyOf/hasAllOf compares the same id space on both sides.
optionIds: Record<string, string>;
};
/**
* Provision a shared-scale multiselect setup for a list-vs-list (hasAnyOf /
* hasAllOf) rule: a template multiselect field plus a user field and a channel
* field that both link to it via linked_field_id. All three live in the
* access_control group — a user CPA field in the custom_profile_attributes group
* cannot carry a linked_field_id (the server rejects it), and the scale-match
* validation requires the receiver (user) and argument (channel) fields to share
* a non-nil, equal linked_field_id. The linked user field still surfaces as
* user.attributes.<name> to the engine. Returns the field ids/names and the
* option ids (identical across all three fields).
*/
export async function createLinkedMultiselectScale(
adminClient: Client4,
baseName: string,
optionNames: string[],
): Promise<MultiselectScale> {
const template = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
name: `${baseName}_tmpl`,
type: 'multiselect',
target_type: 'system',
target_id: '',
attrs: {options: optionNames.map((name) => ({id: '', name, color: '#0000ff'}))},
} as Parameters<Client4['createPropertyField']>[2]);
const userFieldName = `${baseName}_user`;
const userField = await adminClient.createPropertyField(PROPERTY_GROUP, USER_OBJECT_TYPE, {
name: userFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const channelFieldName = `${baseName}_chan`;
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name: channelFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const optionIds: Record<string, string> = {};
for (const opt of (template.attrs?.options ?? []) as Array<{id: string; name: string}>) {
optionIds[opt.name] = opt.id;
}
return {
templateId: template.id,
userFieldId: userField.id,
userFieldName,
channelFieldId: channelField.id,
channelFieldName,
optionIds,
};
}
/**
* Set a user's multiselect value (list of option ids) for an access_control-group
* user field. Multiselect values are stored as option ids, not names.
*/
export async function setUserMultiselectValue(
adminClient: Client4,
userId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, USER_OBJECT_TYPE, userId, [
{field_id: fieldId, value: optionIds},
]);
}
export async function setChannelMultiselectValue(
adminClient: Client4,
channelId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [
{field_id: fieldId, value: optionIds},
]);
}
/**
* Open an existing membership policy in the System Console editor by id. The
* parent-policy editor page carries the rule table and the Test-access-rule
* button, so UI specs provision the policy over the API and drive only the
* feature under test here.
*/
export async function openPolicyEditor(page: Page, policyId: string): Promise<void> {
await page.goto(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
await page.waitForLoadState('networkidle');
}
type ApiError = {status_code?: number; server_error_id?: string};
/**
* Run an API call that the server is expected to refuse, and assert it failed
* for the named reason. A bare `try { … } catch {}` proves only that something
* threw, so it passes just as happily on a 500, a transport fault, or a gating
* rejection that never reached the rule under test — which is how these specs
* once passed with the feature flag off.
*/
async function expectRejection(
call: () => Promise<unknown>,
expected: {statusCode: number; serverErrorId: string},
because: string,
): Promise<void> {
let error: ApiError | undefined;
try {
await call();
} catch (err) {
error = err as ApiError;
}
expect(error, `expected the server to refuse: ${because}`).toBeDefined();
expect(error?.status_code, because).toBe(expected.statusCode);
expect(error?.server_error_id, because).toBe(expected.serverErrorId);
}
/**
* Assert the runtime PDP refuses to add a user to a channel. The "user is not in
* the channel" check that usually follows can pass on its own, because the sync
* job already removed them — only this establishes that the add was refused.
*/
export async function expectAddToChannelDenied(adminClient: Client4, userId: string, channelId: string): Promise<void> {
await expectRejection(
() => adminClient.addToChannel(userId, channelId),
{statusCode: 403, serverErrorId: 'api.channel.add_user.to.channel.rejected'},
'the runtime PDP denies a non-matching user',
);
}
/**
* Assert assigning a parent policy to a team is refused. The team-boundary
* rejection carries its own error id, so pinning it rules out a permission,
* feature-gate or generic server error reaching this assertion instead.
*/
export async function expectAssignTeamsDenied(
adminClient: Client4,
policyId: string,
teamIds: string[],
): Promise<void> {
await expectRejection(
() => adminClient.assignTeamsToAccessControlPolicy(policyId, teamIds),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.team_resource_attributes'},
'a team cannot import a parent that references resource.attributes.*',
);
}
/**
* Assert a policy save is refused because its expression still carries the
* masked-value sentinel. That rejection has its own error id, distinguishing
* it from other save failures rather than reporting only the general one.
*/
export async function expectMaskedTokenRejected(adminClient: Client4, opts: ParentPolicyOptions): Promise<void> {
await expectRejection(
() => createParentPolicyViaAPI(adminClient, opts),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.masked_token_in_expression'},
'a masked sentinel cannot be resolved to a stored value',
);
}
@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {createChannelTextField, expectMaskedTokenRejected} from './helpers';
/**
* Attribute-value masking on the resource.attributes.* write path.
*
* The masked-value sentinel ("--------", model.MaskingTokenValue) is a
* response-only placeholder the server substitutes for literals a caller can't
* see. It must never round-trip back into storage: saving a policy whose
* resource.attributes.* condition still carries the sentinel is rejected. This
* is the write-path half of the symmetric masking gate, asserted over HTTP.
*
* The read-path (caller-relative redaction of resource literals in GET /
* visual_ast / simulate) reuses the shared-template bridge and protected /
* shared_only fields, which require direct DB provisioning; that path is covered
* by the app-layer resolver test (TestAppMaskingResolver_ChannelFieldUsesUserHoldings),
* the CEL-walker/visual-AST masking tests, and the existing user-attribute
* masking Playwright suite whose mechanics the resource path shares.
*/
test.describe('ABAC resource.attributes - masking write path', {tag: ['@abac', '@abac_masking']}, () => {
test('rejects saving a resource.attributes condition carrying the masked sentinel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
// The sentinel rejection under test lives inside the server's
// AttributeValueMasking branch, so the flag has to be on. It cannot be
// turned on from here (the config store restores feature flags on
// write), so guard rather than try to set it.
await pw.skipIfFeatureFlagNotSet('AttributeValueMasking', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// The 8-dash sentinel is server-generated and never a real value, so a
// submitted expression containing it cannot be resolved to a stored
// value and is rejected.
await expectMaskedTokenRejected(adminClient, {
name: `Masked Sentinel ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "--------"`,
});
});
});
@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createChannelTextField,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelAttributeValue,
triggerSyncJob,
} from './helpers';
/**
* resource.attributes.* membership sync + enforcement (lane agreement).
*
* A policy that mixes the requesting user's attribute with the accessed
* channel's attribute (user.attributes.<a> == resource.attributes.<a>) must:
* - remove only the members whose value differs from the channel's (SQL sync lane),
* - let an admin add a matching user but block a non-matching one (runtime PDP lane),
* - remove everyone when the channel doesn't set the referenced attribute (deny-on-miss).
*
* Authoring/sync are driven through the REST API so the tests don't depend on
* the table-editor's RHS constraints.
*/
test.describe('ABAC resource.attributes - membership sync', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('mixed user/resource scalar policy syncs and enforces joins', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types → user.attributes.<attr> compared
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const userAttribute: CustomProfileAttribute[] = [{name: attr, type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, userAttribute);
const channelFieldId = await createChannelTextField(adminClient, attr);
const matchingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const matchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const nonMatchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'eu'},
]);
for (const u of [matchingUserNotInChannel, matchingUserInChannel, nonMatchingUserInChannel]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channel.id, channelFieldId, 'us');
await adminClient.addToChannel(matchingUserInChannel.id, channel.id);
await adminClient.addToChannel(nonMatchingUserInChannel.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource Region ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane on a private channel with an active policy:
// - the non-matching (eu) member is removed,
// - the matching (us) member stays,
// - the matching (us) user who was only in the team is auto-added —
// an active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchingUserInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
// Runtime lane agrees with the sync result: re-adding the matching user
// succeeds, while adding the non-matching user is blocked.
await adminClient.addToChannel(matchingUserNotInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatchingUserInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
});
test('deny-on-miss removes all members when the channel attribute is absent', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: attr, type: 'text', value: ''},
]);
// Channel field exists so the reference resolves at save time, but the
// channel below never sets a value → the referenced field is missing for
// that channel → deny-on-miss.
await createChannelTextField(adminClient, attr);
const member = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
await adminClient.addToTeam(team.id, member.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(member.id, channel.id);
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(true);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource DenyOnMiss ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// The channel is missing the referenced attribute, so the whole policy
// denies and every governed member is removed — fail-secure.
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(false);
});
});
@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createLinkedMultiselectScale,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelMultiselectValue,
setUserMultiselectValue,
triggerSyncJob,
} from './helpers';
/**
* List-vs-list (multiselect) resource targets end to end: a rule comparing a
* multiselect user attribute against a multiselect channel attribute with
* hasAnyOf / hasAllOf, authored here over the real HTTP boundary. Both fields
* link to a shared template (equal linked_field_id) so they share one option-id
* scale — the only shape the save-time validation accepts.
*
* Each test asserts the two evaluation lanes agree on the same fixtures:
* - SQL sync lane: a non-matching member is removed, a matching member stays,
* and a matching team-only user is auto-added to the assigned private channel.
* - Runtime PDP lane: re-adding a matching user succeeds; adding a non-matching
* user is blocked.
*
* hasAnyOf = the lists intersect. hasAllOf = the channel's list is a subset of
* the user's (the user holds every value the channel requires).
*/
test.describe('ABAC resource.attributes - multiselect targets', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('has any of syncs and enforces on list intersection', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `region${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-any-of matches a user sharing >=1.
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [beta] -> shares beta
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha] -> shares alpha
const nonMatch = await createUserForABAC(adminClient, {}, []); // [gamma] -> disjoint
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [beta]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [gamma]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAnyOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAnyOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the disjoint member is removed, the intersecting member
// stays, and the intersecting team-only user is auto-added — an active
// private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees with the sync verdict: re-adding the
// intersecting user succeeds, while the disjoint one is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
test('has all of syncs and enforces on channel-list subset', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `clr${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-all-of matches only a user holding
// BOTH — a user with just alpha does not (missing beta).
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [alpha, beta, gamma] superset
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha, beta] exact
const nonMatch = await createUserForABAC(adminClient, {}, []); // [alpha] missing beta
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [alpha, beta, gamma]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha, beta]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [alpha]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAllOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAllOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the superset member stays, the member missing a required
// value is removed, and the exact-match team-only user is auto-added — an
// active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees: re-adding the exact-match user succeeds, the
// subset-missing user is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
});
@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
assertAccessControlAutocompleteContains,
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
} from '../support';
import {createChannelTextField, createParentPolicyViaAPI, openPolicyEditor, setChannelAttributeValue} from './helpers';
/**
* Test-matching-users channel picker end to end.
*
* A parent policy that references resource.attributes.* has no channel scope of
* its own, so "Test access rule" cannot resolve the rule until the admin picks a
* concrete channel. The shared test modal therefore opens a channel-picker step
* first; the picked channel's attribute values are threaded into the matching-users
* query. This asserts that flow over the real UI + HTTP: the picker finds a
* channel by name, choosing it resolves the rule against that channel (a matching
* user appears, a non-matching one does not), and the back arrow returns to the
* picker.
*
* The matching-users query reads the attribute materialized view, refreshed on a
* throttled (~30s) cadence, so the first read after setting values may lag — the
* matching assertion re-searches until the view catches up.
*/
test.describe('ABAC resource.attributes - test picker', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('picker resolves a resource rule against the chosen channel', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types so user.attributes.<attr> compares
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, [{name: attr, type: 'text', value: ''}]);
const channelFieldId = await createChannelTextField(adminClient, attr);
const userUS = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'us'}]);
const userEU = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'eu'}]);
await adminClient.addToTeam(team.id, userUS.id);
await adminClient.addToTeam(team.id, userEU.id);
const channelUS = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channelUS.id, channelFieldId, 'us');
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Picker ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
// Fail fast if the editor won't see the attribute (keeps the Test button disabled).
await assertAccessControlAutocompleteContains(adminClient, [attr]);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await openPolicyEditor(page, policyId);
const testButton = page.getByRole('button', {name: /test access rule/i});
await expect(testButton).toBeVisible({timeout: 10000});
await expect(testButton).toBeEnabled({timeout: 15000});
await testButton.click();
const modal = page.locator('.TestResultsModal');
// Picker step: no channel scope on the parent, so the picker precedes the
// members list.
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
const channelSearch = modal.locator('.TestChannelPicker__search-input');
await channelSearch.fill(channelUS.display_name);
const channelRow = modal.locator('.TestChannelPicker__row', {hasText: channelUS.display_name});
await expect(channelRow).toBeVisible({timeout: 10000});
await channelRow.click();
// Members step: the rule now resolves against channelUS (region == "us").
await expect(modal.getByText('Access Rule Test Results')).toBeVisible({timeout: 10000});
await expect(modal.locator('.TestResultsModal__back')).toBeVisible();
const memberSearch = modal.locator('input[placeholder*="Search" i]').first();
await expect(async () => {
await memberSearch.fill(userUS.username);
await expect(modal.locator('.more-modal__name', {hasText: userUS.username})).toBeVisible({timeout: 3000});
}).toPass({timeout: 60000, intervals: [3000]});
// The non-matching user (region == "eu") is not admitted against this channel.
// Wait for the search round-trip before asserting absence: the previous
// row is cleared the moment the term changes, so toHaveCount(0) would
// pass on that intermediate empty render even if userEU were admitted.
const euSearch = page.waitForResponse(
(r) => r.url().includes('/access_control_policies/cel/test') && r.request().method() === 'POST',
{timeout: 15000},
);
await memberSearch.fill(userEU.username);
await euSearch;
await expect(modal.locator('.more-modal__name', {hasText: userEU.username})).toHaveCount(0);
// The back arrow returns to the picker (only present because it preceded).
await modal.locator('.TestResultsModal__back').click();
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
});
});
@@ -366,6 +366,69 @@ export async function createPrivateChannelForABAC(client: Client4, teamId: strin
return channel;
}
/**
* Fill a single-value condition in the table editor's value cell.
*
* A free-text attribute's value editor renders one of two ways depending on
* what comparison targets are available:
* - an always-visible inline input (.values-editor__simple-input), when there
* are no comparable channel-attribute targets; or
* - a dropdown (valueSelectorMenuButton) whose "Add value..." field is only
* revealed after the menu is opened, when channel attributes are offered as
* comparison targets.
*
* Since channel (resource) attributes are system-wide, whether the dropdown
* appears depends on data that other specs may have created on the shared
* server. Handle both variants so the helper is agnostic to that.
*
* An attribute that carries options also renders the dropdown, but with a
* filter field rather than "Add value..." — pick the option from the menu
* instead of calling this.
*/
export async function fillSingleConditionValue(page: Page, value: string): Promise<void> {
const inlineInput = page.locator('.values-editor__simple-input').first();
const menuButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
// Wait for whichever variant rendered after the operator was chosen.
await page
.locator('.values-editor__simple-input, [data-testid="valueSelectorMenuButton"]')
.first()
.waitFor({state: 'visible', timeout: 10000});
if (await inlineInput.isVisible().catch(() => false)) {
await inlineInput.fill(value);
await inlineInput.press('Tab'); // commit (onBlur)
await page.waitForTimeout(300);
return;
}
// Dropdown variant: open the menu, then fill the "Add value..." field inside it.
// Match on the accessible name, not the placeholder: the menu autofocuses this
// input, and Input only sets a placeholder attribute while unfocused (the text
// moves into the floating legend), so a placeholder locator resolves to nothing.
await menuButton.click({force: true});
const menuInput = page.locator('input[aria-label*="Add value" i], input[placeholder*="Add value" i]').first();
// A click that lands while a sibling menu (e.g. the operator selector the caller
// just used) is still closing is spent dismissing that menu instead, leaving this
// one shut. Re-click once rather than requiring every caller to pause first. The
// gate has to be a waitFor, not isVisible(), which returns immediately and would
// toggle the menu straight back shut.
try {
await menuInput.waitFor({state: 'visible', timeout: 3000});
} catch {
await menuButton.click({force: true});
await menuInput.waitFor({state: 'visible', timeout: 10000});
}
await menuInput.fill(value);
// Tab commits (input onBlur) and closes the menu (menu closeMenuOnTab), so
// the dropdown doesn't overlay later actions. Enter would commit but leave
// the menu open, and Escape is swallowed by the input's stopPropagation.
await menuInput.press('Tab');
await page.waitForTimeout(300);
}
/**
* Create basic policy using Table Editor (Simple mode)
*/
@@ -493,10 +556,7 @@ export async function createBasicPolicy(
await page.waitForTimeout(300);
} else {
// Single-value operator
const valueInput = page.locator('.values-editor__simple-input, input[placeholder*="Add value" i]').first();
await valueInput.waitFor({state: 'visible', timeout: 10000});
await valueInput.fill(options.value);
await page.waitForTimeout(500);
await fillSingleConditionValue(page, options.value);
}
} // end if (clickedAddAttribute)
@@ -11,7 +11,7 @@ import {
deleteCustomProfileAttributes,
setupCustomProfileAttributeFields,
} from '../../../channels/custom_profile_attributes/helpers';
import {getPolicyIdByName} from '../support';
import {fillSingleConditionValue, getPolicyIdByName} from '../support';
type FieldsMap = Record<string, UserPropertyField>;
@@ -154,10 +154,7 @@ test.describe('ABAC Attribute Selector - display_name rendering and filtering',
await operatorMenu.waitFor({state: 'visible', timeout: 5000});
await operatorMenu.locator('li:has-text("is")').first().click();
const valueInput = page.locator('.values-editor__simple-input').first();
await valueInput.waitFor({state: 'visible', timeout: 10000});
await valueInput.fill('engineering');
await valueInput.press('Tab');
await fillSingleConditionValue(page, 'engineering');
const saveButton = page.getByRole('button', {name: 'Save'});
await expect(saveButton).toBeEnabled({timeout: 10000});
+12 -1
View File
@@ -426,6 +426,9 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
Cursor: model.SubjectCursor{
TargetID: checkExpressionRequest.After,
},
// Carry the channel so a resource.attributes.* expression resolves
// against that channel's values; ignored for resource-free expressions.
ResourceID: channelId,
}
// Scope results to a team's current members only for callers authorized on
@@ -1310,6 +1313,14 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
teamID := r.URL.Query().Get("team_id")
// Pulls channel-object-type CPA fields — the ones a rule references as
// resource.attributes.* — for a caller with no single channel to scope by,
// such as an editor for a policy that many channels import. Only meaningful
// without a channelId (a channel scope already includes them), and in that
// case the permission check below requires either ManageSystem or
// team-admin access-rule permission on team_id (via teamAdminCELContextOK).
includeResourceFields := r.URL.Query().Get("include_resource_fields") == "true"
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
if !teamAdminCELContextOK(c, channelId, teamID) {
@@ -1348,7 +1359,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
var ac []*model.PropertyField
var appErr *model.AppError
ac, appErr = c.App.GetAccessControlFieldsAutocomplete(c.AppContext, after, limit, c.AppContext.Session().UserId)
ac, appErr = c.App.GetAccessControlFieldsAutocomplete(c.AppContext, channelId, includeResourceFields, after, limit, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
+104
View File
@@ -3284,6 +3284,110 @@ func TestSimulatePolicyForUsers(t *testing.T) {
})
}
// TestGetFieldsAutocompleteResourceFields exercises the HTTP boundary of
// GET /cel/autocomplete/fields for channel-object-type (resource.attributes.*)
// fields: a channel scope or the include_resource_fields flag surfaces them
// (tagged with their ObjectType), neither excludes them, and the permission
// gating holds. This endpoint resolves fields from the property store directly
// and does not go through the mocked AccessControl engine.
func TestGetFieldsAutocompleteResourceFields(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
// Setup() only clears the read-only guard on feature flags when it is handed a
// config updater, so do it explicitly before flipping one.
th.ConfigStore.SetReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = true
})
group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
mkField := func(objectType string) *model.PropertyField {
f, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: "region" + model.NewId(),
Type: model.PropertyFieldTypeText,
ObjectType: objectType,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
return f
}
userField := mkField(model.PropertyFieldObjectTypeUser)
channelField := mkField(model.PropertyFieldObjectTypeChannel)
const base = "/access_control_policies/cel/autocomplete/fields"
get := func(t *testing.T, client *model.Client4, query string) []*model.PropertyField {
t.Helper()
resp, err := client.DoAPIGet(context.Background(), base+query, "")
require.NoError(t, err)
defer resp.Body.Close()
var fields []*model.PropertyField
require.NoError(t, json.NewDecoder(resp.Body).Decode(&fields))
return fields
}
find := func(fields []*model.PropertyField, id string) *model.PropertyField {
for _, f := range fields {
if f.ID == id {
return f
}
}
return nil
}
t.Run("channel scope surfaces channel fields tagged by object type", func(t *testing.T) {
fields := get(t, th.SystemAdminClient, "?limit=100&channelId="+th.BasicChannel.Id)
require.NotNil(t, find(fields, userField.ID), "user field must be present when channel-scoped")
ch := find(fields, channelField.ID)
require.NotNil(t, ch, "channel field must be present when channel-scoped")
require.Equal(t, model.PropertyFieldObjectTypeChannel, ch.ObjectType, "channel field must carry its ObjectType")
})
t.Run("include_resource_fields surfaces channel fields with no channel scope", func(t *testing.T) {
fields := get(t, th.SystemAdminClient, "?limit=100&include_resource_fields=true")
require.NotNil(t, find(fields, channelField.ID), "channel field must be present when include_resource_fields=true")
})
t.Run("channel fields excluded without a channel scope or the flag", func(t *testing.T) {
fields := get(t, th.SystemAdminClient, "?limit=100")
require.NotNil(t, find(fields, userField.ID), "user field must still be present")
require.Nil(t, find(fields, channelField.ID), "channel field must be excluded when neither channelId nor the flag is set")
})
t.Run("channel fields excluded on both paths while the feature is off", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = false
})
defer th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = true
})
scoped := get(t, th.SystemAdminClient, "?limit=100&channelId="+th.BasicChannel.Id)
require.Nil(t, find(scoped, channelField.ID), "channel scope must not surface a channel field while off")
require.NotNil(t, find(scoped, userField.ID), "user fields must keep working while off")
asked := get(t, th.SystemAdminClient, "?limit=100&include_resource_fields=true")
require.Nil(t, find(asked, channelField.ID), "include_resource_fields must not surface a channel field while off")
})
t.Run("regular user without manage-system is denied when unscoped", func(t *testing.T) {
resp, err := th.Client.DoAPIGet(context.Background(), base+"?limit=100", "")
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("regular user without channel-manage-access-rules is denied for a channel", func(t *testing.T) {
resp, err := th.Client.DoAPIGet(context.Background(), base+"?limit=100&channelId="+th.BasicChannel.Id, "")
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
+148 -50
View File
@@ -21,6 +21,22 @@ import (
const attributeViewRefreshInterval = 30 * time.Second
const accessControlChildPolicySearchLimit = 1000
// ResourceAttributesInPoliciesEnabled reports whether access rules may compare a
// user's attributes against the accessed channel's. It gates authoring only: the
// autocomplete endpoint stops offering channel-object-type fields, so no editor
// can build such a rule, and the engine rejects one at save time. Evaluation of a
// rule already stored is deliberately not gated — those rules deny for any channel
// missing a value, so dropping enforcement would remove every member of every
// channel the policy governs.
//
// Only the flag is checked here. The licence and AccessControlSettings gates are
// applied by the surfaces that need them, and ABAC is unusable without both
// anyway; re-checking them here would make the autocomplete endpoint stricter
// than it is for user attributes today.
func (a *App) ResourceAttributesInPoliciesEnabled() bool {
return a.Config().FeatureFlags.ResourceAttributesInPolicies
}
func (a *App) GetChannelsForPolicy(rctx request.CTX, policyID string, cursor model.AccessControlPolicyCursor, limit int) ([]*model.ChannelWithTeamData, int64, *model.AppError) {
policy, appErr := a.GetAccessControlPolicy(rctx, policyID)
if appErr != nil {
@@ -687,6 +703,41 @@ func isThisRuleScope(scope string) bool {
// indexed by field name.
const userAttributesPathPrefix = "user.attributes."
// resourceAttributesPathPrefix is the analogous prefix for the accessed
// resource's custom attributes (e.g. `resource.attributes.Sensitivity`).
// A simulator leaf carrying this prefix records the target channel's own
// attribute value, which must be hidden when the channel field is
// protected — separately from the user side, since a channel field's
// visibility can differ from a same-named user field.
const resourceAttributesPathPrefix = "resource.attributes."
// protectedCPAAttributes bundles the sets of protected CPA field names
// per attribute root. User and resource (channel) fields are tracked
// separately because a channel field's visibility/access mode can differ
// from a user field that happens to share its name, so a leaf must be
// matched against the set for its own root.
type protectedCPAAttributes struct {
user map[string]struct{}
resource map[string]struct{}
}
func (p protectedCPAAttributes) isEmpty() bool {
return len(p.user) == 0 && len(p.resource) == 0
}
// set returns the protected-name set for a CPA object type, or nil for an
// object type that never surfaces in a simulation trace.
func (p protectedCPAAttributes) set(objectType string) map[string]struct{} {
switch objectType {
case model.PropertyFieldObjectTypeUser:
return p.user
case model.PropertyFieldObjectTypeChannel:
return p.resource
default:
return nil
}
}
// RedactSimulationAttributesForCaller strips attribute values from a
// PolicySimulationResponse on every surface the picker exposes
// (top-level user/session Attributes maps AND the per-leaf
@@ -708,7 +759,7 @@ const userAttributesPathPrefix = "user.attributes."
// - `access_mode == "shared_only"`: the underlying property
// service computes an intersection of the caller's and target's
// values on read. The simulator does NOT call the property
// service (it reads from AttributeView directly), so we
// service (it reads from UserAttributeView directly), so we
// conservatively redact these values rather than ship them
// unfiltered.
//
@@ -747,41 +798,56 @@ func (a *App) RedactSimulationAttributesForCaller(rctx request.CTX, resp *model.
clearAllEvaluationTreeActualValues(resp)
return
}
if len(protected) == 0 {
if protected.isEmpty() {
return
}
stripProtectedAttributes(resp, protected)
// Top-level Attributes maps hold only the simulated user's own
// snapshot (resource attributes never appear there), so they are
// pruned against the user set alone. The evaluation trees can carry
// both user.attributes.* and resource.attributes.* leaves, so the
// tree walker matches each against the set for its root.
stripProtectedAttributes(resp, protected.user)
redactProtectedEvaluationTreeActualValues(resp, protected)
}
// protectedCPAFieldNamesForCaller returns the set of CPA field names
// whose contents must be hidden from a non-system-admin caller. The
// set includes both `visibility: hidden` fields and any field whose
// `access_mode` is not public (source_only / shared_only). The
// simulator's AttributeView populates its per-user map keyed by
// `pf.Name` (see db/migrations/postgres/000137_update_attribute_view.up.sql),
// protectedCPAFieldNamesForCaller returns, per attribute root, the set of
// CPA field names whose contents must be hidden from a non-system-admin
// caller. The set includes both `visibility: hidden` fields and any field
// whose `access_mode` is not public (source_only / shared_only). The
// simulator's UserAttributeView populates its per-user map keyed by
// `pf.Name` (see db/migrations/postgres/000216_split_attribute_view_by_object_type.up.sql),
// and the evaluation-tree walker likewise records `user.attributes.<name>`
// on each leaf — so matching by name is correct for both.
func (a *App) protectedCPAFieldNamesForCaller(rctx request.CTX) (map[string]struct{}, error) {
// on each leaf — so matching by name is correct for the user set. Channel
// fields are matched the same way, by name, against their own set: the
// walker records those as `resource.attributes.<name>`.
func (a *App) protectedCPAFieldNamesForCaller(rctx request.CTX) (protectedCPAAttributes, error) {
protected := protectedCPAAttributes{
user: map[string]struct{}{},
resource: map[string]struct{}{},
}
group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, appErr
return protected, appErr
}
propertyFields, appErr := a.SearchPropertyFields(rctx, group.ID, model.PropertyFieldSearchOpts{
ObjectTypes: []string{model.PropertyFieldObjectTypeUser},
ObjectTypes: []string{model.PropertyFieldObjectTypeUser, model.PropertyFieldObjectTypeChannel},
PerPage: model.AccessControlGroupFieldLimit + 5,
})
if appErr != nil {
return nil, appErr
return protected, appErr
}
protected := map[string]struct{}{}
for _, pf := range propertyFields {
if pf == nil {
continue
}
set := protected.set(pf.ObjectType)
if set == nil {
continue
}
f, err := model.NewCPAFieldFromPropertyField(pf)
if err != nil {
// Fail-closed: an unparseable field is treated as protected
@@ -789,13 +855,14 @@ func (a *App) protectedCPAFieldNamesForCaller(rctx request.CTX) (map[string]stru
rctx.Logger().Warn("Failed to parse property field for CPA protection check; treating as protected",
mlog.String("field_name", pf.Name),
mlog.String("field_id", pf.ID),
mlog.String("object_type", pf.ObjectType),
mlog.Err(err),
)
protected[pf.Name] = struct{}{}
set[pf.Name] = struct{}{}
continue
}
if cpaFieldIsProtectedForChannelAdmin(f) {
protected[f.Name] = struct{}{}
set[f.Name] = struct{}{}
}
}
return protected, nil
@@ -900,18 +967,20 @@ func stripProtectedAttributes(resp *model.PolicySimulationResponse, protected ma
// EvaluationTree (and the per-rule subtrees attached under
// MergedRules) on every result and session decision in `resp`. For
// each leaf node whose `Attribute` references a protected CPA field
// (path format `user.attributes.<name>`), the leaf's `ActualValue`
// is blanked.
// (path format `user.attributes.<name>` or `resource.attributes.<name>`),
// the leaf's `ActualValue` is blanked.
//
// Why ActualValue and nothing else:
// - `Attribute` is the path; it already appears in the rule's
// `Expression`, which the channel admin can see.
// - `ExpectedValue` is the literal from the rule (e.g. `"il5"`),
// not the user's data — also already in `Expression`.
// - `ActualValue` is the only field that records the target user's
// concrete attribute value. That's the one we must redact.
func redactProtectedEvaluationTreeActualValues(resp *model.PolicySimulationResponse, protected map[string]struct{}) {
if resp == nil || len(protected) == 0 {
// not the user's or channel's data — also already in `Expression`.
// - `ActualValue` is the only field that records the target's
// concrete attribute value — the user's for a `user.attributes.*`
// leaf, the accessed channel's for a `resource.attributes.*` leaf.
// That's the one we must redact.
func redactProtectedEvaluationTreeActualValues(resp *model.PolicySimulationResponse, protected protectedCPAAttributes) {
if resp == nil || protected.isEmpty() {
return
}
for i := range resp.Results {
@@ -929,7 +998,7 @@ func redactProtectedEvaluationTreeActualValues(resp *model.PolicySimulationRespo
}
}
func redactProtectedActualValuesInDecision(dec *model.PolicySimulationActionDecision, protected map[string]struct{}) {
func redactProtectedActualValuesInDecision(dec *model.PolicySimulationActionDecision, protected protectedCPAAttributes) {
for i := range dec.Blame {
b := &dec.Blame[i]
if b.EvaluationTree != nil {
@@ -945,9 +1014,9 @@ func redactProtectedActualValuesInDecision(dec *model.PolicySimulationActionDeci
// redactProtectedActualValuesInTree recursively walks `node` and
// blanks the `ActualValue` on every leaf whose `Attribute` resolves
// to a CPA field in `protected`. Operates in place on the tree
// pointer the response shares with its parent blame entry.
func redactProtectedActualValuesInTree(node *model.PolicySimulationEvaluationNode, protected map[string]struct{}) {
// to a protected CPA field. Operates in place on the tree pointer the
// response shares with its parent blame entry.
func redactProtectedActualValuesInTree(node *model.PolicySimulationEvaluationNode, protected protectedCPAAttributes) {
if node == nil {
return
}
@@ -959,20 +1028,22 @@ func redactProtectedActualValuesInTree(node *model.PolicySimulationEvaluationNod
}
}
// isProtectedAttributePath returns true when `path` is the canonical
// CEL form `user.attributes.<name>` and `<name>` is in `protected`.
// Returns false for empty paths and for any path that doesn't carry
// the user-attribute prefix (other shapes — function-call leaves,
// constant comparisons — are not user data).
func isProtectedAttributePath(path string, protected map[string]struct{}) bool {
if path == "" || len(protected) == 0 {
// isProtectedAttributePath returns true when `path` is a canonical CPA
// leaf reference — `user.attributes.<name>` or `resource.attributes.<name>`
// — whose `<name>` is in the protected set for that root. Each root is
// matched against its own set so a channel field's visibility can't be
// inferred from a same-named user field. Returns false for empty paths
// and for any path that doesn't carry a CPA prefix (function-call
// leaves, constant comparisons, native selectors — not custom data).
func isProtectedAttributePath(path string, protected protectedCPAAttributes) bool {
if path == "" || protected.isEmpty() {
return false
}
name, ok := strings.CutPrefix(path, userAttributesPathPrefix)
if !ok || name == "" {
objectType, name, ok := splitCPAAttribute(path)
if !ok {
return false
}
_, found := protected[name]
_, found := protected.set(objectType)[name]
return found
}
@@ -1724,16 +1795,32 @@ func (a *App) GetAccessControlPolicyAttributes(rctx request.CTX, channelID strin
return attributes, nil
}
func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, after string, limit int, callerID string) ([]*model.PropertyField, *model.AppError) {
func (a *App) GetAccessControlFieldsAutocomplete(rctx request.CTX, channelID string, includeResourceFields bool, after string, limit int, callerID string) ([]*model.PropertyField, *model.AppError) {
group, appErr := a.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
if appErr != nil {
return nil, model.NewAppError("GetAccessControlAutoComplete", "app.pap.get_access_control_auto_complete.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
// A policy references the requesting user (user.attributes.*) and the
// accessed resource (resource.attributes.*). Resource attributes are
// channel-object-type CPA fields, so include them when a channel is in
// scope, or when the caller explicitly asks for them — a policy that many
// channels import has no single channel to scope by, and still needs the
// fields to author against. Each returned field carries its ObjectType, so
// a caller can tell the two namespaces apart.
//
// Both paths are gated: while resource attributes are off, omitting the
// channel object type is what keeps every editor from offering one, since
// each authoring surface is driven by what this endpoint returns.
objectTypes := []string{model.PropertyFieldObjectTypeUser}
if (channelID != "" || includeResourceFields) && a.ResourceAttributesInPoliciesEnabled() {
objectTypes = append(objectTypes, model.PropertyFieldObjectTypeChannel)
}
// Use property app layer to enforce access control
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
fields, appErr := a.SearchPropertyFields(rctxWithCaller, group.ID, model.PropertyFieldSearchOpts{
ObjectType: model.PropertyFieldObjectTypeUser,
ObjectTypes: objectTypes,
Cursor: model.PropertyFieldSearchCursor{
PropertyFieldID: after,
CreateAt: 1,
@@ -2350,8 +2437,17 @@ func (a *App) TestExpressionWithChannelContext(rctx request.CTX, expression stri
currentUserID := session.UserId
// SECURITY: First check if the channel admin themselves matches this expression
// If they don't match, they shouldn't be able to see users who do
// Only return results if the requesting admin matches the expression
// themselves. This blocks the obvious probe: an admin who is not, say,
// "TopSecret" cannot run clearance == "TopSecret" just to discover who is.
//
// It is a speed bump, not a hard boundary. An admin can OR in a term they
// always satisfy (user.id == "<self>" || <probe>) to pass this check, then
// read the returned match count to answer yes/no questions about values they
// cannot see directly. That kind of enumeration is unavoidable in any feature
// that reveals who a rule matches, so we accept it. The thing we actually
// protect -- raw attribute values and options -- is masked when a policy is
// read, not here.
adminMatches, appErr := a.ValidateExpressionAgainstRequester(rctx, expression, currentUserID)
if appErr != nil {
return nil, 0, appErr
@@ -2371,9 +2467,9 @@ func (a *App) TestExpressionWithChannelContext(rctx request.CTX, expression stri
return a.TestExpression(rctx, expression, opts)
}
// TestExpressionWithTeamContext tests expressions for team admins with the same
// info-leak guard as the channel variant: a team admin may only see users who
// match an expression that they themselves match.
// TestExpressionWithTeamContext is the team-admin counterpart of
// TestExpressionWithChannelContext and applies the same self-match check; see
// that function's comment for what the check does and does not protect.
func (a *App) TestExpressionWithTeamContext(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) {
session := rctx.Session()
if session == nil {
@@ -2382,7 +2478,8 @@ func (a *App) TestExpressionWithTeamContext(rctx request.CTX, expression string,
currentUserID := session.UserId
// SECURITY: a team admin who doesn't match the expression must not learn who does.
// Same self-match check as the channel variant (see its comment): a partial
// guard against casual probing, not a confidentiality boundary.
adminMatches, appErr := a.ValidateExpressionAgainstRequester(rctx, expression, currentUserID)
if appErr != nil {
return nil, 0, appErr
@@ -2446,7 +2543,7 @@ func (a *App) BuildAccessControlSubject(rctx request.CTX, userID string, roles s
return nil, model.NewAppError("BuildAccessControlSubject", "app.access_control.build_subject.group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
subject, storeErr := a.Srv().Store().Attributes().GetSubject(rctx, userID, group.ID)
subject, storeErr := a.Srv().Store().Attributes().GetSubject(rctx, userID, group.ID, model.PropertyFieldObjectTypeUser)
if storeErr != nil {
var nfErr *store.ErrNotFound
if errors.As(storeErr, &nfErr) {
@@ -2642,9 +2739,10 @@ func ResolveSystemRole(roles string) string {
return model.SystemUserRoleId
}
// refreshAttributeViewIfStale refreshes the materialized AttributeView if the last
// refresh was more than attributeViewRefreshInterval ago. The refresh is non-blocking:
// if another goroutine is already refreshing, this call returns immediately.
// refreshAttributeViewIfStale refreshes the attribute materialized views if the
// last refresh was more than attributeViewRefreshInterval ago. The refresh is
// non-blocking: if another goroutine is already refreshing, this call returns
// immediately.
func (a *App) refreshAttributeViewIfStale(rctx request.CTX) {
ch := a.Srv().Channels()
+202 -50
View File
@@ -5,6 +5,7 @@ package app
import (
"encoding/json"
"maps"
"net/http"
"strings"
@@ -39,44 +40,58 @@ func (a *App) GetMaskedVisualAST(rctx request.CTX, expression string, callerID s
// Embed callerID in context so GetPropertyFieldByName applies per-caller option filtering.
rctxWithCaller := RequestContextWithCallerID(rctx, callerID)
// Pre-fetch all referenced fields once to avoid N+1 DB queries across conditions.
fieldsByName := a.fetchConditionFields(rctxWithCaller, visualAST.Conditions, cpaGroupID)
// Pre-fetch the holdings-bearing field for each referenced condition once to
// avoid N+1 DB queries across conditions.
fieldsByKey := a.fetchConditionFields(rctxWithCaller, visualAST.Conditions, cpaGroupID)
for i := range visualAST.Conditions {
a.maskConditionValues(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByName)
a.maskConditionValues(rctxWithCaller, callerID, &visualAST.Conditions[i], cpaGroupID, fieldsByKey)
}
return visualAST, nil
}
// fetchConditionFields collects unique field names from conditions and fetches each once.
// Lookup failures are logged and omitted from the returned map; read-path callers treat
// missing entries as fail-closed (mask the value). Write-path callers should additionally
// call requireAllFieldsResolved to refuse to proceed when any referenced field is missing.
func (a *App) fetchConditionFields(rctx request.CTX, conditions []model.Condition, cpaGroupID string) map[string]*model.PropertyField {
seen := make(map[string]bool)
// maskingHoldings pairs the field whose per-caller values determine which
// literals are visible (holdings) with the access mode that governs the
// reference. For a channel attribute these come from different fields: the
// access mode is the channel field's own (it defines whether the attribute is
// protected), while holdings are read from the user-side sibling — see
// holdingsFieldFor.
type maskingHoldings struct {
field *model.PropertyField
accessMode string
}
// fetchConditionFields collects the unique CPA references from conditions and
// fetches, for each, the holdings/access-mode pair that determines its
// visibility (see holdingsFieldFor). The map is keyed by
// "<objectType>/<fieldName>" so a user and a channel reference sharing a name
// resolve independently. Lookup failures are logged and omitted; read-path
// callers treat missing entries as fail-closed (mask the value).
func (a *App) fetchConditionFields(rctx request.CTX, conditions []model.Condition, cpaGroupID string) map[string]*maskingHoldings {
type ref struct{ objectType, fieldName string }
seen := make(map[ref]struct{})
for _, c := range conditions {
if c.ValueType == model.AttrValue {
continue
}
if name := extractFieldName(c.Attribute); name != "" {
seen[name] = true
if objectType, fieldName, ok := splitCPAAttribute(c.Attribute); ok {
seen[ref{objectType, fieldName}] = struct{}{}
}
}
fields := make(map[string]*model.PropertyField, len(seen))
for name := range seen {
// Scope to user CPA fields so a name shared across object types in this
// group resolves deterministically.
field, appErr := a.GetPropertyFieldByNameForObjectType(rctx, cpaGroupID, "", model.PropertyFieldObjectTypeUser, name)
fields := make(map[string]*maskingHoldings, len(seen))
for r := range seen {
h, appErr := a.holdingsFieldFor(rctx, cpaGroupID, r.objectType, r.fieldName)
if appErr != nil {
rctx.Logger().Warn("Failed to look up field for masking, failing closed",
mlog.String("field_name", name),
mlog.String("object_type", r.objectType),
mlog.String("field_name", r.fieldName),
mlog.Err(appErr),
)
continue
}
fields[name] = field
fields[holdingsKey(r.objectType, r.fieldName)] = h
}
return fields
}
@@ -105,34 +120,152 @@ func newMaskingResolver(a *App, rctx request.CTX, callerID string) (*appMaskingR
}, nil
}
func (r *appMaskingResolver) Resolve(fieldName string) (*model.MaskingFieldInfo, error) {
if info, ok := r.cache[fieldName]; ok {
func (r *appMaskingResolver) Resolve(objectType, fieldName string) (*model.MaskingFieldInfo, error) {
// The cache key includes the object type: a user field and a channel field
// can share a name yet have different visibility.
cacheKey := holdingsKey(objectType, fieldName)
if info, ok := r.cache[cacheKey]; ok {
return info, nil
}
// Scope to user CPA fields so a name shared across object types in this
// group resolves deterministically.
field, appErr := r.app.GetPropertyFieldByNameForObjectType(r.rctxWithCaller, r.cpaGroupID, "", model.PropertyFieldObjectTypeUser, fieldName)
h, appErr := r.app.holdingsFieldFor(r.rctxWithCaller, r.cpaGroupID, objectType, fieldName)
if appErr != nil {
return nil, appErr
}
info := r.fieldToMaskingInfo(field)
r.cache[fieldName] = info
info := r.fieldToMaskingInfo(h)
r.cache[cacheKey] = info
return info, nil
}
func (r *appMaskingResolver) fieldToMaskingInfo(field *model.PropertyField) *model.MaskingFieldInfo {
// holdingsFieldFor returns the holdings-bearing field plus the access mode that
// governs a reference to (objectType, fieldName). The field is fetched with
// caller context so its options are already filtered to the caller's holdings.
//
// For a user reference both come from the field itself. For a channel reference
// they come from different fields: the access mode is the CHANNEL field's own —
// whether the attribute is protected is a property of the channel field, and a
// linked field does NOT inherit access mode from its template at creation, so
// reading it off the user sibling can silently under-protect. Holdings, by
// contrast, must come from the user-side sibling linked to the same template,
// because users never hold channel-side values directly.
//
// A caller's held option names are only pre-filtered onto the sibling by the
// read path when the sibling is itself shared_only. If the channel field is
// shared_only but the sibling is not, the sibling's option list is unfiltered,
// so its options are dropped (fail closed); text holdings are queried directly
// and are unaffected. When a channel field is unlinked or has no user sibling,
// its own (caller-filtered) field is used — for shared_only that yields no
// visible values (the caller holds nothing channel-side), the fail-closed
// direction.
func (a *App) holdingsFieldFor(rctx request.CTX, groupID, objectType, fieldName string) (*maskingHoldings, *model.AppError) {
var lookupType string
switch objectType {
case model.PropertyFieldObjectTypeChannel:
lookupType = model.PropertyFieldObjectTypeChannel
case model.PropertyFieldObjectTypeUser:
lookupType = model.PropertyFieldObjectTypeUser
default:
// Only user./resource. CPA roots reach here (see cpaAttributeRoots); fail
// loud rather than silently treating an unexpected type as a user lookup.
return nil, model.NewAppError("holdingsFieldFor", "app.pap.masking.unknown_object_type.app_error", map[string]any{"ObjectType": objectType}, "", http.StatusInternalServerError)
}
field, appErr := a.GetPropertyFieldByNameForObjectType(rctx, groupID, "", lookupType, fieldName)
if appErr != nil {
return nil, appErr
}
if lookupType != model.PropertyFieldObjectTypeChannel {
return &maskingHoldings{field: field, accessMode: field.GetAccessMode()}, nil
}
accessMode := field.GetAccessMode()
if field.LinkedFieldID != nil && *field.LinkedFieldID != "" {
sibling, appErr := a.userSiblingField(rctx, groupID, *field.LinkedFieldID)
if appErr != nil {
return nil, appErr
}
if sibling != nil {
// The sibling supplies the caller's held values, but its options are
// only held-filtered by the read path when the sibling is itself
// shared_only. If the channel field is protected but the sibling is
// not, the sibling's option list is unfiltered — drop it so no
// unheld option name leaks (text holdings query the caller directly
// and are unaffected).
if accessMode == model.PropertyAccessModeSharedOnly &&
sibling.GetAccessMode() != model.PropertyAccessModeSharedOnly &&
sibling.Type.SupportsOptions() {
sibling = fieldWithEmptyOptions(sibling)
}
return &maskingHoldings{field: sibling, accessMode: accessMode}, nil
}
}
return &maskingHoldings{field: field, accessMode: accessMode}, nil
}
// fieldWithEmptyOptions returns a shallow copy of f with an empty options list,
// so extractVisibleOptionNames yields nothing. Used to fail closed without
// mutating the (possibly cached) source field.
func fieldWithEmptyOptions(f *model.PropertyField) *model.PropertyField {
cp := *f
cp.Attrs = make(model.StringInterface, len(f.Attrs)+1)
maps.Copy(cp.Attrs, f.Attrs)
cp.Attrs[model.PropertyFieldAttributeOptions] = []any{}
return &cp
}
// userSiblingField returns the user-object-type CPA field linked to the same
// template (linkedFieldID), fetched through rctx so its options are filtered to
// that caller's holdings. Returns nil when no such field exists.
func (a *App) userSiblingField(rctx request.CTX, groupID, linkedFieldID string) (*model.PropertyField, *model.AppError) {
fields, appErr := a.SearchPropertyFields(rctx, groupID, model.PropertyFieldSearchOpts{
ObjectTypes: []string{model.PropertyFieldObjectTypeUser},
LinkedFieldID: linkedFieldID,
PerPage: 2,
})
if appErr != nil {
return nil, appErr
}
// The masking decision uses this sibling's held values to gate a channel
// field's option names, so it must resolve to exactly one field. Nothing
// enforces LinkedFieldID uniqueness at the DB level, so a second match would
// make the choice depend on store order and could disclose a value via the
// "wrong" sibling. Error on ambiguity rather than guess — the caller fails
// this field closed (precompute logs and skips it, masking every value).
// PerPage:2 is enough to detect the second match.
var match *model.PropertyField
for _, f := range fields {
if f == nil {
continue
}
if match != nil {
return nil, model.NewAppError("userSiblingField", "app.pap.masking.ambiguous_sibling.app_error", map[string]any{"LinkedFieldID": linkedFieldID}, "multiple user fields link the same template", http.StatusInternalServerError)
}
match = f
}
return match, nil
}
func (r *appMaskingResolver) fieldToMaskingInfo(h *maskingHoldings) *model.MaskingFieldInfo {
info := &model.MaskingFieldInfo{}
switch field.GetAccessMode() {
// h.accessMode is authoritative (the channel field's own for a channel
// reference); h.field supplies the caller's held values.
switch h.accessMode {
case model.PropertyAccessModePublic:
info.Access = model.MaskingFieldAccessPublic
case model.PropertyAccessModeSourceOnly:
info.Access = model.MaskingFieldAccessSourceOnly
case model.PropertyAccessModeSharedOnly:
info.Access = model.MaskingFieldAccessSharedOnly
if field.Type == model.PropertyFieldTypeSelect || field.Type == model.PropertyFieldTypeMultiselect {
info.VisibleValues = extractVisibleOptionNames(field)
// Same split as maskConditionValues, through the same predicate: an
// options-bearing field's visible values are its caller-filtered option
// names, anything else's are the caller's stored text values. Spelling the
// type list out here instead would drop rank — whose values are options
// too — and the two paths would disagree about what a caller can see.
if h.field.Type.SupportsOptions() {
info.VisibleValues = extractVisibleOptionNames(h.field)
} else {
info.VisibleValues = r.app.getCallerTextValues(r.rctxWithCaller, r.callerID, field, r.cpaGroupID)
info.VisibleValues = r.app.getCallerTextValues(r.rctxWithCaller, r.callerID, h.field, r.cpaGroupID)
}
default:
info.Access = model.MaskingFieldAccessUnknown
@@ -151,18 +284,21 @@ func (r *appMaskingResolver) fieldToMaskingInfo(field *model.PropertyField) *mod
// The condition's value is either visible in full (the caller's stored
// text value matches it exactly) or fully masked. No partial chip behavior
// is possible because there's no multi-value list to filter.
func (a *App) maskConditionValues(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByName map[string]*model.PropertyField) {
func (a *App) maskConditionValues(rctx request.CTX, callerID string, condition *model.Condition, cpaGroupID string, fieldsByKey map[string]*maskingHoldings) {
// AttrValue conditions compare two attributes (e.g. user.attr1 == user.attr2) — no literal values to mask.
if condition.ValueType == model.AttrValue {
return
}
fieldName := extractFieldName(condition.Attribute)
if fieldName == "" {
objectType, fieldName, ok := splitCPAAttribute(condition.Attribute)
if !ok {
return
}
field, ok := fieldsByName[fieldName]
// h pairs the holdings-bearing field (the user sibling for a channel
// attribute) with the authoritative access mode (the channel field's own),
// keyed by object type so a shared name does not collide across roots.
h, ok := fieldsByKey[holdingsKey(objectType, fieldName)]
if !ok {
// Fail closed: field lookup failed at prefetch time.
condition.Value = nil
@@ -170,17 +306,17 @@ func (a *App) maskConditionValues(rctx request.CTX, callerID string, condition *
return
}
switch field.GetAccessMode() {
switch h.accessMode {
case model.PropertyAccessModePublic:
// no-op
case model.PropertyAccessModeSourceOnly:
condition.Value = nil
condition.HasMaskedValues = true
case model.PropertyAccessModeSharedOnly:
if field.Type.SupportsOptions() {
filterConditionValues(condition, extractVisibleOptionNames(field))
if h.field.Type.SupportsOptions() {
filterConditionValues(condition, extractVisibleOptionNames(h.field))
} else {
filterConditionValues(condition, a.getCallerTextValues(rctx, callerID, field, cpaGroupID))
filterConditionValues(condition, a.getCallerTextValues(rctx, callerID, h.field, cpaGroupID))
}
default:
// Unknown access mode: fail closed.
@@ -189,16 +325,32 @@ func (a *App) maskConditionValues(rctx request.CTX, callerID string, condition *
}
}
// extractFieldName strips the "user.attributes." prefix from a CEL attribute
// reference, returning just the property field name. Returns the empty string
// if the attribute is not a user-attribute reference.
func extractFieldName(attribute string) string {
const prefix = "user.attributes."
name := strings.TrimPrefix(attribute, prefix)
if name == attribute || name == "" {
return ""
// cpaAttributeRoots maps a CEL attribute-path prefix to the PropertyField
// object type whose CPA schema backs it: user.attributes.* is the requesting
// user, resource.attributes.* is the accessed channel.
var cpaAttributeRoots = []struct{ prefix, objectType string }{
{userAttributesPathPrefix, model.PropertyFieldObjectTypeUser},
{resourceAttributesPathPrefix, model.PropertyFieldObjectTypeChannel},
}
// splitCPAAttribute splits a CEL attribute path into its CPA object type and
// field name. ok is false for any path that is not a non-empty custom-attribute
// selector (native selectors such as user.email or resource.id carry no
// ".attributes." segment and own no maskable literal).
func splitCPAAttribute(attribute string) (objectType, fieldName string, ok bool) {
for _, r := range cpaAttributeRoots {
if name, found := strings.CutPrefix(attribute, r.prefix); found && name != "" {
return r.objectType, name, true
}
}
return name
return "", "", false
}
// holdingsKey keys the prefetched-holdings map and the resolver cache. Object
// type is part of the key because a user field and a channel field can share
// a name and differ in visibility.
func holdingsKey(objectType, fieldName string) string {
return objectType + "/" + fieldName
}
// extractVisibleOptionNames pulls option names from a pre-filtered PropertyField's
@@ -589,11 +741,11 @@ func (a *App) maskSimulationEvaluationTree(node *model.PolicySimulationEvaluatio
// caller cannot see that value. Uses mc.resolver so field info is cached across
// all leaves in the trace — no per-leaf DB calls. Fails closed on resolver error.
func (a *App) maskLeafActualValue(node *model.PolicySimulationEvaluationNode, mc *simulationMaskContext) {
fieldName := extractFieldName(node.Attribute)
if fieldName == "" {
objectType, fieldName, ok := splitCPAAttribute(node.Attribute)
if !ok {
return
}
info, err := mc.resolver.Resolve(fieldName)
info, err := mc.resolver.Resolve(objectType, fieldName)
if err != nil {
node.ActualValue = maskedTokenValue
return
@@ -17,28 +17,6 @@ import (
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestExtractFieldName(t *testing.T) {
tests := []struct {
name string
attribute string
expected string
}{
{"standard attribute path", "user.attributes.Program", "Program"},
{"multi-word field", "user.attributes.Clearance Level", "Clearance Level"},
{"no prefix", "Program", ""},
{"partial prefix", "user.attributes.", ""},
{"empty string", "", ""},
{"different prefix", "team.attributes.Program", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractFieldName(tc.attribute)
assert.Equal(t, tc.expected, result)
})
}
}
// Note: tests for field.GetAccessMode() live in model/property_access_test.go,
// where the method is defined (TestPropertyFieldGetAccessMode).
@@ -389,6 +367,13 @@ func TestMaskConditionValues(t *testing.T) {
return &model.PropertyField{Type: fieldType, Attrs: attrs}
}
// hold wraps a field as its own holdings source with its own access mode —
// the direct-field case these unit tests exercise (channel-sibling access-mode
// resolution is covered by the store-backed tests).
hold := func(f *model.PropertyField) *maskingHoldings {
return &maskingHoldings{field: f, accessMode: f.GetAccessMode()}
}
options := []any{
map[string]any{"id": "id1", "name": "Alpha"},
map[string]any{"id": "id2", "name": "Bravo"},
@@ -411,7 +396,7 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Engineering",
ValueType: model.LiteralValue,
}
a.maskConditionValues(rctx, "caller", condition, "", map[string]*model.PropertyField{})
a.maskConditionValues(rctx, "caller", condition, "", map[string]*maskingHoldings{})
assert.Equal(t, "Engineering", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
@@ -422,7 +407,7 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Alpha",
ValueType: model.LiteralValue,
}
a.maskConditionValues(rctx, "caller", condition, "", map[string]*model.PropertyField{})
a.maskConditionValues(rctx, "caller", condition, "", map[string]*maskingHoldings{})
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
@@ -433,8 +418,8 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Program": makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect, options),
fields := map[string]*maskingHoldings{
"user/Program": hold(makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Equal(t, "Alpha", condition.Value)
@@ -447,8 +432,8 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Top Secret",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Clearance": makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, options),
fields := map[string]*maskingHoldings{
"user/Clearance": hold(makeField(model.PropertyAccessModeSourceOnly, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
@@ -461,8 +446,8 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Location": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options),
fields := map[string]*maskingHoldings{
"user/Location": hold(makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
// "Alpha" is in the field options so it is visible
@@ -476,8 +461,8 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Charlie",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Location": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options),
fields := map[string]*maskingHoldings{
"user/Location": hold(makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
@@ -490,8 +475,8 @@ func TestMaskConditionValues(t *testing.T) {
Value: []any{"Alpha", "Charlie"},
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Programs": makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeMultiselect, options),
fields := map[string]*maskingHoldings{
"user/Programs": hold(makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeMultiselect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
values, ok := condition.Value.([]any)
@@ -506,16 +491,57 @@ func TestMaskConditionValues(t *testing.T) {
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*model.PropertyField{
"Program": {
fields := map[string]*maskingHoldings{
"user/Program": hold(&model.PropertyField{
Type: model.PropertyFieldTypeSelect,
Attrs: model.StringInterface{model.PropertyAttrsAccessMode: "future_unknown_mode"},
},
}),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("resource attribute: keyed by channel object type, public passes through", func(t *testing.T) {
condition := &model.Condition{
Attribute: "resource.attributes.Sensitivity",
Value: "Alpha",
ValueType: model.LiteralValue,
}
fields := map[string]*maskingHoldings{
"channel/Sensitivity": hold(makeField(model.PropertyAccessModePublic, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Equal(t, "Alpha", condition.Value)
assert.False(t, condition.HasMaskedValues)
})
t.Run("resource attribute: shared_only value the caller does not hold is masked", func(t *testing.T) {
condition := &model.Condition{
Attribute: "resource.attributes.Sensitivity",
Value: "secret",
ValueType: model.LiteralValue,
}
// The prefetched field is the holdings-bearing sibling; "secret" is not
// among the caller's visible options, so it masks.
fields := map[string]*maskingHoldings{
"channel/Sensitivity": hold(makeField(model.PropertyAccessModeSharedOnly, model.PropertyFieldTypeSelect, options)),
}
a.maskConditionValues(rctx, "caller", condition, "", fields)
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
t.Run("resource attribute missing from prefetch map: fail-closed", func(t *testing.T) {
condition := &model.Condition{
Attribute: "resource.attributes.Sensitivity",
Value: "Alpha",
ValueType: model.LiteralValue,
}
a.maskConditionValues(rctx, "caller", condition, "", map[string]*maskingHoldings{})
assert.Nil(t, condition.Value)
assert.True(t, condition.HasMaskedValues)
})
}
// TestMaskConditionValues_SharedOnlyText covers the shared_only + text-field branch of
@@ -564,7 +590,7 @@ func TestMaskConditionValues_SharedOnlyText(t *testing.T) {
})
require.Nil(t, appErr)
fieldsByName := map[string]*model.PropertyField{createdField.Name: createdField}
fieldsByName := map[string]*maskingHoldings{model.PropertyFieldObjectTypeUser + "/" + createdField.Name: {field: createdField, accessMode: createdField.GetAccessMode()}}
t.Run("caller's own value passes through", func(t *testing.T) {
condition := &model.Condition{
@@ -1254,3 +1280,294 @@ func TestMaskSimulationPolicyLiteralsForCaller_CompoundOrPreserved(t *testing.T)
assert.Equal(t, blame.EvaluationTree.Expression, blame.Expression)
mockACS.AssertExpectations(t)
}
// TestSplitCPAAttribute pins the CPA attribute-path splitter that routes a leaf
// to the right object type. user.attributes.* → user, resource.attributes.* →
// channel; everything else (native selectors, empty suffix) is not a CPA leaf.
func TestSplitCPAAttribute(t *testing.T) {
mainHelper.Parallel(t)
cases := []struct {
path string
wantObject string
wantField string
wantOK bool
}{
{"user.attributes.Clearance", model.PropertyFieldObjectTypeUser, "Clearance", true},
{"resource.attributes.Sensitivity", model.PropertyFieldObjectTypeChannel, "Sensitivity", true},
{"user.email", "", "", false},
{"resource.id", "", "", false},
{"session.network_status", "", "", false},
{"user.attributes.", "", "", false},
{"resource.attributes.", "", "", false},
{"", "", "", false},
}
for _, c := range cases {
objectType, fieldName, ok := splitCPAAttribute(c.path)
assert.Equal(t, c.wantOK, ok, "ok mismatch for %q", c.path)
assert.Equal(t, c.wantObject, objectType, "objectType mismatch for %q", c.path)
assert.Equal(t, c.wantField, fieldName, "fieldName mismatch for %q", c.path)
}
}
// TestAppMaskingResolver_ChannelFieldUsesUserHoldings verifies the shared-template
// bridge: a resource.attributes.<field> reference is masked by the caller's own
// USER-side holdings for the linked template, since users never hold channel
// values directly. A shared_only channel field whose user sibling the caller
// partially holds must expose only the held option values.
//
// Run over both option-bearing types the clearance/classification pairing uses.
// Rank is the one that regresses if the resolver spells its type list out instead
// of asking Type.SupportsOptions(): a rank field would fall through to the
// caller's raw text values, which hold the option ID rather than its name, so
// every option would read as hidden.
func TestAppMaskingResolver_ChannelFieldUsesUserHoldings(t *testing.T) {
mainHelper.Parallel(t)
for _, fieldType := range []model.PropertyFieldType{model.PropertyFieldTypeSelect, model.PropertyFieldTypeRank} {
t.Run(string(fieldType), func(t *testing.T) {
assertChannelFieldUsesUserHoldings(t, fieldType)
})
}
}
func assertChannelFieldUsesUserHoldings(t *testing.T, fieldType model.PropertyFieldType) {
t.Helper()
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
groupID := cpaGroup.ID
callerID := model.NewId()
optA := model.NewId()
optB := model.NewId()
options := []any{
map[string]any{"id": optA, "name": "A", "rank": 1},
map[string]any{"id": optB, "name": "B", "rank": 2},
}
// Template field that both the user and channel fields link to.
tmpl, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: fieldType,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{model.PropertyFieldAttributeOptions: options},
})
require.NoError(t, sErr)
linkedAttrs := func() model.StringInterface {
return model.StringInterface{
model.PropertyFieldAttributeOptions: options,
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
}
}
// Store-level Create bypasses the CPA access-control layer so protected /
// shared_only linked fields can be written directly (the same shortcut the
// other shared_only masking tests take). Reads still filter per caller.
userField, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: fieldType,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: linkedAttrs(),
})
require.NoError(t, sErr)
channelFieldName := celSafeName()
_, sErr = th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: channelFieldName,
Type: fieldType,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: linkedAttrs(),
})
require.NoError(t, sErr)
// Caller holds option A on the USER field (users never hold channel values).
// Written store-level because the field is protected (app-layer writes to
// protected fields are plugin-only).
_, vErr := th.Store.PropertyValue().Create(&model.PropertyValue{
TargetID: callerID,
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: userField.ID,
Value: json.RawMessage(`"` + optA + `"`),
})
require.NoError(t, vErr)
resolver, rErr := newMaskingResolver(th.App, rctx, callerID)
require.Nil(t, rErr)
info, err := resolver.Resolve(model.PropertyFieldObjectTypeChannel, channelFieldName)
require.NoError(t, err)
require.Equal(t, model.MaskingFieldAccessSharedOnly, info.Access)
assert.False(t, info.IsValueHidden("A"), "value the caller holds user-side must be visible on the channel field")
assert.True(t, info.IsValueHidden("B"), "value the caller does not hold must be hidden on the channel field")
}
// TestAppMaskingResolver_AmbiguousUserSibling guards the fail-open hole where two
// user fields link the same template. Nothing enforces LinkedFieldID uniqueness
// at the DB level, so picking the "first" sibling would make channel-field
// visibility depend on store order and could leak a value via the wrong sibling.
// Resolution must error instead of guessing.
func TestAppMaskingResolver_AmbiguousUserSibling(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
groupID := cpaGroup.ID
callerID := model.NewId()
optA := model.NewId()
options := []any{map[string]any{"id": optA, "name": "A"}}
tmpl, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{model.PropertyFieldAttributeOptions: options},
})
require.NoError(t, sErr)
linkedAttrs := func() model.StringInterface {
return model.StringInterface{
model.PropertyFieldAttributeOptions: options,
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
}
}
// Two user fields link the same template — the ambiguity this test triggers.
for range 2 {
_, sErr = th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: linkedAttrs(),
})
require.NoError(t, sErr)
}
channelFieldName := celSafeName()
_, sErr = th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: channelFieldName,
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: linkedAttrs(),
})
require.NoError(t, sErr)
resolver, rErr := newMaskingResolver(th.App, rctx, callerID)
require.Nil(t, rErr)
// The Resolve path propagates the ambiguity error rather than guessing a
// sibling (the batch precompute path instead logs and fails the field closed).
_, err := resolver.Resolve(model.PropertyFieldObjectTypeChannel, channelFieldName)
require.Error(t, err)
var appErr *model.AppError
require.ErrorAs(t, err, &appErr)
require.Equal(t, "app.pap.masking.ambiguous_sibling.app_error", appErr.Id)
}
// TestAppMaskingResolver_ChannelFieldProtectedSiblingPublic guards the fail-open
// hole where the channel field is protected (shared_only) but its user-side
// sibling is public (the default when access mode is unset — linked fields do
// NOT inherit access mode at creation). The access mode must come from the
// channel field, not the sibling, and because a public sibling's options are
// not held-filtered, the select values fail closed (all hidden) rather than
// leaking every option name.
func TestAppMaskingResolver_ChannelFieldProtectedSiblingPublic(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
rctx := request.TestContext(t)
cpaGroup, gErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, gErr)
groupID := cpaGroup.ID
callerID := model.NewId()
optA := model.NewId()
optB := model.NewId()
options := []any{
map[string]any{"id": optA, "name": "A"},
map[string]any{"id": optB, "name": "B"},
}
tmpl, sErr := th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeTemplate,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{model.PropertyFieldAttributeOptions: options},
})
require.NoError(t, sErr)
// User sibling: PUBLIC (access mode unset) — the misconfiguration the
// resolver must not trust for access mode.
_, sErr = th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: model.StringInterface{model.PropertyFieldAttributeOptions: options},
})
require.NoError(t, sErr)
// Channel field: shared_only (protected).
channelFieldName := celSafeName()
_, sErr = th.Store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: channelFieldName,
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
LinkedFieldID: &tmpl.ID,
Attrs: model.StringInterface{
model.PropertyFieldAttributeOptions: options,
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSharedOnly,
},
})
require.NoError(t, sErr)
resolver, rErr := newMaskingResolver(th.App, rctx, callerID)
require.Nil(t, rErr)
info, err := resolver.Resolve(model.PropertyFieldObjectTypeChannel, channelFieldName)
require.NoError(t, err)
require.Equal(t, model.MaskingFieldAccessSharedOnly, info.Access,
"access mode must come from the protected channel field, not the public sibling")
assert.True(t, info.IsValueHidden("A"), "protected channel field with a public sibling must fail closed, not leak option names")
assert.True(t, info.IsValueHidden("B"), "protected channel field with a public sibling must fail closed, not leak option names")
}
+164 -13
View File
@@ -3424,9 +3424,14 @@ func TestCPAFieldIsProtectedForChannelAdmin(t *testing.T) {
func TestRedactProtectedActualValuesInTree(t *testing.T) {
mainHelper.Parallel(t)
protected := map[string]struct{}{
"Clearance": {},
"NetworkZone": {},
protected := protectedCPAAttributes{
user: map[string]struct{}{
"Clearance": {},
"NetworkZone": {},
},
resource: map[string]struct{}{
"Sensitivity": {},
},
}
t.Run("redacts ActualValue on protected leaves at every depth", func(t *testing.T) {
@@ -3453,6 +3458,19 @@ func TestRedactProtectedActualValuesInTree(t *testing.T) {
Attribute: "user.attributes.Region",
ActualValue: "us",
},
// Protected channel attribute: blanked against the
// resource set, not the user set.
{
Kind: model.PolicySimulationEvaluationKindCompare,
Attribute: "resource.attributes.Sensitivity",
ActualValue: "secret",
},
// Public channel attribute: preserved.
{
Kind: model.PolicySimulationEvaluationKindCompare,
Attribute: "resource.attributes.Region",
ActualValue: "us-east",
},
},
},
{
@@ -3479,6 +3497,10 @@ func TestRedactProtectedActualValuesInTree(t *testing.T) {
assert.Empty(t, tree.Children[1].Children[0].ActualValue, "NetworkZone leaf must be blanked")
assert.Equal(t, "us", tree.Children[1].Children[1].ActualValue, "Region leaf must be preserved")
// Protected channel attribute blanked; public channel attribute preserved.
assert.Empty(t, tree.Children[1].Children[2].ActualValue, "resource Sensitivity leaf must be blanked")
assert.Equal(t, "us-east", tree.Children[1].Children[3].ActualValue, "resource Region leaf must be preserved")
// Function leaf with no attribute path is left alone.
assert.Equal(t, "some-internal-value", tree.Children[2].ActualValue, "non-user-attribute leaf must be preserved")
})
@@ -3495,7 +3517,7 @@ func TestRedactProtectedActualValuesInTree(t *testing.T) {
Attribute: "user.attributes.Clearance",
ActualValue: "il5",
}
redactProtectedActualValuesInTree(tree, nil)
redactProtectedActualValuesInTree(tree, protectedCPAAttributes{})
// Helper itself is unconditional but the public entry point
// short-circuits before calling it with an empty set —
@@ -3509,16 +3531,32 @@ func TestRedactProtectedActualValuesInTree(t *testing.T) {
// paths, empty paths, and empty protected sets.
func TestIsProtectedAttributePath(t *testing.T) {
mainHelper.Parallel(t)
protected := map[string]struct{}{"Clearance": {}}
// Clearance is protected only on the user side, Sensitivity only on the
// resource side — so a leaf must match the set for its own root.
protected := protectedCPAAttributes{
user: map[string]struct{}{"Clearance": {}},
resource: map[string]struct{}{"Sensitivity": {}},
}
t.Run("returns true for the canonical user.attributes.<name> form", func(t *testing.T) {
assert.True(t, isProtectedAttributePath("user.attributes.Clearance", protected))
})
t.Run("returns false for non-user-attribute paths", func(t *testing.T) {
// Resource / session / channel paths must not collide with
// the user-attributes namespace — only `user.attributes.*`
// is in scope for the CPA visibility filter.
t.Run("returns true for the canonical resource.attributes.<name> form", func(t *testing.T) {
assert.True(t, isProtectedAttributePath("resource.attributes.Sensitivity", protected))
})
t.Run("matches each root against its own set, not the other's", func(t *testing.T) {
// Clearance is protected user-side but not resource-side, and
// vice versa for Sensitivity — the sets must not cross.
assert.False(t, isProtectedAttributePath("resource.attributes.Clearance", protected))
assert.False(t, isProtectedAttributePath("user.attributes.Sensitivity", protected))
})
t.Run("returns false for non-CPA paths", func(t *testing.T) {
// Session / native / bare resource selectors carry no
// `.attributes.` segment — only `user.attributes.*` and
// `resource.attributes.*` are in scope for the CPA filter.
assert.False(t, isProtectedAttributePath("session.network_status", protected))
assert.False(t, isProtectedAttributePath("resource.id", protected))
assert.False(t, isProtectedAttributePath("channel.member_count", protected))
@@ -3526,13 +3564,16 @@ func TestIsProtectedAttributePath(t *testing.T) {
t.Run("returns false for paths whose suffix is not in the protected set", func(t *testing.T) {
assert.False(t, isProtectedAttributePath("user.attributes.Region", protected))
assert.False(t, isProtectedAttributePath("resource.attributes.Region", protected))
})
t.Run("returns false for empty inputs", func(t *testing.T) {
assert.False(t, isProtectedAttributePath("", protected))
assert.False(t, isProtectedAttributePath("user.attributes.Clearance", nil))
assert.False(t, isProtectedAttributePath("user.attributes.Clearance", protectedCPAAttributes{}))
assert.False(t, isProtectedAttributePath("user.attributes.", protected),
"empty suffix must not match — that's a malformed path, not a protected reference")
assert.False(t, isProtectedAttributePath("resource.attributes.", protected),
"empty suffix must not match — that's a malformed path, not a protected reference")
})
}
@@ -5416,7 +5457,7 @@ func TestGetAccessControlFieldsAutocomplete_ExcludesNonUserFields(t *testing.T)
require.NoError(t, err)
}
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, strings.Repeat("0", 26), 100, th.BasicUser.Id)
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", false, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
for _, f := range fields {
@@ -5431,6 +5472,116 @@ func TestGetAccessControlFieldsAutocomplete_ExcludesNonUserFields(t *testing.T)
assert.Contains(t, fieldIDs, userField.ID, "user CPA field must appear in autocomplete results")
}
// When scoped to a channel, autocomplete additionally returns channel-object-type
// CPA fields (resource.attributes.*), each tagged with its ObjectType, alongside
// the user fields.
func TestGetAccessControlFieldsAutocomplete_IncludesChannelFieldsWhenScoped(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.ConfigStore.SetReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = true
})
rctx := request.TestContext(t)
cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, cErr)
userField, appErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
GroupID: cpaGroup.ID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
channelField, appErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
GroupID: cpaGroup.ID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, th.BasicChannel.Id, false, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
byID := make(map[string]*model.PropertyField, len(fields))
for _, f := range fields {
byID[f.ID] = f
}
require.Contains(t, byID, userField.ID, "user CPA field must appear when scoped to a channel")
require.Contains(t, byID, channelField.ID, "channel CPA field must appear when scoped to a channel")
assert.Equal(t, model.PropertyFieldObjectTypeChannel, byID[channelField.ID].ObjectType,
"channel field must be tagged with its ObjectType")
}
// includeResourceFields=true is how a caller with no channel to scope by asks
// for channel-object-type fields anyway. Verify the flag alone (channelID="")
// surfaces channel fields, and that without it channel fields are excluded.
func TestGetAccessControlFieldsAutocomplete_IncludeResourceFieldsFlag(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.ConfigStore.SetReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = true
})
rctx := request.TestContext(t)
cpaGroup, cErr := th.App.GetPropertyGroup(rctx, model.AccessControlPropertyGroupName)
require.Nil(t, cErr)
channelField, appErr := th.App.CreatePropertyField(rctx, &model.PropertyField{
GroupID: cpaGroup.ID,
Name: celSafeName(),
Type: model.PropertyFieldTypeSelect,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
contains := func(fields []*model.PropertyField, id string) bool {
for _, f := range fields {
if f.ID == id {
return true
}
}
return false
}
withFlag, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", true, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
assert.True(t, contains(withFlag, channelField.ID),
"channel field must appear when includeResourceFields=true even with no channel scope")
withoutFlag, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", false, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
assert.False(t, contains(withoutFlag, channelField.ID),
"channel field must not appear without a channel scope or the flag")
// With the feature off, neither entry path offers a channel field. This is what
// keeps every editor from being able to author a resource rule, so assert both
// the request flag and a channel scope are powerless on their own.
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.ResourceAttributesInPolicies = false
})
gatedByRequestFlag, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", true, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
assert.False(t, contains(gatedByRequestFlag, channelField.ID),
"channel field must not appear via includeResourceFields while the feature is off")
assert.NotEmpty(t, gatedByRequestFlag, "user attributes must still be returned while the feature is off")
gatedByScope, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, th.BasicChannel.Id, false, strings.Repeat("0", 26), 100, th.BasicUser.Id)
require.Nil(t, appErr)
assert.False(t, contains(gatedByScope, channelField.ID),
"channel field must not appear via a channel scope while the feature is off")
}
// Verify that the team join path (channelID="") produces a subject with no
// channel-scoped role — the user holds no channel role at team-join time, so
// attaching one would produce a misleading subject for the membership decision.
@@ -5479,7 +5630,7 @@ func TestGetAccessControlFieldsAutocompleteNativeAttributes(t *testing.T) {
t.Run("first page prepends native attributes", func(t *testing.T) {
// The API maps an empty first page to a 26-zero sentinel cursor.
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, strings.Repeat("0", 26), 50, anonymousCallerId)
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", false, strings.Repeat("0", 26), 50, anonymousCallerId)
require.Nil(t, appErr)
seen := map[string]bool{}
@@ -5497,7 +5648,7 @@ func TestGetAccessControlFieldsAutocompleteNativeAttributes(t *testing.T) {
})
t.Run("subsequent pages omit native attributes", func(t *testing.T) {
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, cpaField.ID, 50, anonymousCallerId)
fields, appErr := th.App.GetAccessControlFieldsAutocomplete(rctx, "", false, cpaField.ID, 50, anonymousCallerId)
require.Nil(t, appErr)
for _, f := range fields {
isNative, _ := f.Attrs[model.NativeAttributeAttrMarker].(bool)
+3
View File
@@ -545,6 +545,9 @@ func TestDoSetupSessionAttributesProperties(t *testing.T) {
}
require.Len(t, trimmed, len(persisted)-1)
field.Attrs[model.PropertyFieldAttributeOptions] = trimmed
// System-caller context, same as the seed and the sibling upgrade
// subtests: SessionAttributesHook otherwise reads the existing field
// through RequestContextWithMaster, which panics on a nil rctx.
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(properties.SystemCallerContext(th.Context), group.ID, []*model.PropertyField{field})
require.NoError(t, err)
+3 -1
View File
@@ -427,7 +427,9 @@ func (a *App) GetPluginAccessControlFieldsAutocomplete(rctx request.CTX, pluginI
after = strings.Repeat("0", 26)
}
return a.GetAccessControlFieldsAutocomplete(rctx, after, limit, actingUserID)
// No channel scope and no opt-in to resource fields: a plugin policy is not
// evaluated against a channel, so only user attributes are offered.
return a.GetAccessControlFieldsAutocomplete(rctx, "", false, after, limit, actingUserID)
}
// GetPluginAccessControlVisualAST converts a CEL expression to the visual
@@ -1341,10 +1341,9 @@ func (h *AccessControlHook) filterSharedOnlyScalarValue(field *model.PropertyFie
// applyFieldReadAccessControl applies read access control to a single field.
// Returns the field with options filtered based on the caller's access permissions.
// - Public fields: returned as-is
// - User-editable fields (PermissionValues=member): returned as-is so users can see all choices
// - Source-only fields: returned with empty options if caller is not the source plugin
// - Any access mode when the caller is the field's source plugin: returned as-is
// - Shared-only fields: returned with options filtered using filterSharedOnlyFieldOptions
// - Unknown access modes: treated as source-only (secure default)
// - Source-only or unknown access modes: returned with empty options (secure default)
func (h *AccessControlHook) applyFieldReadAccessControl(field *model.PropertyField, callerID string) *model.PropertyField {
if h.hasUnrestrictedFieldReadAccess(field, callerID) {
return field
@@ -425,3 +425,5 @@ channels/db/migrations/postgres/000214_drop_channelmembers_autotranslation.down.
channels/db/migrations/postgres/000214_drop_channelmembers_autotranslation.up.sql
channels/db/migrations/postgres/000215_drop_channelmembers_autotranslation_column.down.sql
channels/db/migrations/postgres/000215_drop_channelmembers_autotranslation_column.up.sql
channels/db/migrations/postgres/000216_split_attribute_view_by_object_type.down.sql
channels/db/migrations/postgres/000216_split_attribute_view_by_object_type.up.sql
@@ -0,0 +1,44 @@
-- Restore the single combined AttributeView matview (the 000200 definition,
-- user-scoped with the rank branch) and drop the per-object-type views.
DROP MATERIALIZED VIEW IF EXISTS UserAttributeView;
DROP MATERIALIZED VIEW IF EXISTS ChannelAttributeView;
CREATE MATERIALIZED VIEW IF NOT EXISTS AttributeView AS
SELECT
pv.GroupID,
pv.TargetID,
pv.TargetType,
jsonb_object_agg(
pf.Name,
CASE
WHEN pf.Type = 'select' THEN (
SELECT to_jsonb(options.name)
FROM jsonb_to_recordset(pf.Attrs->'options') AS options(id text, name text)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
WHEN pf.Type = 'multiselect' AND jsonb_typeof(pv.Value) = 'array' THEN (
SELECT jsonb_agg(option_names.name)
FROM jsonb_array_elements_text(pv.Value) AS option_id
JOIN jsonb_to_recordset(pf.Attrs->'options') AS option_names(id text, name text)
ON option_id = option_names.id
)
WHEN pf.Type = 'rank' THEN (
SELECT jsonb_build_object(
'name', options.name,
'rank', options.rank
)
FROM jsonb_to_recordset(pf.Attrs->'options')
AS options(id text, name text, rank int)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
ELSE pv.Value
END
) AS Attributes
FROM PropertyValues pv
LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID
WHERE (pv.DeleteAt = 0 OR pv.DeleteAt IS NULL)
AND (pf.DeleteAt = 0 OR pf.DeleteAt IS NULL)
AND pf.ObjectType = 'user'
GROUP BY pv.GroupID, pv.TargetID, pv.TargetType;
@@ -0,0 +1,89 @@
-- Split the single AttributeView matview into per-object-type views so ABAC
-- policies can reference channel attributes independently of user attributes:
-- a channel and a user can each carry a field of the same name without the two
-- colliding in one keyed-by-name row. Splitting also makes it possible to later
-- refresh only the view whose attributes changed (callers refresh both today).
-- UserAttributeView keeps the existing user-scoped definition; ChannelAttributeView
-- is the identical shape scoped to channels. The two SELECTs differ only in the
-- pf.ObjectType filter.
DROP MATERIALIZED VIEW IF EXISTS AttributeView;
CREATE MATERIALIZED VIEW IF NOT EXISTS UserAttributeView AS
SELECT
pv.GroupID,
pv.TargetID,
pv.TargetType,
jsonb_object_agg(
pf.Name,
CASE
WHEN pf.Type = 'select' THEN (
SELECT to_jsonb(options.name)
FROM jsonb_to_recordset(pf.Attrs->'options') AS options(id text, name text)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
WHEN pf.Type = 'multiselect' AND jsonb_typeof(pv.Value) = 'array' THEN (
SELECT jsonb_agg(option_names.name)
FROM jsonb_array_elements_text(pv.Value) AS option_id
JOIN jsonb_to_recordset(pf.Attrs->'options') AS option_names(id text, name text)
ON option_id = option_names.id
)
WHEN pf.Type = 'rank' THEN (
SELECT jsonb_build_object(
'name', options.name,
'rank', options.rank
)
FROM jsonb_to_recordset(pf.Attrs->'options')
AS options(id text, name text, rank int)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
ELSE pv.Value
END
) AS Attributes
FROM PropertyValues pv
LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID
WHERE (pv.DeleteAt = 0 OR pv.DeleteAt IS NULL)
AND (pf.DeleteAt = 0 OR pf.DeleteAt IS NULL)
AND pf.ObjectType = 'user'
GROUP BY pv.GroupID, pv.TargetID, pv.TargetType;
CREATE MATERIALIZED VIEW IF NOT EXISTS ChannelAttributeView AS
SELECT
pv.GroupID,
pv.TargetID,
pv.TargetType,
jsonb_object_agg(
pf.Name,
CASE
WHEN pf.Type = 'select' THEN (
SELECT to_jsonb(options.name)
FROM jsonb_to_recordset(pf.Attrs->'options') AS options(id text, name text)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
WHEN pf.Type = 'multiselect' AND jsonb_typeof(pv.Value) = 'array' THEN (
SELECT jsonb_agg(option_names.name)
FROM jsonb_array_elements_text(pv.Value) AS option_id
JOIN jsonb_to_recordset(pf.Attrs->'options') AS option_names(id text, name text)
ON option_id = option_names.id
)
WHEN pf.Type = 'rank' THEN (
SELECT jsonb_build_object(
'name', options.name,
'rank', options.rank
)
FROM jsonb_to_recordset(pf.Attrs->'options')
AS options(id text, name text, rank int)
WHERE options.id = pv.Value #>> '{}'
LIMIT 1
)
ELSE pv.Value
END
) AS Attributes
FROM PropertyValues pv
LEFT JOIN PropertyFields pf ON pf.ID = pv.FieldID
WHERE (pv.DeleteAt = 0 OR pv.DeleteAt IS NULL)
AND (pf.DeleteAt = 0 OR pf.DeleteAt IS NULL)
AND pf.ObjectType = 'channel'
GROUP BY pv.GroupID, pv.TargetID, pv.TargetType;
@@ -852,11 +852,11 @@ func (s *RetryLayerAttributesStore) GetChannelMembersToRemove(rctx request.CTX,
}
func (s *RetryLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) {
func (s *RetryLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string, objectType string) (*model.Subject, error) {
tries := 0
for {
result, err := s.AttributesStore.GetSubject(rctx, ID, groupID)
result, err := s.AttributesStore.GetSubject(rctx, ID, groupID, objectType)
if err == nil {
return result, nil
}
@@ -24,6 +24,27 @@ type SqlAttributesStore struct {
selectQueryBuilder sq.SelectBuilder
}
// attributeViewsByObjectType is the single source of truth for the
// object-type-to-materialized-view mapping: both the per-type lookup and the
// refresh sweep read it, so adding an object type cannot leave a new view
// perpetually stale.
var attributeViewsByObjectType = map[string]string{
model.PropertyFieldObjectTypeChannel: "ChannelAttributeView",
model.PropertyFieldObjectTypeUser: "UserAttributeView",
}
// attributeViewFor maps a property-field object type to its materialized view.
// An unrecognized type is an error rather than a fallback to the user view: a
// silent coercion would surface a future miswire as a wrong-but-successful user
// lookup.
func attributeViewFor(objectType string) (string, error) {
view, ok := attributeViewsByObjectType[objectType]
if !ok {
return "", errors.Errorf("unknown object type %q for attribute view", objectType)
}
return view, nil
}
func attributesSliceColumns(prefix ...string) []string {
var p string
if len(prefix) == 1 {
@@ -56,21 +77,38 @@ func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterf
metrics: metrics,
}
s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...).From("AttributeView")
// The From clause is chosen per call by object type (GetSubject), so the
// shared builder carries only the Select.
s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...)
return s
}
func (s *SqlAttributesStore) RefreshAttributes() error {
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil {
return errors.Wrap(err, "error refreshing materialized view AttributeView")
// Both per-object-type views (migration 000216) refresh on one cadence, so a
// caller does not have to know which object types it is about to read. They
// refresh in separate statements, though, so this is not an atomic snapshot:
// a read straddling a refresh can see the two views a generation apart. Every
// consumer tolerates that — values are eventually consistent and the
// membership sync re-runs on cadence and converges. Refreshing only the view
// whose attributes actually changed is a scale follow-up. Refresh order is
// unspecified (map iteration) and does not matter: each REFRESH is its own
// independent statement.
for _, view := range attributeViewsByObjectType {
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW " + view); err != nil {
return errors.Wrapf(err, "error refreshing materialized view %s", view)
}
}
return nil
}
func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error) {
query := s.selectQueryBuilder.Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}})
func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID, objectType string) (*model.Subject, error) {
view, err := attributeViewFor(objectType)
if err != nil {
return nil, err
}
query := s.selectQueryBuilder.From(view).Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}})
q, args, err := query.ToSql()
if err != nil {
@@ -98,10 +136,10 @@ func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID string) (*
func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) {
query := s.getQueryBuilder().
Select(getUsersColumns()...).From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID").
Select(getUsersColumns()...).From("Users").LeftJoin("UserAttributeView ON Users.Id = UserAttributeView.TargetID").
OrderBy("Users.Id ASC")
count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("AttributeView ON Users.Id = AttributeView.TargetID")
count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("UserAttributeView ON Users.Id = UserAttributeView.TargetID")
if opts.Query != "" {
// Wrap the CEL-derived expression in parentheses so that any top-level
@@ -147,7 +185,7 @@ func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSea
if opts.Cursor.TargetID != "" {
argCount++
// Paginate on Users.Id (the ORDER BY column), not AttributeView.TargetID.
// Paginate on Users.Id (the ORDER BY column), not UserAttributeView.TargetID.
// The cursor value is a user id, and TargetID comes from a LEFT JOIN so it
// is NULL for users with no custom-attribute row — comparing against it
// silently drops those users (e.g. matches of a native-only policy).
@@ -200,14 +238,14 @@ func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channel
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
// resolve here, mirroring SearchUsers on the add path.
LeftJoin("Users ON Users.Id = ChannelMembers.UserId").
LeftJoin("AttributeView ON ChannelMembers.UserId = AttributeView.TargetID").
LeftJoin("UserAttributeView ON ChannelMembers.UserId = UserAttributeView.TargetID").
OrderBy("ChannelMembers.UserId ASC")
if opts.Query != "" {
// A member is removed when they do NOT satisfy the policy; a NULL result
// (e.g. a missing custom attribute) counts as "does not satisfy" via
// COALESCE. We must not additionally remove members just because they
// lack an AttributeView row — a native-only policy matches against the
// lack a UserAttributeView row — a native-only policy matches against the
// Users table, so a user with zero custom attributes can still satisfy it.
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
}
@@ -247,7 +285,7 @@ func (s *SqlAttributesStore) GetTeamMembersToRemove(rctx request.CTX, teamID str
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
// resolve here, mirroring SearchUsers on the add path.
LeftJoin("Users ON Users.Id = TeamMembers.UserId").
LeftJoin("AttributeView ON TeamMembers.UserId = AttributeView.TargetID").
LeftJoin("UserAttributeView ON TeamMembers.UserId = UserAttributeView.TargetID").
Where("TeamMembers.DeleteAt = 0").
OrderBy("TeamMembers.UserId ASC")
@@ -255,7 +293,7 @@ func (s *SqlAttributesStore) GetTeamMembersToRemove(rctx request.CTX, teamID str
// A member is removed when they do NOT satisfy the policy; a NULL result
// (e.g. a missing custom attribute) counts as "does not satisfy" via
// COALESCE. We must not additionally remove members just because they
// lack an AttributeView row — a native-only policy matches against the
// lack a UserAttributeView row — a native-only policy matches against the
// Users table, so a user with zero custom attributes can still satisfy it.
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
}
@@ -149,15 +149,17 @@ func TestMigration000185(t *testing.T) {
assert.Equal(t, targetUserID, val.TargetID, "value TargetID should be unchanged")
assert.Equal(t, "user", val.TargetType, "value TargetType should be unchanged")
// Verify: AttributeView exists and includes the ObjectType filter (user-type fields only).
// Verify: the user attribute matview exists and includes the ObjectType
// filter (user-type fields only). Since migration 000216 the single
// AttributeView is split per object type; the user row lives in UserAttributeView.
var viewDef string
err = master.Get(&viewDef, "SELECT definition FROM pg_matviews WHERE matviewname = 'attributeview'")
require.NoError(t, err, "AttributeView should exist")
err = master.Get(&viewDef, "SELECT definition FROM pg_matviews WHERE matviewname = 'userattributeview'")
require.NoError(t, err, "UserAttributeView should exist")
assert.Contains(t, viewDef, "pf.objecttype", "view definition should filter by pf.ObjectType")
// Verify: materialized view contains expected data after refresh.
_, err = master.ExecNoTimeout("REFRESH MATERIALIZED VIEW AttributeView")
require.NoError(t, err, "refreshing AttributeView should succeed")
_, err = master.ExecNoTimeout("REFRESH MATERIALIZED VIEW UserAttributeView")
require.NoError(t, err, "refreshing UserAttributeView should succeed")
var viewRow struct {
GroupID string `db:"groupid"`
@@ -165,8 +167,8 @@ func TestMigration000185(t *testing.T) {
TargetType string `db:"targettype"`
Attributes []byte `db:"attributes"`
}
err = master.Get(&viewRow, "SELECT GroupID, TargetID, TargetType, Attributes FROM AttributeView WHERE TargetID = ?", targetUserID)
require.NoError(t, err, "AttributeView should contain a row for the target user")
err = master.Get(&viewRow, "SELECT GroupID, TargetID, TargetType, Attributes FROM UserAttributeView WHERE TargetID = ?", targetUserID)
require.NoError(t, err, "UserAttributeView should contain a row for the target user")
assert.Equal(t, groupID, viewRow.GroupID)
assert.Equal(t, targetUserID, viewRow.TargetID)
assert.Equal(t, "user", viewRow.TargetType)
@@ -321,10 +323,13 @@ func TestMigration000185NoOpOnFreshDB(t *testing.T) {
_, err = master.ExecNoTimeout(upSQL)
assert.NoError(t, err, "up migration should be a safe no-op on fresh DB")
// Even with no CPA data, the view should be (re)created.
var viewExists bool
require.NoError(t, master.Get(&viewExists, "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'attributeview')"))
assert.True(t, viewExists, "AttributeView should exist after up migration on fresh DB")
// The attribute matviews exist in the final schema. Since migration 000216
// the single AttributeView is split into per-object-type views.
for _, view := range []string{"userattributeview", "channelattributeview"} {
var viewExists bool
require.NoError(t, master.Get(&viewExists, "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = $1)", view))
assert.True(t, viewExists, "%s should exist after up migration on fresh DB", view)
}
_, err = master.ExecNoTimeout(downSQL)
assert.NoError(t, err, "down migration should be a safe no-op on fresh DB")
@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func matviewExists(t *testing.T, s *SqlStore, name string) bool {
t.Helper()
var count int
err := s.GetMaster().Get(&count,
"SELECT COUNT(*) FROM pg_matviews WHERE lower(matviewname) = lower($1)", name)
require.NoError(t, err)
return count > 0
}
// TestMigration000216 verifies the split of AttributeView into per-object-type
// views: after the migration both UserAttributeView and ChannelAttributeView
// exist (and the combined AttributeView is gone), each filtering to its own
// ObjectType. The down migration restores the single combined view.
func TestMigration000216(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
// New() applies all migrations, so 000216 is already in effect.
require.True(t, matviewExists(t, store, "UserAttributeView"), "UserAttributeView should exist after migration")
require.True(t, matviewExists(t, store, "ChannelAttributeView"), "ChannelAttributeView should exist after migration")
require.False(t, matviewExists(t, store, "AttributeView"), "combined AttributeView should be gone after migration")
// Seed one user-scoped and one channel-scoped attribute in the same group.
group, err := store.PropertyGroup().Register(&model.PropertyGroup{Name: model.NewId(), Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
groupID := group.ID
userField, err := store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "user_prop",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
channelField, err := store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "channel_prop",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
userTarget := model.NewId()
channelTarget := model.NewId()
userVal, err := store.PropertyValue().Create(&model.PropertyValue{
TargetID: userTarget, TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID, FieldID: userField.ID, Value: []byte(`"u"`),
})
require.NoError(t, err)
channelVal, err := store.PropertyValue().Create(&model.PropertyValue{
TargetID: channelTarget, TargetType: model.PropertyValueTargetTypeChannel,
GroupID: groupID, FieldID: channelField.ID, Value: []byte(`"c"`),
})
require.NoError(t, err)
t.Cleanup(func() {
store.PropertyValue().Delete(groupID, userVal.ID) //nolint:errcheck
store.PropertyValue().Delete(groupID, channelVal.ID) //nolint:errcheck
store.PropertyField().Delete(groupID, userField.ID) //nolint:errcheck
store.PropertyField().Delete(groupID, channelField.ID) //nolint:errcheck
})
require.NoError(t, store.Attributes().RefreshAttributes())
countInView := func(view, targetID string) int {
var c int
gErr := store.GetMaster().Get(&c, "SELECT COUNT(*) FROM "+view+" WHERE TargetID = $1", targetID)
require.NoError(t, gErr)
return c
}
require.Equal(t, 1, countInView("UserAttributeView", userTarget), "user row should be in UserAttributeView")
require.Equal(t, 0, countInView("UserAttributeView", channelTarget), "channel row should not be in UserAttributeView")
require.Equal(t, 1, countInView("ChannelAttributeView", channelTarget), "channel row should be in ChannelAttributeView")
require.Equal(t, 0, countInView("ChannelAttributeView", userTarget), "user row should not be in ChannelAttributeView")
// Down then up round-trips the view topology.
downSQL := readMigrationSQL(t, "000216_split_attribute_view_by_object_type.down.sql")
upSQL := readMigrationSQL(t, "000216_split_attribute_view_by_object_type.up.sql")
_, err = store.GetMaster().Exec(downSQL)
require.NoError(t, err)
require.True(t, matviewExists(t, store, "AttributeView"), "down should recreate AttributeView")
require.False(t, matviewExists(t, store, "UserAttributeView"), "down should drop UserAttributeView")
require.False(t, matviewExists(t, store, "ChannelAttributeView"), "down should drop ChannelAttributeView")
_, err = store.GetMaster().Exec(upSQL)
require.NoError(t, err)
require.True(t, matviewExists(t, store, "UserAttributeView"), "up should recreate UserAttributeView")
require.True(t, matviewExists(t, store, "ChannelAttributeView"), "up should recreate ChannelAttributeView")
require.False(t, matviewExists(t, store, "AttributeView"), "up should drop AttributeView")
}
+1 -1
View File
@@ -1243,7 +1243,7 @@ type AccessControlPolicyStore interface {
type AttributesStore interface {
RefreshAttributes() error
GetSubject(rctx request.CTX, ID, groupID string) (*model.Subject, error)
GetSubject(rctx request.CTX, ID, groupID, objectType string) (*model.Subject, error)
SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error)
GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error)
GetTeamMembersToRemove(rctx request.CTX, teamID string, opts model.SubjectSearchOptions) ([]*model.TeamMember, error)
@@ -32,6 +32,8 @@ var (
func TestAttributesStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("RefreshAndGet", func(t *testing.T) { testAttributesStoreRefresh(t, rctx, ss) })
t.Run("GetChannelSubject", func(t *testing.T) { testAttributesStoreGetChannelSubject(t, rctx, ss) })
t.Run("EmptyMultiselect", func(t *testing.T) { testAttributesStoreEmptyMultiselect(t, rctx, ss) })
t.Run("SearchUsers", func(t *testing.T) { testAttributesStoreSearchUsers(t, rctx, ss, s) })
t.Run("SearchUsersBySubjectID", func(t *testing.T) { testAttributesStoreSearchUsersBySubjectID(t, rctx, ss, s) })
t.Run("GetChannelMembersToRemove", func(t *testing.T) { testAttributesStoreGetChannelMembersToRemove(t, rctx, ss, s) })
@@ -211,7 +213,7 @@ func testAttributesStoreRefresh(t *testing.T, rctx request.CTX, ss store.Store)
// Check if the attributes are set correctly
for _, user := range users {
subject, err := ss.Attributes().GetSubject(rctx, user.Id, groupID)
subject, err := ss.Attributes().GetSubject(rctx, user.Id, groupID, model.PropertyFieldObjectTypeUser)
require.NoError(t, err, "couldn't get subject")
require.Equal(t, user.Id, subject.ID)
@@ -220,13 +222,141 @@ func testAttributesStoreRefresh(t *testing.T, rctx request.CTX, ss store.Store)
})
t.Run("Get non-existing subject", func(t *testing.T) {
subject, err := ss.Attributes().GetSubject(rctx, "non-existing-id", groupID)
subject, err := ss.Attributes().GetSubject(rctx, "non-existing-id", groupID, model.PropertyFieldObjectTypeUser)
require.Error(t, err, "expected error when getting non-existing subject")
require.IsType(t, &store.ErrNotFound{}, err, "expected not found error")
require.Nil(t, subject, "expected nil subject for non-existing ID")
})
}
// testAttributesStoreGetChannelSubject verifies the per-object-type view split
// (migration 000216): a channel-scoped attribute is readable via
// GetSubject(..., "channel") from ChannelAttributeView, and is *not* visible
// through the user view — proving the two views filter by ObjectType.
func testAttributesStoreGetChannelSubject(t *testing.T, rctx request.CTX, ss store.Store) {
group, err := ss.PropertyGroup().Register(&model.PropertyGroup{Name: model.NewId(), Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
groupID := group.ID
field, err := ss.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "channel_prop",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
channelID := model.NewId()
val, err := json.Marshal("channel_value")
require.NoError(t, err)
pv, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: channelID,
TargetType: model.PropertyValueTargetTypeChannel,
GroupID: groupID,
FieldID: field.ID,
Value: val,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, ss.PropertyValue().Delete(groupID, pv.ID))
require.NoError(t, ss.PropertyField().Delete(groupID, field.ID))
})
require.NoError(t, ss.Attributes().RefreshAttributes())
t.Run("channel view returns channel attributes", func(t *testing.T) {
subject, err := ss.Attributes().GetSubject(rctx, channelID, groupID, model.PropertyFieldObjectTypeChannel)
require.NoError(t, err)
require.Equal(t, channelID, subject.ID)
require.Equal(t, model.PropertyValueTargetTypeChannel, subject.Type)
require.Equal(t, "channel_value", subject.Attributes["channel_prop"])
})
t.Run("user view does not see the channel row", func(t *testing.T) {
subject, err := ss.Attributes().GetSubject(rctx, channelID, groupID, model.PropertyFieldObjectTypeUser)
require.Error(t, err)
require.IsType(t, &store.ErrNotFound{}, err)
require.Nil(t, subject)
})
}
// testAttributesStoreEmptyMultiselect pins the fail-closed contract for
// multiselect attributes (migration 000216): an empty multiselect resolves to
// NULL in the matview, never an empty array. The view builds the value with
// jsonb_agg over the joined option rows, and jsonb_agg over zero rows yields
// NULL — there is no NULLIF/COALESCE. A refactor that wrapped it in
// COALESCE(..., '[]') would silently flip fail-closed to fail-open (an empty tag
// set would start satisfying an "in"/membership rule) with the rest of the suite
// still green, so assert the NULL directly.
func testAttributesStoreEmptyMultiselect(t *testing.T, rctx request.CTX, ss store.Store) {
group, err := ss.PropertyGroup().Register(&model.PropertyGroup{Name: model.NewId(), Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
groupID := group.ID
optID1, optID2 := model.NewId(), model.NewId()
field, err := ss.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "tags",
Type: model.PropertyFieldTypeMultiselect,
Attrs: map[string]any{"options": []any{
map[string]any{"id": optID1, "name": "blue", "color": ""},
map[string]any{"id": optID2, "name": "green", "color": ""},
}},
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
emptyChannelID := model.NewId()
fullChannelID := model.NewId()
emptyVal, err := json.Marshal([]string{})
require.NoError(t, err)
fullVal, err := json.Marshal([]string{optID1, optID2})
require.NoError(t, err)
pvEmpty, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: emptyChannelID,
TargetType: model.PropertyValueTargetTypeChannel,
GroupID: groupID,
FieldID: field.ID,
Value: emptyVal,
})
require.NoError(t, err)
pvFull, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: fullChannelID,
TargetType: model.PropertyValueTargetTypeChannel,
GroupID: groupID,
FieldID: field.ID,
Value: fullVal,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, ss.PropertyValue().Delete(groupID, pvEmpty.ID))
require.NoError(t, ss.PropertyValue().Delete(groupID, pvFull.ID))
require.NoError(t, ss.PropertyField().Delete(groupID, field.ID))
})
require.NoError(t, ss.Attributes().RefreshAttributes())
t.Run("empty multiselect resolves to NULL, not []", func(t *testing.T) {
subject, err := ss.Attributes().GetSubject(rctx, emptyChannelID, groupID, model.PropertyFieldObjectTypeChannel)
require.NoError(t, err)
// nil (JSON null), never []any{} — an empty slice would satisfy a
// membership rule and open the fail-open hole this test guards.
require.Nil(t, subject.Attributes["tags"])
})
t.Run("populated multiselect resolves to option names", func(t *testing.T) {
subject, err := ss.Attributes().GetSubject(rctx, fullChannelID, groupID, model.PropertyFieldObjectTypeChannel)
require.NoError(t, err)
require.ElementsMatch(t, []any{"blue", "green"}, subject.Attributes["tags"])
})
}
func testAttributesStoreSearchUsers(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
users, _, cleanup := createTestUsers(t, rctx, ss)
t.Cleanup(cleanup)
@@ -315,14 +445,14 @@ func testAttributesStoreSearchUsers(t *testing.T, rctx request.CTX, ss store.Sto
})
// Regression test: pagination must page on Users.Id, not the LEFT-JOINed
// AttributeView.TargetID. A user with no custom-attribute row has a NULL
// UserAttributeView.TargetID. A user with no custom-attribute row has a NULL
// TargetID, so a "TargetID > cursor" predicate silently drops them from
// every page. Native-only policies (e.g. user.verified) match exactly such
// users, and the membership sync always seeds a cursor, so this manifested
// as the sync adding zero members while the Test modal (no cursor) showed
// the full set.
t.Run("Search paginates users with no attribute row", func(t *testing.T) {
// A fresh user with no custom attributes => no AttributeView row.
// A fresh user with no custom attributes => no UserAttributeView row.
u := model.User{Email: MakeEmail(), Username: model.NewUsername()}
_, err := ss.User().Save(rctx, &u)
require.NoError(t, err, "couldn't save attribute-less user")
@@ -333,7 +463,7 @@ func testAttributesStoreSearchUsers(t *testing.T, rctx request.CTX, ss store.Sto
require.NoError(t, ss.Attributes().RefreshAttributes(), "couldn't refresh attributes")
// Native-style predicate against the Users table; matches the user above
// despite the missing AttributeView row.
// despite the missing UserAttributeView row.
subjects, _, err := ss.Attributes().SearchUsers(rctx, model.SubjectSearchOptions{
Query: "Users.Email = $1::text",
Args: []any{u.Email},
@@ -433,9 +563,9 @@ func testAttributesStoreGetChannelMembersToRemove(t *testing.T, rctx request.CTX
})
// Regression test: a native-attribute policy resolves against the Users
// table (the query is joined to Users), and a member with no AttributeView
// table (the query is joined to Users), and a member with no UserAttributeView
// row who satisfies it must NOT be removed. Before the fix, native columns
// failed to resolve (no Users join) and the "OR AttributeView.TargetID IS
// failed to resolve (no Users join) and the "OR UserAttributeView.TargetID IS
// NULL" clause removed attribute-less members outright.
t.Run("native policy keeps members with no attribute row", func(t *testing.T) {
extra := model.User{Email: MakeEmail(), Username: model.NewUsername()}
@@ -554,9 +684,9 @@ func testAttributesStoreGetTeamMembersToRemove(t *testing.T, rctx request.CTX, s
})
// Regression test: a native-attribute policy resolves against the Users
// table (the query is joined to Users), and a member with no AttributeView
// table (the query is joined to Users), and a member with no UserAttributeView
// row who satisfies it must NOT be removed. Before the fix, native columns
// failed to resolve (no Users join) and the "OR AttributeView.TargetID IS
// failed to resolve (no Users join) and the "OR UserAttributeView.TargetID IS
// NULL" clause removed attribute-less members outright.
t.Run("native policy keeps members with no attribute row", func(t *testing.T) {
extra := model.User{Email: MakeEmail(), Username: model.NewUsername()}
@@ -45,9 +45,9 @@ func (_m *AttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID
return r0, r1
}
// GetSubject provides a mock function with given fields: rctx, ID, groupID
func (_m *AttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) {
ret := _m.Called(rctx, ID, groupID)
// GetSubject provides a mock function with given fields: rctx, ID, groupID, objectType
func (_m *AttributesStore) GetSubject(rctx request.CTX, ID string, groupID string, objectType string) (*model.Subject, error) {
ret := _m.Called(rctx, ID, groupID, objectType)
if len(ret) == 0 {
panic("no return value specified for GetSubject")
@@ -55,19 +55,19 @@ func (_m *AttributesStore) GetSubject(rctx request.CTX, ID string, groupID strin
var r0 *model.Subject
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.Subject, error)); ok {
return rf(rctx, ID, groupID)
if rf, ok := ret.Get(0).(func(request.CTX, string, string, string) (*model.Subject, error)); ok {
return rf(rctx, ID, groupID, objectType)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.Subject); ok {
r0 = rf(rctx, ID, groupID)
if rf, ok := ret.Get(0).(func(request.CTX, string, string, string) *model.Subject); ok {
r0 = rf(rctx, ID, groupID, objectType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Subject)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok {
r1 = rf(rctx, ID, groupID)
if rf, ok := ret.Get(1).(func(request.CTX, string, string, string) error); ok {
r1 = rf(rctx, ID, groupID, objectType)
} else {
r1 = ret.Error(1)
}
@@ -790,10 +790,10 @@ func (s *TimerLayerAttributesStore) GetChannelMembersToRemove(rctx request.CTX,
return result, err
}
func (s *TimerLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string) (*model.Subject, error) {
func (s *TimerLayerAttributesStore) GetSubject(rctx request.CTX, ID string, groupID string, objectType string) (*model.Subject, error) {
start := time.Now()
result, err := s.AttributesStore.GetSubject(rctx, ID, groupID)
result, err := s.AttributesStore.GetSubject(rctx, ID, groupID, objectType)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
+24
View File
@@ -7940,6 +7940,14 @@
"id": "app.pap.mask_expression.app_error",
"translation": "Could not mask policy expression."
},
{
"id": "app.pap.masking.ambiguous_sibling.app_error",
"translation": "Multiple user attributes link the same template, so channel-attribute visibility cannot be determined unambiguously."
},
{
"id": "app.pap.masking.unknown_object_type.app_error",
"translation": "Cannot resolve attribute visibility for an unknown object type."
},
{
"id": "app.pap.merge_expression.app_error",
"translation": "Could not merge policy expression with stored masked values."
@@ -7984,10 +7992,18 @@
"id": "app.pap.save_policy.name_exists.app_error",
"translation": "A policy with this name already exists. Please choose a different name."
},
{
"id": "app.pap.save_policy.parent_resource_attributes_team_assigned",
"translation": "This access rule uses channel attributes, which teams do not have. Remove the teams assigned to this policy before saving."
},
{
"id": "app.pap.save_policy.resolver_error",
"translation": "Could not initialise attribute resolver for policy save."
},
{
"id": "app.pap.save_policy.resource_attributes_disabled",
"translation": "Access rules cannot use channel attributes on this server. This capability is not enabled."
},
{
"id": "app.pap.save_policy.rule_name_unique.app_error",
"translation": "Permission rule names must be unique within the policy."
@@ -7996,6 +8012,10 @@
"id": "app.pap.save_policy.self_exclusion",
"translation": "You do not satisfy one or more conditions in this policy."
},
{
"id": "app.pap.save_policy.team_resource_attributes",
"translation": "This access rule uses channel attributes, which teams do not have. Access rules with channel attributes can only be applied to channels."
},
{
"id": "app.pap.search_access_control_policies.app_error",
"translation": "Could not search access control policies."
@@ -8004,6 +8024,10 @@
"id": "app.pap.simulate.attribute_refresh",
"translation": "Failed to refresh attributes for the simulation."
},
{
"id": "app.pap.simulate.channel_required_for_resource",
"translation": "A channel is required to simulate a policy that references channel attributes."
},
{
"id": "app.pap.simulate.compile_failed",
"translation": "Failed to compile the policy for simulation."
@@ -55,12 +55,17 @@ func (info *MaskingFieldInfo) IsValueHidden(lit string) bool {
}
}
// MaskingFieldResolver answers field-visibility questions for a named property
// attribute (the suffix after "user.attributes.", e.g. "department").
// MaskingFieldResolver answers field-visibility questions for a property
// attribute identified by its CPA object type (PropertyFieldObjectTypeUser for
// user.attributes.*, PropertyFieldObjectTypeChannel for resource.attributes.*)
// and field name (the suffix after the ".attributes." segment, e.g.
// "department"). The object type is required because a user field and a channel
// field can share a name but differ in visibility, so each must be resolved
// against its own CPA schema.
//
// Implementations must be fail-closed: return a non-nil error for any lookup
// that cannot be proven safe. The walker treats any resolver error as a
// reason to mask all literals for that field.
type MaskingFieldResolver interface {
Resolve(fieldName string) (*MaskingFieldInfo, error)
Resolve(objectType, fieldName string) (*MaskingFieldInfo, error)
}
+7
View File
@@ -191,6 +191,13 @@ type SubjectSearchOptions struct {
// normal user-search path. Zero value (false) preserves the full-name search
// used by privileged callers (e.g. the admin CEL tester).
ExcludeFullNames bool `json:"exclude_full_names,omitempty"`
// ResourceID is the channel whose custom attributes an ad-hoc expression test
// resolves resource.attributes.* against, so a resource-referencing expression
// can be previewed against one specific channel's values. Set from the
// channelId on a cel/test request. Unused when the expression references no
// resource attributes. Stored-policy search paths derive the resource from
// the policy itself and ignore this.
ResourceID string `json:"resource_id,omitempty"`
}
type SubjectCursor struct {
+1 -1
View File
@@ -4167,7 +4167,7 @@ func (s *AccessControlSettings) isValid() *AppError {
if *s.SyncJobIntervalSeconds < 60 {
return NewAppError("Config.IsValid", "model.config.is_valid.access_control_sync_interval.app_error", nil, "", http.StatusBadRequest)
}
// Refresh interval is designed to avoid spamming a refresh of the AttributeView in the database.
// Refresh interval is designed to avoid spamming a refresh of the attribute materialized views in the database.
// Minimum is set to 0, so an operator can effectively disable this protection if desired.
if *s.AttributeRefreshIntervalSeconds < 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.access_control_attribute_refresh_interval.app_error", nil, "", http.StatusBadRequest)
+11
View File
@@ -133,6 +133,16 @@ type FeatureFlags struct {
TeamMembershipAccessControl bool
// FEATURE_FLAG_REMOVAL: ResourceAttributesInPolicies - Remove this when the
// feature is GA. Gates access rules that compare a user's attributes against
// the accessed channel's (resource.attributes.*): when off, the autocomplete
// endpoint omits channel-object-type fields, so no editor offers them, and
// saving a policy that references one is rejected. It does NOT gate
// evaluation — a rule stored while the flag was on keeps being enforced,
// because such rules deny on a missing channel value and silently dropping
// enforcement would empty every channel the policy governs.
ResourceAttributesInPolicies bool
// Enable the new mm_blocks Interactive Messages framework
MmBlocksEnabled bool
@@ -169,6 +179,7 @@ func (f *FeatureFlags) SetDefaults() {
f.AttributeValueMasking = true
f.PermissionPolicies = true
f.TeamMembershipAccessControl = true
f.ResourceAttributesInPolicies = false
f.ChannelPermissionPolicies = true
f.PolicySimulation = true
f.ContentFlagging = true
@@ -130,7 +130,7 @@ describe('CELEditor', () => {
await userEvent.click(screen.getByRole('button', {name: /test access rule/i}));
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(expression, '', '', 50);
expect(mockSearch).toHaveBeenCalledWith(expression, '', '', 50, undefined);
});
expect(searchUsersForExpression).not.toHaveBeenCalled();
});
@@ -8,7 +8,6 @@ import {FormattedMessage, useIntl} from 'react-intl';
import type {AccessControlTestResult, CELExpressionError} from '@mattermost/types/access_control';
import {SESSION_ATTRIBUTES_OBJECT_TYPE, USER_OBJECT_TYPE} from '@mattermost/types/properties_user';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {debounce} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -16,8 +15,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import {MonacoLanguageProvider} from './language_provider';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {TestButton, HelpText} from '../shared';
import {TestButton, TestResults, HelpText} from '../shared';
import './editor.scss';
@@ -116,8 +114,9 @@ export interface CELEditorActions {
/** Overrides Client4.checkAccessControlExpression. */
checkExpression?: (expression: string) => Promise<CELExpressionError[]>;
/** Overrides the searchUsersForExpression thunk backing the built-in TestResultsModal. */
searchUsers?: (expression: string, term: string, after: string, limit: number) => Promise<ActionResult<AccessControlTestResult>>;
/** Overrides the searchUsersForExpression thunk backing the built-in TestResultsModal.
* Receives the test modal's chosen channel id as the trailing arg. */
searchUsers?: (expression: string, term: string, after: string, limit: number, channelId?: string) => Promise<ActionResult<AccessControlTestResult>>;
}
export interface CELEditorProps {
@@ -131,6 +130,17 @@ export interface CELEditorProps {
disabled?: boolean;
userAttributes: CELUserAttribute[];
/**
* Channel-object-type attributes exposed as the resource.attributes.*
* autocomplete root, letting a policy compare the requesting user against
* the accessed channel (e.g. user.attributes.clearance >=
* resource.attributes.minClearance). Empty for editors with no channel
* fields in scope (e.g. team policies), which then get no resource root.
*/
resourceAttributes?: Array<{
attribute: string;
}>;
/**
* When provided, the built-in expression-only TestResultsModal is
* suppressed and the test button forwards its click to the parent.
@@ -161,6 +171,7 @@ function CELEditor({
teamId,
disabled = false,
userAttributes,
resourceAttributes = [],
onTestClick,
testButtonLabel,
hasMaskedRows = false,
@@ -181,6 +192,15 @@ function CELEditor({
const schemas = buildCELSchemas(userAttributes);
// Only declare the resource.attributes.* root when channel fields are in
// scope, so editors that can't reference a resource (e.g. team policies)
// don't offer an empty root.
if (resourceAttributes.length > 0) {
const validName = (attr: string) => !attr.includes(' ') && attr.trim() !== '';
schemas.resource = ['attributes'];
schemas['resource.attributes'] = resourceAttributes.map((attr) => attr.attribute).filter(validName);
}
const injectedCheckExpression = actions?.checkExpression;
const editorRef = useRef(null);
@@ -478,39 +498,34 @@ function CELEditor({
/>
</div>
</div>
<TestButton
onClick={onTestClick ?? (() => setEditorState((prev) => ({...prev, showTestResults: true})))}
label={testButtonLabel}
disabled={disabled || hasMaskedRows || !editorState.expression || !editorState.isValid || editorState.isValidating}
disabledTooltip={
hasMaskedRows ?
intl.formatMessage({
<div className='access-control-test-controls'>
<TestButton
onClick={onTestClick ?? (() => setEditorState((prev) => ({...prev, showTestResults: true})))}
label={testButtonLabel}
disabled={disabled || hasMaskedRows || !editorState.expression || !editorState.isValid || editorState.isValidating}
disabledTooltip={
hasMaskedRows ? intl.formatMessage({
id: 'admin.access_control.cel_editor.masked_values_tooltip',
defaultMessage: 'Test is unavailable because this policy contains restricted attribute values.',
}) :
undefined
}
/>
}) : undefined
}
/>
</div>
</div>
{/* Built-in expression-only modal. Suppressed when the
* parent provided an `onTestClick` override (used by the
* permission-rule editor, which renders its own dual-lane
* SimulateAccessModal). */}
* SimulateAccessModal). With no channelId, a resource.attributes.*
* rule gets a channel-picker step inside the modal before the
* members list. */}
{!onTestClick && editorState.showTestResults && (
<TestResultsModal
onExited={() => setEditorState((prev) => ({...prev, showTestResults: false}))}
<TestResults
expression={editorState.expression}
channelId={channelId}
teamId={teamId}
isStacked={true}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
if (actions?.searchUsers) {
// Wrap in a thunk so TestResultsModal can dispatch it unchanged.
const search = actions.searchUsers;
return () => search(editorState.expression, term, after, limit);
}
return searchUsersForExpression(editorState.expression, term, after, limit, channelId, teamId);
},
}}
onExited={() => setEditorState((prev) => ({...prev, showTestResults: false}))}
searchUsers={actions?.searchUsers}
/>
)}
{showHelpModal && (
@@ -54,6 +54,14 @@
}
}
// Positions the Test button on the right of an editor's action row.
.access-control-test-controls {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
.editor__help-text {
color: var(--center-channel-color-72);
font-size: 12px;
@@ -7,7 +7,7 @@ import type {UserPropertyField} from '@mattermost/types/properties_user';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestButton, celPrefixForField, excludeSessionAttributes, hasUsableAttributes, isSimpleCondition, isSimpleExpression, mergeSessionAttributes, toCELEditorAttributes, allowedOperatorLabelsForField, defaultOperatorForField, isNativeBooleanField, isFieldAdvertisedOperator, isNativeMethodOperator, isValidYoungerThanDaysValue, OperatorLabel} from './shared';
import {TestButton, celPrefixForField, excludeSessionAttributes, hasUsableAttributes, isSimpleCondition, isSimpleExpression, mergeSessionAttributes, referencesResourceAttributes, toCELEditorAttributes, allowedOperatorLabelsForField, defaultOperatorForField, isNativeBooleanField, isFieldAdvertisedOperator, isNativeMethodOperator, isValidYoungerThanDaysValue, OperatorLabel} from './shared';
const makeField = (name: string, attrs: Partial<UserPropertyField['attrs']>, type: UserPropertyField['type'] = 'text'): UserPropertyField => ({
id: `id-${name}`,
@@ -768,3 +768,22 @@ describe('isNativeMethodOperator', () => {
expect(isNativeMethodOperator('not an operator')).toBe(false);
});
});
describe('referencesResourceAttributes', () => {
test('true for an actual resource attribute reference', () => {
expect(referencesResourceAttributes('user.attributes.clearance >= resource.attributes.minClearance')).toBe(true);
});
test('false when the prefix only appears inside a quoted literal', () => {
expect(referencesResourceAttributes('user.attributes.note == "resource.attributes.minClearance"')).toBe(false);
expect(referencesResourceAttributes("user.attributes.note == 'resource.attributes.minClearance'")).toBe(false);
});
test('true when a real reference coexists with a quoted literal', () => {
expect(referencesResourceAttributes('user.attributes.note == "resource.attributes.x" && user.attributes.c >= resource.attributes.min')).toBe(true);
});
test('false for a resource-free expression', () => {
expect(referencesResourceAttributes('user.attributes.team == "Sales"')).toBe(false);
});
});
@@ -7,16 +7,31 @@ import type {MessageDescriptor} from 'react-intl';
import {Button} from '@mattermost/shared/components/button';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {AccessControlTestResult} from '@mattermost/types/access_control';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {isSessionAttributeField} from '@mattermost/types/properties_user';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import Markdown from 'components/markdown';
import TestResultsModal from '../modals/policy_test/test_modal';
import './shared.scss';
// Sentinel emitted by the server in masked CEL expressions for values the caller cannot see.
export const MASKED_VALUE_TOKEN_LITERAL = '"--------"';
// The accessed channel's attributes, the comparison target for a rule about the
// requesting user (whose own attributes are USER_ATTRIBUTE_CEL_PREFIX, below).
export const RESOURCE_ATTRIBUTES_PREFIX = 'resource.attributes.';
// value_type on a visual-AST condition. Matches model.ValueType: 0 = literal,
// 1 = attribute reference (the RHS is another attribute path, e.g. a
// resource.attributes.* selector rather than a quoted constant).
export const VISUAL_AST_ATTRIBUTE_VALUE_TYPE = 1;
// CEL operator constants
export enum CELOperator {
EQUALS = '==',
@@ -296,11 +311,24 @@ const CEL_STRING = String.raw`(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')`;
// unescaped quotes that the previous `\[.*?\]` matcher would accept.
const CEL_STRING_LIST = String.raw`\[\s*(?:${CEL_STRING}(?:\s*,\s*${CEL_STRING})*)?\s*\]`;
// A selector reading an attribute of the channel being accessed, which a
// comparison may use in place of a literal.
const RESOURCE_SELECTOR = String.raw`resource\.attributes\.\w+`;
// The first pattern accepts ==, != and the ranked ordinal operators
// (>=, <=, >, <) against a quoted value. >= / <= precede > / < in the
// alternation so the two-char forms match before the one-char ones.
// (>=, <=, >, <) against either a quoted value or a resource.attributes.*
// selector (comparing the user attribute to the accessed channel's). >= / <=
// precede > / < in the alternation so the two-char forms match before the
// one-char ones.
const SIMPLE_CONDITION_PATTERNS: RegExp[] = [
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\s*(==|!=|>=|<=|>|<)\s*${CEL_STRING}$`),
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\s*(==|!=|>=|<=|>|<)\s*(?:${CEL_STRING}|${RESOURCE_SELECTOR})$`),
// Multiselect list-vs-list against the accessed channel's attribute,
// stored verbatim as a member call: the receiver is the user's multiselect
// attribute and the single argument is a resource.attributes.* selector
// (never a literal — that form is the in-chain below).
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.(?:hasAnyOf|hasAllOf)\(${RESOURCE_SELECTOR}\)$`),
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\s+in\s+${CEL_STRING_LIST}$`),
new RegExp(String.raw`^((${CEL_STRING_LIST})|${CEL_STRING})\s+in\s+user\.(?:attributes|session)\.\w+$`),
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.startsWith\(${CEL_STRING}.*?\)$`),
@@ -451,6 +479,66 @@ export function TestButton({onClick, disabled, disabledTooltip, label}: TestButt
return button;
}
// True when an expression compares against the accessed channel's attributes.
// Such a rule can only be tested against a concrete channel's values, so the
// test modal must resolve one — the editor's own scope, or a channel picked in
// the modal's first step.
export function referencesResourceAttributes(expression: string): boolean {
// Strip quoted string literals first so a value like
// "resource.attributes.minClearance" is not mistaken for an actual
// attribute reference (which would wrongly force a test channel).
// Simple quote stripping; doesn't handle escaped quotes inside a literal,
// which these editors never emit — parse the AST if that ever changes.
const withoutLiterals = expression.replace(/'[^']*'|"[^"]*"/g, '');
return withoutLiterals.includes(RESOURCE_ATTRIBUTES_PREFIX);
}
interface TestResultsProps {
expression: string;
/** Channel to resolve resource.attributes.* against, when the editor has
* one of its own (channel settings). When absent and the rule references
* resource.attributes.*, the modal opens a channel-picker step first and
* threads the chosen id into the search. */
channelId?: string;
teamId?: string;
isStacked?: boolean;
onExited: () => void;
/** Plugin override for the members search, forwarded from
* CELEditorActions.searchUsers. When provided it replaces the built-in
* searchUsersForExpression thunk. The picker's chosen channel id is
* threaded in as the trailing arg so a resource.attributes.* rule can be
* resolved against it (the override may ignore it if it resolves its own). */
searchUsers?: (expression: string, term: string, after: string, limit: number, channelId?: string) => Promise<ActionResult<AccessControlTestResult>>;
}
// The built-in expression test/simulate results modal.
export function TestResults({expression, channelId, teamId, isStacked, onExited, searchUsers}: TestResultsProps): JSX.Element {
const requireChannel = !channelId && referencesResourceAttributes(expression);
return (
<TestResultsModal
onExited={onExited}
isStacked={isStacked}
requireChannel={requireChannel}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number, pickedChannelId?: string) => {
if (searchUsers) {
// Wrap in a thunk so TestResultsModal can dispatch it unchanged.
// Thread the picker's channel (falling back to the editor's own
// scope) so a resource.attributes.* rule resolves against it —
// without this, such a rule tested here fails to sqlize server-side.
const search = searchUsers;
return () => search(expression, term, after, limit, pickedChannelId ?? channelId);
}
return searchUsersForExpression(expression, term, after, limit, pickedChannelId ?? channelId, teamId);
},
}}
/>
);
}
export function AddAttributeButton({onClick, disabled}: AddAttributeButtonProps): JSX.Element {
return (
<Button
@@ -46,7 +46,7 @@ const AttributeLabel = ({displayName, name}: AttributeLabelProps) => (
);
// Define AttributeIcon outside the main component
const AttributeIcon = (props: IconProps & {attribute?: UserPropertyField}) => {
export const AttributeIcon = (props: IconProps & {attribute?: UserPropertyField}) => {
const {attribute, ...iconProps} = props;
if (attribute) {
const valueType = attribute.attrs?.value_type;
@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {IntlShape} from 'react-intl';
import {CheckIcon, CodeBracketsIcon} from '@mattermost/compass-icons/components';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import * as Menu from 'components/menu';
import {getUserPropertyFieldLabel} from 'utils/properties';
import {AttributeIcon} from './attribute_selector_menu';
// Renders the CHANNEL ATTRIBUTES section of the consolidated right-hand-side
// dropdown: the comparable channel fields the row's user attribute may be
// compared against, as a single-select radio list with a checkmark on the
// current target. Selecting one switches the row from a literal value to a
// resource.attributes.<name> target (the caller clears the literal values).
//
// Returned as a flat array (not a wrapper component) so the items stay direct
// children of the MUI menu list — matching the option-list render — which keeps
// keyboard navigation working.
export function channelAttributeMenuItems(
channelFields: UserPropertyField[],
selectedName: string | undefined,
onSelect: (name: string) => void,
formatMessage: IntlShape['formatMessage'],
): React.ReactNode[] {
if (channelFields.length === 0) {
return [];
}
const items: React.ReactNode[] = [
<Menu.Separator key='channel-attr-separator'/>,
<Menu.Title
key='channel-attr-title'
role='presentation'
>
{formatMessage({
id: 'admin.access_control.table_editor.rhs.channel_attributes_section',
defaultMessage: 'Channel attributes',
})}
</Menu.Title>,
];
for (const field of channelFields) {
const isSelected = field.name === selectedName;
items.push(
<Menu.Item
id={`channel-attr-${field.id}`}
key={`channel-attr-${field.id}`}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={isSelected}
onClick={() => onSelect(field.name)}
leadingElement={
<AttributeIcon
attribute={field}
size={18}
/>
}
labels={<span>{getUserPropertyFieldLabel(field)}</span>}
trailingElements={isSelected && <CheckIcon/>}
/>,
);
}
return items;
}
// The button label shown when the row compares against a channel attribute
// (target mode): a "[] Channel: X" chip. The bracket glyph marks it
// as a channel-attribute reference (as opposed to a literal value). Rendered as
// the first child of the button's inner wrapper, next to the chevron.
export function SelectedChannelAttributeLabel({field, fallbackName}: {field?: UserPropertyField; fallbackName: string}): JSX.Element {
return (
<span className='value-selector-menu-button__target-label'>
{/* 14, not 12: the glyph fills 16 of the icon's 24-unit viewBox, so
14 renders the brackets at the design's 9px. */}
<CodeBracketsIcon size={14}/>
{/* Own element so a long display name ellipsizes: text-overflow
applies to the text's own box, not to the chip's flex items. */}
<span className='value-selector-menu-button__target-label-text'>
<FormattedMessage
id='admin.access_control.table_editor.rhs.channel_target_label'
defaultMessage='Channel: {name}'
values={{name: field ? getUserPropertyFieldLabel(field) : fallbackName}}
/>
</span>
</span>
);
}
@@ -7,14 +7,15 @@ import {useIntl} from 'react-intl';
import {CheckIcon, ChevronDownIcon, CloseIcon} from '@mattermost/compass-icons/components';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import * as Menu from 'components/menu';
import './selector_menus.scss';
import {channelAttributeMenuItems, SelectedChannelAttributeLabel} from './channel_attribute_target';
import MaskedChip from './masked_chip';
// MultiValueSelector handles selection of multiple values (operator 'in')
const MultiValueSelector = ({
values,
disabled,
@@ -23,6 +24,9 @@ const MultiValueSelector = ({
allowCreateValue = false,
placeholder,
hasMaskedValues = false,
channelFields = [],
targetAttribute,
onSelectTarget,
}: {
values: string[];
disabled: boolean;
@@ -31,11 +35,17 @@ const MultiValueSelector = ({
allowCreateValue?: boolean;
placeholder?: string;
hasMaskedValues?: boolean;
channelFields?: UserPropertyField[];
targetAttribute?: string;
onSelectTarget?: (name: string) => void;
}) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const hasOptions = options.length > 0;
const hasChannelFields = channelFields.length > 0;
const inTargetMode = Boolean(targetAttribute);
const selectedTarget = inTargetMode ? channelFields.find((cf) => cf.name === targetAttribute) : undefined;
const actualAllowCreateForMenu = hasOptions ? allowCreateValue : true;
// Filter logic for options
@@ -159,7 +169,14 @@ const MultiValueSelector = ({
}),
children: (
<span className='value-selector-menu-button__inner-wrapper'>
{cellContents}
{inTargetMode ? (
<SelectedChannelAttributeLabel
field={selectedTarget}
fallbackName={targetAttribute || ''}
/>
) : (
cellContents
)}
<ChevronDownIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.5)'
@@ -191,6 +208,14 @@ const MultiValueSelector = ({
onChange={onFilterChange}
onKeyDown={handleInputKeyDownForMenu}
/>
{hasChannelFields && (
<Menu.Title role='presentation'>
{formatMessage({
id: 'admin.access_control.table_editor.rhs.values_section',
defaultMessage: 'Values',
})}
</Menu.Title>
)}
{filteredOptions.map((option) => {
const name = option.name || '';
const id = option.id || name;
@@ -224,6 +249,7 @@ const MultiValueSelector = ({
</span>}
/>
)}
{onSelectTarget && channelAttributeMenuItems(channelFields, targetAttribute, onSelectTarget, formatMessage)}
</Menu.Container>
</div>
);
@@ -1,3 +1,5 @@
@use 'utils/mixins';
.select-attribute-mui-menu {
max-height: 400px;
overflow-y: auto;
@@ -5,21 +7,39 @@
.field-selector-menu-button {
width: 100%;
// 40px is the floor, not the height: the value trigger wraps its selected-value
// chips onto new lines (see &__multi-values-container) and must grow to fit them
// rather than clip under overflow: hidden.
height: auto;
min-height: 40px;
justify-content: start;
border: none;
font-weight: normal;
&:hover {
// These triggers also carry `btn btn-transparent`, whose :hover/:active paint
// the whole button solid blue (var(--button-bg)) with white text which would
// swallow the channel-attribute chip. Keep the hover a subtle neutral wash.
// The extra .btn.btn-transparent qualifiers out-specify _buttons.scss.
&:hover,
&.btn.btn-transparent:hover,
&.btn.btn-transparent:active {
background: rgba(var(--center-channel-color-rgb), 0.04);
color: inherit;
}
&:focus,
// Menu-open state: a slightly deeper neutral than the hover wash. Never the
// accent tint the only blue in a row belongs to the channel-attribute
// token, and a blue cell background competes with it.
&[aria-expanded="true"] {
background: rgba(var(--button-bg-rgb), 0.08);
background: rgba(var(--center-channel-color-rgb), 0.08);
}
// Focus gets the standard ring rather than a background fill: clicking a
// trigger leaves it focused, so a fill would stick on the cell long after
// the menu closed and read as "selected". The mixin suppresses the ring for
// pointer focus and keeps it for keyboard.
@include mixins.button-focus;
&.disabled {
cursor: not-allowed;
opacity: 0.6;
@@ -45,13 +65,9 @@
}
.value-selector-menu-button {
// Chips wrap onto new lines within the fixed-width Values cell so the
// table never expands horizontally past the modal. The row grows
// vertically to fit however many values are added.
&__multi-values-container {
display: flex;
overflow: hidden;
min-width: 0;
flex-grow: 1;
flex-wrap: wrap;
gap: 2px;
@@ -60,10 +76,37 @@
&__inner-wrapper {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
}
// Shown in the button when the RHS compares against a channel attribute
// (target mode): a "[] Channel: X" token in the accent color,
// styled as a chip so it reads as a variable reference rather than a value.
&__target-label {
display: inline-flex;
overflow: hidden;
min-width: 0; // shrink inside the button's flex row instead of clipping
align-items: center;
padding: 4px 6px;
border-radius: 4px;
background: rgba(var(--button-bg-rgb), 0.12);
color: var(--button-bg);
font-size: 12px;
font-weight: 600;
gap: 4px;
line-height: 12px;
white-space: nowrap;
> svg {
flex-shrink: 0;
}
}
&__target-label-text {
overflow: hidden;
text-overflow: ellipsis;
}
}
.attribute-selector-platform-icon {
@@ -99,7 +142,6 @@
.select__multi-value {
display: flex;
height: 24px;
flex: 0 0 auto;
align-items: center;
border-radius: 4px;
margin: 2px;
@@ -7,11 +7,13 @@ import {useIntl} from 'react-intl';
import {CheckIcon, ChevronDownIcon} from '@mattermost/compass-icons/components';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import * as Menu from 'components/menu';
import Constants from 'utils/constants';
import {channelAttributeMenuItems, SelectedChannelAttributeLabel} from './channel_attribute_target';
import MaskedChip from './masked_chip';
import './selector_menus.scss';
@@ -25,6 +27,9 @@ const SingleValueSelector = ({
allowCreateValue = false,
placeholder,
hasMaskedValues = false,
channelFields = [],
targetAttribute,
onSelectTarget,
}: {
value: string;
disabled: boolean;
@@ -33,6 +38,9 @@ const SingleValueSelector = ({
allowCreateValue?: boolean;
placeholder?: string;
hasMaskedValues?: boolean;
channelFields?: UserPropertyField[];
targetAttribute?: string;
onSelectTarget?: (name: string) => void;
}) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
@@ -40,6 +48,9 @@ const SingleValueSelector = ({
const [isEditing, setIsEditing] = useState(false);
const hasOptions = options.length > 0;
const hasChannelFields = channelFields.length > 0;
const inTargetMode = Boolean(targetAttribute);
const selectedTarget = inTargetMode ? channelFields.find((cf) => cf.name === targetAttribute) : undefined;
// Simple input logic for attributes without options
const commitInputValue = useCallback(() => {
@@ -58,6 +69,19 @@ const SingleValueSelector = ({
}
}, [commitInputValue]);
// The same free-text entry as the bare input, but living inside the menu
// (atop the CHANNEL ATTRIBUTES list) — so keystrokes must not bubble to the
// menu's own key handling.
const handleKeyDownMenuInput = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Tab') {
e.stopPropagation();
}
if (e.key === 'Enter') {
e.preventDefault();
commitInputValue();
}
}, [commitInputValue]);
// Filter logic for options
const onFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
@@ -104,7 +128,7 @@ const SingleValueSelector = ({
// Placed AFTER hook declarations so hook order stays stable when the
// masked state changes between renders (e.g., parent re-renders after
// a sibling rule is deleted).
if (hasMaskedValues && !value) {
if (hasMaskedValues && !value && !inTargetMode) {
return (
<div className='values-editor'>
<div className='value-selector-menu-button__multi-values-container'>
@@ -114,8 +138,13 @@ const SingleValueSelector = ({
);
}
if (!hasOptions) {
// For attributes without options, show simple input field
if (!hasOptions && !hasChannelFields && !inTargetMode) {
// For attributes without options and no channel targets, show a simple
// inline input field. A row already targeting a channel attribute is
// excluded: the target list can be empty while the target is set (fields
// still loading, the feature turned off, the attribute deleted), and the
// bare input would hide the target and silently drop it on the next
// keystroke.
return (
<div className='values-editor'>
<input
@@ -142,7 +171,7 @@ const SingleValueSelector = ({
);
}
// For attributes with options, show dropdown menu
// Consolidated dropdown: literal value(s) atop a CHANNEL ATTRIBUTES list.
const actualTextDisplayed = value || placeholder || defaultPlaceholder;
const useStyle = actualTextDisplayed === defaultPlaceholder;
@@ -156,11 +185,18 @@ const SingleValueSelector = ({
}),
children: (
<span className='value-selector-menu-button__inner-wrapper'>
<span
className={classNames({'value-selector-menu-button__placeholder': useStyle})}
>
{actualTextDisplayed}
</span>
{inTargetMode ? (
<SelectedChannelAttributeLabel
field={selectedTarget}
fallbackName={targetAttribute || ''}
/>
) : (
<span
className={classNames({'value-selector-menu-button__placeholder': useStyle})}
>
{actualTextDisplayed}
</span>
)}
<ChevronDownIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.5)'
@@ -176,23 +212,55 @@ const SingleValueSelector = ({
className: 'select-value-mui-menu',
}}
>
<Menu.InputItem
key='filter_values'
id='filter_values'
type='text'
placeholder={formatMessage(allowCreateValue ? {
id: 'admin.access_control.table_editor.selector.filter_or_create',
defaultMessage: 'Search or create value...',
} : {
id: 'admin.access_control.table_editor.selector.filter_values',
defaultMessage: 'Search values...',
})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
onKeyDown={handleInputKeyDownForMenu}
/>
{filteredOptions.map((option) => {
{hasOptions ? (
<Menu.InputItem
key='filter_values'
id='filter_values'
type='text'
placeholder={formatMessage(allowCreateValue ? {
id: 'admin.access_control.table_editor.selector.filter_or_create',
defaultMessage: 'Search or create value...',
} : {
id: 'admin.access_control.table_editor.selector.filter_values',
defaultMessage: 'Search values...',
})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
onKeyDown={handleInputKeyDownForMenu}
/>
) : (
<Menu.InputItem
key='value_text'
id='value_text'
type='text'
placeholder={placeholder || formatMessage({
id: 'admin.access_control.table_editor.value.placeholder',
defaultMessage: 'Add value...',
})}
className='attribute-selector-search'
value={isEditing ? inputValue : value}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => {
setIsEditing(true);
if (value) {
setInputValue(value);
}
}}
onBlur={commitInputValue}
onKeyDown={handleKeyDownMenuInput}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
/>
)}
{hasOptions && hasChannelFields && (
<Menu.Title role='presentation'>
{formatMessage({
id: 'admin.access_control.table_editor.rhs.values_section',
defaultMessage: 'Values',
})}
</Menu.Title>
)}
{hasOptions && filteredOptions.map((option) => {
const name = option.name || '';
const id = option.id || name;
const isSelected = value === name;
@@ -212,7 +280,7 @@ const SingleValueSelector = ({
/>
);
})}
{allowCreateValue && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && (
{hasOptions && allowCreateValue && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && (
<Menu.Item
id='create-value-option'
key='create-value-option'
@@ -225,6 +293,7 @@ const SingleValueSelector = ({
</span>}
/>
)}
{onSelectTarget && channelAttributeMenuItems(channelFields, targetAttribute, onSelectTarget, formatMessage)}
</Menu.Container>
</div>
);
@@ -6,11 +6,6 @@
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
border-collapse: collapse;
// Fixed layout keeps columns at their declared widths so long value
// lists wrap within the Values cell instead of stretching the table
// beyond the modal width.
table-layout: fixed;
}
th {
@@ -77,4 +72,20 @@
&__add-button-container {
padding: 8px;
}
// Value cell holds an optional right-hand-side kind toggle (literal value
// vs. channel attribute) next to the value/attribute selector.
&__value-cell {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
// The selector fills the width and must be allowed to shrink: its field
// defaults to width:100%, which overflows the cell's flex min-content
// floor. min-width:0 lets a long value collapse instead of pushing out.
> * {
min-width: 0;
}
}
}
@@ -67,6 +67,59 @@ describe('parseExpression', () => {
]);
});
test('maps a resource-attribute RHS to a targetAttribute row', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.clearance',
operator: '>=',
value: 'resource.attributes.minClearance',
value_type: 1, // attribute reference, not a literal
attribute_type: 'rank',
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'clearance',
attribute_object_type: 'user',
operator: 'is at least',
values: [],
attribute_type: 'rank',
hasMaskedValues: false,
targetAttribute: 'minClearance',
},
]);
});
test('a literal RHS that looks like a path stays a literal value', () => {
// value_type 0 (literal) must not be treated as a resource target even
// if the string happens to start with resource.attributes.
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.note',
operator: '==',
value: 'resource.attributes.minClearance',
value_type: 0,
attribute_type: 'text',
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'note',
attribute_object_type: 'user',
operator: 'is',
values: ['resource.attributes.minClearance'],
attribute_type: 'text',
hasMaskedValues: false,
},
]);
});
test('handles "in" operator with multiple values', () => {
const ast: AccessControlVisualAST = {
conditions: [
@@ -327,6 +380,61 @@ describe('parseExpression with multiselect attributes', () => {
},
]);
});
test('maps a hasAnyOf channel-attribute target to a targetAttribute row', () => {
// A multiselect list-vs-list comparison is stored as a member call, so
// the visual AST surfaces its RHS as an attribute reference (value_type
// 1) pointing at resource.attributes.* rather than a literal list.
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.programs',
operator: 'hasAnyOf',
value: 'resource.attributes.channelPrograms',
value_type: 1,
attribute_type: 'multiselect',
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'programs',
attribute_object_type: 'user',
operator: 'has any of',
values: [],
attribute_type: 'multiselect',
hasMaskedValues: false,
targetAttribute: 'channelPrograms',
},
]);
});
test('maps a hasAllOf channel-attribute target to a targetAttribute row', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.skills',
operator: 'hasAllOf',
value: 'resource.attributes.requiredSkills',
value_type: 1,
attribute_type: 'multiselect',
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'skills',
attribute_object_type: 'user',
operator: 'has all of',
values: [],
attribute_type: 'multiselect',
hasMaskedValues: false,
targetAttribute: 'requiredSkills',
},
]);
});
});
describe('parseExpression with session attributes', () => {
@@ -569,6 +677,81 @@ describe('rowToCEL', () => {
expect(cel).toBe('user.attributes.clearance == "TopSecret"');
});
test('resource target on "is" compares user attr to the channel attr', () => {
const cel = rowToCEL({
attribute: 'team',
operator: 'is',
values: [],
attribute_type: 'select',
hasMaskedValues: false,
targetAttribute: 'owningTeam',
});
expect(cel).toBe('user.attributes.team == resource.attributes.owningTeam');
});
test('resource target on a ranked operator preserves the ordinal comparison', () => {
const cel = rowToCEL({
attribute: 'clearance',
operator: 'is at least',
values: [],
attribute_type: 'rank',
hasMaskedValues: false,
targetAttribute: 'minClearance',
});
expect(cel).toBe('user.attributes.clearance >= resource.attributes.minClearance');
});
test('resource target is ignored for non-comparison operators', () => {
// "in" is a list operator; a resource target has no meaning there, so
// the literal-value path is used instead.
const cel = rowToCEL({
attribute: 'department',
operator: 'in',
values: ['Eng'],
attribute_type: 'select',
hasMaskedValues: false,
targetAttribute: 'shouldBeIgnored',
});
expect(cel).toBe('user.attributes.department in ["Eng"]');
});
test('has_any_of with a channel-attribute target emits the member-function form', () => {
const cel = rowToCEL({
attribute: 'programs',
operator: 'has any of',
values: [],
attribute_type: 'multiselect',
hasMaskedValues: false,
targetAttribute: 'channelPrograms',
});
expect(cel).toBe('user.attributes.programs.hasAnyOf(resource.attributes.channelPrograms)');
});
test('has_all_of with a channel-attribute target emits the member-function form', () => {
const cel = rowToCEL({
attribute: 'skills',
operator: 'has all of',
values: [],
attribute_type: 'multiselect',
hasMaskedValues: false,
targetAttribute: 'requiredSkills',
});
expect(cel).toBe('user.attributes.skills.hasAllOf(resource.attributes.requiredSkills)');
});
test('has_any_of keeps the literal in-chain when there is no target', () => {
// The channel-attribute-target form and the literal-value form share the
// same operator; only the presence of targetAttribute selects between them.
const cel = rowToCEL({
attribute: 'programs',
operator: 'has any of',
values: ['Dragon', 'Phoenix'],
attribute_type: 'multiselect',
hasMaskedValues: false,
});
expect(cel).toBe('("Dragon" in user.attributes.programs || "Phoenix" in user.attributes.programs)');
});
test('"contains" operator produces method call', () => {
const cel = rowToCEL({
attribute: 'email',
@@ -716,6 +899,53 @@ describe('rowToCEL', () => {
});
});
describe('multiselect target round-trips (parseExpression -> rowToCEL)', () => {
// A multiselect user attribute may be compared against a channel attribute
// (the member-function form) or against literal option values (the in-chain
// form). Both operators must survive a full AST -> row -> CEL round-trip in
// each form so a saved rule re-renders and re-serializes identically.
test.each(['hasAnyOf', 'hasAllOf'])('%s against a channel-attribute target', (celFn) => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.programs',
operator: celFn,
value: 'resource.attributes.channelPrograms',
value_type: 1,
attribute_type: 'multiselect',
},
],
};
const rows = parseExpression(ast);
expect(rows[0].targetAttribute).toBe('channelPrograms');
expect(rows[0].values).toEqual([]);
expect(rowToCEL(rows[0])).toBe(`user.attributes.programs.${celFn}(resource.attributes.channelPrograms)`);
});
test.each([
['hasAnyOf', '("Dragon" in user.attributes.programs || "Phoenix" in user.attributes.programs)'],
['hasAllOf', '"Dragon" in user.attributes.programs && "Phoenix" in user.attributes.programs'],
])('%s against literal values keeps the in-chain form', (celFn, expected) => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.programs',
operator: celFn,
value: ['Dragon', 'Phoenix'],
value_type: 0,
attribute_type: 'multiselect',
},
],
};
const rows = parseExpression(ast);
expect(rows[0].targetAttribute).toBeUndefined();
expect(rows[0].values).toEqual(['Dragon', 'Phoenix']);
expect(rowToCEL(rows[0])).toBe(expected);
});
});
describe('parseExpression with native user attributes', () => {
test('parses native string attribute (user.email)', () => {
const ast: AccessControlVisualAST = {
@@ -1086,6 +1316,25 @@ describe('isSimpleCondition', () => {
expect(isSimpleCondition(`user.attributes.clearance ${op} "Secret"`)).toBe(true);
});
test.each(['==', '!=', '>=', '>', '<=', '<'])('comparison %s against a resource attribute is simple', (op) => {
expect(isSimpleCondition(`user.attributes.clearance ${op} resource.attributes.minClearance`)).toBe(true);
});
test.each(['hasAnyOf', 'hasAllOf'])('%s against a resource attribute is simple', (fn) => {
expect(isSimpleCondition(`user.attributes.programs.${fn}(resource.attributes.channelPrograms)`)).toBe(true);
});
test('rejects hasAnyOf/hasAllOf with a literal (non-resource) argument', () => {
// The engine only ever produces the resource-target form; a literal
// argument is not a shape the table editor round-trips.
expect(isSimpleCondition('user.attributes.programs.hasAnyOf("Dragon")')).toBe(false);
});
test('rejects a resource attribute on the left side', () => {
// The left side must always be the requesting user's attribute.
expect(isSimpleCondition('resource.attributes.minClearance == "Secret"')).toBe(false);
});
test('rejects function calls', () => {
expect(isSimpleCondition('size(user.attributes.roles) > 0')).toBe(false);
});
@@ -6,9 +6,8 @@ import {FormattedMessage, useIntl} from 'react-intl';
import type {AccessControlTestResult, AccessControlVisualAST} from '@mattermost/types/access_control';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {SESSION_ATTRIBUTES_OBJECT_TYPE, isSessionAttributeField} from '@mattermost/types/properties_user';
import {CHANNEL_ATTRIBUTES_OBJECT_TYPE, SESSION_ATTRIBUTES_OBJECT_TYPE, isSessionAttributeField} from '@mattermost/types/properties_user';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {CPA_FIELD_NAME_PATTERN} from 'utils/properties';
@@ -19,8 +18,7 @@ import type {TableRow} from './value_selector_menu';
import ValueSelectorMenu from './value_selector_menu';
import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal';
import {AddAttributeButton, TestButton, HelpText, OPERATOR_CONFIG, OPERATOR_LABELS, OperatorLabel, isMultiValueOperator, isMultiselectOperator, isRankOperator, isNativeMethodOperator, isFieldAdvertisedOperator, celPathFor, isNativeField, isNativeBooleanField, hasControlledAttributeValues, allowedOperatorLabelsForField, defaultOperatorForField, isValidYoungerThanDaysValue, valuePlaceholderForOperator, SESSION_ATTRIBUTE_CEL_PREFIX, USER_ATTRIBUTE_CEL_PREFIX} from '../shared';
import {AddAttributeButton, TestButton, TestResults, HelpText, OPERATOR_CONFIG, OPERATOR_LABELS, OperatorLabel, isMultiValueOperator, isMultiselectOperator, isRankOperator, isNativeMethodOperator, isFieldAdvertisedOperator, celPathFor, isNativeField, isNativeBooleanField, hasControlledAttributeValues, allowedOperatorLabelsForField, defaultOperatorForField, isValidYoungerThanDaysValue, valuePlaceholderForOperator, RESOURCE_ATTRIBUTES_PREFIX, VISUAL_AST_ATTRIBUTE_VALUE_TYPE, SESSION_ATTRIBUTE_CEL_PREFIX, USER_ATTRIBUTE_CEL_PREFIX} from '../shared';
import './table_editor.scss';
@@ -43,12 +41,28 @@ export function rowToCEL(row: TableRow): string {
// Without this guard the condition would be filtered out by updateExpression,
// the empty expression would be sent to the server, and buildCELFromConditions
// would return "true" — making the policy wide-open (security regression).
if (row.hasMaskedValues && row.values.length === 0) {
if (row.hasMaskedValues && row.values.length === 0 && !row.targetAttribute) {
return `${attributeExpr} in []`;
}
const config = OPERATOR_CONFIG[row.operator];
// Right-hand side is the accessed channel's attribute, not a literal:
// user.attributes.X <op> resource.attributes.Y. Only comparison operators
// (is / is not / the ranked ordinals) take an attribute target.
if (row.targetAttribute && config && config.type === 'comparison') {
return `${attributeExpr} ${config.celOp} resource.attributes.${row.targetAttribute}`;
}
// A multiselect list-vs-list comparison against a channel attribute is stored
// verbatim as a member-function call the engine holds as-is:
// user.attributes.X.hasAnyOf(resource.attributes.Y). The literal-value
// in-chain (below) still applies when the row has no targetAttribute.
if (row.targetAttribute && isMultiselectOperator(row.operator)) {
const fn = row.operator === OperatorLabel.HAS_ALL_OF ? 'hasAllOf' : 'hasAnyOf';
return `${attributeExpr}.${fn}(resource.attributes.${row.targetAttribute})`;
}
// native_method (e.g. youngerThanDays) takes an unquoted integer argument.
// A valid non-negative integer is normalized (stripping leading zeros);
// anything else is emitted verbatim so the invalid rule surfaces an error on
@@ -123,8 +137,9 @@ export interface TableEditorProps {
actions: {
getVisualAST: (expr: string) => Promise<ActionResult>;
/** Overrides the searchUsersForExpression thunk backing the built-in TestResultsModal. */
searchUsers?: (expression: string, term: string, after: string, limit: number) => Promise<ActionResult<AccessControlTestResult>>;
/** Overrides the searchUsersForExpression thunk backing the built-in TestResultsModal.
* Receives the test modal's chosen channel id as the trailing arg. */
searchUsers?: (expression: string, term: string, after: string, limit: number, channelId?: string) => Promise<ActionResult<AccessControlTestResult>>;
};
// Props for user self-exclusion detection
@@ -217,7 +232,9 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] =
// Extracts the attribute name, removing the CEL namespace prefix. The
// two-segment forms (user.attributes.<name>, user.session.<name>) are
// matched before the single-segment native form (user.<name>).
// matched before the single-segment native form (user.<name>). The left
// side is always the requesting user's attribute; a resource.attributes.*
// reference only appears on the right (captured below as targetAttribute).
if (node.attribute.startsWith(USER_ATTRIBUTE_CEL_PREFIX)) {
attr = node.attribute.slice(USER_ATTRIBUTE_CEL_PREFIX.length);
} else if (node.attribute.startsWith(SESSION_ATTRIBUTE_CEL_PREFIX)) {
@@ -244,13 +261,24 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] =
op = OperatorLabel.IS_EXACTLY;
}
// The visual AST carries typed values: native booleans arrive as JS
// booleans and youngerThanDays arguments as numbers. Normalize to the
// string form the table rows store, and remember booleans so rowToCEL
// re-emits them unquoted.
// A value_type of "attribute" whose RHS is a resource.attributes.*
// selector means the condition compares the user attribute to the
// accessed channel's attribute. Capture the target field; values are
// unused in that case.
//
// Otherwise the visual AST carries typed values: native booleans arrive
// as JS booleans and youngerThanDays arguments as numbers. Normalize to
// the string form the table rows store, and remember booleans so
// rowToCEL re-emits them unquoted.
let targetAttribute: string | undefined;
let isBoolean = false;
let values: string[];
if (Array.isArray(node.value)) {
if (node.value_type === VISUAL_AST_ATTRIBUTE_VALUE_TYPE &&
typeof node.value === 'string' &&
node.value.startsWith(RESOURCE_ATTRIBUTES_PREFIX)) {
targetAttribute = node.value.slice(RESOURCE_ATTRIBUTES_PREFIX.length);
values = [];
} else if (Array.isArray(node.value)) {
values = node.value.map((v) => String(v));
} else if (typeof node.value === 'boolean') {
isBoolean = true;
@@ -270,14 +298,17 @@ export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] =
hasMaskedValues: node.has_masked_values === true,
};
// Only set the native flags when they apply so custom-profile-attribute
// rows keep their original shape.
// Only set the native/target flags when they apply so custom-profile-
// attribute rows keep their original shape.
if (isNative) {
tableRow.isNative = true;
}
if (isBoolean) {
tableRow.isBoolean = true;
}
if (targetAttribute) {
tableRow.targetAttribute = targetAttribute;
}
tableRows.push(tableRow);
}
@@ -322,21 +353,68 @@ function TableEditor({
// Derived state: whether any row has masked values
const hasMaskedRows = useMemo(() => rows.some((r) => r.hasMaskedValues), [rows]);
// The autocomplete returns both the requesting user's attributes and the
// accessed channel's (resource) attributes, tagged by object_type. The left
// picker only ever offers user attributes; channel attributes are offered
// as comparison targets on the right side (resource.attributes.*).
const {userFields, resourceAttributes} = useMemo(() => {
const uf: UserPropertyField[] = [];
const ra: UserPropertyField[] = [];
for (const f of userAttributes) {
if (f.object_type === CHANNEL_ATTRIBUTES_OBJECT_TYPE) {
ra.push(f);
} else {
uf.push(f);
}
}
return {userFields: uf, resourceAttributes: ra};
}, [userAttributes]);
// Channel attributes the given user attribute may be compared against.
// Same field type is required; for option-based types (select/multiselect/
// rank) the two must also share an option scale, enforced structurally by
// linking to the same template field (equal, non-null linked_field_id).
// Non-comparable pairs are also rejected server-side at save/check.
const comparableChannelFields = useCallback((userField?: UserPropertyField): UserPropertyField[] => {
if (!userField) {
return [];
}
const optionBased = userField.type === 'select' || userField.type === 'multiselect' || userField.type === 'rank';
return resourceAttributes.filter((cf) => {
if (cf.type !== userField.type) {
return false;
}
if (optionBased) {
return Boolean(userField.linked_field_id) && userField.linked_field_id === cf.linked_field_id;
}
return true;
});
}, [resourceAttributes]);
// Prevents getVisualAST re-parse when expression change is from internal row editing.
const isInternalChange = React.useRef(false);
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
return undefined;
}
if (!value || value.trim() === '') {
setRows([]);
return;
return undefined;
}
// Guard against out-of-order resolution: if `value` changes again (or the
// component unmounts) before this getVisualAST resolves, ignore the stale
// result so a previous parse can't overwrite the current rows (which would
// surface as a row showing another attribute's values/operators).
let cancelled = false;
actions.getVisualAST(value).then((result) => {
if (cancelled) {
return;
}
if (result.error) {
setRows([]);
@@ -349,6 +427,9 @@ function TableEditor({
setRows(parseExpression(result.data));
}).catch((err) => {
if (cancelled) {
return;
}
setRows([]);
if (onValidate) {
onValidate(false);
@@ -359,6 +440,10 @@ function TableEditor({
onParseError(err.message);
}
});
return () => {
cancelled = true;
};
}, [value]);
useEffect(() => {
@@ -386,7 +471,8 @@ function TableEditor({
const updateExpression = useCallback((newRows: TableRow[]) => {
// Include masked rows with no visible values: rowToCEL will emit an "in []"
// placeholder so the backend merge can restore the hidden values on save.
const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute && (row.values.length > 0 || row.hasMaskedValues));
// A resource-target row is complete without literal values.
const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute && (row.values.length > 0 || row.hasMaskedValues || row.targetAttribute));
const expr = rowsThatCanFormExpressions.map((row) => rowToCEL(row)).join(' && ');
@@ -403,11 +489,11 @@ function TableEditor({
}, [onChange, onValidate]);
const findFirstAvailableAttribute = useCallback(() => {
return findFirstAvailableAttributeFromList(userAttributes, enableUserManagedAttributes);
}, [userAttributes, enableUserManagedAttributes]);
return findFirstAvailableAttributeFromList(userFields, enableUserManagedAttributes);
}, [userFields, enableUserManagedAttributes]);
const addRow = useCallback(() => {
if (userAttributes.length === 0) {
if (userFields.length === 0) {
onParseError('No user attributes available. Please ensure ABAC is properly configured and you have the necessary permissions.');
return;
}
@@ -418,6 +504,11 @@ function TableEditor({
return;
}
// Every mutator below computes the next rows from `rows` and runs its side
// effects (updateExpression, which calls onChange/onValidate and flips the
// isInternalChange ref) outside setRows. React may invoke a state updater
// more than once, so a side effect inside one can fire onChange twice and
// desync that ref into a spurious getVisualAST reparse.
const newRow: TableRow = {
attribute: firstAvailableAttribute.name,
attribute_object_type: firstAvailableAttribute.object_type,
@@ -432,7 +523,7 @@ function TableEditor({
setRows(newRows);
setAutoOpenAttributeMenuForRow(newRows.length - 1);
updateExpression(newRows);
}, [userAttributes, updateExpression, findFirstAvailableAttribute, rows]);
}, [userFields, updateExpression, findFirstAvailableAttribute, rows]);
const removeRow = useCallback((index: number) => {
const newRows = rows.toSpliced(index, 1);
@@ -463,6 +554,9 @@ function TableEditor({
if (attributeChanged) {
newRows[index].values = [];
// A resource target is type-specific to the old attribute; drop it.
newRows[index].targetAttribute = undefined;
const newType = newAttributeObj?.type || '';
newRows[index].attribute_type = newType;
newRows[index].attribute_object_type = newObjectType;
@@ -501,10 +595,8 @@ function TableEditor({
const isMulti = isMultiValueOperator(newOperator);
if (isMulti && !wasMulti) {
// Transitioning TO a multi-value operator FROM a single-value operator:
newValues = newValues.map((v) => v.trim()).filter((v) => v !== '');
} else if (!isMulti && wasMulti) {
// Transitioning TO a single-value operator FROM a multi-value operator:
if (newValues.length > 1) {
newValues = [newValues[0]];
}
@@ -517,13 +609,34 @@ function TableEditor({
values: newValues,
};
// A resource target is valid only for comparison operators and the
// multiselect list operators (has any of / has all of); drop it when
// moving to any other operator (e.g. "in", "starts with").
if (OPERATOR_CONFIG[newOperator]?.type !== 'comparison' && !isMultiselectOperator(newOperator)) {
newRows[index].targetAttribute = undefined;
}
setRows(newRows);
updateExpression(newRows);
}, [updateExpression, rows]);
const updateRowValues = useCallback((index: number, values: string[]) => {
const newRows = [...rows];
newRows[index] = {...newRows[index], values};
// Literal value(s) and a channel-attribute target are mutually
// exclusive: picking a value in the consolidated dropdown drops any
// target the row was comparing against.
newRows[index] = {...newRows[index], values, targetAttribute: undefined};
setRows(newRows);
updateExpression(newRows);
}, [updateExpression, rows]);
// Switch the row's right-hand side to the accessed channel's attribute
// (resource.attributes.*). Literal values are cleared — the two are
// mutually exclusive.
const updateRowTarget = useCallback((index: number, targetAttribute: string) => {
const newRows = [...rows];
newRows[index] = {...newRows[index], targetAttribute, values: []};
setRows(newRows);
updateExpression(newRows);
}, [updateExpression, rows]);
@@ -578,11 +691,27 @@ function TableEditor({
rows.map((row, index) => {
// Resolve by name AND namespace: a CPA and a session
// attribute can share a name, so object_type disambiguates.
const field = userAttributes.find((attr) => attr.name === row.attribute && (attr.object_type || 'user') === (row.attribute_object_type || 'user'));
// The left picker only offers user attributes, so resolve
// against userFields (channel fields are RHS targets only).
const field = userFields.find((attr) => attr.name === row.attribute && (attr.object_type || 'user') === (row.attribute_object_type || 'user'));
const isYoungerThan = row.operator === OperatorLabel.YOUNGER_THAN;
const youngerThanValue = row.values.length > 0 ? row.values[0] : '';
const youngerThanInvalid = isYoungerThan && youngerThanValue.trim() !== '' && !isValidYoungerThanDaysValue(youngerThanValue);
const valuePlaceholder = valuePlaceholderForOperator(row.operator);
// Channel attributes this row's user attribute may be
// compared against (offered as the right-hand side
// alongside literal values).
const targets = comparableChannelFields(field);
// Comparison operators target any comparable channel
// field; the multiselect list operators (has any of /
// has all of) target a multiselect channel field
// (list-vs-list). comparableChannelFields already
// enforces the shared option scale.
const supportsTarget = targets.length > 0 &&
(OPERATOR_CONFIG[row.operator]?.type === 'comparison' || isMultiselectOperator(row.operator));
const cellDisabled = disabled || row.hasMaskedValues;
return (
<tr
key={index}
@@ -592,8 +721,8 @@ function TableEditor({
<AttributeSelectorMenu
currentAttribute={row.attribute}
currentAttributeObjectType={row.attribute_object_type}
availableAttributes={userAttributes}
disabled={disabled || row.hasMaskedValues}
availableAttributes={userFields}
disabled={cellDisabled}
onChange={(attributeId) => updateRowAttribute(index, attributeId)}
menuId={`attribute-selector-menu-${index}`}
buttonId={`attribute-selector-button-${index}`}
@@ -605,40 +734,51 @@ function TableEditor({
<td className='table-editor__cell'>
<OperatorSelectorMenu
currentOperator={row.operator}
disabled={disabled || row.hasMaskedValues}
disabled={cellDisabled}
onChange={(operator) => updateRowOperator(index, operator)}
// Use the row's own type, kept in sync by
// addRow/updateRowAttribute/parseExpression. A name-only
// lookup could resolve the wrong namespace when a user and
// a session attribute share a name.
attributeType={row.attribute_type || undefined}
// Prefer the resolved field's live type over the
// row's stored attribute_type. The stored value is a
// snapshot — from the server visual AST at parse time,
// or the field type when the row was added — and can
// drift from the current attribute definition: a saved
// rank rule whose server AST labeled the attribute
// 'select' would otherwise show the default operator set
// instead of the ranked one. `field` is resolved by name
// AND object_type, so this keeps the namespace
// disambiguation the stored type was introduced for.
attributeType={field?.type || row.attribute_type || undefined}
allowedOperators={allowedOperatorLabelsForField(field)}
/>
</td>
<td className='table-editor__cell'>
<ValueSelectorMenu
row={row}
disabled={disabled || row.hasMaskedValues}
updateValues={(values: string[]) => updateRowValues(index, values)}
options={row.attribute ? field?.attrs?.options || [] : []}
placeholder={valuePlaceholder ? formatMessage(valuePlaceholder) : undefined}
/>
{youngerThanInvalid && (
<div className='table-editor__value-error'>
<FormattedMessage
id='admin.access_control.table_editor.value.days_invalid'
defaultMessage='Enter a whole number of days (e.g. 30).'
/>
</div>
)}
<div className='table-editor__value-cell'>
<ValueSelectorMenu
row={row}
disabled={cellDisabled}
updateValues={(values: string[]) => updateRowValues(index, values)}
options={row.attribute ? field?.attrs?.options || [] : []}
placeholder={valuePlaceholder ? formatMessage(valuePlaceholder) : undefined}
channelFields={supportsTarget ? targets : undefined}
onSelectTarget={(name: string) => updateRowTarget(index, name)}
/>
{youngerThanInvalid && (
<div className='table-editor__value-error'>
<FormattedMessage
id='admin.access_control.table_editor.value.days_invalid'
defaultMessage='Enter a whole number of days (e.g. 30).'
/>
</div>
)}
</div>
</td>
<td className='table-editor__cell-actions'>
<button
type='button'
className='table-editor__row-remove'
onClick={() => requestRemoveRow(index)}
disabled={disabled || row.hasMaskedValues}
disabled={cellDisabled}
aria-label={formatMessage({id: 'admin.access_control.table_editor.remove_row', defaultMessage: 'Remove row'})}
>
<i className='icon icon-trash-can-outline'/>
@@ -657,7 +797,7 @@ function TableEditor({
>
<AddAttributeButton
onClick={addRow}
disabled={disabled || userAttributes.length === 0}
disabled={disabled || userFields.length === 0}
/>
</td>
</tr>
@@ -671,52 +811,45 @@ function TableEditor({
defaultMessage: 'Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).',
})}
/>
<TestButton
onClick={onTestClick ?? (() => setShowTestResults(true))}
disabled={(testButtonDisabled ?? false) || disabled || (!onTestClick && !value) || userWouldBeExcluded || hasMaskedRows}
disabledTooltip={
<div className='access-control-test-controls'>
<TestButton
onClick={onTestClick ?? (() => setShowTestResults(true))}
disabled={(testButtonDisabled ?? false) || disabled || (!onTestClick && !value) || userWouldBeExcluded || hasMaskedRows}
disabledTooltip={
// Precedence: an explicit parent-supplied
// tooltip paired with `testButtonDisabled`
// wins (the parent already chose what the
// user should see and why), then the
// user-excluded message, then any other
// testButtonTooltip the parent passed
// alongside other disable reasons. The
// earlier `userWouldBeExcluded ? … : tooltip`
// ternary silenced parent hints whenever the
// self-exclusion check happened to also
// be true.
(testButtonDisabled && testButtonTooltip) ||
(userWouldBeExcluded ? formatMessage({
id: 'admin.access_control.table_editor.user_excluded_tooltip',
defaultMessage: 'You cannot test access rules that would exclude you from the channel',
}) : testButtonTooltip)
}
label={testButtonLabel}
/>
// Precedence: an explicit parent-supplied tooltip
// paired with `testButtonDisabled` (the parent
// already chose what the user should see and why),
// then the user-excluded message, then any other
// testButtonTooltip the parent passed alongside
// other disable reasons. `testButtonDisabled` must be
// checked first, or the self-exclusion tooltip would
// override an explicit parent tooltip whenever both
// conditions are true.
(testButtonDisabled && testButtonTooltip) ||
(userWouldBeExcluded ? formatMessage({
id: 'admin.access_control.table_editor.user_excluded_tooltip',
defaultMessage: 'You cannot test access rules that would exclude you from the channel',
}) : testButtonTooltip)
}
label={testButtonLabel}
/>
</div>
</div>
{/* Built-in expression-only modal. Suppressed when the parent
* provided an `onTestClick` override (used by the permission-rule
* editor, which renders its own dual-lane simulation modal). */}
* editor, which renders its own dual-lane simulation modal). With
* no channelId, a resource.attributes.* rule gets a channel-picker
* step inside the modal before the members list. */}
{!onTestClick && showTestResults && (
<TestResultsModal
onExited={() => setShowTestResults(false)}
<TestResults
expression={value}
channelId={channelId}
teamId={teamId}
isStacked={true}
actions={{
openModal: () => {},
searchUsers: (term: string, after: string, limit: number) => {
if (actions.searchUsers) {
// Wrap in a thunk so TestResultsModal can dispatch it unchanged.
const search = actions.searchUsers;
return () => search(value, term, after, limit);
}
// Return the action for the modal to dispatch
return searchUsersForExpression(value, term, after, limit, channelId, teamId);
},
}}
onExited={() => setShowTestResults(false)}
searchUsers={actions.searchUsers}
/>
)}
{showHelpModal && (
@@ -471,11 +471,38 @@ describe('TableEditor - injected searchUsers', () => {
await userEvent.click(screen.getByRole('button', {name: /test access rule/i}));
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(expression, '', '', 50);
expect(mockSearch).toHaveBeenCalledWith(expression, '', '', 50, undefined);
});
expect(searchUsersForExpression).not.toHaveBeenCalled();
});
test('should thread the editor channel into the injected searchUsers', async () => {
// A resource.attributes.* rule can only be tested against a concrete
// channel, so the channel (the editor's own scope here, or one picked in
// the modal) has to reach the searchUsers override. Without it the server
// cannot resolve the resource side and the test reports no users.
const mockSearch = jest.fn().mockResolvedValue({data: {users: [], total: 0}});
renderWithContext(
<TableEditor
{...baseProps}
channelId='channel1'
actions={{getVisualAST, searchUsers: mockSearch}}
/>,
{},
);
await waitFor(() => {
expect(screen.getByRole('button', {name: /test access rule/i})).not.toBeDisabled();
});
await userEvent.click(screen.getByRole('button', {name: /test access rule/i}));
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledWith(expression, '', '', 50, 'channel1');
});
});
test('should fall back to the redux thunk when searchUsers is not injected', async () => {
renderWithContext(
<TableEditor
@@ -0,0 +1,245 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import type {TableRow} from './value_selector_menu';
import ValueSelectorMenu from './value_selector_menu';
// A comparable channel attribute the row's user attribute can target instead of
// a literal value. Same shape as a CPA field; object_type marks it a channel
// (resource) attribute.
function channelField(name: string, type: UserPropertyField['type'], displayName: string): UserPropertyField {
return {
id: `cf_${name}`,
name,
type,
group_id: 'channel_attributes',
target_id: '',
target_type: '',
object_type: 'channel',
attrs: {
sort_order: 0,
visibility: 'always',
value_type: '',
display_name: displayName,
},
create_at: 0,
update_at: 0,
delete_at: 0,
created_by: '',
updated_by: '',
} as unknown as UserPropertyField;
}
const selectOptions: PropertyFieldOption[] = [
{id: 'o1', name: 'engineering'} as PropertyFieldOption,
{id: 'o2', name: 'sales'} as PropertyFieldOption,
];
function baseRow(overrides: Partial<TableRow> = {}): TableRow {
return {
attribute: 'team',
operator: 'is',
values: [],
attribute_type: 'select',
hasMaskedValues: false,
...overrides,
};
}
describe('ValueSelectorMenu — consolidated value/channel-attribute dropdown', () => {
const updateValues = jest.fn();
const onSelectTarget = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
describe('option-based single field with channel targets', () => {
const owningTeam = channelField('owningTeam', 'select', 'Owning team');
function renderIt(row: TableRow) {
renderWithContext(
<ValueSelectorMenu
row={row}
disabled={false}
updateValues={updateValues}
options={selectOptions}
channelFields={[owningTeam]}
onSelectTarget={onSelectTarget}
/>,
);
fireEvent.click(screen.getByTestId('valueSelectorMenuButton'));
}
test('shows both the VALUES options and the CHANNEL ATTRIBUTES section', () => {
renderIt(baseRow());
expect(screen.getByText('Values')).toBeInTheDocument();
expect(screen.getByText('Channel attributes')).toBeInTheDocument();
expect(screen.getByText('engineering')).toBeInTheDocument();
expect(screen.getByText('Owning team')).toBeInTheDocument();
});
test('picking a value commits a literal value', () => {
renderIt(baseRow());
fireEvent.click(screen.getByText('sales'));
expect(updateValues).toHaveBeenCalledWith(['sales']);
expect(onSelectTarget).not.toHaveBeenCalled();
});
test('picking a channel attribute switches to target mode', () => {
renderIt(baseRow());
fireEvent.click(screen.getByText('Owning team'));
expect(onSelectTarget).toHaveBeenCalledWith('owningTeam');
});
test('in target mode the button shows the channel attribute label', () => {
renderWithContext(
<ValueSelectorMenu
row={baseRow({targetAttribute: 'owningTeam'})}
disabled={false}
updateValues={updateValues}
options={selectOptions}
channelFields={[owningTeam]}
onSelectTarget={onSelectTarget}
/>,
);
// The button (before opening) renders the target's display label,
// not a literal value.
expect(screen.getByTestId('valueSelectorMenuButton')).toHaveTextContent('Owning team');
});
});
describe('text field (no options) with channel targets', () => {
const owningTeamText = channelField('owningTeamText', 'text', 'Owning team (text)');
test('renders a free-text input atop the CHANNEL ATTRIBUTES list', () => {
renderWithContext(
<ValueSelectorMenu
row={baseRow({attribute_type: 'text'})}
disabled={false}
updateValues={updateValues}
options={[]}
channelFields={[owningTeamText]}
onSelectTarget={onSelectTarget}
/>,
);
fireEvent.click(screen.getByTestId('valueSelectorMenuButton'));
expect(screen.getByText('Channel attributes')).toBeInTheDocument();
expect(screen.getByText('Owning team (text)')).toBeInTheDocument();
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {target: {value: 'platform'}});
fireEvent.keyDown(input, {key: 'Enter'});
expect(updateValues).toHaveBeenCalledWith(['platform']);
});
});
describe('text field with no channel targets', () => {
test('keeps the legacy bare input (no dropdown, no sections)', () => {
renderWithContext(
<ValueSelectorMenu
row={baseRow({attribute_type: 'text'})}
disabled={false}
updateValues={updateValues}
options={[]}
/>,
);
expect(screen.queryByTestId('valueSelectorMenuButton')).not.toBeInTheDocument();
expect(screen.queryByText('Channel attributes')).not.toBeInTheDocument();
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, {target: {value: 'platform'}});
fireEvent.blur(input);
expect(updateValues).toHaveBeenCalledWith(['platform']);
});
});
describe('text field in target mode with the target list unavailable', () => {
test('still shows the channel target instead of a bare input', () => {
renderWithContext(
<ValueSelectorMenu
row={baseRow({attribute_type: 'text', targetAttribute: 'owningTeamText'})}
disabled={false}
updateValues={updateValues}
options={[]}
onSelectTarget={onSelectTarget}
/>,
);
// No options and no channel fields (still loading / feature off /
// attribute deleted), but the row targets one — falling back to the
// bare input would hide the target and drop it on the next keystroke.
expect(screen.getByTestId('valueSelectorMenuButton')).toHaveTextContent('owningTeamText');
});
});
describe('multiselect field (has any of) with channel targets', () => {
const programs = channelField('channelPrograms', 'multiselect', 'Channel programs');
function renderMulti(row: TableRow) {
renderWithContext(
<ValueSelectorMenu
row={row}
disabled={false}
updateValues={updateValues}
options={selectOptions}
channelFields={[programs]}
onSelectTarget={onSelectTarget}
/>,
);
}
test('offers both multi-values and the channel attribute', () => {
renderMulti(baseRow({operator: 'has any of', attribute_type: 'multiselect'}));
fireEvent.click(screen.getByTestId('valueSelectorMenuButton'));
expect(screen.getByText('engineering')).toBeInTheDocument();
expect(screen.getByText('Channel programs')).toBeInTheDocument();
fireEvent.click(screen.getByText('Channel programs'));
expect(onSelectTarget).toHaveBeenCalledWith('channelPrograms');
});
test('in target mode the multiselect button shows the channel attribute label', () => {
renderMulti(baseRow({operator: 'has any of', attribute_type: 'multiselect', targetAttribute: 'channelPrograms'}));
expect(screen.getByTestId('valueSelectorMenuButton')).toHaveTextContent('Channel programs');
});
});
describe('no channel targets on an option field', () => {
test('renders values only, without the CHANNEL ATTRIBUTES section', () => {
renderWithContext(
<ValueSelectorMenu
row={baseRow()}
disabled={false}
updateValues={updateValues}
options={selectOptions}
/>,
);
fireEvent.click(screen.getByTestId('valueSelectorMenuButton'));
expect(screen.getByText('engineering')).toBeInTheDocument();
expect(screen.queryByText('Channel attributes')).not.toBeInTheDocument();
// Without a second section the "Values" section header is omitted too.
expect(screen.queryByText('Values')).not.toBeInTheDocument();
});
});
});
@@ -4,6 +4,7 @@
import React from 'react';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import MultiValueSelector from './multi_value_selector_menu';
import SingleValueSelector from './single_value_selector_menu';
@@ -20,6 +21,13 @@ export interface TableRow {
attribute_type: string;
hasMaskedValues: boolean;
// When set, the right-hand side of the condition is the accessed channel's
// attribute (resource.attributes.<targetAttribute>) rather than a literal
// value; `values` is then ignored. Only meaningful for comparison operators
// and the multiselect list operators (has any of / has all of). The left
// side stays the requesting user's attribute.
targetAttribute?: string;
// Native user attributes are referenced as `user.<name>` (vs `user.attributes.<name>`).
isNative?: boolean;
@@ -34,6 +42,13 @@ export interface ValueSelectorMenuProps {
options?: PropertyFieldOption[];
allowCreateValue?: boolean;
placeholder?: string;
// Comparable channel attributes offered as the right-hand side alongside
// literal values (the consolidated VALUES + CHANNEL ATTRIBUTES dropdown).
// Empty/undefined when the operator or attribute type has no target. When
// one is picked, the row switches to a resource.attributes.<name> target.
channelFields?: UserPropertyField[];
onSelectTarget?: (name: string) => void;
}
const ValueSelectorMenu = ({
@@ -43,6 +58,8 @@ const ValueSelectorMenu = ({
options = [],
allowCreateValue = false,
placeholder,
channelFields = [],
onSelectTarget,
}: ValueSelectorMenuProps) => {
const isMultiOperator = isMultiValueOperator(row.operator);
@@ -56,6 +73,9 @@ const ValueSelectorMenu = ({
allowCreateValue={allowCreateValue}
placeholder={placeholder}
hasMaskedValues={row.hasMaskedValues}
channelFields={channelFields}
targetAttribute={row.targetAttribute}
onSelectTarget={onSelectTarget}
/>
);
}
@@ -69,6 +89,9 @@ const ValueSelectorMenu = ({
allowCreateValue={allowCreateValue}
placeholder={placeholder}
hasMaskedValues={row.hasMaskedValues}
channelFields={channelFields}
targetAttribute={row.targetAttribute}
onSelectTarget={onSelectTarget}
/>
);
};
@@ -43,7 +43,7 @@ const CELHelpModal: React.FC<Props> = ({onExited, onHide}: Props) => {
<div className='cel-help-modal__content-container'>
<div className='cel-help-modal__content'>
<Markdown
message={'### Basic Syntax\nCEL expressions evaluate to boolean values (`true`/`false`) to determine if access should be granted.\n### Common Examples\n- To match a specific program:\n&ensp;`user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n&ensp;`user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n&ensp;`user.attributes.Email.endsWith("example.com")`\n- To combine conditions (for this example with `OR` operator, altertanitvely use `&&` for `AND` operation):\n&ensp;`user.attrs.Program == "Alpha" || user.attrs.Team == "Operations"`\n### Supported Operators and functions\n- `==`, `!=`, `&&`, `||`, `in`, `contains()`, `startsWith()`, `endsWith()`'}
message={'### Basic Syntax\nCEL expressions evaluate to boolean values (`true`/`false`) to determine if access should be granted.\n### Common Examples\n- To match a specific program:\n&ensp;`user.attributes.Program == "Delta"`\n- To match any of multiple teams:\n&ensp;`user.attributes.Team in ["Sales", "Engineering"]`\n- To match an email domain:\n&ensp;`user.attributes.Email.endsWith("example.com")`\n- To require the user to meet the accessed channel\'s requirement:\n&ensp;`user.attributes.Clearance >= resource.attributes.MinClearance`\n- To combine conditions (for this example with `OR` operator, alternatively use `&&` for `AND` operation):\n&ensp;`user.attributes.Program == "Alpha" || user.attributes.Team == "Operations"`\n### Supported Operators and functions\n- `==`, `!=`, `<`, `>`, `<=`, `>=`, `&&`, `||`, `in`, `contains()`, `startsWith()`, `endsWith()`\n- On multiselect attributes, `hasAnyOf()` and `hasAllOf()` compare the user\'s selections against the channel\'s:\n&ensp;`user.attributes.Programs.hasAnyOf(resource.attributes.Programs)`'}
/>
</div>
<div className='cel-help-additional-info-modal__content'>
@@ -58,7 +58,7 @@ const CELHelpModal: React.FC<Props> = ({onExited, onHide}: Props) => {
</div>
<div className='cel-help-additional-info-modal__text'>
<Markdown
message={'- Operators like `<` or `>` are forbidden due to incorrect string comparison.\n- Only `user.attributes` are supported; any other variables are not supported yet.'}
message={'- Use the ordering operators (`<`, `>`, `<=`, `>=`) only on ranked attributes, where the levels have a defined order. On a plain text attribute they compare alphabetically, which is rarely what you mean.\n- `user.attributes.*` refers to the requesting user; `resource.attributes.*` refers to the channel being accessed.\n- If a channel is missing an attribute the policy references, that channel denies access. There is nothing to add — access is denied automatically until the attribute is set.'}
/>
<FormattedMessage
id='admin.access_control.cel_help_modal.external_link'
@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {searchAllChannels} from 'mattermost-redux/actions/channels';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import TestChannelPicker from './test_channel_picker';
jest.mock('mattermost-redux/actions/channels', () => ({
searchAllChannels: jest.fn(),
}));
// ChannelIcon reads plugin overrides from the store; stub it so the picker can
// be tested without that wiring.
jest.mock('components/channel_type_icon', () => ({
ChannelIcon: () => <span data-testid='channel-icon'/>,
}));
const mockSearchAllChannels = searchAllChannels as jest.MockedFunction<any>;
const channels = [
{id: 'c1', display_name: 'Engineering', team_display_name: 'Core', type: 'P', delete_at: 0},
{id: 'c2', display_name: 'Design', team_display_name: 'Product', type: 'P', delete_at: 0},
];
describe('TestChannelPicker', () => {
const onSelect = jest.fn();
beforeEach(() => {
onSelect.mockClear();
mockSearchAllChannels.mockReset();
mockSearchAllChannels.mockReturnValue(() => Promise.resolve({data: channels}));
});
it('searches public and private channels and renders a row per result', async () => {
renderWithContext(<TestChannelPicker onSelect={onSelect}/>);
expect(await screen.findByText('Engineering')).toBeInTheDocument();
expect(screen.getByText('Design')).toBeInTheDocument();
// Team name shown as subtext, no member counts.
expect(screen.getByText('Core')).toBeInTheDocument();
// No public/private flag: ABAC policies can be assigned to both, so the
// picker must not restrict to private. Exact match locks that out.
expect(mockSearchAllChannels).toHaveBeenCalledWith('', {
exclude_group_constrained: true,
exclude_remote: true,
exclude_default_channels: true,
});
});
it('reports the chosen channel id through onSelect', async () => {
renderWithContext(<TestChannelPicker onSelect={onSelect}/>);
const row = await screen.findByText('Engineering');
await userEvent.click(row);
expect(onSelect).toHaveBeenCalledWith('c1');
});
it('re-queries as the search term changes', async () => {
renderWithContext(<TestChannelPicker onSelect={onSelect}/>);
await screen.findByText('Engineering');
const input = screen.getByLabelText('Search channels');
await userEvent.type(input, 'des');
await waitFor(() => {
expect(mockSearchAllChannels).toHaveBeenLastCalledWith('des', expect.anything());
});
});
it('shows an empty state when there are no results', async () => {
mockSearchAllChannels.mockReturnValue(() => Promise.resolve({data: []}));
renderWithContext(<TestChannelPicker onSelect={onSelect}/>);
expect(await screen.findByText('No channels found')).toBeInTheDocument();
});
it('surfaces an error state instead of "no results" on a failed search', async () => {
mockSearchAllChannels.mockReturnValue(() => Promise.resolve({error: new Error('network')}));
renderWithContext(<TestChannelPicker onSelect={onSelect}/>);
expect(await screen.findByText('Could not load channels. Check your connection and try again.')).toBeInTheDocument();
expect(screen.queryByText('No channels found')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {ChevronRightIcon} from '@mattermost/compass-icons/components';
import type {ChannelWithTeamData} from '@mattermost/types/channels';
import {searchAllChannels} from 'mattermost-redux/actions/channels';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {ChannelIcon} from 'components/channel_type_icon';
import LoadingScreen from 'components/loading_screen';
const SEARCH_DEBOUNCE_MS = 250;
// Access policies apply to public and private channels alike, and both types
// carry channel attributes a resource-aware rule reads — so search both.
// Omitting the public/private flags returns open + private; the exclusions
// mirror channel eligibility.
const CHANNEL_SEARCH_OPTS = {
exclude_group_constrained: true,
exclude_remote: true,
exclude_default_channels: true,
};
interface Props {
onSelect: (channelId: string) => void;
}
// First step of the shared test modal for a resource.attributes.* rule with no
// channel scope of its own: pick a concrete channel whose attribute values the
// rule is resolved against. Rows are icon + name + team + chevron — no member
// counts (the members step reports the matching users).
export default function TestChannelPicker({onSelect}: Props): JSX.Element {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [term, setTerm] = useState('');
const [channels, setChannels] = useState<ChannelWithTeamData[]>([]);
const [loading, setLoading] = useState(true);
const [hasError, setHasError] = useState(false);
// Guards against a slow earlier request overwriting a newer one's results.
const requestSeq = useRef(0);
const search = useCallback(async (searchTerm: string) => {
const seq = ++requestSeq.current;
setLoading(true);
const action = await dispatch(searchAllChannels(searchTerm, CHANNEL_SEARCH_OPTS));
if (seq !== requestSeq.current) {
return;
}
const result = action as ActionResult<ChannelWithTeamData[]>;
// A dispatch error (e.g. network failure) must not read as an empty
// result — otherwise the admin sees "No channels found" and assumes none
// match rather than retrying.
setHasError(Boolean(result.error));
setChannels(result.data ?? []);
setLoading(false);
}, [dispatch]);
useEffect(() => {
const timer = setTimeout(() => search(term), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [term, search]);
const placeholder = formatMessage({
id: 'admin.access_control.test.channel_picker.search',
defaultMessage: 'Search channels',
});
let listContent: JSX.Element | JSX.Element[];
if (loading && channels.length === 0) {
listContent = <LoadingScreen/>;
} else if (hasError) {
listContent = (
<div className='TestChannelPicker__empty'>
<FormattedMessage
id='admin.access_control.test.channel_picker.error'
defaultMessage='Could not load channels. Check your connection and try again.'
/>
</div>
);
} else if (channels.length === 0) {
listContent = (
<div className='TestChannelPicker__empty'>
<FormattedMessage
id='admin.access_control.test.channel_picker.no_results'
defaultMessage='No channels found'
/>
</div>
);
} else {
listContent = channels.map((channel) => (
<button
key={channel.id}
type='button'
className='TestChannelPicker__row'
onClick={() => onSelect(channel.id)}
>
<ChannelIcon
className='TestChannelPicker__row-icon'
channel={channel}
size={18}
/>
<span className='TestChannelPicker__row-text'>
<span className='TestChannelPicker__row-name'>{channel.display_name}</span>
{channel.team_display_name && (
<span className='TestChannelPicker__row-team'>{channel.team_display_name}</span>
)}
</span>
<ChevronRightIcon size={18}/>
</button>
));
}
return (
<div className='TestChannelPicker'>
<div className='TestChannelPicker__search'>
<i className='icon icon-magnify'/>
<input
type='text'
className='TestChannelPicker__search-input'
placeholder={placeholder}
aria-label={placeholder}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className='TestChannelPicker__list'>
{listContent}
</div>
</div>
);
}
@@ -9,9 +9,48 @@
.GenericModal__body {
padding: 0 !important;
// The members-step search (SearchableUserList's default filter row) is
// matched to the channel-picker search so the two steps line up: same
// 32px inset (the .modal-header gutter, so the search lines up with the
// title above it), same 12px top offset, same bordered input with an
// inset magnify icon. The default row has no icon element, so it's drawn
// with a ::before (compass-icons magnify glyph).
.filter-row {
padding: 0 16px;
margin: 5px 0 10px;
padding: 0 32px;
margin: 12px 0 10px;
}
.filter-row .col-xs-12,
.filter-row .col-sm-12 {
padding: 0;
}
.filter-row .col-xs-12 {
position: relative;
// z-index lifts the glyph above the input, whose opaque
// background would otherwise paint over it and leave the 38px
// padding below looking like unexplained blank space.
&::before {
position: absolute;
z-index: 1;
top: 50%;
left: 12px;
color: rgba(var(--center-channel-color-rgb), 0.56);
content: "\f0349";
font-family: "compass-icons";
font-size: 18px;
pointer-events: none;
transform: translateY(-50%);
}
}
// Left padding leaves room for the ::before magnify icon; the border,
// radius, and focus ring come from the shared .form-control styling
// (matched by the picker input below).
.filter-row .filter-textbox {
height: 40px;
padding: 0 12px 0 38px;
}
.filtered-user-list .filter-row span.member-count{
@@ -19,4 +58,146 @@
opacity: 0.8;
}
}
// The back button leads the title in normal flow, so it starts at the
// .modal-header gutter (32px) like every other element in the modal. Pulling
// it out of flow into that gutter instead would keep the title at the same
// x-position across steps, but the gutter is only as wide as the button plus
// its gap, leaving the arrow flush against the modal edge.
&__title {
display: flex;
align-items: center;
gap: 8px;
}
&__back {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.75);
cursor: pointer;
&:hover {
color: var(--center-channel-color);
}
.icon {
font-size: 18px;
line-height: 1;
}
}
}
.TestChannelPicker {
display: flex;
// Match the members step's SearchableUserList (.filtered-user-list) so the
// modal body is the same fixed height on both steps and doesn't jump on
// transition. The search stays put; only the list scrolls internally.
height: calc(90vh - 120px);
flex-direction: column;
// Matched to the members-step search (see .GenericModal__body .filter-row):
// same 32px inset, 12px top, bordered input with an inset magnify icon.
&__search {
position: relative;
flex: 0 0 auto;
margin: 12px 32px 10px;
.icon-magnify {
position: absolute;
top: 50%;
left: 12px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 18px;
pointer-events: none;
transform: translateY(-50%);
}
}
&__search-input {
width: 100%;
height: 40px;
padding: 0 12px 0 38px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: 4px;
background: transparent;
&:focus {
border-color: var(--button-bg);
box-shadow: 0 0 0 1px var(--button-bg);
outline: none;
}
}
&__list {
min-height: 0;
flex: 1;
overflow-y: auto;
}
&__empty {
padding: 32px 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
text-align: center;
}
&__row {
display: flex;
width: 100%;
align-items: center;
// 32px matches .more-modal__row, the members step's list row.
padding: 8px 32px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
// Keyboard focus needs an indicator of its own the hover wash alone is
// too faint to locate a row with. Scoped to :focus-visible so clicking a
// row does not leave a ring behind, and inset because the row is
// full-bleed inside &__list: overflow-y makes overflow-x compute to auto,
// which would clip an outset ring or a UA outline on the sides.
&:focus-visible {
background: rgba(var(--center-channel-color-rgb), 0.08);
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
outline: none;
}
}
&__row-icon {
flex: 0 0 auto;
margin-right: 12px;
color: rgba(var(--center-channel-color-rgb), 0.75);
}
&__row-text {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
&__row-name {
overflow: hidden;
color: var(--center-channel-color);
text-overflow: ellipsis;
white-space: nowrap;
}
&__row-team {
overflow: hidden;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -5,11 +5,26 @@ import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {act, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import TestResultsModal from './test_modal';
// Mock the channel-picker step so the modal's step logic can be tested in
// isolation (the picker's own search/rendering is covered by its own test).
jest.mock('./test_channel_picker', () => {
return function MockTestChannelPicker({onSelect}: {onSelect: (channelId: string) => void}) {
return (
<button
data-testid='mock-pick-channel'
onClick={() => onSelect('picked-channel')}
>
{'Pick channel'}
</button>
);
};
});
// Mock the SearchableUserList component
jest.mock('components/searchable_user_list/searchable_user_list_container', () => {
return function MockSearchableUserList({
@@ -113,7 +128,7 @@ describe('TestResultsModal', () => {
renderWithContext(<TestResultsModal {...defaultProps}/>);
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenCalledWith('', '', 50);
expect(mockSearchUsers).toHaveBeenCalledWith('', '', 50, undefined);
});
});
@@ -144,7 +159,7 @@ describe('TestResultsModal', () => {
await userEvent.type(searchInput, 'test search');
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenCalledWith('test search', '', 50);
expect(mockSearchUsers).toHaveBeenCalledWith('test search', '', 50, undefined);
});
});
@@ -171,7 +186,7 @@ describe('TestResultsModal', () => {
// The nextPage function gets called with page 1 (second page), but since it's above USERS_PER_PAGE (10)
// but less than USERS_TO_FETCH (50), it should use the cursor logic and call with the last user's ID
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenLastCalledWith('', 'user2', 50);
expect(mockSearchUsers).toHaveBeenLastCalledWith('', 'user2', 50, undefined);
});
});
@@ -300,7 +315,7 @@ describe('TestResultsModal', () => {
await userEvent.type(searchInput, 'search1');
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenCalledWith('search1', '', 50);
expect(mockSearchUsers).toHaveBeenCalledWith('search1', '', 50, undefined);
});
// Perform second search
@@ -308,7 +323,7 @@ describe('TestResultsModal', () => {
await userEvent.type(searchInput, 'search2');
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenCalledWith('search2', '', 50);
expect(mockSearchUsers).toHaveBeenCalledWith('search2', '', 50, undefined);
});
});
@@ -328,4 +343,140 @@ describe('TestResultsModal', () => {
expect(dialog).toHaveAttribute('aria-label', 'Access Rule Test Results');
});
});
describe('channel-picker step', () => {
it('opens the members list directly (no picker) when requireChannel is false', async () => {
renderWithContext(<TestResultsModal {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('searchable-user-list')).toBeInTheDocument();
});
expect(screen.queryByTestId('mock-pick-channel')).not.toBeInTheDocument();
expect(screen.getByText('Access Rule Test Results')).toBeInTheDocument();
});
it('shows the picker step first (no user fetch) when requireChannel is true', async () => {
renderWithContext(
<TestResultsModal
{...defaultProps}
requireChannel={true}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('mock-pick-channel')).toBeInTheDocument();
});
// Picker step defers the members fetch until a channel is chosen.
expect(mockSearchUsers).not.toHaveBeenCalled();
expect(screen.queryByTestId('searchable-user-list')).not.toBeInTheDocument();
expect(screen.getByText('Select a channel to test against')).toBeInTheDocument();
});
it('threads the picked channel id into the members search', async () => {
renderWithContext(
<TestResultsModal
{...defaultProps}
requireChannel={true}
/>,
);
const pick = await screen.findByTestId('mock-pick-channel');
await userEvent.click(pick);
await waitFor(() => {
expect(screen.getByTestId('searchable-user-list')).toBeInTheDocument();
});
expect(mockSearchUsers).toHaveBeenCalledWith('', '', 50, 'picked-channel');
});
it('keeps a members search scoped to the picked channel', async () => {
renderWithContext(
<TestResultsModal
{...defaultProps}
requireChannel={true}
/>,
);
await userEvent.click(await screen.findByTestId('mock-pick-channel'));
await screen.findByTestId('searchable-user-list');
// handleSearch passes no channel override, so the picked channel has
// to come from the channelId the fetchUsers callback closed over.
await userEvent.type(screen.getByTestId('search-input'), 'ann');
await waitFor(() => {
expect(mockSearchUsers).toHaveBeenLastCalledWith('ann', '', 50, 'picked-channel');
});
});
it('ignores a stale in-flight response after the channel is re-picked', async () => {
const laterUser = TestHelper.getUserMock({
id: 'user3',
username: 'testuser3',
email: 'test3@example.com',
});
let resolveStale: (value: unknown) => void = () => {};
mockSearchUsers.
mockReturnValueOnce(() => new Promise((resolve) => {
resolveStale = resolve;
})).
mockReturnValueOnce(() => Promise.resolve({data: {users: [laterUser], total: 1}}));
renderWithContext(
<TestResultsModal
{...defaultProps}
requireChannel={true}
/>,
);
// First pick's response is withheld, then the admin goes back and
// picks again — the second fetch supersedes the first.
await userEvent.click(await screen.findByTestId('mock-pick-channel'));
await userEvent.click(await screen.findByLabelText('Back to channel selection'));
await userEvent.click(await screen.findByTestId('mock-pick-channel'));
await waitFor(() => {
expect(screen.getByTestId('user-user3')).toBeInTheDocument();
});
// The superseded response landing late must not replace the results.
await act(async () => {
resolveStale({data: {users: mockUsers, total: 2}});
});
expect(screen.getByTestId('user-count')).toHaveTextContent('Showing 1 of 1 users');
expect(screen.queryByTestId('user-user1')).not.toBeInTheDocument();
});
it('shows a back arrow in the members view that returns to the picker', async () => {
renderWithContext(
<TestResultsModal
{...defaultProps}
requireChannel={true}
/>,
);
const pick = await screen.findByTestId('mock-pick-channel');
await userEvent.click(pick);
const back = await screen.findByLabelText('Back to channel selection');
await userEvent.click(back);
await waitFor(() => {
expect(screen.getByTestId('mock-pick-channel')).toBeInTheDocument();
});
expect(screen.queryByTestId('searchable-user-list')).not.toBeInTheDocument();
});
it('does not show a back arrow in a members-only modal', async () => {
renderWithContext(<TestResultsModal {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByTestId('searchable-user-list')).toBeInTheDocument();
});
expect(screen.queryByLabelText('Back to channel selection')).not.toBeInTheDocument();
});
});
});
@@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import React, {useEffect, useRef, useState, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {GenericModal} from '@mattermost/components';
@@ -16,6 +16,8 @@ import SearchableUserList from 'components/searchable_user_list/searchable_user_
import type {ModalData} from 'types/actions';
import type {ActionFuncAsync} from 'types/store';
import TestChannelPicker from './test_channel_picker';
import './test_modal.scss';
const USERS_TO_FETCH = 50;
@@ -24,8 +26,17 @@ const USERS_PER_PAGE = 10;
type Props = {
onExited: () => void;
isStacked?: boolean;
/**
* Show a channel-picker step before the members list. Used for a
* resource.attributes.* rule the editor has no channel scope for: the
* picked channel id is threaded into searchUsers so the rule can be
* resolved against that channel's attribute values. When false the modal
* opens straight to the members list, unchanged.
*/
requireChannel?: boolean;
actions: {
searchUsers: (term: string, after: string, limit: number) => ActionFuncAsync<AccessControlTestResult>;
searchUsers: (term: string, after: string, limit: number, channelId?: string) => ActionFuncAsync<AccessControlTestResult>;
openModal?: <P>(modalData: ModalData<P>) => void;
};
};
@@ -33,8 +44,10 @@ type Props = {
function TestResultsModal({
onExited,
isStacked = false,
requireChannel = false,
actions,
}: Props): JSX.Element {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [term, setTerm] = useState<string>('');
const [users, setUsers] = useState<UserProfile[]>([]);
@@ -42,9 +55,24 @@ function TestResultsModal({
const [loading, setLoading] = useState<boolean>(true);
const [cursorHistory, setCursorHistory] = useState<string[]>([]); // Stores the 'after' cursor for page 1, page 2, etc.
const fetchUsers = useCallback(async (searchTerm: string, cursor: string, reset: boolean = false) => {
// Channel chosen in the picker step. Undefined until a channel is picked
// (or always, when requireChannel is false — the editor already supplies
// its channel through searchUsers).
const [channelId, setChannelId] = useState<string | undefined>(undefined);
const showPicker = requireChannel && !channelId;
// Guards against a slow earlier request overwriting a newer one's results —
// e.g. back out of the members list and pick a different channel before the
// first channel's fetch resolves.
const requestSeq = useRef(0);
const fetchUsers = useCallback(async (searchTerm: string, cursor: string, reset: boolean = false, channelOverride?: string) => {
const seq = ++requestSeq.current;
setLoading(true);
const result: ActionResult<AccessControlTestResult> = await dispatch(actions.searchUsers(searchTerm, cursor, USERS_TO_FETCH));
const result: ActionResult<AccessControlTestResult> = await dispatch(actions.searchUsers(searchTerm, cursor, USERS_TO_FETCH, channelOverride ?? channelId));
if (seq !== requestSeq.current) {
return;
}
if (result?.data) {
const newUsers = result.data.users;
if (reset) {
@@ -58,12 +86,29 @@ function TestResultsModal({
setTotal(0);
}
setLoading(false);
}, [dispatch, actions]);
}, [dispatch, actions, channelId]);
useEffect(() => {
fetchUsers(term, '');
// The picker step defers the initial fetch until a channel is chosen
// (handled in handleChannelSelected).
if (!requireChannel) {
fetchUsers('', '');
}
}, []);
const handleChannelSelected = (selectedChannelId: string) => {
setChannelId(selectedChannelId);
setTerm('');
setCursorHistory([]);
fetchUsers('', '', true, selectedChannelId);
};
const handleBack = () => {
setChannelId(undefined);
setUsers([]);
setTotal(0);
};
const handleSearch = (newTerm: string) => {
setCursorHistory([]);
setTerm(newTerm);
@@ -82,13 +127,39 @@ function TestResultsModal({
fetchUsers(term, cursorForNextPage);
};
const modalTitle = (
const pickerTitle = (
<FormattedMessage
id='admin.access_control.test.channel_picker.title'
defaultMessage='Select a channel to test against'
/>
);
const resultsTitle = (
<FormattedMessage
id='admin.access_control.testResults'
defaultMessage='Access Rule Test Results'
/>
);
// The back arrow shows only when requireChannel is true, so the admin can
// return to the channel picker; a modal invoked with a channel already
// fixed renders no back arrow.
const modalTitle = showPicker ? pickerTitle : (
<span className='TestResultsModal__title'>
{requireChannel && (
<button
type='button'
className='TestResultsModal__back'
onClick={handleBack}
aria-label={formatMessage({id: 'admin.access_control.test.channel_picker.back', defaultMessage: 'Back to channel selection'})}
>
<i className='icon icon-arrow-left'/>
</button>
)}
{resultsTitle}
</span>
);
return (
<GenericModal
className='TestResultsModal a11y__modal'
@@ -103,14 +174,18 @@ function TestResultsModal({
ariaLabel='Access Rule Test Results'
isStacked={isStacked}
>
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={total}
nextPage={handleNextPage}
search={handleSearch}
actionUserProps={{}}
/>
{showPicker ? (
<TestChannelPicker onSelect={handleChannelSelected}/>
) : (
<SearchableUserList
users={users}
usersPerPage={USERS_PER_PAGE}
total={total}
nextPage={handleNextPage}
search={handleSearch}
actionUserProps={{}}
/>
)}
</GenericModal>
);
}
@@ -3,6 +3,7 @@
import React from 'react';
import type {AccessControlPolicy} from '@mattermost/types/access_control';
import type {ChannelWithTeamData} from '@mattermost/types/channels';
import {useChannelAccessControlActions} from 'hooks/useChannelAccessControlActions';
@@ -248,6 +249,105 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
expect(passedNames).not.toContain('network_name');
});
test('never hands the editor the stored marker form of a rank rule', async () => {
// Regression guard. The `policy` prop is the copy the policies list left
// in the store, and the list is filled by the search endpoint, which
// returns rules in their stored form: a rank comparison is stored
// desugared as `_rank_ge(...)`. /cel/visual_ast rejects that marker call,
// so seeding the editor with it fired a doomed parse whose failure flipped
// the editor into Advanced mode. Only fetchPolicy's rehydrated expression
// may reach the editor.
const storedForm = '_rank_ge(user.attributes.clearance, "Secret", "sxrgeknhajds3qdt5hhrm4fy3h")';
const rehydratedForm = 'user.attributes.clearance >= "Secret"';
MockedTableEditor.mockClear();
const props = {
...defaultProps,
policy: {
id: 'policy1',
name: 'Policy 1',
type: 'parent',
rules: [{actions: ['membership'], expression: storedForm}],
} as unknown as AccessControlPolicy,
actions: {
...defaultProps.actions,
fetchPolicy: jest.fn().mockResolvedValue({
data: {
id: 'policy1',
name: 'Policy 1',
rules: [{actions: ['membership'], expression: rehydratedForm}],
},
}),
},
};
renderWithContext(<PolicyDetails {...props}/>);
await waitFor(() => {
const values = MockedTableEditor.mock.calls.map((call) => call[0].value);
expect(values).toContain(rehydratedForm);
});
const values = MockedTableEditor.mock.calls.map((call) => call[0].value);
expect(values).not.toContain(storedForm);
});
describe('channel attribute warning notice', () => {
const NOTICE_TITLE = 'Channels without this attribute lose all members';
// A channel with no value for a referenced channel attribute denies every
// member, so the notice fires as soon as the rule references a channel
// attribute — before any channel is assigned, without inspecting values.
const renderWithPolicy = (expression: string) => {
const props = {
...defaultProps,
actions: {
...defaultProps.actions,
fetchPolicy: jest.fn().mockResolvedValue({
data: {
id: 'policy1',
name: 'Policy 1',
rules: [{actions: ['membership'], expression}],
},
}),
searchChannels: jest.fn().mockResolvedValue({
data: {
channels: [],
total_count: 0,
},
}),
},
};
return renderWithContext(<PolicyDetails {...props}/>);
};
test('shows as soon as a channel attribute is referenced, before any channel is assigned', async () => {
renderWithPolicy('user.attributes.clearance == resource.attributes.minClearance');
expect(await screen.findByText(NOTICE_TITLE)).toBeInTheDocument();
});
test('stays hidden when the rule only references user attributes', async () => {
renderWithPolicy('user.attributes.clearance == "Secret"');
await waitFor(() => {
expect(screen.getByTestId('table-editor')).toBeInTheDocument();
});
expect(screen.queryByText(NOTICE_TITLE)).not.toBeInTheDocument();
});
test('stays hidden when resource.attributes appears only inside a string literal', async () => {
// referencesResourceAttributes strips quoted literals first, so a value
// that happens to spell an attribute path is not a reference.
renderWithPolicy('user.attributes.name == "resource.attributes.minClearance"');
await waitFor(() => {
expect(screen.getByTestId('table-editor')).toBeInTheDocument();
});
expect(screen.queryByText(NOTICE_TITLE)).not.toBeInTheDocument();
});
});
test('hasMaskedRows derivation survives Simple → Advanced → Simple mode toggles', async () => {
// Regression guard: hasMaskedRows must come from the expression itself,
// not from a TableEditor lifecycle callback. Toggling editor modes
@@ -13,6 +13,7 @@ import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/cha
import type {AccessControlSettings} from '@mattermost/types/config';
import type {JobTypeBase} from '@mattermost/types/jobs';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {CHANNEL_ATTRIBUTES_OBJECT_TYPE} from '@mattermost/types/properties_user';
import type {Team} from '@mattermost/types/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -33,7 +34,7 @@ import Constants from 'utils/constants';
import ChannelList from './channel_list';
import CELEditor from '../editors/cel_editor/editor';
import {excludeSessionAttributes, hasUsableAttributes, isSimpleExpression, toCELEditorAttributes, MASKED_VALUE_TOKEN_LITERAL} from '../editors/shared';
import {excludeSessionAttributes, hasUsableAttributes, isSimpleExpression, referencesResourceAttributes, toCELEditorAttributes, MASKED_VALUE_TOKEN_LITERAL} from '../editors/shared';
import TableEditor from '../editors/table_editor/table_editor';
import PolicyConfirmationModal from '../modals/confirmation/confirmation_modal';
@@ -82,7 +83,17 @@ function PolicyDetails({
accessControlSettings,
}: PolicyDetailsProps): JSX.Element {
const [policyName, setPolicyName] = useState(policy?.name || '');
const [expression, setExpression] = useState(getMembershipRule(policy?.rules)?.expression || '');
// Deliberately not seeded from `policy`: that copy comes from the policies
// list, which is filled by the search endpoint, and search returns a rule in
// its *stored* form. A rank comparison is stored desugared into the marker
// call `_rank_ge(user.attributes.x, "Secret", "<fieldID>")`, which
// /cel/visual_ast rejects — so seeding it makes TableEditor fire a doomed
// parse on mount, and whether that 400 or fetchPolicy below resolves first
// decides whether the editor flips to Advanced mode. fetchPolicy (the single
// GET, which rehydrates markers back to operators) is the only source, and it
// sets every other field seeded here too.
const [expression, setExpression] = useState('');
const [existingRules, setExistingRules] = useState<AccessControlPolicyRule[]>(policy?.rules || []);
const [autoSyncMembership, setAutoSyncMembership] = useState(policy?.active || false);
const [serverError, setServerError] = useState<string | undefined>(undefined);
@@ -145,8 +156,26 @@ function PolicyDetails({
</div>
), []);
// Check if there are any usable attributes for ABAC
const noUsableAttributes = attributesLoaded && !hasUsableAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes);
// The autocomplete mixes the requesting user's attributes (user.attributes.*)
// and the accessed channel's attributes (resource.attributes.*), tagged by
// object_type. Split them: user fields drive the left picker and the
// user.attributes.* autocomplete; channel fields back resource.attributes.*.
const {userFields, resourceFields} = useMemo(() => {
const uf: UserPropertyField[] = [];
const rf: UserPropertyField[] = [];
for (const f of autocompleteResult) {
if (f.object_type === CHANNEL_ATTRIBUTES_OBJECT_TYPE) {
rf.push(f);
} else {
uf.push(f);
}
}
return {userFields: uf, resourceFields: rf};
}, [autocompleteResult]);
// Check if there are any usable user attributes for ABAC (channel fields
// are comparison targets, not standalone rules, so they don't count).
const noUsableAttributes = attributesLoaded && !hasUsableAttributes(userFields, accessControlSettings.EnableUserManagedAttributes);
useEffect(() => {
loadPage();
@@ -164,7 +193,8 @@ function PolicyDetails({
const loadPage = async (): Promise<void> => {
// Fetch autocomplete fields first, as they are general and needed for both new and existing policies.
const fieldsPromise = abacActions.getAccessControlFields('', 100).then((result) => {
// Parent policies reference resource.attributes.* against many channels, so request channel fields too.
const fieldsPromise = abacActions.getAccessControlFields('', 100, true).then((result) => {
if (result.data) {
setAutocompleteResult(excludeSessionAttributes(result.data));
}
@@ -425,6 +455,14 @@ function PolicyDetails({
);
};
// A channel that has no value for a referenced channel attribute cannot be
// evaluated, and the server fails closed by denying the whole rule — so every
// member of that channel loses access. Warn as soon as the rule references any
// channel attribute, before any channel is assigned, without checking which
// channels actually carry a value: reading that would cost one property-values
// request per assigned channel (the endpoint takes a single target id).
const showChannelAttributeWarning = referencesResourceAttributes(expression);
// Deletion is blocked while the policy still has ANY assigned resource —
// channels or teams. Teams aren't editable from this editor (MVF), so a
// linked team must be removed from the per-team System Console page first.
@@ -522,6 +560,21 @@ function PolicyDetails({
}}
/>
</div>)}
{showChannelAttributeWarning && (<div className='admin-console__warning-notice'>
<SectionNotice
type='warning'
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_attribute_notice.title'
defaultMessage='Channels without this attribute lose all members'
/>
}
text={formatMessage({
id: 'admin.access_control.policy.edit_policy.channel_attribute_notice.text',
defaultMessage: 'If an assigned channel is missing the referenced attribute, every member of that channel is removed.',
})}
/>
</div>)}
<Card
expanded={true}
className={'console'}
@@ -601,7 +654,10 @@ function PolicyDetails({
onValidate={() => {}}
disabled={noUsableAttributes}
hasMaskedRows={hasMaskedRows}
userAttributes={toCELEditorAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes)}
userAttributes={toCELEditorAttributes(userFields, accessControlSettings.EnableUserManagedAttributes)}
resourceAttributes={resourceFields.map((attr) => ({
attribute: attr.name,
}))}
/>
) : (
<TableEditor
@@ -28,6 +28,9 @@ import {
CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID,
CLASSIFICATIONS_TEMPLATE_FIELD_NAME,
CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
CLASSIFICATIONS_USER_OBJECT_TYPE,
CLEARANCE_FIELD_DISPLAY_NAME,
CLEARANCE_FIELD_NAME,
DISPLAY_BANNER_BOTTOM,
DISPLAY_BANNER_TOP,
} from './utils';
@@ -39,6 +42,11 @@ const BASE_STATE = {entities: {users: {currentUserId: MOCK_USER_ID}}};
jest.mock('mattermost-redux/client');
const mockHistoryPush = jest.fn();
jest.mock('utils/browser_history', () => ({
getHistory: () => ({push: mockHistoryPush}),
}));
function makePropertyField(overrides: Partial<PropertyField> = {}): PropertyField {
return {
id: 'field1',
@@ -98,6 +106,35 @@ function makeChannelLinkedField(overrides: Partial<PropertyField> = {}): Propert
};
}
// A "Clearance" user field linked to the classification template ('field1').
function makeUserLinkedField(overrides: Partial<PropertyField> = {}): PropertyField {
return {
id: 'clearance_field1',
group_id: CLASSIFICATIONS_GROUP_NAME,
name: CLEARANCE_FIELD_NAME,
type: 'rank',
attrs: {},
target_id: '',
target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE,
object_type: CLASSIFICATIONS_USER_OBJECT_TYPE,
linked_field_id: 'field1',
create_at: 5000,
update_at: 5000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
...overrides,
};
}
// State with ABAC enabled, which reveals the clearance attribute checkbox.
const ABAC_STATE = {
entities: {
users: {currentUserId: MOCK_USER_ID},
admin: {config: {AccessControlSettings: {EnableAttributeBasedAccessControl: true}}},
},
};
function makeSystemValue(fieldId: string, optionId: string): PropertyValue<string> {
return {
id: 'value1',
@@ -471,6 +508,57 @@ describe('ClassificationMarkings component', () => {
).toBeInTheDocument();
});
test('should hide the informational notice while the clearance attribute is enabled', async () => {
const field = makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}});
const linked = makeLinkedField({attrs: {actions: []}});
const channel = makeChannelLinkedField();
const clearance = makeUserLinkedField();
// Clearance exists, so the levels are enforced and the notice is untrue.
// Second user-object-type page comes back empty to end pagination.
let userCalls = 0;
jest.spyOn(Client4, 'getPropertyFields').mockImplementation(async (_group, objectType) => {
switch (objectType) {
case CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE:
return [field];
case CLASSIFICATIONS_SYSTEM_OBJECT_TYPE:
return [linked];
case CLASSIFICATIONS_CHANNEL_OBJECT_TYPE:
return [channel];
default:
return (userCalls++ % 2 === 0) ? [clearance] : [];
}
});
renderWithContext(<ClassificationMarkings/>, ABAC_STATE);
await screen.findByTestId('clearanceAttributeCheckbox');
expect(screen.getByTestId('clearanceAttributeCheckbox')).toBeChecked();
expect(
screen.queryByRole('heading', {name: 'Classification markings are informational only'}),
).not.toBeInTheDocument();
// ...and it comes back the moment enforcement is turned off again.
await userEvent.setup().click(screen.getByTestId('clearanceAttributeCheckbox'));
expect(
await screen.findByRole('heading', {name: 'Classification markings are informational only'}),
).toBeInTheDocument();
await act(async () => {});
});
test('should navigate to the membership policies page from the clearance help text', async () => {
const field = makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}});
jest.spyOn(Client4, 'getPropertyFields').mockImplementation(async (_group, objectType) => {
return objectType === CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE ? [field] : [];
});
renderWithContext(<ClassificationMarkings/>, ABAC_STATE);
await screen.findByTestId('clearanceAttributeCheckbox');
await userEvent.setup().click(screen.getByText('membership policy'));
expect(mockHistoryPush).toHaveBeenCalledWith('/admin_console/system_attributes/membership_policies');
});
test('should render disabled state when no existing field', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
@@ -1055,7 +1143,8 @@ describe('GlobalClassificationIndicators section', () => {
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]).
mockResolvedValueOnce([]);
mockResolvedValueOnce([]). // channel-linked field lookup during disable -> none
mockResolvedValueOnce([]); // clearance user field lookup during disable -> none
const deleteOrder: string[] = [];
const deleteFieldSpy = jest.spyOn(Client4, 'deletePropertyField');
@@ -1205,6 +1294,111 @@ describe('Channel classification linked field branches', () => {
expect(fieldsById[existingChannelField.id]).toEqual(existingChannelField);
});
test('should create the linked Clearance user field on save when the clearance checkbox is enabled (ABAC on)', async () => {
const field = makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}});
const linked = makeLinkedField({attrs: {actions: []}});
const channel = makeChannelLinkedField();
// No existing clearance field; everything else already exists (patch, not create).
jest.spyOn(Client4, 'getPropertyFields').mockImplementation(async (_group, objectType) => {
switch (objectType) {
case CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE:
return [field];
case CLASSIFICATIONS_SYSTEM_OBJECT_TYPE:
return [linked];
case CLASSIFICATIONS_CHANNEL_OBJECT_TYPE:
return [channel];
default:
return []; // user object type: no clearance yet
}
});
jest.spyOn(Client4, 'patchPropertyField').
mockResolvedValueOnce(makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}})).
mockResolvedValueOnce(makeLinkedField({attrs: {actions: []}}));
const createdClearance = makeUserLinkedField();
const createSpy = jest.spyOn(Client4, 'createPropertyField').mockResolvedValue(createdClearance);
const {store} = renderWithContext(<ClassificationMarkings/>, ABAC_STATE);
await screen.findByTestId('clearanceAttributeCheckbox');
const user = userEvent.setup();
await user.click(screen.getByTestId('clearanceAttributeCheckbox'));
await user.click(await screen.findByText('Save'));
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
CLASSIFICATIONS_GROUP_NAME,
CLASSIFICATIONS_USER_OBJECT_TYPE,
expect.objectContaining({
name: CLEARANCE_FIELD_NAME,
type: 'rank',
linked_field_id: field.id,
attrs: expect.objectContaining({managed: 'admin', display_name: CLEARANCE_FIELD_DISPLAY_NAME}),
}),
);
});
await act(async () => {});
// Pushed into Redux eagerly, like every other field this save touches, so
// a consumer reading the properties slice sees it without a reload.
expect(store.getState().entities.properties.fields.byId[createdClearance.id]).toEqual(createdClearance);
});
test('should delete the linked Clearance user field on save when the clearance checkbox is disabled (ABAC on)', async () => {
const field = makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}});
const linked = makeLinkedField({attrs: {actions: []}});
const channel = makeChannelLinkedField();
const clearance = makeUserLinkedField();
// Two linked clearance fields: this UI only ever creates one, but every
// match must be deleted — leaving one behind would keep enforcement live
// while the saved state records it as off.
const extraClearance = makeUserLinkedField({id: 'clearance_field2', name: 'clearance_dupe'});
// Clearance exists: return both on the first user-object-type page, then an
// empty page to end pagination (per fetchUserLinkedFields invocation).
let userCalls = 0;
jest.spyOn(Client4, 'getPropertyFields').mockImplementation(async (_group, objectType) => {
switch (objectType) {
case CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE:
return [field];
case CLASSIFICATIONS_SYSTEM_OBJECT_TYPE:
return [linked];
case CLASSIFICATIONS_CHANNEL_OBJECT_TYPE:
return [channel];
default:
return (userCalls++ % 2 === 0) ? [clearance, extraClearance] : [];
}
});
jest.spyOn(Client4, 'patchPropertyField').
mockResolvedValueOnce(makePropertyField({attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}})).
mockResolvedValueOnce(makeLinkedField({attrs: {actions: []}}));
const deleteSpy = jest.spyOn(Client4, 'deletePropertyField').mockResolvedValue({status: 'OK'});
renderWithContext(<ClassificationMarkings/>, ABAC_STATE);
await screen.findByTestId('clearanceAttributeCheckbox');
const user = userEvent.setup();
const checkbox = screen.getByTestId('clearanceAttributeCheckbox');
expect(checkbox).toBeChecked();
await user.click(checkbox);
await user.click(await screen.findByText('Save'));
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalledWith(
CLASSIFICATIONS_GROUP_NAME,
CLASSIFICATIONS_USER_OBJECT_TYPE,
clearance.id,
);
expect(deleteSpy).toHaveBeenCalledWith(
CLASSIFICATIONS_GROUP_NAME,
CLASSIFICATIONS_USER_OBJECT_TYPE,
extraClearance.id,
);
});
await act(async () => {});
});
test('should delete channel-linked field before linked and template when disabling', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
@@ -1215,7 +1409,8 @@ describe('Channel classification linked field branches', () => {
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]). // template field load
mockResolvedValueOnce([linked]). // linked field load
mockResolvedValueOnce([channel]); // channel field lookup during disable
mockResolvedValueOnce([channel]). // channel field lookup during disable
mockResolvedValueOnce([]); // clearance user field lookup during disable -> none
const deleteOrder: string[] = [];
jest.spyOn(Client4, 'deletePropertyField').mockImplementation(async (_group, objectType, id) => {
@@ -1274,7 +1469,8 @@ describe('Channel classification linked field branches', () => {
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]).
mockResolvedValueOnce([]); // no channel field exists
mockResolvedValueOnce([]). // no channel field exists
mockResolvedValueOnce([]); // no clearance user field exists
const deletedTypes: string[] = [];
jest.spyOn(Client4, 'deletePropertyField').mockImplementation(async (_group, objectType) => {
@@ -1310,6 +1506,64 @@ describe('Channel classification linked field branches', () => {
console.error = origError;
}
});
test('should delete an existing clearance field when disabling even with ABAC off', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({attrs: {actions: []}});
const clearance = makeUserLinkedField();
// ABAC is off now, but the clearance field was created while it was on.
// Skipping its deletion would leave a dependent and fail the template delete.
let userCalls = 0;
jest.spyOn(Client4, 'getPropertyFields').mockImplementation(async (_group, objectType) => {
switch (objectType) {
case CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE:
return [field];
case CLASSIFICATIONS_SYSTEM_OBJECT_TYPE:
return [linked];
case CLASSIFICATIONS_CHANNEL_OBJECT_TYPE:
return [];
default:
return (userCalls++ % 2 === 0) ? [clearance] : [];
}
});
const deletedIds: string[] = [];
jest.spyOn(Client4, 'deletePropertyField').mockImplementation(async (_group, _objectType, id) => {
deletedIds.push(id);
return {status: 'OK'};
});
const origError = console.error;
console.error = (...args: Parameters<typeof console.error>) => {
if (typeof args[0] === 'string' && args[0].includes('not configured to support act')) {
return;
}
origError(...args);
};
try {
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Global Classification Indicators');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByTestId('classificationEnabledfalse'));
});
await act(async () => {
await user.click(screen.getByText('Save'));
});
await waitFor(() => {
expect(deletedIds).toEqual([clearance.id, linked.id, field.id]);
});
} finally {
console.error = origError;
}
});
});
describe('Custom preset caching and dropdown visibility', () => {
@@ -10,6 +10,7 @@ import {PlusIcon} from '@mattermost/compass-icons/components';
import type {PropertyField} from '@mattermost/types/properties';
import PropertyTypes from 'mattermost-redux/action_types/properties';
import {getAccessControlSettings} from 'mattermost-redux/selectors/entities/access_control';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setNavigationBlocked} from 'actions/admin_actions';
@@ -23,6 +24,8 @@ import LoadingScreen from 'components/loading_screen';
import SectionNotice from 'components/section_notice';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {getHistory} from 'utils/browser_history';
import {
AddLevelButton,
AddLevelButtonRow,
@@ -34,6 +37,8 @@ import ClassificationLevelsTable from './components/classification_levels_table'
import GlobalClassificationIndicators from './components/global_classification_indicators';
import type {GlobalBannerConfig} from './utils';
import {
CLEARANCE_FIELD_DISPLAY_NAME,
CLEARANCE_FIELD_NAME,
DEFAULT_GLOBAL_BANNER,
DISPLAY_BANNER_TOP,
actionsToGlobalBanner,
@@ -41,13 +46,16 @@ import {
fetchClassificationField,
fetchLinkedClassificationField,
fetchSystemClassificationValue,
fetchUserLinkedFields,
processClassificationField,
saveCreateChannelLinkedField,
saveCreateField,
saveCreateLinkedField,
saveCreateUserLinkedField,
saveDeleteChannelLinkedField,
saveDeleteField,
saveDeleteLinkedField,
saveDeleteUserLinkedField,
savePatchField,
savePatchLinkedField,
saveUpsertSystemValue,
@@ -59,12 +67,17 @@ import {PENDING_LEVEL_PREFIX, PRESET_CUSTOM, PRESET_EMPTY, presets} from './util
import SaveChangesPanel from '../save_changes_panel';
import {AdminSection, AdminWrapper, SectionHeader, SectionHeading} from '../system_properties/controls';
const MEMBERSHIP_POLICIES_URL = '/admin_console/system_attributes/membership_policies';
const msg = defineMessages({
pageTitle: {id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'},
enableTitle: {id: 'admin.classification_markings.enable.title', defaultMessage: 'Enable classification markings'},
enableDescription: {id: 'admin.classification_markings.enable.description', defaultMessage: 'Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.'},
presetTitle: {id: 'admin.classification_markings.preset.title', defaultMessage: 'Classification preset'},
presetDescription: {id: 'admin.classification_markings.preset.description', defaultMessage: 'Select a classification preset from the dropdown menu based on your country affiliation. This will help tailor the options to your specific needs. You can also create custom classification levels.'},
clearanceTitle: {id: 'admin.classification_markings.enforcement.clearance.title', defaultMessage: 'Clearance attribute'},
clearanceCheckbox: {id: 'admin.classification_markings.enforcement.clearance.checkbox', defaultMessage: 'Enable clearance attribute'},
clearanceHelp: {id: 'admin.classification_markings.enforcement.clearance.help', defaultMessage: 'Creates a ranked "Clearance" user attribute linked to these classification levels. Channel membership can then be managed with a corresponding <link>membership policy</link>.'},
levelsTitle: {id: 'admin.classification_markings.levels.title', defaultMessage: 'Classification levels'},
levelsDescription: {id: 'admin.classification_markings.levels.description', defaultMessage: 'Text and colors for different classification levels that will be used in the system'},
informationalNoticeTitle: {id: 'admin.classification_markings.notice.title', defaultMessage: 'Classification markings are informational only'},
@@ -93,6 +106,7 @@ export default function ClassificationMarkings({disabled}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const currentUserId = useSelector(getCurrentUserId);
const abacEnabled = Boolean(useSelector(getAccessControlSettings)?.EnableAttributeBasedAccessControl);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string>();
@@ -102,6 +116,8 @@ export default function ClassificationMarkings({disabled}: Props) {
const [existingLinkedField, setExistingLinkedField] = useState<PropertyField | null>(null);
const [enabled, setEnabled] = useState(false);
const [clearanceEnabled, setClearanceEnabled] = useState(false);
const [initialClearanceEnabled, setInitialClearanceEnabled] = useState(false);
const [presetId, setPresetId] = useState<string>(PRESET_EMPTY);
const [levels, setLevels] = useState<ClassificationLevel[]>([]);
const [globalBanner, setGlobalBanner] = useState<GlobalBannerConfig>({...DEFAULT_GLOBAL_BANNER});
@@ -120,6 +136,9 @@ export default function ClassificationMarkings({disabled}: Props) {
if (!enabled) {
return false;
}
if (clearanceEnabled !== initialClearanceEnabled) {
return true;
}
if (
globalBanner.enabled !== initialGlobalBanner.enabled ||
globalBanner.placement !== initialGlobalBanner.placement ||
@@ -134,7 +153,7 @@ export default function ClassificationMarkings({disabled}: Props) {
const initial = initialLevels[i];
return level.name !== initial.name || level.color !== initial.color || level.id !== initial.id || level.rank !== initial.rank;
});
}, [enabled, initialEnabled, levels, initialLevels, globalBanner, initialGlobalBanner]);
}, [enabled, initialEnabled, clearanceEnabled, initialClearanceEnabled, levels, initialLevels, globalBanner, initialGlobalBanner]);
useEffect(() => {
dispatch(setNavigationBlocked(hasChanges));
@@ -176,8 +195,21 @@ export default function ClassificationMarkings({disabled}: Props) {
banner = actionsToGlobalBanner(actions, levelId);
}
// Clearance is an ABAC-only concept; only probe for it when
// ABAC is on (keeps the load path untouched otherwise).
let hasClearance = false;
if (abacEnabled) {
const clearanceFields = await fetchUserLinkedFields(field.id);
if (cancelled) {
return;
}
hasClearance = clearanceFields.length > 0;
}
setExistingField(field);
setExistingLinkedField(linkedField ?? null);
setClearanceEnabled(hasClearance);
setInitialClearanceEnabled(hasClearance);
setEnabled(true);
setInitialEnabled(true);
setLevels(result.levels);
@@ -205,12 +237,28 @@ export default function ClassificationMarkings({disabled}: Props) {
return () => {
cancelled = true;
};
}, [currentUserId]);
}, [currentUserId, abacEnabled]);
const handleClassificationEnabledChange = useCallback((_id: string, value: boolean) => {
setEnabled(value);
}, []);
const handleClearanceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setClearanceEnabled(e.target.checked);
}, []);
const membershipPolicyLink = useCallback((chunks: React.ReactNode) => (
<a
href={MEMBERSHIP_POLICIES_URL}
onClick={(e) => {
e.preventDefault();
getHistory().push(MEMBERSHIP_POLICIES_URL);
}}
>
{chunks}
</a>
), []);
const applyPreset = useCallback((newPresetId: string) => {
if (newPresetId === PRESET_CUSTOM) {
setPresetId(PRESET_CUSTOM);
@@ -422,8 +470,30 @@ export default function ClassificationMarkings({disabled}: Props) {
dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedTemplate, savedLinked, savedChannelField]}});
}
// Clearance user field: create or delete to match the checkbox. The
// template exists now (savedTemplate), which the linked field
// requires — this is why creation is deferred to save. Re-fetch to
// avoid duplicating if the initial load missed it. ABAC-gated to
// match the section's visibility.
if (abacEnabled) {
// Delete every match, not just the first: this UI creates one, but a
// second linked field made another way would otherwise survive and
// reappear on reload while enforcement records itself as disabled.
const currentClearance = await fetchUserLinkedFields(savedTemplate.id);
if (clearanceEnabled && currentClearance.length === 0) {
const savedClearance = await saveCreateUserLinkedField(savedTemplate.id, CLEARANCE_FIELD_NAME, CLEARANCE_FIELD_DISPLAY_NAME);
dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedClearance]}});
} else if (!clearanceEnabled) {
for (const cf of currentClearance) {
await saveDeleteUserLinkedField(cf.id); // eslint-disable-line no-await-in-loop
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: cf.id}});
}
}
}
setExistingField(savedTemplate);
setExistingLinkedField(savedLinked);
setInitialClearanceEnabled(clearanceEnabled);
setLevels(result.levels);
setInitialLevels(result.levels);
setPresetId(result.presetId);
@@ -432,12 +502,21 @@ export default function ClassificationMarkings({disabled}: Props) {
setInitialEnabled(true);
} else if (templateField) {
// Linked fields must be deleted before the template (deletion protection).
// Order: channel field -> system field -> template.
// Order: channel field -> clearance user field -> system field -> template.
const channelField = await fetchChannelClassificationField();
if (channelField) {
await saveDeleteChannelLinkedField(channelField.id);
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: channelField.id}});
}
// Not ABAC-gated, unlike the create path: a clearance field created
// while ABAC was on outlives the setting, and leaving it behind makes
// the template delete below fail on its dependents.
const clearanceFields = await fetchUserLinkedFields(templateField.id);
for (const cf of clearanceFields) {
await saveDeleteUserLinkedField(cf.id); // eslint-disable-line no-await-in-loop
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: cf.id}});
}
if (linkedField) {
await saveDeleteLinkedField(linkedField.id);
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: linkedField.id}});
@@ -447,6 +526,8 @@ export default function ClassificationMarkings({disabled}: Props) {
setExistingField(null);
setExistingLinkedField(null);
setClearanceEnabled(false);
setInitialClearanceEnabled(false);
setInitialEnabled(false);
setInitialLevels([]);
setLevels([]);
@@ -455,7 +536,7 @@ export default function ClassificationMarkings({disabled}: Props) {
setGlobalBanner({...DEFAULT_GLOBAL_BANNER});
setInitialGlobalBanner({...DEFAULT_GLOBAL_BANNER});
}
}, [enabled, existingField, existingLinkedField, levels, globalBanner, dispatch]);
}, [enabled, abacEnabled, clearanceEnabled, existingField, existingLinkedField, levels, globalBanner, dispatch]);
const handleSave = useCallback(async () => {
setSaveError(undefined);
@@ -521,14 +602,19 @@ export default function ClassificationMarkings({disabled}: Props) {
<FormattedMessage {...msg.pageTitle}/>
</AdminHeader>
<AdminWrapper>
<InformationNoticeWrapper>
<SectionNotice
type='warning'
iconOverride='icon-information-outline'
title={<FormattedMessage {...msg.informationalNoticeTitle}/>}
text={formatMessage(msg.informationalNoticeBody)}
/>
</InformationNoticeWrapper>
{/* Only true while nothing enforces the levels. The clearance attribute
feeds a membership policy, so once it is on the markings do decide
access and this notice would contradict the section below. */}
{!clearanceEnabled && (
<InformationNoticeWrapper>
<SectionNotice
type='warning'
iconOverride='icon-information-outline'
title={<FormattedMessage {...msg.informationalNoticeTitle}/>}
text={formatMessage(msg.informationalNoticeBody)}
/>
</InformationNoticeWrapper>
)}
<form
className='form-horizontal'
onSubmit={(e) => e.preventDefault()}
@@ -577,6 +663,30 @@ export default function ClassificationMarkings({disabled}: Props) {
</PresetDropdownWrapper>
</Setting>
)}
{enabled && abacEnabled && (
<Setting
inputId='clearanceAttribute'
label={<FormattedMessage {...msg.clearanceTitle}/>}
helpText={
<FormattedMessage
{...msg.clearanceHelp}
values={{link: membershipPolicyLink}}
/>
}
setByEnv={false}
>
<label className='checkbox-inline'>
<input
data-testid='clearanceAttributeCheckbox'
type='checkbox'
checked={clearanceEnabled}
onChange={handleClearanceChange}
disabled={disabled}
/>
<FormattedMessage {...msg.clearanceCheckbox}/>
</label>
</Setting>
)}
</form>
{enabled && (
@@ -338,3 +338,80 @@ export async function saveCreateChannelLinkedField(templateFieldId: string): Pro
export async function saveDeleteChannelLinkedField(fieldId: string): Promise<void> {
await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, fieldId);
}
// --- User field API (clearance attribute for classification enforcement) ---
//
// A user field linked to the template mirrors the classification levels as its
// option scale, so ABAC policies can gate access with `user.attributes.<name>`
// compared against a channel's classification. Values are inherited wholesale
// from the template (no per-level mapping), exactly like the channel field.
export const CLASSIFICATIONS_USER_OBJECT_TYPE = 'user';
// Default name/label for the clearance field created from this page. The name
// is fixed: CEL rule authors write it directly as user.attributes.clearance, so
// renaming it would break existing rules. It is lowercase like
// CLASSIFICATIONS_CHANNEL_FIELD_NAME; the display name is the label the System
// Console and profile popovers show.
export const CLEARANCE_FIELD_NAME = 'clearance';
export const CLEARANCE_FIELD_DISPLAY_NAME = 'Clearance';
/**
* Fetches every live user field linked to the given classification template.
* The enforcement checkbox is on when this returns anything, and disabling
* classification markings deletes all of them, so it returns the whole set
* rather than just the first match.
*/
export async function fetchUserLinkedFields(templateFieldId: string): Promise<PropertyField[]> {
const maxItems = 500;
let fetched = 0;
let cursorId: string | undefined;
let cursorCreateAt: number | undefined;
const matches: PropertyField[] = [];
while (fetched < maxItems) {
const fields = await Client4.getPropertyFields( // eslint-disable-line no-await-in-loop
CLASSIFICATIONS_GROUP_NAME,
CLASSIFICATIONS_USER_OBJECT_TYPE,
CLASSIFICATIONS_FIELD_TARGET_TYPE,
CLASSIFICATIONS_FIELD_TARGET_ID,
{cursorId, cursorCreateAt},
);
for (const f of fields) {
if (f.delete_at === 0 && f.linked_field_id === templateFieldId) {
matches.push(f);
}
}
if (fields.length === 0) {
return matches;
}
fetched += fields.length;
const last = fields[fields.length - 1];
cursorId = last.id;
cursorCreateAt = last.create_at;
}
return matches;
}
export async function saveCreateUserLinkedField(templateFieldId: string, name: string, displayName: string): Promise<PropertyField> {
return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_USER_OBJECT_TYPE, {
name,
type: 'rank' as PropertyField['type'],
target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE,
target_id: CLASSIFICATIONS_FIELD_TARGET_ID,
linked_field_id: templateFieldId,
// Admin-managed: clearance is assigned by an admin/integration, so users
// cannot self-edit their own value.
attrs: {managed: 'admin', display_name: displayName},
permission_field: 'admin',
permission_values: 'admin',
permission_options: 'admin',
});
}
export async function saveDeleteUserLinkedField(fieldId: string): Promise<void> {
await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_USER_OBJECT_TYPE, fieldId);
}
@@ -153,6 +153,28 @@ describe('components/admin_console/permission_policies/policy_details/Permission
jest.clearAllMocks();
});
test('shows the load failure and hides the editor when fetchPolicy errors, even with a store-copy policy prop', async () => {
mockFetchPolicy.mockResolvedValue({error: {message: 'failed'}});
renderWithContext(
<PermissionPolicyDetails
{...baseProps}
policy={{
id: 'policy1',
name: 'Policy 1',
roles: ['system_user'],
rules: [{actions: ['download_file_attachment'], expression: 'user.attributes.teams == "engineering"'}],
type: 'permission',
}}
/>,
);
expect(await screen.findByText('Failed to load policy')).toBeInTheDocument();
expect(screen.getByText('failed')).toBeInTheDocument();
expect(screen.queryByTestId('table-editor')).not.toBeInTheDocument();
expect(screen.queryByTestId('cel-editor')).not.toBeInTheDocument();
});
test('merges the fetched enabled session attributes into the table-mode picker', async () => {
renderWithContext(<PermissionPolicyDetails {...baseProps}/>);
@@ -11,6 +11,7 @@ import {buttonClassNames} from '@mattermost/shared/components/button';
import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control';
import type {AccessControlSettings} from '@mattermost/types/config';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {CHANNEL_ATTRIBUTES_OBJECT_TYPE} from '@mattermost/types/properties_user';
import {isPolicySimulationEnabled} from 'mattermost-redux/selectors/entities/general';
import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -118,7 +119,13 @@ function PermissionPolicyDetails({
sessionAttributesEnabled,
}: PermissionPolicyDetailsProps): JSX.Element {
const [policyName, setPolicyName] = useState(policy?.name || '');
const [expression, setExpression] = useState(policy?.rules?.[0]?.expression || '');
// Not seeded from `policy`: the list leaves the search endpoint's copy in the
// store, and search returns rules in their stored form — a rank comparison is
// stored desugared as `_rank_ge(...)`, which /cel/visual_ast rejects, so the
// editor would fire a doomed parse on mount. fetchPolicy below is the only
// source; it also sets the name, role and permissions seeded here.
const [expression, setExpression] = useState('');
const [selectedRole, setSelectedRole] = useState(policy?.roles?.[0] || 'system_user');
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(
getPermissionActions(policy?.rules || []),
@@ -131,6 +138,7 @@ function PermissionPolicyDetails({
const [attributesLoaded, setAttributesLoaded] = useState(false);
const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false);
const [pageLoaded, setPageLoaded] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [showTest, setShowTest] = useState(false);
const {formatMessage} = useIntl();
@@ -143,9 +151,27 @@ function PermissionPolicyDetails({
// the channel-settings Permissions Policy tab.
const policySimulationEnabled = useSelector(isPolicySimulationEnabled);
// The autocomplete mixes the requesting user's attributes (user.attributes.*)
// and the accessed channel's attributes (resource.attributes.*), tagged by
// object_type. Permission policies are channel-scoped, so they may reference
// resource.attributes.*; split so user fields drive rules and channel fields
// are comparison targets.
const {userFields, resourceFields} = useMemo(() => {
const uf: UserPropertyField[] = [];
const rf: UserPropertyField[] = [];
for (const f of autocompleteResult) {
if (f.object_type === CHANNEL_ATTRIBUTES_OBJECT_TYPE) {
rf.push(f);
} else {
uf.push(f);
}
}
return {userFields: uf, resourceFields: rf};
}, [autocompleteResult]);
// Permission policies can reference session attributes (e.g. user.session.ip_address),
// so the editor stays usable even without any configured user attributes when SessionAttributes is on.
const noUsableAttributes = attributesLoaded && !sessionAttributesEnabled && !hasUsableAttributes(autocompleteResult, accessControlSettings.EnableUserManagedAttributes);
const noUsableAttributes = attributesLoaded && !sessionAttributesEnabled && !hasUsableAttributes(userFields, accessControlSettings.EnableUserManagedAttributes);
const sessionFields = useEnabledSessionAttributeFields(sessionAttributesEnabled);
const mergedAttributes = useMemo(
@@ -162,7 +188,11 @@ function PermissionPolicyDetails({
// are recognized as simple and open in table mode.
const loadPage = async (): Promise<void> => {
const fieldsPromise = abacActions.getAccessControlFields('', 100).then((result) => {
setLoadFailed(false);
// Permission policies can reference resource.attributes.* (the accessed
// channel), so request channel fields too.
const fieldsPromise = abacActions.getAccessControlFields('', 100, true).then((result) => {
if (result.data) {
setAutocompleteResult(result.data);
}
@@ -172,6 +202,7 @@ function PermissionPolicyDetails({
if (policyId) {
const policyPromise = actions.fetchPolicy(policyId).then((result: ActionResult) => {
if (result.error) {
setLoadFailed(true);
setServerError(result.error.message || formatMessage({
id: 'admin.permission_policies.edit.error.load',
defaultMessage: 'Failed to load policy',
@@ -316,7 +347,23 @@ function PermissionPolicyDetails({
/>
</div>
</AdminHeader>
{pageLoaded ? (
{pageLoaded && loadFailed && (
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
<div className='admin-console__warning-notice'>
<SectionNotice
type='danger'
title={formatMessage({
id: 'admin.permission_policies.edit.error.load',
defaultMessage: 'Failed to load policy',
})}
text={serverError}
/>
</div>
</div>
</div>
)}
{pageLoaded && !loadFailed && (
<>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
@@ -538,7 +585,10 @@ function PermissionPolicyDetails({
}}
onValidate={() => {}}
disabled={noUsableAttributes}
userAttributes={toCELEditorAttributes(mergedAttributes, accessControlSettings.EnableUserManagedAttributes)}
userAttributes={toCELEditorAttributes(mergeSessionAttributes(userFields, sessionFields), accessControlSettings.EnableUserManagedAttributes)}
resourceAttributes={resourceFields.map((attr) => ({
attribute: attr.name,
}))}
// Both editor modes route the test
// button through SimulateAccessModal:
@@ -830,7 +880,8 @@ function PermissionPolicyDetails({
)}
</div>
</>
) : (
)}
{!pageLoaded && (
<div className='admin-console__wrapper'>
<div className='admin-console__content'/>
</div>
@@ -418,6 +418,47 @@ describe('UserPropertyDotMenu', () => {
expect(updateField).not.toHaveBeenCalled();
});
it('disables the options-editing actions for a template-linked field but leaves the rest editable', async () => {
const linkedField: UserPropertyField = {
...baseField,
id: 'template-linked-field',
type: 'rank',
linked_field_id: 'template-field-id',
};
renderComponent(linkedField);
await userEvent.click(screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`));
// type and options are owned by the template
expect(screen.getByRole('menuitem', {name: /Edit ranking/})).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('menuitem', {name: /Link attribute to AD\/LDAP/})).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('menuitem', {name: /Link attribute to SAML/})).toHaveAttribute('aria-disabled', 'true');
expect(screen.getAllByText('Managed by a linked attribute template')).toHaveLength(3);
// everything else stays editable
expect(screen.getByRole('menuitem', {name: /Visibility/})).not.toHaveAttribute('aria-disabled', 'true');
expect(within(screen.getByRole('menuitemcheckbox', {name: /Editable by users/})).getByRole('button')).toBeEnabled();
expect(screen.getByRole('menuitem', {name: /Duplicate attribute/})).not.toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('menuitem', {name: /Delete attribute/})).not.toHaveAttribute('aria-disabled', 'true');
});
it('does not open the ranking modal for a template-linked field', async () => {
const linkedField: UserPropertyField = {
...baseField,
id: 'template-linked-ranking',
type: 'rank',
linked_field_id: 'template-field-id',
};
renderComponent(linkedField);
await userEvent.click(screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`));
screen.getByRole('menuitem', {name: /Edit ranking/}).click();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('handles field duplication', async () => {
renderComponent();
@@ -439,6 +480,28 @@ describe('UserPropertyDotMenu', () => {
});
});
it('duplicating a template-linked field produces an unlinked copy', async () => {
const linkedField: UserPropertyField = {
...baseField,
id: 'template-linked-duplicate',
type: 'rank',
linked_field_id: 'template-field-id',
};
renderComponent(linkedField);
await userEvent.click(screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`));
await userEvent.click(screen.getByText(/Duplicate attribute/));
// A copy is a standalone field. Carrying the link over would make it
// inherit a type and option set the create request cannot send, and would
// leave a second dependent on the template.
await waitFor(() => {
expect(createField).toHaveBeenCalledWith(expect.objectContaining({name: 'test_field_copy'}));
});
expect(createField.mock.calls[0][0]).not.toHaveProperty('linked_field_id');
});
it('duplicate produces _2 suffix when base name is already taken', async () => {
const existingCopy = {
...baseField,
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactElement, ReactNode} from 'react';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
@@ -20,7 +21,7 @@ import {slugifyForCEL} from 'utils/properties';
import AttributeModal from './attribute_modal';
import RankedSchemaModal from './ranked_schema_modal';
import {useUserPropertyFieldDelete} from './user_properties_delete_modal';
import {isCreatePending} from './user_properties_utils';
import {isCreatePending, isLinkedField} from './user_properties_utils';
import './user_properties_dot_menu.scss';
@@ -110,6 +111,15 @@ export const useAttributeLinkModal = (field: UserPropertyField, updateField: Pro
const menuId = 'user-property-field_dotmenu';
// Menu.Item renders a second label child as help text under the primary label —
// the same treatment the "Editable by users" item uses when it's locked.
const withLinkedHelp = (label: ReactElement, isLinked: boolean, help: ReactNode): ReactElement => (isLinked ? (
<>
<span>{label}</span>
<span>{help}</span>
</>
) : label);
const DotMenu = ({
field,
canCreate,
@@ -138,6 +148,19 @@ const DotMenu = ({
const isSynced = Boolean(field.attrs.ldap || field.attrs.saml);
// Linked fields take their type and options from the template they link to.
// That rules out editing the ranking (options) and linking to AD/LDAP or SAML
// (both coerce the field to `text`) — the server rejects either change. The
// rest of this menu (visibility, editable-by-users, duplicate, delete) is
// unaffected.
const isLinked = isLinkedField(field);
const linkedHelp = (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.linked.help'
defaultMessage='Managed by a linked attribute template'
/>
);
// Owner-managed fields (e.g. SCIM-provisioned) are read-only in this
// screen: ownership and values are governed by the owning integration, so
// they behave like synced fields for the "Editable by users" toggle and
@@ -148,9 +171,16 @@ const DotMenu = ({
const handleDuplicate = () => {
const name = `${slugifyForCEL(field.name)}_copy`;
const attrs = {...field.attrs};
delete attrs.owners;
createField({...field, attrs, name});
const duplicate = {...field, attrs: {...field.attrs}, name};
// A copy is a standalone field, not a second holder of the original's
// provenance: owners belong to the integration that assigned them, and a
// template link would make the copy inherit a type and option set the
// create request cannot carry anyway.
delete duplicate.attrs.owners;
delete duplicate.linked_field_id;
createField(duplicate);
};
const handleDelete = () => {
@@ -235,14 +265,15 @@ const DotMenu = ({
{field.type === 'rank' && (
<Menu.Item
id={`${menuId}_edit-ranking`}
disabled={isLinked}
onClick={promptEditRanking}
leadingElement={<FormatListNumberedIcon size={18}/>}
labels={(
labels={withLinkedHelp((
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.edit_ranking.label'
defaultMessage='Edit ranking'
/>
)}
), isLinked, linkedHelp)}
/>
)}
<Menu.SubMenu
@@ -385,8 +416,9 @@ const DotMenu = ({
key={`${menuId}_link_ad-ldap`}
id={`${menuId}_link_ad-ldap`}
leadingElement={<SyncIcon size={18}/>}
disabled={isLinked}
onClick={() => promptEditLdapLink()}
labels={field.attrs.ldap ? (
labels={withLinkedHelp(field.attrs.ldap ? (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label'
defaultMessage='Edit LDAP link'
@@ -396,14 +428,15 @@ const DotMenu = ({
id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label'
defaultMessage='Link attribute to AD/LDAP'
/>
)}
), isLinked, linkedHelp)}
/>,
<Menu.Item
key={`${menuId}_link_saml`}
id={`${menuId}_link_saml`}
leadingElement={<SyncIcon size={18}/>}
disabled={isLinked}
onClick={() => promptEditSamlLink()}
labels={field.attrs.saml ? (
labels={withLinkedHelp(field.attrs.saml ? (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.edit_link.label'
defaultMessage='Edit SAML link'
@@ -413,7 +446,7 @@ const DotMenu = ({
id='admin.system_properties.user_properties.dotmenu.saml.link_property.label'
defaultMessage='Link attribute to SAML'
/>
)}
), isLinked, linkedHelp)}
/>,
])}
<Menu.Separator/>
@@ -92,6 +92,19 @@ describe('UserPropertyRankValues', () => {
]);
});
it('locks the chips and hides the add input when the field is linked to a template', () => {
renderWithContext(
<UserPropertyRankValues
field={{...baseField(), linked_field_id: 'template-field-id'}}
updateField={jest.fn()}
/>,
);
expect(screen.getByTestId('rank-chip-a')).toBeDisabled();
expect(screen.queryByTestId('rank-chip-a-remove')).not.toBeInTheDocument();
expect(screen.queryByTestId('user-property-rank-values__add-input')).not.toBeInTheDocument();
});
it('does not show the duplicate error while the label still matches the option\'s own name', async () => {
renderWithContext(
<UserPropertyRankValues
@@ -20,6 +20,7 @@ import Constants from 'utils/constants';
import {DangerText} from './controls';
import RankBadge from './rank_badge';
import {moveOptionByAscIndex, nextRank, sortOptionsByRankAsc} from './rank_utils';
import {isLinkedField} from './user_properties_utils';
import './user_properties_rank_values.scss';
@@ -48,7 +49,9 @@ const UserPropertyRankValues = ({field, updateField, autoFocus}: Props) => {
const ascOptions = useMemo(() => sortOptionsByRankAsc(options), [options]);
const sortedRanks = useMemo(() => ascOptions.map((option) => option.rank ?? 0), [ascOptions]);
const isDisabled = field.delete_at !== 0;
// Linked fields inherit their options from the template they link to; the
// server rejects an options change on them.
const isDisabled = field.delete_at !== 0 || isLinkedField(field);
const addInputRef = useRef<HTMLInputElement>(null);
@@ -85,6 +85,12 @@ describe('UserPropertyTypeMenu', () => {
expect(menuButton).toBeDisabled();
});
it('disables menu button when the field is linked to a template', () => {
renderComponent({...baseField, linked_field_id: 'template-field-id'});
expect(screen.getByTestId('fieldTypeSelectorMenuButton')).toBeDisabled();
});
it('changes field type when a new type is selected', async () => {
renderComponent();
@@ -17,6 +17,8 @@ import type {IDMappedObjects} from '@mattermost/types/utilities';
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
import * as Menu from 'components/menu';
import {isLinkedField} from './user_properties_utils';
import './user_properties_type_menu.scss';
interface Props {
@@ -61,7 +63,10 @@ const SelectType = (props: Props) => {
const CurrentTypeIcon = currentTypeDescriptor.icon;
const isProtected = Boolean(props.field.attrs?.protected);
const isDisabled = props.field.delete_at !== 0 || isProtected;
// Linked fields take their type from the template they link to; the server
// rejects a type change on them.
const isDisabled = props.field.delete_at !== 0 || isProtected || isLinkedField(props.field);
return (
<Menu.Container
@@ -15,6 +15,8 @@ import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import {
isLinkedField,
newPendingField,
useUserPropertyFields,
ValidationWarningNameInvalidCEL,
ValidationWarningNameRequired,
@@ -623,3 +625,30 @@ describe('useUserPropertyFields', () => {
expect(pendingNames).toEqual(['Text', 'Text_2']);
});
});
describe('isLinkedField', () => {
it('is true only when the field links to a template field', () => {
expect(isLinkedField({})).toBe(false);
expect(isLinkedField({linked_field_id: ''})).toBe(false);
expect(isLinkedField({linked_field_id: 'template-field-id'})).toBe(true);
});
});
describe('newPendingField', () => {
// newPendingField doesn't strip the link, so a pending field keeps whatever
// it was handed. That only reaches the collection, not the server: the
// commit path sends name/type/attrs, and every linked field is created
// directly via Client4.createPropertyField (classification_markings/utils).
// Dropping the link is specific to duplication and lives in the dot menu's
// handleDuplicate — see user_properties_dot_menu.test.tsx.
it('keeps an explicitly requested template link', () => {
const pending = newPendingField({
name: 'clearance',
type: 'rank',
linked_field_id: 'template-field-id',
} as UserPropertyFieldPatch & Pick<UserPropertyField, 'name'>);
expect(pending.linked_field_id).toBe('template-field-id');
expect(isLinkedField(pending)).toBe(true);
});
});
@@ -314,6 +314,15 @@ export const isDeletePending = <T extends {delete_at: number; create_at: number}
return item.create_at !== 0 && item.delete_at !== 0;
};
/**
* A field created as a link to a template field (e.g. the Clearance attribute
* minted by Classification Markings) inherits its type and option values from
* that template. The server rejects any change to either, so the UI locks the
* type selector and the values cell. Everything else name, display name,
* visibility, sort order, delete stays editable.
*/
export const isLinkedField = (field: Pick<UserPropertyField, 'linked_field_id'>) => Boolean(field.linked_field_id);
export const newPendingId = () => `${PENDING}${generateId()}`;
export const newPendingField = (patch: UserPropertyFieldPatch & Pick<UserPropertyField, 'name'>): UserPropertyField => {
@@ -150,6 +150,13 @@ describe('UserPropertyValues', () => {
expect(option.closest('div[aria-disabled]')).toBeInTheDocument();
});
it('is disabled when the field is linked to a template', () => {
renderComponent({...baseField, linked_field_id: 'template-field-id'});
const option = screen.getByText('Option 1');
expect(option.closest('div[aria-disabled]')).toBeInTheDocument();
});
it('shows LDAP sync information when field has LDAP attribute', () => {
const ldapField = {
...baseField,
@@ -27,6 +27,7 @@ import {DangerText} from './controls';
import './user_properties_values.scss';
import {useAttributeLinkModal} from './user_properties_dot_menu';
import UserPropertyRankValues from './user_properties_rank_values';
import {isLinkedField} from './user_properties_utils';
type Props = {
field: UserPropertyField;
@@ -136,20 +137,29 @@ const UserPropertyValues = ({
);
});
// Editing an LDAP/SAML link rewrites the field as `text`, which the
// server refuses on a linked field (its type comes from the template).
// The dot menu already disables the same action when linked; render the
// chip as plain text here so this cell doesn't offer it either.
const editable = !isLinkedField(field);
const editProps = (onEdit: () => void) => (editable ? {
onClick: onEdit,
onKeyDown: (e: React.KeyboardEvent) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
onEdit();
}
},
role: 'button',
tabIndex: 0,
} : {});
const syncedProperties = [
field.attrs.ldap && (
<a
className='user-property-field-values__chip-link'
key={`${field.name}-ldap`}
data-testid={`user-property-field-values__ldap-${field.name}`}
onClick={() => promptEditLdapLink()}
onKeyDown={(e) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
promptEditLdapLink();
}
}}
role='button'
tabIndex={0}
{...editProps(promptEditLdapLink)}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.ldap'
@@ -163,14 +173,7 @@ const UserPropertyValues = ({
className='user-property-field-values__chip-link'
key={`${field.name}-saml`}
data-testid={`user-property-field-values__saml-${field.name}`}
onClick={() => promptEditSamlLink()}
onKeyDown={(e) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
promptEditSamlLink();
}
}}
role='button'
tabIndex={0}
{...editProps(promptEditSamlLink)}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.saml'
@@ -235,7 +238,9 @@ const UserPropertyValues = ({
);
}
const isDisabled = field.delete_at !== 0 || isProtected;
// Linked fields inherit their options from the template they link to; the
// server rejects an options change on them.
const isDisabled = field.delete_at !== 0 || isProtected || isLinkedField(field);
// Ranked fields render numbered chips with a per-chip rank/label/remove
// popover instead of the plain creatable value list.
@@ -26,9 +26,9 @@ import {createJob} from 'mattermost-redux/actions/jobs';
import type {ActionResult} from 'mattermost-redux/types/actions';
export interface ChannelAccessControlActions {
getAccessControlFields: (after: string, limit: number) => Promise<ActionResult<UserPropertyField[]>>;
getAccessControlFields: (after: string, limit: number, includeResourceFields?: boolean) => Promise<ActionResult<UserPropertyField[]>>;
getVisualAST: (expression: string) => Promise<ActionResult<AccessControlVisualAST>>;
searchUsers: (expression: string, term: string, after: string, limit: number) => Promise<ActionResult<AccessControlTestResult>>;
searchUsers: (expression: string, term: string, after: string, limit: number, channelIdOverride?: string) => Promise<ActionResult<AccessControlTestResult>>;
getChannelPolicy: (channelId: string) => Promise<ActionResult<AccessControlPolicy>>;
saveChannelPolicy: (policy: AccessControlPolicy) => Promise<ActionResult<AccessControlPolicy>>;
deleteChannelPolicy: (policyId: string) => Promise<ActionResult>;
@@ -57,16 +57,19 @@ export const useChannelAccessControlActions = (channelId?: string, teamId?: stri
const dispatch = useDispatch();
return useMemo(() => ({
getAccessControlFields: (after: string, limit: number) => {
return dispatch(getAccessControlFields(after, limit, channelId, teamId));
getAccessControlFields: (after: string, limit: number, includeResourceFields?: boolean) => {
return dispatch(getAccessControlFields(after, limit, channelId, teamId, includeResourceFields));
},
getVisualAST: (expression: string) => {
return dispatch(getVisualAST(expression, channelId, teamId));
},
searchUsers: (expression: string, term: string, after: string, limit: number) => {
return dispatch(searchUsersForExpression(expression, term, after, limit, channelId, teamId));
searchUsers: (expression: string, term: string, after: string, limit: number, channelIdOverride?: string) => {
// A channel picked in the test modal (for a resource.attributes.* rule
// in an editor with no channel scope of its own) takes precedence over
// the hook's scoped channel.
return dispatch(searchUsersForExpression(expression, term, after, limit, channelIdOverride ?? channelId, teamId));
},
getChannelPolicy: (channelId: string) => {
+14
View File
@@ -311,6 +311,8 @@
"admin.access_control.policy.channels_affected": "Are you sure you want to save and apply the membership policy?",
"admin.access_control.policy.edit_policy.access_rules.subtitle": "Select user attributes and values that qualifying users must have",
"admin.access_control.policy.edit_policy.access_rules.title": "Attribute-based membership rules",
"admin.access_control.policy.edit_policy.channel_attribute_notice.text": "If an assigned channel is missing the referenced attribute, every member of that channel is removed.",
"admin.access_control.policy.edit_policy.channel_attribute_notice.title": "Channels without this attribute lose all members",
"admin.access_control.policy.edit_policy.channel_selector.addChannels": "Add channels",
"admin.access_control.policy.edit_policy.channel_selector.remove": "Remove",
"admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this membership policy will apply to.",
@@ -467,6 +469,9 @@
"admin.access_control.table_editor.operator.version_less_than": "version is less than",
"admin.access_control.table_editor.operator.younger_than": "younger than (days)",
"admin.access_control.table_editor.remove_row": "Remove row",
"admin.access_control.table_editor.rhs.channel_attributes_section": "Channel attributes",
"admin.access_control.table_editor.rhs.channel_target_label": "Channel: {name}",
"admin.access_control.table_editor.rhs.values_section": "Values",
"admin.access_control.table_editor.selector.custom_attributes": "Custom attributes",
"admin.access_control.table_editor.selector.filter_attributes": "Search attributes...",
"admin.access_control.table_editor.selector.filter_operators": "Search operators...",
@@ -484,6 +489,11 @@
"admin.access_control.table_editor.values": "Values",
"admin.access_control.table_editor.values.create_placeholder": "Type to create value",
"admin.access_control.table_editor.values.select_values": "Select values...",
"admin.access_control.test.channel_picker.back": "Back to channel selection",
"admin.access_control.test.channel_picker.error": "Could not load channels. Check your connection and try again.",
"admin.access_control.test.channel_picker.no_results": "No channels found",
"admin.access_control.test.channel_picker.search": "Search channels",
"admin.access_control.test.channel_picker.title": "Select a channel to test against",
"admin.access_control.testResults": "Access Rule Test Results",
"admin.accesscontrol.enableAuditLogging.desc": "When enabled, attribute-based access control policy decisions are written to the server audit log. Requires server audit logging to be active.",
"admin.accesscontrol.enableAuditLogging.disabled": "When enabled, attribute-based access control policy decisions are written to the server audit log. This setting requires attribute-based access control to be enabled and server audit logging to be active (enable file audit logging or configure an advanced audit logging target).",
@@ -828,6 +838,9 @@
"admin.classification_markings.enable.false": "False",
"admin.classification_markings.enable.title": "Enable classification markings",
"admin.classification_markings.enable.true": "True",
"admin.classification_markings.enforcement.clearance.checkbox": "Enable clearance attribute",
"admin.classification_markings.enforcement.clearance.help": "Creates a ranked \"Clearance\" user attribute linked to these classification levels. Channel membership can then be managed with a corresponding <link>membership policy</link>.",
"admin.classification_markings.enforcement.clearance.title": "Clearance attribute",
"admin.classification_markings.error.delete_has_dependents": "Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.",
"admin.classification_markings.error.duplicate_name": "Classification level names must be unique. Duplicate: {name}",
"admin.classification_markings.error.empty_name": "All classification levels must have a name.",
@@ -3588,6 +3601,7 @@
"admin.system_properties.user_properties.dotmenu.editable_by_users.owner_managed_synced_help": "Managed by an integration and synced via AD/LDAP or SAML",
"admin.system_properties.user_properties.dotmenu.editable_by_users.synced_help": "Synced attributes are managed by AD/LDAP or SAML",
"admin.system_properties.user_properties.dotmenu.label": "Select an action",
"admin.system_properties.user_properties.dotmenu.linked.help": "Managed by a linked attribute template",
"admin.system_properties.user_properties.dotmenu.saml.edit_link.label": "Edit SAML link",
"admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link attribute to SAML",
"admin.system_properties.user_properties.dotmenu.saml.modal.helpText": "The attribute in the SAML server used to sync as a custom attribute in user's profile in Mattermost.",
@@ -175,9 +175,9 @@ export function getTeamAccessControlPolicy(teamId: string) {
});
}
export function getAccessControlFields(after: string, limit: number, channelId?: string, teamId?: string) {
export function getAccessControlFields(after: string, limit: number, channelId?: string, teamId?: string, includeResourceFields?: boolean) {
return bindClientFunc({
clientFunc: () => Client4.getAccessControlFields(after, limit, channelId, teamId),
clientFunc: () => Client4.getAccessControlFields(after, limit, channelId, teamId, includeResourceFields),
params: [],
});
}
@@ -111,6 +111,36 @@ describe('Client4', () => {
});
});
describe('access control field autocomplete', () => {
let client: Client4;
beforeEach(() => {
client = new Client4();
client.setUrl('http://mattermost.example.com');
});
test('getAccessControlFields sends include_resource_fields when requested', async () => {
const fields = [{id: 'f1', name: 'classification'}];
nock(client.getBaseRoute()).
get('/access_control_policies/cel/autocomplete/fields').
query({after: '', limit: '100', include_resource_fields: 'true'}).
reply(200, fields);
const result = await client.getAccessControlFields('', 100, undefined, undefined, true);
expect(result).toEqual(fields);
});
test('getAccessControlFields omits include_resource_fields by default', async () => {
nock(client.getBaseRoute()).
get('/access_control_policies/cel/autocomplete/fields').
query((q) => q.include_resource_fields === undefined && q.after === '' && q.limit === '100').
reply(200, []);
const result = await client.getAccessControlFields('', 100);
expect(result).toEqual([]);
});
});
describe('content flagging routes', () => {
let client: Client4;
+7 -1
View File
@@ -5208,7 +5208,7 @@ export default class Client4 {
return this.createJob(job);
};
getAccessControlFields = (after: string, limit: number, channelId?: string, teamId?: string) => {
getAccessControlFields = (after: string, limit: number, channelId?: string, teamId?: string, includeResourceFields?: boolean) => {
const params = new URLSearchParams({after, limit: limit.toString()});
if (channelId) {
params.append('channelId', channelId);
@@ -5217,6 +5217,12 @@ export default class Client4 {
params.append('team_id', teamId);
}
// Parent policies reference resource.attributes.* (channel-object-type
// fields) without a single channel to scope by; ask for them explicitly.
if (includeResourceFields) {
params.append('include_resource_fields', 'true');
}
return this.doFetch<UserPropertyField[]>(
`${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?${params.toString()}`,
{method: 'get'},
@@ -27,6 +27,11 @@ export const SESSION_ATTRIBUTES_OBJECT_TYPE = 'session';
// object type; session attributes are the exception (`session`).
export const USER_OBJECT_TYPE = 'user';
// Channel-targeted attributes. On the ABAC autocomplete these are the fields a
// policy references as `resource.attributes.*` — the accessed channel's values,
// as opposed to the requesting user's (`USER_OBJECT_TYPE`).
export const CHANNEL_ATTRIBUTES_OBJECT_TYPE = 'channel';
/**
* Session attributes are the only property fields targeting the `session`
* object type, so identity is keyed off `object_type` rather than the group