@mondaydotcomorg/monday-authorization 3.3.0-feature-bashanye-navigate-can-action-in-scope-to-graph-46d4fc5 → 3.3.0-feature-bashanye-navigate-can-action-in-scope-to-graph-9ad5fa5
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/authorization-service.d.ts +0 -8
- package/dist/authorization-service.d.ts.map +1 -1
- package/dist/authorization-service.js +30 -161
- package/dist/clients/graph-api.client.d.ts +24 -0
- package/dist/clients/graph-api.client.d.ts.map +1 -0
- package/dist/clients/graph-api.client.js +102 -0
- package/dist/clients/platform-api.client.d.ts +31 -0
- package/dist/clients/platform-api.client.d.ts.map +1 -0
- package/dist/clients/platform-api.client.js +86 -0
- package/dist/esm/authorization-service.d.ts +0 -8
- package/dist/esm/authorization-service.d.ts.map +1 -1
- package/dist/esm/authorization-service.mjs +31 -156
- package/dist/esm/clients/graph-api.client.d.ts +24 -0
- package/dist/esm/clients/graph-api.client.d.ts.map +1 -0
- package/dist/esm/clients/graph-api.client.mjs +100 -0
- package/dist/esm/clients/platform-api.client.d.ts +31 -0
- package/dist/esm/clients/platform-api.client.d.ts.map +1 -0
- package/dist/esm/clients/platform-api.client.mjs +84 -0
- package/dist/esm/prometheus-service.d.ts +3 -3
- package/dist/esm/prometheus-service.d.ts.map +1 -1
- package/dist/esm/prometheus-service.mjs +65 -3
- package/dist/esm/testKit/index.d.ts.map +1 -1
- package/dist/esm/testKit/index.mjs +9 -7
- package/dist/esm/types/graph-api.types.d.ts +11 -0
- package/dist/esm/types/graph-api.types.d.ts.map +1 -0
- package/dist/esm/types/graph-api.types.mjs +1 -0
- package/dist/esm/utils/authorization.utils.d.ts +22 -0
- package/dist/esm/utils/authorization.utils.d.ts.map +1 -0
- package/dist/esm/utils/authorization.utils.mjs +39 -0
- package/dist/prometheus-service.d.ts +3 -3
- package/dist/prometheus-service.d.ts.map +1 -1
- package/dist/prometheus-service.js +65 -3
- package/dist/testKit/index.d.ts.map +1 -1
- package/dist/testKit/index.js +9 -7
- package/dist/types/graph-api.types.d.ts +11 -0
- package/dist/types/graph-api.types.d.ts.map +1 -0
- package/dist/types/graph-api.types.js +1 -0
- package/dist/utils/authorization.utils.d.ts +22 -0
- package/dist/utils/authorization.utils.d.ts.map +1 -0
- package/dist/utils/authorization.utils.js +49 -0
- package/package.json +1 -1
|
@@ -1,18 +1,16 @@
|
|
|
1
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
2
|
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
6
3
|
import { HttpFetcherError } from '@mondaydotcomorg/monday-fetch-api';
|
|
7
4
|
import { getIgniteClient } from '@mondaydotcomorg/ignite-sdk';
|
|
8
|
-
import { sendAuthorizationCheckResponseTimeMetric,
|
|
5
|
+
import { sendAuthorizationCheckResponseTimeMetric, incrementAuthorizationSuccess } from './prometheus-service.mjs';
|
|
9
6
|
import { AuthorizationInternalService, logger } from './authorization-internal-service.mjs';
|
|
10
7
|
import { getProfile, PlatformProfile, getAttributionsFromApi } from './attributions-service.mjs';
|
|
8
|
+
import { GraphApiClient } from './clients/graph-api.client.mjs';
|
|
9
|
+
import { PlatformApiClient } from './clients/platform-api.client.mjs';
|
|
10
|
+
import { scopeToResource } from './utils/authorization.utils.mjs';
|
|
11
11
|
|
|
12
12
|
const GRANTED_FEATURE_CACHE_EXPIRATION_SECONDS = 5 * 60;
|
|
13
13
|
const PLATFORM_AUTHORIZE_PATH = '/internal_ms/authorization/authorize';
|
|
14
|
-
const PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH = '/internal_ms/authorization/can_actions_in_scopes';
|
|
15
|
-
const CAN_ACTION_IN_SCOPE_GRAPH_PATH = '/permissions/is-allowed';
|
|
16
14
|
const ALLOWED_SDK_PLATFORM_PROFILES_KEY = 'allowed-sdk-platform-profiles';
|
|
17
15
|
const IN_RELEASE_SDK_PLATFORM_PROFILES_KEY = 'in-release-sdk-platform-profile';
|
|
18
16
|
const PLATFORM_PROFILE_RELEASE_FF = 'sdk-platform-profiles';
|
|
@@ -93,167 +91,44 @@ class AuthorizationService {
|
|
|
93
91
|
const internalAuthToken = AuthorizationInternalService.generateInternalAuthToken(accountId, userId);
|
|
94
92
|
const startTime = performance.now();
|
|
95
93
|
let scopedActionResponseObjects;
|
|
94
|
+
let usedGraphApi = false;
|
|
96
95
|
if (shouldNavigateToGraph) {
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
try {
|
|
97
|
+
scopedActionResponseObjects = await GraphApiClient.checkPermissions(internalAuthToken, scopedActions);
|
|
98
|
+
usedGraphApi = true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Fallback to Platform API if Graph API fails
|
|
102
|
+
logger.warn({
|
|
103
|
+
tag: 'authorization-service',
|
|
104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
105
|
+
accountId,
|
|
106
|
+
userId,
|
|
107
|
+
}, 'Graph API authorization failed, falling back to Platform API');
|
|
108
|
+
const profile = this.getProfile(accountId, userId);
|
|
109
|
+
scopedActionResponseObjects = await PlatformApiClient.checkPermissions(profile, internalAuthToken, userId, scopedActions);
|
|
110
|
+
usedGraphApi = false;
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
113
|
else {
|
|
101
114
|
const profile = this.getProfile(accountId, userId);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
scopedActionResponseObjects = this.mapPlatformResponse(platformResponse);
|
|
115
|
+
scopedActionResponseObjects = await PlatformApiClient.checkPermissions(profile, internalAuthToken, userId, scopedActions);
|
|
116
|
+
usedGraphApi = false;
|
|
105
117
|
}
|
|
106
118
|
const endTime = performance.now();
|
|
107
119
|
const time = endTime - startTime;
|
|
108
|
-
const apiType =
|
|
120
|
+
const apiType = usedGraphApi ? 'graph' : 'platform';
|
|
121
|
+
// Record metrics for each authorization check
|
|
109
122
|
for (const obj of scopedActionResponseObjects) {
|
|
110
|
-
const { action } = obj.scopedAction;
|
|
111
|
-
const
|
|
123
|
+
const { action, scope } = obj.scopedAction;
|
|
124
|
+
const { resourceType } = scopeToResource(scope);
|
|
112
125
|
const isAuthorized = obj.permit.can;
|
|
113
|
-
sendAuthorizationCheckResponseTimeMetric(
|
|
114
|
-
if (obj.permit.can)
|
|
115
|
-
|
|
116
|
-
return scopedActionResponseObjects;
|
|
117
|
-
}
|
|
118
|
-
static buildScopedActionsPayload(scopedActions) {
|
|
119
|
-
return scopedActions.map(scopedAction => {
|
|
120
|
-
return { ...scopedAction, scope: mapKeys(scopedAction.scope, (_, key) => snakeCase(key)) };
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
static scopeToResource(scope) {
|
|
124
|
-
if ('workspaceId' in scope) {
|
|
125
|
-
return { resourceType: 'workspace', resourceId: scope.workspaceId };
|
|
126
|
-
}
|
|
127
|
-
if ('boardId' in scope) {
|
|
128
|
-
return { resourceType: 'board', resourceId: scope.boardId };
|
|
129
|
-
}
|
|
130
|
-
if ('pulseId' in scope) {
|
|
131
|
-
return { resourceType: 'pulse', resourceId: scope.pulseId };
|
|
132
|
-
}
|
|
133
|
-
if ('accountProductId' in scope) {
|
|
134
|
-
return { resourceType: 'account_product', resourceId: scope.accountProductId };
|
|
135
|
-
}
|
|
136
|
-
if ('accountId' in scope) {
|
|
137
|
-
return { resourceType: 'account', resourceId: scope.accountId };
|
|
138
|
-
}
|
|
139
|
-
throw new Error('Unsupported scope provided');
|
|
140
|
-
}
|
|
141
|
-
static buildGraphRequestBody(scopedActions) {
|
|
142
|
-
const resourcesAccumulator = {};
|
|
143
|
-
for (const { action, scope } of scopedActions) {
|
|
144
|
-
const { resourceType, resourceId } = this.scopeToResource(scope);
|
|
145
|
-
if (!resourcesAccumulator[resourceType]) {
|
|
146
|
-
resourcesAccumulator[resourceType] = {};
|
|
147
|
-
}
|
|
148
|
-
if (!resourcesAccumulator[resourceType][resourceId]) {
|
|
149
|
-
resourcesAccumulator[resourceType][resourceId] = new Set();
|
|
150
|
-
}
|
|
151
|
-
resourcesAccumulator[resourceType][resourceId].add(action);
|
|
152
|
-
}
|
|
153
|
-
const resourcesPayload = {};
|
|
154
|
-
for (const [resourceType, idMap] of Object.entries(resourcesAccumulator)) {
|
|
155
|
-
resourcesPayload[resourceType] = {};
|
|
156
|
-
for (const [idStr, actionsSet] of Object.entries(idMap)) {
|
|
157
|
-
const idNum = Number(idStr);
|
|
158
|
-
resourcesPayload[resourceType][idNum] = Array.from(actionsSet);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return resourcesPayload;
|
|
162
|
-
}
|
|
163
|
-
static async fetchGraphIsAllowed(internalAuthToken, scopedActions) {
|
|
164
|
-
const httpClient = Api.getPart('httpClient');
|
|
165
|
-
const attributionHeaders = getAttributionsFromApi();
|
|
166
|
-
const bodyPayload = this.buildGraphRequestBody(scopedActions);
|
|
167
|
-
try {
|
|
168
|
-
const response = await httpClient.fetch({
|
|
169
|
-
url: {
|
|
170
|
-
appName: 'authorization-graph',
|
|
171
|
-
path: CAN_ACTION_IN_SCOPE_GRAPH_PATH,
|
|
172
|
-
},
|
|
173
|
-
method: 'POST',
|
|
174
|
-
headers: {
|
|
175
|
-
Authorization: internalAuthToken,
|
|
176
|
-
'Content-Type': 'application/json',
|
|
177
|
-
...attributionHeaders,
|
|
178
|
-
},
|
|
179
|
-
body: JSON.stringify(bodyPayload),
|
|
180
|
-
}, {
|
|
181
|
-
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
182
|
-
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
183
|
-
});
|
|
184
|
-
setGraphAvailability(true);
|
|
185
|
-
return response;
|
|
186
|
-
}
|
|
187
|
-
catch (err) {
|
|
188
|
-
if (err instanceof HttpFetcherError) {
|
|
189
|
-
AuthorizationInternalService.throwOnHttpError(err.status, 'canActionInScopeMultiple');
|
|
190
|
-
incrementAuthorizationError(this.scopeToResource(scopedActions[0].scope).resourceType, scopedActions[0].action, err.status);
|
|
191
|
-
}
|
|
192
|
-
throw err;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
static mapGraphResponse(scopedActions, userId, graphResponse) {
|
|
196
|
-
const resources = graphResponse ?? {};
|
|
197
|
-
return scopedActions.map(scopedAction => {
|
|
198
|
-
const { action, scope } = scopedAction;
|
|
199
|
-
const { resourceType, resourceId } = this.scopeToResource(scope);
|
|
200
|
-
const permissionResult = resources?.[resourceType]?.[String(resourceId)]?.[action];
|
|
201
|
-
const permit = {
|
|
202
|
-
can: permissionResult?.can ?? false,
|
|
203
|
-
reason: { key: permissionResult?.reason ?? 'unknown' },
|
|
204
|
-
technicalReason: 0,
|
|
205
|
-
};
|
|
206
|
-
return { scopedAction, permit };
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
static async fetchPlatformCanActions(profile, internalAuthToken, userId, scopedActionsPayload) {
|
|
210
|
-
const attributionHeaders = getAttributionsFromApi();
|
|
211
|
-
const httpClient = Api.getPart('httpClient');
|
|
212
|
-
try {
|
|
213
|
-
const response = await httpClient.fetch({
|
|
214
|
-
url: {
|
|
215
|
-
appName: 'platform',
|
|
216
|
-
path: PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH,
|
|
217
|
-
profile,
|
|
218
|
-
},
|
|
219
|
-
method: 'POST',
|
|
220
|
-
headers: {
|
|
221
|
-
Authorization: internalAuthToken,
|
|
222
|
-
'Content-Type': 'application/json',
|
|
223
|
-
...attributionHeaders,
|
|
224
|
-
},
|
|
225
|
-
body: JSON.stringify({ user_id: userId, scoped_actions: scopedActionsPayload }),
|
|
226
|
-
}, {
|
|
227
|
-
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
228
|
-
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
229
|
-
});
|
|
230
|
-
return response;
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
if (err instanceof HttpFetcherError) {
|
|
234
|
-
AuthorizationInternalService.throwOnHttpError(err.status, 'canActionInScopeMultiple');
|
|
235
|
-
incrementAuthorizationError(this.scopeToResource(this.toCamelCase(scopedActionsPayload[0].scope)).resourceType, scopedActionsPayload[0].action, err.status);
|
|
126
|
+
sendAuthorizationCheckResponseTimeMetric(resourceType, action, isAuthorized, 200, time, apiType);
|
|
127
|
+
if (obj.permit.can) {
|
|
128
|
+
incrementAuthorizationSuccess(resourceType, action);
|
|
236
129
|
}
|
|
237
|
-
throw err;
|
|
238
130
|
}
|
|
239
|
-
|
|
240
|
-
static toCamelCase(obj) {
|
|
241
|
-
return mapKeys(obj, (_, key) => camelCase(key));
|
|
242
|
-
}
|
|
243
|
-
static mapPlatformResponse(response) {
|
|
244
|
-
if (!response) {
|
|
245
|
-
logger.error({ tag: 'authorization-service', response }, 'AuthorizationService: missing response');
|
|
246
|
-
throw new Error('AuthorizationService: missing response');
|
|
247
|
-
}
|
|
248
|
-
return response.result.map(responseObject => {
|
|
249
|
-
const { scopedAction, permit } = responseObject;
|
|
250
|
-
const { scope } = scopedAction;
|
|
251
|
-
return {
|
|
252
|
-
...responseObject,
|
|
253
|
-
scopedAction: { ...scopedAction, scope: this.toCamelCase(scope) },
|
|
254
|
-
permit: this.toCamelCase(permit),
|
|
255
|
-
};
|
|
256
|
-
});
|
|
131
|
+
return scopedActionResponseObjects;
|
|
257
132
|
}
|
|
258
133
|
static async isAuthorizedSingular(accountId, userId, resources, action) {
|
|
259
134
|
const { authorizationObjects } = createAuthorizationParams(resources, action);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ScopedAction, ScopedActionResponseObject } from '../types/scoped-actions-contracts';
|
|
2
|
+
import { GraphIsAllowedDto, GraphIsAllowedResponse } from '../types/graph-api.types';
|
|
3
|
+
/**
|
|
4
|
+
* Client for handling Graph API authorization operations
|
|
5
|
+
*/
|
|
6
|
+
export declare class GraphApiClient {
|
|
7
|
+
/**
|
|
8
|
+
* Builds the request body for Graph API calls
|
|
9
|
+
*/
|
|
10
|
+
static buildRequestBody(scopedActions: ScopedAction[]): GraphIsAllowedDto;
|
|
11
|
+
/**
|
|
12
|
+
* Fetches authorization data from the Graph API
|
|
13
|
+
*/
|
|
14
|
+
static fetchPermissions(internalAuthToken: string, scopedActions: ScopedAction[]): Promise<GraphIsAllowedResponse>;
|
|
15
|
+
/**
|
|
16
|
+
* Maps Graph API response to the expected format
|
|
17
|
+
*/
|
|
18
|
+
static mapResponse(scopedActions: ScopedAction[], graphResponse: GraphIsAllowedResponse): ScopedActionResponseObject[];
|
|
19
|
+
/**
|
|
20
|
+
* Performs a complete authorization check using the Graph API
|
|
21
|
+
*/
|
|
22
|
+
static checkPermissions(internalAuthToken: string, scopedActions: ScopedAction[]): Promise<ScopedActionResponseObject[]>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=graph-api.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-api.client.d.ts","sourceRoot":"","sources":["../../../src/clients/graph-api.client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,0BAA0B,EAAsB,MAAM,mCAAmC,CAAC;AAGjH,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EAIvB,MAAM,0BAA0B,CAAC;AAMlC;;GAEG;AACH,qBAAa,cAAc;IACzB;;OAEG;IACH,MAAM,CAAC,gBAAgB,CAAC,aAAa,EAAE,YAAY,EAAE,GAAG,iBAAiB;IAyBzE;;OAEG;WACU,gBAAgB,CAC3B,iBAAiB,EAAE,MAAM,EACzB,aAAa,EAAE,YAAY,EAAE,GAC5B,OAAO,CAAC,sBAAsB,CAAC;IAyClC;;OAEG;IACH,MAAM,CAAC,WAAW,CAChB,aAAa,EAAE,YAAY,EAAE,EAC7B,aAAa,EAAE,sBAAsB,GACpC,0BAA0B,EAAE;IAkB/B;;OAEG;WACU,gBAAgB,CAC3B,iBAAiB,EAAE,MAAM,EACzB,aAAa,EAAE,YAAY,EAAE,GAC5B,OAAO,CAAC,0BAA0B,EAAE,CAAC;CAIzC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
2
|
+
import { HttpFetcherError } from '@mondaydotcomorg/monday-fetch-api';
|
|
3
|
+
import { AuthorizationInternalService } from '../authorization-internal-service.mjs';
|
|
4
|
+
import { getAttributionsFromApi } from '../attributions-service.mjs';
|
|
5
|
+
import { scopeToResource } from '../utils/authorization.utils.mjs';
|
|
6
|
+
import { setGraphAvailability, incrementAuthorizationError } from '../prometheus-service.mjs';
|
|
7
|
+
|
|
8
|
+
const CAN_ACTION_IN_SCOPE_GRAPH_PATH = '/permissions/is-allowed';
|
|
9
|
+
/**
|
|
10
|
+
* Client for handling Graph API authorization operations
|
|
11
|
+
*/
|
|
12
|
+
class GraphApiClient {
|
|
13
|
+
/**
|
|
14
|
+
* Builds the request body for Graph API calls
|
|
15
|
+
*/
|
|
16
|
+
static buildRequestBody(scopedActions) {
|
|
17
|
+
const resourcesAccumulator = {};
|
|
18
|
+
for (const { action, scope } of scopedActions) {
|
|
19
|
+
const { resourceType, resourceId } = scopeToResource(scope);
|
|
20
|
+
if (!resourcesAccumulator[resourceType]) {
|
|
21
|
+
resourcesAccumulator[resourceType] = {};
|
|
22
|
+
}
|
|
23
|
+
if (!resourcesAccumulator[resourceType][resourceId]) {
|
|
24
|
+
resourcesAccumulator[resourceType][resourceId] = new Set();
|
|
25
|
+
}
|
|
26
|
+
resourcesAccumulator[resourceType][resourceId].add(action);
|
|
27
|
+
}
|
|
28
|
+
const resourcesPayload = {};
|
|
29
|
+
for (const [resourceType, idMap] of Object.entries(resourcesAccumulator)) {
|
|
30
|
+
resourcesPayload[resourceType] = {};
|
|
31
|
+
for (const [idStr, actionsSet] of Object.entries(idMap)) {
|
|
32
|
+
const idNum = Number(idStr);
|
|
33
|
+
resourcesPayload[resourceType][idNum] = Array.from(actionsSet);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return resourcesPayload;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetches authorization data from the Graph API
|
|
40
|
+
*/
|
|
41
|
+
static async fetchPermissions(internalAuthToken, scopedActions) {
|
|
42
|
+
const httpClient = Api.getPart('httpClient');
|
|
43
|
+
const attributionHeaders = getAttributionsFromApi();
|
|
44
|
+
const bodyPayload = this.buildRequestBody(scopedActions);
|
|
45
|
+
try {
|
|
46
|
+
const response = await httpClient.fetch({
|
|
47
|
+
url: {
|
|
48
|
+
appName: 'authorization-graph',
|
|
49
|
+
path: CAN_ACTION_IN_SCOPE_GRAPH_PATH,
|
|
50
|
+
},
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: internalAuthToken,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
...attributionHeaders,
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(bodyPayload),
|
|
58
|
+
}, {
|
|
59
|
+
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
60
|
+
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
61
|
+
});
|
|
62
|
+
setGraphAvailability(true);
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
setGraphAvailability(false);
|
|
67
|
+
if (err instanceof HttpFetcherError) {
|
|
68
|
+
AuthorizationInternalService.throwOnHttpError(err.status, 'canActionInScopeMultiple');
|
|
69
|
+
incrementAuthorizationError(scopeToResource(scopedActions[0].scope).resourceType, scopedActions[0].action, err.status);
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Maps Graph API response to the expected format
|
|
76
|
+
*/
|
|
77
|
+
static mapResponse(scopedActions, graphResponse) {
|
|
78
|
+
const resources = graphResponse ?? {};
|
|
79
|
+
return scopedActions.map(scopedAction => {
|
|
80
|
+
const { action, scope } = scopedAction;
|
|
81
|
+
const { resourceType, resourceId } = scopeToResource(scope);
|
|
82
|
+
const permissionResult = resources?.[resourceType]?.[String(resourceId)]?.[action];
|
|
83
|
+
const permit = {
|
|
84
|
+
can: permissionResult?.can ?? false,
|
|
85
|
+
reason: { key: permissionResult?.reason ?? 'unknown' },
|
|
86
|
+
technicalReason: 0,
|
|
87
|
+
};
|
|
88
|
+
return { scopedAction, permit };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Performs a complete authorization check using the Graph API
|
|
93
|
+
*/
|
|
94
|
+
static async checkPermissions(internalAuthToken, scopedActions) {
|
|
95
|
+
const response = await this.fetchPermissions(internalAuthToken, scopedActions);
|
|
96
|
+
return this.mapResponse(scopedActions, response);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { GraphApiClient };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ScopedAction, ScopedActionResponseObject } from '../types/scoped-actions-contracts';
|
|
2
|
+
import { PlatformProfile } from '../attributions-service';
|
|
3
|
+
type ScopedActionPlatformPayload = Omit<ScopedAction, 'scope'> & {
|
|
4
|
+
scope: Record<string, number>;
|
|
5
|
+
};
|
|
6
|
+
interface CanActionsInScopesResponse {
|
|
7
|
+
result: ScopedActionResponseObject[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Client for handling Platform API authorization operations
|
|
11
|
+
*/
|
|
12
|
+
export declare class PlatformApiClient {
|
|
13
|
+
/**
|
|
14
|
+
* Builds the request payload for Platform API calls
|
|
15
|
+
*/
|
|
16
|
+
static buildRequestPayload(scopedActions: ScopedAction[]): ScopedActionPlatformPayload[];
|
|
17
|
+
/**
|
|
18
|
+
* Fetches authorization data from the Platform API
|
|
19
|
+
*/
|
|
20
|
+
static fetchPermissions(profile: PlatformProfile, internalAuthToken: string, userId: number, scopedActionsPayload: ScopedActionPlatformPayload[]): Promise<CanActionsInScopesResponse>;
|
|
21
|
+
/**
|
|
22
|
+
* Maps Platform API response to the expected format
|
|
23
|
+
*/
|
|
24
|
+
static mapResponse(response: CanActionsInScopesResponse): ScopedActionResponseObject[];
|
|
25
|
+
/**
|
|
26
|
+
* Performs a complete authorization check using the Platform API
|
|
27
|
+
*/
|
|
28
|
+
static checkPermissions(profile: PlatformProfile, internalAuthToken: string, userId: number, scopedActions: ScopedAction[]): Promise<ScopedActionResponseObject[]>;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=platform-api.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform-api.client.d.ts","sourceRoot":"","sources":["../../../src/clients/platform-api.client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,0BAA0B,EAAE,MAAM,mCAAmC,CAAC;AAE7F,OAAO,EAA0B,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAOlF,KAAK,2BAA2B,GAAG,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,GAAG;IAC/D,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B,CAAC;AAEF,UAAU,0BAA0B;IAClC,MAAM,EAAE,0BAA0B,EAAE,CAAC;CACtC;AAED;;GAEG;AACH,qBAAa,iBAAiB;IAC5B;;OAEG;IACH,MAAM,CAAC,mBAAmB,CAAC,aAAa,EAAE,YAAY,EAAE,GAAG,2BAA2B,EAAE;IAOxF;;OAEG;WACU,gBAAgB,CAC3B,OAAO,EAAE,eAAe,EACxB,iBAAiB,EAAE,MAAM,EACzB,MAAM,EAAE,MAAM,EACd,oBAAoB,EAAE,2BAA2B,EAAE,GAClD,OAAO,CAAC,0BAA0B,CAAC;IAuCtC;;OAEG;IACH,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,0BAA0B,GAAG,0BAA0B,EAAE;IAkBtF;;OAEG;WACU,gBAAgB,CAC3B,OAAO,EAAE,eAAe,EACxB,iBAAiB,EAAE,MAAM,EACzB,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,YAAY,EAAE,GAC5B,OAAO,CAAC,0BAA0B,EAAE,CAAC;CAKzC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Api } from '@mondaydotcomorg/trident-backend-api';
|
|
2
|
+
import { HttpFetcherError } from '@mondaydotcomorg/monday-fetch-api';
|
|
3
|
+
import { AuthorizationInternalService, logger } from '../authorization-internal-service.mjs';
|
|
4
|
+
import { getAttributionsFromApi } from '../attributions-service.mjs';
|
|
5
|
+
import { toSnakeCase, scopeToResource, toCamelCase } from '../utils/authorization.utils.mjs';
|
|
6
|
+
import { incrementAuthorizationError } from '../prometheus-service.mjs';
|
|
7
|
+
|
|
8
|
+
const PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH = '/internal_ms/authorization/can_actions_in_scopes';
|
|
9
|
+
/**
|
|
10
|
+
* Client for handling Platform API authorization operations
|
|
11
|
+
*/
|
|
12
|
+
class PlatformApiClient {
|
|
13
|
+
/**
|
|
14
|
+
* Builds the request payload for Platform API calls
|
|
15
|
+
*/
|
|
16
|
+
static buildRequestPayload(scopedActions) {
|
|
17
|
+
return scopedActions.map(scopedAction => ({
|
|
18
|
+
...scopedAction,
|
|
19
|
+
scope: toSnakeCase(scopedAction.scope),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fetches authorization data from the Platform API
|
|
24
|
+
*/
|
|
25
|
+
static async fetchPermissions(profile, internalAuthToken, userId, scopedActionsPayload) {
|
|
26
|
+
const attributionHeaders = getAttributionsFromApi();
|
|
27
|
+
const httpClient = Api.getPart('httpClient');
|
|
28
|
+
try {
|
|
29
|
+
const response = await httpClient.fetch({
|
|
30
|
+
url: {
|
|
31
|
+
appName: 'platform',
|
|
32
|
+
path: PLATFORM_CAN_ACTIONS_IN_SCOPES_PATH,
|
|
33
|
+
profile,
|
|
34
|
+
},
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: internalAuthToken,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...attributionHeaders,
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({ user_id: userId, scoped_actions: scopedActionsPayload }),
|
|
42
|
+
}, {
|
|
43
|
+
timeout: AuthorizationInternalService.getRequestTimeout(),
|
|
44
|
+
retryPolicy: AuthorizationInternalService.getRetriesPolicy(),
|
|
45
|
+
});
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof HttpFetcherError) {
|
|
50
|
+
AuthorizationInternalService.throwOnHttpError(err.status, 'canActionInScopeMultiple');
|
|
51
|
+
incrementAuthorizationError(scopeToResource(toCamelCase(scopedActionsPayload[0].scope)).resourceType, scopedActionsPayload[0].action, err.status);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Maps Platform API response to the expected format
|
|
58
|
+
*/
|
|
59
|
+
static mapResponse(response) {
|
|
60
|
+
if (!response) {
|
|
61
|
+
logger.error({ tag: 'platform-api-client', response }, 'PlatformApiClient: missing response');
|
|
62
|
+
throw new Error('PlatformApiClient: missing response');
|
|
63
|
+
}
|
|
64
|
+
return response.result.map(responseObject => {
|
|
65
|
+
const { scopedAction, permit } = responseObject;
|
|
66
|
+
const { scope } = scopedAction;
|
|
67
|
+
return {
|
|
68
|
+
...responseObject,
|
|
69
|
+
scopedAction: { ...scopedAction, scope: toCamelCase(scope) },
|
|
70
|
+
permit: toCamelCase(permit),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Performs a complete authorization check using the Platform API
|
|
76
|
+
*/
|
|
77
|
+
static async checkPermissions(profile, internalAuthToken, userId, scopedActions) {
|
|
78
|
+
const scopedActionsPayload = this.buildRequestPayload(scopedActions);
|
|
79
|
+
const platformResponse = await this.fetchPermissions(profile, internalAuthToken, userId, scopedActionsPayload);
|
|
80
|
+
return this.mapResponse(platformResponse);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { PlatformApiClient };
|
|
@@ -7,7 +7,7 @@ export declare const METRICS: {
|
|
|
7
7
|
export declare function setPrometheus(customPrometheus: any): void;
|
|
8
8
|
export declare function getMetricsManager(): any;
|
|
9
9
|
export declare function sendAuthorizationCheckResponseTimeMetric(resourceType: string, action: Action, isAuthorized: boolean, responseStatus: number, time: number, apiType?: 'platform' | 'graph'): void;
|
|
10
|
-
export declare function incrementAuthorizationSuccess(
|
|
11
|
-
export declare function incrementAuthorizationError(
|
|
12
|
-
export declare function setGraphAvailability(
|
|
10
|
+
export declare function incrementAuthorizationSuccess(resourceType: string, action: Action): void;
|
|
11
|
+
export declare function incrementAuthorizationError(resourceType: string, action: Action, statusCode: number): void;
|
|
12
|
+
export declare function setGraphAvailability(isAvailable: boolean): void;
|
|
13
13
|
//# sourceMappingURL=prometheus-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prometheus-service.d.ts","sourceRoot":"","sources":["../../src/prometheus-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"prometheus-service.d.ts","sourceRoot":"","sources":["../../src/prometheus-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAQzC,eAAO,MAAM,OAAO;;;;CAInB,CAAC;AAQF,wBAAgB,aAAa,CAAC,gBAAgB,KAAA,QAsB7C;AAED,wBAAgB,iBAAiB,QAEhC;AAED,wBAAgB,wCAAwC,CACtD,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,OAAO,EACrB,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,UAAU,GAAG,OAAoB,QAW3C;AAoBD,wBAAgB,6BAA6B,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAQjF;AAED,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,QAQnG;AAED,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,OAAO,QAQxD"}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
let prometheus = null;
|
|
2
2
|
let authorizationCheckResponseTimeMetric = null;
|
|
3
|
+
let authorizationSuccessMetric = null;
|
|
4
|
+
let authorizationErrorMetric = null;
|
|
5
|
+
let graphAvailabilityMetric = null;
|
|
3
6
|
const METRICS = {
|
|
4
7
|
AUTHORIZATION_CHECK: 'authorization_check',
|
|
5
8
|
AUTHORIZATION_CHECKS_PER_REQUEST: 'authorization_checks_per_request',
|
|
@@ -14,12 +17,16 @@ function setPrometheus(customPrometheus) {
|
|
|
14
17
|
prometheus = customPrometheus;
|
|
15
18
|
if (!prometheus) {
|
|
16
19
|
authorizationCheckResponseTimeMetric = null;
|
|
20
|
+
authorizationSuccessMetric = null;
|
|
21
|
+
authorizationErrorMetric = null;
|
|
22
|
+
graphAvailabilityMetric = null;
|
|
17
23
|
return;
|
|
18
24
|
}
|
|
19
25
|
const { METRICS_TYPES } = prometheus;
|
|
20
26
|
const metricsManager = getMetricsManager();
|
|
21
27
|
if (metricsManager) {
|
|
22
28
|
authorizationCheckResponseTimeMetric = metricsManager.addMetric(METRICS_TYPES.SUMMARY, authorizationCheckResponseTimeMetricConfig.name, authorizationCheckResponseTimeMetricConfig.labels, authorizationCheckResponseTimeMetricConfig.description);
|
|
29
|
+
initializeAdditionalMetrics();
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
function getMetricsManager() {
|
|
@@ -37,8 +44,63 @@ function sendAuthorizationCheckResponseTimeMetric(resourceType, action, isAuthor
|
|
|
37
44
|
// ignore
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
const authorizationSuccessMetricConfig = {
|
|
48
|
+
name: 'authorization_success_total',
|
|
49
|
+
labels: ['resourceType', 'action'],
|
|
50
|
+
description: 'Total number of successful authorization checks',
|
|
51
|
+
};
|
|
52
|
+
const authorizationErrorMetricConfig = {
|
|
53
|
+
name: 'authorization_error_total',
|
|
54
|
+
labels: ['resourceType', 'action', 'statusCode'],
|
|
55
|
+
description: 'Total number of authorization errors',
|
|
56
|
+
};
|
|
57
|
+
const graphAvailabilityMetricConfig = {
|
|
58
|
+
name: 'graph_api_availability',
|
|
59
|
+
labels: ['available'],
|
|
60
|
+
description: 'Graph API availability status',
|
|
61
|
+
};
|
|
62
|
+
function incrementAuthorizationSuccess(resourceType, action) {
|
|
63
|
+
try {
|
|
64
|
+
if (authorizationSuccessMetric) {
|
|
65
|
+
authorizationSuccessMetric.labels(resourceType, action).inc();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function incrementAuthorizationError(resourceType, action, statusCode) {
|
|
73
|
+
try {
|
|
74
|
+
if (authorizationErrorMetric) {
|
|
75
|
+
authorizationErrorMetric.labels(resourceType, action, statusCode).inc();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function setGraphAvailability(isAvailable) {
|
|
83
|
+
try {
|
|
84
|
+
if (graphAvailabilityMetric) {
|
|
85
|
+
graphAvailabilityMetric.labels(isAvailable ? 'true' : 'false').set(isAvailable ? 1 : 0);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Initialize additional metrics when prometheus is set
|
|
93
|
+
function initializeAdditionalMetrics() {
|
|
94
|
+
if (!prometheus) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { METRICS_TYPES } = prometheus;
|
|
98
|
+
const metricsManager = getMetricsManager();
|
|
99
|
+
if (metricsManager) {
|
|
100
|
+
authorizationSuccessMetric = metricsManager.addMetric(METRICS_TYPES.COUNTER, authorizationSuccessMetricConfig.name, authorizationSuccessMetricConfig.labels, authorizationSuccessMetricConfig.description);
|
|
101
|
+
authorizationErrorMetric = metricsManager.addMetric(METRICS_TYPES.COUNTER, authorizationErrorMetricConfig.name, authorizationErrorMetricConfig.labels, authorizationErrorMetricConfig.description);
|
|
102
|
+
graphAvailabilityMetric = metricsManager.addMetric(METRICS_TYPES.GAUGE, graphAvailabilityMetricConfig.name, graphAvailabilityMetricConfig.labels, graphAvailabilityMetricConfig.description);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
43
105
|
|
|
44
106
|
export { METRICS, getMetricsManager, incrementAuthorizationError, incrementAuthorizationSuccess, sendAuthorizationCheckResponseTimeMetric, setGraphAvailability, setPrometheus };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/testKit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAG9G,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAGF,eAAO,MAAM,sBAAsB,GAAI,WAAW,MAAM,EAAE,QAAQ,MAAM,EAAE,WAAW,QAAQ,EAAE,EAAE,QAAQ,MAAM,SAE9G,CAAC;AAEF,eAAO,MAAM,yBAAyB,YAErC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/testKit/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAG9G,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAGF,eAAO,MAAM,sBAAsB,GAAI,WAAW,MAAM,EAAE,QAAQ,MAAM,EAAE,WAAW,QAAQ,EAAE,EAAE,QAAQ,MAAM,SAE9G,CAAC;AAEF,eAAO,MAAM,yBAAyB,YAErC,CAAC;AA4BF,eAAO,MAAM,8BAA8B,GACzC,QAAQ,MAAM,EACd,gBAAgB,cAAc,EAC9B,gBAAgB,aAAa,MAG3B,SAAS,WAAW,EACpB,UAAU,YAAY,EACtB,MAAM,YAAY,KACjB,OAAO,CAAC,IAAI,CAYhB,CAAC"}
|
|
@@ -9,18 +9,20 @@ const clearTestPermittedActions = () => {
|
|
|
9
9
|
testPermittedActions = [];
|
|
10
10
|
};
|
|
11
11
|
const isActionAuthorized = (accountId, userId, resources, action) => {
|
|
12
|
+
// If no resources to check, deny access
|
|
13
|
+
if (resources.length === 0) {
|
|
14
|
+
return { isAuthorized: false };
|
|
15
|
+
}
|
|
12
16
|
return {
|
|
13
|
-
isAuthorized: resources.every(
|
|
17
|
+
isAuthorized: resources.every(resource => {
|
|
14
18
|
return testPermittedActions.some(combination => {
|
|
15
19
|
return (combination.accountId === accountId &&
|
|
16
20
|
combination.userId === userId &&
|
|
17
21
|
combination.action === action &&
|
|
18
22
|
combination.resources.some(combinationResource => {
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
JSON.stringify(combinationResource.wrapperData) === JSON.stringify(resource.wrapperData));
|
|
23
|
-
});
|
|
23
|
+
return (combinationResource.id === resource.id &&
|
|
24
|
+
combinationResource.type === resource.type &&
|
|
25
|
+
JSON.stringify(combinationResource.wrapperData) === JSON.stringify(resource.wrapperData));
|
|
24
26
|
}));
|
|
25
27
|
});
|
|
26
28
|
}),
|
|
@@ -32,11 +34,11 @@ const getTestAuthorizationMiddleware = (action, resourceGetter, contextGetter) =
|
|
|
32
34
|
const { userId, accountId } = contextGetter(request);
|
|
33
35
|
const resources = resourceGetter(request);
|
|
34
36
|
const { isAuthorized } = isActionAuthorized(accountId, userId, resources, action);
|
|
35
|
-
AuthorizationInternalService.markAuthorized(request);
|
|
36
37
|
if (!isAuthorized) {
|
|
37
38
|
response.status(403).json({ message: 'Access denied' });
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
41
|
+
AuthorizationInternalService.markAuthorized(request);
|
|
40
42
|
next();
|
|
41
43
|
};
|
|
42
44
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ResourceType = string;
|
|
2
|
+
export type ResourceId = number;
|
|
3
|
+
export type ActionName = string;
|
|
4
|
+
export type GraphIsAllowedDto = Record<ResourceType, Record<ResourceId, ActionName[]>>;
|
|
5
|
+
export type GraphPermissionResult = {
|
|
6
|
+
can: boolean;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
export type GraphPermissionResults = Record<ActionName, GraphPermissionResult>;
|
|
10
|
+
export type GraphIsAllowedResponse = Record<ResourceType, Record<string, GraphPermissionResults>>;
|
|
11
|
+
//# sourceMappingURL=graph-api.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-api.types.d.ts","sourceRoot":"","sources":["../../../src/types/graph-api.types.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAClC,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC;AAChC,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC;AAEhC,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC;AAEvF,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,EAAE,OAAO,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAGF,MAAM,MAAM,sBAAsB,GAAG,MAAM,CAAC,UAAU,EAAE,qBAAqB,CAAC,CAAC;AAI/E,MAAM,MAAM,sBAAsB,GAAG,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|