diff --git a/.changeset/pink-moons-cheer.md b/.changeset/pink-moons-cheer.md new file mode 100644 index 0000000000000..e753031cdb868 --- /dev/null +++ b/.changeset/pink-moons-cheer.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/apps-engine": minor +"@rocket.chat/apps": minor +--- + +Allows apps with the right permission to read room's ABAC attributes. diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index c81792a0bb390..2c699399f1b5e 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -1,3 +1,4 @@ +import { secureFieldsMapper } from '@rocket.chat/apps/dist/lib/SecureFields'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import { LivechatVisitors, Rooms, LivechatDepartment, Users, LivechatContacts } from '@rocket.chat/models'; @@ -435,6 +436,22 @@ export class AppRoomsConverter { return this.orch.getConverters().get('rooms').convertById(prid); }, + ...secureFieldsMapper((room) => { + if (!room.abacAttributes) { + return undefined; + } + + const value = [ + { + permission: 'abac.read', + name: 'abacAttributes', + value: room.abacAttributes, + }, + ]; + + delete room.abacAttributes; + return value; + }), }; return transformMappedData(originalRoom, map); diff --git a/packages/apps-engine/src/definition/abac/AbacAttributes.ts b/packages/apps-engine/src/definition/abac/AbacAttributes.ts new file mode 100644 index 0000000000000..0a10ef9ec4afa --- /dev/null +++ b/packages/apps-engine/src/definition/abac/AbacAttributes.ts @@ -0,0 +1,12 @@ +export interface IAbacAttributeDefinition { + /** + * Validation expectation (NOT enforced here, must be enforced by caller): + * /^[A-Za-z0-9_-]+$/ + */ + key: string; + + /** + * List of string values for this attribute key. + */ + values: string[]; +} diff --git a/packages/apps-engine/src/definition/rooms/IRoom.ts b/packages/apps-engine/src/definition/rooms/IRoom.ts index b32f71bc9949a..7fd382e0929f3 100644 --- a/packages/apps-engine/src/definition/rooms/IRoom.ts +++ b/packages/apps-engine/src/definition/rooms/IRoom.ts @@ -1,3 +1,4 @@ +import type { IAbacAttributeDefinition } from '../abac/AbacAttributes'; import type { IUser } from '../users'; import type { RoomType } from './RoomType'; @@ -25,4 +26,6 @@ export interface IRoom { customFields?: { [key: string]: any }; parentRoom?: IRoom; livechatData?: { [key: string]: any }; + + abacAttributes?: IAbacAttributeDefinition[]; } diff --git a/packages/apps/deno-runtime/lib/codec.ts b/packages/apps/deno-runtime/lib/codec.ts index a1da020799982..90456c76968c2 100644 --- a/packages/apps/deno-runtime/lib/codec.ts +++ b/packages/apps/deno-runtime/lib/codec.ts @@ -1,8 +1,14 @@ import { Buffer } from 'node:buffer'; -import { Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; +import { decode, Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; import type { App as _App } from '@rocket.chat/apps-engine/definition/App'; + import { require } from './require.ts'; +import { applySecureFields, type WithSecureFields } from './secureFields.ts'; + +const FUNCTION_DISABLER_EXT = 0; +const BUFFER_HANDLER_EXT = 1; +const SECURE_FIELDS_HANDLER_EXT = 2; const { App } = require('@rocket.chat/apps-engine/definition/App.js') as { App: typeof _App; @@ -11,7 +17,7 @@ const { App } = require('@rocket.chat/apps-engine/definition/App.js') as { const extensionCodec = new ExtensionCodec(); extensionCodec.register({ - type: 0, + type: FUNCTION_DISABLER_EXT, encode: (object: unknown) => { // We don't care about functions, but also don't want to throw an error if (typeof object === 'function' || object instanceof App) { @@ -25,7 +31,7 @@ extensionCodec.register({ // Since Deno doesn't have Buffer by default, we need to use Uint8Array extensionCodec.register({ - type: 1, + type: BUFFER_HANDLER_EXT, encode: (object: unknown) => { if (object instanceof Buffer) { return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); @@ -39,5 +45,11 @@ extensionCodec.register({ }, }); +extensionCodec.register({ + type: SECURE_FIELDS_HANDLER_EXT, + encode: (_object: unknown) => null, + decode: (data: Uint8Array) => applySecureFields(decode(data, { extensionCodec }) as WithSecureFields>), +}); + export const encoder = new Encoder({ extensionCodec }); export const decoder = new Decoder({ extensionCodec }); diff --git a/packages/apps/deno-runtime/lib/room.ts b/packages/apps/deno-runtime/lib/room.ts index 437f091c3cbf1..f4bb0547d1731 100644 --- a/packages/apps/deno-runtime/lib/room.ts +++ b/packages/apps/deno-runtime/lib/room.ts @@ -1,3 +1,4 @@ +import type { IAbacAttributeDefinition } from '@rocket.chat/apps-engine/definition/abac/AbacAttributes'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType'; import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; @@ -42,6 +43,8 @@ export class Room { public userIds?: Array; + public abacAttributes?: IAbacAttributeDefinition[]; + private _USERNAMES: Promise> | undefined; private [PrivateManager]: IRoomManager | undefined; @@ -88,6 +91,7 @@ export class Room { lastModifiedAt: this.lastModifiedAt, customFields: this.customFields, userIds: this.userIds, + abacAttributes: this.abacAttributes, }; } diff --git a/packages/apps/deno-runtime/lib/secureFields.ts b/packages/apps/deno-runtime/lib/secureFields.ts new file mode 100644 index 0000000000000..aabb5cf854b5c --- /dev/null +++ b/packages/apps/deno-runtime/lib/secureFields.ts @@ -0,0 +1,26 @@ +import { kSecureFields, WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; + +export type { WithSecureFields } from '@rocket.chat/apps/dist/lib/SecureFields'; + +export function applySecureFields(object: WithSecureFields>) { + const { [kSecureFields]: secureFields, ...rest } = object; + + const app = AppObjectRegistry.get('app'); + + if (!app) { + throw new Error("App unavailable, can't parse object with secure fields"); + } + + secureFields.forEach(({ permission, name, value }) => { + if (!app.getInfo().permissions?.find((p) => p.name === permission)) { + return; + } + + rest[name] = value; + }); + + return rest; +} diff --git a/packages/apps/deno-runtime/lib/tests/secureFields.test.ts b/packages/apps/deno-runtime/lib/tests/secureFields.test.ts new file mode 100644 index 0000000000000..a27332eb35fdc --- /dev/null +++ b/packages/apps/deno-runtime/lib/tests/secureFields.test.ts @@ -0,0 +1,59 @@ +import { assertEquals, assertThrows } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { applySecureFields } from '../secureFields.ts'; + +const SECURE_FIELDS_KEY = '@@SecureFields'; + +describe('applySecureFields', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + }); + + it('throws when app is unavailable', () => { + assertThrows( + () => applySecureFields({ foo: 'bar', [SECURE_FIELDS_KEY]: [] } as any), + Error, + "App unavailable, can't parse object with secure fields", + ); + }); + + it('applies only secure fields with matching permissions', () => { + AppObjectRegistry.set('app', { + getInfo: () => ({ + permissions: [{ name: 'abac.read' }], + }), + }); + + const parsed = applySecureFields({ + foo: 'bar', + [SECURE_FIELDS_KEY]: [ + { permission: 'abac.read', name: 'abacAttributes', value: { department: 'support' } }, + { permission: 'api.read', name: 'apiToken', value: 'secret' }, + ], + } as any); + + assertEquals(parsed, { + foo: 'bar', + abacAttributes: { department: 'support' }, + }); + }); + + it('overwrites an existing field when permission is granted', () => { + AppObjectRegistry.set('app', { + getInfo: () => ({ + permissions: [{ name: 'abac.read' }], + }), + }); + + const parsed = applySecureFields({ + abacAttributes: null, + [SECURE_FIELDS_KEY]: [{ permission: 'abac.read', name: 'abacAttributes', value: { tenant: 'alpha' } }], + } as any); + + assertEquals(parsed, { + abacAttributes: { tenant: 'alpha' }, + }); + }); +}); diff --git a/packages/apps/deno-runtime/main.ts b/packages/apps/deno-runtime/main.ts index dc7dacbcea7d0..1ce1b15b7a2cd 100644 --- a/packages/apps/deno-runtime/main.ts +++ b/packages/apps/deno-runtime/main.ts @@ -57,17 +57,20 @@ async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promi const logger = new Logger(method); const context: RequestContext = Object.assign(payload, { - context: { logger } - }) + context: { logger }, + }); const [methodPrefix] = method.split(':') as [keyof Handlers]; const handler = methodHandlers[methodPrefix]; if (!handler) { - return Messenger.errorResponse({ - error: { message: 'Method not found', code: -32601 }, - id, - }, context); + return Messenger.errorResponse( + { + error: { message: 'Method not found', code: -32601 }, + id, + }, + context, + ); } const result = await handler(context); diff --git a/packages/apps/src/lib/SecureFields.ts b/packages/apps/src/lib/SecureFields.ts new file mode 100644 index 0000000000000..1283beb553a30 --- /dev/null +++ b/packages/apps/src/lib/SecureFields.ts @@ -0,0 +1,19 @@ +export type SecureFieldDescriptor, K extends keyof T> = { + permission: string; + name: K; + value: T[K]; +}; + +export const kSecureFields = '@@SecureFields'; + +export type WithSecureFields> = T & { [kSecureFields]: SecureFieldDescriptor[] }; + +export function secureFieldsMapper>( + mapper: (object: T) => SecureFieldDescriptor[] | undefined, +) { + return { [kSecureFields]: mapper }; +} + +export function hasSecureFields(object: unknown): boolean { + return !!object?.[kSecureFields]; +} diff --git a/packages/apps/src/server/bridges/RoomBridge.ts b/packages/apps/src/server/bridges/RoomBridge.ts index 662fb1039b76c..2da64d6ac4fd0 100644 --- a/packages/apps/src/server/bridges/RoomBridge.ts +++ b/packages/apps/src/server/bridges/RoomBridge.ts @@ -12,10 +12,25 @@ import { AppPermissions } from '../permissions/AppPermissions'; export { GetMessagesSortableFields }; export type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions }; +const READ_ONLY_ROOM_FIELDS = ['abacAttributes'] as const; + +const stripReadOnlyRoomFields = (room: IRoom): IRoom => { + for (const field of READ_ONLY_ROOM_FIELDS) { + delete room[field]; + + // This prevents the field being added when a room gets created + // since on room creation `isPartial=false` and so these props are spread on the final room object + // If we need to allow abac room creation from apps, we would need to remove this. + delete (room as any)._unmappedProperties_?.[field]; + } + + return room; +}; + export abstract class RoomBridge extends BaseBridge { public async doCreate(room: IRoom, members: Array, appId: string): Promise { if (this.hasWritePermission(appId)) { - return this.create(room, members, appId); + return this.create(stripReadOnlyRoomFields(room), members, appId); } } @@ -67,7 +82,7 @@ export abstract class RoomBridge extends BaseBridge { public async doUpdate(room: IRoom, members: Array, appId: string): Promise { if (this.hasWritePermission(appId)) { - return this.update(room, members, appId); + return this.update(stripReadOnlyRoomFields(room), members, appId); } } @@ -79,7 +94,7 @@ export abstract class RoomBridge extends BaseBridge { appId: string, ): Promise { if (this.hasWritePermission(appId)) { - return this.createDiscussion(room, parentMessage, reply, members, appId); + return this.createDiscussion(stripReadOnlyRoomFields(room), parentMessage, reply, members, appId); } } diff --git a/packages/apps/src/server/permissions/AppPermissions.ts b/packages/apps/src/server/permissions/AppPermissions.ts index 4e3b55e31fd86..9485aff8593d4 100644 --- a/packages/apps/src/server/permissions/AppPermissions.ts +++ b/packages/apps/src/server/permissions/AppPermissions.ts @@ -126,6 +126,9 @@ export const AppPermissions = { 'experimental': { default: { name: 'experimental.default' }, }, + 'abac': { + read: { name: 'abac.read' }, + }, }; /** diff --git a/packages/apps/src/server/runtime/deno/codec.ts b/packages/apps/src/server/runtime/deno/codec.ts index 53b05846565ee..6bf711bf96b0a 100644 --- a/packages/apps/src/server/runtime/deno/codec.ts +++ b/packages/apps/src/server/runtime/deno/codec.ts @@ -1,9 +1,15 @@ -import { Decoder as _Decoder, Encoder as _Encoder, ExtensionCodec } from '@msgpack/msgpack'; +import { Decoder as _Decoder, Encoder as _Encoder, encode, ExtensionCodec } from '@msgpack/msgpack'; + +import { hasSecureFields } from '../../../lib/SecureFields'; const extensionCodec = new ExtensionCodec(); +const FUNCTION_DISABLER_EXT = 0; +const BUFFER_HANDLER_EXT = 1; +const SECURE_FIELDS_HANDLER_EXT = 2; + extensionCodec.register({ - type: 0, + type: FUNCTION_DISABLER_EXT, encode: (object: unknown) => { // We don't care about functions, but also don't want to throw an error if (typeof object === 'function') { @@ -16,7 +22,7 @@ extensionCodec.register({ // We need to handle Buffers because Deno needs its own decoding extensionCodec.register({ - type: 1, + type: BUFFER_HANDLER_EXT, encode: (object: unknown) => { if (object instanceof Buffer) { return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); @@ -27,6 +33,33 @@ extensionCodec.register({ decode: (data: Uint8Array) => Buffer.from(data), }); +extensionCodec.register({ + type: SECURE_FIELDS_HANDLER_EXT, + /** + * This extension doesn't really change the encoding process, but by + * not returning null or undefined, msgpack attributes the decoding of this + * object to this extension, allowing us to handle secure field logic on the + * subprocess side, without having to iterate through all objects in search + * of the field. + */ + encode: (object: unknown, context: { ignoreRoot?: boolean } = {}) => { + // Ignoring the root object allows msgpack to take care of encoding the object's properties, + // while we mark the root object itself as an extension type. + if (context?.ignoreRoot) { + context.ignoreRoot = false; + + return null; + } + + if (hasSecureFields(object)) { + return encode(object, { extensionCodec, context: { ignoreRoot: true } }); + } + }, + + // We don't really need to handle decoding here, as the subprocess will never send a message with secure fields + decode: (_data: Uint8Array) => undefined, +}); + /** * The Encoder and Decoder classes perform "stateful" operations, i.e. they read from a * stream, store the data locally and decode it from its buffer. diff --git a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts index c9aafe4e4d8e2..0342261534ad8 100644 --- a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts +++ b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import * as assert from 'node:assert'; -import { describe, it, beforeEach, afterEach, mock, before, after } from 'node:test'; +import { describe, it, afterEach, mock, before, after } from 'node:test'; import * as os from 'os'; import * as path from 'path'; @@ -25,42 +25,46 @@ describe('DenoRuntimeSubprocessController', () => { let appPackage: IParseAppPackageResult; let appStorageItem: IAppStorageItem; - before(async () => { - const infrastructure = new TestInfastructureSetup(); - manager = infrastructure.getMockManager(); + before( + async () => { + const infrastructure = new TestInfastructureSetup(); + manager = infrastructure.getMockManager(); - const accessors = new AppAccessorManager(manager); - manager.getAccessorManager = () => accessors; + const accessors = new AppAccessorManager(manager); + manager.getAccessorManager = () => accessors; - const api = new AppApiManager(manager); - manager.getApiManager = () => api; + const api = new AppApiManager(manager); + manager.getApiManager = () => api; - const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip')); - appPackage = await manager.getParser().unpackageApp(appPackageBuffer); + const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip')); + appPackage = await manager.getParser().unpackageApp(appPackageBuffer); - appStorageItem = { - id: 'hello-world-test', - status: AppStatus.MANUALLY_ENABLED, - } as IAppStorageItem; - }); + appStorageItem = { + id: 'hello-world-test', + status: AppStatus.MANUALLY_ENABLED, + } as IAppStorageItem; - beforeEach(async () => { - controller = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); - await controller.setupApp(); - }); + controller = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); + await controller.setupApp(); + }, + { timeout: 60_000 }, + ); - afterEach(async () => { - await controller?.stopApp(); + afterEach(() => { mock.restoreAll(); }); - after(async () => { - await fs.unlink(path.join(os.tmpdir(), 'deno-runtime')).catch((reason) => { - console.warn('Failed to delete temporary Deno runtime symlink', reason); - }); - }); - - it('correctly identifies a call to the HTTP accessor', async () => { + after( + async () => { + await controller?.stopApp(); + await fs.unlink(path.join(os.tmpdir(), 'deno-runtime')).catch((reason) => { + console.warn('Failed to delete temporary Deno runtime symlink', reason); + }); + }, + { timeout: 30_000 }, + ); + + it('correctly identifies a call to the HTTP accessor', { timeout: 15_000 }, async () => { const httpBridge = manager.getBridges().getHttpBridge(); const doCallSpy = mock.method(httpBridge, 'doCall'); @@ -100,7 +104,7 @@ describe('DenoRuntimeSubprocessController', () => { ); }); - it('correctly identifies a call to the IRead accessor', async () => { + it('correctly identifies a call to the IRead accessor', { timeout: 15_000 }, async () => { const userBridge = manager.getBridges().getUserBridge(); const doGetByUsernameSpy = mock.method(userBridge, 'doGetByUsername', () => Promise.resolve({ @@ -139,7 +143,7 @@ describe('DenoRuntimeSubprocessController', () => { assert.partialDeepStrictEqual(result, { username: 'rocket.cat' }); }); - it('correctly identifies a call to the IEnvironmentReader accessor via IRead', async () => { + it('correctly identifies a call to the IEnvironmentReader accessor via IRead', { timeout: 15_000 }, async () => { const { id, result } = await controller['handleAccessorMessage']({ type: rpcTypeRequest, payload: { @@ -155,7 +159,7 @@ describe('DenoRuntimeSubprocessController', () => { assert.partialDeepStrictEqual(result, { id: 'setting test id' }); }); - it('correctly identifies a call to create a visitor via the LivechatCreator', async () => { + it('correctly identifies a call to create a visitor via the LivechatCreator', { timeout: 15_000 }, async () => { const livechatBridge = manager.getBridges().getLivechatBridge(); const doCreateVisitorSpy = mock.method(livechatBridge, 'doCreateVisitor', () => Promise.resolve('random id')); @@ -192,7 +196,7 @@ describe('DenoRuntimeSubprocessController', () => { assert.strictEqual(result, 'random id'); }); - it('correctly identifies a call to the message bridge', async () => { + it('correctly identifies a call to the message bridge', { timeout: 15_000 }, async () => { const messageBridge = manager.getBridges().getMessageBridge(); const doCreateSpy = mock.method(messageBridge, 'doCreate', () => Promise.resolve('random-message-id')); diff --git a/packages/apps/tests/server/runtime/SecureFieldsCodecCompatibility.test.ts b/packages/apps/tests/server/runtime/SecureFieldsCodecCompatibility.test.ts new file mode 100644 index 0000000000000..d865f5de01707 --- /dev/null +++ b/packages/apps/tests/server/runtime/SecureFieldsCodecCompatibility.test.ts @@ -0,0 +1,240 @@ +import * as fs from 'fs/promises'; +import * as assert from 'node:assert'; +import { describe, it, before, after } from 'node:test'; +import * as os from 'os'; +import * as path from 'path'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +import { kSecureFields } from '../../../src/lib/SecureFields'; +import type { AppManager } from '../../../src/server/AppManager'; +import type { IParseAppPackageResult } from '../../../src/server/compiler'; +import { AppAccessorManager, AppApiManager } from '../../../src/server/managers'; +import { DenoRuntimeSubprocessController } from '../../../src/server/runtime/deno/AppsEngineDenoRuntime'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestInfastructureSetup } from '../../test-data/utilities'; + +/** + * These tests verify end-to-end codec compatibility between Node and Deno, specifically + * the '@@SecureFields' mechanism introduced to guard access to sensitive room fields + * (e.g. abacAttributes) behind app permissions. + * + * The flow being tested: + * 1. Node encodes a room object that includes a '@@SecureFields' descriptor for abacAttributes + * using the SECURE_FIELDS_HANDLER_EXT msgpack extension type. + * 2. Deno receives and decodes the message. Its codec calls applySecureFields(), which: + * - Checks the running app's declared permissions. + * - If the app has 'abac.read', it merges abacAttributes into the plain room object. + * - Otherwise it strips the field entirely. + * 3. The Deno app's checkPreRoomCreatePrevent handler returns Array.isArray(room.abacAttributes), + * letting Node observe whether the field was received or withheld. + * + * Two app fixtures are used: + * - secure-fields-test-with-abac_0.0.1.zip → declares { name: 'abac.read' } + * - secure-fields-test-no-abac_0.0.1.zip → declares no permissions + */ +describe('@@SecureFields codec compatibility (Node → Deno)', () => { + /** A minimal room that carries abacAttributes as a secure field. */ + const roomWithSecureField = { + id: 'room-secure-fields-test', + type: RoomType.CHANNEL, + slugifiedName: 'general', + displayName: 'General', + [kSecureFields]: [ + { + permission: 'abac.read', + name: 'abacAttributes', + value: [{ key: 'department', values: ['support', 'engineering'] }], + }, + ], + }; + + /** A room with several regular (non-secured) fields plus the secure abacAttributes field. */ + const roomWithMixedFields = { + id: 'room-mixed-fields-test', + type: RoomType.CHANNEL, + slugifiedName: 'mixed', + displayName: 'Mixed', + customFields: { foo: 'bar' }, + messageCount: 42, + [kSecureFields]: [ + { + permission: 'abac.read', + name: 'abacAttributes', + value: [{ key: 'tenant', values: ['alpha'] }], + }, + ], + }; + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + function buildManager(): AppManager { + const infrastructure = new TestInfastructureSetup(); + const manager = infrastructure.getMockManager(); + + const accessors = new AppAccessorManager(manager); + manager.getAccessorManager = () => accessors; + + const api = new AppApiManager(manager); + manager.getApiManager = () => api; + + return manager; + } + + async function parseAppPackage(manager: AppManager, zipName: string): Promise { + const buf = await fs.readFile(path.join(__dirname, '../../test-data/apps', zipName)); + return manager.getParser().unpackageApp(buf); + } + + // ------------------------------------------------------------------------- + // App WITH abac.read permission + // ------------------------------------------------------------------------- + + describe('app that declares abac.read permission', () => { + let manager: AppManager; + let controller: DenoRuntimeSubprocessController; + let appPackage: IParseAppPackageResult; + let appStorageItem: IAppStorageItem; + + before( + async () => { + manager = buildManager(); + appPackage = await parseAppPackage(manager, 'secure-fields-test-with-abac_0.0.1.zip'); + appStorageItem = { + id: 'secure-fields-test-with-abac', + status: AppStatus.MANUALLY_ENABLED, + } as IAppStorageItem; + + controller = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); + await controller.setupApp(); + }, + { timeout: 60_000 }, + ); + + after( + async () => { + await controller?.stopApp(); + await fs.unlink(path.join(os.tmpdir(), 'deno-runtime')).catch(() => undefined); + }, + { timeout: 30_000 }, + ); + + it('receives abacAttributes when the room is encoded with @@SecureFields', { timeout: 15_000 }, async () => { + /** + * The app's checkPreRoomCreatePrevent returns Array.isArray(room.abacAttributes). + * Because the app has abac.read, Deno's applySecureFields should attach + * abacAttributes to the decoded room object, making the result `true`. + */ + const result = await controller.sendRequest({ + method: 'app:checkPreRoomCreatePrevent', + params: [roomWithSecureField], + }); + + assert.strictEqual(result, true, 'App with abac.read should receive abacAttributes from a @@SecureFields-encoded room'); + }); + + it('still receives regular (non-secured) room fields alongside the secure field', { timeout: 15_000 }, async () => { + /** + * Verify that applying secure fields does not discard ordinary room properties. + * The handler returns `true` only when abacAttributes is an array AND the + * room reached the handler with its other fields intact (the Room constructor + * uses Object.assign, so any missing fields would surface as assertion + * failures in a more detailed handler – here we at minimum confirm abacAttributes + * was applied without corrupting the object). + */ + const result = await controller.sendRequest({ + method: 'app:checkPreRoomCreatePrevent', + params: [roomWithMixedFields], + }); + + assert.strictEqual(result, true, 'abacAttributes should be applied to a room that also carries non-secure fields'); + }); + + it('does not throw when the room carries no @@SecureFields descriptor', { timeout: 15_000 }, async () => { + /** + * Plain rooms (without @@SecureFields) must still be decodable and routable + * to the handler. abacAttributes will be absent, so the handler returns false, + * but no codec error should be raised. + */ + const plainRoom = { + id: 'plain-room', + type: RoomType.CHANNEL, + slugifiedName: 'plain', + }; + + const result = await controller.sendRequest({ + method: 'app:checkPreRoomCreatePrevent', + params: [plainRoom], + }); + + assert.strictEqual(result, false, 'A plain room (no @@SecureFields) should be decoded normally; abacAttributes absent → false'); + }); + }); + + // ------------------------------------------------------------------------- + // App WITHOUT abac.read permission + // ------------------------------------------------------------------------- + + describe('app that does NOT declare abac.read permission', () => { + let manager: AppManager; + let controller: DenoRuntimeSubprocessController; + let appPackage: IParseAppPackageResult; + let appStorageItem: IAppStorageItem; + + before( + async () => { + manager = buildManager(); + appPackage = await parseAppPackage(manager, 'secure-fields-test-no-abac_0.0.1.zip'); + appStorageItem = { + id: 'secure-fields-test-no-abac', + status: AppStatus.MANUALLY_ENABLED, + } as IAppStorageItem; + + controller = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); + await controller.setupApp(); + }, + { timeout: 60_000 }, + ); + + after( + async () => { + await controller?.stopApp(); + await fs.unlink(path.join(os.tmpdir(), 'deno-runtime')).catch(() => undefined); + }, + { timeout: 30_000 }, + ); + + it('does not receive abacAttributes when the app lacks abac.read permission', { timeout: 15_000 }, async () => { + /** + * The room is encoded with @@SecureFields for abacAttributes, but this app + * does not declare abac.read. Deno's applySecureFields should withhold the + * field, so the handler returns `false`. + */ + const result = await controller.sendRequest({ + method: 'app:checkPreRoomCreatePrevent', + params: [roomWithSecureField], + }); + + assert.strictEqual(result, false, 'App without abac.read must not receive abacAttributes from a @@SecureFields-encoded room'); + }); + + it('still decodes regular (non-secured) room fields correctly', { timeout: 15_000 }, async () => { + /** + * Even though abacAttributes is withheld, the remaining room properties + * (id, type, slugifiedName, customFields, messageCount, …) must survive + * the round-trip unaltered. The handler only inspects abacAttributes, so + * we verify this indirectly: if the room object were corrupted entirely, + * Deno would throw an error instead of returning a clean `false`. + */ + const result = await controller.sendRequest({ + method: 'app:checkPreRoomCreatePrevent', + params: [roomWithMixedFields], + }); + + assert.strictEqual(result, false, 'Regular room fields should survive the codec round-trip even when a secure field is withheld'); + }); + }); +}); diff --git a/packages/apps/tests/test-data/apps/secure-fields-test-no-abac_0.0.1.zip b/packages/apps/tests/test-data/apps/secure-fields-test-no-abac_0.0.1.zip new file mode 100644 index 0000000000000..00c839773313b Binary files /dev/null and b/packages/apps/tests/test-data/apps/secure-fields-test-no-abac_0.0.1.zip differ diff --git a/packages/apps/tests/test-data/apps/secure-fields-test-with-abac_0.0.1.zip b/packages/apps/tests/test-data/apps/secure-fields-test-with-abac_0.0.1.zip new file mode 100644 index 0000000000000..6441554e42eb7 Binary files /dev/null and b/packages/apps/tests/test-data/apps/secure-fields-test-with-abac_0.0.1.zip differ diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index dc12d23170750..a0bdd5f315ed4 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -753,6 +753,7 @@ "Apps_Permissions_upload_write": "Upload files to this server", "Apps_Permissions_user_read": "Access user information", "Apps_Permissions_user_write": "Modify user information", + "Apps_Permissions_abac_read": "View ABAC attributes assigned to supported entity types", "Apps_Private_App_Is_Exempt": "{{appName}} is already installed and exempt from the app limit policy.\nExempted apps cannot be updated.", "Apps_Settings": "App's Settings", "Apps_User_Already_Exists": "The username \"{{username}}\" is already being used. Rename or remove the user using it to install this App",