Merge pull request #8853 from backstage/catalog-backend-permission-integration/entity-ancestry
Integrate permissions into `entityAncestry` endpoint in catalog-backend
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Integrate permissions into entity ancestry endpoint in catalog-backend
|
||||
@@ -554,7 +554,12 @@ export type EntitiesCatalog = {
|
||||
authorizationToken?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
|
||||
entityAncestry(
|
||||
entityRef: string,
|
||||
options?: {
|
||||
authorizationToken?: string;
|
||||
},
|
||||
): Promise<EntityAncestryResponse>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
||||
@@ -111,5 +111,8 @@ export type EntitiesCatalog = {
|
||||
*
|
||||
* @param entityRef - An entity reference to the root of the tree
|
||||
*/
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
|
||||
entityAncestry(
|
||||
entityRef: string,
|
||||
options?: { authorizationToken?: string },
|
||||
): Promise<EntityAncestryResponse>;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { createConditionTransformer } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
createConditionTransformer,
|
||||
PermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { EntitiesSearchFilter } from '../catalog/types';
|
||||
import { isEntityKind } from '../permissions/rules/isEntityKind';
|
||||
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
|
||||
@@ -30,6 +35,15 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
authorize: jest.fn(),
|
||||
};
|
||||
|
||||
const createCatalog = (
|
||||
...rules: PermissionRule<Entity, EntitiesSearchFilter, unknown[]>[]
|
||||
) =>
|
||||
new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer(rules),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -39,12 +53,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
fakePermissionApi.authorize.mockResolvedValue([
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([]),
|
||||
);
|
||||
const catalog = createCatalog();
|
||||
|
||||
expect(
|
||||
await catalog.entities({
|
||||
@@ -63,11 +72,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
|
||||
},
|
||||
]);
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([isEntityKind]),
|
||||
);
|
||||
const catalog = createCatalog(isEntityKind);
|
||||
|
||||
await catalog.entities({ authorizationToken: 'abcd' });
|
||||
|
||||
@@ -81,11 +86,7 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
fakePermissionApi.authorize.mockResolvedValue([
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([]),
|
||||
);
|
||||
const catalog = createCatalog();
|
||||
|
||||
await catalog.entities({ authorizationToken: 'abcd' });
|
||||
|
||||
@@ -176,4 +177,81 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityAncestry', () => {
|
||||
it('throws error if denied access to root entity', async () => {
|
||||
fakePermissionApi.authorize.mockResolvedValueOnce([
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const catalog = createCatalog();
|
||||
|
||||
await expect(() =>
|
||||
catalog.entityAncestry('backstage:default/component', {
|
||||
authorizationToken: 'Bearer abcd',
|
||||
}),
|
||||
).rejects.toThrowError(NotAllowedError);
|
||||
});
|
||||
|
||||
it('filters out unauthorized entities and their parents', async () => {
|
||||
fakePermissionApi.authorize.mockResolvedValueOnce([
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
fakePermissionApi.authorize.mockResolvedValueOnce([
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
{ result: AuthorizeResult.DENY },
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
fakeCatalog.entityAncestry.mockResolvedValueOnce({
|
||||
rootEntityRef: 'component:default/a',
|
||||
items: [
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'a' },
|
||||
parentEntityRefs: ['component:default/b1', 'component:default/b2'],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'b1' },
|
||||
parentEntityRefs: ['component:default/c'],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'b2' },
|
||||
parentEntityRefs: [],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'c' },
|
||||
parentEntityRefs: [],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'd' },
|
||||
parentEntityRefs: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const catalog = createCatalog();
|
||||
|
||||
const ancestryResult = await catalog.entityAncestry(
|
||||
'backstage:default/a',
|
||||
{ authorizationToken: 'Bearer abcd' },
|
||||
);
|
||||
|
||||
expect(ancestryResult).toEqual({
|
||||
rootEntityRef: 'component:default/a',
|
||||
items: [
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'a' },
|
||||
parentEntityRefs: ['component:default/b1', 'component:default/b2'],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'b2' },
|
||||
parentEntityRefs: [],
|
||||
},
|
||||
{
|
||||
entity: { kind: 'component', namespace: 'default', name: 'd' },
|
||||
parentEntityRefs: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
catalogEntityDeletePermission,
|
||||
catalogEntityReadPermission,
|
||||
} from '@backstage/plugin-catalog-common';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionAuthorizer,
|
||||
@@ -99,8 +100,79 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
return this.entitiesCatalog.removeEntityByUid(uid);
|
||||
}
|
||||
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse> {
|
||||
// TODO: Implement permissioning
|
||||
return this.entitiesCatalog.entityAncestry(entityRef);
|
||||
async entityAncestry(
|
||||
entityRef: string,
|
||||
options?: { authorizationToken?: string },
|
||||
): Promise<EntityAncestryResponse> {
|
||||
const rootEntityAuthorizeResponse = (
|
||||
await this.permissionApi.authorize(
|
||||
[{ permission: catalogEntityReadPermission, resourceRef: entityRef }],
|
||||
{ token: options?.authorizationToken },
|
||||
)
|
||||
)[0];
|
||||
if (rootEntityAuthorizeResponse.result === AuthorizeResult.DENY) {
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
|
||||
const ancestryResult = await this.entitiesCatalog.entityAncestry(entityRef);
|
||||
const authorizeResponse = await this.permissionApi.authorize(
|
||||
ancestryResult.items.map(item => ({
|
||||
permission: catalogEntityReadPermission,
|
||||
resourceRef: stringifyEntityRef(item.entity),
|
||||
})),
|
||||
{ token: options?.authorizationToken },
|
||||
);
|
||||
const unauthorizedAncestryItems = ancestryResult.items.filter(
|
||||
(_, index) => authorizeResponse[index].result === AuthorizeResult.DENY,
|
||||
);
|
||||
if (unauthorizedAncestryItems.length === 0) {
|
||||
return ancestryResult;
|
||||
}
|
||||
const rootUnauthorizedEntityRefs = unauthorizedAncestryItems.map(
|
||||
ancestryItem => stringifyEntityRef(ancestryItem.entity),
|
||||
);
|
||||
const allUnauthorizedEntityRefs = new Set(
|
||||
rootUnauthorizedEntityRefs.flatMap(rootEntityRef =>
|
||||
this.findParents(
|
||||
rootEntityRef,
|
||||
ancestryResult.items,
|
||||
new Set(rootUnauthorizedEntityRefs),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
rootEntityRef: ancestryResult.rootEntityRef,
|
||||
items: ancestryResult.items.filter(
|
||||
ancestryItem =>
|
||||
!allUnauthorizedEntityRefs.has(
|
||||
stringifyEntityRef(ancestryItem.entity),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private findParents(
|
||||
entityRef: string,
|
||||
allAncestryItems: { entity: Entity; parentEntityRefs: string[] }[],
|
||||
seenEntityRefs: Set<string>,
|
||||
): string[] {
|
||||
const entity = allAncestryItems.find(
|
||||
ancestryItem => stringifyEntityRef(ancestryItem.entity) === entityRef,
|
||||
);
|
||||
if (!entity) return [];
|
||||
|
||||
const newSeenEntityRefs = new Set(seenEntityRefs);
|
||||
entity.parentEntityRefs.forEach(parentRef =>
|
||||
newSeenEntityRefs.add(parentRef),
|
||||
);
|
||||
|
||||
return [
|
||||
entityRef,
|
||||
...entity.parentEntityRefs.flatMap(parentRef =>
|
||||
seenEntityRefs.has(parentRef)
|
||||
? []
|
||||
: this.findParents(parentRef, allAncestryItems, newSeenEntityRefs),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user