Merge pull request #30766 from drodil/notification_receiver_ext

feat(notifications): extension point to modify user resolving
This commit is contained in:
Fredrik Adelöw
2025-09-15 15:45:26 +02:00
committed by GitHub
9 changed files with 457 additions and 209 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-notifications-backend': patch
'@backstage/plugin-notifications-node': patch
---
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`.
@@ -22,6 +22,7 @@ import { createRouter } from './service/router';
import { signalsServiceRef } from '@backstage/plugin-signals-node';
import {
NotificationProcessor,
NotificationRecipientResolver,
notificationsProcessingExtensionPoint,
NotificationsProcessingExtensionPoint,
} from '@backstage/plugin-notifications-node';
@@ -33,6 +34,7 @@ class NotificationsProcessingExtensionPointImpl
implements NotificationsProcessingExtensionPoint
{
#processors = new Array<NotificationProcessor>();
#recipientResolver: NotificationRecipientResolver | undefined = undefined;
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
@@ -43,6 +45,21 @@ class NotificationsProcessingExtensionPointImpl
get processors() {
return this.#processors;
}
setNotificationRecipientResolver(
resolver: NotificationRecipientResolver,
): void {
if (this.#recipientResolver) {
throw new Error(
'Notification recipient resolver is already set. You can only set it once.',
);
}
this.#recipientResolver = resolver;
}
get recipientResolver() {
return this.#recipientResolver;
}
}
/**
@@ -98,6 +115,7 @@ export const notificationsPlugin = createBackendPlugin({
catalog,
signals,
processors: processingExtensions.processors,
recipientResolver: processingExtensions.recipientResolver,
}),
);
httpRouter.addAuthPolicy({
@@ -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';
@@ -501,6 +504,99 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
});
});
describe('POST /notifications with custom receiver resolver', () => {
const httpAuth = mockServices.httpAuth({
defaultCredentials: mockCredentials.service(),
});
const resolveFn = jest.fn();
const recipientResolver: NotificationRecipientResolver = {
resolveNotificationRecipients: resolveFn,
};
beforeAll(async () => {
const router = await createRouter({
logger: mockServices.logger.mock(),
store,
signals: signalService,
userInfo,
config,
httpAuth,
auth,
catalog,
recipientResolver,
});
app = express().use(router).use(mockErrorHandler());
});
beforeEach(async () => {
jest.resetAllMocks();
const client = await database.getClient();
await client('notification').del();
await client('broadcast').del();
await client('user_settings').del();
});
const sendNotification = async (data: NotificationSendOptions) =>
request(app)
.post('/notifications')
.send(data)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
it('should use custom recipient resolver', async () => {
resolveFn.mockResolvedValue({
userEntityRefs: ['user:default/mock'],
});
const response = await sendNotification({
recipients: {
type: 'entity',
entityRef: ['system:default/mock'],
},
payload: {
title: 'test notification',
},
});
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
created: expect.any(String),
id: expect.any(String),
origin: 'external:test-service',
payload: {
severity: 'normal',
title: 'test notification',
},
user: 'user:default/mock',
},
]);
const client = await database.getClient();
const notifications = await client('notification')
.where('user', 'user:default/mock')
.select();
expect(notifications).toHaveLength(1);
});
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',
entityRef: ['system:default/mock'],
},
payload: {
title: 'test notification',
},
});
expect(response.status).toEqual(200);
expect(response.body).toEqual([]);
});
});
describe('GET /', () => {
const httpAuth = mockServices.httpAuth({
defaultCredentials: mockCredentials.user(),
@@ -26,6 +26,7 @@ import { v4 as uuid } from 'uuid';
import { CatalogService } from '@backstage/plugin-catalog-node';
import {
NotificationProcessor,
NotificationRecipientResolver,
NotificationSendOptions,
} from '@backstage/plugin-notifications-node';
import { InputError, NotFoundError } from '@backstage/errors';
@@ -48,10 +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 {
@@ -64,6 +66,7 @@ export interface RouterOptions {
signals?: SignalsService;
catalog: CatalogService;
processors?: NotificationProcessor[];
recipientResolver?: NotificationRecipientResolver;
}
/** @internal */
@@ -80,6 +83,7 @@ export async function createRouter(
catalog,
processors = [],
signals,
recipientResolver,
} = options;
const WEB_NOTIFICATION_CHANNEL = 'Web';
@@ -100,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);
@@ -633,6 +641,17 @@ 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[],
@@ -640,7 +659,7 @@ export async function createRouter(
origin: string,
): Promise<Notification[]> => {
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),
);
@@ -661,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 = [
@@ -699,25 +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 = 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'`,
+15
View File
@@ -41,6 +41,17 @@ export interface NotificationProcessor {
// @public @deprecated (undocumented)
export type NotificationProcessorFilters = NotificationProcessorFilters_2;
// @public
export interface NotificationRecipientResolver {
// (undocumented)
resolveNotificationRecipients(options: {
entityRefs: string[];
excludedEntityRefs?: string[];
}): Promise<{
userEntityRefs: string[];
}>;
}
// @public (undocumented)
export type NotificationRecipients =
| {
@@ -83,6 +94,10 @@ export interface NotificationsProcessingExtensionPoint {
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
// (undocumented)
setNotificationRecipientResolver(
resolver: NotificationRecipientResolver,
): void;
}
// @public (undocumented)
@@ -100,6 +100,24 @@ export interface NotificationProcessor {
getNotificationFilters?(): NotificationProcessorFilters;
}
/**
* NotificationRecipientResolver interface is used to resolve the individual
* users to 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 interface NotificationRecipientResolver {
resolveNotificationRecipients(options: {
entityRefs: string[];
excludedEntityRefs?: string[];
}): Promise<{ userEntityRefs: string[] }>;
}
/**
* @public
*/
@@ -107,6 +125,9 @@ export interface NotificationsProcessingExtensionPoint {
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
setNotificationRecipientResolver(
resolver: NotificationRecipientResolver,
): void;
}
/**