diff --git a/src/app/services/actions/permissions-action-creator.spec.ts b/src/app/services/actions/permissions-action-creator.spec.ts index 4d0df6759..809c5c36e 100644 --- a/src/app/services/actions/permissions-action-creator.spec.ts +++ b/src/app/services/actions/permissions-action-creator.spec.ts @@ -1,21 +1,36 @@ import { FETCH_SCOPES_ERROR, - FETCH_FULL_SCOPES_PENDING, FETCH_FULL_SCOPES_SUCCESS, - FETCH_URL_SCOPES_PENDING + FETCH_URL_SCOPES_PENDING, + QUERY_GRAPH_STATUS } from '../../../app/services/redux-constants'; import { - fetchFullScopesSuccess, fetchFullScopesPending, fetchUrlScopesPending, fetchScopesError, getPermissionsScopeType + fetchFullScopesSuccess, fetchScopesError, getPermissionsScopeType, fetchScopes, + consentToScopes, + fetchUrlScopesPending, + fetchFullScopesPending, + revokeScopes } from './permissions-action-creator'; import { IPermissionsResponse } from '../../../types/permissions'; import { store } from '../../../store/index'; import { ApplicationState } from '../../../types/root'; import { Mode } from '../../../types/enums'; -import { AppAction } from '../../../types/action'; +import configureMockStore from 'redux-mock-store'; +import { authenticationWrapper } from '../../../modules/authentication'; +import thunk from 'redux-thunk'; +import { ACCOUNT_TYPE } from '../graph-constants'; +import { RevokePermissionsUtil } from './permissions-action-creator.util'; +import { cleanup } from '@testing-library/react'; +const middleware = [thunk]; +let mockStore = configureMockStore(middleware); +afterEach(cleanup); +beforeEach(() => { + const mockStore_ = configureMockStore(middleware); + mockStore = mockStore_ +}) window.open = jest.fn(); -window.fetch = jest.fn(); const mockState: ApplicationState = { devxApi: { @@ -23,7 +38,15 @@ const mockState: ApplicationState = { parameters: '$count=true' }, permissionsPanelOpen: true, - profile: null, + profile: { + id: '123', + displayName: 'test', + emailAddress: 'johndoe@ms.com', + profileImageUrl: 'https://graph.microsoft.com/v1.0/me/photo/$value', + ageGroup: 0, + tenant: 'binaryDomain', + profileType: ACCOUNT_TYPE.MSA + }, sampleQuery: { sampleUrl: 'http://localhost:8080/api/v1/samples/1', selectedVerb: 'GET', @@ -31,7 +54,7 @@ const mockState: ApplicationState = { sampleHeaders: [] }, authToken: { token: false, pending: false }, - consentedScopes: [], + consentedScopes: ['profile.read User.Read Files.Read'], isLoadingData: false, queryRunnerStatus: null, termsOfUse: true, @@ -128,7 +151,7 @@ describe('Permissions action creators', () => { } } - const expectedAction: AppAction = { + const expectedAction = { type: FETCH_FULL_SCOPES_SUCCESS, response } @@ -146,7 +169,7 @@ describe('Permissions action creators', () => { error: {} } - const expectedAction: AppAction = { + const expectedAction = { type: FETCH_SCOPES_ERROR, response } @@ -162,7 +185,7 @@ describe('Permissions action creators', () => { it('should dispatch FETCH_FULL_SCOPES_PENDING or FETCH_URL_SCOPES_PENDING depending on type passed to fetchScopesPending', () => { // Arrange const expectedFullScopesAction = { - type: FETCH_FULL_SCOPES_PENDING, + type: 'FETCH_SCOPES_PENDING', response: 'full' } @@ -173,7 +196,7 @@ describe('Permissions action creators', () => { // Act const fullScopesAction = fetchFullScopesPending(); - const urlScopesAction = fetchUrlScopesPending() + const urlScopesAction = fetchUrlScopesPending(); // Assert expect(fullScopesAction).toEqual(expectedFullScopesAction); @@ -191,4 +214,265 @@ describe('Permissions action creators', () => { expect(result).toEqual(expectedResult); }); + + it('should fetch scopes', () => { + // Arrange + const expectedResult = {} + const expectedAction: any = [ + { + type: 'FETCH_SCOPES_PENDING', + response: 'full' + }, + { + type: 'FULL_SCOPES_FETCH_SUCCESS', + response: { + scopes: { + fullPermissions: {} + } + } + } + ]; + + const store_ = mockStore(mockState); + + const mockFetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(expectedResult) + }) + }); + + window.fetch = mockFetch; + + // Act and Assert + // @ts-ignore + return store_.dispatch(fetchScopes()) + // @ts-ignore + .then(() => { + expect(store_.getActions()).toEqual(expectedAction); + }); + }); + + it('should consent to scopes', () => { + // Arrange + jest.spyOn(authenticationWrapper, 'consentToScopes').mockResolvedValue({ + accessToken: 'jkkkkkkkkkkkkkkkkkkkksdss', + authority: 'string', + uniqueId: 'string', + tenantId: 'string', + scopes: ['profile.Read User.Read'], + account: null, + idToken: 'string', + idTokenClaims: {}, + fromCache: true, + expiresOn: new Date(), + tokenType: 'AAD', + correlationId: 'string' + }) + const expectedAction: any = [ + { + type: 'GET_AUTH_TOKEN_SUCCESS', + response: true + }, + { + type: 'GET_CONSENTED_SCOPES_SUCCESS', + response: ['profile.Read User.Read'] + }, + { + type: 'QUERY_GRAPH_STATUS', + response: { + statusText: 'Success', + status: 'Scope consent successful', + ok: true, + messageType: 4 + } + } + ]; + + const store_ = mockStore(mockState); + + // Act and Assert + // @ts-ignore + return store_.dispatch(consentToScopes()) + // @ts-ignore + .then(() => { + expect(store_.getActions()).toEqual(expectedAction); + }); + }); + + describe('Revoke scopes', () => { + it('should return Default Scope error when user tries to dissent to default scope', () => { + // Arrange + const store_ = mockStore(mockState); + jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); + jest.spyOn(RevokePermissionsUtil, 'getSignedInPrincipalGrant').mockReturnValue({ + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'profile.read User.Read', + id: 'SomeNiceId' + }) + jest.spyOn(RevokePermissionsUtil, 'getTenantPermissionGrants').mockResolvedValue({ + 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, + response: { + statusText: 'Failed', + status: 'An error occurred when unconsenting. Please try again', + ok: false, + messageType: 1 + } + } + ] + + + // Act and Assert + // @ts-ignore + return store_.dispatch(revokeScopes('User.Read')) + // @ts-ignore + .then(() => { + expect(store_.getActions()).toEqual(expectedActions); + }); + }); + + it('should return 401 when user does not have required permissions', () => { + // Arrange + const store_ = mockStore(mockState); + jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); + jest.spyOn(RevokePermissionsUtil, 'getSignedInPrincipalGrant').mockReturnValue({ + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'profile.read User.Read Access.Read', + id: 'SomeNiceId' + }) + jest.spyOn(RevokePermissionsUtil, 'getTenantPermissionGrants').mockResolvedValue({ + value: [ + { + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'profile.read User.Read Access.Read', + id: 'SomeNiceId' + }, + { + clientId: '', + consentType: 'AllPrincipals', + principalId: null, + resourceId: '1234', + scope: 'profile.read User.Read Directory.Reaqd.All Access.Read' + } + ], + '@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, + response: { + statusText: 'Failed', + status: 'An error occurred when unconsenting. Please try again', + ok: false, + messageType: 1 + } + } + ] + + // Act and Assert + // @ts-ignore + return store_.dispatch(revokeScopes('Access.Read')) + // @ts-ignore + .then(() => { + expect(store_.getActions()).toEqual(expectedActions); + }); + + }); + + //revisit + it('should raise error when user attempts to dissent to an admin granted permission', () => { + // Arrange + const store_ = mockStore(mockState); + jest.spyOn(RevokePermissionsUtil, 'getServicePrincipalId').mockResolvedValue('1234'); + jest.spyOn(RevokePermissionsUtil, 'getSignedInPrincipalGrant').mockReturnValue({ + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'profile.read User.Read Access.Read Files.Read', + id: 'SomeNiceId' + }) + jest.spyOn(RevokePermissionsUtil, 'getTenantPermissionGrants').mockResolvedValue({ + value: [ + { + clientId: '1234', + consentType: 'Principal', + principalId: '1234', + resourceId: '1234', + scope: 'User.Read Access.Read DelegatedPermissionGrant.ReadWrite.All Directory.Read.All Files.Read', + id: 'SomeNiceId' + }, + { + clientId: '', + consentType: 'AllPrincipals', + principalId: null, + resourceId: '1234', + // eslint-disable-next-line max-len + scope: 'profile.read User.Read Directory.Reaqd.All Access.Read DelegatedPermissionGrant.ReadWrite.All Directory.Read.All' + } + ], + '@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#permissionGrants' + }); + + jest.spyOn(RevokePermissionsUtil, 'isSignedInUserTenantAdmin').mockResolvedValue(false); + + const expectedActions = [ + { type: 'REVOKE_SCOPES_PENDING', response: null }, + { type: 'REVOKE_SCOPES_ERROR', response: null }, + { + type: QUERY_GRAPH_STATUS, + response: { + statusText: 'Failed', + status: 'An error occurred when unconsenting. Please try again', + ok: false, + messageType: 1 + } + } + ] + + // Act and Assert + // @ts-ignore + return store_.dispatch(revokeScopes('Access.Read')) + // @ts-ignore + .then(() => { + 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 0b51deab3..12208854c 100644 --- a/src/app/services/actions/permissions-action-creator.ts +++ b/src/app/services/actions/permissions-action-creator.ts @@ -10,13 +10,18 @@ import { sanitizeQueryUrl } from '../../utils/query-url-sanitization'; import { parseSampleUrl } from '../../utils/sample-url-generation'; import { translateMessage } from '../../utils/translate-messages'; import { getConsentAuthErrorHint } from '../../../modules/authentication/authentication-error-hints'; -import { ACCOUNT_TYPE, PERMS_SCOPE } from '../graph-constants'; +import { + ACCOUNT_TYPE, DEFAULT_USER_SCOPES, PERMS_SCOPE, + REVOKING_PERMISSIONS_REQUIRED_SCOPES +} from '../graph-constants'; import { FETCH_SCOPES_ERROR, FETCH_FULL_SCOPES_PENDING, FETCH_URL_SCOPES_PENDING, FETCH_FULL_SCOPES_SUCCESS, - FETCH_URL_SCOPES_SUCCESS + FETCH_URL_SCOPES_SUCCESS, + GET_ALL_PRINCIPAL_GRANTS_SUCCESS, GET_ALL_PRINCIPAL_GRANTS_ERROR, REVOKE_SCOPES_PENDING, + REVOKE_SCOPES_SUCCESS, REVOKE_SCOPES_ERROR } from '../redux-constants'; import { getAuthTokenSuccess, @@ -24,6 +29,9 @@ import { } from './auth-action-creators'; import { getProfileInfo } from './profile-action-creators'; import { setQueryResponseStatus } from './query-status-action-creator'; +import { RevokePermissionsUtil, REVOKE_STATUS } from './permissions-action-creator.util'; +import { componentNames, eventTypes, telemetry } from '../../../telemetry'; +import { RevokeScopesError } from '../../utils/error-utils/RevokeScopesError'; export function fetchFullScopesSuccess(response: object): AppAction { return { @@ -60,6 +68,42 @@ export function fetchScopesError(response: object): AppAction { }; } +export function getAllPrincipalGrantsSuccess(response: object): AppAction { + return { + type: GET_ALL_PRINCIPAL_GRANTS_SUCCESS, + response + }; +} + +export function getAllPrincipalGrantsError(response: object): AppAction { + return { + type: GET_ALL_PRINCIPAL_GRANTS_ERROR, + response + }; + +} + +export function revokeScopesPending(): AppAction { + return { + type: REVOKE_SCOPES_PENDING, + response: null + } +} + +export function revokeScopesSuccess(): AppAction { + return { + type: REVOKE_SCOPES_SUCCESS, + response: null + } +} + +export function revokeScopesError(): AppAction { + return { + type: REVOKE_SCOPES_ERROR, + response: null + } +} + export function fetchScopes() { return async (dispatch: Function, getState: Function) => { try { @@ -162,3 +206,104 @@ export function consentToScopes(scopes: string[]) { } }; } + +export function revokeScopes(permissionToRevoke: string) { + return async (dispatch: Function, getState: Function) => { + const { consentedScopes, profile } = getState(); + const requiredPermissions = REVOKING_PERMISSIONS_REQUIRED_SCOPES.split(' '); + const defaultUserScopes = DEFAULT_USER_SCOPES.split(' '); + const revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); + dispatch(revokeScopesPending()); + + if (!consentedScopes || consentedScopes.length === 0) { + dispatch(revokeScopesError()); + trackRevokeConsentEvent(REVOKE_STATUS.preliminaryChecksFail, permissionToRevoke); + return; + } + + 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.updateAllPrincipalPermissionGrant(grantsPayload, permissionToRevoke); + } + else { + updatedScopes = await revokePermissionUtil. + updateSinglePrincipalPermissionGrant(grantsPayload, profile, newScopesString); + } + + if (updatedScopes.length !== newScopesArray.length) { + 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); + const permissionObject = { + permissionToRevoke, + statusCode: statusText, + status: errorText + } + trackRevokeConsentEvent(REVOKE_STATUS.failure, permissionObject); + } + 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); + } + } + } +} + +const dispatchRevokeScopesStatus = (dispatch: Function, statusText: string, status: string, messageType: number) => { + dispatch(revokeScopesError()); + dispatch( + setQueryResponseStatus({ + statusText: translateMessage(status), + status: translateMessage(statusText), + ok: false, + messageType + }) + ) +} + +const trackRevokeConsentEvent = (status: string, permissionObject: any) => { + telemetry.trackEvent(eventTypes.BUTTON_CLICK_EVENT, { + componentName: componentNames.REVOKE_PERMISSION_CONSENT_BUTTON, + permissionObject, + status + }); +} + +export function fetchAllPrincipalGrants() { + return async (dispatch: Function, getState: Function) => { + try { + const { profile } = getState(); + const revokePermissionUtil = await RevokePermissionsUtil.initialize(profile.id); + const servicePrincipalAppId = revokePermissionUtil.getServicePrincipalAppId(); + if (servicePrincipalAppId) { + const tenantWideGrant = revokePermissionUtil.getGrantsPayload(); + dispatch(getAllPrincipalGrantsSuccess(tenantWideGrant.value)); + } + else { + dispatch(getAllPrincipalGrantsError({})); + } + } catch (error: any) { + dispatch(getAllPrincipalGrantsError(error)); + } + } +} \ No newline at end of file diff --git a/src/app/services/actions/permissions-action-creator.util.ts b/src/app/services/actions/permissions-action-creator.util.ts new file mode 100644 index 000000000..d17764fbd --- /dev/null +++ b/src/app/services/actions/permissions-action-creator.util.ts @@ -0,0 +1,259 @@ +import { componentNames, eventTypes, telemetry } from '../../../telemetry'; +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 { GRAPH_URL } from '../graph-constants'; +import { makeGraphRequest, parseResponse } from './query-action-creator-util'; + +interface IPreliminaryChecksObject { + defaultUserScopes: string[]; + requiredPermissions: string[]; + consentedScopes: string[]; + permissionToRevoke: string; + grantsPayload?: IOAuthGrantPayload; +} + +type PartialCheckObject = Omit + +const genericQuery: IQuery = { + selectedVerb: 'GET', + sampleHeaders: [], + selectedVersion: '', + sampleUrl: '' +}; + +export enum REVOKE_STATUS { + success = 'success', + failure = 'failure', + preliminaryChecksFail = 'preliminaryChecksFail', + allPrincipalScope = 'allPrincipalScope' +} +export class RevokePermissionsUtil { + + private servicePrincipalAppId: string; + private grantsPayload: IOAuthGrantPayload; + private signedInGrant: IPermissionGrant; + + private constructor(servicePrincipalAppId: string, grantsPayload: IOAuthGrantPayload, + signedInGrant: IPermissionGrant) { + this.servicePrincipalAppId = servicePrincipalAppId; + this.grantsPayload = grantsPayload; + this.signedInGrant = signedInGrant; + } + + static async initialize(profileId?: string) { + const servicePrincipalAppId = await RevokePermissionsUtil.getServicePrincipalId([]); + const grantsPayload = await RevokePermissionsUtil.getTenantPermissionGrants([], servicePrincipalAppId); + const signedInGrant = RevokePermissionsUtil.getSignedInPrincipalGrant(grantsPayload, profileId!); + return new RevokePermissionsUtil(servicePrincipalAppId, grantsPayload, signedInGrant); + } + + public preliminaryChecksSuccess(preliminaryChecksObject: IPreliminaryChecksObject) { + const { defaultUserScopes, requiredPermissions, consentedScopes, permissionToRevoke, grantsPayload } + = preliminaryChecksObject + if (this.userRevokingDefaultScopes(defaultUserScopes, permissionToRevoke)) { + throw new RevokeScopesError({ + errorText: 'Revoking default scopes', + statusText: 'Cannot delete default scope', + status: 'Default scope', + messageType: 1 + }) + } + + if (!this.userHasRequiredPermissions(requiredPermissions, consentedScopes, grantsPayload!)) { + throw new RevokeScopesError({ + errorText: 'Revoking admin granted scopes', + statusText: 'Unable to dissentYou require the following permissions to revoke', + status: 'Unable to dissent', + messageType: 1 + }) + } + } + + private userRevokingDefaultScopes(defaultUserScopes: string[], permissionToRevoke: string) { + return defaultUserScopes.includes(permissionToRevoke); + } + + public userRevokingAdminGrantedScopes(grantsPayload: IOAuthGrantPayload, permissionToRevoke: string) { + if (!grantsPayload) { + return false; + } + const allPrincipalGrants = grantsPayload.value.find((grant: IPermissionGrant) => + grant.consentType.toLowerCase() === 'AllPrincipals'.toLowerCase()); + if (allPrincipalGrants && allPrincipalGrants.scope.includes(permissionToRevoke)) { + return true + } + return false; + } + + // We have to check if the required permissions are in the allPrincipal and single principal scopes + // because of a bug in AAD that causes the principal scopes to temporarily miss allPrincipal scopes + private userHasRequiredPermissions(requiredPermissions: string[], consentedScopes: string[], + grantsPayload: IOAuthGrantPayload) { + const allPrincipalGrants = grantsPayload.value.find((grant: IPermissionGrant) => + grant.consentType.toLowerCase() === 'AllPrincipals'.toLowerCase()); + if (!allPrincipalGrants) { + return requiredPermissions.every(scope => consentedScopes.includes(scope)); + } + const allPrincipalScopes = allPrincipalGrants.scope.split(' '); + let principalAndAllPrincipalScopes: string[] = []; + principalAndAllPrincipalScopes = consentedScopes.concat(allPrincipalScopes); + return requiredPermissions.every(scope => principalAndAllPrincipalScopes.includes(scope)); + } + + public static async isSignedInUserTenantAdmin(): Promise { + const tenantAdminQuery = { ...genericQuery }; + tenantAdminQuery.sampleUrl = `${GRAPH_URL}/v1.0/me/memberOf`; + const response = await RevokePermissionsUtil.makePermissionsRequest([], 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 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; + } + + public async updateAllPrincipalPermissionGrant(grantsPayload: IOAuthGrantPayload, permissionToRevoke: string) { + const servicePrincipalAppId = await RevokePermissionsUtil.getServicePrincipalId([]); + const allPrincipalGrant = this.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(' '); + } + + private getAllPrincipalGrant(grantsPayload: IOAuthGrantPayload): IPermissionGrant { + const emptyGrant: IPermissionGrant = { + id: '', + consentType: '', + scope: '', + clientId: '', + principalId: '', + resourceId: '' + } + if (!grantsPayload) { return emptyGrant } + + return grantsPayload.value.find((grant: any) => + grant.consentType.toLowerCase() === 'AllPrincipals'.toLowerCase()) || emptyGrant; + } + + public static getSignedInPrincipalGrant = (grantsPayload: IOAuthGrantPayload, userId: string) => { + if (grantsPayload && grantsPayload.value.length > 1) { + const filteredResponse = grantsPayload.value.find((permissionGrant: IPermissionGrant) => + permissionGrant.principalId === userId); + return filteredResponse!; + } + return grantsPayload.value[0]; + } + + public static async getTenantPermissionGrants(scopes: string[], servicePrincipalAppId: string) + : 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); + return oAuthGrant; + } + + public permissionToRevokeInGrant(permissionsGrant: IPermissionGrant, permissionToRevoke: string) { + if (!permissionsGrant) { return false } + return permissionsGrant.scope.split(' ').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); + return response ? response.value[0].id : ''; + } + + private async revokePermission(permissionGrantId: string, newScopes: string) { + const oAuth2PermissionGrant = { + scope: newScopes + }; + const patchQuery = { ...genericQuery }; + patchQuery.sampleBody = JSON.stringify(oAuth2PermissionGrant); + patchQuery.sampleUrl = `${GRAPH_URL}/v1.0/oauth2PermissionGrants/${permissionGrantId}`; + patchQuery.selectedVerb = 'PATCH'; + // eslint-disable-next-line no-useless-catch + try { + const response = await RevokePermissionsUtil.makePermissionsRequest([], patchQuery); + const { error } = response; + if (error) { + throw error; + } + } + 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 + }) + } + + 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 trackRevokeConsentEvent = (status: string, permissionObject: any) => { + telemetry.trackEvent(eventTypes.BUTTON_CLICK_EVENT, { + componentName: componentNames.REVOKE_PERMISSION_CONSENT_BUTTON, + permissionObject, + status + }); + } + + public getServicePrincipalAppId() { + return this.servicePrincipalAppId + } + + public getGrantsPayload() { + return this.grantsPayload + } + + public getSignedInGrant() { + return this.signedInGrant; + } + +} \ No newline at end of file diff --git a/src/app/services/actions/query-action-creator-util.ts b/src/app/services/actions/query-action-creator-util.ts index 5ff547df8..e87f17890 100644 --- a/src/app/services/actions/query-action-creator-util.ts +++ b/src/app/services/actions/query-action-creator-util.ts @@ -14,7 +14,7 @@ import { ContentType } from '../../../types/enums'; import { IQuery } from '../../../types/query-runner'; import { IRequestOptions } from '../../../types/request'; import { IStatus } from '../../../types/status'; -import { ClientError } from '../../utils/ClientError'; +import { ClientError } from '../../utils/error-utils/ClientError'; import { encodeHashCharacters } from '../../utils/query-url-sanitization'; import { translateMessage } from '../../utils/translate-messages'; import { authProvider, GraphClient } from '../graph-client'; @@ -96,6 +96,7 @@ function createAuthenticatedRequest( sampleHeaders[header.name] = header.value; }); } + const updatedHeaders = { ...sampleHeaders, 'cache-control': 'no-cache', pragma: 'no-cache' } const msalAuthOptions: AuthCodeMSALBrowserAuthenticationProviderOptions = { account: authenticationWrapper.getAccount()!, @@ -109,7 +110,7 @@ function createAuthenticatedRequest( return GraphClient.getInstance() .api(encodeHashCharacters(query)) .middlewareOptions([middlewareOptions]) - .headers(sampleHeaders) + .headers(updatedHeaders) .responseType(ResponseType.RAW); } diff --git a/src/app/services/actions/query-action-creators.ts b/src/app/services/actions/query-action-creators.ts index 6d2b26722..6aeea6276 100644 --- a/src/app/services/actions/query-action-creators.ts +++ b/src/app/services/actions/query-action-creators.ts @@ -4,7 +4,7 @@ import { ContentType } from '../../../types/enums'; import { IHistoryItem } from '../../../types/history'; import { IQuery } from '../../../types/query-runner'; import { IStatus } from '../../../types/status'; -import { ClientError } from '../../utils/ClientError'; +import { ClientError } from '../../utils/error-utils/ClientError'; import { setStatusMessage } from '../../utils/status-message'; import { writeHistoryData } from '../../views/sidebar/history/history-utils'; import { diff --git a/src/app/services/graph-constants.ts b/src/app/services/graph-constants.ts index 4669eed9f..698f487c1 100644 --- a/src/app/services/graph-constants.ts +++ b/src/app/services/graph-constants.ts @@ -26,3 +26,9 @@ export const MOZILLA_CORS_DOCUMENTATION_LINK = 'https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS'; export const USER_ORGANIZATION_URL = `${GRAPH_URL}/v1.0/organization`; export const NPS_FEEDBACK_URL = 'https://petrol.office.microsoft.com/v1/feedback'; +// eslint-disable-next-line max-len +export const REVOKING_PERMISSIONS_REQUIRED_SCOPES = 'DelegatedPermissionGrant.ReadWrite.All Directory.Read.All'; +// eslint-disable-next-line max-len +export const ADMIN_CONSENT_DOC_LINK = 'https://learn.microsoft.com/en-us/graph/security-authorization#:~:text=If%20you%27re%20calling%20the%20Microsoft%20Graph%20Security%20API%20from%20Graph%20Explorer' +// eslint-disable-next-line max-len +export const CONSENT_TYPE_DOC_LINK = 'https://learn.microsoft.com/en-us/graph/api/resources/oauth2permissiongrant?view=graph-rest-1.0#:~:text=(eq%20only).-,consentType,-String' \ No newline at end of file diff --git a/src/app/services/reducers/permissions-reducer.ts b/src/app/services/reducers/permissions-reducer.ts index d4de8b591..4f65f9c96 100644 --- a/src/app/services/reducers/permissions-reducer.ts +++ b/src/app/services/reducers/permissions-reducer.ts @@ -2,17 +2,21 @@ import { AppAction } from '../../../types/action'; import { IPermissionsResponse, IScopes } from '../../../types/permissions'; import { FETCH_SCOPES_ERROR, FETCH_URL_SCOPES_PENDING, FETCH_FULL_SCOPES_SUCCESS, - FETCH_URL_SCOPES_SUCCESS, FETCH_FULL_SCOPES_PENDING + FETCH_URL_SCOPES_SUCCESS, FETCH_FULL_SCOPES_PENDING, GET_ALL_PRINCIPAL_GRANTS_SUCCESS, + REVOKE_SCOPES_PENDING, REVOKE_SCOPES_ERROR, REVOKE_SCOPES_SUCCESS } from '../redux-constants'; const initialState: IScopes = { pending: { isSpecificPermissions: false, - isFullPermissions: false + isFullPermissions: false, + isTenantWidePermissionsGrant: false, + isRevokePermissions: false }, data: { specificPermissions: [], - fullPermissions: [] + fullPermissions: [], + tenantWidePermissionsGrant: [] }, error: null }; @@ -51,6 +55,30 @@ export function scopes(state: IScopes = initialState, action: AppAction): any { data: state.data, error: null }; + case GET_ALL_PRINCIPAL_GRANTS_SUCCESS: + return { + pending: { ...state.pending, isTenantWidePermissionsGrant: false }, + data: { ...state.data, tenantWidePermissionsGrant: action.response }, + error: null + } + case REVOKE_SCOPES_PENDING: + return{ + pending: { ...state.pending, isRevokePermissions: true }, + data: state.data, + error: null + } + case REVOKE_SCOPES_ERROR: + return{ + pending: { ...state.pending, isRevokePermissions: false }, + data: state.data, + error: 'error' + } + case REVOKE_SCOPES_SUCCESS: + return { + pending: { ...state.pending, isRevokePermissions: false }, + data: state.data, + error: null + } default: return state; } diff --git a/src/app/services/redux-constants.ts b/src/app/services/redux-constants.ts index 299ace217..c75836bd0 100644 --- a/src/app/services/redux-constants.ts +++ b/src/app/services/redux-constants.ts @@ -54,3 +54,8 @@ export const RESOURCEPATHS_ADD_SUCCESS = 'RESOURCEPATHS_ADD_SUCCESS'; export const RESOURCEPATHS_DELETE_SUCCESS = 'RESOURCEPATHS_DELETE_SUCCESS'; export const BULK_ADD_HISTORY_ITEMS_SUCCESS = 'BULK_ADD_HISTORY_ITEMS_SUCCESS'; export const SET_SNIPPET_TAB_SUCCESS = 'SET_SNIPPET_TAB_SUCCESS'; +export const GET_ALL_PRINCIPAL_GRANTS_SUCCESS = 'GET_ALL_PRINCIPAL_GRANTS_SUCCESS'; +export const GET_ALL_PRINCIPAL_GRANTS_ERROR = 'GET_ALL_PRINCIPAL_GRANTS_ERROR'; +export const REVOKE_SCOPES_PENDING = 'REVOKE_SCOPES_PENDING'; +export const REVOKE_SCOPES_SUCCESS = 'REVOKE_SCOPES_SUCCESS'; +export const REVOKE_SCOPES_ERROR = 'REVOKE_SCOPES_ERROR'; diff --git a/src/app/utils/ClientError.ts b/src/app/utils/error-utils/ClientError.ts similarity index 100% rename from src/app/utils/ClientError.ts rename to src/app/utils/error-utils/ClientError.ts diff --git a/src/app/utils/error-utils/RevokeScopesError.ts b/src/app/utils/error-utils/RevokeScopesError.ts new file mode 100644 index 000000000..1818f698c --- /dev/null +++ b/src/app/utils/error-utils/RevokeScopesError.ts @@ -0,0 +1,25 @@ +import { ClientError } from './ClientError'; + +interface IRevokeScopesError { + errorText: string; + statusText: string; + messageType: number; + status: string; +} + +export class RevokeScopesError extends ClientError { + statusText: string; + messageType: number; + status: string; + errorText: string; + + constructor(error: IRevokeScopesError = { errorText: '', statusText: '', messageType: 0, status: '' }) { + super(); + Object.assign(this, error); + this.name = 'RevokeScopesError'; + this.errorText = error.errorText; + this.statusText = error.statusText; + this.messageType = error.messageType; + this.status = error.status; + } +} \ No newline at end of file diff --git a/src/app/views/query-runner/request/Request.tsx b/src/app/views/query-runner/request/Request.tsx index 16d9ba161..dd0433948 100644 --- a/src/app/views/query-runner/request/Request.tsx +++ b/src/app/views/query-runner/request/Request.tsx @@ -51,7 +51,6 @@ export class Request extends Component { itemIcon='Send' itemKey='request-body' // To be used to construct component name for telemetry data ariaLabel={messages['request body']} - title={messages['request body']} headerText={messages['request body']} headerButtonProps={{ 'aria-controls': 'request-body-tab' @@ -66,7 +65,6 @@ export class Request extends Component { itemIcon='FileComment' itemKey='request-headers' ariaLabel={messages['request header']} - title={messages['request header']} headerText={messages['request header']} headerButtonProps={{ 'aria-controls': 'request-header-tab' @@ -81,7 +79,6 @@ export class Request extends Component { itemIcon='AzureKeyVault' itemKey='modify-permissions' ariaLabel={translateMessage('modify permissions')} - title={translateMessage('permissions preview')} headerText={messages['modify permissions']} headerButtonProps={{ 'aria-controls': 'permission-tab' @@ -99,7 +96,6 @@ export class Request extends Component { itemIcon='AuthenticatorApp' itemKey='access-token' ariaLabel={translateMessage('Access Token')} - title={translateMessage('Access Token')} headerText={translateMessage('Access Token')} headerButtonProps={{ 'aria-controls': 'access-token-tab' diff --git a/src/app/views/query-runner/request/permissions/PanelList.tsx b/src/app/views/query-runner/request/permissions/PanelList.tsx index be3a04e30..97c5e8a80 100644 --- a/src/app/views/query-runner/request/permissions/PanelList.tsx +++ b/src/app/views/query-runner/request/permissions/PanelList.tsx @@ -1,7 +1,7 @@ import { - Announced, DefaultButton, DetailsList, DetailsListLayoutMode, getTheme, GroupHeader, IColumn, - IDetailsListCheckboxProps, IGroup, IOverlayProps, Label, Panel, PanelType, PrimaryButton, - SearchBox, Selection, SelectionMode + Announced, DetailsList, DetailsListLayoutMode, getTheme, GroupHeader, IColumn, + IGroup, IOverlayProps, Label, Panel, PanelType, + SearchBox, SelectionMode } from '@fluentui/react'; import React, { useEffect, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -11,7 +11,6 @@ import { AppDispatch, useAppSelector } from '../../../../../store'; import { componentNames, eventTypes, telemetry } from '../../../../../telemetry'; import { SortOrder } from '../../../../../types/enums'; import { IPermission } from '../../../../../types/permissions'; -import { consentToScopes } from '../../../../services/actions/permissions-action-creator'; import { togglePermissionsPanel } from '../../../../services/actions/permissions-panel-action-creator'; import { dynamicSort } from '../../../../utils/dynamic-sort'; import { generateGroupsFromList } from '../../../../utils/generate-groups'; @@ -26,11 +25,10 @@ interface IPanelList { classes: any; renderItemColumn: any; renderDetailsHeader: Function; - renderCustomCheckbox: Function; } const PanelList = ({ messages, - columns, classes, renderItemColumn, renderDetailsHeader, renderCustomCheckbox }: IPanelList) : JSX.Element => { + columns, classes, renderItemColumn, renderDetailsHeader }: IPanelList) : JSX.Element => { const sortPermissions = (permissionsToSort: IPermission[]): IPermission[] => { return permissionsToSort ? permissionsToSort.sort(dynamicSort('value', SortOrder.ASC)) : []; @@ -44,11 +42,10 @@ const PanelList = ({ messages, const [searchStarted, setSearchStarted] = useState(false); const permissionsList: any[] = []; const tokenPresent = !!authToken.token; - const [selectedPermissions, setSelectedPermissions] = useState([]); const loading = scopes.pending.isFullPermissions; const theme = getTheme(); - const { inactiveConsentStyles, permissionPanelStyles, activeConsentStyles } = profileStyles(theme); + const { permissionPanelStyles } = profileStyles(theme); useEffect(() => { setPermissions(sortPermissions(fullPermissions)); @@ -99,7 +96,7 @@ const PanelList = ({ messages, const onRenderGroupHeader = (props: any): JSX.Element | null => { if (props) { return ( - ) } @@ -110,7 +107,6 @@ const PanelList = ({ messages, let open = !!permissionsPanelOpen; open = !open; dispatch(togglePermissionsPanel(open)); - selectedPermissions.length = 0; trackSelectPermissionsButtonClickEvent(); }; @@ -120,29 +116,6 @@ const PanelList = ({ messages, }); }; - const handleConsent = () => { - dispatch(consentToScopes(selectedPermissions)); - setSelectedPermissions([]); - }; - - - const onRenderFooterContent = () => { - return ( -
- handleConsent()} - style={(selectedPermissions.length > 0) ? activeConsentStyles: inactiveConsentStyles} - > - {translateMessage('Consent')} - - changePanelState()}> - {translateMessage('Cancel')} - -
- ); - }; - const panelOverlayProps: IOverlayProps = { isDarkThemed: true } @@ -157,32 +130,25 @@ const PanelList = ({ messages, const groupHeaderStyles = () => { return { - check: { display: 'none' } + check: { display: 'none'}, + root: { background: theme.palette.white} } } - const selection = new Selection({ - onSelectionChanged: () => { - const selected = selection.getSelection() as IPermission[]; - const permissionsToConsent: string[] = []; - if (selected.length > 0) { - selected.forEach((option: IPermission) => { - permissionsToConsent.push(option.value); - }); - } - setSelectedPermissions(permissionsToConsent); - } - }); + const hideCheckbox = (): JSX.Element => { + return ( +
+ ) + } return (
changePanelState()} - type={PanelType.medium} + type={PanelType.largeFixed} hasCloseButton={true} headerText={translateMessage('Permissions')} - onRenderFooterContent={onRenderFooterContent} isFooterAtBottom={true} closeButtonAriaLabel='Close' overlayProps={panelOverlayProps} @@ -212,7 +178,6 @@ const PanelList = ({ messages, renderItemColumn(item, index, column)} selectionMode={SelectionMode.multiple} layoutMode={DetailsListLayoutMode.justified} - selection={selection} compact={true} groupProps={{ showEmptyGroups: false, @@ -223,7 +188,7 @@ const PanelList = ({ messages, 'Toggle selection for all items'} checkButtonAriaLabel={messages['Row checkbox'] || 'Row checkbox'} onRenderDetailsHeader={(props?: any, defaultRender?: any) => renderDetailsHeader(props, defaultRender)} - onRenderCheckbox={(props?: IDetailsListCheckboxProps) => renderCustomCheckbox(props)} + onRenderCheckbox={() => hideCheckbox()} /> } diff --git a/src/app/views/query-runner/request/permissions/Permission.styles.ts b/src/app/views/query-runner/request/permissions/Permission.styles.ts index 07e62795f..1a26c4afe 100644 --- a/src/app/views/query-runner/request/permissions/Permission.styles.ts +++ b/src/app/views/query-runner/request/permissions/Permission.styles.ts @@ -49,6 +49,42 @@ export const permissionStyles = (theme: ITheme) => { paddingLeft: 10, paddingRight: 20, minHeight: 200 + }, + tooltipStyles: { + root: { + display: 'flex', + alignItems: 'stretch' + } + }, + columnCellStyles: { + cellName: { + overflow: 'visible !important' as 'visible', + wordBreak: 'break-word', + overflowWrap: 'break-word', + flex: 1, + whiteSpace: 'normal' + }, + cellTitle: { + display: 'flex', + flex: 1, + position: 'relative' as 'relative', + right: '4px', + textAlign: 'center' + } + }, + cellTitleStyles: { + root: { + position: 'relative' as 'relative', + top: '4px' + } + }, + detailsHeaderStyles: { + root: { + height: window.innerWidth < 1830 ? '40px' : '32px', + lineHeight: '20px', + textAlign: 'center', + marginTop: '7px' + } } }; }; diff --git a/src/app/views/query-runner/request/permissions/Permission.tsx b/src/app/views/query-runner/request/permissions/Permission.tsx index 969c413d6..2631cf5eb 100644 --- a/src/app/views/query-runner/request/permissions/Permission.tsx +++ b/src/app/views/query-runner/request/permissions/Permission.tsx @@ -1,25 +1,39 @@ import { - Checkbox, FontSizes, getId, getTheme, IColumn, Icon, IDetailsListCheckboxProps, Label, - PrimaryButton, TooltipHost + DefaultButton, + FontSizes, + getId, + getTheme, + IColumn, + IconButton, + IIconProps, + Label, + PrimaryButton, + TooltipHost } from '@fluentui/react'; import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; -import messages from '../../../../../messages'; import { AppDispatch, useAppSelector } from '../../../../../store'; -import { IPermission, IPermissionProps } from '../../../../../types/permissions'; -import { consentToScopes, fetchScopes } from '../../../../services/actions/permissions-action-creator'; +import { IPermission,IPermissionGrant, IPermissionProps } from '../../../../../types/permissions'; +import { consentToScopes, fetchScopes, revokeScopes, fetchAllPrincipalGrants } from + '../../../../services/actions/permissions-action-creator'; import { translateMessage } from '../../../../utils/translate-messages'; import { classNames } from '../../../classnames'; import { convertVhToPx } from '../../../common/dimensions/dimensions-adjustment'; import PanelList from './PanelList'; import { permissionStyles } from './Permission.styles'; import TabList from './TabList'; +import messages from '../../../../../messages'; +import { ADMIN_CONSENT_DOC_LINK, CONSENT_TYPE_DOC_LINK, + REVOKING_PERMISSIONS_REQUIRED_SCOPES } from '../../../../services/graph-constants'; +import { styles } from '../../query-input/auto-complete/suffix/suffix.styles'; +import { setDescriptionColumnSize } from './util'; +import { componentNames, telemetry } from '../../../../../telemetry'; export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { - const { sampleQuery, scopes, dimensions, authToken } = + const { sampleQuery, scopes, dimensions, authToken, consentedScopes } = useAppSelector((state) => state); const { pending: loading } = scopes; const tokenPresent = !!authToken.token; @@ -33,23 +47,48 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { const classes = classNames(classProps); const theme = getTheme(); - const panelStyles = permissionStyles(theme).panelContainer; + const {panelContainer: panelStyles, tooltipStyles, columnCellStyles, cellTitleStyles, + detailsHeaderStyles} = permissionStyles(theme); const tabHeight = convertVhToPx(dimensions.request.height, 110); + const tabHeaderStyles = {... detailsHeaderStyles}; + + const handleResize = () => { + if(window.innerWidth < 1830){ + tabHeaderStyles.root.height = '40px'; + } + } + window.addEventListener('resize', handleResize); const getPermissions = (): void => { dispatch(fetchScopes()); } + useEffect(() => { + dispatch(fetchAllPrincipalGrants()); + }, []) + useEffect(() => { getPermissions(); - }, [sampleQuery]); + },[sampleQuery, scopes.pending.isRevokePermissions, authToken]); const handleConsent = async (permission: IPermission): Promise => { const consentScopes = [permission.value]; dispatch(consentToScopes(consentScopes)); }; + const handleRevoke = async (permission: IPermission) : Promise => { + dispatch(revokeScopes(permission.value)); + }; + + const buttonIcon: IIconProps = { + iconName: 'Info', + style: { + position: 'relative', + top: '1px', + left: '6px' + } }; + const renderItemColumn = (item: any, index: any, column: IColumn | undefined) => { const hostId: string = getId('tooltipHost'); const consented = !!item.consented; @@ -60,46 +99,16 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { switch (column.key) { case 'isAdmin': - if (item.isAdmin) { - return
- -
; - } else { - return
- -
; - } + return adminLabel(item); case 'consented': - if (consented) { - return ; - } else { - if (!panel) { - return handleConsent(item)}> - - ; - } - return null; - } + return consentedButton(consented, item, hostId); case 'consentDescription': - return <> - - - {item.consentDescription} - - - ; + return consentDescriptionJSX(item, hostId); + + case 'consentType': + return consentTypeProperty(consented, item); default: return ( @@ -118,33 +127,135 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { } }; + const consentDescriptionJSX = (item: any, hostId: string) => { + return( + <> + + + {item.consentDescription} + + + ) + } + + const adminLabel = (item: any): JSX.Element => { + if (item.isAdmin) { + return
+ +
; + } else { + return
+ +
; + } + } + + const consentedButton = (consented: boolean, item: any, hostId: string): JSX.Element => { + if (consented) { + if(userHasRequiredPermissions()){ + return handleRevoke(item)} style={{width: '100px', textAlign:'center'}}> + + ; + } + else{ + return + + ; + } + } else { + return handleConsent(item)} style={{width: '100px'}}> + + ; + } + } + + const consentTypeProperty = (consented: boolean, item: any): JSX.Element => { + if(scopes && scopes.data.tenantWidePermissionsGrant && scopes.data.tenantWidePermissionsGrant.length > 0 + && consented) { + + const tenantWideGrant : IPermissionGrant[] = scopes.data.tenantWidePermissionsGrant; + const allPrincipalPermissions = getAllPrincipalPermissions(tenantWideGrant); + const permissionInAllPrincipal = allPrincipalPermissions.some((permission: string) => + item.value === permission); + return permissionConsentTypeLabel(permissionInAllPrincipal); + } + return
+ } + + const permissionConsentTypeLabel = (permissionInAllPrincipal : boolean) : JSX.Element => { + if(permissionInAllPrincipal){ + return ( +
+ +
+ ) + } + else{ + return ( +
+ +
+ ) + } + } + + const getAllPrincipalPermissions = (tenantWidePermissionsGrant: IPermissionGrant[]): string[] => { + const allPrincipalPermissions = tenantWidePermissionsGrant.find((permission: any) => + permission.consentType.toLowerCase() === 'AllPrincipals'.toLowerCase()); + return allPrincipalPermissions ? allPrincipalPermissions.scope.split(' ') : []; + } + + const userHasRequiredPermissions = () : boolean => { + if(scopes && scopes.data.tenantWidePermissionsGrant && scopes.data.tenantWidePermissionsGrant.length > 0) { + const allPrincipalPermissions = getAllPrincipalPermissions(scopes.data.tenantWidePermissionsGrant); + const principalAndAllPrincipalPermissions = [...allPrincipalPermissions, ...consentedScopes]; + const requiredPermissions = REVOKING_PERMISSIONS_REQUIRED_SCOPES.split(' '); + return requiredPermissions.every(scope => principalAndAllPrincipalPermissions.includes(scope)); + } + return false; + } + const renderDetailsHeader = (props: any, defaultRender?: any) => { return defaultRender!({ ...props, onRenderColumnHeaderTooltip: (tooltipHostProps: any) => { return ( - + ); - } + }, + styles: tabHeaderStyles }); } - const renderCustomCheckbox = (props: IDetailsListCheckboxProps): JSX.Element => { - return ( -
- -
- ) - } - - const getColumns = (): IColumn[] => { + const getColumns = () : IColumn[] => { + const columnSizes = setDescriptionColumnSize(); const columns: IColumn[] = [ { key: 'value', name: translateMessage('Permission'), fieldName: 'value', - minWidth: 200, - maxWidth: 250, + minWidth: 150, + maxWidth: 200, isResizable: true, columnActionsMode: 0 } @@ -157,8 +268,8 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { name: translateMessage('Description'), fieldName: 'consentDescription', isResizable: true, - minWidth: (tokenPresent) ? 400 : 600, - maxWidth: (tokenPresent) ? 600 : 1000, + minWidth: (tokenPresent) ? columnSizes.minWidth : 600, + maxWidth: (tokenPresent) ? columnSizes.maxWidth : 1000, isMultiline: true, columnActionsMode: 0 } @@ -168,13 +279,15 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { columns.push( { key: 'isAdmin', - isResizable: true, name: translateMessage('Admin consent required'), fieldName: 'isAdmin', minWidth: (tokenPresent) ? 150 : 200, maxWidth: (tokenPresent) ? 200 : 300, ariaLabel: translateMessage('Administrator permission'), - columnActionsMode: 0 + isMultiline: true, + headerClassName: 'permissionHeader', + styles: columnCellStyles, + onRenderHeader: () => renderColumnHeader('Admin consent required') } ); @@ -186,14 +299,77 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { isResizable: false, fieldName: 'consented', minWidth: 100, - maxWidth: 100 - } + maxWidth: 120, + onRenderHeader: () => renderColumnHeader('Status'), + styles: columnCellStyles + }, + ); } + columns.push( + { + key: 'consentType', + name: translateMessage('Consent type'), + isResizable: false, + fieldName: 'consentType', + minWidth: 100, + maxWidth: 100, + onRenderHeader: () => renderColumnHeader('Consent type'), + styles: columnCellStyles, + ariaLabel: translateMessage('Permission consent type') + } + ) return columns; } - const displayPermissionsPanel = (): JSX.Element => { + const infoIcon: IIconProps = { + iconName: 'Info', + styles: cellTitleStyles + }; + + const openExternalWebsite = (url: string) => { + switch(url){ + case 'Consent type': + window.open(CONSENT_TYPE_DOC_LINK, '_blank'); + trackLinkClickedEvent(CONSENT_TYPE_DOC_LINK, componentNames.CONSENT_TYPE_DOC_LINK) + break; + case 'Admin consent required': + window.open(ADMIN_CONSENT_DOC_LINK, '_blank'); + trackLinkClickedEvent(ADMIN_CONSENT_DOC_LINK, componentNames.ADMIN_CONSENT_DOC_LINK); + break; + } + } + + const trackLinkClickedEvent = (link: string, componentName: string) => { + telemetry.trackLinkClickEvent(link, componentName); + } + + + const renderColumnHeader = (headerText: string) => { + if(headerText === 'Status'){ + return ( + + {translateMessage('Status')} + + ) + } + + return (
+ openExternalWebsite(headerText)} + > + + + {translateMessage(headerText)} + +
) + } + + const displayPermissionsPanel = () : JSX.Element => { return
{ renderItemColumn={(item?: any, index?: number, column?: IColumn) => renderItemColumn(item, index, column)} renderDetailsHeader={renderDetailsHeader} - renderCustomCheckbox={renderCustomCheckbox} />
}; @@ -224,7 +399,7 @@ export const Permission = (permissionProps?: IPermissionProps): JSX.Element => { const displayLoadingPermissionsText = () => { return ( - ); } + const displayNotSignedInMessage = () : JSX.Element => { return (