Merge pull request #23217 from backstage/freben/permission-node

auth: convert permission-backend to the new auth services
This commit is contained in:
Fredrik Adelöw
2024-02-27 13:28:35 +01:00
committed by GitHub
21 changed files with 448 additions and 121 deletions
+12 -3
View File
@@ -3,27 +3,36 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import express from 'express';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { PermissionPolicy } from '@backstage/plugin-permission-node';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { UserInfoService } from '@backstage/backend-plugin-api';
// @public
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public
export interface RouterOptions {
// (undocumented)
auth?: AuthService;
// (undocumented)
config: Config;
// (undocumented)
discovery: PluginEndpointDiscovery;
discovery: DiscoveryService;
// (undocumented)
identity: IdentityApi;
httpAuth?: HttpAuthService;
// (undocumented)
identity?: IdentityApi;
// (undocumented)
logger: Logger;
// (undocumented)
policy: PermissionPolicy;
// (undocumented)
userInfo?: UserInfoService;
}
```
+1
View File
@@ -62,6 +62,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/lodash": "^4.14.151",
"@types/supertest": "^2.0.8",
+15 -3
View File
@@ -55,9 +55,19 @@ export const permissionPlugin = createBackendPlugin({
config: coreServices.rootConfig,
logger: coreServices.logger,
discovery: coreServices.discovery,
identity: coreServices.identity,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
userInfo: coreServices.userInfo,
},
async init({ http, config, logger, discovery, identity }) {
async init({
http,
config,
logger,
discovery,
auth,
httpAuth,
userInfo,
}) {
const winstonLogger = loggerToWinstonLogger(logger);
if (!policies.policy) {
throw new Error(
@@ -69,9 +79,11 @@ export const permissionPlugin = createBackendPlugin({
await createRouter({
config,
discovery,
identity,
logger: winstonLogger,
policy: policies.policy,
auth,
httpAuth,
userInfo,
}),
);
},
@@ -19,7 +19,7 @@ import { Server } from 'http';
import express, { Router, RequestHandler } from 'express';
import { RestContext, rest } from 'msw';
import { setupServer, SetupServer } from 'msw/node';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import {
AuthorizeResult,
PermissionCondition,
@@ -31,10 +31,12 @@ import {
} from '@backstage/plugin-permission-node';
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
import { z } from 'zod';
import { DiscoveryService } from '@backstage/backend-plugin-api';
describe('PermissionIntegrationClient', () => {
describe('applyConditions', () => {
let server: SetupServer;
const auth = mockServices.auth();
const mockConditions: PermissionCriteria<PermissionCondition> = {
not: {
@@ -58,7 +60,7 @@ describe('PermissionIntegrationClient', () => {
);
const mockBaseUrl = 'http://backstage:9191';
const discovery: PluginEndpointDiscovery = {
const discovery: DiscoveryService = {
async getBaseUrl(pluginId) {
return `${mockBaseUrl}/${pluginId}`;
},
@@ -70,6 +72,7 @@ describe('PermissionIntegrationClient', () => {
const client: PermissionIntegrationClient = new PermissionIntegrationClient(
{
discovery,
auth,
},
);
@@ -91,7 +94,7 @@ describe('PermissionIntegrationClient', () => {
});
it('should make a POST request to the correct endpoint', async () => {
await client.applyConditions('plugin-1', [
await client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -104,7 +107,7 @@ describe('PermissionIntegrationClient', () => {
});
it('should include a request body', async () => {
await client.applyConditions('plugin-1', [
await client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -132,14 +135,18 @@ describe('PermissionIntegrationClient', () => {
});
it('should return the response from the fetch request', async () => {
const response = await client.applyConditions('plugin-1', [
{
id: '123',
resourceRef: 'testResource1',
resourceType: 'test-resource',
conditions: mockConditions,
},
]);
const response = await client.applyConditions(
'plugin-1',
mockCredentials.none(),
[
{
id: '123',
resourceRef: 'testResource1',
resourceType: 'test-resource',
conditions: mockConditions,
},
],
);
expect(response).toEqual(
expect.objectContaining([{ id: '123', result: AuthorizeResult.ALLOW }]),
@@ -147,7 +154,7 @@ describe('PermissionIntegrationClient', () => {
});
it('should not include authorization headers if no token is supplied', async () => {
await client.applyConditions('plugin-1', [
await client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -161,21 +168,22 @@ describe('PermissionIntegrationClient', () => {
});
it('should include correctly-constructed authorization header if token is supplied', async () => {
await client.applyConditions(
'plugin-1',
[
{
id: '123',
resourceRef: 'testResource1',
resourceType: 'test-resource',
conditions: mockConditions,
},
],
'Bearer fake-token',
);
await client.applyConditions('plugin-1', mockCredentials.user(), [
{
id: '123',
resourceRef: 'testResource1',
resourceType: 'test-resource',
conditions: mockConditions,
},
]);
const request = mockApplyConditionsHandler.mock.calls[0][0];
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
expect(request.headers.get('authorization')).toEqual(
mockCredentials.service.header({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'plugin-1',
}),
);
});
it('should forward response errors', async () => {
@@ -186,7 +194,7 @@ describe('PermissionIntegrationClient', () => {
);
await expect(
client.applyConditions('plugin-1', [
client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -194,7 +202,7 @@ describe('PermissionIntegrationClient', () => {
conditions: mockConditions,
},
]),
).rejects.toThrow(/401/i);
).rejects.toThrow(/401/);
});
it('should reject invalid responses', async () => {
@@ -207,7 +215,7 @@ describe('PermissionIntegrationClient', () => {
);
await expect(
client.applyConditions('plugin-1', [
client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -234,7 +242,7 @@ describe('PermissionIntegrationClient', () => {
);
await expect(
client.applyConditions('plugin-1', [
client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -268,6 +276,7 @@ describe('PermissionIntegrationClient', () => {
let server: Server;
let client: PermissionIntegrationClient;
let routerSpy: RequestHandler;
const auth = mockServices.auth();
beforeAll(async () => {
const router = Router();
@@ -319,7 +328,7 @@ describe('PermissionIntegrationClient', () => {
server = app.listen(resolve);
});
const discovery: PluginEndpointDiscovery = {
const discovery: DiscoveryService = {
async getBaseUrl(pluginId: string) {
const listenPort = (server.address()! as AddressInfo).port;
@@ -332,6 +341,7 @@ describe('PermissionIntegrationClient', () => {
client = new PermissionIntegrationClient({
discovery,
auth,
});
});
@@ -348,7 +358,7 @@ describe('PermissionIntegrationClient', () => {
it('works for simple conditions', async () => {
await expect(
client.applyConditions('plugin-1', [
client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -367,7 +377,7 @@ describe('PermissionIntegrationClient', () => {
it('works for complex criteria', async () => {
await expect(
client.applyConditions('plugin-1', [
client.applyConditions('plugin-1', mockCredentials.none(), [
{
id: '123',
resourceRef: 'testResource1',
@@ -16,7 +16,6 @@
import fetch from 'node-fetch';
import { z } from 'zod';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
AuthorizeResult,
ConditionalPolicyDecision,
@@ -25,6 +24,11 @@ import {
ApplyConditionsRequestEntry,
ApplyConditionsResponseEntry,
} from '@backstage/plugin-permission-node';
import {
AuthService,
BackstageCredentials,
DiscoveryService,
} from '@backstage/backend-plugin-api';
const responseSchema = z.object({
items: z.array(
@@ -42,20 +46,30 @@ export type ResourcePolicyDecision = ConditionalPolicyDecision & {
};
export class PermissionIntegrationClient {
private readonly discovery: PluginEndpointDiscovery;
private readonly discovery: DiscoveryService;
private readonly auth: AuthService;
constructor(options: { discovery: PluginEndpointDiscovery }) {
constructor(options: { discovery: DiscoveryService; auth: AuthService }) {
this.discovery = options.discovery;
this.auth = options.auth;
}
async applyConditions(
pluginId: string,
credentials: BackstageCredentials,
decisions: readonly ApplyConditionsRequestEntry[],
authHeader?: string,
): Promise<ApplyConditionsResponseEntry[]> {
const endpoint = `${await this.discovery.getBaseUrl(
pluginId,
)}/.well-known/backstage/permissions/apply-conditions`;
const baseUrl = await this.discovery.getBaseUrl(pluginId);
const endpoint = `${baseUrl}/.well-known/backstage/permissions/apply-conditions`;
const token = this.auth.isPrincipal(credentials, 'none')
? undefined
: await this.auth
.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: pluginId,
})
.then(t => t.token);
const response = await fetch(endpoint, {
method: 'POST',
@@ -70,7 +84,7 @@ export class PermissionIntegrationClient {
),
}),
headers: {
...(authHeader ? { authorization: authHeader } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
'content-type': 'application/json',
},
});
@@ -26,12 +26,15 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
const mockApplyConditions: jest.MockedFunction<
InstanceType<typeof PermissionIntegrationClient>['applyConditions']
> = jest.fn(
async (
_pluginId: string,
_credentials: BackstageCredentials,
decisions: readonly ApplyConditionsRequestEntry[],
) =>
decisions.map(decision => ({
@@ -65,28 +68,12 @@ describe('createRouter', () => {
const router = await createRouter({
config: new ConfigReader({ permission: { enabled: true } }),
logger: getVoidLogger(),
discovery: {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
},
identity: {
getIdentity: jest.fn(({ request: req }) => {
const token = req.headers.authorization?.replace(/^Bearer[ ]+/, '');
if (!token) {
return Promise.resolve(undefined);
}
return Promise.resolve({
identity: {
type: 'user',
userEntityRef: 'test-user',
ownershipEntityRefs: ['blah'],
},
token,
});
}),
},
discovery: mockServices.discovery(),
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth({
defaultCredentials: mockCredentials.none(),
}),
userInfo: mockServices.userInfo(),
policy,
});
@@ -163,10 +150,9 @@ describe('createRouter', () => {
});
it('resolves identity from the Authorization header', async () => {
const token = 'test-token';
const response = await request(app)
.post('/authorize')
.auth(token, { type: 'bearer' })
.auth(mockCredentials.user.token(), { type: 'bearer' })
.send({
items: [
{
@@ -190,11 +176,16 @@ describe('createRouter', () => {
},
},
{
token: 'test-token',
token: mockCredentials.service.token({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'catalog',
}),
identity: {
type: 'user',
userEntityRef: 'test-user',
ownershipEntityRefs: ['blah'],
userEntityRef: mockCredentials.user().principal.userEntityRef,
ownershipEntityRefs: [
mockCredentials.user().principal.userEntityRef,
],
},
},
);
@@ -271,7 +262,7 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.auth(mockCredentials.user.token(), { type: 'bearer' })
.send({
items: [
{
@@ -319,6 +310,7 @@ describe('createRouter', () => {
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
mockCredentials.user(),
[
expect.objectContaining({
id: '123',
@@ -333,11 +325,11 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['no'] },
}),
],
'Bearer test-token',
);
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-2',
mockCredentials.user(),
[
expect.objectContaining({
id: '234',
@@ -352,7 +344,6 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['no'] },
}),
],
'Bearer test-token',
);
expect(response.status).toEqual(200);
@@ -401,7 +392,7 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.auth(mockCredentials.user.token(), { type: 'bearer' })
.send({
items: [
{
@@ -467,6 +458,7 @@ describe('createRouter', () => {
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
mockCredentials.user(),
[
expect.objectContaining({
id: '123',
@@ -481,11 +473,11 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['yes'] },
}),
],
'Bearer test-token',
);
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-2',
mockCredentials.user(),
[
expect.objectContaining({
id: '234',
@@ -500,7 +492,6 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['yes'] },
}),
],
'Bearer test-token',
);
expect(response.status).toEqual(200);
@@ -542,7 +533,7 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.auth(mockCredentials.user.token(), { type: 'bearer' })
.send({
items: [
{
@@ -589,6 +580,7 @@ describe('createRouter', () => {
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-1',
mockCredentials.user(),
[
expect.objectContaining({
id: '123',
@@ -597,11 +589,11 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['yes'] },
}),
],
'Bearer test-token',
);
expect(mockApplyConditions).toHaveBeenCalledWith(
'plugin-2',
mockCredentials.user(),
[
expect.objectContaining({
id: '234',
@@ -610,7 +602,6 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params: ['yes'] },
}),
],
'Bearer test-token',
);
expect(response.status).toEqual(200);
@@ -656,7 +647,7 @@ describe('createRouter', () => {
const response = await request(app)
.post('/authorize')
.auth('test-token', { type: 'bearer' })
.auth(mockCredentials.user.token(), { type: 'bearer' })
.send({
items: [
{
@@ -684,6 +675,7 @@ describe('createRouter', () => {
expect(mockApplyConditions).toHaveBeenCalledWith(
'test-plugin',
mockCredentials.user(),
[
expect.objectContaining({
id: '123',
@@ -698,7 +690,6 @@ describe('createRouter', () => {
conditions: { rule: 'test-rule', params },
}),
],
'Bearer test-token',
);
expect(response.status).toEqual(200);
@@ -19,8 +19,8 @@ import express, { Request, Response } from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
createLegacyAuthAdapters,
errorHandler,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import {
@@ -46,6 +46,15 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient';
import { memoize } from 'lodash';
import DataLoader from 'dataloader';
import { Config } from '@backstage/config';
import {
AuthService,
BackstageCredentials,
BackstageNonePrincipal,
BackstageUserPrincipal,
DiscoveryService,
HttpAuthService,
UserInfoService,
} from '@backstage/backend-plugin-api';
const attributesSchema: z.ZodSchema<PermissionAttributes> = z.object({
action: z
@@ -93,28 +102,51 @@ const evaluatePermissionRequestBatchSchema: z.ZodSchema<EvaluatePermissionReques
*/
export interface RouterOptions {
logger: Logger;
discovery: PluginEndpointDiscovery;
discovery: DiscoveryService;
policy: PermissionPolicy;
identity: IdentityApi;
identity?: IdentityApi;
config: Config;
auth?: AuthService;
httpAuth?: HttpAuthService;
userInfo?: UserInfoService;
}
const handleRequest = async (
requests: IdentifiedPermissionMessage<EvaluatePermissionRequest>[],
user: BackstageIdentityResponse | undefined,
policy: PermissionPolicy,
permissionIntegrationClient: PermissionIntegrationClient,
authHeader?: string,
credentials: BackstageCredentials<
BackstageNonePrincipal | BackstageUserPrincipal
>,
auth: AuthService,
userInfo: UserInfoService,
): Promise<IdentifiedPermissionMessage<EvaluatePermissionResponse>[]> => {
const applyConditionsLoaderFor = memoize((pluginId: string) => {
return new DataLoader<
ApplyConditionsRequestEntry,
ApplyConditionsResponseEntry
>(batch =>
permissionIntegrationClient.applyConditions(pluginId, batch, authHeader),
permissionIntegrationClient.applyConditions(pluginId, credentials, batch),
);
});
let user: BackstageIdentityResponse | undefined;
if (auth.isPrincipal(credentials, 'user')) {
const { ownershipEntityRefs } = 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,
},
token,
};
}
return Promise.all(
requests.map(({ id, resourceRef, ...request }) =>
policy.handle(request, user).then(decision => {
@@ -163,7 +195,8 @@ const handleRequest = async (
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { policy, discovery, identity, config, logger } = options;
const { policy, discovery, config, logger } = options;
const { auth, httpAuth, userInfo } = createLegacyAuthAdapters(options);
if (!config.getOptionalBoolean('permission.enabled')) {
logger.warn(
@@ -173,6 +206,7 @@ export async function createRouter(
const permissionIntegrationClient = new PermissionIntegrationClient({
discovery,
auth,
});
const router = Router();
@@ -188,7 +222,9 @@ export async function createRouter(
req: Request<EvaluatePermissionRequestBatch>,
res: Response<EvaluatePermissionResponseBatch>,
) => {
const user = await identity.getIdentity({ request: req });
const credentials = await httpAuth.credentials(req, {
allow: ['user', 'none'],
});
const parseResult = evaluatePermissionRequestBatchSchema.safeParse(
req.body,
@@ -203,10 +239,11 @@ export async function createRouter(
res.json({
items: await handleRequest(
body.items,
user,
policy,
permissionIntegrationClient,
req.header('authorization'),
credentials,
auth,
userInfo,
),
});
},