-
Notifications
You must be signed in to change notification settings - Fork 5.5k
/
PermissionClient.ts
157 lines (143 loc) · 5.27 KB
/
PermissionClient.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/*
* Copyright 2021 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 { ResponseError } from '@backstage/errors';
import fetch from 'cross-fetch';
import * as uuid from 'uuid';
import { z } from 'zod';
import {
AuthorizeResult,
AuthorizeRequest,
AuthorizeResponse,
Identified,
PermissionCriteria,
PermissionCondition,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import {
PermissionAuthorizer,
AuthorizeRequestOptions,
} from './types/permission';
const permissionCriteriaSchema: z.ZodSchema<
PermissionCriteria<PermissionCondition>
> = z.lazy(() =>
z
.object({
rule: z.string(),
params: z.array(z.unknown()),
})
.or(z.object({ anyOf: z.array(permissionCriteriaSchema) }))
.or(z.object({ allOf: z.array(permissionCriteriaSchema) }))
.or(z.object({ not: permissionCriteriaSchema })),
);
const responseSchema = z.array(
z
.object({
id: z.string(),
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
})
.or(
z.object({
id: z.string(),
result: z.literal(AuthorizeResult.CONDITIONAL),
conditions: permissionCriteriaSchema,
}),
),
);
/**
* An isomorphic client for requesting authorization for Backstage permissions.
* @public
*/
export class PermissionClient implements PermissionAuthorizer {
private readonly enabled: boolean;
private readonly discovery: DiscoveryApi;
constructor(options: { discovery: DiscoveryApi; config: Config }) {
this.discovery = options.discovery;
this.enabled =
options.config.getOptionalBoolean('permission.enabled') ?? false;
}
/**
* Request authorization from the permission-backend for the given set of permissions.
*
* Authorization requests check that a given Backstage user can perform a protected operation,
* potentially for a specific resource (such as a catalog entity). The Backstage identity token
* should be included in the `options` if available.
*
* Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`.
*
* The response will be either ALLOW or DENY when either the permission has no resourceType, or a
* resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be
* returned if no resourceRef is provided in the request. Conditional responses are intended only
* for backends which have access to the data source for permissioned resources, so that filters
* can be applied when loading collections of resources.
* @public
*/
async authorize(
requests: AuthorizeRequest[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]> {
// TODO(permissions): it would be great to provide some kind of typing guarantee that
// conditional responses will only ever be returned for requests containing a resourceType
// but no resourceRef. That way clients who aren't prepared to handle filtering according
// to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response.
if (!this.enabled) {
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
const identifiedRequests: Identified<AuthorizeRequest>[] = requests.map(
request => ({
id: uuid.v4(),
...request,
}),
);
const permissionApi = await this.discovery.getBaseUrl('permission');
const response = await fetch(`${permissionApi}/authorize`, {
method: 'POST',
body: JSON.stringify(identifiedRequests),
headers: {
...this.getAuthorizationHeader(options?.token),
'content-type': 'application/json',
},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const identifiedResponses = await response.json();
this.assertValidResponses(identifiedRequests, identifiedResponses);
const responsesById = identifiedResponses.reduce((acc, r) => {
acc[r.id] = r;
return acc;
}, {} as Record<string, Identified<AuthorizeResponse>>);
return identifiedRequests.map(request => responsesById[request.id]);
}
private getAuthorizationHeader(token?: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
private assertValidResponses(
requests: Identified<AuthorizeRequest>[],
json: any,
): asserts json is Identified<AuthorizeResponse>[] {
const authorizedResponses = responseSchema.parse(json);
const responseIds = authorizedResponses.map(r => r.id);
const hasAllRequestIds = requests.every(r => responseIds.includes(r.id));
if (!hasAllRequestIds) {
throw new Error(
'Unexpected authorization response from permission-backend',
);
}
}
}