@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.
@@ -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;
@@ -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
+ };