Merge pull request #34252 from backstage/freben/lazy-permission-user

permission: cache user info, clean up PolicyQueryUser, drop getPluginRequestToken
This commit is contained in:
Fredrik Adelöw
2026-05-19 15:20:48 +02:00
committed by GitHub
12 changed files with 311 additions and 51 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-backend': patch
---
The permission backend no longer populates the removed `token` and `identity` fields on `PolicyQueryUser`, and no longer calls `auth.getPluginRequestToken()` during policy evaluation. This removes one internal round-trip per authorize request.
@@ -0,0 +1,11 @@
---
'@backstage/plugin-permission-node': minor
---
**BREAKING**: Cleaned up the `PolicyQueryUser` type:
- `token`**Removed.** Was previously deprecated in favor of `credentials` with `coreServices.auth`.
- `expiresInSeconds`**Removed.** Was previously deprecated.
- `identity`**Removed.** Was previously deprecated in favor of `info`.
- `info`**Deprecated.** Still required and populated for now; will be made optional and then removed in a future release.
- `credentials` — Unchanged.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Added a new `CachedUserInfoService` decorator that wraps `DefaultUserInfoService` with a 5-second TTL cache and in-flight request coalescing. The decorator is wired in via `userInfoServiceFactory` using a shared root-level cache. Repeated `getUserInfo()` calls for the same user token within the TTL window return the cached result without making an HTTP call to the auth backend. Note that custom `UserInfoService` implementations registered via their own factory will not benefit from this cache automatically.
@@ -0,0 +1,189 @@
/*
* Copyright 2026 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 {
BackstageUserInfo,
UserInfoService,
} from '@backstage/backend-plugin-api';
import { mockCredentials } from '@backstage/backend-test-utils';
import {
CachedUserInfoService,
UserInfoCacheEntry,
} from './CachedUserInfoService';
const aliceInfo: BackstageUserInfo = {
userEntityRef: 'user:default/alice',
ownershipEntityRefs: ['user:default/alice', 'group:default/team-a'],
};
describe('CachedUserInfoService', () => {
it('delegates to the underlying service on the first call', async () => {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
const result = await service.getUserInfo(mockCredentials.user());
expect(result).toEqual(aliceInfo);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
});
it('returns the cached result on subsequent calls within TTL', async () => {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
const creds = mockCredentials.user();
await service.getUserInfo(creds);
await service.getUserInfo(creds);
await service.getUserInfo(creds);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
});
it('coalesces concurrent in-flight requests for the same token', async () => {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
const creds = mockCredentials.user();
const [r1, r2, r3] = await Promise.all([
service.getUserInfo(creds),
service.getUserInfo(creds),
service.getUserInfo(creds),
]);
expect(r1).toEqual(aliceInfo);
expect(r2).toEqual(aliceInfo);
expect(r3).toEqual(aliceInfo);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
});
it('caches different tokens separately', async () => {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
await service.getUserInfo(mockCredentials.user('user:default/alice'));
await service.getUserInfo(mockCredentials.user('user:default/bob'));
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
});
it('re-fetches after the TTL expires', async () => {
jest.useFakeTimers();
try {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate, { ttlMs: 50 });
const creds = mockCredentials.user();
await service.getUserInfo(creds);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(60);
await service.getUserInfo(creds);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('evicts the cache entry on rejection and retries on next call', async () => {
const delegate: UserInfoService = {
getUserInfo: jest
.fn()
.mockRejectedValueOnce(new Error('auth backend down'))
.mockResolvedValueOnce(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
const creds = mockCredentials.user();
await expect(service.getUserInfo(creds)).rejects.toThrow(
'auth backend down',
);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(1);
const result = await service.getUserInfo(creds);
expect(result).toEqual(aliceInfo);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
});
it('evicts eagerly so concurrent waiters see the rejection and the next call retries', async () => {
let rejectFirst: (error: Error) => void;
const firstCall = new Promise<BackstageUserInfo>((_resolve, reject) => {
rejectFirst = reject;
});
const delegate: UserInfoService = {
getUserInfo: jest
.fn()
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
const creds = mockCredentials.user();
const p1 = service.getUserInfo(creds);
const p2 = service.getUserInfo(creds);
rejectFirst!(new Error('boom'));
await expect(p1).rejects.toThrow('boom');
await expect(p2).rejects.toThrow('boom');
const result = await service.getUserInfo(creds);
expect(result).toEqual(aliceInfo);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
});
it('delegates directly when credentials have no token', async () => {
const delegate: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const service = new CachedUserInfoService(delegate);
await service.getUserInfo(mockCredentials.none());
await service.getUserInfo(mockCredentials.none());
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
});
it('shares cache entries across instances when given the same map', async () => {
const delegate1: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const delegate2: UserInfoService = {
getUserInfo: jest.fn().mockResolvedValue(aliceInfo),
};
const entries = new Map<string, UserInfoCacheEntry>();
const service1 = new CachedUserInfoService(delegate1, { entries });
const service2 = new CachedUserInfoService(delegate2, { entries });
const creds = mockCredentials.user();
await service1.getUserInfo(creds);
await service2.getUserInfo(creds);
expect(delegate1.getUserInfo).toHaveBeenCalledTimes(1);
expect(delegate2.getUserInfo).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,87 @@
/*
* Copyright 2026 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 {
BackstageCredentials,
BackstageUserInfo,
UserInfoService,
} from '@backstage/backend-plugin-api';
import { toInternalBackstageCredentials } from '../auth/helpers';
const DEFAULT_TTL_MS = 5_000;
const SWEEP_INTERVAL_MS = 30_000;
export type UserInfoCacheEntry = {
promise: Promise<BackstageUserInfo>;
expiresAt: number;
};
export class CachedUserInfoService implements UserInfoService {
readonly #delegate: UserInfoService;
readonly #entries: Map<string, UserInfoCacheEntry>;
readonly #ttlMs: number;
#lastSweep: number = Date.now();
constructor(
delegate: UserInfoService,
options?: {
entries?: Map<string, UserInfoCacheEntry>;
ttlMs?: number;
},
) {
this.#delegate = delegate;
this.#entries = options?.entries ?? new Map();
this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
}
async getUserInfo(
credentials: BackstageCredentials,
): Promise<BackstageUserInfo> {
const internalCredentials = toInternalBackstageCredentials(credentials);
const token = internalCredentials.token;
if (!token) {
return this.#delegate.getUserInfo(credentials);
}
const now = Date.now();
if (now - this.#lastSweep > SWEEP_INTERVAL_MS) {
this.#lastSweep = now;
for (const [key, entry] of this.#entries) {
if (entry.expiresAt <= now) {
this.#entries.delete(key);
}
}
}
const cached = this.#entries.get(token);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
const promise = this.#delegate.getUserInfo(credentials).catch(error => {
this.#entries.delete(token);
throw error;
});
this.#entries.set(token, {
promise,
expiresAt: now + this.#ttlMs,
});
return promise;
}
}
@@ -18,6 +18,10 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import {
CachedUserInfoService,
UserInfoCacheEntry,
} from './CachedUserInfoService';
import { DefaultUserInfoService } from './DefaultUserInfoService';
/**
@@ -34,7 +38,13 @@ export const userInfoServiceFactory = createServiceFactory({
deps: {
discovery: coreServices.discovery,
},
async factory({ discovery }) {
return new DefaultUserInfoService({ discovery });
createRootContext() {
return new Map<string, UserInfoCacheEntry>();
},
async factory({ discovery }, entries) {
return new CachedUserInfoService(
new DefaultUserInfoService({ discovery }),
{ entries },
);
},
});
@@ -270,17 +270,6 @@ describe('createRouter', () => {
},
},
{
token: mockCredentials.service.token({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'catalog',
}),
identity: {
type: 'user',
userEntityRef: mockCredentials.user().principal.userEntityRef,
ownershipEntityRefs: [
mockCredentials.user().principal.userEntityRef,
],
},
info: {
userEntityRef: mockCredentials.user().principal.userEntityRef,
ownershipEntityRefs: [
@@ -132,17 +132,7 @@ const handleRequest = async (
let user: PolicyQueryUser | undefined;
if (auth.isPrincipal(credentials, 'user')) {
const info = await userInfo.getUserInfo(credentials);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'catalog', // TODO: unknown at this point
});
user = {
identity: {
type: 'user',
userEntityRef: credentials.principal.userEntityRef,
ownershipEntityRefs: info.ownershipEntityRefs,
},
token,
credentials,
info,
};
-1
View File
@@ -59,7 +59,6 @@
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@types/express": "^4.17.6",
"express": "^4.22.0",
-4
View File
@@ -10,7 +10,6 @@ import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { AuthService } from '@backstage/backend-plugin-api';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { BackstageUserIdentity } from '@backstage/plugin-auth-node';
import { BackstageUserInfo } from '@backstage/backend-plugin-api';
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common';
import { Config } from '@backstage/config';
@@ -365,9 +364,6 @@ export type PolicyQuery = {
// @public
export type PolicyQueryUser = {
token: string;
expiresInSeconds?: number;
identity: BackstageUserIdentity;
credentials: BackstageCredentials;
info: BackstageUserInfo;
};
+2 -22
View File
@@ -18,7 +18,6 @@ import {
Permission,
PolicyDecision,
} from '@backstage/plugin-permission-common';
import { BackstageUserIdentity } from '@backstage/plugin-auth-node';
import {
BackstageCredentials,
BackstageUserInfo,
@@ -44,27 +43,6 @@ export type PolicyQuery = {
* @public
*/
export type PolicyQueryUser = {
/**
* The token used to authenticate the user within Backstage.
*
* @deprecated User the `credentials` field in combination with `coreServices.auth` to generate a request token instead.
*/
token: string;
/**
* The number of seconds until the token expires. If not set, it can be assumed that the token does not expire.
*
* @deprecated This field is deprecated and will be removed in a future release.
*/
expiresInSeconds?: number;
/**
* A plaintext description of the identity that is encapsulated within the token.
*
* @deprecated Use the `info` field instead.
*/
identity: BackstageUserIdentity;
/**
* The credentials of the user making the request.
*/
@@ -72,6 +50,8 @@ export type PolicyQueryUser = {
/**
* The information for the user making the request.
*
* @deprecated This field is deprecated and will be removed in a future release.
*/
info: BackstageUserInfo;
};
-1
View File
@@ -6498,7 +6498,6 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@types/express": "npm:^4.17.6"
"@types/supertest": "npm:^2.0.8"