From d7d8931453c8c5e812da957e31547232ae059881 Mon Sep 17 00:00:00 2001 From: EvansA Date: Wed, 26 Apr 2023 10:56:38 +0300 Subject: [PATCH] Fix: Add retry handler for revoking permissions (#2515) --- .../permissions-action-creator.spec.ts | 79 ++++++++-- .../actions/permissions-action-creator.ts | 142 ++++++++++++------ .../permissions-action-creator.util.ts | 133 ++++++++-------- src/app/utils/fetch-retry-handler.ts | 23 +++ .../request/permissions/Permission.tsx | 28 ++-- src/messages/GE.json | 9 +- 6 files changed, 281 insertions(+), 133 deletions(-) create mode 100644 src/app/utils/fetch-retry-handler.ts diff --git a/src/app/services/actions/permissions-action-creator.spec.ts b/src/app/services/actions/permissions-action-creator.spec.ts index 2d0011241..bdcf456ca 100644 --- a/src/app/services/actions/permissions-action-creator.spec.ts +++ b/src/app/services/actions/permissions-action-creator.spec.ts @@ -1,8 +1,7 @@ import { FETCH_SCOPES_ERROR, FETCH_FULL_SCOPES_SUCCESS, - FETCH_URL_SCOPES_PENDING, - QUERY_GRAPH_STATUS + FETCH_URL_SCOPES_PENDING } from '../../../app/services/redux-constants'; import { @@ -299,7 +298,7 @@ describe('Permissions action creators', () => { }); describe('Revoke scopes', () => { - it('should return Default Scope error when user tries to dissent to default scope', () => { + it('should return Default Scope error when user tries to dissent to default scope', async () => { // Arrange const store_ = mockStore(mockState); jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); @@ -332,11 +331,48 @@ describe('Permissions action creators', () => { '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#permissionGrants' }) + const revokePermissionsUtil = await RevokePermissionsUtil.initialize('kkk'); + + jest.spyOn(revokePermissionsUtil, 'getUserPermissionChecks').mockResolvedValue({ + userIsTenantAdmin: false, + permissionBeingRevokedIsAllPrincipal: true, + grantsPayload: { + value: [ + { + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'profile.read User.Read', + id: 'SomeNiceId' + }, + { + clientId: '', + consentType: 'AllPrincipal', + principalId: null, + resourceId: '1234', + scope: 'profile.read User.Read Directory.Read.All' + } + ], + '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#permissionGrants' + } + }) + const expectedActions = [ { type: 'REVOKE_SCOPES_PENDING', response: null }, { type: 'REVOKE_SCOPES_ERROR', response: null }, { - type: QUERY_GRAPH_STATUS, + type: 'QUERY_GRAPH_STATUS', + response: { + statusText: 'Revoking ', + status: 'Please wait while we revoke this permission', + ok: false, + messageType: 0 + } + }, + { type: 'REVOKE_SCOPES_ERROR', response: null }, + { + type: 'QUERY_GRAPH_STATUS', response: { statusText: 'Failed', status: 'An error occurred when unconsenting. Please try again', @@ -356,7 +392,7 @@ describe('Permissions action creators', () => { }); }); - it('should return 401 when user does not have required permissions', () => { + it('should return 401 when user does not have required permissions', async () => { // Arrange const store_ = mockStore(mockState); jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); @@ -393,7 +429,17 @@ describe('Permissions action creators', () => { { type: 'REVOKE_SCOPES_PENDING', response: null }, { type: 'REVOKE_SCOPES_ERROR', response: null }, { - type: QUERY_GRAPH_STATUS, + type: 'QUERY_GRAPH_STATUS', + response: { + statusText: 'Revoking ', + status: 'Please wait while we revoke this permission', + ok: false, + messageType: 0 + } + }, + { type: 'REVOKE_SCOPES_ERROR', response: null }, + { + type: 'QUERY_GRAPH_STATUS', response: { statusText: 'Failed', status: 'An error occurred when unconsenting. Please try again', @@ -414,7 +460,7 @@ describe('Permissions action creators', () => { }); //revisit - it('should raise error when user attempts to dissent to an admin granted permission', () => { + it('should raise error when user attempts to dissent to an admin granted permission', async () => { // Arrange const store_ = mockStore(mockState); jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); @@ -454,7 +500,17 @@ describe('Permissions action creators', () => { { type: 'REVOKE_SCOPES_PENDING', response: null }, { type: 'REVOKE_SCOPES_ERROR', response: null }, { - type: QUERY_GRAPH_STATUS, + type: 'QUERY_GRAPH_STATUS', + response: { + statusText: 'Revoking ', + status: 'Please wait while we revoke this permission', + ok: false, + messageType: 0 + } + }, + { type: 'REVOKE_SCOPES_ERROR', response: null }, + { + type: 'QUERY_GRAPH_STATUS', response: { statusText: 'Failed', status: 'An error occurred when unconsenting. Please try again', @@ -466,11 +522,8 @@ describe('Permissions action creators', () => { // Act and Assert // @ts-ignore - return store_.dispatch(revokeScopes('Access.Read')) - // @ts-ignore - .then(() => { - expect(store_.getActions()).toEqual(expectedActions); - }); + await store_.dispatch(revokeScopes('Access.Read')); + expect(store_.getActions()).toEqual(expectedActions); }); }) }) \ No newline at end of file diff --git a/src/app/services/actions/permissions-action-creator.ts b/src/app/services/actions/permissions-action-creator.ts index a26896192..f5f83c212 100644 --- a/src/app/services/actions/permissions-action-creator.ts +++ b/src/app/services/actions/permissions-action-creator.ts @@ -228,6 +228,18 @@ const validateConsentedScopes = (scopeToBeConsented: string[], consentedScopes: return expectedScopes; } +interface IPermissionUpdate { + permissionBeingRevokedIsAllPrincipal: boolean; + userIsTenantAdmin: boolean; + revokePermissionUtil: RevokePermissionsUtil; + grantsPayload: IOAuthGrantPayload; + profile: IUser; + permissionToRevoke: string; + newScopesArray: string[]; + retryCount: number; + retryDelay: number; + dispatch: Function; +} export function revokeScopes(permissionToRevoke: string) { return async (dispatch: Function, getState: Function) => { const { consentedScopes, profile } = getState(); @@ -235,6 +247,7 @@ export function revokeScopes(permissionToRevoke: string) { const defaultUserScopes = DEFAULT_USER_SCOPES.split(' '); const revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); dispatch(revokeScopesPending()); + dispatchScopesStatus(dispatch, 'Please wait while we revoke this permission', 'Revoking ', 0); if (!consentedScopes || consentedScopes.length === 0) { dispatch(revokeScopesError()); @@ -243,38 +256,35 @@ export function revokeScopes(permissionToRevoke: string) { } const newScopesArray: string[] = consentedScopes.filter((scope: string) => scope !== permissionToRevoke); - const newScopesString: string = newScopesArray.join(' '); try { const { userIsTenantAdmin, permissionBeingRevokedIsAllPrincipal, grantsPayload } = await revokePermissionUtil. getUserPermissionChecks({ consentedScopes, requiredPermissions, defaultUserScopes, permissionToRevoke }); - - let updatedScopes; - if (permissionBeingRevokedIsAllPrincipal && userIsTenantAdmin) { - updatedScopes = await revokePermissionUtil. - getUpdatedAllPrincipalPermissionGrant(grantsPayload, permissionToRevoke); - } - else { - updatedScopes = await revokePermissionUtil. - updateSinglePrincipalPermissionGrant(grantsPayload, profile, newScopesString); + const retryCount = 0; + const retryDelay = 100; + const permissionsUpdateObject: IPermissionUpdate = { + permissionBeingRevokedIsAllPrincipal, userIsTenantAdmin, revokePermissionUtil, grantsPayload, + profile, permissionToRevoke, newScopesArray, retryCount, dispatch, retryDelay } + + const updatedScopes = await updatePermissions(permissionsUpdateObject) + + if (updatedScopes) { + dispatchScopesStatus(dispatch, 'Permission revoked', 'Success', 4); + dispatch(getConsentedScopesSuccess(updatedScopes)); + dispatch(revokeScopesSuccess()); + trackRevokeConsentEvent(REVOKE_STATUS.success, permissionToRevoke); } - - if (updatedScopes.length !== newScopesArray.length) { + else{ throw new RevokeScopesError({ errorText: 'Scopes not updated', statusText: 'An error occurred when unconsenting', status: '500', messageType: 1 }) } - - dispatchRevokeScopesStatus(dispatch, 'Permission revoked', 'Success', 4); - dispatch(getConsentedScopesSuccess(updatedScopes)); - dispatch(revokeScopesSuccess()); - trackRevokeConsentEvent(REVOKE_STATUS.success, permissionToRevoke); } catch (errorMessage: any) { if (errorMessage instanceof RevokeScopesError || errorMessage instanceof Function) { const { errorText, statusText, status, messageType } = errorMessage - dispatchRevokeScopesStatus(dispatch, statusText, status, messageType); + dispatchScopesStatus(dispatch, statusText, status, messageType); const permissionObject = { permissionToRevoke, statusCode: statusText, @@ -285,13 +295,46 @@ export function revokeScopes(permissionToRevoke: string) { else { const { code, message } = errorMessage; trackRevokeConsentEvent(REVOKE_STATUS.failure, 'Failed to revoke consent'); - dispatchRevokeScopesStatus(dispatch, message ? message : 'Failed to revoke consent', code ? code : 'Failed', 1); + dispatchScopesStatus(dispatch, message ? message : 'Failed to revoke consent', code ? code : 'Failed', 1); } } } } -const dispatchRevokeScopesStatus = (dispatch: Function, statusText: string, status: string, messageType: number) => { +async function updatePermissions(permissionsUpdateObject: IPermissionUpdate): +Promise { + const { + permissionBeingRevokedIsAllPrincipal, userIsTenantAdmin, revokePermissionUtil, grantsPayload, + profile, permissionToRevoke, newScopesArray, retryCount, dispatch, retryDelay } = permissionsUpdateObject; + let isRevokeSuccessful; + const maxRetryCount = 7; + const newScopesString = newScopesArray.join(' '); + + if (permissionBeingRevokedIsAllPrincipal && userIsTenantAdmin) { + isRevokeSuccessful = await revokePermissionUtil.getUpdatedAllPrincipalPermissionGrant(grantsPayload, + permissionToRevoke); + } else { + isRevokeSuccessful = await revokePermissionUtil.updateSinglePrincipalPermissionGrant(grantsPayload, profile, + newScopesString); + } + + if (isRevokeSuccessful) { + return newScopesString.split(' '); + } + else if((retryCount < maxRetryCount) && !isRevokeSuccessful) { + await new Promise(resolve => setTimeout(resolve, retryDelay * 2)); + dispatchScopesStatus(dispatch, 'We are retrying the revoking operation', 'Retrying', 5); + + permissionsUpdateObject.retryCount += 1; + return updatePermissions(permissionsUpdateObject); + } + else{ + return null; + } + +} + +const dispatchScopesStatus = (dispatch: Function, statusText: string, status: string, messageType: number) => { dispatch(revokeScopesError()); dispatch( setQueryResponseStatus({ @@ -315,29 +358,18 @@ export function fetchAllPrincipalGrants() { return async (dispatch: Function, getState: Function) => { try { const { profile, consentedScopes, scopes } = getState(); - let tenantWideGrant: IOAuthGrantPayload = scopes.data.tenantWidePermissionsGrant; - let revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); - const servicePrincipalAppId = revokePermissionUtil.getServicePrincipalAppId(); - dispatch(getAllPrincipalGrantsPending(true)); - let requestCounter = 0; - - if (servicePrincipalAppId) { - tenantWideGrant = revokePermissionUtil.getGrantsPayload(); - if(tenantWideGrant){ - if (!allScopesHaveConsentType(consentedScopes, tenantWideGrant, profile.id)){ - while (requestCounter < 10 && profile && profile.id && - !allScopesHaveConsentType(consentedScopes, tenantWideGrant, profile.id)) { - requestCounter += 1; - revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); - dispatch(getAllPrincipalGrantsPending(true)); - tenantWideGrant = revokePermissionUtil.getGrantsPayload(); - } - dispatchGrantsStatus(dispatch, tenantWideGrant.value) - } - else{ - dispatchGrantsStatus(dispatch, tenantWideGrant.value) - } - } + const tenantWideGrant: IOAuthGrantPayload = scopes.data.tenantWidePermissionsGrant; + const revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); + if (revokePermissionUtil && revokePermissionUtil.getGrantsPayload() !== null){ + const servicePrincipalAppId = revokePermissionUtil.getServicePrincipalAppId(); + dispatch(getAllPrincipalGrantsPending(true)); + const requestCounter = 0; + + await checkScopesConsentType(servicePrincipalAppId, tenantWideGrant, revokePermissionUtil, + consentedScopes, profile, requestCounter, dispatch); + } + else{ + dispatchScopesStatus(dispatch, 'Permissions', 'You require the following permissions to read', 0) } } catch (error: any) { dispatch(getAllPrincipalGrantsPending(false)); @@ -381,3 +413,27 @@ export const getSinglePrincipalGrant = (tenantWideGrant: IPermissionGrant[], pri } return []; } +async function checkScopesConsentType(servicePrincipalAppId: string, tenantWideGrant: IOAuthGrantPayload, + revokePermissionUtil: RevokePermissionsUtil, consentedScopes: string[], profile: IUser, + requestCounter: number, dispatch: Function) { + if (servicePrincipalAppId) { + tenantWideGrant = revokePermissionUtil.getGrantsPayload(); + if (tenantWideGrant) { + if (!allScopesHaveConsentType(consentedScopes, tenantWideGrant, profile.id)) { + while (requestCounter < 10 && profile && profile.id && + !allScopesHaveConsentType(consentedScopes, tenantWideGrant, profile.id)) { + requestCounter += 1; + await new Promise((resolve) => setTimeout(resolve, 400 * requestCounter)); + revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); + dispatch(getAllPrincipalGrantsPending(true)); + tenantWideGrant = revokePermissionUtil.getGrantsPayload(); + } + dispatchGrantsStatus(dispatch, tenantWideGrant.value); + } + else { + dispatchGrantsStatus(dispatch, tenantWideGrant.value); + } + } + } +} + diff --git a/src/app/services/actions/permissions-action-creator.util.ts b/src/app/services/actions/permissions-action-creator.util.ts index 99df42a87..397bb9608 100644 --- a/src/app/services/actions/permissions-action-creator.util.ts +++ b/src/app/services/actions/permissions-action-creator.util.ts @@ -3,6 +3,7 @@ import { IOAuthGrantPayload, IPermissionGrant } from '../../../types/permissions import { IUser } from '../../../types/profile'; import { IQuery } from '../../../types/query-runner'; import { RevokeScopesError } from '../../utils/error-utils/RevokeScopesError'; +import { exponentialFetchRetry } from '../../utils/fetch-retry-handler'; import { GRAPH_URL } from '../graph-constants'; import { makeGraphRequest, parseResponse } from './query-action-creator-util'; @@ -36,16 +37,16 @@ export class RevokePermissionsUtil { private signedInGrant: IPermissionGrant; private constructor(servicePrincipalAppId: string, grantsPayload: IOAuthGrantPayload, - signedInGrant: IPermissionGrant) { + signedInGrant: IPermissionGrant, ) { this.servicePrincipalAppId = servicePrincipalAppId; this.grantsPayload = grantsPayload; this.signedInGrant = signedInGrant; } - static async initialize(profileId?: string) { + static async initialize(profileId: string) { const servicePrincipalAppId = await RevokePermissionsUtil.getServicePrincipalId([]); const grantsPayload = await RevokePermissionsUtil.getTenantPermissionGrants([], servicePrincipalAppId); - const signedInGrant = RevokePermissionsUtil.getSignedInPrincipalGrant(grantsPayload, profileId!); + const signedInGrant = RevokePermissionsUtil.getSignedInPrincipalGrant(grantsPayload, profileId); return new RevokePermissionsUtil(servicePrincipalAppId, grantsPayload, signedInGrant); } @@ -105,37 +106,62 @@ export class RevokePermissionsUtil { public static async isSignedInUserTenantAdmin(): Promise { const tenantAdminQuery = { ...genericQuery }; tenantAdminQuery.sampleUrl = `${GRAPH_URL}/v1.0/me/memberOf`; - const response = await RevokePermissionsUtil.makePermissionsRequest([], tenantAdminQuery); + const response = await RevokePermissionsUtil.makeExponentialFetch([], tenantAdminQuery); return response ? response.value.some((value: any) => value.displayName === 'Global Administrator') : false } - private static async makePermissionsRequest(scopes: string[], query: IQuery) { - const respHeaders: any = {}; - const response = await makeGraphRequest(scopes)(query); - return parseResponse(response, respHeaders); + public async getUserPermissionChecks(preliminaryObject: PartialCheckObject): Promise<{ + userIsTenantAdmin: boolean, permissionBeingRevokedIsAllPrincipal: boolean, grantsPayload: IOAuthGrantPayload + }> { + const { permissionToRevoke } = preliminaryObject; + const grantsPayload = this.getGrantsPayload(); + const signedInGrant = this.getSignedInGrant(); + const preliminaryChecksObject: IPreliminaryChecksObject = { + ...preliminaryObject, grantsPayload + } + const allPrincipalGrants = RevokePermissionsUtil.getAllPrincipalGrant(grantsPayload); + + this.preliminaryChecksSuccess(preliminaryChecksObject) + + const userIsTenantAdmin = await RevokePermissionsUtil.isSignedInUserTenantAdmin(); + const permissionBeingRevokedIsAllPrincipal = this.userRevokingAdminGrantedScopes(grantsPayload, permissionToRevoke); + + if (permissionBeingRevokedIsAllPrincipal && !userIsTenantAdmin) { + this.trackRevokeConsentEvent(REVOKE_STATUS.allPrincipalScope, permissionToRevoke); + throw new RevokeScopesError({ + errorText: 'Revoking admin granted scopes', + statusText: 'You are unconsenting to an admin pre-consented permission', + status: 'Revoking admin granted scopes', + messageType: 1 + }) + } + + if (!this.permissionToRevokeInGrant(signedInGrant, allPrincipalGrants, permissionToRevoke) && userIsTenantAdmin) { + this.trackRevokeConsentEvent(REVOKE_STATUS.allPrincipalScope, permissionToRevoke); + throw new RevokeScopesError({ + errorText: 'Permission propagation delay', + statusText: 'You cannot revoke permissions not in your scopes', status: 'Permission propagation delay', + messageType: 1 + }) + } + return { userIsTenantAdmin, permissionBeingRevokedIsAllPrincipal, grantsPayload }; } public async updateSinglePrincipalPermissionGrant(grantsPayload: IOAuthGrantPayload, profile: IUser, newScopesString: string) { - const servicePrincipalAppId = await RevokePermissionsUtil.getServicePrincipalId([]); const permissionGrantId = RevokePermissionsUtil.getSignedInPrincipalGrant(grantsPayload, profile.id).id; - await this.revokePermission(permissionGrantId!, newScopesString); - const response = await RevokePermissionsUtil.getTenantPermissionGrants([], servicePrincipalAppId); - const principalGrant = RevokePermissionsUtil.getSignedInPrincipalGrant(response, profile.id); - const updatedScopes = principalGrant.scope.split(' '); - return updatedScopes; + const isRevokeSuccessful = await this.revokePermission(permissionGrantId!, newScopesString); + return isRevokeSuccessful; } public async getUpdatedAllPrincipalPermissionGrant(grantsPayload: IOAuthGrantPayload, permissionToRevoke: string) { - const servicePrincipalAppId = await RevokePermissionsUtil.getServicePrincipalId([]); - const allPrincipalGrant = this.getAllPrincipalGrant(grantsPayload); + const allPrincipalGrant = RevokePermissionsUtil.getAllPrincipalGrant(grantsPayload); const updatedScopes = allPrincipalGrant.scope.split(' ').filter((scope: string) => scope !== permissionToRevoke); - await this.revokePermission(allPrincipalGrant.id!, updatedScopes.join(' ')); - const response = await RevokePermissionsUtil.getTenantPermissionGrants([], servicePrincipalAppId); - return this.getAllPrincipalGrant(response).scope.split(' '); + const isRevokeSuccessful = await this.revokePermission(allPrincipalGrant.id!, updatedScopes.join(' ')); + return isRevokeSuccessful; } - private getAllPrincipalGrant(grantsPayload: IOAuthGrantPayload): IPermissionGrant { + private static getAllPrincipalGrant(grantsPayload: IOAuthGrantPayload): IPermissionGrant { const emptyGrant: IPermissionGrant = { id: '', consentType: '', @@ -163,77 +189,62 @@ export class RevokePermissionsUtil { : Promise { if (!servicePrincipalAppId) { return { value: [], '@odata.context': '' } } genericQuery.sampleUrl = `${GRAPH_URL}/v1.0/oauth2PermissionGrants?$filter=clientId eq '${servicePrincipalAppId}'`; - const oAuthGrant = await RevokePermissionsUtil.makePermissionsRequest(scopes, genericQuery); + genericQuery.sampleHeaders = [{ name: 'ConsistencyLevel', value: 'eventual' }]; + const oAuthGrant = await RevokePermissionsUtil.makeExponentialFetch(scopes, genericQuery); return oAuthGrant; } - public permissionToRevokeInGrant(permissionsGrant: IPermissionGrant, permissionToRevoke: string) { - if (!permissionsGrant) { return false } - return permissionsGrant.scope.split(' ').includes(permissionToRevoke); + public permissionToRevokeInGrant(permissionsGrant: IPermissionGrant, allPrincipalGrant: IPermissionGrant, + permissionToRevoke: string) { + if (!permissionsGrant || !allPrincipalGrant) { return false } + const allPrincipalPermissions = allPrincipalGrant.scope.split(' '); + const principalPermissions = permissionsGrant.scope.split(' '); + const combinedPermissions = [...allPrincipalPermissions, ...principalPermissions]; + return combinedPermissions.includes(permissionToRevoke); } public static async getServicePrincipalId(scopes: string[]): Promise { const currentAppId = process.env.REACT_APP_CLIENT_ID; genericQuery.sampleUrl = `${GRAPH_URL}/v1.0/servicePrincipals?$filter=appId eq '${currentAppId}'`; - const response = await this.makePermissionsRequest(scopes, genericQuery); + const response = await this.makeGraphRequest(scopes, genericQuery); return response ? response.value[0].id : ''; } - private async revokePermission(permissionGrantId: string, newScopes: string) { + private async revokePermission(permissionGrantId: string, newScopes: string): Promise { const oAuth2PermissionGrant = { scope: newScopes }; const patchQuery = { ...genericQuery }; patchQuery.sampleBody = JSON.stringify(oAuth2PermissionGrant); patchQuery.sampleUrl = `${GRAPH_URL}/v1.0/oauth2PermissionGrants/${permissionGrantId}`; + genericQuery.sampleHeaders = [{ name: 'ConsistencyLevel', value: 'eventual' }]; patchQuery.selectedVerb = 'PATCH'; // eslint-disable-next-line no-useless-catch try { - const response = await RevokePermissionsUtil.makePermissionsRequest([], patchQuery); + const response = await RevokePermissionsUtil.makeGraphRequest([], patchQuery); const { error } = response; if (error) { - throw error; + return false; } + return true; } catch (error: any) { throw error; } } - public async getUserPermissionChecks(preliminaryObject: PartialCheckObject): Promise<{ - userIsTenantAdmin: boolean, permissionBeingRevokedIsAllPrincipal: boolean, grantsPayload: IOAuthGrantPayload - }> { - const { permissionToRevoke } = preliminaryObject; - const grantsPayload = this.getGrantsPayload(); - const signedInGrant = this.getSignedInGrant(); - const preliminaryChecksObject: IPreliminaryChecksObject = { - ...preliminaryObject, grantsPayload - } - - this.preliminaryChecksSuccess(preliminaryChecksObject) - - const userIsTenantAdmin = await RevokePermissionsUtil.isSignedInUserTenantAdmin(); - const permissionBeingRevokedIsAllPrincipal = this.userRevokingAdminGrantedScopes(grantsPayload, permissionToRevoke); - - if (permissionBeingRevokedIsAllPrincipal && !userIsTenantAdmin) { - this.trackRevokeConsentEvent(REVOKE_STATUS.allPrincipalScope, permissionToRevoke); - throw new RevokeScopesError({ - errorText: 'Revoking admin granted scopes', - statusText: 'You are unconsenting to an admin pre-consented permission', - status: 'Revoking admin granted scopes', - messageType: 1 - }) - } + private static async makeExponentialFetch(scopes: string[], query: IQuery, condition?: + (args?: any) => Promise) { + const respHeaders: any = {}; + const response = await exponentialFetchRetry(() => makeGraphRequest(scopes)(query), + 8, 100, condition); + return parseResponse(response, respHeaders); + } - if (!this.permissionToRevokeInGrant(signedInGrant, permissionToRevoke) && userIsTenantAdmin) { - this.trackRevokeConsentEvent(REVOKE_STATUS.allPrincipalScope, permissionToRevoke); - throw new RevokeScopesError({ - errorText: 'Permission propagation delay', - statusText: 'You cannot revoke permissions not in your scopes', status: 'Permission propagation delay', - messageType: 1 - }) - } - return { userIsTenantAdmin, permissionBeingRevokedIsAllPrincipal, grantsPayload }; + private static async makeGraphRequest(scopes: string[], query: IQuery) { + const respHeaders: any = {}; + const response = await makeGraphRequest(scopes)(query); + return parseResponse(response, respHeaders); } private trackRevokeConsentEvent = (status: string, permissionObject: any) => { diff --git a/src/app/utils/fetch-retry-handler.ts b/src/app/utils/fetch-retry-handler.ts new file mode 100644 index 000000000..2c1ccdd92 --- /dev/null +++ b/src/app/utils/fetch-retry-handler.ts @@ -0,0 +1,23 @@ +export async function exponentialFetchRetry( fn: () => Promise, retriesLeft: number, + interval: number, condition?: (result: T, retriesLeft?: number) => Promise +): Promise { + try { + const result = await fn(); + if (condition) { + const isConditionSatisfied = await condition(result, retriesLeft); + if(isConditionSatisfied){ + throw new Error('An error occured during the execution of the request'); + } + } + if (result && result.status && result.status >= 500){ + throw new Error('Encountered a server error during execution of the request'); + } + return result; + } catch (error: unknown) { + if (retriesLeft === 1) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + return exponentialFetchRetry(fn, retriesLeft - 1, interval * 2, condition); + } +} diff --git a/src/app/views/query-runner/request/permissions/Permission.tsx b/src/app/views/query-runner/request/permissions/Permission.tsx index 2df90cb21..1e3592acc 100644 --- a/src/app/views/query-runner/request/permissions/Permission.tsx +++ b/src/app/views/query-runner/request/permissions/Permission.tsx @@ -269,21 +269,21 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { }, ); + columns.push( + { + key: 'consentType', + name: translateMessage('Consent type'), + isResizable: false, + fieldName: 'consentType', + minWidth: 130, + onRenderHeader: () => renderColumnHeader('Consent type'), + styles: columnCellStyles, + ariaLabel: translateMessage('Permission consent type'), + targetWidthProportion: 1, + flexGrow: 1 + } + ); } - columns.push( - { - key: 'consentType', - name: translateMessage('Consent type'), - isResizable: false, - fieldName: 'consentType', - minWidth: 130, - onRenderHeader: () => renderColumnHeader('Consent type'), - styles: columnCellStyles, - ariaLabel: translateMessage('Permission consent type'), - targetWidthProportion: 1, - flexGrow: 1 - } - ) return columns; } diff --git a/src/messages/GE.json b/src/messages/GE.json index 88a9d4888..27fccd4fe 100644 --- a/src/messages/GE.json +++ b/src/messages/GE.json @@ -454,7 +454,7 @@ "No samples found": "We did not find any sample queries", "You require the following permissions to revoke": "You require Directory.Read.All and DelegatedPermissionGrant.ReadWrite.All to be able to revoke consent to permissions", "Cannot delete default scope": "Graph Explorer requires this permission for its normal working behavior", - "Permission revoked": "Permission revoked successfully. Sign out and sign in again for these changes to take effect", + "Permission revoked": "Permission revoked successfully. Sign out and sign in again for these changes to take effect. These changes may take some time to reflect on your token", "An error occurred when unconsenting": "An error occurred when unconsenting. Please try again", "You are unconsenting to an admin pre-consented permission": "You are unconsenting to an admin pre-consented permission. Ask your tenant admin to revoke consent to this permission on Azure AD", "Possible error found in URL near": "Possible error found in URL near", @@ -473,5 +473,10 @@ "This token is not a jwt token and cannot be decoded by jwt.ms": "This token is not a jwt token and cannot be decoded by jwt.ms", "The URL must contain graph.microsoft.com": "The URL must contain graph.microsoft.com", "Fetching code snippet failing": "Retry again", - "Fetching permissions failing": "Retry again" + "Fetching permissions failing": "Retry again", + "Reload consent-type": "Confirm that you have consented to Directory.Read.All or Directory.ReadWrite.All or DelegatedPermissionGrant.ReadWrite.All and that you have reauthenticated if you revoked this permission earlier then click Reload", + "An error occured during the revoking process": "An error occured during the revoking process. Click the Unconsent button to retry the request", + "Retrying": "Retrying", + "You require the following permissions to read": "You require Directory.Read.All or Directory.ReadWrite.All or DelegatedPermissionGrant.ReadWrite.All to read permission grants for your tenant", + "We are retrying the revoking operation": "An error occurred during the revoking process. We are retrying the revoking operation" } \ No newline at end of file