From 175a7cfb1a7a2378f2f23638d5674ef768342fe1 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Fri, 23 Aug 2024 17:09:44 +0200 Subject: [PATCH] feat(backend): start of handling grant token rotation more gracefully (#2887) * feat(backend): add grantService.getOrCreate method * feat(backend): update grant lookup call * feat(backend): add deletedAt to grants table * feat(backend): add migrations for grants table * chore(backend): separating into functions * chore(backend): adding authServerService to list of services * test(backend): adding tests for getOrCreate and getExistingGrant functions * chore(backend): merge migrations * feat(backend): add subset actions if present --- .../20240820101201_add_deleted_at_grants.js | 21 + packages/backend/src/app.ts | 4 + packages/backend/src/index.ts | 1 + .../backend/src/open_payments/grant/errors.ts | 8 + .../backend/src/open_payments/grant/model.ts | 1 + .../src/open_payments/grant/service.test.ts | 477 +++++++++++++++++- .../src/open_payments/grant/service.ts | 174 ++++++- 7 files changed, 680 insertions(+), 6 deletions(-) create mode 100644 packages/backend/migrations/20240820101201_add_deleted_at_grants.js create mode 100644 packages/backend/src/open_payments/grant/errors.ts diff --git a/packages/backend/migrations/20240820101201_add_deleted_at_grants.js b/packages/backend/migrations/20240820101201_add_deleted_at_grants.js new file mode 100644 index 0000000000..7af33288ea --- /dev/null +++ b/packages/backend/migrations/20240820101201_add_deleted_at_grants.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('grants', (table) => { + table.dropUnique(['authServerId', 'accessType', 'accessActions']) + table.timestamp('deletedAt').nullable() + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('grants', (table) => { + table.unique(['authServerId', 'accessType', 'accessActions']) + table.dropColumn('deletedAt') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 4614e99380..c56544f38f 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -98,6 +98,8 @@ import { } from './open_payments/wallet_address/middleware' import { LoggingPlugin } from './graphql/plugin' +import { GrantService } from './open_payments/grant/service' +import { AuthServerService } from './open_payments/authServer/service' export interface AppContextData { logger: Logger container: AppContainer @@ -232,6 +234,8 @@ export interface AppServices { incomingPaymentService: Promise remoteIncomingPaymentService: Promise receiverService: Promise + grantService: Promise + authServerService: Promise streamServer: Promise webhookService: Promise quoteService: Promise diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bd72bba1fa..e438e8ba81 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -256,6 +256,7 @@ export function initIocContainer( container.singleton('grantService', async (deps) => { return await createGrantService({ authServerService: await deps.use('authServerService'), + openPaymentsClient: await deps.use('openPaymentsClient'), logger: await deps.use('logger'), knex: await deps.use('knex') }) diff --git a/packages/backend/src/open_payments/grant/errors.ts b/packages/backend/src/open_payments/grant/errors.ts new file mode 100644 index 0000000000..b12ec922f3 --- /dev/null +++ b/packages/backend/src/open_payments/grant/errors.ts @@ -0,0 +1,8 @@ +export enum GrantError { + GrantRequiresInteraction = 'GrantRequiresInteraction', + InvalidGrantRequest = 'InvalidGrantRequest' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isGrantError = (o: any): o is GrantError => + Object.values(GrantError).includes(o) diff --git a/packages/backend/src/open_payments/grant/model.ts b/packages/backend/src/open_payments/grant/model.ts index 6ccffd0949..ed2c77daa7 100644 --- a/packages/backend/src/open_payments/grant/model.ts +++ b/packages/backend/src/open_payments/grant/model.ts @@ -33,6 +33,7 @@ export class Grant extends BaseModel { public accessType!: AccessType public accessActions!: AccessAction[] public expiresAt?: Date | null + public deletedAt?: Date public get expired(): boolean { return !!this.expiresAt && this.expiresAt <= new Date() diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts index d3eaa8c55a..3e70c936ac 100644 --- a/packages/backend/src/open_payments/grant/service.test.ts +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -1,32 +1,50 @@ import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { Knex } from 'knex' - +import assert from 'assert' import { Grant } from './model' -import { CreateOptions, GrantService } from './service' +import { + CreateOptions, + GrantService, + ServiceDependencies, + getExistingGrant +} from './service' import { AuthServer } from '../authServer/model' import { initIocContainer } from '../..' import { AppServices } from '../../app' import { Config } from '../../config/app' import { createTestApp, TestContainer } from '../../tests/app' import { truncateTables } from '../../tests/tableManager' -import { AccessType, AccessAction } from '@interledger/open-payments' +import { + AccessType, + AccessAction, + AuthenticatedClient, + mockGrant, + mockAccessToken, + mockPendingGrant +} from '@interledger/open-payments' import { v4 as uuid } from 'uuid' +import { GrantError, isGrantError } from './errors' +import { AuthServerService } from '../authServer/service' describe('Grant Service', (): void => { let deps: IocContract let appContainer: TestContainer let grantService: GrantService + let openPaymentsClient: AuthenticatedClient + let authServerService: AuthServerService let knex: Knex beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) + grantService = await deps.use('grantService') + openPaymentsClient = await deps.use('openPaymentsClient') + authServerService = await deps.use('authServerService') knex = appContainer.knex }) beforeEach(async (): Promise => { - grantService = await deps.use('grantService') jest.useFakeTimers() jest.setSystemTime(Date.now()) }) @@ -201,4 +219,455 @@ describe('Grant Service', (): void => { } ) }) + + describe('getOrCreate', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).toBe(existingGrant.id) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + }) + + test('updates expired grant (by rotating existing token)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.ReadAll, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) + }) + + const openPaymentsGrantRequestSpy = jest.spyOn( + openPaymentsClient.grant, + 'request' + ) + + const rotatedAccessToken = mockAccessToken() + const managementId = uuid() + rotatedAccessToken.access_token.manage = `${faker.internet.url()}token/${managementId}` + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockResolvedValueOnce(rotatedAccessToken) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + id: existingGrant.id, + authServerId: existingGrant.authServerId, + accessToken: rotatedAccessToken.access_token.value, + expiresAt: new Date( + Date.now() + rotatedAccessToken.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).not.toHaveBeenCalled() + expect(openPaymentsTokenRotationSpy).toHaveBeenCalledWith({ + url: existingGrant.getManagementUrl(authServer.url), + accessToken: existingGrant.accessToken + }) + }) + + test('creates new grant when no prior existing grant', async () => { + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant).toMatchObject({ + authServerId: authServer.id, + accessType: options.accessType, + accessActions: options.accessActions, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() + }) + + test('creates new grant with additional subset actions', async () => { + const newOpenPaymentsGrant = mockGrant() + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce({ + ...newOpenPaymentsGrant + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll + ] + } + + const authServerServiceGetOrCreateSoy = jest.spyOn( + authServerService, + 'getOrCreate' + ) + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.accessActions.sort()).toEqual( + [ + AccessAction.Create, + AccessAction.ReadAll, + AccessAction.ListAll, + AccessAction.List, + AccessAction.Read + ].sort() + ) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalledWith( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + expect(authServerServiceGetOrCreateSoy).toHaveBeenCalled() + }) + + test('creates new grant and deletes old one after being unable to rotate existing token', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Read, + AccessAction.Create, + AccessAction.Complete + ], + expiresAt: new Date(Date.now() - 1000) + }) + + const managementId = uuid() + const newOpenPaymentsGrant = mockGrant() + newOpenPaymentsGrant.access_token.manage = `${faker.internet.url()}token/${managementId}` + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(newOpenPaymentsGrant) + + const openPaymentsTokenRotationSpy = jest + .spyOn(openPaymentsClient.token, 'rotate') + .mockImplementationOnce(() => { + throw new Error('Could not rotate token') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.Read] + } + + const grant = await grantService.getOrCreate(options) + + assert(!isGrantError(grant)) + expect(grant.id).not.toBe(existingGrant.id) + expect(grant).toMatchObject({ + accessType: options.accessType, + accessActions: options.accessActions, + authServerId: authServer.id, + accessToken: newOpenPaymentsGrant.access_token.value, + expiresAt: new Date( + Date.now() + newOpenPaymentsGrant.access_token.expires_in! * 1000 + ), + managementId + }) + expect(openPaymentsTokenRotationSpy).toHaveBeenCalled() + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + + const originalGrant = await Grant.query(knex).findById(existingGrant.id) + expect(originalGrant?.deletedAt).toBeDefined() + }) + + test('returns error if Open Payments grant request fails', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockImplementationOnce(() => { + throw new Error('Could not request grant') + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.InvalidGrantRequest) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + + test('returns error if Open Payments grant request returns a pending grant', async () => { + const openPaymentsGrantRequestSpy = jest + .spyOn(openPaymentsClient.grant, 'request') + .mockResolvedValueOnce(mockPendingGrant()) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.Create, AccessAction.ReadAll] + } + + const error = await grantService.getOrCreate(options) + + expect(error).toBe(GrantError.GrantRequiresInteraction) + expect(openPaymentsGrantRequestSpy).toHaveBeenCalled() + }) + }) + + describe('getExistingGrant', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('gets existing grant (identical match)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('gets existing grant (requested actions are a subset of saved actions)', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [ + AccessAction.Complete, + AccessAction.Create, + AccessAction.ReadAll + ] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toEqual({ ...existingGrant, authServer }) + }) + + test('ignores deleted grants', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll], + deletedAt: new Date() + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores different accessType', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.url, + accessType: AccessType.OutgoingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores different auth server url', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + + test('ignores insufficient accessActions', async () => { + await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const options = { + authServer: authServer.id, + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll, AccessAction.Create] + } + + await expect( + getExistingGrant({ knex } as ServiceDependencies, options) + ).resolves.toBeUndefined() + }) + }) + + describe('delete', (): void => { + let authServer: AuthServer + + beforeEach(async (): Promise => { + const authServerService = await deps.use('authServerService') + const url = faker.internet.url({ appendSlash: false }) + authServer = await authServerService.getOrCreate(url) + }) + + test('deletes grant', async () => { + const existingGrant = await Grant.query(knex).insertAndFetch({ + authServerId: authServer.id, + accessToken: uuid(), + managementId: uuid(), + accessType: AccessType.IncomingPayment, + accessActions: [AccessAction.ReadAll] + }) + + const now = new Date() + jest.setSystemTime(now) + + const grant = await grantService.delete(existingGrant.id) + + expect(grant.id).toBe(existingGrant.id) + expect(grant.deletedAt).toEqual(now) + }) + }) }) diff --git a/packages/backend/src/open_payments/grant/service.ts b/packages/backend/src/open_payments/grant/service.ts index ea8b68f106..b6ef161e80 100644 --- a/packages/backend/src/open_payments/grant/service.ts +++ b/packages/backend/src/open_payments/grant/service.ts @@ -1,16 +1,26 @@ import { Grant } from './model' import { AuthServerService } from '../authServer/service' import { BaseService } from '../../shared/baseService' -import { AccessAction, AccessType } from '@interledger/open-payments' +import { + AccessAction, + AccessToken, + AccessType, + AuthenticatedClient, + isPendingGrant +} from '@interledger/open-payments' +import { GrantError } from './errors' export interface GrantService { create(options: CreateOptions): Promise get(options: GrantOptions): Promise update(grant: Grant, options: UpdateOptions): Promise + getOrCreate(options: GrantOptions): Promise + delete(id: string): Promise } export interface ServiceDependencies extends BaseService { authServerService: AuthServerService + openPaymentsClient: AuthenticatedClient } export async function createGrantService( @@ -26,7 +36,9 @@ export async function createGrantService( return { get: (options) => getGrant(deps, options), create: (options) => createGrant(deps, options), - update: (grant, options) => updateGrant(deps, grant, options) + update: (grant, options) => updateGrant(deps, grant, options), + getOrCreate: (options) => getOrCreateGrant(deps, options), + delete: (id) => deleteGrant(deps, id) } } @@ -89,6 +101,164 @@ async function updateGrant( .withGraphFetched('authServer') } +async function getOrCreateGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + const existingGrant = await getExistingGrant(deps, options) + + if (!existingGrant) { + return requestNewGrant(deps, options) + } + + if (existingGrant.expired) { + return ( + (await rotateTokenAndUpdateGrant(deps, existingGrant)) ?? + (await requestNewGrant(deps, options)) + ) + } + + return existingGrant +} + +export async function getExistingGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + return await Grant.query(deps.knex) + .findOne({ + accessType: options.accessType + }) + .whereNull('deletedAt') + .andWhere('authServer.url', options.authServer) + // all options.accessActions are a subset of saved accessActions + // e.g. if [ReadAll, Create] is saved, requesting just [Create] would still match + .andWhere('accessActions', '@>', options.accessActions) + .withGraphJoined('authServer') +} + +async function requestNewGrant( + deps: ServiceDependencies, + options: GrantOptions +): Promise { + let openPaymentsGrant + try { + openPaymentsGrant = await deps.openPaymentsClient.grant.request( + { url: options.authServer }, + { + access_token: { + access: [ + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: options.accessType as any, + actions: options.accessActions + } + ] + }, + interact: { + start: ['redirect'] + } + } + ) + } catch (err) { + deps.logger.error( + { err, options }, + 'Received error requesting Open Payments grant' + ) + return GrantError.InvalidGrantRequest + } + + if (isPendingGrant(openPaymentsGrant)) { + deps.logger.error({ ...options }, 'Requested grant requires interaction') + return GrantError.GrantRequiresInteraction + } + + const { id: authServerId } = await deps.authServerService.getOrCreate( + options.authServer + ) + + return Grant.query(deps.knex) + .insertAndFetch({ + accessType: options.accessType, + accessActions: addSubsetActions(options.accessActions), + accessToken: openPaymentsGrant.access_token.value, + managementId: retrieveManagementId(openPaymentsGrant.access_token.manage), + authServerId, + expiresAt: openPaymentsGrant.access_token.expires_in + ? new Date( + Date.now() + openPaymentsGrant.access_token.expires_in * 1000 + ) + : undefined + }) + .withGraphFetched('authServer') +} + +async function rotateTokenAndUpdateGrant( + deps: ServiceDependencies, + grant: Grant +): Promise { + if (!grant.authServer) { + deps.logger.error( + { grantId: grant.id }, + 'Could not get auth server from grant during token rotation' + ) + return undefined + } + + let rotatedToken: AccessToken + + try { + rotatedToken = await deps.openPaymentsClient.token.rotate({ + url: grant.getManagementUrl(grant.authServer.url), + accessToken: grant.accessToken + }) + } catch (err) { + deps.logger.warn( + { err, authServerUrl: grant.authServer.url }, + 'Grant token rotation failed' + ) + await deleteGrant(deps, grant.id) + return undefined + } + + return updateGrant(deps, grant, { + accessToken: rotatedToken.access_token.value, + managementUrl: rotatedToken.access_token.manage, + expiresIn: rotatedToken.access_token.expires_in + }) +} + +async function deleteGrant( + deps: ServiceDependencies, + grantId: string +): Promise { + return Grant.query(deps.knex).updateAndFetchById(grantId, { + deletedAt: new Date() + }) +} + +function addSubsetActions(accessActions: AccessAction[]): AccessAction[] { + const newAccessActions = [...accessActions] + + // Read is a subset action of ReadAll + if ( + accessActions.includes(AccessAction.ReadAll) && + !accessActions.includes(AccessAction.Read) + ) { + newAccessActions.push(AccessAction.Read) + } + + // List is a subset action of ListAll + if ( + accessActions.includes(AccessAction.ListAll) && + !accessActions.includes(AccessAction.List) + ) { + newAccessActions.push(AccessAction.List) + } + + return newAccessActions +} + function retrieveManagementId(managementUrl: string): string { const managementUrlParts = managementUrl.split('/') const managementId = managementUrlParts.pop() || managementUrlParts.pop() // handle trailing slash