feat(auth-backend): add who-am-i action to actions registry (#33046)

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-02-28 10:08:46 +01:00
committed by GitHub
parent 7349cd5cac
commit 1ccad86e35
5 changed files with 234 additions and 0 deletions
+5
View File
@@ -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.
@@ -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"',
);
});
});
@@ -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,
},
};
},
});
};
+28
View File
@@ -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);
};
+8
View File
@@ -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 });
},
});
},