Implement the scope feature of external access service tokens, as per BEP-0007
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Vendored
+80
@@ -88,6 +88,46 @@ export interface Config {
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -130,6 +170,46 @@ export interface Config {
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { AuthenticationError, ForwardedError } from '@backstage/errors';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ForwardedError,
|
||||
NotAllowedError,
|
||||
} from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
|
||||
@@ -82,7 +86,21 @@ export class DefaultAuthService implements AuthService {
|
||||
|
||||
const externalResult = await this.externalTokenHandler.verifyToken(token);
|
||||
if (externalResult) {
|
||||
return createCredentialsWithServicePrincipal(externalResult.subject);
|
||||
const restrictions = externalResult.accessRestrictions;
|
||||
if (restrictions) {
|
||||
if (!restrictions.has(this.pluginId)) {
|
||||
const valid = [...restrictions.keys()].map(k => `'${k}'`).join(', ');
|
||||
throw new NotAllowedError(
|
||||
`This token's access is restricted to plugin(s) ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return createCredentialsWithServicePrincipal(
|
||||
externalResult.subject,
|
||||
undefined,
|
||||
restrictions?.get(this.pluginId),
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Illegal token');
|
||||
|
||||
+59
-1
@@ -42,7 +42,26 @@ const mockDeps = [
|
||||
data: {
|
||||
backend: {
|
||||
baseUrl: 'http://localhost',
|
||||
auth: { keys: [{ secret: 'abc' }] },
|
||||
auth: {
|
||||
keys: [{ secret: 'abc' }],
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'limited-static-token',
|
||||
subject: 'limited-static-subject',
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'catalog', permission: 'do.it' }],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'unlimited-static-token',
|
||||
subject: 'unlimited-static-subject',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -385,4 +404,43 @@ describe('authServiceFactory', () => {
|
||||
"Unable to call 'kubernetes' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist",
|
||||
);
|
||||
});
|
||||
|
||||
it('should eagerly reject access to external access tokens based on plugin id', async () => {
|
||||
const tester = ServiceFactoryTester.from(authServiceFactory, {
|
||||
dependencies: mockDeps,
|
||||
});
|
||||
|
||||
const catalogAuth = await tester.get('catalog');
|
||||
|
||||
await expect(
|
||||
catalogAuth.authenticate('limited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: {
|
||||
subject: 'limited-static-subject',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalogAuth.authenticate('unlimited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: {
|
||||
subject: 'unlimited-static-subject',
|
||||
},
|
||||
});
|
||||
|
||||
const scaffolderAuth = await tester.get('scaffolder');
|
||||
|
||||
await expect(
|
||||
scaffolderAuth.authenticate('limited-static-token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'catalog'"`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
scaffolderAuth.authenticate('unlimited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: { subject: 'unlimited-static-subject' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+9
-3
@@ -20,8 +20,8 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { TokenHandler } from './types';
|
||||
import { JWKSHandler } from './jwks';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
@@ -61,7 +61,7 @@ export class ExternalTokenHandler {
|
||||
`Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
handler.add(handlerConfig.getConfig('options'));
|
||||
handler.add(handlerConfig);
|
||||
}
|
||||
|
||||
// Load the old keys too
|
||||
@@ -80,7 +80,13 @@ export class ExternalTokenHandler {
|
||||
|
||||
constructor(private readonly handlers: TokenHandler[]) {}
|
||||
|
||||
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
|
||||
async verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
for (const handler of this.handlers) {
|
||||
const result = await handler.verifyToken(token);
|
||||
if (result) {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2024 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 { ConfigReader } from '@backstage/config';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
describe('readAccessRestrictionsFromConfig', () => {
|
||||
function r(config: JsonObject) {
|
||||
return readAccessRestrictionsFromConfig(new ConfigReader(config));
|
||||
}
|
||||
|
||||
it('handles empty / missing restrictions', () => {
|
||||
expect(r({})).toBeUndefined();
|
||||
expect(r({ accessRestrictions: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles type errors', () => {
|
||||
expect(() =>
|
||||
r({ accessRestrictions: 7 }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions' in 'mock-config', got number, wanted object-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: ['hello'] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0]' in 'mock-config', got string, wanted object-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ unknown: {} }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid key 'unknown' in 'accessRestrictions' config, expected one of 'plugin', 'permission', 'permissionAttribute'"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permission: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permission' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permission: [7] }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permission[0]' in 'mock-config', got number, wanted string-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permissionAttribute: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permissionAttribute' in 'mock-config', got number, wanted object"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { a: [] } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid key 'a' in 'permissionAttribute' config, expected 'action'"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { action: 7 } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permissionAttribute.action' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { action: 'wrong' } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid value 'wrong' at 'action' in 'permissionAttributes' config, valid values are 'create', 'read', 'update', 'delete'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('parses valid access restrictions', () => {
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permission: 'a, b a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionNames: ['a', 'b'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permission: ['a', 'b', 'a'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionNames: ['a', 'b'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permissionAttribute: { action: 'read, update read' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionAttributes: { action: ['read', 'update'] } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permissionAttribute: { action: ['read', 'update', 'read'] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionAttributes: { action: ['read', 'update'] } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2024 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 { Config } from '@backstage/config';
|
||||
import { AccessRestriptionsMap } from './types';
|
||||
|
||||
/**
|
||||
* Parses and returns the `accessRestrictions` configuration from an
|
||||
* `externalAccess` entry, or undefined if there wasn't one.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function readAccessRestrictionsFromConfig(
|
||||
externalAccessEntryConfig: Config,
|
||||
): AccessRestriptionsMap | undefined {
|
||||
const configs =
|
||||
externalAccessEntryConfig.getOptionalConfigArray('accessRestrictions') ??
|
||||
[];
|
||||
|
||||
const result: AccessRestriptionsMap = new Map();
|
||||
for (const config of configs) {
|
||||
const validKeys = ['plugin', 'permission', 'permissionAttribute'];
|
||||
for (const key of config.keys()) {
|
||||
if (!validKeys.includes(key)) {
|
||||
const valid = validKeys.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pluginId = config.getString('plugin');
|
||||
const permissionNames = readPermissionNames(config);
|
||||
const permissionAttributes = readPermissionAttributes(config);
|
||||
|
||||
if (result.has(pluginId)) {
|
||||
throw new Error(
|
||||
`Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`,
|
||||
);
|
||||
}
|
||||
|
||||
result.set(pluginId, {
|
||||
...(permissionNames ? { permissionNames } : {}),
|
||||
...(permissionAttributes ? { permissionAttributes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result.size ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a config value as a string or an array of strings, and deduplicates and
|
||||
* splits by comma/space into a string array. Can also validate against a known
|
||||
* set of values. Returns undefined if the key didn't exist or if the array
|
||||
* would have ended up being empty.
|
||||
*/
|
||||
function stringOrStringArray<T extends string>(
|
||||
root: Config,
|
||||
key: string,
|
||||
validValues?: readonly T[],
|
||||
): T[] | undefined {
|
||||
if (!root.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawValues = Array.isArray(root.get(key))
|
||||
? root.getStringArray(key)
|
||||
: [root.getString(key)];
|
||||
|
||||
const values = [
|
||||
...new Set(
|
||||
rawValues
|
||||
.map(v => v.split(/[ ,]/))
|
||||
.flat()
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
if (!values.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validValues?.length) {
|
||||
for (const value of values) {
|
||||
if (!validValues.includes(value as T)) {
|
||||
const valid = validValues.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values as T[];
|
||||
}
|
||||
|
||||
function readPermissionNames(externalAccessEntryConfig: Config) {
|
||||
return stringOrStringArray(externalAccessEntryConfig, 'permission');
|
||||
}
|
||||
|
||||
function readPermissionAttributes(externalAccessEntryConfig: Config) {
|
||||
const config = externalAccessEntryConfig.getOptionalConfig(
|
||||
'permissionAttribute',
|
||||
);
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validKeys = ['action'];
|
||||
for (const key of config.keys()) {
|
||||
if (!validKeys.includes(key)) {
|
||||
const valid = validKeys.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const action = stringOrStringArray(config, 'action', [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
|
||||
const result = {
|
||||
...(action ? { action } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
+117
-33
@@ -25,17 +25,35 @@ describe('LegacyTokenHandler', () => {
|
||||
const key1 = randomBytes(24);
|
||||
const key2 = randomBytes(24);
|
||||
const key3 = randomBytes(24);
|
||||
const accessRestrictions1 = new Map(
|
||||
Object.entries({
|
||||
scaffolder: {},
|
||||
}),
|
||||
);
|
||||
const accessRestrictions2 = new Map(
|
||||
Object.entries({
|
||||
catalog: { permissionNames: ['catalog.entity.read'] },
|
||||
}),
|
||||
);
|
||||
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
options: {
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
}),
|
||||
);
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
options: {
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
tokenHandler.addOld(
|
||||
@@ -54,6 +72,7 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({
|
||||
subject: 'key1',
|
||||
accessRestrictions: accessRestrictions1,
|
||||
});
|
||||
|
||||
const token2 = await new SignJWT({
|
||||
@@ -65,6 +84,7 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
accessRestrictions: accessRestrictions2,
|
||||
});
|
||||
|
||||
const token3 = await new SignJWT({
|
||||
@@ -147,39 +167,93 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
// new style add, bad secrets
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingsecret: true, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({ options: { _missingsecret: true, subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: '', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(new ConfigReader({ options: { secret: '', subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'has spaces', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({ options: { secret: 'has spaces', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'hasnewline\n', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'hasnewline\n', subject: 'ok' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 3, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(new ConfigReader({ options: { secret: 3, subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// new style add, bad subjects
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', _missingsubject: true })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: '' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 'has spaces' })),
|
||||
).toThrow(/subject/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', _missingsubject: true },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ secret: 'b2s=', subject: 'hasnewline\n' }),
|
||||
new ConfigReader({ options: { secret: 'b2s=', subject: '' } }),
|
||||
),
|
||||
).toThrow(/subject/);
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 3 })),
|
||||
).toThrow(/subject/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'has spaces' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'hasnewline\n' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ options: { secret: 'b2s=', subject: 3 } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// new style add, bad access restrictions
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'subject' },
|
||||
accessRestrictions: [{ plugin: ['a'] }],
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got array, wanted string"`,
|
||||
);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
@@ -187,18 +261,28 @@ describe('LegacyTokenHandler', () => {
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ _missingsecret: true })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: '' }))).toThrow(
|
||||
/secret/,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: '' })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'has spaces' })),
|
||||
).toThrow(/secret/);
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: 3 }))).toThrow(
|
||||
/secret/,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 3 })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-9
@@ -16,7 +16,8 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import { TokenHandler } from './types';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: legacy` access.
|
||||
@@ -24,19 +25,32 @@ import { TokenHandler } from './types';
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ key: Uint8Array; subject: string }> = [];
|
||||
#entries = new Array<{
|
||||
key: Uint8Array;
|
||||
subject: string;
|
||||
accessRestrictions?: AccessRestriptionsMap;
|
||||
}>();
|
||||
|
||||
add(options: Config) {
|
||||
this.#doAdd(options.getString('secret'), options.getString('subject'));
|
||||
add(config: Config) {
|
||||
const accessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
this.#doAdd(
|
||||
config.getString('options.secret'),
|
||||
config.getString('options.subject'),
|
||||
accessRestrictions,
|
||||
);
|
||||
}
|
||||
|
||||
// used only for the old backend.auth.keys array
|
||||
addOld(options: Config) {
|
||||
addOld(config: Config) {
|
||||
// This choice of subject is for compatibility reasons
|
||||
this.#doAdd(options.getString('secret'), 'external:backstage-plugin');
|
||||
this.#doAdd(config.getString('secret'), 'external:backstage-plugin');
|
||||
}
|
||||
|
||||
#doAdd(secret: string, subject: string) {
|
||||
#doAdd(
|
||||
secret: string,
|
||||
subject: string,
|
||||
accessRestrictions?: AccessRestriptionsMap,
|
||||
) {
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
@@ -52,7 +66,11 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
this.#entries.push({ key, subject });
|
||||
this.#entries.push({
|
||||
key,
|
||||
subject,
|
||||
accessRestrictions,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
@@ -79,7 +97,10 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
for (const entry of this.#entries) {
|
||||
try {
|
||||
await jwtVerify(token, entry.key);
|
||||
return { subject: entry.subject };
|
||||
return {
|
||||
subject: entry.subject,
|
||||
accessRestrictions: entry.accessRestrictions,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw e;
|
||||
|
||||
+63
-23
@@ -20,14 +20,36 @@ import { StaticTokenHandler } from './static';
|
||||
describe('StaticTokenHandler', () => {
|
||||
it('accepts any of the added list of tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
handler.add(new ConfigReader({ token: 'abcabcabc', subject: 'one' }));
|
||||
handler.add(new ConfigReader({ token: 'defdefdef', subject: 'two' }));
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'abcabcabc', subject: 'one' },
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
}),
|
||||
);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'defdefdef', subject: 'two' },
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const accessRestrictionsOne = new Map(Object.entries({ scaffolder: {} }));
|
||||
const accessRestrictionsTwo = new Map(
|
||||
Object.entries({
|
||||
catalog: {
|
||||
permissionNames: ['catalog.entity.read'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(handler.verifyToken('abcabcabc')).resolves.toEqual({
|
||||
subject: 'one',
|
||||
accessRestrictions: accessRestrictionsOne,
|
||||
});
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'two',
|
||||
accessRestrictions: accessRestrictionsTwo,
|
||||
});
|
||||
await expect(handler.verifyToken('ghighighi')).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -41,71 +63,89 @@ describe('StaticTokenHandler', () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingtoken: true, subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'token' in 'mock-config'"`,
|
||||
`"Missing required config value at 'options.token' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: '', subject: 'ok' })),
|
||||
handler.add(new ConfigReader({ options: { token: '', subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'has spaces', subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'has spaces', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
token: 'hasnewlinebutislongenough\n',
|
||||
subject: 'ok',
|
||||
options: {
|
||||
token: 'hasnewlinebutislongenough\n',
|
||||
subject: 'ok',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'short', subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'short', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be at least 8 characters length"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 3, subject: 'ok' })),
|
||||
handler.add(new ConfigReader({ options: { token: 3, subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', _missingsubject: true }),
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', _missingsubject: true },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'validtoken', subject: '' })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', subject: 'has spaces' }),
|
||||
new ConfigReader({ options: { token: 'validtoken', subject: '' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', subject: 'has spaces' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', subject: 'hasnewline\n' }),
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', subject: 'hasnewline\n' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'validtoken', subject: 3 })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'validtoken', subject: 3 } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-7
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { TokenHandler } from './types';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
const MIN_TOKEN_LENGTH = 8;
|
||||
|
||||
@@ -25,10 +26,14 @@ const MIN_TOKEN_LENGTH = 8;
|
||||
* @internal
|
||||
*/
|
||||
export class StaticTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ token: string; subject: string }> = [];
|
||||
#entries = new Array<{
|
||||
token: string;
|
||||
subject: string;
|
||||
accessRestrictions?: AccessRestriptionsMap;
|
||||
}>();
|
||||
|
||||
add(options: Config) {
|
||||
const token = options.getString('token');
|
||||
add(config: Config) {
|
||||
const token = config.getString('options.token');
|
||||
if (!token.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
}
|
||||
@@ -38,12 +43,18 @@ export class StaticTokenHandler implements TokenHandler {
|
||||
);
|
||||
}
|
||||
|
||||
const subject = options.getString('subject');
|
||||
const subject = config.getString('options.subject');
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
this.#entries.push({ token, subject });
|
||||
const accessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
|
||||
this.#entries.push({
|
||||
token,
|
||||
subject,
|
||||
accessRestrictions,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
@@ -52,6 +63,9 @@ export class StaticTokenHandler implements TokenHandler {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { subject: entry.subject };
|
||||
return {
|
||||
subject: entry.subject,
|
||||
accessRestrictions: entry.accessRestrictions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -14,9 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
export type AccessRestriptionsMap = Map<
|
||||
string, // plugin ID
|
||||
BackstagePrincipalAccessRestrictions
|
||||
>;
|
||||
|
||||
export interface TokenHandler {
|
||||
add(options: Config): void;
|
||||
verifyToken(token: string): Promise<{ subject: string } | undefined>;
|
||||
verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -25,6 +26,7 @@ import { InternalBackstageCredentials } from './types';
|
||||
export function createCredentialsWithServicePrincipal(
|
||||
sub: string,
|
||||
token?: string,
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions,
|
||||
): InternalBackstageCredentials<BackstageServicePrincipal> {
|
||||
return {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
@@ -33,6 +35,7 @@ export function createCredentialsWithServicePrincipal(
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: sub,
|
||||
accessRestrictions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+3
-1
@@ -31,12 +31,14 @@ export const permissionsServiceFactory = createServiceFactory({
|
||||
config: coreServices.rootConfig,
|
||||
discovery: coreServices.discovery,
|
||||
tokenManager: coreServices.tokenManager,
|
||||
pluginMetadata: coreServices.pluginMetadata,
|
||||
},
|
||||
async factory({ auth, config, discovery, tokenManager }) {
|
||||
async factory({ auth, config, discovery, tokenManager, pluginMetadata }) {
|
||||
return ServerPermissionClient.fromConfig(config, {
|
||||
auth,
|
||||
discovery,
|
||||
tokenManager,
|
||||
pluginId: pluginMetadata.getId(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user