diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 01ec14f780f16..4c2ab21eb5290 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -13,6 +13,8 @@ import { POSTSingleRoomAbacAttributeBodySchema, PUTRoomAbacAttributeValuesBodySchema, GenericErrorSchema, + GETAbacRoomsListQueryValidator, + GETAbacRoomsResponseValidator, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -281,6 +283,33 @@ const abacEndpoints = API.v1 const inUse = await Abac.isAbacAttributeInUseByKey(key); return API.v1.success({ inUse }); }, + ) + .get( + 'abac/rooms', + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { + 200: GETAbacRoomsResponseValidator, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, + query: GETAbacRoomsListQueryValidator, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { filter, filterType } = this.queryParams; + + const result = await Abac.listAbacRooms({ + offset, + count, + filter, + filterType, + }); + + return API.v1.success(result); + }, ); export type AbacEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 894cb95a6a5f6..48f3be8151429 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,4 +1,5 @@ -import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings'; +import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings'; const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$'; @@ -217,3 +218,49 @@ const GenericError = { }; export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); + +const GETAbacRoomsListQuerySchema = { + type: 'object', + properties: { + filter: { type: 'string', minLength: 1 }, + filterType: { type: 'string', enum: ['all', 'roomName', 'attribute', 'value'] }, + offset: { type: 'number' }, + count: { type: 'number' }, + }, + additionalProperties: false, +}; + +type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>; + +export const GETAbacRoomsListQueryValidator = ajv.compile(GETAbacRoomsListQuerySchema); + +export const GETAbacRoomsResponseSchema = { + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + rooms: { + type: 'array', + items: { type: 'object' }, + }, + offset: { + type: 'number', + }, + count: { + type: 'number', + }, + total: { + type: 'number', + }, + }, + required: ['rooms', 'offset', 'count', 'total'], + additionalProperties: false, +}; + +type GETAbacRoomsResponse = PaginatedResult<{ + rooms: IRoom[]; +}>; + +export const GETAbacRoomsResponseValidator = ajv.compile(GETAbacRoomsResponseSchema); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index f1063d9e4f56f..e35d06f6c7a90 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1574,4 +1574,345 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); }); + + describe('list abac rooms', () => { + const listAttrKey1 = `list_attr_1_${Date.now()}`; + const listAttrKey2 = `list_attr_2_${Date.now()}`; + const listRoomName1 = `abac-list-room-1-${Date.now()}`; + const listRoomName2 = `abac-list-room-2-${Date.now()}`; + const listRoomNoAttrName = `abac-list-room-noattr-${Date.now()}`; + + let listRoomId1: string; + let listRoomId2: string; + let listRoomNoAttrId: string; + + before('ensure ABAC enabled and create attribute definitions & rooms', async () => { + await updateSetting('ABAC_Enabled', true); + + // Create attribute definitions + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: listAttrKey1, values: ['alpha', 'beta'] }) + .expect(200); + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: listAttrKey2, values: ['x', 'y'] }) + .expect(200); + + // Create private rooms + listRoomId1 = (await createRoom({ type: 'p', name: listRoomName1 })).body.group._id; + listRoomId2 = (await createRoom({ type: 'p', name: listRoomName2 })).body.group._id; + listRoomNoAttrId = (await createRoom({ type: 'p', name: listRoomNoAttrName })).body.group._id; + + // Assign attributes to first two rooms + await request + .post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${listRoomId2}/attributes/${listAttrKey2}`) + .set(credentials) + .send({ values: ['x'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'p', roomId: listRoomId1 }), + deleteRoom({ type: 'p', roomId: listRoomId2 }), + deleteRoom({ type: 'p', roomId: listRoomNoAttrId }), + ]); + }); + + it('should list only private rooms with ABAC attributes (baseline)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').that.is.an('array'); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomNoAttrId); + }); + }); + + it('should NOT list a newly created private room without attributes', async () => { + const tempNoAttrRoomId = (await createRoom({ type: 'p', name: `abac-list-temp-noattr-${Date.now()}` })).body.group._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(tempNoAttrRoomId); + await deleteRoom({ type: 'p', roomId: tempNoAttrRoomId }); + }); + + it('should NOT list a public room (cannot be ABAC managed)', async () => { + const publicRoomId = (await createRoom({ type: 'c', name: `abac-list-public-${Date.now()}` })).body.channel._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(publicRoomId); + await deleteRoom({ type: 'c', roomId: publicRoomId }); + }); + + it('should NOT list a team private room without attributes', async () => { + // Create a private team and a private room inside it (not main room) + const teamRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: `abac-list-team-noattr-${Date.now()}`, type: 0 }) + .expect(200); + const teamIdLocal = teamRes.body.team._id; + const teamRoomRes = await createRoom({ type: 'p', name: `abac-list-team-room-${Date.now()}`, extraData: { teamId: teamIdLocal } }); + const teamPrivateRoomId = teamRoomRes.body.group._id; + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamPrivateRoomId); + await deleteRoom({ type: 'p', roomId: teamPrivateRoomId }); + await deleteTeam(credentials, teamRes.body.team.name); + }); + + it('should stop listing a room after its ABAC attributes are removed', async () => { + // Ensure room 1 currently listed + const resBefore = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const idsBefore = resBefore.body.rooms.map((r: any) => r._id); + expect(idsBefore).to.include(listRoomId1); + + // Remove its only attribute key (listAttrKey1) - value was 'alpha' + await request.delete(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`).set(credentials).expect(200); + + const resAfter = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const idsAfter = resAfter.body.rooms.map((r: any) => r._id); + expect(idsAfter).to.not.include(listRoomId1); + + // Re-add attribute so other tests relying on it remain stable + await request + .post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + it('should NOT list a default private room even if attempt to add attribute fails', async () => { + const defaultRoomId = (await createRoom({ type: 'p', name: `abac-list-default-${Date.now()}` })).body.group._id; + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: true }).expect(200); + const defKey = `list_def_attr_${Date.now()}`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: defKey, values: ['one'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${defaultRoomId}/attributes/${defKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(400); + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(defaultRoomId); + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: false }).expect(200); + await deleteRoom({ type: 'p', roomId: defaultRoomId }); + }); + + it('should filter by room name (filterType=roomName)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'abac-list-room-1', filterType: 'roomName' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.not.include(listRoomId2); + }); + }); + + it('should filter by attribute key (filterType=attribute)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: listAttrKey2, filterType: 'attribute' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomId1); + }); + }); + + it('should filter by attribute value (filterType=value)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'alpha', filterType: 'value' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId1); + expect(ids).to.not.include(listRoomId2); + }); + }); + + it('should match across name, key and values when filterType=all (default)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'x', filterType: 'all' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(listRoomId2); + expect(ids).to.not.include(listRoomId1); + }); + }); + + it('should paginate results (count=1 offset=0)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ count: 1, offset: 0 }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count', 1); + expect(res.body.rooms).to.have.lengthOf(1); + }); + }); + + it('should paginate results (count=1 offset=1)', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ count: 1, offset: 1 }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body).to.have.property('offset', 1); + expect(res.body).to.have.property('count').that.is.at.most(1); + expect(res.body.rooms).to.have.length.at.most(1); + }); + }); + + it('should return empty list when filter does not match anything', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'nonexistent-filter-xyz', filterType: 'roomName' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + expect(res.body.rooms).to.be.an('array').with.lengthOf(0); + }); + }); + + it('should reject unauthorized user (403)', async () => { + await request.get(`${v1}/abac/rooms`).set(unauthorizedCredentials).expect(403); + }); + + describe('ABAC managed private team main rooms listing', () => { + const teamListAttrKey = `team_list_attr_${Date.now()}`; + const teamListName = `abac-list-team-${Date.now()}`; + let teamListMainRoomId: string; + + before('create private team (no attributes yet) and attribute definition', async () => { + const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamListName, type: 1 }).expect(200); + + teamListMainRoomId = teamRes.body.team.roomId; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: teamListAttrKey, values: ['red', 'blue'] }) + .expect(200); + }); + + it('baseline: team main private room without attributes should NOT appear in /abac/rooms list', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamListMainRoomId); + }); + }); + + it('after adding ABAC attribute to team main room it SHOULD appear in /abac/rooms list', async () => { + await request + .post(`${v1}/abac/rooms/${teamListMainRoomId}/attributes/${teamListAttrKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200); + + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=attribute should return team main room when filtering by its attribute key', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: teamListAttrKey, filterType: 'attribute' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=value should return team main room when filtering by an assigned attribute value', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'red', filterType: 'value' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.include(teamListMainRoomId); + }); + }); + + it('filterType=value should NOT return team main room when filtering by a non-existent value', async () => { + await request + .get(`${v1}/abac/rooms`) + .set(credentials) + .query({ filter: 'nonexistent-team-value', filterType: 'value' }) + .expect(200) + .expect((res) => { + const ids = res.body.rooms.map((r: any) => r._id); + expect(ids).to.not.include(teamListMainRoomId); + }); + }); + + it('pagination should include team main room on a page where it falls (count=1 offset varies)', async () => { + // Get full list to determine index + const fullRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const allIds: string[] = fullRes.body.rooms.map((r: any) => r._id); + const index = allIds.indexOf(teamListMainRoomId); + expect(index).to.not.equal(-1); + + // Request the page containing the team main room + const pageRes = await request.get(`${v1}/abac/rooms`).set(credentials).query({ count: 1, offset: index }).expect(200); + expect(pageRes.body.rooms.map((r: any) => r._id)).to.include(teamListMainRoomId); + }); + + after(async () => { + await deleteTeam(credentials, teamListName); + }); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 7513754d8afb2..ba03bea2048fa 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -122,6 +122,69 @@ export class AbacService extends ServiceClass implements IAbacService { }; } + async listAbacRooms(filters?: { + offset?: number; + count?: number; + filter?: string; + filterType?: 'all' | 'roomName' | 'attribute' | 'value'; + }): Promise<{ + rooms: IRoom[]; + offset: number; + count: number; + total: number; + }> { + const offset = filters?.offset ?? 0; + const limit = filters?.count ?? 25; + + const baseQuery: Document = { + t: 'p', + abacAttributes: { $exists: true, $ne: [] }, + }; + + const { filter, filterType } = filters || {}; + + if (filter?.trim().length) { + const regex = new RegExp(escapeRegExp(filter.trim()), 'i'); + + let condition: Document; + + switch (filterType) { + case 'roomName': + condition = { $or: [{ name: regex }, { fname: regex }] }; + break; + case 'attribute': + condition = { 'abacAttributes.key': regex }; + break; + case 'value': + condition = { 'abacAttributes.values': regex }; + break; + case 'all': + default: + condition = { + $or: [{ name: regex }, { fname: regex }, { 'abacAttributes.key': regex }, { 'abacAttributes.values': regex }], + }; + break; + } + + Object.assign(baseQuery, condition); + } + + const { cursor, totalCount } = Rooms.findPaginated(baseQuery, { + skip: offset, + limit, + sort: { name: 1 }, + }); + + const rooms = await cursor.toArray(); + + return { + rooms, + offset, + count: rooms.length, + total: await totalCount, + }; + } + async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise { if (!update.key && !update.values) { return; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 69bc2e018e6e2..6a8755d2cc3f0 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -16,6 +16,12 @@ export interface IAbacService { offset?: number; count?: number; }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; + listAbacRooms(filters?: { + offset?: number; + count?: number; + filter?: string; + filterType?: 'all' | 'roomName' | 'attribute' | 'value'; + }): Promise<{ rooms: IRoom[]; offset: number; count: number; total: number }>; updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise; deleteAbacAttributeById(_id: string): Promise; // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use.