feat(catalog): change extension point to inteface

ignore other than user entity references instead error

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-09-09 22:41:45 +03:00
parent 7e7ed57de2
commit 4632b6b4da
8 changed files with 335 additions and 245 deletions
@@ -15,33 +15,28 @@
*/
import { mockServices } from '@backstage/backend-test-utils';
import { getUsersForEntityRef } from './getUsersForEntityRef';
import {
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
RELATION_PARENT_OF,
} from '@backstage/catalog-model';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts';
describe('getUsersForEntityRef', () => {
it('should return empty array if entityRef is null', async () => {
await expect(
getUsersForEntityRef(null, [], {
auth: mockServices.auth(),
catalog: catalogServiceMock(),
}),
).resolves.toEqual([]);
});
it('should resolve users without calling catalog', async () => {
const catalog = catalogServiceMock();
jest.spyOn(catalog, 'getEntitiesByRefs');
const resolver = new DefaultNotificationRecipientResolver(
mockServices.auth(),
catalog,
);
await expect(
getUsersForEntityRef(['user:foo', 'user:ignored'], ['user:ignored'], {
auth: mockServices.auth(),
catalog,
resolver.resolveNotificationRecipients({
entityRefs: ['user:foo', 'user:ignored'],
excludeEntityRefs: ['user:ignored'],
}),
).resolves.toEqual(['user:foo']);
).resolves.toEqual({ userEntityRefs: ['user:foo'] });
expect(catalog.getEntitiesByRefs).not.toHaveBeenCalled();
});
@@ -85,16 +80,18 @@ describe('getUsersForEntityRef', () => {
],
});
const resolver = new DefaultNotificationRecipientResolver(
mockServices.auth(),
catalog,
);
await expect(
getUsersForEntityRef(
'group:default/parent_group',
['user:default/ignored'],
{
auth: mockServices.auth(),
catalog,
},
),
).resolves.toEqual(['user:default/foo', 'user:default/bar']);
resolver.resolveNotificationRecipients({
entityRefs: ['group:default/parent_group'],
excludeEntityRefs: ['user:default/ignored'],
}),
).resolves.toEqual({
userEntityRefs: ['user:default/foo', 'user:default/bar'],
});
});
it('should resolve user owner of entity from entity ref', async () => {
@@ -116,12 +113,16 @@ describe('getUsersForEntityRef', () => {
],
});
const resolver = new DefaultNotificationRecipientResolver(
mockServices.auth(),
catalog,
);
await expect(
getUsersForEntityRef('component:default/test_component', [], {
auth: mockServices.auth(),
catalog,
resolver.resolveNotificationRecipients({
entityRefs: ['component:default/test_component'],
excludeEntityRefs: [],
}),
).resolves.toEqual(['user:default/foo']);
).resolves.toEqual({ userEntityRefs: ['user:default/foo'] });
});
it('should resolve group owner of entity from entity ref', async () => {
@@ -156,11 +157,59 @@ describe('getUsersForEntityRef', () => {
],
});
const resolver = new DefaultNotificationRecipientResolver(
mockServices.auth(),
catalog,
);
await expect(
getUsersForEntityRef('component:default/test_component', [], {
auth: mockServices.auth(),
catalog,
resolver.resolveNotificationRecipients({
entityRefs: ['component:default/test_component'],
excludeEntityRefs: [],
}),
).resolves.toEqual(['user:default/foo']);
).resolves.toEqual({ userEntityRefs: ['user:default/foo'] });
});
it('should filter excluded refs after resolving', async () => {
const catalog = catalogServiceMock({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test_component',
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/owner_group',
},
],
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'owner_group',
},
relations: [
{
type: RELATION_HAS_MEMBER,
targetRef: 'user:default/foo',
},
],
},
],
});
const resolver = new DefaultNotificationRecipientResolver(
mockServices.auth(),
catalog,
);
await expect(
resolver.resolveNotificationRecipients({
entityRefs: ['component:default/test_component'],
excludeEntityRefs: ['user:default/foo'],
}),
).resolves.toEqual({ userEntityRefs: [] });
});
});
@@ -0,0 +1,177 @@
/*
* 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 { CatalogService } from '@backstage/plugin-catalog-node';
import { NotificationRecipientResolver } from '@backstage/plugin-notifications-node';
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[][] => {
const ret = [[], []] as string[][];
for (const ref of refs) {
if (isUserEntityRef(ref)) {
ret[0].push(ref);
} else {
ret[1].push(ref);
}
}
return ret;
};
export class DefaultNotificationRecipientResolver
implements NotificationRecipientResolver
{
constructor(
private readonly auth: AuthService,
private readonly catalog: CatalogService,
) {}
async resolveNotificationRecipients(options: {
entityRefs: string[];
excludeEntityRefs?: string[];
}): Promise<{ userEntityRefs: string[] }> {
const { entityRefs, excludeEntityRefs = [] } = options;
const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(entityRefs);
const users: string[] = userEntityRefs.filter(
ref => !excludeEntityRefs.includes(ref),
);
const filtered = otherEntityRefs.filter(
ref => !excludeEntityRefs.includes(ref),
);
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
let entities: Array<Entity | undefined> = [];
if (filtered.length > 0) {
const fetchedEntities = await this.catalog.getEntitiesByRefs(
{
entityRefs: filtered,
fields,
},
{ credentials: await this.auth.getOwnServiceCredentials() },
);
entities = fetchedEntities.items;
}
const cachedEntityRefs = new Map<string, string[]>();
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
if (!entity) {
return [];
}
const currentEntityRef = stringifyEntityRef(entity);
if (excludeEntityRefs.includes(currentEntityRef)) {
return [];
}
if (cachedEntityRefs.has(currentEntityRef)) {
return cachedEntityRefs.get(currentEntityRef)!;
}
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 this.catalog.getEntitiesByRefs(
{
entityRefs: childGroupRefs,
fields,
},
{ credentials: await this.auth.getOwnServiceCredentials() },
);
childGroupUsers = await Promise.all(childGroups.items.map(mapEntity));
}
const ret = [
...new Set([...groupUsers, ...childGroupUsers.flat(2)]),
].filter(ref => !excludeEntityRefs.includes(ref));
cachedEntityRefs.set(currentEntityRef, ret);
return ret;
}
if (entity.relations?.length) {
const ownerRef = entity.relations.find(
relation => relation.type === RELATION_OWNED_BY,
)?.targetRef;
if (!ownerRef) {
return [];
}
if (isUserEntityRef(ownerRef)) {
if (excludeEntityRefs.includes(ownerRef)) {
return [];
}
return [ownerRef];
}
const owner = await this.catalog.getEntityByRef(ownerRef, {
credentials: await this.auth.getOwnServiceCredentials(),
});
const ret = await mapEntity(owner);
cachedEntityRefs.set(currentEntityRef, ret);
return ret;
}
return [];
};
for (const entity of entities) {
const u = await mapEntity(entity);
users.push(...u);
}
return {
userEntityRefs: [...new Set(users)]
.filter(Boolean)
// Need to filter again after resolving users
.filter(ref => !excludeEntityRefs.includes(ref)),
};
}
}
@@ -1,159 +0,0 @@
/*
* 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 { CatalogService } from '@backstage/plugin-catalog-node';
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;
catalog: CatalogService;
},
): Promise<string[]> => {
const { auth, catalog } = options;
if (entityRef === null) {
return [];
}
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 catalog.getEntitiesByRefs(
{
entityRefs,
fields,
},
{ credentials: await auth.getOwnServiceCredentials() },
);
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 catalog.getEntitiesByRefs(
{
entityRefs: childGroupRefs,
fields,
},
{ credentials: await auth.getOwnServiceCredentials() },
);
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 catalog.getEntityByRef(ownerRef, {
credentials: await auth.getOwnServiceCredentials(),
});
return mapEntity(owner);
}
return [];
};
for (const entity of entities) {
const u = await mapEntity(entity);
users.push(...u);
}
return [...new Set(users)].filter(Boolean);
};
@@ -25,7 +25,10 @@ import {
TestDatabaseId,
TestDatabases,
} from '@backstage/backend-test-utils';
import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
import {
NotificationRecipientResolver,
NotificationSendOptions,
} from '@backstage/plugin-notifications-node';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { DatabaseService } from '@backstage/backend-plugin-api';
import { v4 as uuid } from 'uuid';
@@ -506,7 +509,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
defaultCredentials: mockCredentials.service(),
});
const recipientResolver = jest.fn();
const resolveFn = jest.fn();
const recipientResolver: NotificationRecipientResolver = {
resolveNotificationRecipients: resolveFn,
};
beforeAll(async () => {
const router = await createRouter({
@@ -539,7 +545,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
.set('Accept', 'application/json');
it('should use custom recipient resolver', async () => {
recipientResolver.mockResolvedValue(['user:default/mock']);
resolveFn.mockResolvedValue({
userEntityRefs: ['user:default/mock'],
});
const response = await sendNotification({
recipients: {
type: 'entity',
@@ -571,8 +579,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
expect(notifications).toHaveLength(1);
});
it('should return error if recipient resolver returns something other than an array of user entity refs', async () => {
recipientResolver.mockResolvedValue(['system:default/mock']);
it('should ignore if recipient resolver returns something other than an array of user entity refs', async () => {
resolveFn.mockResolvedValue({
userEntityRefs: ['system:default/mock'],
});
const response = await sendNotification({
recipients: {
type: 'entity',
@@ -582,7 +592,8 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
title: 'test notification',
},
});
expect(response.status).toEqual(400);
expect(response.status).toEqual(200);
expect(response.body).toEqual([]);
});
});
@@ -49,11 +49,11 @@ import {
OriginSetting,
} from '@backstage/plugin-notifications-common';
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { getUsersForEntityRef } from './getUsersForEntityRef';
import { Config, readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
import pThrottle from 'p-throttle';
import { parseEntityRef } from '@backstage/catalog-model';
import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts';
/** @internal */
export interface RouterOptions {
@@ -104,6 +104,10 @@ export async function createRouter(
const defaultNotificationSettings: NotificationSettings | undefined =
config.getOptional<NotificationSettings>('notifications.defaultSettings');
const usedRecipientResolver =
recipientResolver ??
new DefaultNotificationRecipientResolver(auth, catalog);
const getUser = async (req: Request<unknown>) => {
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
const info = await userInfo.getUserInfo(credentials);
@@ -637,25 +641,25 @@ export async function createRouter(
return ret;
};
const filterNonUserEntityRefs = (refs: string[]): string[] => {
return refs.filter(ref => {
try {
const parsed = parseEntityRef(ref);
return parsed.kind.toLowerCase() === 'user';
} catch {
return false;
}
});
};
const sendUserNotifications = async (
baseNotification: Omit<Notification, 'user' | 'id'>,
users: string[],
opts: NotificationSendOptions,
origin: string,
): Promise<Notification[]> => {
if (
users.find(u => {
const compound = parseEntityRef(u);
return compound.kind.toLocaleLowerCase('en-US') !== 'user';
})
) {
throw new InputError(
'Invalid user entity reference provided in recipients',
);
}
const { scope } = opts.payload;
const uniqueUsers = [...new Set(users)];
const uniqueUsers = [...new Set(filterNonUserEntityRefs(users))];
const throttled = throttle((user: string) =>
sendUserNotification(baseNotification, user, opts, origin, scope),
);
@@ -676,7 +680,6 @@ export async function createRouter(
const { recipients, payload } = opts;
const { title, link } = payload;
const notifications: Notification[] = [];
let users = [];
if (!recipients || !title) {
const missing = [
@@ -714,30 +717,26 @@ export async function createRouter(
);
notifications.push(broadcast);
} else if (recipients.type === 'entity') {
const entityRef = recipients.entityRef;
const entityRefs = [recipients.entityRef].flat();
const excludedEntityRefs = recipients.excludeEntityRef
? [recipients.excludeEntityRef].flat()
: undefined;
try {
users = recipientResolver
? await recipientResolver(
entityRef,
recipients.excludeEntityRef ?? [],
)
: await getUsersForEntityRef(
entityRef,
recipients.excludeEntityRef ?? [],
{ auth, catalog },
);
const { userEntityRefs } =
await usedRecipientResolver.resolveNotificationRecipients({
entityRefs,
excludedEntityRefs,
});
const userNotifications = await sendUserNotifications(
baseNotification,
userEntityRefs,
opts,
origin,
);
notifications.push(...userNotifications);
} catch (e) {
throw new InputError('Failed to resolve notification receivers', e);
throw new InputError('Failed to send user notifications', e);
}
const userNotifications = await sendUserNotifications(
baseNotification,
users,
opts,
origin,
);
notifications.push(...userNotifications);
} else {
throw new InputError(
`Invalid recipients type, please use either 'broadcast' or 'entity'`,