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:
@@ -291,6 +291,7 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
discovery: DiscoveryService;
|
||||
tokenManager: TokenManager;
|
||||
auth?: AuthService;
|
||||
pluginId?: string;
|
||||
},
|
||||
): ServerPermissionClient;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ const discovery: PluginEndpointDiscovery = {
|
||||
};
|
||||
const testBasicPermission = createPermission({
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
attributes: {
|
||||
action: 'create',
|
||||
},
|
||||
});
|
||||
|
||||
const testResourcePermission = createPermission({
|
||||
@@ -362,4 +364,66 @@ describe('ServerPermissionClient', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with access restrictions', () => {
|
||||
it('short circuits the response when relevant access restrictions are present', async () => {
|
||||
const client = ServerPermissionClient.fromConfig(config, {
|
||||
discovery,
|
||||
tokenManager: mockServices.tokenManager(),
|
||||
auth: mockServices.auth(),
|
||||
pluginId: 'test',
|
||||
});
|
||||
|
||||
// no restrictions for the given plugin
|
||||
await expect(
|
||||
client.authorize([{ permission: testBasicPermission }], {
|
||||
credentials: mockCredentials.service('foo', {}),
|
||||
}),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching permission name
|
||||
await expect(
|
||||
client.authorize([{ permission: testBasicPermission }], {
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: [testBasicPermission.name, 'other'],
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching attributes
|
||||
await expect(
|
||||
client.authorize([{ permission: testBasicPermission }], {
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionAttributes: {
|
||||
action: [testBasicPermission.attributes.action!, 'other' as any],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.ALLOW }]);
|
||||
|
||||
// matching permission name but not attributes
|
||||
await expect(
|
||||
client.authorize([{ permission: testBasicPermission }], {
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: [testBasicPermission.name],
|
||||
permissionAttributes: {
|
||||
action: ['other' as any],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
|
||||
// matching attributes but not permission name
|
||||
await expect(
|
||||
client.authorize([{ permission: testBasicPermission }], {
|
||||
credentials: mockCredentials.service('foo', {
|
||||
permissionNames: ['wrong-name'],
|
||||
permissionAttributes: {
|
||||
action: [testBasicPermission.attributes.action!],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual([{ result: AuthorizeResult.DENY }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,18 +33,21 @@ import {
|
||||
AuthorizePermissionResponse,
|
||||
PolicyDecision,
|
||||
QueryPermissionRequest,
|
||||
DefinitivePolicyDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
* A thin wrapper around
|
||||
* {@link @backstage/plugin-permission-common#PermissionClient} that allows all
|
||||
* service-to-service requests.
|
||||
* {@link @backstage/plugin-permission-common#PermissionClient} that ensures the
|
||||
* proper short-circuit handling of service principals.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class ServerPermissionClient implements PermissionsService {
|
||||
readonly #auth: AuthService;
|
||||
readonly #permissionClient: PermissionClient;
|
||||
readonly #permissionEnabled: boolean;
|
||||
readonly #pluginId?: string;
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
@@ -52,6 +55,7 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
discovery: DiscoveryService;
|
||||
tokenManager: TokenManager;
|
||||
auth?: AuthService;
|
||||
pluginId?: string;
|
||||
},
|
||||
) {
|
||||
const { discovery, tokenManager } = options;
|
||||
@@ -74,6 +78,7 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
auth,
|
||||
permissionClient,
|
||||
permissionEnabled,
|
||||
pluginId: options.pluginId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,16 +86,26 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
auth: AuthService;
|
||||
permissionClient: PermissionClient;
|
||||
permissionEnabled: boolean;
|
||||
pluginId?: string;
|
||||
}) {
|
||||
this.#auth = options.auth;
|
||||
this.#permissionClient = options.permissionClient;
|
||||
this.#permissionEnabled = options.permissionEnabled;
|
||||
this.#pluginId = options.pluginId;
|
||||
}
|
||||
|
||||
async authorizeConditional(
|
||||
queries: QueryPermissionRequest[],
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): Promise<PolicyDecision[]> {
|
||||
const maybeResponse = this.#decideBasedOnPrincipalAccessRestrictions(
|
||||
queries,
|
||||
options,
|
||||
);
|
||||
if (maybeResponse) {
|
||||
return maybeResponse;
|
||||
}
|
||||
|
||||
if (await this.#shouldPermissionsBeApplied(options)) {
|
||||
return this.#permissionClient.authorizeConditional(
|
||||
queries,
|
||||
@@ -105,6 +120,14 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
requests: AuthorizePermissionRequest[],
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): Promise<AuthorizePermissionResponse[]> {
|
||||
const maybeResponse = this.#decideBasedOnPrincipalAccessRestrictions(
|
||||
requests,
|
||||
options,
|
||||
);
|
||||
if (maybeResponse) {
|
||||
return maybeResponse;
|
||||
}
|
||||
|
||||
if (await this.#shouldPermissionsBeApplied(options)) {
|
||||
return this.#permissionClient.authorize(
|
||||
requests,
|
||||
@@ -130,6 +153,44 @@ export class ServerPermissionClient implements PermissionsService {
|
||||
return options;
|
||||
}
|
||||
|
||||
#decideBasedOnPrincipalAccessRestrictions(
|
||||
requests: Array<QueryPermissionRequest | AuthorizePermissionRequest>,
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
): DefinitivePolicyDecision[] | undefined {
|
||||
if (!options || !('credentials' in options)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Bail out to the old behavior if
|
||||
// - the principal is not a service
|
||||
// - the principal was apparently unrestricted
|
||||
// - we are in legacy mode because nobody passed in a plugin ID
|
||||
const credentials = options.credentials;
|
||||
if (
|
||||
!this.#auth.isPrincipal(credentials, 'service') ||
|
||||
!credentials.principal.accessRestrictions ||
|
||||
!this.#pluginId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { permissionNames, permissionAttributes } =
|
||||
credentials.principal.accessRestrictions;
|
||||
|
||||
return requests.map(query => {
|
||||
if (permissionNames && !permissionNames.includes(query.permission.name)) {
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}
|
||||
if (permissionAttributes?.action) {
|
||||
const action = query.permission.attributes?.action;
|
||||
if (!action || !permissionAttributes.action.includes(action)) {
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}
|
||||
}
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
});
|
||||
}
|
||||
|
||||
async #shouldPermissionsBeApplied(
|
||||
options?: PermissionsServiceRequestOptions,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user