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
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:
@@ -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});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user