From e0cfef1ee6bac585796d857a61861ad37856262b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 12:39:49 -0600 Subject: [PATCH 01/43] feat: add ABAC classification banner engine --- .../src/classification-banners/engine.spec.ts | 226 ++++++++++++++++++ .../abac/src/classification-banners/engine.ts | 122 ++++++++++ .../abac/src/classification-banners/types.ts | 59 +++++ ee/packages/abac/src/index.ts | 10 + 4 files changed, 417 insertions(+) create mode 100644 ee/packages/abac/src/classification-banners/engine.spec.ts create mode 100644 ee/packages/abac/src/classification-banners/engine.ts create mode 100644 ee/packages/abac/src/classification-banners/types.ts diff --git a/ee/packages/abac/src/classification-banners/engine.spec.ts b/ee/packages/abac/src/classification-banners/engine.spec.ts new file mode 100644 index 0000000000000..0cf20e6a21a4f --- /dev/null +++ b/ee/packages/abac/src/classification-banners/engine.spec.ts @@ -0,0 +1,226 @@ +import { buildClassificationBanner, parseClassificationBannersConfig, readableTextColor } from './engine'; +import type { ClassificationBannersConfig } from './types'; + +const config: ClassificationBannersConfig = { + version: 1, + enabled: true, + source: 'idp', + banner: { + style: 'classic', + position: 'top', + 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: 'U', label: 'UNCLASSIFIED', color: '#007a33' }, + { source: 'CUI', label: 'CUI', color: '#502b85' }, + { source: 'C', label: 'CONFIDENTIAL', color: '#0033a0' }, + { source: 'S', label: 'SECRET', color: '#c8102e' }, + { source: 'TS', label: 'TOP SECRET', color: '#ff8c00' }, + { source: 'TS-SCI', label: 'TOP SECRET//SCI', color: '#fce100' }, + ], + }, + { + 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('#FFFFFF'); + expect(banner.fallback).toBe(false); + expect(banner).toMatchObject({ style: 'classic', position: 'top', 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', () => { + const banner = buildClassificationBanner(config, [{ key: 'clearance.level', values: ['U', 'TS'] }]); + + expect(banner.backgroundColor).toBe('#ff8c00'); + }); + + it('picks the first selected value color in attribute mode', () => { + const attributeModeConfig = { ...config, banner: { ...config.banner, colorMode: 'attribute' as const } }; + const banner = buildClassificationBanner(attributeModeConfig, [{ key: 'clearance.level', values: ['U', 'TS'] }]); + + expect(banner.backgroundColor).toBe('#007a33'); + }); + + 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('ignores room values not covered by the config and uses the fallback color when the driver has no match', () => { + const banner = buildClassificationBanner(config, [ + { key: 'clearance.level', values: ['X'] }, + { key: 'dissem.relto', values: ['USA'] }, + ]); + + expect(banner.text).toBe('RELTO USA'); + expect(banner.backgroundColor).toBe('#6C727A'); + expect(banner.fallback).toBe(false); + }); + + it('renders the fallback banner when nothing matches', () => { + const banner = buildClassificationBanner(config, [{ key: 'clearance.level', values: ['X'] }]); + + expect(banner).toMatchObject({ + text: 'NO CLASSIFICATION DATA', + segments: [], + backgroundColor: '#6C727A', + color: '#FFFFFF', + fallback: true, + }); + }); + + it('applies engine defaults for omitted banner options', () => { + const minimal: ClassificationBannersConfig = { + version: 1, + enabled: true, + banner: { delimiter: ' // ' }, + attributes: [config.attributes[0]], + }; + const banner = buildClassificationBanner(minimal, []); + + expect(banner).toMatchObject({ + style: 'classic', + position: 'top', + uppercase: true, + monospace: false, + text: 'NO CLASSIFICATION DATA', + backgroundColor: '#6C727A', + fallback: true, + }); + }); +}); + +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'); + }); +}); + +describe('parseClassificationBannersConfig', () => { + it('parses a valid config', () => { + expect(parseClassificationBannersConfig(JSON.stringify(config))).toEqual(config); + }); + + it.each([ + ['empty string', ''], + ['invalid JSON', 'not json'], + ['non-object', '"str"'], + ['missing everything', '{}'], + ['wrong version', JSON.stringify({ ...config, version: 2 })], + ['missing delimiter', JSON.stringify({ ...config, banner: {} })], + ['empty attributes', JSON.stringify({ ...config, attributes: [] })], + ['malformed attribute', JSON.stringify({ ...config, attributes: [{ id: 'x' }] })], + ])('returns null for %s', (_name, raw) => { + expect(parseClassificationBannersConfig(raw)).toBeNull(); + }); +}); diff --git a/ee/packages/abac/src/classification-banners/engine.ts b/ee/packages/abac/src/classification-banners/engine.ts new file mode 100644 index 0000000000000..552aba34f18c1 --- /dev/null +++ b/ee/packages/abac/src/classification-banners/engine.ts @@ -0,0 +1,122 @@ +import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; +import { isTruthy } from '@rocket.chat/tools'; + +import type { + ClassificationBannerAttribute, + ClassificationBannerPayload, + ClassificationBannerSegment, + ClassificationBannersConfig, +} from './types'; + +const FALLBACK_TEXT = 'NO CLASSIFICATION DATA'; +const FALLBACK_COLOR = '#6C727A'; + +export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { + const [r, g, b] = [0, 2, 4].map((offset) => parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) / 255); + const linearize = (channel: number): number => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + const luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); + return luminance > 0.55 ? '#1F2329' : '#FFFFFF'; +}; + +export const parseClassificationBannersConfig = (raw: string): ClassificationBannersConfig | null => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const config = parsed as ClassificationBannersConfig; + if ( + config.version !== 1 || + typeof config.enabled !== 'boolean' || + typeof config.banner?.delimiter !== 'string' || + !Array.isArray(config.attributes) || + config.attributes.length === 0 || + !config.attributes.every( + (attribute) => typeof attribute?.id === 'string' && typeof attribute.source === 'string' && Array.isArray(attribute.values), + ) + ) { + return null; + } + + return config; +}; + +const getRoomValues = (attribute: ClassificationBannerAttribute, roomAttributes: IAbacAttributeDefinition[]): string[] => + roomAttributes.find(({ key }) => key === attribute.source)?.values ?? []; + +const buildSegment = ( + attribute: ClassificationBannerAttribute, + roomAttributes: IAbacAttributeDefinition[], +): ClassificationBannerSegment | null => { + const roomValues = getRoomValues(attribute, roomAttributes); + let matched = attribute.values.filter(({ source }) => roomValues.includes(source)); + if (matched.length === 0) { + return null; + } + if (attribute.sortAlpha) { + matched = [...matched].sort((a, b) => a.label.localeCompare(b.label)); + } + + const threshold = attribute.groupThreshold ?? 0; + const body = threshold > 0 && matched.length >= threshold ? (attribute.multipleLabel ?? '') : matched.map(({ label }) => label).join(attribute.valueSeparator ?? '/'); + + return { + attrId: attribute.id, + text: attribute.showLabel ? `${attribute.bannerLabel ?? ''}${attribute.labelSeparator ?? ''}${body}` : body, + }; +}; + +const resolveColor = (config: ClassificationBannersConfig, roomAttributes: IAbacAttributeDefinition[]): string => { + const driver = config.attributes.find(({ drivesColor }) => drivesColor) ?? config.attributes[0]; + const roomValues = getRoomValues(driver, roomAttributes); + const matched = driver.values.filter(({ source }) => roomValues.includes(source)); + if (matched.length === 0) { + return config.banner.fallbackColor ?? FALLBACK_COLOR; + } + return (config.banner.colorMode ?? 'highest') === 'highest' ? matched[matched.length - 1].color : matched[0].color; +}; + +export const buildClassificationBanner = ( + config: ClassificationBannersConfig, + roomAttributes: IAbacAttributeDefinition[], +): ClassificationBannerPayload => { + const { banner } = config; + const base = { + style: banner.style ?? 'classic', + position: banner.position ?? 'top', + uppercase: banner.uppercase ?? true, + monospace: banner.monospace ?? false, + } as const; + + const segments = config.attributes + .filter(({ showInBanner }) => showInBanner) + .map((attribute) => buildSegment(attribute, roomAttributes)) + .filter(isTruthy); + + if (segments.length === 0) { + const backgroundColor = banner.fallbackColor ?? FALLBACK_COLOR; + return { + ...base, + text: banner.fallbackText ?? FALLBACK_TEXT, + segments: [], + backgroundColor, + color: readableTextColor(backgroundColor), + fallback: true, + }; + } + + const backgroundColor = resolveColor(config, roomAttributes); + return { + ...base, + text: segments.map(({ text }) => text).join(banner.delimiter), + segments, + backgroundColor, + color: readableTextColor(backgroundColor), + fallback: false, + }; +}; diff --git a/ee/packages/abac/src/classification-banners/types.ts b/ee/packages/abac/src/classification-banners/types.ts new file mode 100644 index 0000000000000..6d9fab5e7a44a --- /dev/null +++ b/ee/packages/abac/src/classification-banners/types.ts @@ -0,0 +1,59 @@ +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' | 'segmented' | 'edge'; + +export type ClassificationBannerPosition = 'top' | 'bottom' | 'both'; + +export type ClassificationBannersConfig = { + version: 1; + enabled: boolean; + source?: 'idp' | 'manual'; + banner: { + style?: ClassificationBannerStyle; + position?: ClassificationBannerPosition; + 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; + position: ClassificationBannerPosition; + uppercase: boolean; + monospace: boolean; + fallback: boolean; +}; diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 77ef86c256550..054690e2601dc 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -983,6 +983,16 @@ export class AbacService extends ServiceClass implements IAbacService { } } +export { buildClassificationBanner, parseClassificationBannersConfig, readableTextColor } from './classification-banners/engine'; +export type { + ClassificationBannerAttribute, + ClassificationBannerPayload, + ClassificationBannerPosition, + ClassificationBannerSegment, + ClassificationBannerStyle, + ClassificationBannerValue, + ClassificationBannersConfig, +} from './classification-banners/types'; export { LocalPDP, VirtruPDP } from './pdp'; export type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp'; export { PdpHealthCheckError, getPdpHealthErrorCode, AbacAttributeStoreExternalError } from './errors'; From 189dd758c35405c918450c9e5d68b5643fc76cd8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 12:43:15 -0600 Subject: [PATCH 02/43] feat: add ABAC classification banner settings, endpoint and schema docs --- .../ABAC/ABACSettingTab/SettingsPage.tsx | 2 + apps/meteor/ee/server/api/abac/index.ts | 38 ++++- apps/meteor/ee/server/api/abac/schemas.ts | 43 ++++++ apps/meteor/ee/server/settings/abac.ts | 18 +++ .../abac/docs/classification-banners.md | 136 ++++++++++++++++++ .../docs/classification-banners.schema.json | 78 ++++++++++ packages/i18n/src/locales/en.i18n.json | 4 + 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 ee/packages/abac/docs/classification-banners.md create mode 100644 ee/packages/abac/docs/classification-banners.schema.json diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index fd0f1e66c9e07..57390eec8b61f 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -20,6 +20,8 @@ const SettingsPage = () => { {pdpType !== 'local' && } + + {pdpType === 'local' && ( diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 94c0b41cadbb0..3b384a30496a4 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,8 +1,8 @@ -import { AbacAttributeStoreExternalError, getPdpHealthErrorCode } from '@rocket.chat/abac'; +import { AbacAttributeStoreExternalError, buildClassificationBanner, getPdpHealthErrorCode, parseClassificationBannersConfig } from '@rocket.chat/abac'; import { Abac } from '@rocket.chat/core-services'; import type { AbacActor } from '@rocket.chat/core-services'; import type { IServerEvents, IUser } from '@rocket.chat/core-typings'; -import { ServerEvents } from '@rocket.chat/models'; +import { Rooms, ServerEvents } from '@rocket.chat/models'; import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; import { convertSubObjectsIntoPaths } from '@rocket.chat/tools'; @@ -25,7 +25,10 @@ import { GETAbacAuditEventsResponseSchema, GETAbacPdpHealthResponseSchema, GETAbacPdpHealthErrorResponseSchema, + GETAbacClassificationBannerResponseSchema, } from './schemas'; +import { canAccessRoomAsync } from '../../../../app/authorization/server'; +import { isABACManagedRoom } from '../../../../app/authorization/server/lib/isABACManagedRoom'; import { API } from '../../../../server/api'; import type { ExtractRoutesFromAPI } from '../../../../server/api/ApiClass'; import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems'; @@ -403,6 +406,37 @@ const abacEndpoints = API.v1 } }, ) + .get( + 'abac/rooms/:rid/classification-banner', + { + authRequired: true, + license: ['abac'], + response: { + 200: GETAbacClassificationBannerResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid } = this.urlParams; + + const room = await Rooms.findOneById(rid); + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.unauthorized(); + } + + if (!settings.get('ABAC_Classification_Banners_Enabled') || !isABACManagedRoom(room)) { + return API.v1.success({ banner: null }); + } + + const config = parseClassificationBannersConfig(settings.get('ABAC_Classification_Banners_Config')); + if (!config?.enabled) { + return API.v1.success({ banner: null }); + } + + return API.v1.success({ banner: buildClassificationBanner(config, room.abacAttributes ?? []) }); + }, + ) .get( 'abac/audit', { diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 34c46e36b9591..05082e434cd7d 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,3 +1,4 @@ +import type { ClassificationBannerPayload as ClassificationBannerPayloadType } from '@rocket.chat/abac'; import type { IAbacAttribute, IAbacAttributeDefinition, @@ -148,6 +149,48 @@ const GetAbacAttributeIsInUseResponse = { export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); +const ClassificationBannerPayload = { + type: 'object', + properties: { + text: { type: 'string' }, + segments: { + type: 'array', + items: { + type: 'object', + properties: { + attrId: { type: 'string' }, + text: { type: 'string' }, + }, + required: ['attrId', 'text'], + additionalProperties: false, + }, + }, + backgroundColor: { type: 'string' }, + color: { type: 'string' }, + style: { type: 'string', enum: ['classic', 'segmented', 'edge'] }, + position: { type: 'string', enum: ['top', 'bottom', 'both'] }, + uppercase: { type: 'boolean' }, + monospace: { type: 'boolean' }, + fallback: { type: 'boolean' }, + }, + required: ['text', 'segments', 'backgroundColor', 'color', 'style', 'position', 'uppercase', 'monospace', 'fallback'], + additionalProperties: false, +}; + +const GetAbacClassificationBannerResponse = { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + banner: { anyOf: [{ type: 'null' }, ClassificationBannerPayload] }, + }, + required: ['banner'], + additionalProperties: false, +}; + +export const GETAbacClassificationBannerResponseSchema = ajv.compile<{ banner: ClassificationBannerPayloadType | null }>( + GetAbacClassificationBannerResponse, +); + const GetAbacAuditEventsQuerySchemaObject = { type: 'object', properties: { diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 0e86a6f8cb555..7dab459c4df53 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -50,6 +50,24 @@ export function addSettings(): Promise { section: 'ABAC', enableQuery: abacEnabledQuery, }); + await this.add('ABAC_Classification_Banners_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + section: 'ABAC', + enableQuery: abacEnabledQuery, + i18nDescription: 'ABAC_Classification_Banners_Enabled_Description', + }); + await this.add('ABAC_Classification_Banners_Config', '', { + type: 'code', + code: 'application/json', + multiline: true, + public: false, + invalidValue: '', + section: 'ABAC', + enableQuery: [abacEnabledQuery, { _id: 'ABAC_Classification_Banners_Enabled', value: true }], + i18nDescription: 'ABAC_Classification_Banners_Config_Description', + }); await this.add('Abac_Cache_Decision_Time_Seconds', 300, { type: 'int', public: true, diff --git a/ee/packages/abac/docs/classification-banners.md b/ee/packages/abac/docs/classification-banners.md new file mode 100644 index 0000000000000..476a6dc2efcbb --- /dev/null +++ b/ee/packages/abac/docs/classification-banners.md @@ -0,0 +1,136 @@ +# Classification banners (ABAC) + +Renders a US-Government-style classification banner above the room header in ABAC-managed rooms, driven by a JSON configuration stored in the `ABAC_Classification_Banners_Config` admin setting (Admin → ABAC → Settings), toggled by `ABAC_Classification_Banners_Enabled`. + +The banner is computed **server-side** (`GET /v1/abac/rooms/:rid/classification-banner`) from the room's `abacAttributes` and the configuration; the configuration itself is never sent to non-admin clients. + +## Configuration schema + +Version 1 of the configuration is described by [classification-banners.schema.json](./classification-banners.schema.json) (`$id: https://rocket.chat/schemas/classification-banners/v1.json`). + +A builder page that generates valid configurations with a live preview is available at [classification-banner-builder/index.html](./classification-banner-builder/index.html) (GitHub Pages compatible — serve the file statically). + +## Semantics + +- **Segments**: one per attribute with `showInBanner: true`, in array order, joined by `banner.delimiter`. An attribute contributes a segment only when the room has at least one value matching one of its `values[].source` entries (matched against the room attribute whose key equals `attributes[].source`). +- **Segment text**: matched value labels joined by `valueSeparator`. With `showLabel: true` the segment is prefixed with `bannerLabel + labelSeparator`. With `sortAlpha: true` labels are sorted alphabetically. When `groupThreshold > 0` and the matched value count is ≥ the threshold, the value list collapses to `multipleLabel`. +- **Color**: the single attribute with `drivesColor: true` picks the banner background. `colorMode: "highest"` (default) selects the matched value with the highest index in that attribute's `values` array (most restrictive); `"attribute"` selects the first matched value. The foreground color is computed for readable contrast (WCAG relative luminance). If the driver attribute has no matched values, `banner.fallbackColor` is used. +- **Fallback**: when no attribute produces a segment, the banner renders `banner.fallbackText` on `banner.fallbackColor` (defaults: `NO CLASSIFICATION DATA` / `#6C727A`). +- **Styles** (`banner.style`): `classic` — single centered line; `segmented` — segments separated by vertical rules instead of the delimiter; `edge` — classic with contrasting top/bottom edge rules. +- **Position** (`banner.position`): `top` (above the room header), `bottom` (below the message list), or `both`. +- Config changes require no server restart; open clients pick up changes when the room is reloaded. + +### Cross-field rules + +These rules are part of the v1 contract and are validated by the builder page (save-time enforcement in Rocket.Chat ships separately): + +- Exactly one attribute has `drivesColor: true`. +- `attributes[].id` values are unique; within an attribute, `values[].source` are unique. +- `multipleLabel` is required when `groupThreshold > 0`. +- `bannerLabel` is required when `showLabel: true`. + +## Example + +This configuration reproduces the design prototype's default state. For a room with `clearance.level = [TS]`, `access.programs = [SAP-1042, SAP-2271, SAP-3380]`, `dissem.relto = [USA]` it renders `TOP SECRET // SAR-APPLES/BANANAS/ORANGES // RELTO USA` on the Top Secret orange. + +```json +{ + "$schema": "https://rocket.chat/schemas/classification-banners/v1.json", + "version": 1, + "enabled": true, + "source": "idp", + "banner": { + "style": "classic", + "position": "top", + "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": "U", "label": "UNCLASSIFIED", "color": "#007a33" }, + { "source": "CUI", "label": "CUI", "color": "#502b85" }, + { "source": "C", "label": "CONFIDENTIAL", "color": "#0033a0" }, + { "source": "S", "label": "SECRET", "color": "#c8102e" }, + { "source": "TS", "label": "TOP SECRET", "color": "#ff8c00" }, + { "source": "TS-SCI", "label": "TOP SECRET//SCI", "color": "#fce100" } + ] + }, + { + "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" } + ] + } + ] +} +``` 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..28d8bb8a8fe62 --- /dev/null +++ b/ee/packages/abac/docs/classification-banners.schema.json @@ -0,0 +1,78 @@ +{ + "$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" }, + "source": { "enum": ["idp", "manual"], "default": "idp" }, + "banner": { + "type": "object", + "required": ["delimiter"], + "additionalProperties": false, + "properties": { + "style": { "enum": ["classic", "segmented", "edge"], "default": "classic" }, + "position": { "enum": ["top", "bottom", "both"], "default": "top" }, + "uppercase": { "type": "boolean", "default": true }, + "monospace": { "type": "boolean", "default": false }, + "delimiter": { "type": "string", "maxLength": 8 }, + "colorMode": { "enum": ["highest", "attribute"], "default": "highest" }, + "fallbackText": { "type": "string", "default": "NO CLASSIFICATION DATA" }, + "fallbackColor": { "$ref": "#/$defs/hexColor" } + } + }, + "attributes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/attribute" } + } + }, + "$defs": { + "hexColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "attribute": { + "type": "object", + "required": ["id", "source", "label", "showInBanner", "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", "default": false }, + "bannerLabel": { "type": "string", "default": "" }, + "labelSeparator": { "enum": ["-", " ", ""], "default": "" }, + "valueSeparator": { "enum": ["/", ", ", " "], "default": "/" }, + "sortAlpha": { "type": "boolean", "default": false }, + "groupThreshold": { + "type": "integer", + "default": 0, + "description": "0 = never collapse; otherwise collapse when value count >= threshold (2-20)", + "oneOf": [{ "const": 0 }, { "minimum": 2, "maximum": 20 }] + }, + "multipleLabel": { "type": "string", "default": "" }, + "drivesColor": { "type": "boolean", "default": false }, + "values": { + "type": "array", + "minItems": 1, + "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..549546d4fe36a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -37,6 +37,10 @@ "one": "{{count}} room affected", "other": "{{count}} rooms affected" }, + "ABAC_Classification_Banners_Enabled": "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_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", From f60109a8b7ea482e6865a295aeecef6bb724e17f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 12:46:50 -0600 Subject: [PATCH 03/43] feat: render classification banner in ABAC rooms --- apps/meteor/client/lib/queryKeys.ts | 2 + .../ClassificationBanner.tsx | 84 +++++++++++++++++++ .../views/room/ClassificationBanner/index.ts | 1 + apps/meteor/client/views/room/Room.tsx | 3 + .../client/views/room/layout/RoomLayout.tsx | 6 +- apps/meteor/ee/server/api/abac/index.ts | 7 +- 6 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx create mode 100644 apps/meteor/client/views/room/ClassificationBanner/index.ts diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index c703743e567d6..70e4c2002e969 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -157,6 +157,8 @@ export const ABACQueryKeys = { list: (...args: [query?: PaginatedRequest]) => [...ABACQueryKeys.rooms.all(), ...args] as const, autocomplete: (...args: [query?: PaginatedRequest]) => [...ABACQueryKeys.rooms.all(), 'autocomplete', ...args] as const, room: (roomId: string) => [...ABACQueryKeys.rooms.all(), roomId] as const, + classificationBanner: (roomId: string, attributes?: unknown) => + [...ABACQueryKeys.rooms.room(roomId), 'classification-banner', ...(attributes !== undefined ? [attributes] : [])] as const, }, }; 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..14f2504772589 --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx @@ -0,0 +1,84 @@ +import type { ClassificationBannerPayload } from '@rocket.chat/abac'; +import type { IRoom } from '@rocket.chat/core-typings'; +import { isABACManagedRoom } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { CSSProperties } from 'react'; + +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type ClassificationBannerProps = { + room: IRoom; + placement: 'top' | 'bottom'; +}; + +const shade = (hex: string, factor: number): string => { + const [r, g, b] = [0, 2, 4].map((offset) => Math.round(parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) * factor)); + return `rgb(${r}, ${g}, ${b})`; +}; + +const getBannerStyle = (banner: ClassificationBannerPayload): CSSProperties => { + const edgeRule = banner.color === '#FFFFFF' ? shade(banner.backgroundColor, 0.6) : 'rgba(0, 0, 0, 0.28)'; + + return { + height: 28, + background: banner.backgroundColor, + color: banner.color, + fontSize: 13, + fontWeight: 700, + lineHeight: 1, + ...(banner.monospace && { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }), + letterSpacing: banner.monospace ? '0.02em' : '0.08em', + textTransform: banner.uppercase ? 'uppercase' : 'none', + whiteSpace: 'nowrap', + userSelect: 'none', + ...(banner.style === 'edge' && { borderTop: `3px solid ${edgeRule}`, borderBottom: `3px solid ${edgeRule}` }), + }; +}; + +const ClassificationBanner = ({ room, placement }: ClassificationBannerProps) => { + const { data: hasABAC = false } = useHasLicenseModule('abac'); + const bannersEnabled = useSetting('ABAC_Classification_Banners_Enabled', false); + const enabled = hasABAC && bannersEnabled && isABACManagedRoom(room); + + const getClassificationBanner = useEndpoint('GET', '/v1/abac/rooms/:rid/classification-banner', { rid: room._id }); + const { data } = useQuery({ + queryKey: ABACQueryKeys.rooms.classificationBanner(room._id, room.abacAttributes), + queryFn: () => getClassificationBanner(), + enabled, + }); + + const banner = data?.banner; + if (!enabled || !banner || (banner.position !== placement && banner.position !== 'both')) { + return null; + } + + return ( + + {banner.style === 'segmented' ? ( + banner.segments.map((segment, index) => ( + + {index > 0 && } + {segment.text} + + )) + ) : ( + {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/Room.tsx b/apps/meteor/client/views/room/Room.tsx index b6c713db3f0ba..19730b103fd97 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,8 @@ const Room = () => { } + bannerBottom={} 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..39d7bbbd8a0c4 100644 --- a/apps/meteor/client/views/room/layout/RoomLayout.tsx +++ b/apps/meteor/client/views/room/layout/RoomLayout.tsx @@ -9,6 +9,8 @@ import { Suspense, useMemo } from 'react'; import HeaderSkeleton from '../Header/HeaderSkeleton'; export type RoomLayoutProps = { + bannerTop?: ReactNode; + bannerBottom?: ReactNode; header?: ReactNode; body?: ReactNode; footer?: ReactNode; @@ -34,7 +36,7 @@ const useBreakpointsElement = () => { }; }; -const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps) => { +const RoomLayout = ({ bannerTop, bannerBottom, header, body, footer, aside, ...props }: RoomLayoutProps) => { const { ref, breakpoints } = useBreakpointsElement(); const contextualbarPosition = breakpoints.includes('md') ? 'relative' : 'absolute'; @@ -58,6 +60,7 @@ const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps) )} > + {bannerTop} }>{header} @@ -68,6 +71,7 @@ const RoomLayout = ({ header, body, footer, aside, ...props }: RoomLayoutProps) {aside && {aside}} + {bannerBottom} ); diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 3b384a30496a4..870b457672a97 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,4 +1,9 @@ -import { AbacAttributeStoreExternalError, buildClassificationBanner, getPdpHealthErrorCode, parseClassificationBannersConfig } from '@rocket.chat/abac'; +import { + AbacAttributeStoreExternalError, + buildClassificationBanner, + getPdpHealthErrorCode, + parseClassificationBannersConfig, +} from '@rocket.chat/abac'; import { Abac } from '@rocket.chat/core-services'; import type { AbacActor } from '@rocket.chat/core-services'; import type { IServerEvents, IUser } from '@rocket.chat/core-typings'; From 7228534361a0d035dcf9a2adf2d6d336712ec797 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 12:50:59 -0600 Subject: [PATCH 04/43] feat: add classification banner config builder page --- .../classification-banner-builder/index.html | 628 ++++++++++++++++++ 1 file changed, 628 insertions(+) create mode 100644 ee/packages/abac/docs/classification-banner-builder/index.html diff --git a/ee/packages/abac/docs/classification-banner-builder/index.html b/ee/packages/abac/docs/classification-banner-builder/index.html new file mode 100644 index 0000000000000..ae23eb2836c05 --- /dev/null +++ b/ee/packages/abac/docs/classification-banner-builder/index.html @@ -0,0 +1,628 @@ + + + + + +Classification banner builder — Rocket.Chat + + + + +
+ +
+ #ops-sector-bravosimulated room header + +
+
+ +
+

Classification banner builder

+
+ + + +

+ Compose the ABAC_Classification_Banners_Config document for Rocket.Chat. + The strip above renders live from this configuration and the simulated room values on the right — exactly what members + of a matching ABAC room will see. +

+
+ +
+
+
+ +
+

Banner options

+ +
+ +
+

Attributes — banner segments render in this order

+
+
+ +
+
+
+ +
+
+

Simulated room values

+
+
+ +
+

Configuration JSON

+
+ +

Paste into Admin → ABAC → Settings → Classification banners configuration (JSON).

+
+
+
+
+ + +

Import configuration

+ +
+
+ + +
+
+ + + + From 6062f0750d6ee718db9e783f5d2982fecc4ee1c1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 12:56:37 -0600 Subject: [PATCH 05/43] chore: add changeset for ABAC classification banners --- .changeset/abac-classification-banners.md | 5 +++++ ee/packages/abac/src/classification-banners/engine.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/abac-classification-banners.md 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/ee/packages/abac/src/classification-banners/engine.ts b/ee/packages/abac/src/classification-banners/engine.ts index 552aba34f18c1..1884c71b0574e 100644 --- a/ee/packages/abac/src/classification-banners/engine.ts +++ b/ee/packages/abac/src/classification-banners/engine.ts @@ -63,7 +63,10 @@ const buildSegment = ( } const threshold = attribute.groupThreshold ?? 0; - const body = threshold > 0 && matched.length >= threshold ? (attribute.multipleLabel ?? '') : matched.map(({ label }) => label).join(attribute.valueSeparator ?? '/'); + const body = + threshold > 0 && matched.length >= threshold + ? (attribute.multipleLabel ?? '') + : matched.map(({ label }) => label).join(attribute.valueSeparator ?? '/'); return { attrId: attribute.id, From c20d8197ba8b1d246c20db61fd88d915a35fa523 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 13:06:34 -0600 Subject: [PATCH 06/43] feat: move classification banner settings into their own ABAC accordion --- .../views/admin/ABAC/ABACSettingTab/SettingsPage.tsx | 9 +++++++-- apps/meteor/ee/server/settings/abac.ts | 4 ++-- packages/i18n/src/locales/en.i18n.json | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index 57390eec8b61f..fca15fe7bd0b5 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -20,8 +20,6 @@ const SettingsPage = () => { {pdpType !== 'local' && } - - {pdpType === 'local' && ( @@ -34,6 +32,13 @@ const SettingsPage = () => { )} + + + + + + + diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 7dab459c4df53..75bb9335ff1d3 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -54,7 +54,7 @@ export function addSettings(): Promise { type: 'boolean', public: true, invalidValue: false, - section: 'ABAC', + section: 'ABAC_Classification_Banners', enableQuery: abacEnabledQuery, i18nDescription: 'ABAC_Classification_Banners_Enabled_Description', }); @@ -64,7 +64,7 @@ export function addSettings(): Promise { multiline: true, public: false, invalidValue: '', - section: 'ABAC', + section: 'ABAC_Classification_Banners', enableQuery: [abacEnabledQuery, { _id: 'ABAC_Classification_Banners_Enabled', value: true }], i18nDescription: 'ABAC_Classification_Banners_Config_Description', }); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 549546d4fe36a..0a5d0f77c6002 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -37,7 +37,8 @@ "one": "{{count}} room affected", "other": "{{count}} rooms affected" }, - "ABAC_Classification_Banners_Enabled": "Classification banners", + "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.", From 7c242883135b95c3f7b415bf755363ffe987a9ee Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 14:02:46 -0600 Subject: [PATCH 07/43] feat: improve classification banner builder UX --- .../classification-banner-builder/index.html | 290 +++++++++++++----- 1 file changed, 206 insertions(+), 84 deletions(-) diff --git a/ee/packages/abac/docs/classification-banner-builder/index.html b/ee/packages/abac/docs/classification-banner-builder/index.html index ae23eb2836c05..a786cb9a94647 100644 --- a/ee/packages/abac/docs/classification-banner-builder/index.html +++ b/ee/packages/abac/docs/classification-banner-builder/index.html @@ -13,6 +13,7 @@ --wash: #f7f8fa; --action: #156ff5; --danger: #d40c26; + --ok: #148660; --warn-bg: #fff8e0; --warn-line: #f5c700; --radius: 6px; @@ -69,7 +70,7 @@ gap: 12px; } header.page h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; } - header.page p { color: var(--ink-soft); flex-basis: 100%; max-width: 70ch; } + header.page p { color: var(--ink-soft); flex-basis: 100%; max-width: 78ch; } header.page .spacer { flex: 1; } main { @@ -77,7 +78,7 @@ margin: 0 auto; padding: 8px 20px 60px; display: grid; - grid-template-columns: minmax(0, 1fr) 380px; + grid-template-columns: minmax(0, 1fr) 400px; gap: 20px; align-items: start; } @@ -98,12 +99,15 @@ padding: 12px 16px; border-bottom: 1px solid var(--line); } + section.card > .card-hint { padding: 10px 16px 0; color: var(--ink-soft); font-size: 12px; } .card-body { padding: 16px; } - .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px 16px; } + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px 16px; } label.field { display: block; font-size: 12px; color: var(--ink-soft); } - label.field > span { display: block; margin-bottom: 4px; } - label.check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--ink); cursor: pointer; } + label.field > span.fname { display: block; margin-bottom: 4px; font-weight: 600; color: var(--ink); } + .fhint { display: block; margin-top: 4px; font-size: 11px; line-height: 1.4; color: var(--ink-soft); font-weight: 400; } + label.check { display: inline-flex; align-items: flex-start; gap: 6px; font-size: 13px; color: var(--ink); cursor: pointer; } + label.check input { margin-top: 2px; } input[type=text], input[type=number], select, textarea { width: 100%; padding: 6px 8px; @@ -155,6 +159,7 @@ .tag.color-driver { background: var(--ink); color: #fff; } .attr-fields { padding: 12px; border-bottom: 1px solid var(--line); } .values { padding: 8px 12px 12px; } + .values .card-hint { padding: 4px 0 8px; color: var(--ink-soft); font-size: 12px; } .values table { width: 100%; border-collapse: collapse; } .values th { text-align: left; font-size: 11px; color: var(--ink-soft); font-weight: 600; padding: 4px 6px; } .values td { padding: 3px 6px; } @@ -162,17 +167,47 @@ .swatch-cell { display: flex; align-items: center; gap: 6px; } .hex { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); } + #empty-state { + border: 1px dashed var(--line); + border-radius: var(--radius); + padding: 24px 16px; + text-align: center; + color: var(--ink-soft); + margin-bottom: 12px; + } + #empty-state b { color: var(--ink); } + #warnings { border: 1px solid var(--warn-line); background: var(--warn-bg); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 20px; } - #warnings h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 6px; } + #warnings h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; } #warnings ul { margin-left: 18px; } - #warnings.ok { border-color: var(--line); background: var(--paper); color: var(--ink-soft); } - - #sim .sim-group { margin-bottom: 12px; } + #warnings li { margin-bottom: 3px; } + #warnings.ok { border-color: var(--line); background: var(--paper); } + #warnings.ok h3 { color: var(--ok); } + #warnings.info { border-color: var(--line); background: var(--paper); color: var(--ink-soft); } + #warnings .rules { list-style: none; margin-left: 0; color: var(--ink-soft); font-size: 12px; } + #warnings .rules li::before { content: '✓ '; color: var(--ok); font-weight: 700; } + #warnings .rules-note { font-size: 11px; color: var(--ink-soft); margin-top: 8px; } + + .ms { border: 1px solid var(--line); border-radius: 4px; background: var(--paper); padding: 4px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-height: 34px; } + .ms .chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--wash); + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 4px 2px 8px; + font-size: 12px; + font-weight: 600; + } + .ms .chip button { border: none; padding: 0 4px; color: var(--ink-soft); background: transparent; font-size: 12px; line-height: 1; } + .ms .chip button:hover { color: var(--danger); background: transparent; } + .ms select { flex: 1; min-width: 110px; border: none; background: transparent; color: var(--ink-soft); font-size: 12px; padding: 4px; } + #sim .sim-group { margin-bottom: 14px; } #sim .sim-group > b { display: block; font-size: 12px; margin-bottom: 4px; } #sim .sim-group .src { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); font-weight: 400; } - #sim .sim-values { display: flex; flex-wrap: wrap; gap: 4px 14px; } - #json-out { height: 320px; resize: vertical; } + #json-out { height: 300px; resize: vertical; } .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } .hint { font-size: 12px; color: var(--ink-soft); margin-top: 8px; } .hint code { font-family: var(--mono); background: var(--wash); padding: 1px 4px; border-radius: 3px; } @@ -189,7 +224,7 @@
- #ops-sector-bravosimulated room header + #simulated-roomthis is how the strip renders above the room header
@@ -198,12 +233,13 @@

Classification banner builder

- +

Compose the ABAC_Classification_Banners_Config document for Rocket.Chat. - The strip above renders live from this configuration and the simulated room values on the right — exactly what members - of a matching ABAC room will see. + Add the attributes your rooms carry (e.g. from Virtru or LDAP), map each raw value to a display label and color, and + the strip at the top renders live — exactly what members of a matching ABAC room will see. “Load example” shows the + complete US-Government configuration from the design prototype.

@@ -213,11 +249,16 @@

Classification banner builder

Banner options

+
Global settings for the strip itself — how segments are joined, positioned, colored and typeset.
-

Attributes — banner segments render in this order

+

Attributes

+
+ One entry per room attribute that should appear in the banner. Segments render in list order, joined by the + delimiter. An attribute only produces a segment when the room carries at least one of its mapped values. +
@@ -228,14 +269,19 @@

Attributes — banner segments render in this order

Simulated room values

+
+ A pretend room for the live preview only — not part of the JSON. Pick which raw values the room carries per + attribute, like assigning ABAC attributes to a room in Rocket.Chat. +

Configuration JSON

+
-

Paste into Admin → ABAC → Settings → Classification banners configuration (JSON).

+

Paste into Admin → ABAC → Settings → Classification banners → configuration (JSON) and enable the toggle.

@@ -314,9 +360,9 @@

Import configuration

return { ...base, text: segments.map(({ text }) => text).join(banner.delimiter), segments, backgroundColor, color: readableTextColor(backgroundColor), fallback: false }; }; -// ---- default configuration (the design prototype's default state) ---- +// ---- configurations ---- -const exampleConfig = () => ({ +const emptyConfig = () => ({ $schema: 'https://rocket.chat/schemas/classification-banners/v1.json', version: 1, enabled: true, @@ -331,6 +377,11 @@

Import configuration

fallbackText: 'NO CLASSIFICATION DATA', fallbackColor: '#6C727A', }, + attributes: [], +}); + +const exampleConfig = () => ({ + ...emptyConfig(), attributes: [ { id: 'classification', source: 'clearance.level', label: 'Classification level', @@ -367,26 +418,30 @@

Import configuration

{ 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' }, - ], - }, ], }); -let config = exampleConfig(); -let simulation = { 'clearance.level': ['TS'], 'access.programs': ['SAP-1042', 'SAP-2271', 'SAP-3380'], 'dissem.relto': ['USA'] }; +let config = emptyConfig(); +let simulation = {}; // ---- cross-field rules (documented in the v1 schema; enforced here as warnings) ---- +const RULES = [ + 'Exactly one attribute drives the banner color', + 'Attribute ids are unique and lowercase (^[a-z][a-z0-9_-]*$)', + 'Every attribute has a source key, a label and at least one value', + 'Value sources are unique within each attribute, each with a label and a color', + 'A grouped label is set wherever a group threshold is set', + 'A banner label is set wherever “show label prefix” is on', + 'A segment delimiter is set', +]; + const lintConfig = (cfg) => { const problems = []; + if (cfg.attributes.length === 0) { + problems.push('At least one attribute is required.'); + return problems; + } const drivers = cfg.attributes.filter((a) => a.drivesColor); if (drivers.length !== 1) problems.push(`Exactly one attribute must have “drives color” — currently ${drivers.length}.`); const ids = cfg.attributes.map((a) => a.id); @@ -401,7 +456,7 @@

Import configuration

if (a.values.some((v) => !v.source || !v.label)) problems.push(`Attribute “${a.id}”: every value needs a source and a label.`); if (a.groupThreshold > 0 && !a.multipleLabel) problems.push(`Attribute “${a.id}”: a grouped label is required when the group threshold is set.`); if (a.groupThreshold !== 0 && (a.groupThreshold < 2 || a.groupThreshold > 20)) problems.push(`Attribute “${a.id}”: group threshold must be 0 or between 2 and 20.`); - if (a.showLabel && !a.bannerLabel) problems.push(`Attribute “${a.id}”: a banner label is required when “show label” is on.`); + if (a.showLabel && !a.bannerLabel) problems.push(`Attribute “${a.id}”: a banner label is required when “show label prefix” is on.`); } if (!cfg.banner.delimiter) problems.push('A segment delimiter is required.'); return problems; @@ -416,12 +471,15 @@

Import configuration

if (key.includes('-') || key === 'for' || key === 'style') node.setAttribute(key, value); else node[key] = value; } - node.append(...children.filter((child) => child !== null && child !== false)); + node.append(...children.filter((child) => child !== null && child !== false && child !== undefined)); return node; }; -const field = (labelText, input) => el('label', { className: 'field' }, el('span', {}, labelText), input); -const check = (labelText, props) => el('label', { className: 'check' }, el('input', { type: 'checkbox', ...props }), labelText); +const field = (labelText, input, hint) => + el('label', { className: 'field' }, el('span', { className: 'fname' }, labelText), input, hint && el('span', { className: 'fhint' }, hint)); +const check = (labelText, props, hint) => + el('label', { className: 'check' }, el('input', { type: 'checkbox', ...props }), + el('span', {}, labelText, hint && el('span', { className: 'fhint' }, hint))); const renderPreview = () => { const banner = buildClassificationBanner(config, Object.entries(simulation).map(([key, values]) => ({ key, values }))); @@ -446,28 +504,35 @@

Import configuration

const renderBannerOptions = () => { const { banner } = config; - const select = (value, options, onchange) => { - const node = el('select', { onchange: (e) => { onchange(e.target.value); update(); } }, + const select = (value, options, onchange) => + el('select', { onchange: (e) => { onchange(e.target.value); update(); } }, ...options.map(([v, text]) => el('option', { value: v, selected: v === value }, text))); - return node; - }; $('#banner-options').replaceChildren( - field('Style', select(banner.style, [['classic', 'Classic'], ['segmented', 'Segmented'], ['edge', 'Edge']], (v) => { banner.style = v; })), - field('Position', select(banner.position, [['top', 'Top'], ['bottom', 'Bottom'], ['both', 'Both']], (v) => { banner.position = v; })), - field('Segment delimiter', el('input', { type: 'text', className: 'mono', maxLength: 8, value: banner.delimiter, oninput: (e) => { banner.delimiter = e.target.value; update(false); } })), - field('Color mode', select(banner.colorMode, [['highest', 'Most restrictive value'], ['attribute', 'First selected value']], (v) => { banner.colorMode = v; })), - field('Fallback text', el('input', { type: 'text', value: banner.fallbackText, oninput: (e) => { banner.fallbackText = e.target.value; update(false); } })), + field('Style', select(banner.style, [['classic', 'Classic'], ['segmented', 'Segmented'], ['edge', 'Edge']], (v) => { banner.style = v; }), + 'Classic: one centered line. Segmented: vertical rules between segments instead of the delimiter. Edge: classic plus contrasting top/bottom rules.'), + field('Position', select(banner.position, [['top', 'Top'], ['bottom', 'Bottom'], ['both', 'Both']], (v) => { banner.position = v; }), + 'Where the strip renders in the room: above the header, below the message list, or both.'), + field('Segment delimiter', el('input', { type: 'text', className: 'mono', maxLength: 8, value: banner.delimiter, oninput: (e) => { banner.delimiter = e.target.value; update(false); } }), + 'Text placed between segments (max 8 characters). The US-Gov convention is “ // ”.'), + field('Color mode', select(banner.colorMode, [['highest', 'Most restrictive value'], ['attribute', 'First matched value']], (v) => { banner.colorMode = v; }), + 'Which matched value of the color-driving attribute paints the banner. “Most restrictive” = the value furthest down its list, so order values least → most restrictive.'), + field('Fallback text', el('input', { type: 'text', value: banner.fallbackText, oninput: (e) => { banner.fallbackText = e.target.value; update(false); } }), + 'Shown when the room carries no values covered by this configuration.'), field('Fallback color', el('div', { className: 'swatch-cell' }, el('input', { type: 'color', value: banner.fallbackColor, oninput: (e) => { banner.fallbackColor = e.target.value; update(false); } }), - el('span', { className: 'hex' }, banner.fallbackColor))), + el('span', { className: 'hex' }, banner.fallbackColor)), + 'Background of the fallback banner, and of the banner when the color-driving attribute has no match.'), el('div', {}, - check('Uppercase', { checked: banner.uppercase, onchange: (e) => { banner.uppercase = e.target.checked; update(); } }), + check('Uppercase', { checked: banner.uppercase, onchange: (e) => { banner.uppercase = e.target.checked; update(); } }, + 'Force the whole banner text to uppercase.'), ), el('div', {}, - check('Monospace', { checked: banner.monospace, onchange: (e) => { banner.monospace = e.target.checked; update(); } }), + check('Monospace', { checked: banner.monospace, onchange: (e) => { banner.monospace = e.target.checked; update(); } }, + 'Render the banner in a monospaced font.'), ), el('div', {}, - check('Enabled', { checked: config.enabled, onchange: (e) => { config.enabled = e.target.checked; update(); } }), + check('Enabled', { checked: config.enabled, onchange: (e) => { config.enabled = e.target.checked; update(); } }, + 'Master switch inside the JSON. Rocket.Chat additionally has its own admin toggle.'), ), ); }; @@ -494,35 +559,43 @@

Import configuration

return el('div', { className: 'attr' }, el('div', { className: 'attr-head' }, - el('button', { className: 'ghost', title: 'Move up', 'aria-label': 'Move attribute up', disabled: index === 0, onclick: () => move(-1) }, '↑'), - el('button', { className: 'ghost', title: 'Move down', 'aria-label': 'Move attribute down', disabled: index === config.attributes.length - 1, onclick: () => move(1) }, '↓'), + el('button', { className: 'ghost', title: 'Move up (segments render in list order)', 'aria-label': 'Move attribute up', disabled: index === 0, onclick: () => move(-1) }, '↑'), + el('button', { className: 'ghost', title: 'Move down (segments render in list order)', 'aria-label': 'Move attribute down', disabled: index === config.attributes.length - 1, onclick: () => move(1) }, '↓'), el('b', {}, attribute.label || attribute.id || 'Untitled'), el('span', { className: 'src' }, attribute.source || 'no source key'), el('div', { className: 'flags' }, - attribute.drivesColor ? el('span', { className: 'tag color-driver' }, 'color driver') : el('button', { className: 'ghost', onclick: () => { config.attributes.forEach((a) => { a.drivesColor = false; }); attribute.drivesColor = true; update(); } }, 'Make color driver'), + attribute.drivesColor + ? el('span', { className: 'tag color-driver', title: 'This attribute’s matched value decides the banner background color' }, 'color driver') + : el('button', { className: 'ghost', title: 'Make this attribute decide the banner background color (exactly one attribute must)', onclick: () => { config.attributes.forEach((a) => { a.drivesColor = false; }); attribute.drivesColor = true; update(); } }, 'Make color driver'), el('button', { className: 'danger-ghost', onclick: () => { config.attributes.splice(index, 1); update(); } }, 'Remove'), ), ), el('div', { className: 'attr-fields grid' }, - field('Id', textInput('id', { className: 'mono' })), - field('Source key (IdP)', textInput('source', { className: 'mono' })), - field('Label (admin-facing)', textInput('label')), - field('Banner label', textInput('bannerLabel')), - field('Label separator', selectInput('labelSeparator', [['', 'None'], ['-', 'Dash (-)'], [' ', 'Space']])), - field('Value separator', selectInput('valueSeparator', [['/', 'Slash (/)'], [', ', 'Comma (, )'], [' ', 'Space']])), - field('Group threshold (0 = never)', el('input', { type: 'number', min: 0, max: 20, value: attribute.groupThreshold ?? 0, oninput: (e) => { attribute.groupThreshold = Number(e.target.value); update(false); } })), - field('Grouped label', textInput('multipleLabel')), + field('Id', textInput('id', { className: 'mono' }), 'Internal identifier — lowercase letters, digits, dashes. Must be unique across attributes.'), + field('Source key', textInput('source', { className: 'mono' }), 'Attribute key exactly as stored on the room (e.g. the Virtru attribute name, or the LDAP-mapped key). Case-sensitive.'), + field('Label', textInput('label'), 'Admin-facing name for this attribute. Not shown in the banner.'), + field('Banner label', textInput('bannerLabel'), 'Prefix shown before the values when “show label prefix” is on (e.g. SAR, REL TO).'), + field('Label separator', selectInput('labelSeparator', [['', 'None'], ['-', 'Dash (-)'], [' ', 'Space']]), 'Between the banner label and the values: SAR-APPLES vs REL TO USA.'), + field('Value separator', selectInput('valueSeparator', [['/', 'Slash (/)'], [', ', 'Comma (, )'], [' ', 'Space']]), 'Between multiple values in this segment: AAA/BBB.'), + field('Group threshold', el('input', { type: 'number', min: 0, max: 20, value: attribute.groupThreshold ?? 0, oninput: (e) => { attribute.groupThreshold = Number(e.target.value); update(false); } }), + 'Collapse the value list into the grouped label when the room carries at least this many matching values. 0 = never collapse.'), + field('Grouped label', textInput('multipleLabel'), 'Replaces the value list when collapsed (e.g. MULTIPLE PROGRAMS). Required if a threshold is set.'), el('div', {}, - check('Show in banner', { checked: attribute.showInBanner, onchange: (e) => { attribute.showInBanner = e.target.checked; update(); } }), + check('Show in banner', { checked: attribute.showInBanner, onchange: (e) => { attribute.showInBanner = e.target.checked; update(); } }, + 'Render this attribute as a banner segment. Off keeps it in the config without displaying it.'), ), el('div', {}, - check('Show label prefix', { checked: !!attribute.showLabel, onchange: (e) => { attribute.showLabel = e.target.checked; update(); } }), + check('Show label prefix', { checked: !!attribute.showLabel, onchange: (e) => { attribute.showLabel = e.target.checked; update(); } }, + 'Prefix the segment with the banner label.'), ), el('div', {}, - check('Sort values A→Z', { checked: !!attribute.sortAlpha, onchange: (e) => { attribute.sortAlpha = e.target.checked; update(); } }), + check('Sort values A→Z', { checked: !!attribute.sortAlpha, onchange: (e) => { attribute.sortAlpha = e.target.checked; update(); } }, + 'Sort matched value labels alphabetically instead of list order.'), ), ), el('div', { className: 'values' }, + el('div', { className: 'card-hint' }, + 'Value mappings — “source value” is the raw value on the room (case-sensitive), “banner label” is what displays. For the color-driving attribute, order values least → most restrictive: “most restrictive” color mode picks the furthest-down match.'), el('table', {}, el('thead', {}, el('tr', {}, el('th', {}, 'Source value'), el('th', {}, 'Banner label'), el('th', {}, 'Color'), el('th', {}))), el('tbody', {}, ...attribute.values.map(valueRow)), @@ -533,35 +606,81 @@

Import configuration

}; const renderAttributes = () => { - $('#attributes').replaceChildren(...config.attributes.map(renderAttribute)); + const host = $('#attributes'); + if (config.attributes.length === 0) { + host.replaceChildren(el('div', { id: 'empty-state' }, + el('p', {}, el('b', {}, 'No attributes yet.')), + el('p', {}, 'Add the attributes your ABAC rooms carry, or load the US-Government example to see a complete configuration.'), + )); + return; + } + host.replaceChildren(...config.attributes.map(renderAttribute)); }; const renderSimulation = () => { - $('#sim').replaceChildren( - ...config.attributes.map((attribute) => el('div', { className: 'sim-group' }, - el('b', {}, `${attribute.label || attribute.id} `, el('span', { className: 'src' }, attribute.source)), - el('div', { className: 'sim-values' }, - ...attribute.values.map((value) => check(value.label || value.source, { - checked: (simulation[attribute.source] ?? []).includes(value.source), - onchange: (e) => { - const selected = new Set(simulation[attribute.source] ?? []); - e.target.checked ? selected.add(value.source) : selected.delete(value.source); - simulation[attribute.source] = [...selected]; + const host = $('#sim'); + if (config.attributes.length === 0) { + host.replaceChildren(el('p', { className: 'hint' }, 'Add an attribute first — its values become pickable here.')); + return; + } + host.replaceChildren( + ...config.attributes.map((attribute) => { + const selected = simulation[attribute.source] ?? []; + const available = attribute.values.filter((v) => v.source && !selected.includes(v.source)); + const chip = (source) => { + const value = attribute.values.find((v) => v.source === source); + return el('span', { className: 'chip' }, value?.label || source, + el('button', { 'aria-label': `Remove ${source}`, title: 'Remove', onclick: () => { + simulation[attribute.source] = selected.filter((s) => s !== source); update(); + } }, '✕')); + }; + return el('div', { className: 'sim-group' }, + el('b', {}, `${attribute.label || attribute.id} `, el('span', { className: 'src' }, attribute.source)), + el('div', { className: 'ms' }, + ...selected.map(chip), + available.length > 0 && el('select', { + 'aria-label': `Add ${attribute.label || attribute.id} value`, + onchange: (e) => { + if (!e.target.value) return; + simulation[attribute.source] = [...selected, e.target.value]; + update(); + }, }, - })), - ), - )), + el('option', { value: '' }, selected.length ? 'Add value…' : 'Select values…'), + ...available.map((v) => el('option', { value: v.source }, `${v.label || v.source} (${v.source})`)), + ), + ), + ); + }), ); }; const renderWarnings = () => { const problems = lintConfig(config); const box = $('#warnings'); - box.className = problems.length ? '' : 'ok'; + if (config.attributes.length === 0) { + box.className = 'info'; + box.replaceChildren( + el('h3', {}, 'Getting started'), + el('p', {}, 'This configuration has no attributes yet, so every room would show the fallback banner. Add an attribute below, or load the example.'), + ); + return; + } + if (problems.length) { + box.className = ''; + box.replaceChildren( + el('h3', {}, `Fix before saving (${problems.length})`), + el('ul', {}, ...problems.map((p) => el('li', {}, p))), + ); + return; + } + box.className = 'ok'; box.replaceChildren( - el('h3', {}, problems.length ? `Fix before saving (${problems.length})` : 'Configuration passes all cross-field rules'), - problems.length ? el('ul', {}, ...problems.map((p) => el('li', {}, p))) : null, + el('h3', {}, 'Configuration passes all cross-field rules'), + el('ul', { className: 'rules' }, ...RULES.map((r) => el('li', {}, r))), + el('p', { className: 'rules-note' }, + 'These rules are part of the v1 contract on top of the JSON Schema. Rocket.Chat currently validates JSON syntax on save; rule enforcement ships separately — this builder is the guardrail until then.'), ); }; @@ -582,13 +701,16 @@

Import configuration

// ---- toolbar ---- -$('#btn-copy').addEventListener('click', async () => { +const copyJson = (button) => async () => { await navigator.clipboard.writeText($('#json-out').value); - $('#btn-copy').textContent = 'Copied'; - setTimeout(() => { $('#btn-copy').textContent = 'Copy JSON'; }, 1200); -}); + const original = button.textContent; + button.textContent = 'Copied'; + setTimeout(() => { button.textContent = original; }, 1200); +}; +$('#btn-copy').addEventListener('click', copyJson($('#btn-copy'))); +$('#btn-copy-json').addEventListener('click', copyJson($('#btn-copy-json'))); -$('#btn-reset').addEventListener('click', () => { +$('#btn-example').addEventListener('click', () => { config = exampleConfig(); simulation = { 'clearance.level': ['TS'], 'access.programs': ['SAP-1042', 'SAP-2271', 'SAP-3380'], 'dissem.relto': ['USA'] }; update(); From 1deeb2895eb97a50a7ef8a4a9f2af7d1ceee73e6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 9 Jul 2026 14:19:38 -0600 Subject: [PATCH 08/43] feat: dark mode for classification banner builder --- .../classification-banner-builder/index.html | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/ee/packages/abac/docs/classification-banner-builder/index.html b/ee/packages/abac/docs/classification-banner-builder/index.html index a786cb9a94647..933422e5e32d6 100644 --- a/ee/packages/abac/docs/classification-banner-builder/index.html +++ b/ee/packages/abac/docs/classification-banner-builder/index.html @@ -4,6 +4,14 @@ Classification banner builder — Rocket.Chat + - - - -
- -
- #simulated-roomthis is how the strip renders above the room header - -
-
- -
-

Classification banner builder

-
- - - - -

- Compose the ABAC_Classification_Banners_Config document for Rocket.Chat. - Add the attributes your rooms carry (e.g. from Virtru or LDAP), map each raw value to a display label and color, and - the strip at the top renders live — exactly what members of a matching ABAC room will see. “Load example” shows the - complete US-Government configuration from the design prototype. -

-
- -
-
-
-

Banner options

-
Global settings for the strip itself — how segments are joined, positioned, colored and typeset.
- -
- -
-

Attributes

-
- One entry per room attribute that should appear in the banner. Segments render in list order, joined by the - delimiter. An attribute only produces a segment when the room carries at least one of its mapped values. -
-
-
- -
-
-
- -
-
-

Simulated room values

-
- A pretend room for the live preview only — not part of the JSON. Pick which raw values the room carries per - attribute, like assigning ABAC attributes to a room in Rocket.Chat. -
-
-
- -
-

Configuration JSON

-
-
- -

Paste into Admin → ABAC → Settings → Classification banners → configuration (JSON) and enable the toggle.

-
-
- -
-
-
- - -

Classification banners (ABAC)

-
-

- Renders a US-Government-style classification banner above the room header in ABAC-managed rooms, driven by a JSON - configuration stored in the ABAC_Classification_Banners_Config admin setting - (Admin → ABAC → Settings), toggled by ABAC_Classification_Banners_Enabled. -

-

- The configuration is a public setting: it syncs to every logged-in client, and the banner is computed - client-side from the room's abacAttributes and the configuration. Config or room-attribute changes - propagate to open clients live, with no reload. Note the visibility tradeoff: any logged-in user can read the full - configuration (all source keys and value→label/color mappings), so don't encode anything in it that members - shouldn't see. -

- -

Configuration schema

-

- Version 1 of the configuration is described by the JSON Schema with - $id: https://rocket.chat/schemas/classification-banners/v1.json - (published in the Rocket.Chat repository at ee/packages/abac/docs/classification-banners.schema.json). - This builder generates and validates documents against that contract. -

- -

Semantics

-
    -
  • - Segments — one per attribute with showInBanner: true, in array order, joined by - banner.delimiter. An attribute contributes a segment only when the room has at least one value - matching one of its values[].source entries (matched against the room attribute whose key equals - attributes[].source). -
  • -
  • - Segment text — matched value labels joined by valueSeparator. With - showLabel: true the segment is prefixed with bannerLabel + labelSeparator. With - sortAlpha: true labels are sorted alphabetically. When groupThreshold > 0 and the - matched value count reaches the threshold, the value list collapses to multipleLabel. -
  • -
  • - Color — the single attribute with drivesColor: true picks the banner background. Its - values array is ranked most restrictive first — index 0 = highest ranking (same convention - as Virtru HIERARCHY attributes). colorMode: "highest" (default) selects the highest-ranked matched - value (earliest in the array); "attribute" selects the first of the room's values that is mapped. - The foreground color is computed for readable contrast (WCAG relative luminance). If the driver attribute has - no matched values, banner.fallbackColor is used. -
  • -
  • - Fallback — when no attribute produces a segment, the banner renders banner.fallbackText on - banner.fallbackColor (defaults: NO CLASSIFICATION DATA / #6C727A). -
  • -
  • - Styles (banner.style) — classic: single centered line; segmented: - segments separated by vertical rules instead of the delimiter; edge: classic with contrasting - top/bottom edge rules. -
  • -
  • The banner always renders above the room header.
  • -
  • Config changes require no server restart and propagate to open clients live via settings sync.
  • -
- -

Cross-field rules

-

These rules are part of the v1 contract and are validated by this builder (save-time enforcement in Rocket.Chat ships separately):

-
    -
  • Exactly one attribute has drivesColor: true.
  • -
  • attributes[].id values are unique; within an attribute, values[].source are unique.
  • -
  • multipleLabel is required when groupThreshold > 0.
  • -
  • bannerLabel is required when showLabel: true.
  • -
- -

Example

-

- The bundled example reproduces the design prototype's default state. For a room with - clearance.level = [TS], access.programs = [SAP-1042, SAP-2271, SAP-3380], - dissem.relto = [USA] it renders TOP SECRET // SAR-APPLES/BANANAS/ORANGES // RELTO USA on - the Top Secret orange. Use Load example to open it in the builder and copy its JSON. -

-
-
- -
-
- - -

Replace your current work?

-

-
- - -
-
- - -

Import configuration

- -
-
- - -
-
- - - - From 7580128c94642d20b121fda89966bfebbef2df52 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 13:37:03 -0600 Subject: [PATCH 29/43] refactor: trust the saved config, parse-only guard on the client --- .../ClassificationBanner/lib/engine.spec.ts | 8 +------- .../room/ClassificationBanner/lib/engine.ts | 19 +------------------ 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts index 5f63c6b8a3645..e1693faf820b8 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -210,14 +210,8 @@ describe('parseClassificationBannersConfig', () => { }); it.each([ - ['empty string', ''], + ['empty string (unset setting)', ''], ['invalid JSON', 'not json'], - ['non-object', '"str"'], - ['missing everything', '{}'], - ['wrong version', JSON.stringify({ ...config, version: 2 })], - ['missing delimiter', JSON.stringify({ ...config, banner: {} })], - ['empty attributes', JSON.stringify({ ...config, attributes: [] })], - ['malformed attribute', JSON.stringify({ ...config, attributes: [{ id: 'x' }] })], ])('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 index fbc21757e20aa..3e2842996d44d 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -16,29 +16,12 @@ export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { return luminance > 0.55 ? '#1F2329' : '#FFFFFF'; }; -const isStructurallyValidConfig = (config: ClassificationBannersConfig): boolean => - config.version === 1 && - typeof config.enabled === 'boolean' && - typeof config.banner?.delimiter === 'string' && - Array.isArray(config.attributes) && - config.attributes.length > 0 && - config.attributes.every( - (attribute) => typeof attribute?.id === 'string' && typeof attribute.source === 'string' && Array.isArray(attribute.values), - ); - export const parseClassificationBannersConfig = (raw: string): ClassificationBannersConfig | null => { - let parsed: unknown; try { - parsed = JSON.parse(raw); + return JSON.parse(raw) as ClassificationBannersConfig; } catch { return null; } - if (typeof parsed !== 'object' || parsed === null) { - return null; - } - - const config = parsed as ClassificationBannersConfig; - return isStructurallyValidConfig(config) ? config : null; }; const buildSegment = ( From e4607a0ef0665f73a5d643d798f904f54b7fb1e4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 14:06:45 -0600 Subject: [PATCH 30/43] style: prefer falsy length checks in banner engine --- .../client/views/room/ClassificationBanner/lib/engine.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts index 3e2842996d44d..9879b4f10db88 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -30,7 +30,7 @@ const buildSegment = ( ): ClassificationBannerSegment | null => { const roomValues = roomAttributes.find(({ key }) => key === attribute.source)?.values ?? []; let matched = attribute.values.filter(({ source }) => roomValues.includes(source)); - if (matched.length === 0) { + if (!matched.length) { return null; } if (attribute.sortAlpha) { @@ -54,7 +54,7 @@ const resolveColor = (config: ClassificationBannersConfig, roomAttributes: IAbac const roomValues = roomAttributes.find(({ key }) => key === driver.source)?.values ?? []; // values are ranked most restrictive first: index 0 = highest ranking const matched = driver.values.filter(({ source }) => roomValues.includes(source)); - if (matched.length === 0) { + if (!matched.length) { return config.banner.fallbackColor ?? FALLBACK_COLOR; } if ((config.banner.colorMode ?? 'highest') === 'highest') { @@ -79,7 +79,7 @@ export const buildClassificationBanner = ( .map((attribute) => buildSegment(attribute, roomAttributes)) .filter(isTruthy); - if (segments.length === 0) { + if (!segments.length) { const backgroundColor = banner.fallbackColor ?? FALLBACK_COLOR; return { ...base, From 32f52c6b20f0a9b8d761b140eec80371f6d179dd Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 14:17:45 -0600 Subject: [PATCH 31/43] refactor: require all config fields per schema, extract documented color helpers --- .../ClassificationBanner.tsx | 6 +-- .../room/ClassificationBanner/lib/colors.ts | 26 +++++++++++ .../ClassificationBanner/lib/constants.ts | 2 - .../ClassificationBanner/lib/engine.spec.ts | 21 +-------- .../room/ClassificationBanner/lib/engine.ts | 35 ++++++--------- .../room/ClassificationBanner/lib/types.ts | 28 ++++++------ .../docs/classification-banners.schema.json | 43 ++++++++++++------- 7 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts delete mode 100644 apps/meteor/client/views/room/ClassificationBanner/lib/constants.ts diff --git a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx index e03ed22624b0a..93dd7bd3b0489 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx @@ -3,15 +3,11 @@ import { Box } from '@rocket.chat/fuselage'; import { useSetting } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { shade } from './lib/colors'; import { buildClassificationBanner, parseClassificationBannersConfig } from './lib/engine'; import { useIsABACManagedRoom } from '../../admin/ABAC/hooks/useIsABACManagedRoom'; import { useRoom } from '../contexts/RoomContext'; -const shade = (hex: string, factor: number): string => { - const [r, g, b] = [0, 2, 4].map((offset) => Math.round(parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) * factor)); - return `rgb(${r}, ${g}, ${b})`; -}; - const ClassificationBanner = () => { const room = useRoom(); const isABACRoom = useIsABACManagedRoom(room); 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..9fc23bbd01a6b --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts @@ -0,0 +1,26 @@ +/** + * Picks a readable text color for a solid background fill. + * + * Computes the background's WCAG relative luminance (sRGB channels are gamma-decoded to linear + * light, then weighted by how strongly the eye perceives each: 21% red, 72% green, 7% blue). + * Backgrounds brighter than the 0.55 threshold get dark ink, darker ones get white — so admins + * can pick any banner color and the label stays legible. + */ +export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { + const [r, g, b] = [0, 2, 4].map((offset) => parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) / 255); + const linearize = (channel: number): number => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + const luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); + return luminance > 0.55 ? '#1F2329' : '#FFFFFF'; +}; + +/** + * Darkens a hex color by multiplying each RGB channel by `factor` (0–1, lower = darker). + * + * Used by the `edge` banner style to draw its top/bottom rules: on dark backgrounds (white text) + * a plain black overlay would be invisible, so the rules use a darkened shade of the background + * color instead. + */ +export const shade = (hex: string, factor: number): string => { + const [r, g, b] = [0, 2, 4].map((offset) => Math.round(parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) * factor)); + return `rgb(${r}, ${g}, ${b})`; +}; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/constants.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/constants.ts deleted file mode 100644 index f95e6a42b420b..0000000000000 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const FALLBACK_TEXT = 'NO CLASSIFICATION DATA'; -export const FALLBACK_COLOR = '#6C727A'; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts index e1693faf820b8..5cc15a0b3be03 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -1,4 +1,5 @@ -import { buildClassificationBanner, parseClassificationBannersConfig, readableTextColor } from './engine'; +import { readableTextColor } from './colors'; +import { buildClassificationBanner, parseClassificationBannersConfig } from './engine'; import type { ClassificationBannersConfig } from './types'; const config: ClassificationBannersConfig = { @@ -175,24 +176,6 @@ describe('buildClassificationBanner', () => { color: '#FFFFFF', }); }); - - it('applies engine defaults for omitted banner options', () => { - const minimal: ClassificationBannersConfig = { - version: 1, - enabled: true, - banner: { delimiter: ' // ' }, - attributes: [config.attributes[0]], - }; - const banner = buildClassificationBanner(minimal, []); - - expect(banner).toMatchObject({ - style: 'classic', - uppercase: true, - monospace: false, - text: 'NO CLASSIFICATION DATA', - backgroundColor: '#6C727A', - }); - }); }); describe('readableTextColor', () => { diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts index 9879b4f10db88..c56b55c43d452 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -1,7 +1,7 @@ import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import { isTruthy } from '@rocket.chat/tools'; -import { FALLBACK_COLOR, FALLBACK_TEXT } from './constants'; +import { readableTextColor } from './colors'; import type { ClassificationBannerAttribute, ClassificationBannerPayload, @@ -9,13 +9,6 @@ import type { ClassificationBannersConfig, } from './types'; -export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { - const [r, g, b] = [0, 2, 4].map((offset) => parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) / 255); - const linearize = (channel: number): number => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); - const luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); - return luminance > 0.55 ? '#1F2329' : '#FFFFFF'; -}; - export const parseClassificationBannersConfig = (raw: string): ClassificationBannersConfig | null => { try { return JSON.parse(raw) as ClassificationBannersConfig; @@ -37,15 +30,14 @@ const buildSegment = ( matched = [...matched].sort((a, b) => a.label.localeCompare(b.label)); } - const threshold = attribute.groupThreshold ?? 0; const body = - threshold > 0 && matched.length >= threshold - ? (attribute.multipleLabel ?? '') - : matched.map(({ label }) => label).join(attribute.valueSeparator ?? '/'); + 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, + text: attribute.showLabel ? `${attribute.bannerLabel}${attribute.labelSeparator}${body}` : body, }; }; @@ -55,9 +47,9 @@ const resolveColor = (config: ClassificationBannersConfig, roomAttributes: IAbac // values are ranked most restrictive first: index 0 = highest ranking const matched = driver.values.filter(({ source }) => roomValues.includes(source)); if (!matched.length) { - return config.banner.fallbackColor ?? FALLBACK_COLOR; + return config.banner.fallbackColor; } - if ((config.banner.colorMode ?? 'highest') === 'highest') { + if (config.banner.colorMode === 'highest') { return matched[0].color; } return roomValues.map((value) => driver.values.find(({ source }) => source === value)).find(isTruthy)?.color ?? matched[0].color; @@ -69,9 +61,9 @@ export const buildClassificationBanner = ( ): ClassificationBannerPayload => { const { banner } = config; const base = { - style: banner.style ?? 'classic', - uppercase: banner.uppercase ?? true, - monospace: banner.monospace ?? false, + style: banner.style, + uppercase: banner.uppercase, + monospace: banner.monospace, } as const; const segments = config.attributes @@ -80,13 +72,12 @@ export const buildClassificationBanner = ( .filter(isTruthy); if (!segments.length) { - const backgroundColor = banner.fallbackColor ?? FALLBACK_COLOR; return { ...base, - text: banner.fallbackText ?? FALLBACK_TEXT, + text: banner.fallbackText, segments: [], - backgroundColor, - color: readableTextColor(backgroundColor), + backgroundColor: banner.fallbackColor, + color: readableTextColor(banner.fallbackColor), }; } diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts index 9b4b11229faa9..fa4d8a43d6580 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts @@ -9,14 +9,14 @@ export type ClassificationBannerAttribute = { source: string; label: string; showInBanner: boolean; - showLabel?: boolean; - bannerLabel?: string; - labelSeparator?: string; - valueSeparator?: string; - sortAlpha?: boolean; - groupThreshold?: number; - multipleLabel?: string; - drivesColor?: boolean; + showLabel: boolean; + bannerLabel: string; + labelSeparator: string; + valueSeparator: string; + sortAlpha: boolean; + groupThreshold: number; + multipleLabel: string; + drivesColor: boolean; values: ClassificationBannerValue[]; }; @@ -26,13 +26,13 @@ export type ClassificationBannersConfig = { version: 1; enabled: boolean; banner: { - style?: ClassificationBannerStyle; - uppercase?: boolean; - monospace?: boolean; + style: ClassificationBannerStyle; + uppercase: boolean; + monospace: boolean; delimiter: string; - colorMode?: 'highest' | 'attribute'; - fallbackText?: string; - fallbackColor?: string; + colorMode: 'highest' | 'attribute'; + fallbackText: string; + fallbackColor: string; }; attributes: ClassificationBannerAttribute[]; }; diff --git a/ee/packages/abac/docs/classification-banners.schema.json b/ee/packages/abac/docs/classification-banners.schema.json index 6ffdcb02097a6..09b3443f6281e 100644 --- a/ee/packages/abac/docs/classification-banners.schema.json +++ b/ee/packages/abac/docs/classification-banners.schema.json @@ -11,15 +11,15 @@ "enabled": { "type": "boolean" }, "banner": { "type": "object", - "required": ["delimiter"], + "required": ["style", "uppercase", "monospace", "delimiter", "colorMode", "fallbackText", "fallbackColor"], "additionalProperties": false, "properties": { - "style": { "enum": ["classic", "segmented", "edge"], "default": "classic" }, - "uppercase": { "type": "boolean", "default": true }, - "monospace": { "type": "boolean", "default": false }, + "style": { "enum": ["classic", "segmented", "edge"] }, + "uppercase": { "type": "boolean" }, + "monospace": { "type": "boolean" }, "delimiter": { "type": "string", "maxLength": 8 }, - "colorMode": { "enum": ["highest", "attribute"], "default": "highest" }, - "fallbackText": { "type": "string", "default": "NO CLASSIFICATION DATA" }, + "colorMode": { "enum": ["highest", "attribute"] }, + "fallbackText": { "type": "string" }, "fallbackColor": { "$ref": "#/$defs/hexColor" } } }, @@ -38,26 +38,39 @@ }, "attribute": { "type": "object", - "required": ["id", "source", "label", "showInBanner", "values"], + "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", "default": false }, - "bannerLabel": { "type": "string", "default": "" }, - "labelSeparator": { "enum": ["-", " ", ""], "default": "" }, - "valueSeparator": { "enum": ["/", ", ", " "], "default": "/" }, - "sortAlpha": { "type": "boolean", "default": false }, + "showLabel": { "type": "boolean" }, + "bannerLabel": { "type": "string" }, + "labelSeparator": { "enum": ["-", " ", ""] }, + "valueSeparator": { "enum": ["/", ", ", " "] }, + "sortAlpha": { "type": "boolean" }, "groupThreshold": { "type": "integer", - "default": 0, "description": "0 = never collapse; otherwise collapse when value count >= threshold (2-20)", "oneOf": [{ "const": 0 }, { "minimum": 2, "maximum": 20 }] }, - "multipleLabel": { "type": "string", "default": "" }, - "drivesColor": { "type": "boolean", "default": false }, + "multipleLabel": { "type": "string" }, + "drivesColor": { "type": "boolean" }, "values": { "type": "array", "minItems": 1, From 9b0d6986bedc9c2607edf2dd4588450300b41e5b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 14:26:01 -0600 Subject: [PATCH 32/43] test: lock the banner config schema as the enforcement contract --- .../ClassificationBanner/lib/schema.spec.ts | 81 +++++++++++++++++++ .../docs/classification-banners.schema.json | 4 +- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts 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..5d59fb7c758ba --- /dev/null +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts @@ -0,0 +1,81 @@ +import Ajv2020 from 'ajv/dist/2020'; + +import type { ClassificationBannersConfig } from './types'; +import schema from '../../../../../../../ee/packages/abac/docs/classification-banners.schema.json'; + +const ajv = new Ajv2020({ allErrors: true }); +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 clone = (value: T): T => JSON.parse(JSON.stringify(value)); + +const mutate = (change: (config: typeof validConfig) => void): unknown => { + const config = clone(validConfig); + change(config); + return config; +}; + +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', mutate((c) => Object.assign(c, { version: 2 }))], + ['missing banner option (colorMode)', mutate((c) => delete (c.banner as Partial).colorMode)], + ['empty delimiter', mutate((c) => Object.assign(c.banner, { delimiter: '' }))], + ['oversized delimiter', mutate((c) => Object.assign(c.banner, { delimiter: 'x'.repeat(9) }))], + ['empty fallbackText', mutate((c) => Object.assign(c.banner, { fallbackText: '' }))], + ['malformed fallbackColor', mutate((c) => Object.assign(c.banner, { fallbackColor: 'red' }))], + ['unknown banner option', mutate((c) => Object.assign(c.banner, { position: 'top' }))], + ['unknown top-level property', mutate((c) => Object.assign(c, { source: 'idp' }))], + ['empty attributes', mutate((c) => Object.assign(c, { attributes: [] }))], + ['identical duplicate attributes', mutate((c) => c.attributes.push(clone(c.attributes[0])))], + ['missing attribute field (drivesColor)', mutate((c) => delete (c.attributes[0] as Partial<(typeof c.attributes)[0]>).drivesColor)], + ['uppercase attribute id', mutate((c) => Object.assign(c.attributes[0], { id: 'Classification' }))], + ['invalid labelSeparator', mutate((c) => Object.assign(c.attributes[0], { labelSeparator: '::' }))], + ['invalid valueSeparator', mutate((c) => Object.assign(c.attributes[0], { valueSeparator: '|' }))], + ['groupThreshold of 1', mutate((c) => Object.assign(c.attributes[0], { groupThreshold: 1 }))], + ['groupThreshold above 20', mutate((c) => Object.assign(c.attributes[0], { groupThreshold: 21 }))], + ['empty values', mutate((c) => Object.assign(c.attributes[0], { values: [] }))], + ['identical duplicate values', mutate((c) => c.attributes[0].values.push(clone(c.attributes[0].values[0])))], + ['value with 3-digit color', mutate((c) => Object.assign(c.attributes[0].values[0], { color: '#fff' }))], + ['value missing label', mutate((c) => delete (c.attributes[0].values[0] as Partial<(typeof c.attributes)[0]['values'][0]>).label)], + ])('rejects %s', (_name, config) => { + expect(validate(config)).toBe(false); + }); +}); diff --git a/ee/packages/abac/docs/classification-banners.schema.json b/ee/packages/abac/docs/classification-banners.schema.json index 09b3443f6281e..a8f8aa4987765 100644 --- a/ee/packages/abac/docs/classification-banners.schema.json +++ b/ee/packages/abac/docs/classification-banners.schema.json @@ -17,9 +17,9 @@ "style": { "enum": ["classic", "segmented", "edge"] }, "uppercase": { "type": "boolean" }, "monospace": { "type": "boolean" }, - "delimiter": { "type": "string", "maxLength": 8 }, + "delimiter": { "type": "string", "minLength": 1, "maxLength": 8 }, "colorMode": { "enum": ["highest", "attribute"] }, - "fallbackText": { "type": "string" }, + "fallbackText": { "type": "string", "minLength": 1 }, "fallbackColor": { "$ref": "#/$defs/hexColor" } } }, From e28f70053302aa65572e26425daf23f7d929e016 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 15:00:02 -0600 Subject: [PATCH 33/43] test: document why schema spec clones via JSON round-trip --- .../views/room/ClassificationBanner/lib/schema.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts index 5d59fb7c758ba..314749c7a8ea1 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment node + */ import Ajv2020 from 'ajv/dist/2020'; import type { ClassificationBannersConfig } from './types'; @@ -41,6 +44,8 @@ const validConfig: ClassificationBannersConfig & { $schema: string } = { ], }; +// Not structuredClone: under jest it clones into the host realm, and ajv's fast-deep-equal +// rejects cross-realm objects by constructor, silently disabling the uniqueItems assertions. const clone = (value: T): T => JSON.parse(JSON.stringify(value)); const mutate = (change: (config: typeof validConfig) => void): unknown => { From a4ee987f476fe5bf6e73692fb0105ebfdd901c01 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 15:04:32 -0600 Subject: [PATCH 34/43] test: validate banner schema with the shared rest-typings ajv instance --- .../client/views/room/ClassificationBanner/lib/schema.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts index 314749c7a8ea1..60e40ea691315 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts @@ -1,12 +1,11 @@ /** * @jest-environment node */ -import Ajv2020 from 'ajv/dist/2020'; +import { ajv } from '@rocket.chat/rest-typings'; import type { ClassificationBannersConfig } from './types'; import schema from '../../../../../../../ee/packages/abac/docs/classification-banners.schema.json'; -const ajv = new Ajv2020({ allErrors: true }); const validate = ajv.compile(schema); const validConfig: ClassificationBannersConfig & { $schema: string } = { From 3f66514c22184d314d2cdafd6da86041c10420cd Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 15:13:25 -0600 Subject: [PATCH 35/43] test: express schema rejection cases as plain object literals --- .../ClassificationBanner/lib/schema.spec.ts | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts index 60e40ea691315..b51a8ab8142e3 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/schema.spec.ts @@ -43,15 +43,9 @@ const validConfig: ClassificationBannersConfig & { $schema: string } = { ], }; -// Not structuredClone: under jest it clones into the host realm, and ajv's fast-deep-equal -// rejects cross-realm objects by constructor, silently disabling the uniqueItems assertions. -const clone = (value: T): T => JSON.parse(JSON.stringify(value)); - -const mutate = (change: (config: typeof validConfig) => void): unknown => { - const config = clone(validConfig); - change(config); - return config; -}; +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', () => { @@ -59,26 +53,26 @@ describe('classification banners JSON schema (v1 enforcement contract)', () => { }); it.each([ - ['wrong version', mutate((c) => Object.assign(c, { version: 2 }))], - ['missing banner option (colorMode)', mutate((c) => delete (c.banner as Partial).colorMode)], - ['empty delimiter', mutate((c) => Object.assign(c.banner, { delimiter: '' }))], - ['oversized delimiter', mutate((c) => Object.assign(c.banner, { delimiter: 'x'.repeat(9) }))], - ['empty fallbackText', mutate((c) => Object.assign(c.banner, { fallbackText: '' }))], - ['malformed fallbackColor', mutate((c) => Object.assign(c.banner, { fallbackColor: 'red' }))], - ['unknown banner option', mutate((c) => Object.assign(c.banner, { position: 'top' }))], - ['unknown top-level property', mutate((c) => Object.assign(c, { source: 'idp' }))], - ['empty attributes', mutate((c) => Object.assign(c, { attributes: [] }))], - ['identical duplicate attributes', mutate((c) => c.attributes.push(clone(c.attributes[0])))], - ['missing attribute field (drivesColor)', mutate((c) => delete (c.attributes[0] as Partial<(typeof c.attributes)[0]>).drivesColor)], - ['uppercase attribute id', mutate((c) => Object.assign(c.attributes[0], { id: 'Classification' }))], - ['invalid labelSeparator', mutate((c) => Object.assign(c.attributes[0], { labelSeparator: '::' }))], - ['invalid valueSeparator', mutate((c) => Object.assign(c.attributes[0], { valueSeparator: '|' }))], - ['groupThreshold of 1', mutate((c) => Object.assign(c.attributes[0], { groupThreshold: 1 }))], - ['groupThreshold above 20', mutate((c) => Object.assign(c.attributes[0], { groupThreshold: 21 }))], - ['empty values', mutate((c) => Object.assign(c.attributes[0], { values: [] }))], - ['identical duplicate values', mutate((c) => c.attributes[0].values.push(clone(c.attributes[0].values[0])))], - ['value with 3-digit color', mutate((c) => Object.assign(c.attributes[0].values[0], { color: '#fff' }))], - ['value missing label', mutate((c) => delete (c.attributes[0].values[0] as Partial<(typeof c.attributes)[0]['values'][0]>).label)], + ['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); }); From 789daf1fd089241bd15abbdb4571c90494fd3270 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Jul 2026 15:25:57 -0600 Subject: [PATCH 36/43] refactor: collapse resolveColor into a single ordered find --- .../room/ClassificationBanner/lib/engine.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts index c56b55c43d452..38d007dbe815d 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -41,18 +41,16 @@ const buildSegment = ( }; }; -const resolveColor = (config: ClassificationBannersConfig, roomAttributes: IAbacAttributeDefinition[]): string => { - const driver = config.attributes.find(({ drivesColor }) => drivesColor) ?? config.attributes[0]; +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: index 0 = highest ranking - const matched = driver.values.filter(({ source }) => roomValues.includes(source)); - if (!matched.length) { - return config.banner.fallbackColor; - } - if (config.banner.colorMode === 'highest') { - return matched[0].color; - } - return roomValues.map((value) => driver.values.find(({ source }) => source === value)).find(isTruthy)?.color ?? matched[0].color; + // 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 = ( From 4ad9c52c4bfb275886ccab623c527678fd2c9eaf Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 16 Jul 2026 12:02:34 -0600 Subject: [PATCH 37/43] feat: render unmapped values of configured attributes as raw text in the classification banner --- .../ClassificationBanner/lib/engine.spec.ts | 25 +++++++++++++++---- .../room/ClassificationBanner/lib/engine.ts | 5 +++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts index 5cc15a0b3be03..7b55dec6dbfec 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -155,19 +155,34 @@ describe('buildClassificationBanner', () => { expect(banner.text).toBe('SECRET'); }); - it('ignores room values not covered by the config and uses the fallback color when the driver has no match', () => { + 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 mapped and unmapped values together when sortAlpha is set and counts both toward groupThreshold', () => { + expect(buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-3380', 'cherry'] }]).text).toBe('SAR-cherry/ORANGES'); + 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('RELTO USA'); + expect(banner.text).toBe('X // RELTO USA'); expect(banner.backgroundColor).toBe('#6C727A'); - expect(banner.segments).toHaveLength(1); }); - it('renders the fallback banner when nothing matches', () => { - const banner = buildClassificationBanner(config, [{ key: 'clearance.level', values: ['X'] }]); + 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', diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts index 38d007dbe815d..6485ba2c48b4f 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -22,7 +22,10 @@ const buildSegment = ( roomAttributes: IAbacAttributeDefinition[], ): ClassificationBannerSegment | null => { const roomValues = roomAttributes.find(({ key }) => key === attribute.source)?.values ?? []; - let matched = attribute.values.filter(({ source }) => roomValues.includes(source)); + let matched = [ + ...attribute.values.filter(({ source }) => roomValues.includes(source)), + ...roomValues.filter((value) => !attribute.values.some(({ source }) => source === value)).map((label) => ({ label })), + ]; if (!matched.length) { return null; } From ba115cfcd29187a81b752778b1ff258f04150cbf Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 16 Jul 2026 12:23:24 -0600 Subject: [PATCH 38/43] order --- .../room/ClassificationBanner/lib/engine.spec.ts | 6 ++++-- .../views/room/ClassificationBanner/lib/engine.ts | 15 ++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts index 7b55dec6dbfec..0a6b1be971d85 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -164,8 +164,10 @@ describe('buildClassificationBanner', () => { expect(banner.text).toBe('TOP SECRET // RELTO USA/ACGU'); }); - it('sorts mapped and unmapped values together when sortAlpha is set and counts both toward groupThreshold', () => { - expect(buildClassificationBanner(config, [{ key: 'access.programs', values: ['SAP-3380', 'cherry'] }]).text).toBe('SAR-cherry/ORANGES'); + 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'); diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts index 6485ba2c48b4f..dc681fd6af6ae 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.ts @@ -22,16 +22,17 @@ const buildSegment = ( roomAttributes: IAbacAttributeDefinition[], ): ClassificationBannerSegment | null => { const roomValues = roomAttributes.find(({ key }) => key === attribute.source)?.values ?? []; - let matched = [ - ...attribute.values.filter(({ source }) => roomValues.includes(source)), - ...roomValues.filter((value) => !attribute.values.some(({ source }) => source === value)).map((label) => ({ label })), - ]; + 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; } - if (attribute.sortAlpha) { - matched = [...matched].sort((a, b) => a.label.localeCompare(b.label)); - } const body = attribute.groupThreshold > 0 && matched.length >= attribute.groupThreshold From 0ec04123a03c620fb2ed7ca076540defa0124278 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 20 Jul 2026 10:28:50 -0600 Subject: [PATCH 39/43] feat: validate the classification banners config against its JSON schema on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON code settings may now declare a schema add-option: kept in memory (never persisted), pre-compiled at registration, and enforced for every save path by validateSettingRules — empty means unconfigured and always passes. The banners config setting declares the published schema, so unparsable or non-conforming documents are rejected with ABAC_Classification_Banners_Config_Invalid. --- apps/meteor/ee/server/settings/abac.ts | 3 + .../server/lib/settingValidationRules.ts | 36 +++++- .../server/settings/SettingsRegistry.ts | 9 +- .../settings/functions/settingSchemas.ts | 10 ++ .../e2e/abac-classification-banner.spec.ts | 119 ++++++++++++++++++ apps/meteor/tests/end-to-end/api/abac.ts | 79 ++++++++++++ .../server/lib/settingValidationRules.spec.ts | 46 +++++++ .../settings/functions/settings.tests.ts | 23 ++++ packages/i18n/src/locales/en.i18n.json | 1 + 9 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 apps/meteor/server/settings/functions/settingSchemas.ts create mode 100644 apps/meteor/tests/e2e/abac-classification-banner.spec.ts diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 2228b0526a5eb..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 }; @@ -67,6 +69,7 @@ export function addSettings(): Promise { 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', diff --git a/apps/meteor/server/lib/settingValidationRules.ts b/apps/meteor/server/lib/settingValidationRules.ts index f729cde6eaf30..5ebfcca662818 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,28 @@ const parseValidationRules = (settingId: ISetting['_id'], validation: NonNullabl const isSettingReference = (value: unknown): value is { $setting: ISetting['_id'] } => isRecord(value) && typeof value.$setting === 'string'; +/** + * Validates the value being saved on a `code: application/json` setting against the JSON schema the setting + * optionally registers in code, pre-compiled at registration. 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 +135,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..186a12b6bf1de --- /dev/null +++ b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts @@ -0,0 +1,119 @@ +import type { IRoom } 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 }, { $set: { 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 }, { $unset: { abacAttributes: 1 } }); + 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('note', { name: 'CLEARANCE-TOP SECRET' }); + await expect(banner).toBeVisible(); + 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.locator('[data-qa-id="classification-banner"]')).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/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 0a5d0f77c6002..e4e8c957a49b0 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -42,6 +42,7 @@ "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", From 20dc820f9fdb48439dcbbe980abf9a5a933a832d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 21 Jul 2026 12:30:58 -0600 Subject: [PATCH 40/43] fix: address review feedback on banner contrast, keys, and test hygiene - pick the banner ink by comparing WCAG contrast ratios instead of a fixed luminance threshold, so mid-range backgrounds like #999999 and the TS orange get the more legible color - index the segment React key since duplicate attribute ids are not rejected server-side - scope the Playwright admin attribute seeding with $push/$pull so parallel specs cannot clobber each other, and locate the banner by role instead of data-qa-id - trim the validatesSchema JSDoc down to its one non-obvious line --- .../ClassificationBanner.tsx | 2 +- .../room/ClassificationBanner/lib/colors.ts | 25 +++++++++++++------ .../ClassificationBanner/lib/engine.spec.ts | 6 ++++- .../server/lib/settingValidationRules.ts | 6 +---- .../e2e/abac-classification-banner.spec.ts | 12 ++++----- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx index 93dd7bd3b0489..d1606fba7889c 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx @@ -64,7 +64,7 @@ const ClassificationBanner = () => { > {banner.style === 'segmented' ? ( banner.segments.map((segment, index) => ( - + {index > 0 && } {segment.text} diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts index 9fc23bbd01a6b..dd982624ecfdf 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts @@ -1,16 +1,25 @@ /** * Picks a readable text color for a solid background fill. * - * Computes the background's WCAG relative luminance (sRGB channels are gamma-decoded to linear - * light, then weighted by how strongly the eye perceives each: 21% red, 72% green, 7% blue). - * Backgrounds brighter than the 0.55 threshold get dark ink, darker ones get white — so admins - * can pick any banner color and the label stays legible. + * 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 [r, g, b] = [0, 2, 4].map((offset) => parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) / 255); - const linearize = (channel: number): number => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); - const luminance = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); - return luminance > 0.55 ? '#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 index 0a6b1be971d85..c671ae2c07137 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/engine.spec.ts @@ -110,7 +110,7 @@ describe('buildClassificationBanner', () => { 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('#FFFFFF'); + expect(banner.color).toBe('#1F2329'); expect(banner).toMatchObject({ style: 'classic', uppercase: true, monospace: false }); }); @@ -202,6 +202,10 @@ describe('readableTextColor', () => { 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', () => { diff --git a/apps/meteor/server/lib/settingValidationRules.ts b/apps/meteor/server/lib/settingValidationRules.ts index 5ebfcca662818..7acbce39528bd 100644 --- a/apps/meteor/server/lib/settingValidationRules.ts +++ b/apps/meteor/server/lib/settingValidationRules.ts @@ -47,11 +47,7 @@ const parseValidationRules = (settingId: ISetting['_id'], validation: NonNullabl const isSettingReference = (value: unknown): value is { $setting: ISetting['_id'] } => isRecord(value) && typeof value.$setting === 'string'; -/** - * Validates the value being saved on a `code: application/json` setting against the JSON schema the setting - * optionally registers in code, pre-compiled at registration. An empty value means the setting is unconfigured and - * always passes. - */ +// 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 === '') { diff --git a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts index 186a12b6bf1de..c9ce941fa24e1 100644 --- a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts +++ b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts @@ -1,4 +1,4 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { MongoClient } from 'mongodb'; import { IS_EE, URL_MONGODB } from './config/constants'; @@ -73,8 +73,8 @@ test.describe.serial('abac-classification-banner', () => { // 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 }, { $set: { abacAttributes: [{ key: attrKey, values: ['TS', 'U'] }] } }); + .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); @@ -85,8 +85,8 @@ test.describe.serial('abac-classification-banner', () => { await api.delete(`/abac/attributes/${attributeId}`); await connection .db() - .collection('users') - .updateOne({ username: Users.admin.data.username }, { $unset: { abacAttributes: 1 } }); + .collection('users') + .updateOne({ username: Users.admin.data.username }, { $pull: { abacAttributes: { key: attrKey } } }); await connection.close(); await Promise.all([ @@ -114,6 +114,6 @@ test.describe.serial('abac-classification-banner', () => { await poHomeChannel.navbar.openChat(room.name as string); await expect(poHomeChannel.composer.inputMessage).toBeVisible(); - await expect(page.locator('[data-qa-id="classification-banner"]')).toHaveCount(0); + await expect(page.getByRole('note', { name: 'CLEARANCE-TOP SECRET' })).toHaveCount(0); }); }); From ba380a83c39c8654d931b2a005ef4f6ea2554726 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 23 Jul 2026 18:02:35 -0300 Subject: [PATCH 41/43] fix: ui review --- .../ClassificationBanner.stories.tsx | 146 ++++++++++++++++++ .../ClassificationBanner.tsx | 56 ++----- .../room/ClassificationBanner/lib/colors.ts | 12 -- .../room/ClassificationBanner/lib/types.ts | 2 +- 4 files changed, 162 insertions(+), 54 deletions(-) create mode 100644 apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.stories.tsx 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 index d1606fba7889c..b6166bf0fe842 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx +++ b/apps/meteor/client/views/room/ClassificationBanner/ClassificationBanner.tsx @@ -1,14 +1,14 @@ -import { css } from '@rocket.chat/css-in-js'; import { Box } from '@rocket.chat/fuselage'; import { useSetting } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; -import { shade } from './lib/colors'; 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); @@ -19,6 +19,7 @@ const ClassificationBanner = () => { if (!enabled) { return null; } + const config = parseClassificationBannersConfig(rawConfig); return config?.enabled ? buildClassificationBanner(config, room.abacAttributes ?? []) : null; }, [enabled, rawConfig, room.abacAttributes]); @@ -27,51 +28,24 @@ const ClassificationBanner = () => { return null; } - const edgeRule = banner.color === '#FFFFFF' ? shade(banner.backgroundColor, 0.6) : 'rgba(0, 0, 0, 0.28)'; - const bannerClass = css` - height: 28px; - background-color: ${banner.backgroundColor}; - color: ${banner.color}; - font-size: 13px; - font-weight: 700; - line-height: 1; - letter-spacing: ${banner.monospace ? '0.02em' : '0.08em'}; - text-transform: ${banner.uppercase ? 'uppercase' : 'none'}; - white-space: nowrap; - user-select: none; - ${banner.monospace ? 'font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;' : ''} - ${banner.style === 'edge' ? `border-top: 3px solid ${edgeRule}; border-bottom: 3px solid ${edgeRule};` : ''} - `; - const segmentRuleClass = css` - width: 1px; - height: 60%; - margin: 0 14px; - background-color: ${banner.color}; - opacity: 0.45; - `; - return ( - {banner.style === 'segmented' ? ( - banner.segments.map((segment, index) => ( - - {index > 0 && } - {segment.text} - - )) - ) : ( - {banner.text} - )} + + {banner.text} + ); }; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts index dd982624ecfdf..cb843644fb8c7 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/colors.ts @@ -21,15 +21,3 @@ export const readableTextColor = (hex: string): '#1F2329' | '#FFFFFF' => { return contrastWith('#1F2329') >= contrastWith('#FFFFFF') ? '#1F2329' : '#FFFFFF'; }; - -/** - * Darkens a hex color by multiplying each RGB channel by `factor` (0–1, lower = darker). - * - * Used by the `edge` banner style to draw its top/bottom rules: on dark backgrounds (white text) - * a plain black overlay would be invisible, so the rules use a darkened shade of the background - * color instead. - */ -export const shade = (hex: string, factor: number): string => { - const [r, g, b] = [0, 2, 4].map((offset) => Math.round(parseInt(hex.replace('#', '').slice(offset, offset + 2), 16) * factor)); - return `rgb(${r}, ${g}, ${b})`; -}; diff --git a/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts index fa4d8a43d6580..9bdf604693247 100644 --- a/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts +++ b/apps/meteor/client/views/room/ClassificationBanner/lib/types.ts @@ -20,7 +20,7 @@ export type ClassificationBannerAttribute = { values: ClassificationBannerValue[]; }; -export type ClassificationBannerStyle = 'classic' | 'segmented' | 'edge'; +export type ClassificationBannerStyle = 'classic'; export type ClassificationBannersConfig = { version: 1; From 8671417376fe528c713663c1e2040339c9251d7d Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Thu, 23 Jul 2026 18:18:21 -0300 Subject: [PATCH 42/43] chore: remove style values from classification schema --- ee/packages/abac/docs/classification-banners.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/abac/docs/classification-banners.schema.json b/ee/packages/abac/docs/classification-banners.schema.json index a8f8aa4987765..52168f906d59c 100644 --- a/ee/packages/abac/docs/classification-banners.schema.json +++ b/ee/packages/abac/docs/classification-banners.schema.json @@ -14,7 +14,7 @@ "required": ["style", "uppercase", "monospace", "delimiter", "colorMode", "fallbackText", "fallbackColor"], "additionalProperties": false, "properties": { - "style": { "enum": ["classic", "segmented", "edge"] }, + "style": { "enum": ["classic"] }, "uppercase": { "type": "boolean" }, "monospace": { "type": "boolean" }, "delimiter": { "type": "string", "minLength": 1, "maxLength": 8 }, From 1455f76976ad9e8330d22851256426d874f8afe0 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 30 Jul 2026 10:25:27 -0600 Subject: [PATCH 43/43] test: match banner locator to region role and static aria-label --- apps/meteor/tests/e2e/abac-classification-banner.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts index c9ce941fa24e1..fc9d9a87d9ea4 100644 --- a/apps/meteor/tests/e2e/abac-classification-banner.spec.ts +++ b/apps/meteor/tests/e2e/abac-classification-banner.spec.ts @@ -104,8 +104,9 @@ test.describe.serial('abac-classification-banner', () => { 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('note', { name: 'CLEARANCE-TOP SECRET' }); + 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')); }); @@ -114,6 +115,6 @@ test.describe.serial('abac-classification-banner', () => { await poHomeChannel.navbar.openChat(room.name as string); await expect(poHomeChannel.composer.inputMessage).toBeVisible(); - await expect(page.getByRole('note', { name: 'CLEARANCE-TOP SECRET' })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Room Attributes' })).toHaveCount(0); }); });