backend-app-api: added backend.auth.dangerouslyDisableDefaultAuthPolicy config + tests

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-19 18:46:33 +01:00
parent cb993f9dc8
commit a2b90a82d4
10 changed files with 302 additions and 13 deletions
+20
View File
@@ -15,6 +15,26 @@
*/
export interface Config {
backend?: {
auth?: {
/**
* This disables the otherwise default auth policy, which requires all
* requests to be authenticated with either user or service credentials.
*
* Disabling this check means that the backend will no longer block
* unauthenticated requests, but instead allow them to pass through to
* plugins.
*
* If permissions are enabled, unauthenticated requests will be treated
* exactly as such, leaving it to the permission policy to determine what
* permissions should be allowed for an unauthenticated identity. Note
* that this will also apply to service-to-service calls between plugins
* unless you configure credentials for service calls.
*/
dangerouslyDisableDefaultAuthPolicy?: boolean;
};
};
/** Discovery options. */
discovery?: {
/**
@@ -111,6 +111,7 @@ class DefaultAuthService implements AuthService {
private readonly tokenManager: TokenManager,
private readonly identity: IdentityService,
private readonly pluginId: string,
private readonly disableDefaultAuthPolicy: boolean,
) {}
async authenticate(token: string): Promise<BackstageCredentials> {
@@ -171,6 +172,15 @@ class DefaultAuthService implements AuthService {
const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
const { type } = internalForward.principal;
// Since disabling the default policy means we'll be allowing
// unauthenticated requests through, we might have unauthenticated
// credentials from service calls that reach this point. If that's the case,
// we'll want to keep "forwarding" the unauthenticated credentials, which we
// do by returning an empty token.
if (type === 'none' && this.disableDefaultAuthPolicy) {
return { token: '' };
}
switch (type) {
// TODO: Check whether the principal is ourselves
case 'service':
@@ -200,8 +210,18 @@ export const authServiceFactory = createServiceFactory({
createRootContext({ config, logger }) {
return ServerTokenManager.fromConfig(config, { logger });
},
async factory({ discovery, plugin }, tokenManager) {
async factory({ discovery, config, plugin }, tokenManager) {
const identity = DefaultIdentityClient.create({ discovery });
return new DefaultAuthService(tokenManager, identity, plugin.getId());
const disableDefaultAuthPolicy = Boolean(
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
),
);
return new DefaultAuthService(
tokenManager,
identity,
plugin.getId(),
disableDefaultAuthPolicy,
);
},
});
@@ -17,6 +17,7 @@
import {
HttpAuthService,
HttpRouterServiceAuthPolicy,
RootConfigService,
} from '@backstage/backend-plugin-api';
import { RequestHandler } from 'express';
import { pathToRegexp } from 'path-to-regexp';
@@ -37,11 +38,23 @@ export function createPathPolicyPredicate(policyPath: string) {
export function createCredentialsBarrier(options: {
httpAuth: HttpAuthService;
config: RootConfigService;
}): {
middleware: RequestHandler;
addAuthPolicy: (policy: HttpRouterServiceAuthPolicy) => void;
} {
const { httpAuth } = options;
const { httpAuth, config } = options;
const disableDefaultAuthPolicy = config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
);
if (disableDefaultAuthPolicy) {
return {
middleware: (_req, _res, next) => next(),
addAuthPolicy: () => {},
};
}
const unauthenticatedPredicates = new Array<(path: string) => boolean>();
const cookiePredicates = new Array<(path: string) => boolean>();
@@ -16,9 +16,17 @@
import {
ServiceFactoryTester,
mockCredentials,
mockServices,
startTestBackend,
} from '@backstage/backend-test-utils';
import { httpRouterServiceFactory } from './httpRouterServiceFactory';
import request from 'supertest';
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
describe('httpRouterFactory', () => {
it('should register plugin paths', async () => {
@@ -69,4 +77,190 @@ describe('httpRouterFactory', () => {
expect.any(Function),
);
});
describe('auth services', () => {
const pluginSubject = createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
},
async init({ httpRouter, auth, httpAuth }) {
const router = Router();
httpRouter.use(router);
httpRouter.addAuthPolicy({
path: '/public',
allow: 'unauthenticated',
});
httpRouter.addAuthPolicy({
path: '/cookie',
allow: 'user-cookie',
});
router.get('/public', (_req, res) => {
res.json({ ok: true });
});
router.get('/cookie', (_req, res) => {
res.json({ ok: true });
});
router.get('/protected/no-checks', (_req, res) => {
res.json({ ok: true });
});
router.get('/protected/only-users', async (req, res) => {
await httpAuth.credentials(req, { allow: ['user'] });
res.json({ ok: true });
});
router.get('/protected/only-services', async (req, res) => {
await httpAuth.credentials(req, { allow: ['service'] });
res.json({ ok: true });
});
router.get('/protected/credentials', async (req, res) => {
res.json({
credentials: await httpAuth.credentials(req),
});
});
router.get('/protected/service-token', async (req, res) => {
res.json(
await auth.getPluginRequestToken({
onBehalfOf: await httpAuth.credentials(req),
targetPluginId: 'test',
}),
);
});
},
});
},
});
const defaultServices = [
httpRouterServiceFactory(),
mockServices.httpAuth.factory({
defaultCredentials: mockCredentials.none(),
}),
];
it('should block unauthenticated requests by default', async () => {
const { server } = await startTestBackend({
features: [pluginSubject, ...defaultServices],
});
await expect(
request(server).get('/api/test/public'),
).resolves.toMatchObject({
status: 200,
});
// TODO: cookie
await expect(
request(server).get('/api/test/protected/no-checks'),
).resolves.toMatchObject({
status: 401,
});
});
it('should not block unauthenticated requests if default policy is disabled', async () => {
const { server } = await startTestBackend({
features: [
pluginSubject,
...defaultServices,
mockServices.rootConfig.factory({
data: {
backend: { auth: { dangerouslyDisableDefaultAuthPolicy: true } },
},
}),
],
});
await expect(
request(server).get('/api/test/public'),
).resolves.toMatchObject({
status: 200,
});
// TODO: cookie
await expect(
request(server).get('/api/test/protected/no-checks'),
).resolves.toMatchObject({
status: 200,
body: { ok: true },
});
await expect(
request(server).get('/api/test/protected/only-users'),
).resolves.toMatchObject({
status: 401,
});
await expect(
request(server).get('/api/test/protected/only-services'),
).resolves.toMatchObject({
status: 401,
});
await expect(
request(server).get('/api/test/protected/credentials'),
).resolves.toMatchObject({
status: 200,
body: { credentials: mockCredentials.none() },
});
await expect(
request(server)
.get('/api/test/protected/credentials')
.set('authorization', mockCredentials.user.header()),
).resolves.toMatchObject({
status: 200,
body: { credentials: mockCredentials.user() },
});
await expect(
request(server)
.get('/api/test/protected/credentials')
.set('authorization', mockCredentials.service.header()),
).resolves.toMatchObject({
status: 200,
body: { credentials: mockCredentials.service() },
});
await expect(
request(server).get('/api/test/protected/service-token'),
).resolves.toMatchObject({
status: 200,
body: { token: '' },
});
await expect(
request(server)
.get('/api/test/protected/service-token')
.set('authorization', mockCredentials.user.header()),
).resolves.toMatchObject({
status: 200,
body: {
token: mockCredentials.service.token({
onBehalfOf: mockCredentials.user(),
targetPluginId: 'test',
}),
},
});
await expect(
request(server)
.get('/api/test/protected/service-token')
.set('authorization', mockCredentials.service.header()),
).resolves.toMatchObject({
status: 200,
body: {
token: mockCredentials.service.token({
onBehalfOf: mockCredentials.service(),
targetPluginId: 'test',
}),
},
});
});
});
});
@@ -40,18 +40,19 @@ export const httpRouterServiceFactory = createServiceFactory(
service: coreServices.httpRouter,
deps: {
plugin: coreServices.pluginMetadata,
config: coreServices.rootConfig,
lifecycle: coreServices.lifecycle,
rootHttpRouter: coreServices.rootHttpRouter,
httpAuth: coreServices.httpAuth,
},
async factory({ httpAuth, plugin, rootHttpRouter, lifecycle }) {
async factory({ httpAuth, config, plugin, rootHttpRouter, lifecycle }) {
const getPath = options?.getPath ?? (id => `/api/${id}`);
const path = getPath(plugin.getId());
const router = PromiseRouter();
rootHttpRouter.use(path, router);
const credentialsBarrier = createCredentialsBarrier({ httpAuth });
const credentialsBarrier = createCredentialsBarrier({ httpAuth, config });
router.use(createLifecycleMiddleware({ lifecycle }));
router.use(credentialsBarrier.middleware);
+4 -1
View File
@@ -128,7 +128,10 @@ export interface MockDirectoryOptions {
// @public (undocumented)
export namespace mockServices {
// (undocumented)
export function auth(options?: { pluginId?: string }): AuthService;
export function auth(options?: {
pluginId?: string;
disableDefaultAuthPolicy?: boolean;
}): AuthService;
// (undocumented)
export namespace auth {
const // (undocumented)
@@ -24,7 +24,10 @@ import {
} from './mockCredentials';
describe('MockAuthService', () => {
const auth = new MockAuthService('test');
const auth = new MockAuthService({
pluginId: 'test',
disableDefaultAuthPolicy: false,
});
it('should reject invalid tokens', async () => {
await expect(auth.authenticate('')).rejects.toThrow('Token is empty');
@@ -37,7 +37,16 @@ import {
/** @internal */
export class MockAuthService implements AuthService {
constructor(private readonly pluginId: string) {}
readonly pluginId: string;
readonly disableDefaultAuthPolicy: boolean;
constructor(options: {
pluginId: string;
disableDefaultAuthPolicy: boolean;
}) {
this.pluginId = options.pluginId;
this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy;
}
async authenticate(token: string): Promise<BackstageCredentials> {
switch (token) {
@@ -117,6 +126,10 @@ export class MockAuthService implements AuthService {
| BackstageServicePrincipal
| BackstageNonePrincipal;
if (principal.type === 'none' && this.disableDefaultAuthPolicy) {
return { token: '' };
}
if (principal.type !== 'user' && principal.type !== 'service') {
throw new AuthenticationError(
`Refused to issue service token for credential type '${principal.type}'`,
@@ -35,7 +35,10 @@ export class MockHttpAuthService implements HttpAuthService {
#defaultCredentials: BackstageCredentials;
constructor(pluginId: string, defaultCredentials: BackstageCredentials) {
this.#auth = new MockAuthService(pluginId);
this.#auth = new MockAuthService({
pluginId,
disableDefaultAuthPolicy: false,
});
this.#defaultCredentials = defaultCredentials;
}
@@ -169,14 +169,33 @@ export namespace mockServices {
}));
}
export function auth(options?: { pluginId?: string }): AuthService {
return new MockAuthService(options?.pluginId ?? 'test');
export function auth(options?: {
pluginId?: string;
disableDefaultAuthPolicy?: boolean;
}): AuthService {
return new MockAuthService({
pluginId: options?.pluginId ?? 'test',
disableDefaultAuthPolicy: Boolean(options?.disableDefaultAuthPolicy),
});
}
export namespace auth {
export const factory = createServiceFactory({
service: coreServices.auth,
deps: { plugin: coreServices.pluginMetadata },
factory: ({ plugin }) => new MockAuthService(plugin.getId()),
deps: {
plugin: coreServices.pluginMetadata,
config: coreServices.rootConfig,
},
factory({ plugin, config }) {
const disableDefaultAuthPolicy = Boolean(
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
),
);
return new MockAuthService({
pluginId: plugin.getId(),
disableDefaultAuthPolicy,
});
},
});
export const mock = simpleMock(coreServices.auth, () => ({
authenticate: jest.fn(),