Merge pull request #24702 from backstage/freben/service-token-scopes
Implement the access restrictions feature of external access service tokens
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Added an optional `accessRestrictions` to external access service tokens and service principals in general, such that you can limit their access to certain plugins or permissions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Made it possible to give access restrictions to `mockCredentials.service`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-node': patch
|
||||
---
|
||||
|
||||
Ensure that service token access restrictions, when present, are taken into account
|
||||
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>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,11 @@ export class DefaultAuthService implements AuthService {
|
||||
|
||||
const externalResult = await this.externalTokenHandler.verifyToken(token);
|
||||
if (externalResult) {
|
||||
return createCredentialsWithServicePrincipal(externalResult.subject);
|
||||
return createCredentialsWithServicePrincipal(
|
||||
externalResult.subject,
|
||||
undefined,
|
||||
externalResult.accessRestrictions,
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,19 +39,7 @@ export const authServiceFactory = createServiceFactory({
|
||||
// new auth services in the new backend system.
|
||||
tokenManager: coreServices.tokenManager,
|
||||
},
|
||||
async createRootContext({ config, logger }) {
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
externalTokens,
|
||||
};
|
||||
},
|
||||
async factory(
|
||||
{ config, discovery, plugin, tokenManager, logger, database },
|
||||
{ externalTokens },
|
||||
) {
|
||||
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
|
||||
const disableDefaultAuthPolicy = Boolean(
|
||||
config.getOptionalBoolean(
|
||||
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
|
||||
@@ -73,6 +61,11 @@ export const authServiceFactory = createServiceFactory({
|
||||
publicKeyStore,
|
||||
discovery,
|
||||
});
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
ownPluginId: plugin.getId(),
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
|
||||
return new DefaultAuthService(
|
||||
userTokens,
|
||||
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { ExternalTokenHandler } from './ExternalTokenHandler';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
describe('ExternalTokenHandler', () => {
|
||||
it('skips over inner handlers that do not match, and applies plugin restrictions', async () => {
|
||||
const handler1: TokenHandler = {
|
||||
add: jest.fn(),
|
||||
verifyToken: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const handler2: TokenHandler = {
|
||||
add: jest.fn(),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
allAccessRestrictions: new Map(
|
||||
Object.entries({
|
||||
plugin1: {
|
||||
permissionNames: ['do.it'],
|
||||
} satisfies BackstagePrincipalAccessRestrictions,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
const plugin1 = new ExternalTokenHandler('plugin1', [handler1, handler2]);
|
||||
const plugin2 = new ExternalTokenHandler('plugin2', [handler1, handler2]);
|
||||
|
||||
await expect(plugin1.verifyToken('token')).resolves.toEqual({
|
||||
subject: 'sub',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
});
|
||||
await expect(
|
||||
plugin2.verifyToken('token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'plugin1'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Vendored
+43
-8
@@ -15,16 +15,19 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { TokenHandler } from './types';
|
||||
import { JWKSHandler } from './jwks';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
let loggedDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
@@ -34,10 +37,11 @@ const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
*/
|
||||
export class ExternalTokenHandler {
|
||||
static create(options: {
|
||||
ownPluginId: string;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
}): ExternalTokenHandler {
|
||||
const { config, logger } = options;
|
||||
const { ownPluginId, config, logger } = options;
|
||||
|
||||
const staticHandler = new StaticTokenHandler();
|
||||
const legacyHandler = new LegacyTokenHandler();
|
||||
@@ -61,12 +65,13 @@ 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
|
||||
const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
|
||||
if (legacyConfigs.length) {
|
||||
if (legacyConfigs.length && !loggedDeprecationWarning) {
|
||||
loggedDeprecationWarning = true;
|
||||
logger.warn(
|
||||
`DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
@@ -75,18 +80,48 @@ export class ExternalTokenHandler {
|
||||
legacyHandler.addOld(handlerConfig);
|
||||
}
|
||||
|
||||
return new ExternalTokenHandler(Object.values(handlers));
|
||||
return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
|
||||
}
|
||||
|
||||
constructor(private readonly handlers: TokenHandler[]) {}
|
||||
constructor(
|
||||
private readonly ownPluginId: string,
|
||||
private readonly handlers: TokenHandler[],
|
||||
) {}
|
||||
|
||||
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
|
||||
async verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
for (const handler of this.handlers) {
|
||||
const result = await handler.verifyToken(token);
|
||||
if (result) {
|
||||
return result;
|
||||
const { allAccessRestrictions, ...rest } = result;
|
||||
if (allAccessRestrictions) {
|
||||
const accessRestrictions = allAccessRestrictions.get(
|
||||
this.ownPluginId,
|
||||
);
|
||||
if (!accessRestrictions) {
|
||||
const valid = [...allAccessRestrictions.keys()]
|
||||
.map(k => `'${k}'`)
|
||||
.join(', ');
|
||||
throw new NotAllowedError(
|
||||
`This token's access is restricted to plugin(s) ${valid}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
accessRestrictions,
|
||||
};
|
||||
}
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+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',
|
||||
allAccessRestrictions: accessRestrictions1,
|
||||
});
|
||||
|
||||
const token2 = await new SignJWT({
|
||||
@@ -65,6 +84,7 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
allAccessRestrictions: 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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-13
@@ -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,21 +25,38 @@ import { TokenHandler } from './types';
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ key: Uint8Array; subject: string }> = [];
|
||||
#entries = new Array<{
|
||||
key: Uint8Array;
|
||||
result: {
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
};
|
||||
}>();
|
||||
|
||||
add(options: Config) {
|
||||
this.#doAdd(options.getString('secret'), options.getString('subject'));
|
||||
add(config: Config) {
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
this.#doAdd(
|
||||
config.getString('options.secret'),
|
||||
config.getString('options.subject'),
|
||||
allAccessRestrictions,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
allAccessRestrictions?: AccessRestriptionsMap,
|
||||
) {
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
let key: Uint8Array;
|
||||
@@ -48,11 +66,19 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
if (this.#entries.some(e => e.key === key)) {
|
||||
throw new Error(
|
||||
'Legacy externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.push({ key, subject });
|
||||
this.#entries.push({
|
||||
key,
|
||||
result: {
|
||||
subject,
|
||||
allAccessRestrictions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
@@ -76,10 +102,10 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const entry of this.#entries) {
|
||||
for (const { key, result } of this.#entries) {
|
||||
try {
|
||||
await jwtVerify(token, entry.key);
|
||||
return { subject: entry.subject };
|
||||
await jwtVerify(token, key);
|
||||
return result;
|
||||
} 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',
|
||||
allAccessRestrictions: accessRestrictionsOne,
|
||||
});
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'two',
|
||||
allAccessRestrictions: 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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-17
@@ -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,33 +26,37 @@ const MIN_TOKEN_LENGTH = 8;
|
||||
* @internal
|
||||
*/
|
||||
export class StaticTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ token: string; subject: string }> = [];
|
||||
#entries = new Map<
|
||||
string,
|
||||
{
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
>();
|
||||
|
||||
add(config: Config) {
|
||||
const token = config.getString('options.token');
|
||||
const subject = config.getString('options.subject');
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
|
||||
add(options: Config) {
|
||||
const token = options.getString('token');
|
||||
if (!token.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
}
|
||||
if (token.length < MIN_TOKEN_LENGTH) {
|
||||
} else if (token.length < MIN_TOKEN_LENGTH) {
|
||||
throw new Error(
|
||||
`Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`,
|
||||
);
|
||||
}
|
||||
|
||||
const subject = options.getString('subject');
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
} else if (this.#entries.has(token)) {
|
||||
throw new Error(
|
||||
'Static externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.push({ token, subject });
|
||||
this.#entries.set(token, { subject, allAccessRestrictions });
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
const entry = this.#entries.find(e => e.token === token);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { subject: entry.subject };
|
||||
return this.#entries.get(token);
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
allAccessRestrictions?: 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isChildPath } from '@backstage/cli-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Knex } from 'knex';
|
||||
import { PermissionAttributes } from '@backstage/plugin-permission-common';
|
||||
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
|
||||
import { QueryPermissionRequest } from '@backstage/plugin-permission-common';
|
||||
import { QueryPermissionResponse } from '@backstage/plugin-permission-common';
|
||||
@@ -136,6 +137,14 @@ export type BackstageNonePrincipal = {
|
||||
type: 'none';
|
||||
};
|
||||
|
||||
// @public
|
||||
export type BackstagePrincipalAccessRestrictions = {
|
||||
permissionNames?: string[];
|
||||
permissionAttributes?: {
|
||||
action?: Array<Required<PermissionAttributes>['action']>;
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type BackstagePrincipalTypes = {
|
||||
user: BackstageUserPrincipal;
|
||||
@@ -148,6 +157,7 @@ export type BackstagePrincipalTypes = {
|
||||
export type BackstageServicePrincipal = {
|
||||
type: 'service';
|
||||
subject: string;
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PermissionAttributes } from '@backstage/plugin-permission-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,54 @@ export type BackstageServicePrincipal = {
|
||||
|
||||
// Exact format TBD, possibly 'plugin:<pluginId>' or 'external:<externalServiceId>'
|
||||
subject: string;
|
||||
|
||||
/**
|
||||
* The access restrictions that apply to this principal.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If no access restrictions are provided the principal is assumed to have
|
||||
* unlimited access, at a framework level. The permissions system and
|
||||
* individual plugins may or may not still apply additional access controls on
|
||||
* top of this.
|
||||
*/
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
};
|
||||
|
||||
/**
|
||||
* The access restrictions that apply to a given principal.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BackstagePrincipalAccessRestrictions = {
|
||||
/**
|
||||
* If given, the principal is limited to only performing actions with these
|
||||
* named permissions.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* This array always has at least one element, or is missing entirely.
|
||||
*/
|
||||
permissionNames?: string[];
|
||||
/**
|
||||
* If given, the principal 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.
|
||||
*
|
||||
* This object always has at least one key, or is missing entirely.
|
||||
*/
|
||||
permissionAttributes?: {
|
||||
/**
|
||||
* Match any of these action values. This array always has at least one
|
||||
* element, or is missing entirely.
|
||||
*/
|
||||
action?: Array<Required<PermissionAttributes>['action']>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
BackstageCredentials,
|
||||
BackstageUserPrincipal,
|
||||
BackstageServicePrincipal,
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
BackstagePrincipalTypes,
|
||||
BackstageNonePrincipal,
|
||||
} from './AuthService';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Backend } from '@backstage/backend-app-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BackstageCredentials } from '@backstage/backend-plugin-api';
|
||||
import { BackstageNonePrincipal } from '@backstage/backend-plugin-api';
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { BackstageServicePrincipal } from '@backstage/backend-plugin-api';
|
||||
import { BackstageUserInfo } from '@backstage/backend-plugin-api';
|
||||
import { BackstageUserPrincipal } from '@backstage/backend-plugin-api';
|
||||
@@ -68,6 +69,7 @@ export namespace mockCredentials {
|
||||
}
|
||||
export function service(
|
||||
subject?: string,
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions,
|
||||
): BackstageCredentials<BackstageServicePrincipal>;
|
||||
export namespace service {
|
||||
export function header(options?: TokenOptions): string;
|
||||
|
||||
@@ -134,6 +134,16 @@ describe('mockCredentials', () => {
|
||||
expect(mockCredentials.service.invalidHeader()).toBe(
|
||||
'Bearer mock-invalid-service-token',
|
||||
);
|
||||
expect(
|
||||
mockCredentials.service('test', { permissionNames: ['do.it'] }),
|
||||
).toEqual({
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: 'test',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on invalid user entity refs', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -202,14 +203,19 @@ export namespace mockCredentials {
|
||||
/**
|
||||
* Creates a mocked credentials object for a service principal.
|
||||
*
|
||||
* The default subject is 'external:test-service'.
|
||||
* The default subject is 'external:test-service', and no access restrictions.
|
||||
*/
|
||||
export function service(
|
||||
subject: string = DEFAULT_MOCK_SERVICE_SUBJECT,
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions,
|
||||
): BackstageCredentials<BackstageServicePrincipal> {
|
||||
return {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
principal: { type: 'service', subject },
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject,
|
||||
...(accessRestrictions ? { accessRestrictions } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ const discovery: PluginEndpointDiscovery = {
|
||||
};
|
||||
const testBasicPermission = createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
});
|
||||
|
||||
const testResourcePermission = createPermission({
|
||||
@@ -362,4 +364,244 @@ describe('ServerPermissionClient', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with access restrictions', () => {
|
||||
it.each([{ enabled: true }, { enabled: false }])(
|
||||
'short circuits the response when using a service principal, applying the relevant access restrictions if present, when permissions %p',
|
||||
async permissionConfig => {
|
||||
const client = ServerPermissionClient.fromConfig(
|
||||
new ConfigReader({
|
||||
permission: permissionConfig,
|
||||
}),
|
||||
{
|
||||
discovery,
|
||||
tokenManager: mockServices.tokenManager(),
|
||||
auth: mockServices.auth(),
|
||||
},
|
||||
);
|
||||
|
||||
// no restrictions for the given plugin
|
||||
await expect(
|
||||
client.authorize(
|
||||
[
|
||||
{
|
||||
permission: createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
await expect(
|
||||
client.authorizeConditional(
|
||||
[
|
||||
{
|
||||
resourceRef: undefined as any,
|
||||
permission: createPermission({
|
||||
resourceType: 'test',
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching permission name
|
||||
await expect(
|
||||
client.authorize(
|
||||
[
|
||||
{
|
||||
permission: createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['test.permission', 'other'],
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
await expect(
|
||||
client.authorizeConditional(
|
||||
[
|
||||
{
|
||||
resourceRef: undefined as any,
|
||||
permission: createPermission({
|
||||
resourceType: 'test',
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['test.permission', 'other'],
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching attributes
|
||||
await expect(
|
||||
client.authorize(
|
||||
[
|
||||
{
|
||||
permission: createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionAttributes: {
|
||||
action: ['create', 'other' as any],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
await expect(
|
||||
client.authorizeConditional(
|
||||
[
|
||||
{
|
||||
resourceRef: undefined as any,
|
||||
permission: createPermission({
|
||||
resourceType: 'test',
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionAttributes: {
|
||||
action: ['create', 'other' as any],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching permission name but not attributes
|
||||
await expect(
|
||||
client.authorize(
|
||||
[
|
||||
{
|
||||
permission: createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['test.permission'],
|
||||
permissionAttributes: {
|
||||
action: ['other' as any],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
await expect(
|
||||
client.authorizeConditional(
|
||||
[
|
||||
{
|
||||
resourceRef: undefined as any,
|
||||
permission: createPermission({
|
||||
resourceType: 'test',
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['test.permission'],
|
||||
permissionAttributes: {
|
||||
action: ['other' as any],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
|
||||
// matching attributes but not permission name
|
||||
await expect(
|
||||
client.authorize(
|
||||
[
|
||||
{
|
||||
permission: createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['wrong-name'],
|
||||
permissionAttributes: {
|
||||
action: ['create'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
await expect(
|
||||
client.authorizeConditional(
|
||||
[
|
||||
{
|
||||
resourceRef: undefined as any,
|
||||
permission: createPermission({
|
||||
resourceType: 'test',
|
||||
name: 'test.permission',
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
{
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['wrong-name'],
|
||||
permissionAttributes: {
|
||||
action: ['create'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,16 +21,19 @@ import {
|
||||
import {
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
BackstageServicePrincipal,
|
||||
DiscoveryService,
|
||||
PermissionsService,
|
||||
PermissionsServiceRequestOptions,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionClient,
|
||||
AuthorizePermissionRequest,
|
||||
AuthorizePermissionResponse,
|
||||
AuthorizeResult,
|
||||
DefinitivePolicyDecision,
|
||||
Permission,
|
||||
PermissionClient,
|
||||
PolicyDecision,
|
||||
QueryPermissionRequest,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
@@ -91,31 +94,39 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
queries: QueryPermissionRequest[],
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): Promise<PolicyDecision[]> {
|
||||
if (await this.#shouldPermissionsBeApplied(options)) {
|
||||
return this.#permissionClient.authorizeConditional(
|
||||
queries,
|
||||
await this.#getRequestOptions(options),
|
||||
);
|
||||
const credentials = await this.#getIncomingCredentials(options);
|
||||
if (credentials && this.#auth.isPrincipal(credentials, 'service')) {
|
||||
return this.#servicePrincipalDecision(queries, credentials);
|
||||
} else if (!this.#permissionEnabled) {
|
||||
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
}
|
||||
|
||||
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
return this.#permissionClient.authorizeConditional(
|
||||
queries,
|
||||
await this.#getRequestOptions(options),
|
||||
);
|
||||
}
|
||||
|
||||
async authorize(
|
||||
requests: AuthorizePermissionRequest[],
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): Promise<AuthorizePermissionResponse[]> {
|
||||
if (await this.#shouldPermissionsBeApplied(options)) {
|
||||
return this.#permissionClient.authorize(
|
||||
requests,
|
||||
await this.#getRequestOptions(options),
|
||||
);
|
||||
const credentials = await this.#getIncomingCredentials(options);
|
||||
if (credentials && this.#auth.isPrincipal(credentials, 'service')) {
|
||||
return this.#servicePrincipalDecision(requests, credentials);
|
||||
} else if (!this.#permissionEnabled) {
|
||||
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
}
|
||||
|
||||
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
return this.#permissionClient.authorize(
|
||||
requests,
|
||||
await this.#getRequestOptions(options),
|
||||
);
|
||||
}
|
||||
|
||||
async #getRequestOptions(options?: PermissionsServiceRequestOptions) {
|
||||
async #getRequestOptions(
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): Promise<{ token?: string } | undefined> {
|
||||
if (options && 'credentials' in options) {
|
||||
if (this.#auth.isPrincipal(options.credentials, 'none')) {
|
||||
return {};
|
||||
@@ -130,30 +141,48 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
return options;
|
||||
}
|
||||
|
||||
async #shouldPermissionsBeApplied(
|
||||
async #getIncomingCredentials(
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
) {
|
||||
if (!this.#permissionEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let credentials: BackstageCredentials;
|
||||
): Promise<BackstageCredentials | undefined> {
|
||||
if (options && 'credentials' in options) {
|
||||
credentials = options.credentials;
|
||||
} else {
|
||||
if (!options?.token) {
|
||||
return true;
|
||||
}
|
||||
return options.credentials;
|
||||
}
|
||||
|
||||
if (options?.token) {
|
||||
try {
|
||||
credentials = await this.#auth.authenticate(options.token);
|
||||
return await this.#auth.authenticate(options.token);
|
||||
} catch {
|
||||
return true;
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#auth.isPrincipal(credentials, 'service')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* For service principals, we can always make an immediate definitive decision
|
||||
* based on their associated access restrictions (if any).
|
||||
*/
|
||||
#servicePrincipalDecision(
|
||||
input: { permission: Permission }[],
|
||||
credentials: BackstageCredentials<BackstageServicePrincipal>,
|
||||
): DefinitivePolicyDecision[] {
|
||||
const { permissionNames, permissionAttributes } =
|
||||
credentials.principal.accessRestrictions ?? {};
|
||||
|
||||
return input.map(item => {
|
||||
if (permissionNames && !permissionNames.includes(item.permission.name)) {
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}
|
||||
|
||||
if (permissionAttributes?.action) {
|
||||
const action = item.permission.attributes?.action;
|
||||
if (!action || !permissionAttributes.action.includes(action)) {
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}
|
||||
}
|
||||
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user