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
+6 -3
View File
@@ -3,7 +3,10 @@
'@backstage/plugin-notifications-node': patch
---
A new extension point method was added that can be used to modify how the users receiving notifications
are resolved. The function passed to the extension point should only return complete user entity references
based on the notification target references and the excluded entity references. Note that the input is a list
A new extension point was added that can be used to modify how the users receiving notifications
are resolved. The interface passed to the extension point should only return complete user entity references
based on the notification target references and the excluded entity references. Note that the inputs are lists
of entity references that can be any entity kind, not just user entities.
Using this extension point will override the default behavior of resolving users with the
`DefaultNotificationRecipientResolver`.
@@ -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'`,
+9 -4
View File
@@ -42,10 +42,15 @@ export interface NotificationProcessor {
export type NotificationProcessorFilters = NotificationProcessorFilters_2;
// @public
export type NotificationRecipientResolver = (
entityRef: string | string[] | null,
excludeEntityRefs: string | string[],
) => Promise<string[]>;
export interface NotificationRecipientResolver {
// (undocumented)
resolveNotificationRecipients(options: {
entityRefs: string[];
excludedEntityRefs?: string[];
}): Promise<{
userEntityRefs: string[];
}>;
}
// @public (undocumented)
export type NotificationRecipients =
+12 -7
View File
@@ -101,17 +101,22 @@ export interface NotificationProcessor {
}
/**
* NotificationRecipientResolver is a function that resolves the individual users to receive the notification
* based on the entity reference(s) and the excluded entity reference(s).
* NotificationRecipientResolver interface is used to resolve the individual
* users to receive the notification.
*
* The function should return a list of user entity references that should receive the notification.
* The `resolveNotificationRecipients` is used to resolve notifications sent for
* entity references, and it should return object with a list of user
* entity references that should receive the notification. In case the function
* returns other than user entity references, those are ignored.
*
* @public
*/
export type NotificationRecipientResolver = (
entityRef: string | string[] | null,
excludeEntityRefs: string | string[],
) => Promise<string[]>;
export interface NotificationRecipientResolver {
resolveNotificationRecipients(options: {
entityRefs: string[];
excludedEntityRefs?: string[];
}): Promise<{ userEntityRefs: string[] }>;
}
/**
* @public