@mindline/sync 1.0.73 → 1.0.75

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/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  //index.ts - published interface - AAD implementations, facade to Mindline Config API
2
2
  import * as signalR from "@microsoft/signalr"
3
+ import { AccountInfo } from "@azure/msal-common";
3
4
  import { IPublicClientApplication, AuthenticationResult } from "@azure/msal-browser"
4
5
  import { deserializeArray } from 'class-transformer';
5
- import { defineHeaders, adminDelete, adminPost, adminsGet, configConsentReadPut, configConsentWritePut, configDelete, configsGet, configPost, configPut, initPost, readerPost, tenantPost, tenantDelete, tenantsGet, workspacePut, workspacesGet } from './hybridspa';
6
+ import { processErrors, adminDelete, adminPost, adminsGet, configConsentReadPut, configConsentWritePut, configDelete, configsGet, configPost, configPut, initPost, readerPost, tenantPost, tenantDelete, tenantsGet, workspacePut, workspacesGet } from './hybridspa';
6
7
  import { version } from './package.json';
7
8
  import users from "./users.json";
8
9
  import tenants from "./tenants.json";
@@ -10,6 +11,8 @@ import configs from "./configs.json";
10
11
  import workspaces from "./workspaces.json";
11
12
  import tasksData from "./tasks";
12
13
  import syncmilestones from './syncmilestones';
14
+ import resources from './resources';
15
+ import actors from './actors';
13
16
  import { log } from "console";
14
17
  const FILTER_FIELD = "workspaceIDs";
15
18
  // called by unit tests
@@ -28,6 +31,11 @@ export class APIResult {
28
31
  array: Array<Object> | null;
29
32
  constructor() { this.result = true; this.status = 200; this.error = ""; this.version = version; this.array = null; }
30
33
  }
