From 1ccad86e356bd0b4ada8b5cddd2b2a24a3200912 Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Sat, 28 Feb 2026 10:08:46 +0100 Subject: [PATCH] feat(auth-backend): add who-am-i action to actions registry (#33046) Signed-off-by: benjdlambert --- .changeset/add-who-am-i-action.md | 5 + .../src/actions/createWhoAmIAction.test.ts | 101 ++++++++++++++++++ .../src/actions/createWhoAmIAction.ts | 92 ++++++++++++++++ plugins/auth-backend/src/actions/index.ts | 28 +++++ plugins/auth-backend/src/authPlugin.ts | 8 ++ 5 files changed, 234 insertions(+) create mode 100644 .changeset/add-who-am-i-action.md create mode 100644 plugins/auth-backend/src/actions/createWhoAmIAction.test.ts create mode 100644 plugins/auth-backend/src/actions/createWhoAmIAction.ts create mode 100644 plugins/auth-backend/src/actions/index.ts diff --git a/.changeset/add-who-am-i-action.md b/.changeset/add-who-am-i-action.md new file mode 100644 index 0000000000..5c1008a7e1 --- /dev/null +++ b/.changeset/add-who-am-i-action.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added `who-am-i` action to the auth backend actions registry. Returns the catalog entity and user info for the currently authenticated user. diff --git a/plugins/auth-backend/src/actions/createWhoAmIAction.test.ts b/plugins/auth-backend/src/actions/createWhoAmIAction.test.ts new file mode 100644 index 0000000000..6de064e3e9 --- /dev/null +++ b/plugins/auth-backend/src/actions/createWhoAmIAction.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2025 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 { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { createWhoAmIAction } from './createWhoAmIAction'; + +const mockUserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'mock', + namespace: 'default', + }, + spec: { + profile: { + displayName: 'Mock User', + email: 'mock@example.com', + }, + }, +}; + +describe('createWhoAmIAction', () => { + it('should return the user entity and user info for authenticated user credentials', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + createWhoAmIAction({ + auth: mockServices.auth(), + catalog: catalogServiceMock({ entities: [mockUserEntity] }), + userInfo: mockServices.userInfo(), + actionsRegistry: mockActionsRegistry, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:who-am-i', + input: {}, + credentials: mockCredentials.user(), + }); + + expect(result.output).toEqual({ + entity: mockUserEntity, + userInfo: { + userEntityRef: 'user:default/mock', + ownershipEntityRefs: ['user:default/mock'], + }, + }); + }); + + it('should throw when called with service credentials', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + createWhoAmIAction({ + auth: mockServices.auth(), + catalog: catalogServiceMock({ entities: [mockUserEntity] }), + userInfo: mockServices.userInfo(), + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:who-am-i', + input: {}, + credentials: mockCredentials.service(), + }), + ).rejects.toThrow('This action requires user credentials'); + }); + + it('should throw when the user entity is not found in the catalog', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + createWhoAmIAction({ + auth: mockServices.auth(), + catalog: catalogServiceMock(), + userInfo: mockServices.userInfo(), + actionsRegistry: mockActionsRegistry, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:who-am-i', + input: {}, + credentials: mockCredentials.user(), + }), + ).rejects.toThrow( + 'User entity not found in the catalog for "user:default/mock"', + ); + }); +}); diff --git a/plugins/auth-backend/src/actions/createWhoAmIAction.ts b/plugins/auth-backend/src/actions/createWhoAmIAction.ts new file mode 100644 index 0000000000..2a9817bf57 --- /dev/null +++ b/plugins/auth-backend/src/actions/createWhoAmIAction.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2025 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 { AuthService, UserInfoService } from '@backstage/backend-plugin-api'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { CatalogService } from '@backstage/plugin-catalog-node'; + +export const createWhoAmIAction = ({ + auth, + catalog, + userInfo, + actionsRegistry, +}: { + auth: AuthService; + catalog: CatalogService; + userInfo: UserInfoService; + actionsRegistry: ActionsRegistryService; +}) => { + actionsRegistry.register({ + name: 'who-am-i', + title: 'Who Am I', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: + 'Returns the catalog entity and user info for the currently authenticated user. This action requires user credentials and cannot be used with service or unauthenticated credentials.', + schema: { + input: z => z.object({}), + output: z => + z.object({ + entity: z + .object({}) + .passthrough() + .describe('The full catalog entity for the authenticated user'), + userInfo: z + .object({ + userEntityRef: z + .string() + .describe( + 'The entity ref of the user, e.g. user:default/jane.doe', + ), + ownershipEntityRefs: z + .array(z.string()) + .describe('Entity refs that the user claims ownership through'), + }) + .describe( + 'User identity information extracted from the authentication token', + ), + }), + }, + action: async ({ credentials }) => { + if (!auth.isPrincipal(credentials, 'user')) { + throw new NotAllowedError('This action requires user credentials'); + } + + const { userEntityRef } = credentials.principal; + + const [entity, info] = await Promise.all([ + catalog.getEntityByRef(userEntityRef, { credentials }), + userInfo.getUserInfo(credentials), + ]); + + if (!entity) { + throw new NotFoundError( + `User entity not found in the catalog for "${userEntityRef}"`, + ); + } + + return { + output: { + entity, + userInfo: info, + }, + }; + }, + }); +}; diff --git a/plugins/auth-backend/src/actions/index.ts b/plugins/auth-backend/src/actions/index.ts new file mode 100644 index 0000000000..28c611cca0 --- /dev/null +++ b/plugins/auth-backend/src/actions/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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 { AuthService, UserInfoService } from '@backstage/backend-plugin-api'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { CatalogService } from '@backstage/plugin-catalog-node'; +import { createWhoAmIAction } from './createWhoAmIAction'; + +export const createAuthActions = (options: { + auth: AuthService; + actionsRegistry: ActionsRegistryService; + catalog: CatalogService; + userInfo: UserInfoService; +}) => { + createWhoAmIAction(options); +}; diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts index df42ddce9e..3e721d18a7 100644 --- a/plugins/auth-backend/src/authPlugin.ts +++ b/plugins/auth-backend/src/authPlugin.ts @@ -24,7 +24,9 @@ import { AuthProviderFactory, authProvidersExtensionPoint, } from '@backstage/plugin-auth-node'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { createAuthActions } from './actions'; import { createRouter } from './service/router'; import { OfflineAccessService } from './service/OfflineAccessService'; @@ -70,6 +72,8 @@ export const authPlugin = createBackendPlugin({ httpAuth: coreServices.httpAuth, lifecycle: coreServices.lifecycle, catalog: catalogServiceRef, + actionsRegistry: actionsRegistryServiceRef, + userInfo: coreServices.userInfo, }, async init({ httpRouter, @@ -81,6 +85,8 @@ export const authPlugin = createBackendPlugin({ httpAuth, lifecycle, catalog, + actionsRegistry, + userInfo, }) { const refreshTokensEnabled = config.getOptionalBoolean( 'auth.experimentalRefreshToken.enabled', @@ -112,6 +118,8 @@ export const authPlugin = createBackendPlugin({ allow: 'unauthenticated', }); httpRouter.use(router); + + createAuthActions({ auth, catalog, userInfo, actionsRegistry }); }, }); },