mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-30 17:06:34 +08:00
[MM-70189] Add operators for CIDR and version checks to the simple policy editor (#37918)
This commit is contained in:
@@ -1003,6 +1003,11 @@ func (s *Server) seedSessionAttributeFields(groupID string) error {
|
||||
current.Type = expected.Type
|
||||
current.Attrs["platforms"] = expected.Attrs["platforms"]
|
||||
current.Attrs[model.SAAttrDisplayName] = expected.Attrs[model.SAAttrDisplayName]
|
||||
if operators, ok := expected.Attrs[model.NativeAttributeAttrOperators]; ok {
|
||||
current.Attrs[model.NativeAttributeAttrOperators] = operators
|
||||
} else {
|
||||
delete(current.Attrs, model.NativeAttributeAttrOperators)
|
||||
}
|
||||
if err := syncSessionAttributeOptions(current, expected); err != nil {
|
||||
return fmt.Errorf("failed to sync options for session attribute field %q: %w", expected.Name, err)
|
||||
}
|
||||
|
||||
@@ -530,6 +530,23 @@ func TestDoSetupSessionAttributesProperties(t *testing.T) {
|
||||
require.Len(t, after, expectedFieldCount, "re-running must not create duplicate fields")
|
||||
})
|
||||
|
||||
t.Run("backfills operators on legacy fields missing attrs.operators", func(t *testing.T) {
|
||||
th := Setup(t)
|
||||
|
||||
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldIPAddress)
|
||||
delete(field.Attrs, model.NativeAttributeAttrOperators)
|
||||
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(nil, group.ID, []*model.PropertyField{field})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
|
||||
|
||||
updated := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldIPAddress)
|
||||
require.NotNil(t, updated.Attrs[model.NativeAttributeAttrOperators])
|
||||
})
|
||||
|
||||
t.Run("concurrent runs tolerate update conflicts", func(t *testing.T) {
|
||||
th := Setup(t)
|
||||
|
||||
|
||||
@@ -87,6 +87,31 @@ const (
|
||||
SessionAttributeHeaderProxyDeviceID = "X-Mattermost-Session-Attribute-Device-Id"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionOperatorInCIDR = "inCIDR"
|
||||
SessionOperatorVersionEQ = "versionEQ"
|
||||
SessionOperatorVersionGT = "versionGT"
|
||||
SessionOperatorVersionGTE = "versionGTE"
|
||||
SessionOperatorVersionLT = "versionLT"
|
||||
SessionOperatorVersionLTE = "versionLTE"
|
||||
)
|
||||
|
||||
var sessionStringOperators = []string{"==", "!=", "in", "startsWith", "endsWith", "contains"}
|
||||
|
||||
var sessionVersionOperators = []string{
|
||||
SessionOperatorVersionEQ,
|
||||
SessionOperatorVersionGT,
|
||||
SessionOperatorVersionGTE,
|
||||
SessionOperatorVersionLT,
|
||||
SessionOperatorVersionLTE,
|
||||
}
|
||||
|
||||
func sessionOperators(extra ...string) StringInterface {
|
||||
return StringInterface{
|
||||
NativeAttributeAttrOperators: append(slices.Clone(sessionStringOperators), extra...),
|
||||
}
|
||||
}
|
||||
|
||||
var SessionAttributesRequestDerivedFieldNames = map[string]struct{}{
|
||||
SessionAttributesPropertyFieldUserAgentPlatform: {},
|
||||
SessionAttributesPropertyFieldUserAgentOS: {},
|
||||
@@ -240,8 +265,8 @@ func SessionAttributeSystemFields(groupID string) []*PropertyField {
|
||||
}
|
||||
|
||||
return []*PropertyField{
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldIPAddress, SessionAttributesDisplayNameIPAddress, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientIPAddress, SessionAttributesDisplayNameClientIPAddress, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldIPAddress, SessionAttributesDisplayNameIPAddress, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, sessionOperators(SessionOperatorInCIDR)),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientIPAddress, SessionAttributesDisplayNameClientIPAddress, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, sessionOperators(SessionOperatorInCIDR)),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldNetworkInterfaceType, SessionAttributesDisplayNameNetworkInterfaceType, PropertyFieldTypeSelect, clientsOnly, SessionAttributeDefaultTTLNetworkIdentity, SessionAttributeDefaultGraceNetworkIdentity, StringInterface{
|
||||
PropertyFieldAttributeOptions: []map[string]string{
|
||||
{"name": "wifi"},
|
||||
@@ -266,13 +291,13 @@ func SessionAttributeSystemFields(groupID string) []*PropertyField {
|
||||
{"name": "android"},
|
||||
},
|
||||
}),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSVersion, SessionAttributesDisplayNameOSVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientVersion, SessionAttributesDisplayNameClientVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldOSVersion, SessionAttributesDisplayNameOSVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientVersion, SessionAttributesDisplayNameClientVersion, PropertyFieldTypeText, clientsOnly, SessionAttributeDefaultTTLPosture, SessionAttributeDefaultGracePosture, sessionOperators(sessionVersionOperators...)),
|
||||
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentPlatform, SessionAttributesDisplayNameUserAgentPlatform, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentOS, SessionAttributesDisplayNameUserAgentOS, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserName, SessionAttributesDisplayNameUserAgentBrowserName, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserVersion, SessionAttributesDisplayNameUserAgentBrowserVersion, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldUserAgentBrowserVersion, SessionAttributesDisplayNameUserAgentBrowserVersion, PropertyFieldTypeText, allPlatforms, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, sessionOperators(sessionVersionOperators...)),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldTLSDDeviceID, SessionAttributesDisplayNameTLSDDeviceID, PropertyFieldTypeText, desktopBrowser, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldClientDeviceID, SessionAttributesDisplayNameClientDeviceID, PropertyFieldTypeText, mobileOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
sessionAttributeField(groupID, SessionAttributesPropertyFieldHardwareID, SessionAttributesDisplayNameHardwareID, PropertyFieldTypeText, desktopOnly, SessionAttributeDefaultTTLIdentity, SessionAttributeDefaultGraceIdentity, nil),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -111,6 +112,36 @@ func TestSAFieldEnabledForPlatform(t *testing.T) {
|
||||
assert.False(t, disabled.EnabledForPlatform(SessionAttributePlatformDesktop))
|
||||
}
|
||||
|
||||
func TestSessionAttributeSystemFieldsOperators(t *testing.T) {
|
||||
fields := SessionAttributeSystemFields("group-id")
|
||||
byName := make(map[string]*PropertyField, len(fields))
|
||||
for _, field := range fields {
|
||||
byName[field.Name] = field
|
||||
}
|
||||
|
||||
baseStringOps := []string{"==", "!=", "in", "startsWith", "endsWith", "contains"}
|
||||
versionOps := append(slices.Clone(baseStringOps), sessionVersionOperators...)
|
||||
|
||||
assertOperators := func(t *testing.T, field *PropertyField, expected []string) {
|
||||
t.Helper()
|
||||
require.NotNil(t, field)
|
||||
raw, ok := field.Attrs[NativeAttributeAttrOperators]
|
||||
require.True(t, ok, "field %q must declare operators", field.Name)
|
||||
ops, ok := raw.([]string)
|
||||
require.True(t, ok, "field %q operators must be []string", field.Name)
|
||||
assert.Equal(t, expected, ops)
|
||||
}
|
||||
|
||||
assertOperators(t, byName[SessionAttributesPropertyFieldIPAddress], append(slices.Clone(baseStringOps), SessionOperatorInCIDR))
|
||||
assertOperators(t, byName[SessionAttributesPropertyFieldClientIPAddress], append(slices.Clone(baseStringOps), SessionOperatorInCIDR))
|
||||
assertOperators(t, byName[SessionAttributesPropertyFieldOSVersion], versionOps)
|
||||
assertOperators(t, byName[SessionAttributesPropertyFieldClientVersion], versionOps)
|
||||
assertOperators(t, byName[SessionAttributesPropertyFieldUserAgentBrowserVersion], versionOps)
|
||||
|
||||
_, hasOperators := byName[SessionAttributesPropertyFieldSSID].Attrs[NativeAttributeAttrOperators]
|
||||
assert.False(t, hasOperators, "fields without specialized operators must not declare attrs.operators")
|
||||
}
|
||||
|
||||
func TestIsValidSessionAttributeValue(t *testing.T) {
|
||||
textField := &PropertyField{
|
||||
Name: SessionAttributesPropertyFieldOSVersion,
|
||||
|
||||
+28
-2
@@ -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, isNativeMethodOperator, isValidYoungerThanDaysValue, OperatorLabel} from './shared';
|
||||
import {TestButton, celPrefixForField, excludeSessionAttributes, hasUsableAttributes, isSimpleCondition, isSimpleExpression, mergeSessionAttributes, 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}`,
|
||||
@@ -626,6 +626,12 @@ describe('isSimpleExpression / isSimpleCondition with session attributes', () =>
|
||||
expect(isSimpleExpression('user.attributes.dept == "Eng" && user.session.ip_address == "10.0.0.1"')).toBe(true);
|
||||
});
|
||||
|
||||
test('session inCIDR and version helpers are simple', () => {
|
||||
expect(isSimpleCondition('user.session.ip_address.inCIDR("10.0.0.0/8")')).toBe(true);
|
||||
expect(isSimpleCondition('user.session.os_version.versionGTE("6.0.0")')).toBe(true);
|
||||
expect(isSimpleExpression('user.session.ip_address.inCIDR("10.0.0.0/8") && user.session.os_version.versionGTE("6.0.0")')).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown namespaces are not simple', () => {
|
||||
expect(isSimpleCondition('user.bogus.x == "y"')).toBe(false);
|
||||
expect(isSimpleExpression('user.bogus.x == "y"')).toBe(false);
|
||||
@@ -695,7 +701,13 @@ describe('allowedOperatorLabelsForField / defaultOperatorForField', () => {
|
||||
expect(defaultOperatorForField(field)).toBe('younger than');
|
||||
});
|
||||
|
||||
test('returns undefined for non-native fields and falls back to is/has any of', () => {
|
||||
test('maps session attribute operator tokens to UI labels', () => {
|
||||
const field = makeField('ip_address', {operators: ['==', 'inCIDR']});
|
||||
expect(allowedOperatorLabelsForField(field)).toEqual(['is', 'in IP range']);
|
||||
expect(defaultOperatorForField(field)).toBe('is');
|
||||
});
|
||||
|
||||
test('returns undefined when attrs.operators is absent and falls back to is/has any of', () => {
|
||||
expect(allowedOperatorLabelsForField(makeField('dept', {}))).toBeUndefined();
|
||||
expect(defaultOperatorForField(makeField('dept', {}))).toBe('is');
|
||||
expect(defaultOperatorForField(makeField('skills', {}, 'multiselect'))).toBe('has any of');
|
||||
@@ -729,6 +741,20 @@ describe('isValidYoungerThanDaysValue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFieldAdvertisedOperator', () => {
|
||||
test.each([
|
||||
OperatorLabel.IN_CIDR,
|
||||
OperatorLabel.VERSION_IS,
|
||||
OperatorLabel.VERSION_GREATER_THAN,
|
||||
])('true for field-advertised operator %p', (op) => {
|
||||
expect(isFieldAdvertisedOperator(op)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([OperatorLabel.IS, OperatorLabel.STARTS_WITH, OperatorLabel.YOUNGER_THAN])('false for generic operator %p', (op) => {
|
||||
expect(isFieldAdvertisedOperator(op)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNativeMethodOperator', () => {
|
||||
test('true for the native "younger than" operator', () => {
|
||||
expect(isNativeMethodOperator(OperatorLabel.YOUNGER_THAN)).toBe(true);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {defineMessage, FormattedMessage} from 'react-intl';
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
|
||||
import {Button} from '@mattermost/shared/components/button';
|
||||
import {WithTooltip} from '@mattermost/shared/components/tooltip';
|
||||
@@ -29,6 +30,12 @@ export enum CELOperator {
|
||||
CONTAINS = 'contains',
|
||||
IN = 'in',
|
||||
YOUNGER_THAN_DAYS = 'youngerThanDays',
|
||||
IN_CIDR = 'inCIDR',
|
||||
VERSION_EQ = 'versionEQ',
|
||||
VERSION_GT = 'versionGT',
|
||||
VERSION_GTE = 'versionGTE',
|
||||
VERSION_LT = 'versionLT',
|
||||
VERSION_LTE = 'versionLTE',
|
||||
}
|
||||
|
||||
// Operator label constants
|
||||
@@ -52,6 +59,13 @@ export enum OperatorLabel {
|
||||
IS_LESS_THAN = 'is less than',
|
||||
|
||||
YOUNGER_THAN = 'younger than',
|
||||
|
||||
IN_CIDR = 'in IP range',
|
||||
VERSION_IS = 'version is',
|
||||
VERSION_GREATER_THAN = 'version is greater than',
|
||||
VERSION_AT_LEAST = 'version is at least',
|
||||
VERSION_LESS_THAN = 'version is less than',
|
||||
VERSION_AT_MOST = 'version is at most',
|
||||
}
|
||||
|
||||
// Map from visual AST operator to UI label. The comparison symbols (>=, >, <, <=)
|
||||
@@ -70,6 +84,12 @@ export const OPERATOR_LABELS: Record<string, string> = {
|
||||
[CELOperator.CONTAINS]: OperatorLabel.CONTAINS,
|
||||
[CELOperator.IN]: OperatorLabel.IN,
|
||||
[CELOperator.YOUNGER_THAN_DAYS]: OperatorLabel.YOUNGER_THAN,
|
||||
[CELOperator.IN_CIDR]: OperatorLabel.IN_CIDR,
|
||||
[CELOperator.VERSION_EQ]: OperatorLabel.VERSION_IS,
|
||||
[CELOperator.VERSION_GT]: OperatorLabel.VERSION_GREATER_THAN,
|
||||
[CELOperator.VERSION_GTE]: OperatorLabel.VERSION_AT_LEAST,
|
||||
[CELOperator.VERSION_LT]: OperatorLabel.VERSION_LESS_THAN,
|
||||
[CELOperator.VERSION_LTE]: OperatorLabel.VERSION_AT_MOST,
|
||||
hasAnyOf: OperatorLabel.HAS_ANY_OF,
|
||||
hasAllOf: OperatorLabel.HAS_ALL_OF,
|
||||
};
|
||||
@@ -97,6 +117,12 @@ export const OPERATOR_CONFIG: Record<string, {type: OperatorType; celOp: CELOper
|
||||
[OperatorLabel.IS_LESS_THAN]: {type: 'comparison', celOp: CELOperator.LESS_THAN},
|
||||
|
||||
[OperatorLabel.YOUNGER_THAN]: {type: 'native_method', celOp: CELOperator.YOUNGER_THAN_DAYS},
|
||||
[OperatorLabel.IN_CIDR]: {type: 'method', celOp: CELOperator.IN_CIDR},
|
||||
[OperatorLabel.VERSION_IS]: {type: 'method', celOp: CELOperator.VERSION_EQ},
|
||||
[OperatorLabel.VERSION_GREATER_THAN]: {type: 'method', celOp: CELOperator.VERSION_GT},
|
||||
[OperatorLabel.VERSION_AT_LEAST]: {type: 'method', celOp: CELOperator.VERSION_GTE},
|
||||
[OperatorLabel.VERSION_LESS_THAN]: {type: 'method', celOp: CELOperator.VERSION_LT},
|
||||
[OperatorLabel.VERSION_AT_MOST]: {type: 'method', celOp: CELOperator.VERSION_LTE},
|
||||
};
|
||||
|
||||
export function isMultiValueOperator(op: string): boolean {
|
||||
@@ -126,6 +152,22 @@ export function isNativeMethodOperator(op: string): boolean {
|
||||
return OPERATOR_CONFIG[op]?.type === 'native_method';
|
||||
}
|
||||
|
||||
// Field-advertised method operators (inCIDR, version*) are exclusive to attributes
|
||||
// that declare them via attrs.operators. They must never be offered for—or left
|
||||
// applied to—unrelated text attributes.
|
||||
export function isFieldAdvertisedOperator(op: string): boolean {
|
||||
const config = OPERATOR_CONFIG[op];
|
||||
if (!config || config.type !== 'method') {
|
||||
return false;
|
||||
}
|
||||
return config.celOp === CELOperator.IN_CIDR ||
|
||||
config.celOp === CELOperator.VERSION_EQ ||
|
||||
config.celOp === CELOperator.VERSION_GT ||
|
||||
config.celOp === CELOperator.VERSION_GTE ||
|
||||
config.celOp === CELOperator.VERSION_LT ||
|
||||
config.celOp === CELOperator.VERSION_LTE;
|
||||
}
|
||||
|
||||
// Native user attributes are referenced as `user.<name>` rather than the custom
|
||||
// profile attribute form `user.attributes.<name>`.
|
||||
export function isNativeField(field?: Pick<UserPropertyField, 'attrs'>): boolean {
|
||||
@@ -166,11 +208,42 @@ export function isValidYoungerThanDaysValue(value: string): boolean {
|
||||
return (/^\d+$/).test(value.trim());
|
||||
}
|
||||
|
||||
// Returns the operator labels a field may use. Native fields advertise their
|
||||
// allowed operator tokens via attrs.operators; everything else falls back to the
|
||||
const daysValuePlaceholder = defineMessage({
|
||||
id: 'admin.access_control.table_editor.value.days_placeholder',
|
||||
defaultMessage: 'Number of days',
|
||||
});
|
||||
|
||||
const cidrValuePlaceholder = defineMessage({
|
||||
id: 'admin.access_control.table_editor.value.cidr_placeholder',
|
||||
defaultMessage: 'CIDR range (e.g. 10.0.0.0/8)',
|
||||
});
|
||||
|
||||
const versionValuePlaceholder = defineMessage({
|
||||
id: 'admin.access_control.table_editor.value.version_placeholder',
|
||||
defaultMessage: 'Version (e.g. 6.0.0)',
|
||||
});
|
||||
|
||||
// CIDR/version operators have no client-side format validation, so the value
|
||||
// input's placeholder is the only hint at the expected format.
|
||||
export function valuePlaceholderForOperator(operator: string): MessageDescriptor | undefined {
|
||||
if (operator === OperatorLabel.YOUNGER_THAN) {
|
||||
return daysValuePlaceholder;
|
||||
}
|
||||
if (operator === OperatorLabel.IN_CIDR) {
|
||||
return cidrValuePlaceholder;
|
||||
}
|
||||
if (isFieldAdvertisedOperator(operator)) {
|
||||
// Only version operators reach here; inCIDR is handled above.
|
||||
return versionValuePlaceholder;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Returns the operator labels a field may use. Fields advertise an explicit
|
||||
// operator token list via attrs.operators; everything else falls back to the
|
||||
// full set (operator menu still applies its multiselect filter).
|
||||
export function allowedOperatorLabelsForField(field?: UserPropertyField): string[] | undefined {
|
||||
if (!isNativeField(field) || !field?.attrs?.operators) {
|
||||
if (!field?.attrs?.operators) {
|
||||
return undefined;
|
||||
}
|
||||
return field.attrs.operators.
|
||||
@@ -233,6 +306,8 @@ const SIMPLE_CONDITION_PATTERNS: RegExp[] = [
|
||||
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.startsWith\(${CEL_STRING}.*?\)$`),
|
||||
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.endsWith\(${CEL_STRING}.*?\)$`),
|
||||
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.contains\(${CEL_STRING}.*?\)$`),
|
||||
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.inCIDR\(${CEL_STRING}.*?\)$`),
|
||||
new RegExp(String.raw`^user\.(?:attributes|session)\.\w+\.version(?:EQ|GT|GTE|LT|LTE)\(${CEL_STRING}.*?\)$`),
|
||||
|
||||
// Native user attributes (single segment after `user.`). Restricted to
|
||||
// the field/operator pairings the table editor can round-trip: boolean
|
||||
|
||||
+3
-1
@@ -291,7 +291,9 @@ const AttributeSelectorMenu = ({currentAttribute, currentAttributeObjectType, av
|
||||
children: (
|
||||
<>
|
||||
<AttributeIcon attribute={selectedAttributeObject}/>
|
||||
<span className='field-selector-menu-button__label'>{selectedAttributeLabel}</span>
|
||||
<WithTooltip title={selectedAttributeLabel}>
|
||||
<span className='field-selector-menu-button__label'>{selectedAttributeLabel}</span>
|
||||
</WithTooltip>
|
||||
</>
|
||||
),
|
||||
dataTestId: 'attributeSelectorMenuButton',
|
||||
|
||||
+36
@@ -127,4 +127,40 @@ describe('OperatorSelectorMenu', () => {
|
||||
const menuTexts = menuItems.map((item) => item.textContent);
|
||||
expect(menuTexts).toEqual(['younger than (days)']);
|
||||
});
|
||||
|
||||
test('shows advertised inCIDR operator for IP session attributes', () => {
|
||||
renderWithContext(
|
||||
<OperatorSelectorMenu
|
||||
{...defaultProps}
|
||||
currentOperator='in IP range'
|
||||
attributeType='text'
|
||||
allowedOperators={['is', 'in IP range']}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('operatorSelectorMenuButton'));
|
||||
|
||||
const menuItems = screen.getAllByRole('menuitemradio');
|
||||
const menuTexts = menuItems.map((item) => item.textContent);
|
||||
expect(menuTexts).toEqual(['is', 'in IP range']);
|
||||
});
|
||||
|
||||
test('shows advertised version operators for version session attributes', () => {
|
||||
renderWithContext(
|
||||
<OperatorSelectorMenu
|
||||
{...defaultProps}
|
||||
currentOperator='version is at least'
|
||||
attributeType='text'
|
||||
allowedOperators={['version is at least', 'version is greater than']}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('operatorSelectorMenuButton'));
|
||||
|
||||
const menuItems = screen.getAllByRole('menuitemradio');
|
||||
const menuTexts = menuItems.map((item) => item.textContent);
|
||||
expect(menuTexts).toHaveLength(2);
|
||||
expect(menuTexts[0]).toContain('version is at least');
|
||||
expect(menuTexts[1]).toContain('version is greater than');
|
||||
});
|
||||
});
|
||||
|
||||
+56
-1
@@ -9,6 +9,7 @@ import {defineMessage, FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {CheckAllIcon, CheckIcon, ClockOutlineIcon, ElementOfIcon, EqualIcon, FunctionIcon, NotEqualVariantIcon} from '@mattermost/compass-icons/components';
|
||||
import type IconProps from '@mattermost/compass-icons/components/props';
|
||||
import {WithTooltip} from '@mattermost/shared/components/tooltip';
|
||||
import type {IDMappedObjects} from '@mattermost/types/utilities';
|
||||
|
||||
import * as Menu from 'components/menu';
|
||||
@@ -70,6 +71,10 @@ const OperatorSelectorMenu = ({currentOperator, disabled, onChange, attributeTyp
|
||||
return getOperatorDescriptor(currentOperator);
|
||||
}, [currentOperator]);
|
||||
|
||||
const currentOperatorLabel = useMemo(() => {
|
||||
return formatMessage(currentOperatorDescriptor.label);
|
||||
}, [currentOperatorDescriptor, formatMessage]);
|
||||
|
||||
const CurrentOperatorIcon = currentOperatorDescriptor.icon;
|
||||
|
||||
const onFilterChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -123,7 +128,9 @@ const OperatorSelectorMenu = ({currentOperator, disabled, onChange, attributeTyp
|
||||
size={18}
|
||||
color='rgba(var(--center-channel-color-rgb), 0.64)'
|
||||
/>
|
||||
<FormattedMessage {...currentOperatorDescriptor.label}/>
|
||||
<WithTooltip title={currentOperatorLabel}>
|
||||
<span className='field-selector-menu-button__label'>{currentOperatorLabel}</span>
|
||||
</WithTooltip>
|
||||
</>
|
||||
),
|
||||
dataTestId: 'operatorSelectorMenuButton',
|
||||
@@ -298,6 +305,54 @@ const OPERATOR_DESCRIPTORS: IDMappedObjects<OperatorDescriptor> = {
|
||||
defaultMessage: 'younger than (days)',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.IN_CIDR]: {
|
||||
id: OperatorLabel.IN_CIDR,
|
||||
icon: FunctionIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.in_cidr',
|
||||
defaultMessage: 'in IP range',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.VERSION_IS]: {
|
||||
id: OperatorLabel.VERSION_IS,
|
||||
icon: EqualIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.version_is',
|
||||
defaultMessage: 'version is',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.VERSION_GREATER_THAN]: {
|
||||
id: OperatorLabel.VERSION_GREATER_THAN,
|
||||
icon: GreaterThanIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.version_greater_than',
|
||||
defaultMessage: 'version is greater than',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.VERSION_AT_LEAST]: {
|
||||
id: OperatorLabel.VERSION_AT_LEAST,
|
||||
icon: GreaterThanOrEqualIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.version_at_least',
|
||||
defaultMessage: 'version is at least',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.VERSION_LESS_THAN]: {
|
||||
id: OperatorLabel.VERSION_LESS_THAN,
|
||||
icon: LessThanIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.version_less_than',
|
||||
defaultMessage: 'version is less than',
|
||||
}),
|
||||
},
|
||||
[OperatorLabel.VERSION_AT_MOST]: {
|
||||
id: OperatorLabel.VERSION_AT_MOST,
|
||||
icon: LessThanOrEqualIcon,
|
||||
label: defineMessage({
|
||||
id: 'admin.access_control.table_editor.operator.version_at_most',
|
||||
defaultMessage: 'version is at most',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Operator ordering per attribute type. Ranked attributes lead with "is exactly"
|
||||
|
||||
+69
-1
@@ -6,7 +6,7 @@ import type {FieldType} from '@mattermost/types/properties';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties_user';
|
||||
|
||||
import {isSimpleExpression, isSimpleCondition, isMultiselectOrGroup} from 'components/admin_console/access_control/editors/shared';
|
||||
import {parseExpression, findFirstAvailableAttributeFromList, rowToCEL, celStringLiteral, isRowValueValid} from 'components/admin_console/access_control/editors/table_editor/table_editor';
|
||||
import {parseExpression, findFirstAvailableAttributeFromList, rowToCEL, celStringLiteral, isRowValueValid, isOperatorValidForType} from 'components/admin_console/access_control/editors/table_editor/table_editor';
|
||||
import type {TableRow} from 'components/admin_console/access_control/editors/table_editor/value_selector_menu';
|
||||
|
||||
describe('parseExpression', () => {
|
||||
@@ -795,6 +795,69 @@ describe('parseExpression with native user attributes', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses session inCIDR helper', () => {
|
||||
const ast: AccessControlVisualAST = {
|
||||
conditions: [
|
||||
{
|
||||
attribute: 'user.session.ip_address',
|
||||
operator: 'inCIDR',
|
||||
value: '10.0.0.0/8',
|
||||
value_type: 0,
|
||||
attribute_type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseExpression(ast)).toEqual([
|
||||
{
|
||||
attribute: 'ip_address',
|
||||
attribute_object_type: 'session',
|
||||
operator: 'in IP range',
|
||||
values: ['10.0.0.0/8'],
|
||||
attribute_type: 'text',
|
||||
hasMaskedValues: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowToCEL with session attribute helpers', () => {
|
||||
test('inCIDR emits a member call on user.session.<name>', () => {
|
||||
const cel = rowToCEL({
|
||||
attribute: 'ip_address',
|
||||
attribute_object_type: 'session',
|
||||
operator: 'in IP range',
|
||||
values: ['10.0.0.0/8'],
|
||||
attribute_type: 'text',
|
||||
hasMaskedValues: false,
|
||||
});
|
||||
expect(cel).toBe('user.session.ip_address.inCIDR("10.0.0.0/8")');
|
||||
});
|
||||
|
||||
test('versionGTE emits a member call on user.session.<name>', () => {
|
||||
const cel = rowToCEL({
|
||||
attribute: 'os_version',
|
||||
attribute_object_type: 'session',
|
||||
operator: 'version is at least',
|
||||
values: ['6.0.0'],
|
||||
attribute_type: 'text',
|
||||
hasMaskedValues: false,
|
||||
});
|
||||
expect(cel).toBe('user.session.os_version.versionGTE("6.0.0")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOperatorValidForType', () => {
|
||||
test('rejects field-advertised operators for generic text attributes', () => {
|
||||
expect(isOperatorValidForType('in IP range', 'text')).toBe(false);
|
||||
expect(isOperatorValidForType('version is at least', 'text')).toBe(false);
|
||||
});
|
||||
|
||||
test('still accepts standard text operators', () => {
|
||||
expect(isOperatorValidForType('is', 'text')).toBe(true);
|
||||
expect(isOperatorValidForType('starts with', 'text')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowToCEL with native user attributes', () => {
|
||||
@@ -1047,6 +1110,11 @@ describe('isSimpleCondition', () => {
|
||||
expect(isSimpleCondition('user.createat.youngerThanDays(7)')).toBe(true);
|
||||
});
|
||||
|
||||
test('session inCIDR and version helpers', () => {
|
||||
expect(isSimpleCondition('user.session.ip_address.inCIDR("10.0.0.0/8")')).toBe(true);
|
||||
expect(isSimpleCondition('user.session.os_version.versionGTE("6.0.0")')).toBe(true);
|
||||
});
|
||||
|
||||
test('unsupported native field/operator pairings are not simple', () => {
|
||||
// Boolean fields only support true/false equality, not quoted strings or methods.
|
||||
expect(isSimpleCondition('user.verified == "true"')).toBe(false);
|
||||
|
||||
+6
-5
@@ -20,7 +20,7 @@ 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, celPathFor, isNativeField, isNativeBooleanField, hasControlledAttributeValues, allowedOperatorLabelsForField, defaultOperatorForField, isValidYoungerThanDaysValue, SESSION_ATTRIBUTE_CEL_PREFIX, USER_ATTRIBUTE_CEL_PREFIX} from '../shared';
|
||||
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 './table_editor.scss';
|
||||
|
||||
@@ -191,14 +191,14 @@ const defaultOperatorForType = (type?: string): OperatorLabel => {
|
||||
|
||||
// Whether an operator is valid for an attribute of the given type. Mirrors the
|
||||
// per-type operator sets shown by OperatorSelectorMenu.
|
||||
const isOperatorValidForType = (op: string, type?: string): boolean => {
|
||||
export const isOperatorValidForType = (op: string, type?: string): boolean => {
|
||||
if (type === 'multiselect') {
|
||||
return isMultiselectOperator(op);
|
||||
}
|
||||
if (type === 'rank') {
|
||||
return isRankOperator(op) || op === OperatorLabel.IS_NOT;
|
||||
}
|
||||
return !isMultiselectOperator(op) && !isRankOperator(op) && !isNativeMethodOperator(op);
|
||||
return !isMultiselectOperator(op) && !isRankOperator(op) && !isNativeMethodOperator(op) && !isFieldAdvertisedOperator(op);
|
||||
};
|
||||
|
||||
// Parses a CEL (Common Expression Language) string into a structured array of TableRow objects.
|
||||
@@ -421,7 +421,7 @@ function TableEditor({
|
||||
const newRow: TableRow = {
|
||||
attribute: firstAvailableAttribute.name,
|
||||
attribute_object_type: firstAvailableAttribute.object_type,
|
||||
operator: isNativeField(firstAvailableAttribute) ? defaultOperatorForField(firstAvailableAttribute) : defaultOperatorForType(firstAvailableAttribute.type),
|
||||
operator: allowedOperatorLabelsForField(firstAvailableAttribute) ? defaultOperatorForField(firstAvailableAttribute) : defaultOperatorForType(firstAvailableAttribute.type),
|
||||
values: [],
|
||||
attribute_type: firstAvailableAttribute.type || '',
|
||||
hasMaskedValues: false,
|
||||
@@ -582,6 +582,7 @@ function TableEditor({
|
||||
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);
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
@@ -621,7 +622,7 @@ function TableEditor({
|
||||
disabled={disabled || row.hasMaskedValues}
|
||||
updateValues={(values: string[]) => updateRowValues(index, values)}
|
||||
options={row.attribute ? field?.attrs?.options || [] : []}
|
||||
placeholder={isYoungerThan ? formatMessage({id: 'admin.access_control.table_editor.value.days_placeholder', defaultMessage: 'Number of days'}) : undefined}
|
||||
placeholder={valuePlaceholder ? formatMessage(valuePlaceholder) : undefined}
|
||||
/>
|
||||
{youngerThanInvalid && (
|
||||
<div className='table-editor__value-error'>
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"admin.access_control.table_editor.operator.has_all_of": "has all of",
|
||||
"admin.access_control.table_editor.operator.has_any_of": "has any of",
|
||||
"admin.access_control.table_editor.operator.in": "in",
|
||||
"admin.access_control.table_editor.operator.in_cidr": "in IP range",
|
||||
"admin.access_control.table_editor.operator.is": "is",
|
||||
"admin.access_control.table_editor.operator.is_at_least": "is at least",
|
||||
"admin.access_control.table_editor.operator.is_at_most": "is at most",
|
||||
@@ -459,6 +460,11 @@
|
||||
"admin.access_control.table_editor.operator.is_less_than": "is less than",
|
||||
"admin.access_control.table_editor.operator.is_not": "is not",
|
||||
"admin.access_control.table_editor.operator.starts_with": "starts with",
|
||||
"admin.access_control.table_editor.operator.version_at_least": "version is at least",
|
||||
"admin.access_control.table_editor.operator.version_at_most": "version is at most",
|
||||
"admin.access_control.table_editor.operator.version_greater_than": "version is greater than",
|
||||
"admin.access_control.table_editor.operator.version_is": "version is",
|
||||
"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.selector.custom_attributes": "Custom attributes",
|
||||
@@ -469,10 +475,12 @@
|
||||
"admin.access_control.table_editor.selector.session_attributes_header": "Session attributes",
|
||||
"admin.access_control.table_editor.test_access_rule": "Test access rule",
|
||||
"admin.access_control.table_editor.user_excluded_tooltip": "You cannot test access rules that would exclude you from the channel",
|
||||
"admin.access_control.table_editor.value.cidr_placeholder": "CIDR range (e.g. 10.0.0.0/8)",
|
||||
"admin.access_control.table_editor.value.days_invalid": "Enter a whole number of days (e.g. 30).",
|
||||
"admin.access_control.table_editor.value.days_placeholder": "Number of days",
|
||||
"admin.access_control.table_editor.value.placeholder": "Add value...",
|
||||
"admin.access_control.table_editor.value.select_value": "Select value",
|
||||
"admin.access_control.table_editor.value.version_placeholder": "Version (e.g. 6.0.0)",
|
||||
"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...",
|
||||
|
||||
Reference in New Issue
Block a user