permission: cache user info and parallelize resolution

The permission backend previously resolved userInfo and minted a plugin
request token sequentially for every authorize request with user
credentials. On high-traffic endpoints this meant two serial internal
HTTP round-trips per request, even when the same user made many
requests in quick succession.

This change:

1. Adds a 5-second TTL cache to DefaultUserInfoService so that repeated
   getUserInfo() calls for the same user return the cached result
   without an HTTP call to the auth backend.

2. Parallelises the getUserInfo() and getPluginRequestToken() calls in
   the permission backend's handleRequest via Promise.all, saving one
   sequential round-trip on cache misses.

Signed-off-by: Fredrik Adelöw <freben@gmail.com>

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-14 11:53:36 +02:00
parent ada7df7929
commit 2f0519cba3
12 changed files with 306 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,181 @@
/*
* 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 } 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 () => {
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);
await new Promise(resolve => setTimeout(resolve, 60));
await service.getUserInfo(creds);
expect(delegate.getUserInfo).toHaveBeenCalledTimes(2);
});
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();
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,90 @@
/*
* 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();
const cached = this.#entries.get(token);
if (cached) {
if (cached.expiresAt > now) {
return cached.promise;
}
this.#entries.delete(token);
}
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 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"