34
+ export class azureConfig {
35
+ // azure graph REST API endpoints
36
+ static azureElevateAccess: string = "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01";
37
+ static azureListRootAssignments: string = "https://management.azure.com/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&$filter=principalId+eq+";
38
+ };
31
39
  export class mindlineConfig {
32
40
  static environmentTag: string = "dev";
33
41
  // config API endpoints
@@ -119,7 +127,9 @@ export class User {
119
127
  workspaceIDs: string;
120
128
  session: string; // button text
121
129
  spacode: string; // to get front end access token
122
- accessToken: string; // front end access token
130
+ graphAccessToken: string; // front end graph access token
131
+ mindlineAccessToken: string; // front end mindline access token
132
+ azureAccessToken: string; // front end azure access token
123
133
  loginHint: string; // to help sign out without prompt
124
134
  scopes: string[]; // to detect if incremental consent has happened
125
135
  authTS: Date; // timestamp user was authenticated
@@ -136,7 +146,9 @@ export class User {
136
146
  this.workspaceIDs = "";
137
147
  this.session = "Sign In";
138
148
  this.spacode = "";
139
- this.accessToken = "";
149
+ this.graphAccessToken = "";
150
+ this.mindlineAccessToken = "";
151
+ this.azureAccessToken = "";
140
152
  this.loginHint = "";
141
153
  this.scopes = new Array();
142
154
  this.authTS = new Date(0);
@@ -424,7 +436,7 @@ export class InitInfo {
424
436
  newuser.workspaceIDs = user.workspaceIDs;
425
437
  newuser.session = user.session;
426
438
  newuser.spacode = user.spacode;
427
- newuser.accessToken = user.accessToken;
439
+ newuser.graphAccessToken = user.graphAccessToken;
428
440
  newuser.loginHint = user.loginHint;
429
441
  newuser.scopes = user.scopes;
430
442
  newuser.authTS = new Date(user.authTS);
@@ -1228,14 +1240,153 @@ export class TenantNode {
1228
1240
  }
1229
1241
  }
1230
1242
  }
1243
+ export class ResourceArray {
1244
+ resourceNodes: ResourceNode[];
1245
+ constructor(bInitialize: boolean, bClearLocalStorage: boolean) {
1246
+ this.resourceNodes = new Array<ResourceNode>();
1247
+ if (bInitialize) {
1248
+ this.init(bClearLocalStorage);
1249
+ }
1250
+ }
1251
+ // get resource data from localStorage or file
1252
+ init(bClearLocalStorage: boolean): void {
1253
+ console.log(`Calling ResourceArray::init(bClearLocalStorage: ${bClearLocalStorage ? "true" : "false"})`);
1254
+ // if we have a non-empty string value stored, read it from localStorage
1255
+ if (storageAvailable("localStorage")) {
1256
+ let result = localStorage.getItem("ResourceArray");
1257
+ if (result != null && typeof result === "string" && result !== "") {
1258
+ if (bClearLocalStorage) {
1259
+ localStorage.removeItem("ResourceArray");
1260
+ }
1261
+ else {
1262
+ // read entire object from localstorage
1263
+ let raString: string = result;
1264
+ let resourceArray: ResourceArray = JSON.parse(raString);
1265
+ this.resourceNodes = resourceArray.resourceNodes;
1266
+ return;
1267
+ }
1268
+ }
1269
+ }
1270
+ // if storage unavailable or we were just asked to clear, read resources from file
1271
+ var resourceNodesString = JSON.stringify(resources);
1272
+ try {
1273
+ this.resourceNodes = deserializeArray(ResourceNode, resourceNodesString);
1274
+ } catch (e) {
1275
+ debugger;
1276
+ }
1277
+ }
1278
+ // read
1279
+ async read(instance: IPublicClientApplication, user: User): Promise<ResourceArray> {
1280
+ let resources: ResourceArray = new ResourceArray(false, false);
1281
+ resources.resourceNodes = await readResources(instance, user);
1282
+ return resources;
1283
+ }
1284
+ // save resource data to localstorage
1285
+ save(): void {
1286
+ // if we have localStorage, save resources
1287
+ if (storageAvailable("localStorage")) {
1288
+ let raString: string = JSON.stringify(this);
1289
+ localStorage.setItem("ResourceArray", raString);
1290
+ }
1291
+ }
1292
+ }
1293
+ export class ResourceNode {
1294
+ type: string;
1295
+ resource: string;
1296
+ cost: number;
1297
+ expanded: boolean;
1298
+ resources: ResourceNode[];
1299
+ constructor(type: string, resource: string, cost: number) {
1300
+ this.type = type;
1301
+ this.resource = resource;
1302
+ this.cost = cost;
1303
+ this.expanded = false;
1304
+ this.resources = new Array<ResourceNode>();
1305
+ }
1306
+ }
1307
+ export class ActorArray {
1308
+ actorNodes: ActorNode[];
1309
+ constructor(bClearLocalStorage: boolean) {
1310
+ this.actorNodes = new Array<ActorNode>();
1311
+ this.init(bClearLocalStorage);
1312
+ }
1313
+ // get initial data from localStorage or file
1314
+ init(bClearLocalStorage: boolean): void {
1315
+ console.log(`Calling ResourceArray::init(bClearLocalStorage: ${bClearLocalStorage ? "true" : "false"})`);
1316
+ // if we have a non-empty string value stored, read it from localStorage
1317
+ if (storageAvailable("localStorage")) {
1318
+ let result = localStorage.getItem("RBACActors");
1319
+ if (result != null && typeof result === "string" && result !== "") {
1320
+ if (bClearLocalStorage) {
1321
+ localStorage.removeItem("RBACActors");
1322
+ }
1323
+ else {
1324
+ let actorArrayString: string = result;
1325
+ let aaFromLocalStorage: ActorArray = JSON.parse(actorArrayString);
1326
+ this.actorNodes = aaFromLocalStorage.actorNodes;
1327
+ return;
1328
+ }
1329
+ }
1330
+ }
1331
+ // if storage unavailable or we were just asked to clear, read defaults to enable usable UI
1332
+ var actorsString = JSON.stringify(actors);
1333
+ try {
1334
+ this.actorNodes = deserializeArray(ActorNode, actorsString);
1335
+ } catch (e) {
1336
+ debugger;
1337
+ }
1338
+ }
1339
+ }
1340
+ export class ActorNode {
1341
+ type: string;
1342
+ actor: string;
1343
+ resource: string;
1344
+ role: string;
1345
+ updatedby: string;
1346
+ updatedon: string;
1347
+ actors: ActorNode[];
1348
+ constructor(type: string, actor: string, resource: string, role: string, updatedby: string, updatedon: string) {
1349
+ this.type = type;
1350
+ this.actor = resource;
1351
+ this.resource = resource;
1352
+ this.role = role;
1353
+ this.updatedby = updatedby;
1354
+ this.updatedon = updatedon;
1355
+ this.actors = new Array<ActorNode>();
1356
+ }
1357
+ }
1231
1358
  // ======================= Azure AD Graph API ===============================
1359
+ // TODO: this is where you want to trigger a re-authentication if token expires
1360
+ async function graphDefineHeaders(
1361
+ instance: IPublicClientApplication,
1362
+ user: User
1363
+ ): Promise<Headers> {
1364
+ const headers = new Headers();
1365
+ headers.append("Content-Type", "application/json");
1366
+ headers.append("accept", "*/*");
1367
+ // authorization header - if needed, retrieve and cache access token
1368
+ if (user.graphAccessToken == null || user.graphAccessToken === "") {
1369
+ try {
1370
+ let response: AuthenticationResult = await instance.acquireTokenByCode({
1371
+ code: user.spacode,
1372
+ });
1373
+ user.graphAccessToken = response.accessToken; // cache access token
1374
+ console.log("Front end token acquired: " + user.graphAccessToken.slice(0, 20));
1375
+ }
1376
+ catch (error: any) {
1377
+ console.log("Front end token failure: " + error);
1378
+ }
1379
+ }
1380
+ headers.append("Authorization", `Bearer ${user.graphAccessToken}`);
1381
+ return headers;
1382
+ }
1232
1383
  export async function groupsGet(instance: IPublicClientApplication, user: User | undefined, groupSearchString: string): Promise<{ groups: Group[], error: string }> {
1233
1384
  // need a logged in user to get graph users
1234
1385
  if (user == null || user.spacode == "") {
1235
1386
  return { groups: [], error: `500: invalid user passed to groupsGet` };
1236
1387
  }
1237
1388
  // create headers
1238
- const headers = await defineHeaders(instance, user);
1389
+ const headers = await graphDefineHeaders(instance, user);
1239
1390
  let options = { method: "GET", headers: headers };
1240
1391
  // make /groups endpoint call
1241
1392
  try {
@@ -1286,7 +1437,7 @@ export async function oauth2PermissionGrantsSet(instance: IPublicClientApplicati
1286
1437
  let grantsurl: string = getGraphEndpoint(loggedInUser.authority);
1287
1438
  grantsurl += graphConfig.graphOauth2PermissionGrantsPredicate + `/${id}`;
1288
1439
  let scopesBody: string = `{ "scope": "${scopes}" }`;
1289
- const headers = await defineHeaders(instance, loggedInUser);
1440
+ const headers = await graphDefineHeaders(instance, loggedInUser);
1290
1441
  let options: RequestInit = { method: "PATCH", headers: headers, body: scopesBody };
1291
1442
  let response = await fetch(grantsurl, options);
1292
1443
  if (response.status == 204 && response.statusText == "No Content") {
@@ -1472,12 +1623,12 @@ export async function tenantRelationshipsGetByDomain(loggedInUser: User, tenant:
1472
1623
  // do we already have a valid tenant name? if so, nothing to add
1473
1624
  if (tenant.name != null && tenant.name !== "") return false;
1474
1625
  // if needed, retrieve and cache access token
1475
- if (loggedInUser.accessToken != null && loggedInUser.accessToken === "") {
1626
+ if (loggedInUser.graphAccessToken != null && loggedInUser.graphAccessToken === "") {
1476
1627
  console.log(`tenantRelationshipsGetByDomain called with invalid logged in user: ${loggedInUser.name}`);
1477
1628
  try {
1478
- let response: AuthenticationResult = await instance.acquireTokenByCode({ code: loggedInUser.spacode });
1479
- loggedInUser.accessToken = response.accessToken; // cache access token on the user
1480
- console.log("tenantRelationshipsGetByDomain: Front end token acquired: " + loggedInUser.accessToken.slice(0, 20));
1629
+ let response: AuthenticationResult = await instance.acquireTokenByCode({ code: loggedInUser.spacode, scopes: ["user.read", "contacts.read", "CrossTenantInformation.ReadBasic.All"] });
1630
+ loggedInUser.graphAccessToken = response.accessToken; // cache access token on the user
1631
+ console.log("tenantRelationshipsGetByDomain: Front end token acquired: " + loggedInUser.graphAccessToken.slice(0, 20));
1481
1632
  }
1482
1633
  catch (error: any) {
1483
1634
  console.log("tenantRelationshipsGetByDomain: Front end token failure: " + error);
@@ -1486,7 +1637,7 @@ export async function tenantRelationshipsGetByDomain(loggedInUser: User, tenant:
1486
1637
  }
1487
1638
  // prepare Authorization headers as part of options
1488
1639
  const headers = new Headers();
1489
- const bearer = `Bearer ${loggedInUser.accessToken}`;
1640
+ const bearer = `Bearer ${loggedInUser.graphAccessToken}`;
1490
1641
  headers.append("Authorization", bearer);
1491
1642
  let options = { method: "GET", headers: headers };
1492
1643
  // make tenant endpoint call
@@ -1530,11 +1681,11 @@ export async function tenantRelationshipsGetById(loggedInUser: User, tenant: Ten
1530
1681
  console.log("**** tenantRelationshipsGetById");
1531
1682
  if (debug) debugger;
1532
1683
  // if needed, retrieve and cache access token
1533
- if (loggedInUser.accessToken === "") {
1684
+ if (loggedInUser.graphAccessToken === "") {
1534
1685
  try {
1535
- let response: AuthenticationResult = await instance.acquireTokenByCode({ code: loggedInUser.spacode });
1536
- loggedInUser.accessToken = response.accessToken; // cache access token
1537
- console.log("tenantRelationshipsGetById: Front end token acquired: " + loggedInUser.accessToken.slice(0, 20));
1686
+ let response: AuthenticationResult = await instance.acquireTokenByCode({ code: loggedInUser.spacode, scopes: ["user.read", "contacts.read", "CrossTenantInformation.ReadBasic.All"] });
1687
+ loggedInUser.graphAccessToken = response.accessToken; // cache access token
1688
+ console.log("tenantRelationshipsGetById: Front end token acquired: " + loggedInUser.graphAccessToken.slice(0, 20));
1538
1689
  }
1539
1690
  catch (error: any) {
1540
1691
  console.log("tenantRelationshipsGetById: Front end token failure: " + error);
@@ -1543,7 +1694,7 @@ export async function tenantRelationshipsGetById(loggedInUser: User, tenant: Ten
1543
1694
  }
1544
1695
  // prepare Authorization headers as part of options
1545
1696
  const headers = new Headers();
1546
- const bearer = `Bearer ${loggedInUser.accessToken}`;
1697
+ const bearer = `Bearer ${loggedInUser.graphAccessToken}`;
1547
1698
  headers.append("Authorization", bearer);
1548
1699
  let options = { method: "GET", headers: headers };
1549
1700
  // make tenant endpoint call
@@ -1600,7 +1751,7 @@ export async function tenantUnauthenticatedLookup(tenant: Tenant, debug: boolean
1600
1751
  openidEndpoint += "/.well-known/openid-configuration";
1601
1752
  console.log("Attempting GET from openid well-known endpoint: ", openidEndpoint);
1602
1753
  response = await fetch(openidEndpoint);
1603
- if (response.status == 200 && response.statusText == "OK") {
1754
+ if (response.status == 200) {
1604
1755
  let data = await response.json();
1605
1756
  if (data) {
1606
1757
  // store tenant ID and authority
@@ -1640,7 +1791,7 @@ export async function userDelegatedScopesGet(instance: IPublicClientApplication,
1640
1791
  return { scopes: null, id: null, error: `500: invalid parameter(s) passed to getUserDelegatedScopes` };
1641
1792
  }
1642
1793
  // create headers
1643
- const headers = await defineHeaders(instance, loggedInUser);
1794
+ const headers = await graphDefineHeaders(instance, loggedInUser);
1644
1795
  let options: RequestInit = { method: "GET", headers: headers };
1645
1796
  try {
1646
1797
  // first, cache Graph resource ID (service principal) for this tenant if we don't have it already
@@ -1696,12 +1847,12 @@ export async function userDelegatedScopesRemove(instance: IPublicClientApplicati
1696
1847
  export async function usersGet(instance: IPublicClientApplication, user: User | undefined): Promise<{ users: string[], error: string }> {
1697
1848
  // need a logged in user to get graph users
1698
1849
  if (user == null || user.spacode == "") {
1699
- return { users: [], error: `500: invalid user passed to groupsGet` };
1850
+ return { users: [], error: `500: invalid user passed to usersGet` };
1700
1851
  }
1701
1852
  // make /users endpoint call
1702
1853
  try {
1703
1854
  // create headers
1704
- const headers = await defineHeaders(instance, user);
1855
+ const headers = await graphDefineHeaders(instance, user);
1705
1856
  let options = { method: "GET", headers: headers };
1706
1857
  let usersEndpoint = getGraphEndpoint(user.authority);
1707
1858
  usersEndpoint += graphConfig.graphUsersPredicate;
@@ -1796,7 +1947,8 @@ export async function configsRefresh(instance: IPublicClientApplication, authori
1796
1947
  export async function initGet(instance: IPublicClientApplication, user: User, ii: InitInfo, tasks: TaskArray, debug: boolean): Promise<APIResult> {
1797
1948
  console.log(`>>>>>> initGet`);
1798
1949
  let result: APIResult = new APIResult();
1799
- if (debug) debugger;
1950
+ if (debug)
1951
+ debugger;
1800
1952
  // lookup authority for this user (the lookup call does it based on domain, but TID works as well to find authority)
1801
1953
  let tenant: Tenant = new Tenant();
1802
1954
  tenant.tid = user.tid;
@@ -2087,4 +2239,146 @@ async function workspaceInfoGet(instance: IPublicClientApplication, user: User,
2087
2239
  result.result = false;
2088
2240
  result.status = 500;
2089
2241
  return result;
2242
+ }
2243
+ // ======================= Azure REST API ===============================
2244
+ // TODO: this is where you want to trigger a re-authentication if token expires
2245
+ async function azureDefineHeaders(
2246
+ instance: IPublicClientApplication,
2247
+ user: User
2248
+ ): Promise<Headers> {
2249
+ const headers = new Headers();
2250
+ headers.append("Content-Type", "application/json");
2251
+ headers.append("accept", "*/*");
2252
+ // authorization header - if needed, retrieve and cache access token
2253
+ if (user.azureAccessToken == null || user.azureAccessToken === "") {
2254
+ try {
2255
+ let accounts: AccountInfo[] = instance.getAllAccounts();
2256
+ let homeAccountId = user.oid + "." + user.tid;
2257
+ let account: AccountInfo = null;
2258
+ for (let i: number = 0; i < accounts.length; i++) {
2259
+ if (accounts[i].homeAccountId == homeAccountId) {
2260
+ account = accounts[i];
2261
+ }
2262
+ }
2263
+ debugger;
2264
+ let response: AuthenticationResult = await instance.acquireTokenSilent({
2265
+ scopes: ["https://management.azure.com/user_impersonation"],
2266
+ account: account
2267
+ });
2268
+ user.azureAccessToken = response.accessToken; // cache access token
2269
+ console.log("Front end token acquired silently: " + user.azureAccessToken.slice(0, 20));
2270
+ }
2271
+ catch (error: any) {
2272
+ try {
2273
+ console.log("Front end token silent acquisition failure: " + error);
2274
+ // fallback to redirect if silent acquisition fails
2275
+ let accounts: AccountInfo[] = instance.getAllAccounts();
2276
+ let homeAccountId = user.oid + "." + user.tid;
2277
+ let account: AccountInfo = null;
2278
+ for (let i: number = 0; i < accounts.length; i++) {
2279
+ if (accounts[i].homeAccountId == homeAccountId) {
2280
+ account = accounts[i];
2281
+ }
2282
+ }
2283
+ instance.acquireTokenRedirect({
2284
+ scopes: ["https://management.azure.com/user_impersonation"],
2285
+ account: account
2286
+ });
2287
+ }
2288
+ catch (error: any) {
2289
+ console.log("Front end token popup acquisition failure: " + error);
2290
+ }
2291
+ }
2292
+ }
2293
+ headers.append("Authorization", `Bearer ${user.azureAccessToken}`);
2294
+ return headers;
2295
+ }
2296
+ export async function canListRootAssignments(instance: IPublicClientApplication, user: User): Promise<boolean> {
2297
+ // need a logged in user to call Azure REST API
2298
+ if (user == null || user.spacode == "") {
2299
+ return false;
2300
+ }
2301
+ // make Azure REST API call
2302
+ try {
2303
+ // create headers
2304
+ const headers = await azureDefineHeaders(instance, user);
2305
+ let options = { method: "GET", headers: headers };
2306
+ let listrootassignmentsEndpoint: string = azureConfig.azureListRootAssignments;
2307
+ listrootassignmentsEndpoint += "'";
2308
+ listrootassignmentsEndpoint += user.oid;
2309
+ listrootassignmentsEndpoint += "'";
2310
+ let response = await fetch(listrootassignmentsEndpoint, options);
2311
+ if (response.status == 200) {
2312
+ let data = await response.json();
2313
+ debugger;
2314
+ console.log("Successful call to Azure Resource Graph list root assignments");
2315
+ }
2316
+ else {
2317
+ console.log(await processErrors(response));
2318
+ return false;
2319
+ }
2320
+ }
2321
+ catch (error: any) {
2322
+ console.log(error);
2323
+ return false;
2324
+ }
2325
+ return true;
2326
+ }
2327
+ export async function elevateGlobalAdminToUserAccessAdmin(instance: IPublicClientApplication, user: User): Promise<boolean> {
2328
+ // need a logged in user to call Azure REST API
2329
+ if (user == null || user.spacode == "") {
2330
+ return false;
2331
+ }
2332
+ // make Azure REST API call
2333
+ try {
2334
+ // create headers
2335
+ const headers = await azureDefineHeaders(instance, user);
2336
+ let options = { method: "POST", headers: headers };
2337
+ let elevateaccessEndpoint: string = azureConfig.azureElevateAccess;
2338
+ let response = await fetch(elevateaccessEndpoint, options);
2339
+ if (response.status == 200) {
2340
+ console.log("Successful call to Azure Resource Graph list root assignments");
2341
+ }
2342
+ else {
2343
+ console.log(await processErrors(response));
2344
+ return false;
2345
+ }
2346
+ }
2347
+ catch (error: any) {
2348
+ console.log(error);
2349
+ return false;
2350
+ }
2351
+ return true;
2352
+ }
2353
+ async function readResources(instance: IPublicClientApplication, user: User): Promise<ResourceNode[]> {
2354
+ // need a logged in user to call Azure REST API
2355
+ let resources: ResourceNode[] = new Array<ResourceNode>();
2356
+ if (user == null || user.spacode == "") {
2357
+ return resources;
2358
+ }
2359
+ // make Azure REST API call
2360
+ try {
2361
+ // create headers
2362
+ const headers = await azureDefineHeaders(instance, user);
2363
+ let options = { method: "GET", headers: headers };
2364
+ let listrootassignmentsEndpoint: string = azureConfig.azureListRootAssignments;
2365
+ listrootassignmentsEndpoint += "'";
2366
+ listrootassignmentsEndpoint += user.oid;
2367
+ listrootassignmentsEndpoint += "'";
2368
+ let response = await fetch(listrootassignmentsEndpoint, options);
2369
+ if (response.status == 200) {
2370
+ let data = await response.json();
2371
+ debugger;
2372
+ console.log("Successful call to Azure Resource Graph list root assignments");
2373
+ }
2374
+ else {
2375
+ console.log(await processErrors(response));
2376
+ return false;
2377
+ }
2378
+ }
2379
+ catch (error: any) {
2380
+ console.log(error);
2381
+ return false;
2382
+ }
2383
+ return true;
2090
2384
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mindline/sync",
3
3
  "type": "module",
4
- "version": "1.0.73",
4
+ "version": "1.0.75",
5
5
  "types": "index.d.ts",
6
6
  "exports": "./index.ts",
7
7
  "description": "sync is a node.js package encapsulating javscript classes required for configuring Mindline sync service.",
package/resources.json ADDED
@@ -0,0 +1,58 @@
1
+ [
2
+ {
3
+ "type": "mg",
4
+ "resource": "Tenant Root Group",
5
+ "cost": 0,
6
+ "expanded": true,
7
+ "resources": [
8
+ {
9
+ "type": "sub",
10
+ "resource": "Applications",
11
+ "cost": 16677.52,
12
+ "expanded": true,
13
+ "resources": [
14
+ {
15
+ "type": "rg",
16
+ "resource": "ssfdev",
17
+ "cost": 7500.08,
18
+ "expanded": true,
19
+ "resources": [
20
+ {
21
+ "type": "resources",
22
+ "resource": "SSFServices",
23
+ "cost": 0,
24
+ "expanded": false,
25
+ "resources": [
26
+ ]
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "type": "sub",
34
+ "resource": "Infrastructure",
35
+ "cost": 8737.58,
36
+ "expanded": true,
37
+ "resources": [
38
+ {
39
+ "type": "rg",
40
+ "resource": "SFFA_Prod_Mgmt_Shared_Resources_RG",
41
+ "cost": 7500.08,
42
+ "expanded": true,
43
+ "resources": [
44
+ {
45
+ "type": "resources",
46
+ "resource": "SSFA-Prod-UTIL-01",
47
+ "cost": 0,
48
+ "expanded": false,
49
+ "resources": [
50
+ ]
51
+ }
52
+ ]
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
58
+ ]