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:
Fredrik Adelöw
2024-05-07 08:30:04 +02:00
parent 8ed28ffcdf
commit 9e63318311
25 changed files with 968 additions and 85 deletions
+1
View File
@@ -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,
) {