diff --git a/.changeset/abac-classification-banners.md b/.changeset/abac-classification-banners.md new file mode 100644 index 0000000000000..cf348d2467fc1 --- /dev/null +++ b/.changeset/abac-classification-banners.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds classification banners to ABAC-managed rooms: admins can describe US-Government-style classification markings (levels, special access programs, releasability, colors) in a new JSON setting, and matching rooms display a colored classification banner above the room header for all members. diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index fd0f1e66c9e07..fca15fe7bd0b5 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -32,6 +32,13 @@ const SettingsPage = () => { )} + + + + + + + diff --git a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.stories.tsx b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.stories.tsx new file mode 100644 index 0000000000000..6bb944778d67d --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.stories.tsx @@ -0,0 +1,146 @@ +import type { IRoom, RoomType } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ClassificationBanner from './ClassificationBanner'; +import type { ClassificationBannersConfig } from './lib/types'; +import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider'; +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; + +const config: ClassificationBannersConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'classification', + source: 'clearance.level', + label: 'Classification level', + showInBanner: true, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: true, + values: [ + { source: 'TS-SCI', label: 'TOP SECRET//SCI', color: '#fce100' }, + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'S', label: 'SECRET', color: '#c8102e' }, + { source: 'C', label: 'CONFIDENTIAL', color: '#0033a0' }, + { source: 'CUI', label: 'CUI', color: '#502b85' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + { + id: 'sar', + source: 'access.programs', + label: 'Special access programs', + showInBanner: true, + showLabel: true, + bannerLabel: 'SAR', + labelSeparator: '-', + valueSeparator: '/', + sortAlpha: true, + groupThreshold: 4, + multipleLabel: 'MULTIPLE PROGRAMS', + drivesColor: false, + values: [ + { source: 'SAP-1042', label: 'APPLES', color: '#c8102e' }, + { source: 'SAP-2271', label: 'BANANAS', color: '#ff8c00' }, + { source: 'SAP-3380', label: 'ORANGES', color: '#0033a0' }, + { source: 'SAP-4419', label: 'PEACHES', color: '#007a33' }, + { source: 'SAP-5567', label: 'GRAPES', color: '#502b85' }, + ], + }, + { + id: 'relto', + source: 'dissem.relto', + label: 'Releasable to', + showInBanner: true, + showLabel: true, + bannerLabel: 'RELTO', + labelSeparator: ' ', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: false, + values: [ + { source: 'USA', label: 'USA', color: '#0033a0' }, + { source: 'FVEY', label: 'FVEY', color: '#007a33' }, + { source: 'NATO', label: 'NATO', color: '#502b85' }, + ], + }, + ], +}; + +const roomAttributes = [ + { key: 'clearance.level', values: ['TS'] }, + { key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380'] }, + { key: 'dissem.relto', values: ['USA'] }, +]; + +const roomArgs: Partial = { + _id: 'classifiedRoom', + t: 'p' as RoomType, + name: 'operation-nightingale', + fname: 'operation-nightingale', + abacAttributes: roomAttributes, +}; + +const withBanner = (bannerConfig: ClassificationBannersConfig, room: Partial = roomArgs) => + mockAppRoot() + .withJohnDoe() + .withSetting('ABAC_Enabled', true) + .withSetting('ABAC_Classification_Banners_Enabled', true) + .withSetting('ABAC_Classification_Banners_Config', JSON.stringify(bannerConfig)) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: createFakeLicenseInfo({ activeModules: ['abac'] }), + })) + .wrap((children) => {children}) + .buildStoryDecorator(); + +export default { + component: ClassificationBanner, + parameters: { + layout: 'fullscreen', + }, + decorators: [withBanner(config)], +} satisfies Meta; + +export const Classic: StoryObj = {}; + +export const TopSecret: StoryObj = { + decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'clearance.level', values: ['TS-SCI'] }] })], +}; + +export const Unclassified: StoryObj = { + decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'clearance.level', values: ['U'] }] })], +}; + +export const GroupedPrograms: StoryObj = { + decorators: [ + withBanner(config, { + ...roomArgs, + abacAttributes: [ + { key: 'clearance.level', values: ['S'] }, + { key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'SAP-4419', 'SAP-5567'] }, + ], + }), + ], +}; + +export const Fallback: StoryObj = { + decorators: [withBanner(config, { ...roomArgs, abacAttributes: [{ key: 'unmapped.attribute', values: ['whatever'] }] })], +}; diff --git a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx new file mode 100644 index 0000000000000..b6166bf0fe842 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx @@ -0,0 +1,53 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { buildClassificationBanner, parseClassificationBannersConfig } from './lib/engine'; +import { useIsABACManagedRoom } from '../../admin/ABAC/hooks/useIsABACManagedRoom'; +import { useRoom } from '../contexts/RoomContext'; + +const ClassificationBanner = () => { + const { t } = useTranslation(); + const room = useRoom(); + const isABACRoom = useIsABACManagedRoom(room); + const bannersEnabled = useSetting('ABAC_Classification_Banners_Enabled', false); + const rawConfig = useSetting('ABAC_Classification_Banners_Config', ''); + const enabled = bannersEnabled && isABACRoom; + + const banner = useMemo(() => { + if (!enabled) { + return null; + } + + const config = parseClassificationBannersConfig(rawConfig); + return config?.enabled ? buildClassificationBanner(config, room.abacAttributes ?? []) : null; + }, [enabled, rawConfig, room.abacAttributes]); + + if (!banner) { + return null; + } + + return ( + + + {banner.text} + + + ); +}; + +export default ClassificationBanner; diff --git a/apps/meteor/client/views/room/ClassificationBanner/index.ts b/apps/meteor/client/views/room/ClassificationBanner/index.ts new file mode 100644 index 0000000000000..1d3754c1e1d47 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/index.ts @@ -0,0 +1 @@ +export { default } from './ClassificationBanner'; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts new file mode 100644 index 0000000000000..cb843644fb8c7 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts @@ -0,0 +1,23 @@ +/** + * Picks a readable text color for a solid background fill. + * + * Computes each candidate's WCAG contrast ratio against the background (sRGB channels are + * gamma-decoded to linear light, then weighted by how strongly the eye perceives each: 21% red, + * 72% green, 7% blue) and keeps the higher-contrast one — so admins can pick any banner color + * and the label stays legible. + */ +export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { + const luminanceOf = (color: string): number => { + const [r, g, b] = [0, 2, 4].map((offset) => parseInt(color.replace('#', '').slice(offset, offset + 2), 16) / 255); + const linearize = (channel: number): number => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); + }; + + const background = luminanceOf(hex); + const contrastWith = (ink: string): number => { + const text = luminanceOf(ink); + return (Math.max(background, text) + 0.05) / (Math.min(background, text) + 0.05); + }; + + return contrastWith('#1F2329') >= contrastWith('#FFFFFF') ? '#1F2329' : '#FFFFFF'; +}; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts new file mode 100644 index 0000000000000..c671ae2c07137 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -0,0 +1,222 @@ +import { readableTextColor } from './colors'; +import { buildClassificationBanner, parseClassificationBannersConfig } from './engine'; +import type { ClassificationBannersConfig } from './types'; + +const config: ClassificationBannersConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'classification', + source: 'clearance.level', + label: 'Classification level', + showInBanner: true, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: true, + values: [ + { source: 'TS-SCI', label: 'TOP SECRET//SCI', color: '#fce100' }, + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'S', label: 'SECRET', color: '#c8102e' }, + { source: 'C', label: 'CONFIDENTIAL', color: '#0033a0' }, + { source: 'CUI', label: 'CUI', color: '#502b85' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + { + id: 'sar', + source: 'access.programs', + label: 'Special access programs', + showInBanner: true, + showLabel: true, + bannerLabel: 'SAR', + labelSeparator: '-', + valueSeparator: '/', + sortAlpha: true, + groupThreshold: 4, + multipleLabel: 'MULTIPLE PROGRAMS', + drivesColor: false, + values: [ + { source: 'SAP-1042', label: 'APPLES', color: '#c8102e' }, + { source: 'SAP-2271', label: 'BANANAS', color: '#ff8c00' }, + { source: 'SAP-3380', label: 'ORANGES', color: '#0033a0' }, + { source: 'SAP-4419', label: 'PEACHES', color: '#007a33' }, + { source: 'SAP-5567', label: 'GRAPES', color: '#502b85' }, + ], + }, + { + id: 'relto', + source: 'dissem.relto', + label: 'Releasable to', + showInBanner: true, + showLabel: true, + bannerLabel: 'RELTO', + labelSeparator: ' ', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: false, + values: [ + { source: 'USA', label: 'USA', color: '#0033a0' }, + { source: 'FVEY', label: 'FVEY', color: '#007a33' }, + { source: 'NATO', label: 'NATO', color: '#502b85' }, + ], + }, + { + id: 'dissem', + source: 'dissem.controls', + label: 'Dissemination controls', + showInBanner: false, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: true, + groupThreshold: 0, + multipleLabel: '', + drivesColor: false, + values: [ + { source: 'NF', label: 'NOFORN', color: '#c8102e' }, + { source: 'OC', label: 'ORCON', color: '#ff8c00' }, + { source: 'IM', label: 'IMCON', color: '#0033a0' }, + ], + }, + ], +}; + +describe('buildClassificationBanner', () => { + it('assembles the default prototype output', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['TS'] }, + { key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380'] }, + { key: 'dissem.relto', values: ['USA'] }, + ]); + + expect(banner.text).toBe('TOP SECRET // SAR-APPLES/BANANAS/ORANGES // RELTO USA'); + expect(banner.segments.map((s) => s.attrId)).toEqual(['classification', 'sar', 'relto']); + expect(banner.backgroundColor).toBe('#ff8c00'); + expect(banner.color).toBe('#1F2329'); + expect(banner).toMatchObject({ style: 'classic', uppercase: true, monospace: false }); + }); + + it('collapses to multipleLabel when value count reaches groupThreshold', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['TS'] }, + { key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'SAP-4419', 'SAP-5567'] }, + ]); + + expect(banner.text).toBe('TOP SECRET // SAR-MULTIPLE PROGRAMS'); + }); + + it('does not collapse below groupThreshold and sorts labels alphabetically when sortAlpha is set', () => { + const banner = buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-3380', 'SAP-1042'] }]); + + expect(banner.text).toBe('SAR-APPLES/ORANGES'); + }); + + it('picks the most restrictive value color in highest mode (index 0 = highest ranking)', () => { + const banner = buildClassificationBanner(config, [{ key: 'clearance.level', values: ['U', 'TS'] }]); + + expect(banner.backgroundColor).toBe('#ff8c00'); + }); + + it('picks the color of the first room value that is mapped in attribute mode', () => { + const attributeModeConfig = { ...config, banner: { ...config.banner, colorMode: 'attribute' as const } }; + + expect(buildClassificationBanner(attributeModeConfig, [{ key: 'clearance.level', values: ['U', 'TS'] }]).backgroundColor).toBe( + '#007a33', + ); + expect(buildClassificationBanner(attributeModeConfig, [{ key: 'clearance.level', values: ['TS', 'U'] }]).backgroundColor).toBe( + '#ff8c00', + ); + }); + + it('excludes attributes with showInBanner disabled', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['S'] }, + { key: 'dissem.controls', values: ['NF'] }, + ]); + + expect(banner.text).toBe('SECRET'); + }); + + it('renders unmapped values of a configured attribute as raw text, after the mapped labels', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['TS'] }, + { key: 'dissem.relto', values: ['USA', 'ACGU'] }, + ]); + + expect(banner.text).toBe('TOP SECRET // RELTO USA/ACGU'); + }); + + it('sorts each group alphabetically when sortAlpha is set, keeping unmapped values last, and counts both toward groupThreshold', () => { + expect(buildClassificationBanner(config, [{ key: 'access.programs', values: ['aaa', 'SAP-3380', 'SAP-1042'] }]).text).toBe( + 'SAR-APPLES/ORANGES/aaa', + ); + expect( + buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-1042', 'SAP-2271', 'SAP-3380', 'cherry'] }]).text, + ).toBe('SAR-MULTIPLE PROGRAMS'); + }); + + it('does not let unmapped values drive the color', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['X'] }, + { key: 'dissem.relto', values: ['USA'] }, + ]); + + expect(banner.text).toBe('X // RELTO USA'); + expect(banner.backgroundColor).toBe('#6C727A'); + }); + + it('renders the fallback banner when the room only carries attributes not present in the config', () => { + const banner = buildClassificationBanner(config, [{ key: 'country', values: ['USA'] }]); + + expect(banner).toMatchObject({ + text: 'NO CLASSIFICATION DATA', + segments: [], + backgroundColor: '#6C727A', + color: '#FFFFFF', + }); + }); +}); + +describe('readableTextColor', () => { + it('uses dark text over light backgrounds and white text over dark backgrounds', () => { + expect(readableTextColor('#fce100')).toBe('#1F2329'); + expect(readableTextColor('#FFFFFF')).toBe('#1F2329'); + expect(readableTextColor('#c8102e')).toBe('#FFFFFF'); + expect(readableTextColor('#0033a0')).toBe('#FFFFFF'); + }); + + it('picks the higher-contrast ink on mid-range backgrounds', () => { + expect(readableTextColor('#999999')).toBe('#1F2329'); + }); +}); + +describe('parseClassificationBannersConfig', () => { + it('parses a valid config', () => { + expect(parseClassificationBannersConfig(JSON.stringify(config))).toEqual(config); + }); + + it.each([ + ['empty string (unset setting)', ''], + ['invalid JSON', 'not json'], + ])('returns null for %s', (_name, raw) => { + expect(parseClassificationBannersConfig(raw)).toBeNull(); + }); +}); diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts new file mode 100644 index 0000000000000..dc681fd6af6ae --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -0,0 +1,94 @@ +import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; +import { isTruthy } from '@rocket.chat/tools'; + +import { readableTextColor } from './colors'; +import type { + ClassificationBannerAttribute, + ClassificationBannerPayload, + ClassificationBannerSegment, + ClassificationBannersConfig, +} from './types'; + +export const parseClassificationBannersConfig = (raw: string): ClassificationBannersConfig | null => { + try { + return JSON.parse(raw) as ClassificationBannersConfig; + } catch { + return null; + } +}; + +const buildSegment = ( + attribute: ClassificationBannerAttribute, + roomAttributes: IAbacAttributeDefinition[], +): ClassificationBannerSegment | null => { + const roomValues = roomAttributes.find(({ key }) => key === attribute.source)?.values ?? []; + const mapped = attribute.values.filter(({ source }) => roomValues.includes(source)); + const unmapped = roomValues.filter((value) => !attribute.values.some(({ source }) => source === value)).map((label) => ({ label })); + if (attribute.sortAlpha) { + const byLabel = (a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label); + mapped.sort(byLabel); + unmapped.sort(byLabel); + } + const matched = [...mapped, ...unmapped]; + if (!matched.length) { + return null; + } + + const body = + attribute.groupThreshold > 0 && matched.length >= attribute.groupThreshold + ? attribute.multipleLabel + : matched.map(({ label }) => label).join(attribute.valueSeparator); + + return { + attrId: attribute.id, + text: attribute.showLabel ? `${attribute.bannerLabel}${attribute.labelSeparator}${body}` : body, + }; +}; + +const resolveColor = ({ attributes, banner }: ClassificationBannersConfig, roomAttributes: IAbacAttributeDefinition[]): string => { + const driver = attributes.find(({ drivesColor }) => drivesColor) ?? attributes[0]; + const roomValues = roomAttributes.find(({ key }) => key === driver.source)?.values ?? []; + // values are ranked most restrictive first, so in 'highest' mode the first match wins; + // in 'attribute' mode the room's own value order decides instead + const match = + banner.colorMode === 'highest' + ? driver.values.find(({ source }) => roomValues.includes(source)) + : roomValues.map((value) => driver.values.find(({ source }) => source === value)).find(isTruthy); + return match?.color ?? banner.fallbackColor; +}; + +export const buildClassificationBanner = ( + config: ClassificationBannersConfig, + roomAttributes: IAbacAttributeDefinition[], +): ClassificationBannerPayload => { + const { banner } = config; + const base = { + style: banner.style, + uppercase: banner.uppercase, + monospace: banner.monospace, + } as const; + + const segments = config.attributes + .filter(({ showInBanner }) => showInBanner) + .map((attribute) => buildSegment(attribute, roomAttributes)) + .filter(isTruthy); + + if (!segments.length) { + return { + ...base, + text: banner.fallbackText, + segments: [], + backgroundColor: banner.fallbackColor, + color: readableTextColor(banner.fallbackColor), + }; + } + + const backgroundColor = resolveColor(config, roomAttributes); + return { + ...base, + text: segments.map(({ text }) => text).join(banner.delimiter), + segments, + backgroundColor, + color: readableTextColor(backgroundColor), + }; +}; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts new file mode 100644 index 0000000000000..b51a8ab8142e3 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts @@ -0,0 +1,79 @@ +/** + * @jest-environment node + */ +import { ajv } from '@rocket.chat/rest-typings'; + +import type { ClassificationBannersConfig } from './types'; +import schema from '../../../../../../../ee/packages/abac/docs/classification-banners.schema.json'; + +const validate = ajv.compile(schema); + +const validConfig: ClassificationBannersConfig & { $schema: string } = { + $schema: 'https://rocket.chat/schemas/classification-banners/v1.json', + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'classification', + source: 'clearance.level', + label: 'Classification level', + showInBanner: true, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: true, + values: [ + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + ], +}; + +const { banner } = validConfig; +const [attribute] = validConfig.attributes; +const [value] = attribute.values; + +describe('classification banners JSON schema (v1 enforcement contract)', () => { + it('accepts a complete document typed as ClassificationBannersConfig', () => { + expect(validate(validConfig)).toBe(true); + }); + + it.each([ + ['wrong version', { ...validConfig, version: 2 }], + ['missing banner option (colorMode)', { ...validConfig, banner: { ...banner, colorMode: undefined } }], + ['empty delimiter', { ...validConfig, banner: { ...banner, delimiter: '' } }], + ['oversized delimiter', { ...validConfig, banner: { ...banner, delimiter: 'x'.repeat(9) } }], + ['empty fallbackText', { ...validConfig, banner: { ...banner, fallbackText: '' } }], + ['malformed fallbackColor', { ...validConfig, banner: { ...banner, fallbackColor: 'red' } }], + ['unknown banner option', { ...validConfig, banner: { ...banner, position: 'top' } }], + ['unknown top-level property', { ...validConfig, source: 'idp' }], + ['empty attributes', { ...validConfig, attributes: [] }], + ['identical duplicate attributes', { ...validConfig, attributes: [attribute, attribute] }], + ['missing attribute field (drivesColor)', { ...validConfig, attributes: [{ ...attribute, drivesColor: undefined }] }], + ['uppercase attribute id', { ...validConfig, attributes: [{ ...attribute, id: 'Classification' }] }], + ['invalid labelSeparator', { ...validConfig, attributes: [{ ...attribute, labelSeparator: '::' }] }], + ['invalid valueSeparator', { ...validConfig, attributes: [{ ...attribute, valueSeparator: '|' }] }], + ['groupThreshold of 1', { ...validConfig, attributes: [{ ...attribute, groupThreshold: 1 }] }], + ['groupThreshold above 20', { ...validConfig, attributes: [{ ...attribute, groupThreshold: 21 }] }], + ['empty values', { ...validConfig, attributes: [{ ...attribute, values: [] }] }], + ['identical duplicate values', { ...validConfig, attributes: [{ ...attribute, values: [value, value] }] }], + ['value with 3-digit color', { ...validConfig, attributes: [{ ...attribute, values: [{ ...value, color: '#fff' }] }] }], + ['value missing label', { ...validConfig, attributes: [{ ...attribute, values: [{ ...value, label: undefined }] }] }], + ])('rejects %s', (_name, config) => { + expect(validate(config)).toBe(false); + }); +}); diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts new file mode 100644 index 0000000000000..9bdf604693247 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts @@ -0,0 +1,53 @@ +export type ClassificationBannerValue = { + source: string; + label: string; + color: string; +}; + +export type ClassificationBannerAttribute = { + id: string; + source: string; + label: string; + showInBanner: boolean; + showLabel: boolean; + bannerLabel: string; + labelSeparator: string; + valueSeparator: string; + sortAlpha: boolean; + groupThreshold: number; + multipleLabel: string; + drivesColor: boolean; + values: ClassificationBannerValue[]; +}; + +export type ClassificationBannerStyle = 'classic'; + +export type ClassificationBannersConfig = { + version: 1; + enabled: boolean; + banner: { + style: ClassificationBannerStyle; + uppercase: boolean; + monospace: boolean; + delimiter: string; + colorMode: 'highest' | 'attribute'; + fallbackText: string; + fallbackColor: string; + }; + attributes: ClassificationBannerAttribute[]; +}; + +export type ClassificationBannerSegment = { + attrId: string; + text: string; +}; + +export type ClassificationBannerPayload = { + text: string; + segments: ClassificationBannerSegment[]; + backgroundColor: string; + color: string; + style: ClassificationBannerStyle; + uppercase: boolean; + monospace: boolean; +}; diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index b6c713db3f0ba..f9c80f5ce5c70 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -7,6 +7,7 @@ import { createElement, lazy, memo, Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; +import ClassificationBanner from './ClassificationBanner'; import RoomE2EESetup from './E2EESetup/RoomE2EESetup'; import Header from './Header'; import MessageHighlightProvider from './MessageList/providers/MessageHighlightProvider'; @@ -54,6 +55,7 @@ const Room = () => { } header={
} body={ shouldDisplayE2EESetup ? ( diff --git a/apps/meteor/client/views/room/layout/RoomLayout.tsx b/apps/meteor/client/views/room/layout/RoomLayout.tsx index 41fc2227b5ca5..27b1f0f4cc74d 100644 --- a/apps/meteor/client/views/room/layout/RoomLayout.tsx +++ b/apps/meteor/client/views/room/layout/RoomLayout.tsx @@ -9,6 +9,7 @@ import { Suspense, useMemo } from 'react'; import HeaderSkeleton from '../Header/HeaderSkeleton'; export type RoomLayoutProps = { + classificationBanner?: ReactNode; header?: ReactNode; body?: ReactNode; footer?: ReactNode; @@ -34,7 +35,7 @@ const useBreakpointsElement = () => { }; }; -const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps) => { +const RoomLayout = ({ classificationBanner, header, body, footer, aside, ...props }: RoomLayoutProps) => { const { ref, breakpoints } = useBreakpointsElement(); const contextualbarPosition = breakpoints.includes('md') ? 'relative' : 'absolute'; @@ -58,6 +59,7 @@ const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps) )} > + {classificationBanner} }>{header} diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 0e86a6f8cb555..142ef20936c0c 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -1,3 +1,5 @@ +import bannersConfigSchema from '@rocket.chat/abac/docs/classification-banners.schema.json'; + import { settingsRegistry } from '../../../server/settings'; const abacEnabledQuery = { _id: 'ABAC_Enabled', value: true }; @@ -50,6 +52,25 @@ export function addSettings(): Promise { section: 'ABAC', enableQuery: abacEnabledQuery, }); + await this.add('ABAC_Classification_Banners_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + section: 'ABAC_Classification_Banners', + enableQuery: abacEnabledQuery, + i18nDescription: 'ABAC_Classification_Banners_Enabled_Description', + }); + await this.add('ABAC_Classification_Banners_Config', '', { + type: 'code', + code: 'application/json', + multiline: true, + public: true, + invalidValue: '', + section: 'ABAC_Classification_Banners', + enableQuery: [abacEnabledQuery, { _id: 'ABAC_Classification_Banners_Enabled', value: true }], + i18nDescription: 'ABAC_Classification_Banners_Config_Description', + schema: bannersConfigSchema, + }); await this.add('Abac_Cache_Decision_Time_Seconds', 300, { type: 'int', public: true, diff --git a/apps/meteor/server/lib/settingValidationRules.ts b/apps/meteor/server/lib/settingValidationRules.ts index f729cde6eaf30..7acbce39528bd 100644 --- a/apps/meteor/server/lib/settingValidationRules.ts +++ b/apps/meteor/server/lib/settingValidationRules.ts @@ -1,9 +1,11 @@ import type { ISetting, SettingValidationRule } from '@rocket.chat/core-typings'; +import { isSettingCode } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import { isRecord } from '@rocket.chat/tools'; import { settings } from '../settings'; +import { getSettingSchemaValidator } from '../settings/functions/settingSchemas'; const logger = new Logger('SettingValidation'); @@ -45,6 +47,24 @@ const parseValidationRules = (settingId: ISetting['_id'], validation: NonNullabl const isSettingReference = (value: unknown): value is { $setting: ISetting['_id'] } => isRecord(value) && typeof value.$setting === 'string'; +// an empty value means the setting is unconfigured and always passes +const validatesSchema = (setting: ISetting, value: unknown): boolean => { + const validate = getSettingSchemaValidator(setting._id); + if (!validate || !isSettingCode(setting) || setting.code !== 'application/json' || value === '') { + return true; + } + + if (typeof value !== 'string') { + return false; + } + + try { + return !!validate(JSON.parse(value)); + } catch { + return false; + } +}; + /** * Evaluates a setting's validation rule against the value being saved. Returns `false` only when the rule's filter * rejects the candidate. A rule that references a setting which does not exist is logged and treated as passing, so a @@ -111,9 +131,17 @@ export const validateSettingRules = (changes: { _id: ISetting['_id']; value: ISe return beingSaved ? beingSaved.value : settings.get(id); }; - for (const { _id } of changes) { + for (const { _id, value } of changes) { const setting = settings.getSetting(_id); - if (!setting?.validation) { + if (!setting) { + continue; + } + + if (!validatesSchema(setting, value)) { + throw new SettingValidationError(`${setting._id}_Invalid`); + } + + if (!setting.validation) { continue; } diff --git a/apps/meteor/server/settings/SettingsRegistry.ts b/apps/meteor/server/settings/SettingsRegistry.ts index a49974fa6fc9d..89b6ba1a5d0bb 100644 --- a/apps/meteor/server/settings/SettingsRegistry.ts +++ b/apps/meteor/server/settings/SettingsRegistry.ts @@ -8,6 +8,7 @@ import type { ICachedSettings } from './CachedSettings'; import { getSettingDefaults } from './functions/getSettingDefaults'; import { overrideSetting } from './functions/overrideSetting'; import { overwriteSetting } from './functions/overwriteSetting'; +import { registerSettingSchema } from './functions/settingSchemas'; import { validateSetting } from './functions/validateSetting'; import { SystemLogger } from '../lib/logger/system'; @@ -64,7 +65,7 @@ type addGroupCallback = (this: { with(options: ISettingAddOptions, cb: addGroupCallback): Promise; }) => Promise; -type ISettingAddOptions = Partial; +type ISettingAddOptions = Partial & { schema?: Record }; const compareSettingsIgnoringKeys = (keys: Array) => @@ -103,11 +104,15 @@ export class SettingsRegistry { /* * Add a setting */ - async add(_id: string, value: SettingValue, { sorter, section, group, ...options }: ISettingAddOptions = {}): Promise { + async add(_id: string, value: SettingValue, { sorter, section, group, schema, ...options }: ISettingAddOptions = {}): Promise { if (!_id || value == null) { throw new Error('Invalid arguments'); } + if (schema) { + registerSettingSchema(_id, schema); + } + const sorterKey = group && section ? `${group}_${section}` : group; if (sorterKey && this._sorter[sorterKey] == null) { diff --git a/apps/meteor/server/settings/functions/settingSchemas.ts b/apps/meteor/server/settings/functions/settingSchemas.ts new file mode 100644 index 0000000000000..0160a2b563681 --- /dev/null +++ b/apps/meteor/server/settings/functions/settingSchemas.ts @@ -0,0 +1,10 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { ajv } from '@rocket.chat/rest-typings'; + +const schemas = new Map>(); + +export const registerSettingSchema = (_id: ISetting['_id'], schema: Record): void => { + schemas.set(_id, ajv.compile(schema)); +}; + +export const getSettingSchemaValidator = (_id: ISetting['_id']): ReturnType | undefined => schemas.get(_id); diff --git a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts new file mode 100644 index 0000000000000..fc9d9a87d9ea4 --- /dev/null +++ b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts @@ -0,0 +1,120 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { MongoClient } from 'mongodb'; + +import { IS_EE, URL_MONGODB } from './config/constants'; +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetGroupAndReturnFullRoom, deleteRoom, setSettingValueById } from './utils'; +import { convertHexToRGB } from './utils/convertHexToRGB'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.admin.state }); + +const attrKey = `clearance_${Date.now()}`; + +const bannersConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'clearance', + source: attrKey, + label: 'Clearance', + showInBanner: true, + showLabel: true, + bannerLabel: 'CLEARANCE', + labelSeparator: '-', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: 'MULTIPLE', + drivesColor: true, + values: [ + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + ], +}; + +test.describe.serial('abac-classification-banner', () => { + let poHomeChannel: HomeChannel; + let connection: MongoClient; + let room: IRoom; + let attributeId: string; + + test.skip(!IS_EE, 'Enterprise Only'); + + test.beforeAll(async ({ api }) => { + connection = await MongoClient.connect(URL_MONGODB); + + await Promise.all([ + setSettingValueById(api, 'ABAC_Enabled', true), + setSettingValueById(api, 'ABAC_PDP_Type', 'local'), + setSettingValueById(api, 'ABAC_Attribute_Store', 'local'), + setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', true), + setSettingValueById(api, 'ABAC_Classification_Banners_Config', JSON.stringify(bannersConfig)), + ]); + + expect((await api.post('/abac/attributes', { key: attrKey, values: ['TS', 'U'] })).status()).toBe(200); + const { attributes } = await (await api.get('/abac/attributes', { key: attrKey })).json(); + attributeId = attributes.find((attribute: { key: string }) => attribute.key === attrKey)._id; + + // the local PDP only keeps members whose own attributes match the room's, + // so the admin gets them straight in the database before the room is tagged + await connection + .db() + .collection('users') + .updateOne({ username: Users.admin.data.username }, { $push: { abacAttributes: { key: attrKey, values: ['TS', 'U'] } } }); + + ({ group: room } = await createTargetGroupAndReturnFullRoom(api)); + expect((await api.put(`/abac/rooms/${room._id}/attributes/${attrKey}`, { values: ['TS'] })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, room._id); + await api.delete(`/abac/attributes/${attributeId}`); + await connection + .db() + .collection('users') + .updateOne({ username: Users.admin.data.username }, { $pull: { abacAttributes: { key: attrKey } } }); + await connection.close(); + + await Promise.all([ + setSettingValueById(api, 'ABAC_Classification_Banners_Config', ''), + setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false), + setSettingValueById(api, 'ABAC_Enabled', false), + ]); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); + + test('should show the classification banner built from the config in an ABAC room', async ({ page }) => { + await poHomeChannel.navbar.openChat(room.name as string); + + const banner = page.getByRole('region', { name: 'Room Attributes' }); + await expect(banner).toBeVisible(); + await expect(banner).toHaveText('CLEARANCE-TOP SECRET'); + await expect(banner).toHaveCSS('background-color', convertHexToRGB('#ff8c00')); + }); + + test('should not show the banner when classification banners are disabled', async ({ page, api }) => { + await setSettingValueById(api, 'ABAC_Classification_Banners_Enabled', false); + + await poHomeChannel.navbar.openChat(room.name as string); + await expect(poHomeChannel.composer.inputMessage).toBeVisible(); + await expect(page.getByRole('region', { name: 'Room Attributes' })).toHaveCount(0); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 36977818b9ebd..82ba8fe08f259 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -4535,3 +4535,82 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); }); + +(IS_EE ? describe : describe.skip)('[ABAC] Classification banners config schema validation on save', () => { + const validConfig = { + version: 1, + enabled: true, + banner: { + style: 'classic', + uppercase: true, + monospace: false, + delimiter: ' // ', + colorMode: 'highest', + fallbackText: 'NO CLASSIFICATION DATA', + fallbackColor: '#6C727A', + }, + attributes: [ + { + id: 'classification', + source: 'clearance.level', + label: 'Classification level', + showInBanner: true, + showLabel: false, + bannerLabel: '', + labelSeparator: '', + valueSeparator: '/', + sortAlpha: false, + groupThreshold: 0, + multipleLabel: '', + drivesColor: true, + values: [ + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + ], + }, + ], + }; + + const saveBannersConfig = (value: string) => + request.post(api('settings/ABAC_Classification_Banners_Config')).set(credentials).send({ value }); + + before((done) => getCredentials(done)); + + after(() => updateSetting('ABAC_Classification_Banners_Config', '')); + + it('should reject a value that is not valid JSON', async () => { + await saveBannersConfig('not json {') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should reject a JSON document violating the schema', async () => { + await saveBannersConfig(JSON.stringify({ ...validConfig, banner: { ...validConfig.banner, fallbackColor: 'red' } })) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid'); + expect(res.body).to.have.property('errorType', 'error-setting-validation-failed'); + }); + }); + + it('should accept a document matching the schema', async () => { + await saveBannersConfig(JSON.stringify(validConfig)) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should accept an empty value, leaving the setting unconfigured', async () => { + await saveBannersConfig('') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts index 77a763448784e..66af57ea630ac 100644 --- a/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts +++ b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import p from 'proxyquire'; import sinon from 'sinon'; +import { registerSettingSchema } from '../../../../server/settings/functions/settingSchemas'; import { positiveOrDisabled, notGreaterThanSetting, @@ -148,4 +149,49 @@ describe('validateSettingRules', () => { expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 5.5 }])).to.not.throw(); }); + + describe('JSON settings with a schema', () => { + const schema = { + type: 'object', + required: ['version'], + additionalProperties: false, + properties: { version: { const: 1 } }, + }; + + beforeEach(() => { + registerSettingSchema('Json_Config', schema); + settingsGetSettingMock.withArgs('Json_Config').returns({ + _id: 'Json_Config', + type: 'code', + code: 'application/json', + }); + }); + + it('accepts a JSON document matching the schema', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":1}' }])).to.not.throw(); + }); + + it('rejects a JSON document violating the schema', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '{"version":2}' }])).to.throw('Json_Config_Invalid'); + }); + + it('rejects a value that is not parseable JSON', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: 'not json {' }])).to.throw('Json_Config_Invalid'); + }); + + it('accepts an empty value, meaning the setting is unconfigured', () => { + expect(() => validateSettingRules([{ _id: 'Json_Config', value: '' }])).to.not.throw(); + }); + + it('ignores a schema registered for a code setting that is not application/json', () => { + registerSettingSchema('Js_Code', schema); + settingsGetSettingMock.withArgs('Js_Code').returns({ + _id: 'Js_Code', + type: 'code', + code: 'text/javascript', + }); + + expect(() => validateSettingRules([{ _id: 'Js_Code', value: 'not json {' }])).to.not.throw(); + }); + }); }); diff --git a/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts b/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts index 0c0808085b33d..5188b52d6200e 100644 --- a/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts +++ b/apps/meteor/tests/unit/server/settings/functions/settings.tests.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, it } from 'mocha'; import { CachedSettings } from '../../../../../server/settings/CachedSettings'; import { SettingsRegistry } from '../../../../../server/settings/SettingsRegistry'; import { getSettingDefaults } from '../../../../../server/settings/functions/getSettingDefaults'; +import { getSettingSchemaValidator, registerSettingSchema } from '../../../../../server/settings/functions/settingSchemas'; import { Settings } from '../../../../../server/settings/functions/settings.mocks'; const testSetting = getSettingDefaults({ @@ -83,6 +84,28 @@ describe('Settings', () => { expect(Settings.findOne({ _id: 'my_setting2' }).value).to.be.equal(false); }); + it('should keep the schema option in memory instead of persisting it', async () => { + const settings = new CachedSettings(); + Settings.settings = settings; + settings.initialized(); + const settingsRegistry = new SettingsRegistry({ store: settings, model: Settings as any }); + + await settingsRegistry.addGroup('group', async function () { + await this.add('my_json_setting', ' ', { + type: 'code', + code: 'application/json', + schema: { type: 'object' }, + }); + }); + + expect(Settings.findOne({ _id: 'my_json_setting' })).to.not.have.property('schema'); + expect(getSettingSchemaValidator('my_json_setting')).to.be.a('function'); + }); + + it('should throw at registration when the schema does not compile', () => { + expect(() => registerSettingSchema('my_broken_setting', { type: 'not-a-type' })).to.throw(); + }); + it('should respect override via environment as int', async () => { const settings = new CachedSettings(); Settings.settings = settings; diff --git a/ee/packages/abac/docs/classification-banners.schema.json b/ee/packages/abac/docs/classification-banners.schema.json new file mode 100644 index 0000000000000..52168f906d59c --- /dev/null +++ b/ee/packages/abac/docs/classification-banners.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://rocket.chat/schemas/classification-banners/v1.json", + "title": "Rocket.Chat Classification Banners configuration", + "type": "object", + "required": ["version", "enabled", "banner", "attributes"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "version": { "const": 1 }, + "enabled": { "type": "boolean" }, + "banner": { + "type": "object", + "required": ["style", "uppercase", "monospace", "delimiter", "colorMode", "fallbackText", "fallbackColor"], + "additionalProperties": false, + "properties": { + "style": { "enum": ["classic"] }, + "uppercase": { "type": "boolean" }, + "monospace": { "type": "boolean" }, + "delimiter": { "type": "string", "minLength": 1, "maxLength": 8 }, + "colorMode": { "enum": ["highest", "attribute"] }, + "fallbackText": { "type": "string", "minLength": 1 }, + "fallbackColor": { "$ref": "#/$defs/hexColor" } + } + }, + "attributes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Attribute ids must be unique across entries (uniqueness by id is not expressible in JSON Schema; validated by tooling)", + "items": { "$ref": "#/$defs/attribute" } + } + }, + "$defs": { + "hexColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "attribute": { + "type": "object", + "required": [ + "id", + "source", + "label", + "showInBanner", + "showLabel", + "bannerLabel", + "labelSeparator", + "valueSeparator", + "sortAlpha", + "groupThreshold", + "multipleLabel", + "drivesColor", + "values" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*$" }, + "source": { "type": "string", "minLength": 1, "description": "Raw attribute key from the IdP (SAML/LDAP)" }, + "label": { "type": "string", "minLength": 1, "description": "Friendly admin-facing name" }, + "showInBanner": { "type": "boolean" }, + "showLabel": { "type": "boolean" }, + "bannerLabel": { "type": "string" }, + "labelSeparator": { "enum": ["-", " ", ""] }, + "valueSeparator": { "enum": ["/", ", ", " "] }, + "sortAlpha": { "type": "boolean" }, + "groupThreshold": { + "type": "integer", + "description": "0 = never collapse; otherwise collapse when value count >= threshold (2-20)", + "oneOf": [{ "const": 0 }, { "minimum": 2, "maximum": 20 }] + }, + "multipleLabel": { "type": "string" }, + "drivesColor": { "type": "boolean" }, + "values": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Value sources must be unique within the attribute (uniqueness by source is not expressible in JSON Schema; validated by tooling)", + "items": { + "type": "object", + "required": ["source", "label", "color"], + "additionalProperties": false, + "properties": { + "source": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "color": { "$ref": "#/$defs/hexColor" } + } + } + } + } + } + } +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b96c22d3b557d..e4e8c957a49b0 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -37,6 +37,12 @@ "one": "{{count}} room affected", "other": "{{count}} rooms affected" }, + "ABAC_Classification_Banners": "Classification banners", + "ABAC_Classification_Banners_Enabled": "Enable classification banners", + "ABAC_Classification_Banners_Enabled_Description": "Show a classification banner in rooms managed by ABAC.", + "ABAC_Classification_Banners_Config": "Classification banners configuration (JSON)", + "ABAC_Classification_Banners_Config_Description": "JSON document describing which room attributes appear in the banner, their labels and colors. See the classification banners documentation for the schema and examples.", + "ABAC_Classification_Banners_Config_Invalid": "Classification banners configuration must be valid JSON matching the documented schema.", "Abac_Cache_Decision_Time_Seconds": "ABAC Cache Decision Time (seconds)", "Abac_Cache_Decision_Time_Seconds_Description": "Time in seconds to cache access control decisions. Setting this value to 0 will disable caching.", "ABAC_Virtru_PDP_Configuration": "Virtru PDP Configuration",