snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -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]);
});
});

View File

@@ -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',
);
}

View File

@@ -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} == "--------"`,
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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});
});
});