fix: allow using notifications without users in catalog

additionally refactored and added tests for the notification receiver
logic

fixes #25768

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-07-25 12:48:06 +03:00
parent e7dba9ff5d
commit 7a05f50908
4 changed files with 343 additions and 94 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend': patch
---
Allow using notifications without users in the catalog
@@ -0,0 +1,175 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { getUsersForEntityRef } from './getUsersForEntityRef';
import { CatalogApi } from '@backstage/catalog-client';
import {
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
RELATION_PARENT_OF,
} from '@backstage/catalog-model';
describe('getUsersForEntityRef', () => {
const catalogApiMock = {
getEntitiesByRefs: jest.fn(),
getEntityByRef: jest.fn(),
};
const authMock = mockServices.auth();
it('should return empty array if entityRef is null', async () => {
await expect(
getUsersForEntityRef(null, [], {
auth: authMock,
catalogClient: catalogApiMock as unknown as CatalogApi,
}),
).resolves.toEqual([]);
});
it('should resolve users without calling catalog', async () => {
await expect(
getUsersForEntityRef(['user:foo', 'user:ignored'], ['user:ignored'], {
auth: authMock,
catalogClient: catalogApiMock as unknown as CatalogApi,
}),
).resolves.toEqual(['user:foo']);
expect(catalogApiMock.getEntitiesByRefs).not.toHaveBeenCalled();
});
it('should resolve group entities to users', async () => {
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'parent_group',
},
relations: [
{
type: RELATION_HAS_MEMBER,
targetRef: 'user:default/foo',
},
{
type: RELATION_PARENT_OF,
targetRef: 'group:default/child_group',
},
],
},
],
});
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'child_group',
},
relations: [
{
type: RELATION_HAS_MEMBER,
targetRef: 'user:default/bar',
},
{
type: RELATION_HAS_MEMBER,
targetRef: 'user:default/ignored',
},
],
},
],
});
await expect(
getUsersForEntityRef(
'group:default/parent_group',
['user:default/ignored'],
{
auth: authMock,
catalogClient: catalogApiMock as unknown as CatalogApi,
},
),
).resolves.toEqual(['user:default/foo', 'user:default/bar']);
});
it('should resolve user owner of entity from entity ref', async () => {
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test_component',
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/foo',
},
],
},
],
});
await expect(
getUsersForEntityRef('component:default/test_component', [], {
auth: authMock,
catalogClient: catalogApiMock as unknown as CatalogApi,
}),
).resolves.toEqual(['user:default/foo']);
});
it('should resolve group owner of entity from entity ref', async () => {
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test_component',
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/owner_group',
},
],
},
],
});
catalogApiMock.getEntityByRef.mockResolvedValueOnce({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'owner_group',
},
relations: [
{
type: RELATION_HAS_MEMBER,
targetRef: 'user:default/foo',
},
],
});
await expect(
getUsersForEntityRef('component:default/test_component', [], {
auth: authMock,
catalogClient: catalogApiMock as unknown as CatalogApi,
}),
).resolves.toEqual(['user:default/foo']);
});
});
@@ -0,0 +1,161 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
isGroupEntity,
isUserEntity,
parseEntityRef,
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
RELATION_PARENT_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { AuthService } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
const isUserEntityRef = (ref: string) =>
parseEntityRef(ref).kind.toLocaleLowerCase() === 'user';
// Partitions array of entity references to two arrays; user entity refs and other entity refs
const partitionEntityRefs = (refs: string[]): string[][] =>
refs.reduce(
([userEntityRefs, otherEntityRefs]: string[][], ref: string) => {
return isUserEntityRef(ref)
? [[...userEntityRefs, ref], otherEntityRefs]
: [userEntityRefs, [...otherEntityRefs, ref]];
},
[[], []],
);
export const getUsersForEntityRef = async (
entityRef: string | string[] | null,
excludeEntityRefs: string | string[],
options: {
auth: AuthService;
catalogClient: CatalogApi;
},
): Promise<string[]> => {
const { auth, catalogClient } = options;
if (entityRef === null) {
return [];
}
const { token } = await auth.getPluginRequestToken({
onBehalfOf: await auth.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});
const excluded = Array.isArray(excludeEntityRefs)
? excludeEntityRefs
: [excludeEntityRefs];
const refsArr = Array.isArray(entityRef) ? entityRef : [entityRef];
const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(refsArr);
const users: string[] = userEntityRefs.filter(ref => !excluded.includes(ref));
const entityRefs = otherEntityRefs.filter(ref => !excluded.includes(ref));
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
let entities: Array<Entity | undefined> = [];
if (entityRefs.length > 0) {
const fetchedEntities = await catalogClient.getEntitiesByRefs(
{
entityRefs,
fields,
},
{ token },
);
entities = fetchedEntities.items;
}
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
if (!entity) {
return [];
}
const currentEntityRef = stringifyEntityRef(entity);
if (excluded.includes(currentEntityRef)) {
return [];
}
if (isUserEntity(entity)) {
return [currentEntityRef];
}
if (isGroupEntity(entity)) {
if (!entity.relations?.length) {
return [];
}
const groupUsers = entity.relations
.filter(
relation =>
relation.type === RELATION_HAS_MEMBER &&
isUserEntityRef(relation.targetRef),
)
.map(r => r.targetRef);
const childGroupRefs = entity.relations
.filter(relation => relation.type === RELATION_PARENT_OF)
.map(r => r.targetRef);
let childGroupUsers: string[][] = [];
if (childGroupRefs.length > 0) {
const childGroups = await catalogClient.getEntitiesByRefs(
{
entityRefs: childGroupRefs,
fields,
},
{ token },
);
childGroupUsers = await Promise.all(childGroups.items.map(mapEntity));
}
return [...groupUsers, ...childGroupUsers.flat(2)].filter(
ref => !excluded.includes(ref),
);
}
if (entity.relations?.length) {
const ownerRef = entity.relations.find(
relation => relation.type === RELATION_OWNED_BY,
)?.targetRef;
if (!ownerRef) {
return [];
}
if (isUserEntityRef(ownerRef)) {
if (excluded.includes(ownerRef)) {
return [];
}
return [ownerRef];
}
const owner = await catalogClient.getEntityByRef(ownerRef, { token });
return mapEntity(owner);
}
return [];
};
for (const entity of entities) {
const u = await mapEntity(entity);
users.push(...u);
}
return [...new Set(users)];
};
@@ -23,15 +23,6 @@ import {
} from '../database';
import { v4 as uuid } from 'uuid';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import {
Entity,
isGroupEntity,
isUserEntity,
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
RELATION_PARENT_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
NotificationProcessor,
NotificationSendOptions,
@@ -54,6 +45,7 @@ import {
NotificationStatus,
} from '@backstage/plugin-notifications-common';
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { getUsersForEntityRef } from './getUsersForEntityRef';
/** @internal */
export interface RouterOptions {
@@ -94,91 +86,6 @@ export async function createRouter(
return info.userEntityRef;
};
const getUsersForEntityRef = async (
entityRef: string | string[] | null,
excludeEntityRefs: string | string[],
): Promise<string[]> => {
const { token } = await auth.getPluginRequestToken({
onBehalfOf: await auth.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});
if (entityRef === null) {
return [];
}
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
const entities = await catalogClient.getEntitiesByRefs(
{
entityRefs: refs,
fields,
},
{ token },
);
const excluded = Array.isArray(excludeEntityRefs)
? excludeEntityRefs
: [excludeEntityRefs];
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
if (!entity) {
return [];
}
const currentEntityRef = stringifyEntityRef(entity);
if (excluded.includes(currentEntityRef)) {
return [];
}
if (isUserEntity(entity)) {
return [currentEntityRef];
} else if (isGroupEntity(entity) && entity.relations) {
const users = entity.relations
.filter(relation => relation.type === RELATION_HAS_MEMBER)
.map(r => r.targetRef);
const childGroupRefs = entity.relations
.filter(relation => relation.type === RELATION_PARENT_OF)
.map(r => r.targetRef);
const childGroups = await catalogClient.getEntitiesByRefs(
{
entityRefs: childGroupRefs,
fields,
},
{ token },
);
const childGroupUsers = await Promise.all(
childGroups.items.map(mapEntity),
);
return [...users, ...childGroupUsers.flat(2)];
} else if (entity.relations) {
const ownerRef = entity.relations.find(
relation => relation.type === RELATION_OWNED_BY,
)?.targetRef;
if (ownerRef) {
const owner = await catalogClient.getEntityByRef(ownerRef, { token });
if (owner) {
return mapEntity(owner);
}
}
}
return [];
};
const users: string[] = [];
for (const entity of entities.items) {
const u = await mapEntity(entity);
users.push(...u);
}
return users;
};
const filterProcessors = (payload: NotificationPayload) => {
const result: NotificationProcessor[] = [];
@@ -543,6 +450,7 @@ export async function createRouter(
users = await getUsersForEntityRef(
entityRef,
recipients.excludeEntityRef ?? [],
{ auth, catalogClient },
);
} catch (e) {
logger.error(`Failed to resolve notification receivers: ${e}`);