[MM-70189] Add operators for CIDR and version checks to the simple policy editor (#37918)

This commit is contained in:
Devin Binnie
2026-08-12 12:25:48 -04:00
committed by GitHub
parent 7a06c7ae52
commit bf0f9de078
12 changed files with 368 additions and 19 deletions
+5
View File
@@ -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)
}
+17
View File
@@ -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)
+30 -5
View File
@@ -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,
@@ -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
@@ -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',
@@ -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');
});
});
@@ -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"
@@ -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);
@@ -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'>
+8
View File
@@ -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...",