@mondaydotcomorg/monday-authorization 3.2.3 → 3.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attributions-service.d.ts +3 -2
- package/dist/attributions-service.d.ts.map +1 -1
- package/dist/attributions-service.js +1 -0
- package/dist/authorization-service.js +1 -1
- package/dist/esm/attributions-service.d.ts +3 -2
- package/dist/esm/attributions-service.d.ts.map +1 -1
- package/dist/esm/attributions-service.mjs +1 -0
- package/dist/esm/authorization-service.mjs +1 -1
- package/package.json +4 -2
- package/src/attributions-service.ts +93 -0
- package/src/authorization-attributes-service.ts +234 -0
- package/src/authorization-internal-service.ts +129 -0
- package/src/authorization-middleware.ts +51 -0
- package/src/authorization-service.ts +365 -0
- package/src/constants/sns.ts +5 -0
- package/src/constants.ts +22 -0
- package/src/index.ts +46 -0
- package/src/prometheus-service.ts +48 -0
- package/src/roles-service.ts +125 -0
- package/src/testKit/index.ts +66 -0
- package/src/types/authorization-attributes-contracts.ts +33 -0
- package/src/types/express.ts +8 -0
- package/src/types/general.ts +32 -0
- package/src/types/roles.ts +42 -0
- package/src/types/scoped-actions-contracts.ts +48 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { performance } from 'perf_hooks';
|
|
2
|
+
import snakeCase from 'lodash/snakeCase.js';
|
|
3
|
+
import camelCase from 'lodash/camelCase.js';
|
|
4
|
+
import mapKeys from 'lodash/mapKeys.js';
|
|
5
|
+
import { MondayFetchOptions } from '@mondaydotcomorg/monday-fetch';
|
|
6
|
+
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
7
|
+
import { HttpFetcherError } from '@mondaydotcomorg/monday-fetch-api';
|
|
8
|
+
import { getIgniteClient, IgniteClient } from '@mondaydotcomorg/ignite-sdk';
|
|
9
|
+
import { Action, AuthorizationObject, AuthorizationParams, Resource } from './types/general';
|
|
10
|
+
import { sendAuthorizationCheckResponseTimeMetric } from './prometheus-service';
|
|
11
|
+
import {
|
|
12
|
+
ScopedAction,
|
|
13
|
+
ScopedActionPermit,
|
|
14
|
+
ScopedActionResponseObject,
|
|
15
|
+
ScopeOptions,
|
|
16
|
+
} from './types/scoped-actions-contracts';
|
|
17
|
+
import { AuthorizationInternalService, logger } from './authorization-internal-service';
|
|
18
|
+
import { getAttributionsFromApi, getProfile, PlatformProfile } from './attributions-service';
|
|
19
|
+
|
|
20
|
+
const GRANTED_FEATURE_CACHE_EXPIRATION_SECONDS = 5 * 60;
|
|
21
|
+
const PLATFORM_AUTHORIZE_PATH = '/internal_ms/authorization/authorize';
|
|
22
|
+
const PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH = '/internal_ms/authorization/can_actions_in_scopes';
|
|
23
|
+
|
|
24
|
+
const ALLOWED_SDK_PLATFORM_PROFILES_KEY = 'allowed-sdk-platform-profiles';
|
|
25
|
+
const IN_RELEASE_SDK_PLATFORM_PROFILES_KEY = 'in-release-sdk-platform-profile';
|
|
26
|
+
const PLATFORM_PROFILE_RELEASE_FF = 'sdk-platform-profiles';
|
|
27
|
+
|
|
28
|
+
export interface AuthorizeResponse {
|
|
29
|
+
isAuthorized: boolean;
|
|
30
|
+
unauthorizedIds?: number[];
|
|
31
|
+
unauthorizedObjects?: AuthorizationObject[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setRequestFetchOptions(customMondayFetchOptions: MondayFetchOptions) {
|
|
35
|
+
AuthorizationInternalService.setRequestFetchOptions(customMondayFetchOptions);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type CamelCase<S extends string> = S extends `${infer F}_${infer R}` ? `${F}${Capitalize<CamelCase<R>>}` : S;
|
|
39
|
+
type CamelCaseKeys<T> = T extends object
|
|
40
|
+
? { [K in keyof T as K extends string ? CamelCase<K> : K]: CamelCaseKeys<T[K]> }
|
|
41
|
+
: T;
|
|
42
|
+
|
|
43
|
+
interface IsAuthorizedResponse {
|
|
44
|
+
result: boolean[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CanActionsInScopesResponse {
|
|
48
|
+
result: ScopedActionResponseObject[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class AuthorizationService {
|
|
52
|
+
static redisClient?;
|
|
53
|
+
static grantedFeatureRedisExpirationInSeconds?: number;
|
|
54
|
+
static igniteClient?: IgniteClient;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @deprecated use the second form with authorizationRequestObjects instead,
|
|
58
|
+
* support of this function will be dropped gradually
|
|
59
|
+
*/
|
|
60
|
+
static async isAuthorized(
|
|
61
|
+
accountId: number,
|
|
62
|
+
userId: number,
|
|
63
|
+
resources: Resource[],
|
|
64
|
+
action: Action
|
|
65
|
+
): Promise<AuthorizeResponse>;
|
|
66
|
+
|
|
67
|
+
static async isAuthorized(
|
|
68
|
+
accountId: number,
|
|
69
|
+
userId: number,
|
|
70
|
+
authorizationRequestObjects: AuthorizationObject[]
|
|
71
|
+
): Promise<AuthorizeResponse>;
|
|
72
|
+
|
|
73
|
+
static async isAuthorized(...args: any[]) {
|
|
74
|
+
if (args.length === 3) {
|
|
75
|
+
return this.isAuthorizedMultiple(args[0], args[1], args[2]);
|
|
76
|
+
} else if (args.length == 4) {
|
|
77
|
+
return this.isAuthorizedSingular(args[0], args[1], args[2], args[3]);
|
|
78
|
+
} else {
|
|
79
|
+
throw new Error('isAuthorized accepts either 3 or 4 arguments');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated - Please use Ignite instead: https://github.com/DaPulse/ignite-monorepo/blob/master/packages/ignite-sdk/README.md
|
|
85
|
+
* @sunsetDate 2024-12-31
|
|
86
|
+
*/
|
|
87
|
+
static async isUserGrantedWithFeature(
|
|
88
|
+
accountId: number,
|
|
89
|
+
userId: number,
|
|
90
|
+
featureName: string,
|
|
91
|
+
options: { shouldSkipCache?: boolean } = {}
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
const cachedKey = this.getCachedKeyName(userId, featureName);
|
|
94
|
+
const shouldSkipCache = options.shouldSkipCache ?? false;
|
|
95
|
+
if (this.redisClient && !shouldSkipCache) {
|
|
96
|
+
const grantedFeatureValue = await this.redisClient.get(cachedKey);
|
|
97
|
+
if (!(grantedFeatureValue === undefined || grantedFeatureValue === null)) {
|
|
98
|
+
// redis returns the value as string
|
|
99
|
+
return grantedFeatureValue === 'true';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const grantedFeatureValue = await this.fetchIsUserGrantedWithFeature(featureName, accountId, userId);
|
|
104
|
+
if (this.redisClient) {
|
|
105
|
+
await this.redisClient.set(cachedKey, grantedFeatureValue, 'EX', this.grantedFeatureRedisExpirationInSeconds);
|
|
106
|
+
}
|
|
107
|
+
return grantedFeatureValue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static async fetchIsUserGrantedWithFeature(
|
|
111
|
+
featureName: string,
|
|
112
|
+
accountId: number,
|
|
113
|
+
userId: number
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
const authorizationObject: AuthorizationObject = {
|
|
116
|
+
action: featureName,
|
|
117
|
+
resource_type: 'feature',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const authorizeResponsePromise = await this.isAuthorized(accountId, userId, [authorizationObject]);
|
|
121
|
+
return authorizeResponsePromise.isAuthorized;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private static getCachedKeyName(userId: number, featureName: string): string {
|
|
125
|
+
return `granted-feature-${featureName}-${userId}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static async canActionInScope(
|
|
129
|
+
accountId: number,
|
|
130
|
+
userId: number,
|
|
131
|
+
action: string,
|
|
132
|
+
scope: ScopeOptions
|
|
133
|
+
): Promise<ScopedActionPermit> {
|
|
134
|
+
const scopedActions = [{ action, scope }];
|
|
135
|
+
const scopedActionResponseObjects = await this.canActionInScopeMultiple(accountId, userId, scopedActions);
|
|
136
|
+
return scopedActionResponseObjects[0].permit;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static getProfile(accountId: number, userId: number): PlatformProfile {
|
|
140
|
+
const appName: string = process.env.APP_NAME ?? 'INVALID_APP_NAME';
|
|
141
|
+
if (!this.igniteClient) {
|
|
142
|
+
logger.error({ tag: 'authorization-service' }, 'AuthorizationService: igniteClient is not set, failing request');
|
|
143
|
+
throw new Error('AuthorizationService: igniteClient is not set, failing request');
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
this.igniteClient.configuration.getObjectValue<string[]>(ALLOWED_SDK_PLATFORM_PROFILES_KEY, []).includes(appName)
|
|
147
|
+
) {
|
|
148
|
+
return getProfile();
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
this.igniteClient.configuration
|
|
152
|
+
.getObjectValue<string[]>(IN_RELEASE_SDK_PLATFORM_PROFILES_KEY, [])
|
|
153
|
+
.includes(appName) &&
|
|
154
|
+
this.igniteClient.isReleased(PLATFORM_PROFILE_RELEASE_FF, { accountId, userId })
|
|
155
|
+
) {
|
|
156
|
+
return getProfile();
|
|
157
|
+
}
|
|
158
|
+
return PlatformProfile.APP;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static async canActionInScopeMultiple(
|
|
162
|
+
accountId: number,
|
|
163
|
+
userId: number,
|
|
164
|
+
scopedActions: ScopedAction[]
|
|
165
|
+
): Promise<ScopedActionResponseObject[]> {
|
|
166
|
+
const profile = this.getProfile(accountId, userId);
|
|
167
|
+
const internalAuthToken = AuthorizationInternalService.generateInternalAuthToken(accountId, userId);
|
|
168
|
+
const scopedActionsPayload = scopedActions.map(scopedAction => {
|
|
169
|
+
return { ...scopedAction, scope: mapKeys(scopedAction.scope, (_, key) => snakeCase(key)) }; // for example: { workspaceId: 1 } => { workspace_id: 1 }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const attributionHeaders = getAttributionsFromApi();
|
|
173
|
+
const httpClient = Api.getPart('httpClient');
|
|
174
|
+
|
|
175
|
+
let response: CanActionsInScopesResponse | undefined;
|
|
176
|
+
try {
|
|
177
|
+
response = await httpClient!.fetch<CanActionsInScopesResponse>(
|
|
178
|
+
{
|
|
179
|
+
url: {
|
|
180
|
+
appName: 'platform',
|
|
181
|
+
path: PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH,
|
|
182
|
+
profile,
|
|
183
|
+
},
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
Authorization: internalAuthToken,
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
...attributionHeaders,
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
user_id: userId,
|
|
192
|
+
scoped_actions: scopedActionsPayload,
|
|
193
|
+
}),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
197
|
+
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof HttpFetcherError) {
|
|
202
|
+
AuthorizationInternalService.throwOnHttpError(err.status, 'canActionInScopeMultiple');
|
|
203
|
+
} else {
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function toCamelCase<T extends object>(obj: T): CamelCaseKeys<T> {
|
|
209
|
+
return mapKeys(obj, (_, key) => camelCase(key)) as CamelCaseKeys<T>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!response) {
|
|
213
|
+
logger.error({ tag: 'authorization-service', response }, 'AuthorizationService: missing response');
|
|
214
|
+
throw new Error('AuthorizationService: missing response');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const scopedActionsResponseObjects = response.result.map(responseObject => {
|
|
218
|
+
const { scopedAction, permit } = responseObject;
|
|
219
|
+
const { scope } = scopedAction;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...responseObject,
|
|
223
|
+
scopedAction: { ...scopedAction, scope: toCamelCase(scope) },
|
|
224
|
+
permit: toCamelCase(permit),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return scopedActionsResponseObjects;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private static async isAuthorizedSingular(
|
|
232
|
+
accountId: number,
|
|
233
|
+
userId: number,
|
|
234
|
+
resources: Resource[],
|
|
235
|
+
action: Action
|
|
236
|
+
): Promise<AuthorizeResponse> {
|
|
237
|
+
const { authorizationObjects } = createAuthorizationParams(resources, action);
|
|
238
|
+
return this.isAuthorizedMultiple(accountId, userId, authorizationObjects);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private static async isAuthorizedMultiple(
|
|
242
|
+
accountId: number,
|
|
243
|
+
userId: number,
|
|
244
|
+
authorizationRequestObjects: AuthorizationObject[]
|
|
245
|
+
): Promise<AuthorizeResponse> {
|
|
246
|
+
const profile = this.getProfile(accountId, userId);
|
|
247
|
+
const internalAuthToken = AuthorizationInternalService.generateInternalAuthToken(accountId, userId);
|
|
248
|
+
const startTime = performance.now();
|
|
249
|
+
const attributionHeaders = getAttributionsFromApi();
|
|
250
|
+
const httpClient = Api.getPart('httpClient');
|
|
251
|
+
|
|
252
|
+
let response: IsAuthorizedResponse | undefined;
|
|
253
|
+
try {
|
|
254
|
+
response = await httpClient!.fetch<IsAuthorizedResponse>(
|
|
255
|
+
{
|
|
256
|
+
url: {
|
|
257
|
+
appName: 'platform',
|
|
258
|
+
path: PLATFORM_AUTHORIZE_PATH,
|
|
259
|
+
profile,
|
|
260
|
+
},
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: {
|
|
263
|
+
Authorization: internalAuthToken,
|
|
264
|
+
'Content-Type': 'application/json',
|
|
265
|
+
...attributionHeaders,
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
user_id: userId,
|
|
269
|
+
authorize_request_objects: authorizationRequestObjects,
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
274
|
+
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof httpClient!.HttpFetcherError) {
|
|
279
|
+
AuthorizationInternalService.throwOnHttpError(err.status, 'isAuthorizedMultiple');
|
|
280
|
+
} else {
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const endTime = performance.now();
|
|
285
|
+
const time = endTime - startTime;
|
|
286
|
+
|
|
287
|
+
const unauthorizedObjects: AuthorizationObject[] = [];
|
|
288
|
+
|
|
289
|
+
if (!response) {
|
|
290
|
+
logger.error({ tag: 'authorization-service', response }, 'AuthorizationService: missing response');
|
|
291
|
+
throw new Error('AuthorizationService: missing response');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
response.result.forEach(function (isAuthorized, index) {
|
|
295
|
+
const authorizationObject = authorizationRequestObjects[index];
|
|
296
|
+
if (!isAuthorized) {
|
|
297
|
+
unauthorizedObjects.push(authorizationObject);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
sendAuthorizationCheckResponseTimeMetric(
|
|
301
|
+
authorizationObject.resource_type,
|
|
302
|
+
authorizationObject.action,
|
|
303
|
+
isAuthorized,
|
|
304
|
+
200,
|
|
305
|
+
time
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (unauthorizedObjects.length > 0) {
|
|
310
|
+
logger.info(
|
|
311
|
+
{
|
|
312
|
+
resources: JSON.stringify(unauthorizedObjects),
|
|
313
|
+
},
|
|
314
|
+
'AuthorizationService: resource is unauthorized'
|
|
315
|
+
);
|
|
316
|
+
const unauthorizedIds = unauthorizedObjects
|
|
317
|
+
.filter(obj => !!obj.resource_id)
|
|
318
|
+
.map(obj => obj.resource_id) as number[];
|
|
319
|
+
return { isAuthorized: false, unauthorizedIds, unauthorizedObjects };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { isAuthorized: true };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function setRedisClient(
|
|
327
|
+
client,
|
|
328
|
+
grantedFeatureRedisExpirationInSeconds: number = GRANTED_FEATURE_CACHE_EXPIRATION_SECONDS
|
|
329
|
+
) {
|
|
330
|
+
AuthorizationService.redisClient = client;
|
|
331
|
+
if (grantedFeatureRedisExpirationInSeconds && grantedFeatureRedisExpirationInSeconds > 0) {
|
|
332
|
+
AuthorizationService.grantedFeatureRedisExpirationInSeconds = grantedFeatureRedisExpirationInSeconds;
|
|
333
|
+
} else {
|
|
334
|
+
logger.warn(
|
|
335
|
+
{ grantedFeatureRedisExpirationInSeconds },
|
|
336
|
+
'Invalid input for grantedFeatureRedisExpirationInSeconds, must be positive number. using default ttl.'
|
|
337
|
+
);
|
|
338
|
+
AuthorizationService.grantedFeatureRedisExpirationInSeconds = GRANTED_FEATURE_CACHE_EXPIRATION_SECONDS;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function setIgniteClient() {
|
|
343
|
+
const igniteClient = await getIgniteClient({
|
|
344
|
+
namespace: ['authorization-sdk'],
|
|
345
|
+
});
|
|
346
|
+
AuthorizationService.igniteClient = igniteClient;
|
|
347
|
+
AuthorizationInternalService.setIgniteClient(igniteClient);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function createAuthorizationParams(resources: Resource[], action: Action): AuthorizationParams {
|
|
351
|
+
const params = {
|
|
352
|
+
authorizationObjects: resources.map((resource: Resource) => {
|
|
353
|
+
const authorizationObject: AuthorizationObject = {
|
|
354
|
+
resource_id: resource.id,
|
|
355
|
+
resource_type: resource.type,
|
|
356
|
+
action,
|
|
357
|
+
};
|
|
358
|
+
if (resource.wrapperData) {
|
|
359
|
+
authorizationObject.wrapper_data = resource.wrapperData;
|
|
360
|
+
}
|
|
361
|
+
return authorizationObject;
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
return params;
|
|
365
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const SNS_ARN_ENV_VAR_NAME = 'SHARED_AUTHORIZATION_SNS_ENDPOINT_RESOURCE_ATTRIBUTES';
|
|
2
|
+
export const SNS_DEV_TEST_NAME =
|
|
3
|
+
'arn:aws:sns:us-east-1:000000000000:monday-authorization-resource-attributes-sns-local';
|
|
4
|
+
export const RESOURCE_ATTRIBUTES_SNS_UPDATE_OPERATION_MESSAGE_KIND = 'resourceAttributeModification';
|
|
5
|
+
export const ASYNC_RESOURCE_ATTRIBUTES_MAX_OPERATIONS_PER_MESSAGE = 100;
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { RecursivePartial } from '@mondaydotcomorg/monday-fetch-api';
|
|
2
|
+
import { FetcherConfig } from '@mondaydotcomorg/trident-backend-api';
|
|
3
|
+
|
|
4
|
+
export const APP_NAME = 'authorization';
|
|
5
|
+
|
|
6
|
+
export const ERROR_MESSAGES = {
|
|
7
|
+
HTTP_CLIENT_NOT_INITIALIZED: 'MondayAuthorization: HTTP client is not initialized',
|
|
8
|
+
REQUEST_FAILED: (method: string, status: number, reason: string) =>
|
|
9
|
+
`MondayAuthorization: [${method}] request failed with status ${status} with reason: ${reason}`,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_FETCH_OPTIONS: RecursivePartial<FetcherConfig> = {
|
|
13
|
+
retryPolicy: {
|
|
14
|
+
useRetries: true,
|
|
15
|
+
maxRetries: 3,
|
|
16
|
+
retryDelayMS: 10,
|
|
17
|
+
},
|
|
18
|
+
logPolicy: {
|
|
19
|
+
logErrors: 'error',
|
|
20
|
+
logRequests: 'info',
|
|
21
|
+
},
|
|
22
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MondayFetchOptions } from '@mondaydotcomorg/monday-fetch';
|
|
2
|
+
import { setPrometheus } from './prometheus-service';
|
|
3
|
+
import { setIgniteClient, setRedisClient, setRequestFetchOptions } from './authorization-service';
|
|
4
|
+
import * as TestKit from './testKit';
|
|
5
|
+
|
|
6
|
+
export interface InitOptions {
|
|
7
|
+
prometheus?: any;
|
|
8
|
+
mondayFetchOptions?: MondayFetchOptions;
|
|
9
|
+
redisClient?: any;
|
|
10
|
+
grantedFeatureRedisExpirationInSeconds?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function init(options: InitOptions = {}) {
|
|
14
|
+
if (options.prometheus) {
|
|
15
|
+
setPrometheus(options.prometheus);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (options.mondayFetchOptions) {
|
|
19
|
+
setRequestFetchOptions(options.mondayFetchOptions);
|
|
20
|
+
}
|
|
21
|
+
if (options.redisClient) {
|
|
22
|
+
setRedisClient(options.redisClient, options.grantedFeatureRedisExpirationInSeconds);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// add an ignite client for gradual release features
|
|
26
|
+
await setIgniteClient();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
authorizationCheckMiddleware,
|
|
31
|
+
getAuthorizationMiddleware,
|
|
32
|
+
skipAuthorizationMiddleware,
|
|
33
|
+
} from './authorization-middleware';
|
|
34
|
+
export { AuthorizationService, AuthorizeResponse } from './authorization-service';
|
|
35
|
+
export { AuthorizationAttributesService } from './authorization-attributes-service';
|
|
36
|
+
export { RolesService } from './roles-service';
|
|
37
|
+
export { AuthorizationObject, Resource, BaseRequest, ResourceGetter, ContextGetter } from './types/general';
|
|
38
|
+
export {
|
|
39
|
+
Translation,
|
|
40
|
+
ScopedAction,
|
|
41
|
+
ScopedActionResponseObject,
|
|
42
|
+
ScopedActionPermit,
|
|
43
|
+
} from './types/scoped-actions-contracts';
|
|
44
|
+
export { CustomRole, BasicRole, RoleType, RoleCreateRequest, RoleUpdateRequest, RolesResponse } from './types/roles';
|
|
45
|
+
|
|
46
|
+
export { TestKit };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Action } from './types/general';
|
|
2
|
+
|
|
3
|
+
let prometheus: any = null;
|
|
4
|
+
let authorizationCheckResponseTimeMetric: any = null;
|
|
5
|
+
|
|
6
|
+
export const METRICS = {
|
|
7
|
+
AUTHORIZATION_CHECK: 'authorization_check',
|
|
8
|
+
AUTHORIZATION_CHECKS_PER_REQUEST: 'authorization_checks_per_request',
|
|
9
|
+
AUTHORIZATION_CHECK_RESPONSE_TIME: 'authorization_check_response_time',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const authorizationCheckResponseTimeMetricConfig = {
|
|
13
|
+
name: METRICS.AUTHORIZATION_CHECK_RESPONSE_TIME,
|
|
14
|
+
labels: ['resourceType', 'action', 'isAuthorized', 'responseStatus'],
|
|
15
|
+
description: 'Authorization check response time summary',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function setPrometheus(customPrometheus) {
|
|
19
|
+
prometheus = customPrometheus;
|
|
20
|
+
const { METRICS_TYPES } = prometheus;
|
|
21
|
+
|
|
22
|
+
authorizationCheckResponseTimeMetric = getMetricsManager().addMetric(
|
|
23
|
+
METRICS_TYPES.SUMMARY,
|
|
24
|
+
authorizationCheckResponseTimeMetricConfig.name,
|
|
25
|
+
authorizationCheckResponseTimeMetricConfig.labels,
|
|
26
|
+
authorizationCheckResponseTimeMetricConfig.description
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getMetricsManager() {
|
|
31
|
+
return prometheus?.metricsManager;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sendAuthorizationCheckResponseTimeMetric(
|
|
35
|
+
resourceType: string,
|
|
36
|
+
action: Action,
|
|
37
|
+
isAuthorized: boolean,
|
|
38
|
+
responseStatus: number,
|
|
39
|
+
time: number
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
if (authorizationCheckResponseTimeMetric) {
|
|
43
|
+
authorizationCheckResponseTimeMetric.labels(resourceType, action, isAuthorized, responseStatus).observe(time);
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Api, FetcherConfig, HttpClient } from '@mondaydotcomorg/trident-backend-api';
|
|
2
|
+
import { HttpFetcherError, RecursivePartial } from '@mondaydotcomorg/monday-fetch-api';
|
|
3
|
+
import { RoleCreateRequest, RolesResponse, RoleUpdateRequest } from 'types/roles';
|
|
4
|
+
import { getAttributionsFromApi } from 'attributions-service';
|
|
5
|
+
import { APP_NAME, DEFAULT_FETCH_OPTIONS, ERROR_MESSAGES } from './constants';
|
|
6
|
+
|
|
7
|
+
const API_PATH = '/roles/account/{accountId}';
|
|
8
|
+
|
|
9
|
+
export class RolesService {
|
|
10
|
+
private httpClient: HttpClient;
|
|
11
|
+
private fetchOptions: RecursivePartial<FetcherConfig>;
|
|
12
|
+
private attributionHeaders: { [key: string]: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Public constructor to create the AuthorizationAttributesService instance.
|
|
16
|
+
* @param httpClient The HTTP client to use for API requests, if not provided, the default HTTP client from Api will be used.
|
|
17
|
+
* @param fetchOptions The fetch options to use for API requests, if not provided, the default fetch options will be used.
|
|
18
|
+
*/
|
|
19
|
+
constructor(httpClient?: HttpClient, fetchOptions?: RecursivePartial<FetcherConfig>) {
|
|
20
|
+
if (!httpClient) {
|
|
21
|
+
httpClient = Api.getPart('httpClient');
|
|
22
|
+
if (!httpClient) {
|
|
23
|
+
throw new Error(ERROR_MESSAGES.HTTP_CLIENT_NOT_INITIALIZED);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!fetchOptions) {
|
|
28
|
+
fetchOptions = DEFAULT_FETCH_OPTIONS;
|
|
29
|
+
} else {
|
|
30
|
+
fetchOptions = {
|
|
31
|
+
...DEFAULT_FETCH_OPTIONS,
|
|
32
|
+
...fetchOptions,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
this.httpClient = httpClient;
|
|
36
|
+
this.fetchOptions = fetchOptions;
|
|
37
|
+
this.attributionHeaders = getAttributionsFromApi();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get all roles for an account
|
|
42
|
+
* @param accountId - The account ID
|
|
43
|
+
* @param style - The style of the roles to return, either 'A' or 'B', default is 'A'. 'B' is not deprecated and only available for backward compatibility.
|
|
44
|
+
* @returns - The roles for the account, both basic and custom roles. Note that basic role ids are returned in A style and not B style.
|
|
45
|
+
*/
|
|
46
|
+
async getRoles(accountId: number, resourceTypes: string[], style: 'A' | 'B' = 'A'): Promise<RolesResponse> {
|
|
47
|
+
return await this.sendRoleRequest('GET', accountId, {}, { resourceTypes, style });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a custom role for an account
|
|
52
|
+
* @param accountId - The account ID
|
|
53
|
+
* @param roles - The roles to create
|
|
54
|
+
* @returns - The created roles
|
|
55
|
+
* Note that basic role ids should be provided in A style and not in B style.
|
|
56
|
+
*/
|
|
57
|
+
async createCustomRole(accountId: number, roles: RoleCreateRequest[]): Promise<RolesResponse> {
|
|
58
|
+
if (roles.length === 0) {
|
|
59
|
+
throw new Error('Roles array cannot be empty');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return await this.sendRoleRequest('PUT', accountId, {
|
|
63
|
+
customRoles: roles,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Delete a custom role for an account
|
|
69
|
+
* @param accountId - The account ID
|
|
70
|
+
* @param roleIds - The ids of the roles to delete
|
|
71
|
+
* @returns - The deleted roles. Note that basic role ids should be provided in A style and not in B style.
|
|
72
|
+
*/
|
|
73
|
+
async deleteCustomRole(accountId: number, roleIds: number[]): Promise<RolesResponse> {
|
|
74
|
+
return await this.sendRoleRequest('DELETE', accountId, {
|
|
75
|
+
ids: roleIds,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Update a custom role for an account
|
|
81
|
+
* @param accountId - The account ID
|
|
82
|
+
* @param updateRequests - The requests to update the roles
|
|
83
|
+
* @returns - The updated roles. Note that basic role ids should be provided in A style and not in B style.
|
|
84
|
+
*/
|
|
85
|
+
async updateCustomRole(accountId: number, updateRequests: RoleUpdateRequest[]): Promise<RolesResponse> {
|
|
86
|
+
return await this.sendRoleRequest('PATCH', accountId, {
|
|
87
|
+
customRoles: updateRequests,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async sendRoleRequest(
|
|
92
|
+
method: 'PUT' | 'GET' | 'DELETE' | 'PATCH',
|
|
93
|
+
accountId: number,
|
|
94
|
+
body: any,
|
|
95
|
+
additionalQueryParams: { [key: string]: any } = {},
|
|
96
|
+
style: 'A' | 'B' = 'A'
|
|
97
|
+
): Promise<RolesResponse> {
|
|
98
|
+
try {
|
|
99
|
+
return await this.httpClient.fetch<RolesResponse>(
|
|
100
|
+
{
|
|
101
|
+
url: {
|
|
102
|
+
appName: APP_NAME,
|
|
103
|
+
path: API_PATH.replace('{accountId}', accountId.toString()),
|
|
104
|
+
},
|
|
105
|
+
query: {
|
|
106
|
+
style: style,
|
|
107
|
+
...additionalQueryParams,
|
|
108
|
+
},
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
...this.attributionHeaders,
|
|
113
|
+
},
|
|
114
|
+
body: method === 'GET' ? undefined : body,
|
|
115
|
+
},
|
|
116
|
+
this.fetchOptions
|
|
117
|
+
);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof HttpFetcherError) {
|
|
120
|
+
throw new Error(ERROR_MESSAGES.REQUEST_FAILED('sendRoleRequest', err.status, err.message));
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Action, BaseRequest, BaseResponse, ContextGetter, Resource, ResourceGetter } from '../types/general';
|
|
2
|
+
import { defaultContextGetter } from '../authorization-middleware';
|
|
3
|
+
import { AuthorizationInternalService } from '../authorization-internal-service';
|
|
4
|
+
import type { NextFunction } from 'express';
|
|
5
|
+
|
|
6
|
+
export type TestPermittedAction = {
|
|
7
|
+
accountId: number;
|
|
8
|
+
userId: number;
|
|
9
|
+
resources: Resource[];
|
|
10
|
+
action: Action;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let testPermittedActions: TestPermittedAction[] = [];
|
|
14
|
+
export const addTestPermittedAction = (accountId: number, userId: number, resources: Resource[], action: Action) => {
|
|
15
|
+
testPermittedActions.push({ accountId, userId, resources, action });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const clearTestPermittedActions = () => {
|
|
19
|
+
testPermittedActions = [];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isActionAuthorized = (accountId: number, userId: number, resources: Resource[], action: Action) => {
|
|
23
|
+
return {
|
|
24
|
+
isAuthorized: resources.every(_ => {
|
|
25
|
+
return testPermittedActions.some(combination => {
|
|
26
|
+
return (
|
|
27
|
+
combination.accountId === accountId &&
|
|
28
|
+
combination.userId === userId &&
|
|
29
|
+
combination.action === action &&
|
|
30
|
+
combination.resources.some(combinationResource => {
|
|
31
|
+
return resources.some(resource => {
|
|
32
|
+
return (
|
|
33
|
+
combinationResource.id === resource.id &&
|
|
34
|
+
combinationResource.type === resource.type &&
|
|
35
|
+
JSON.stringify(combinationResource.wrapperData) === JSON.stringify(resource.wrapperData)
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const getTestAuthorizationMiddleware = (
|
|
46
|
+
action: Action,
|
|
47
|
+
resourceGetter: ResourceGetter,
|
|
48
|
+
contextGetter?: ContextGetter
|
|
49
|
+
) => {
|
|
50
|
+
return async function authorizationMiddleware(
|
|
51
|
+
request: BaseRequest,
|
|
52
|
+
response: BaseResponse,
|
|
53
|
+
next: NextFunction
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
contextGetter ||= defaultContextGetter;
|
|
56
|
+
const { userId, accountId } = contextGetter(request);
|
|
57
|
+
const resources = resourceGetter(request);
|
|
58
|
+
const { isAuthorized } = isActionAuthorized(accountId, userId, resources, action);
|
|
59
|
+
AuthorizationInternalService.markAuthorized(request);
|
|
60
|
+
if (!isAuthorized) {
|
|
61
|
+
response.status(403).json({ message: 'Access denied' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
};
|