make sure that permissions apply

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-20 13:34:06 +01:00
parent 9dd2f112f0
commit 1ed51c2d8a
3 changed files with 231 additions and 7 deletions
@@ -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', () => {
@@ -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);
}
@@ -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) {