diff --git a/server/channels/app/migrations.go b/server/channels/app/migrations.go index 6f9aafa961d..0f9283f2517 100644 --- a/server/channels/app/migrations.go +++ b/server/channels/app/migrations.go @@ -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) } diff --git a/server/channels/app/migrations_test.go b/server/channels/app/migrations_test.go index f86759e9f86..35e75814cfc 100644 --- a/server/channels/app/migrations_test.go +++ b/server/channels/app/migrations_test.go @@ -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) diff --git a/server/public/model/session_attributes.go b/server/public/model/session_attributes.go index ee58269a9ca..8fd315773a7 100644 --- a/server/public/model/session_attributes.go +++ b/server/public/model/session_attributes.go @@ -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), diff --git a/server/public/model/session_attributes_test.go b/server/public/model/session_attributes_test.go index 0da3660daea..b6767a3e3bb 100644 --- a/server/public/model/session_attributes_test.go +++ b/server/public/model/session_attributes_test.go @@ -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, diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx index c8b85f89686..2c83d83bee8 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.test.tsx @@ -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, 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); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx index 54459dc1cca..ee7a96250af 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx @@ -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 = { [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` rather than the custom // profile attribute form `user.attributes.`. export function isNativeField(field?: Pick): 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 diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx index 827f87a4deb..19a3ac9633f 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx @@ -291,7 +291,9 @@ const AttributeSelectorMenu = ({currentAttribute, currentAttributeObjectType, av children: ( <> - {selectedAttributeLabel} + + {selectedAttributeLabel} + ), dataTestId: 'attributeSelectorMenuButton', diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.test.tsx index 7f70b958d3a..664a123ace4 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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'); + }); }); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx index 290b94776e9..8c222246852 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx @@ -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) => { @@ -123,7 +128,9 @@ const OperatorSelectorMenu = ({currentOperator, disabled, onChange, attributeTyp size={18} color='rgba(var(--center-channel-color-rgb), 0.64)' /> - + + {currentOperatorLabel} + ), dataTestId: 'operatorSelectorMenuButton', @@ -298,6 +305,54 @@ const OPERATOR_DESCRIPTORS: IDMappedObjects = { 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" diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx index 7f4a0280aa6..dd9dde7124b 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx @@ -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.', () => { + 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.', () => { + 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); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx index 8125252612f..ffe91f7c8de 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx @@ -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 ( 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 && (
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 94ff5edb24a..8912fe17ebe 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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...",