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:
Vendored
+20
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+14
-1
@@ -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>();
|
||||
|
||||
+194
@@ -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',
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user