From 1ed51c2d8a7641b052529d7311299ec70cabae57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 13:34:06 +0100 Subject: [PATCH] make sure that permissions apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../service/AuthorizedEntitiesCatalog.test.ts | 172 ++++++++++++++++++ .../src/service/AuthorizedEntitiesCatalog.ts | 64 ++++++- .../applyPredicateEntityFilterToQuery.ts | 2 +- 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..f074f25397 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { Cursor, QueryEntitiesResponse } from '../catalog/types'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedEntitiesCatalog', () => { @@ -306,6 +307,177 @@ describe('AuthorizedEntitiesCatalog', () => { }, }); }); + + it('combines permission filter into query field using $all on CONDITIONAL with initial request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['xxx', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 1, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['xxx', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('combines permission filter into cursor query field using $all on CONDITIONAL with cursor request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['yyy', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + prevCursor: { + isPrevious: true, + orderFieldValues: ['aaa', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 3, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const cursor: Cursor = { + query: userQuery, + orderFields: [{ field: 'name', order: 'asc' }], + isPrevious: false, + orderFieldValues: ['xxx', null], + }; + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + cursor, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + cursor: { + ...cursor, + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['yyy', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + + expect(response.pageInfo.prevCursor).toEqual({ + isPrevious: true, + orderFieldValues: ['aaa', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('converts multi-value permission filter with $in when converting to predicate', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['component', 'api'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: [] }, + pageInfo: {}, + totalItems: 0, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { + $all: [{ kind: { $in: ['component', 'api'] } }, userQuery], + }, + filter: undefined, + }); + }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..bb81144f25 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -35,6 +35,7 @@ import { QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { basicEntityFilter } from './request'; import { isQueryEntitiesCursorRequest } from './util'; import { EntityFilter } from '@backstage/plugin-catalog-node'; @@ -43,6 +44,30 @@ import { PermissionsService, } from '@backstage/backend-plugin-api'; +function entityFilterToFilterPredicate(filter: EntityFilter): FilterPredicate { + if ('allOf' in filter) { + return { $all: filter.allOf.map(entityFilterToFilterPredicate) }; + } + + if ('anyOf' in filter) { + return { $any: filter.anyOf.map(entityFilterToFilterPredicate) }; + } + + if ('not' in filter) { + return { $not: entityFilterToFilterPredicate(filter.not) }; + } + + if (!filter.values) { + return { [filter.key]: { $exists: true } } as FilterPredicate; + } + + if (filter.values.length === 1) { + return { [filter.key]: filter.values[0] } as FilterPredicate; + } + + return { [filter.key]: { $in: filter.values } } as FilterPredicate; +} + export class AuthorizedEntitiesCatalog implements EntitiesCatalog { private readonly entitiesCatalog: EntitiesCatalog; private readonly permissionApi: PermissionsService; @@ -147,18 +172,42 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { let permissionedRequest: QueryEntitiesRequest; let requestFilter: EntityFilter | undefined; + let requestQuery: FilterPredicate | undefined; if (isQueryEntitiesCursorRequest(request)) { requestFilter = request.cursor.filter; + requestQuery = request.cursor.query; + if (request.cursor.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + query: { $all: [permissionPredicate, request.cursor.query] }, + filter: undefined, + }, + }; + } else { + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + filter: request.cursor.filter + ? { allOf: [permissionFilter, request.cursor.filter] } + : permissionFilter, + }, + }; + } + } else if (request.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + requestQuery = request.query; permissionedRequest = { ...request, - cursor: { - ...request.cursor, - filter: request.cursor.filter - ? { allOf: [permissionFilter, request.cursor.filter] } - : permissionFilter, - }, + query: { $all: [permissionPredicate, request.query] }, + filter: undefined, }; } else { permissionedRequest = { @@ -177,11 +226,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && { ...response.pageInfo.prevCursor, filter: requestFilter, + query: requestQuery, }; const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && { ...response.pageInfo.nextCursor, filter: requestFilter, + query: requestQuery, }; return { @@ -193,6 +244,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { }; } + // The ALLOW case return this.entitiesCatalog.queryEntities(request); } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 56d85a9da1..b42252369d 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -105,7 +105,7 @@ export function applyPredicateEntityFilterToQuery(options: { if ('$all' in filter) { if (filter.$all.length === 0) { - return targetQuery; + return targetQuery.andWhereRaw('1 = 1'); } return targetQuery.andWhere(outer => { for (const subFilter of filter.$all) {