From a23a7791d48d4f37aa261086b3f5e4adf122ea6b Mon Sep 17 00:00:00 2001 From: Bikram Choudhury Date: Wed, 15 Oct 2025 18:39:32 +0530 Subject: [PATCH] Added hooks to evaluate reader role access --- src/Contracts/DataModels.ts | 21 +++ .../ContainerCopy/Context/CopyJobContext.tsx | 6 +- .../hooks/usePermissionsSection.tsx | 161 ++++++++++++++++-- .../Screens/AssignPermissions/index.tsx | 25 ++- src/Explorer/ContainerCopy/Enums/index.ts | 16 ++ src/Explorer/ContainerCopy/Types/index.ts | 13 +- src/hooks/useDataContainers.tsx | 9 +- src/hooks/useDatabases.tsx | 9 +- src/hooks/useRoleAssignments.tsx | 93 ++++++++++ src/hooks/useRoleDefinition.tsx | 68 ++++++++ 10 files changed, 385 insertions(+), 36 deletions(-) create mode 100644 src/hooks/useRoleAssignments.tsx create mode 100644 src/hooks/useRoleDefinition.tsx diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index caf4b32e3..b0bb8eb29 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -10,15 +10,34 @@ export interface ArmEntity { resourceGroup?: string; } +export interface DatabaseAccountUserAssignedIdentity { + [key: string]: { + principalId: string; + clientId: string; + } +} + +export interface DatabaseAccountIdentity { + type: string; + principalId?: string; + tenantId?: string; + userAssignedIdentities?: DatabaseAccountUserAssignedIdentity; +} + export interface DatabaseAccount extends ArmEntity { properties: DatabaseAccountExtendedProperties; systemData?: DatabaseAccountSystemData; + identity?: DatabaseAccountIdentity | null; } export interface DatabaseAccountSystemData { createdAt: string; } +export interface DatabaseAccountBackupPolicy { + type: string; +} + export interface DatabaseAccountExtendedProperties { documentEndpoint?: string; disableLocalAuth?: boolean; @@ -29,6 +48,8 @@ export interface DatabaseAccountExtendedProperties { capabilities?: Capability[]; enableMultipleWriteLocations?: boolean; mongoEndpoint?: string; + backupPolicy?: DatabaseAccountBackupPolicy; + defaultIdentity?: string; readLocations?: DatabaseAccountResponseLocation[]; writeLocations?: DatabaseAccountResponseLocation[]; enableFreeTier?: boolean; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index 2a8eac983..59bb024fb 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -38,7 +38,9 @@ const getInitialCopyJobState = (): CopyJobContextState => { const CopyJobContextProvider: React.FC = (props) => { const config = useConfig(); - const { isLoggedIn, armToken } = useAADAuth(config); + const { isLoggedIn, armToken, account } = useAADAuth(config); + const principalId = account?.localAccountId ?? ""; + const [copyJobState, setCopyJobState] = React.useState(getInitialCopyJobState()); const [flow, setFlow] = React.useState(null); @@ -52,7 +54,7 @@ const CopyJobContextProvider: React.FC = (props) => } return ( - + {props.children} ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx index b60edeaf0..fc9b4b4d9 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -1,5 +1,13 @@ +import { useRoleAssignments } from "hooks/useRoleAssignments"; +import { RoleDefinitionType, useRoleDefinitions } from "hooks/useRoleDefinition"; +import { useMemo } from "react"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; -import { CopyJobMigrationType } from "../../../../Enums"; +import { + BackupPolicyType, + CopyJobMigrationType, + DefaultIdentityType, + IdentityType +} from "../../../../Enums"; import { CopyJobContextState } from "../../../../Types"; import AddManagedIdentity from "../AddManagedIdentity"; import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity"; @@ -7,28 +15,36 @@ import DefaultManagedIdentity from "../DefaultManagedIdentity"; import OnlineCopyEnabled from "../OnlineCopyEnabled"; import PointInTimeRestore from "../PointInTimeRestore"; -// Define a typed config for permission sections export interface PermissionSectionConfig { id: string; title: string; Component: React.FC; - shouldShow?: (state: CopyJobContextState) => boolean; // optional conditional rendering + disabled?: boolean; + completed?: boolean; } -// Base permission sections with dynamic visibility logic +// Section IDs for maintainability +const SECTION_IDS = { + addManagedIdentity: "addManagedIdentity", + defaultManagedIdentity: "defaultManagedIdentity", + readPermissionAssigned: "readPermissionAssigned", + pointInTimeRestore: "pointInTimeRestore", + onlineCopyEnabled: "onlineCopyEnabled" +} as const; + const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [ { - id: "addManagedIdentity", + id: SECTION_IDS.addManagedIdentity, title: ContainerCopyMessages.addManagedIdentity.title, Component: AddManagedIdentity, }, { - id: "defaultManagedIdentity", + id: SECTION_IDS.defaultManagedIdentity, title: ContainerCopyMessages.defaultManagedIdentity.title, Component: DefaultManagedIdentity, }, { - id: "readPermissionAssigned", + id: SECTION_IDS.readPermissionAssigned, title: ContainerCopyMessages.readPermissionAssigned.title, Component: AddReadPermissionToDefaultIdentity, } @@ -36,22 +52,139 @@ const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [ { - id: "pointInTimeRestore", + id: SECTION_IDS.pointInTimeRestore, title: ContainerCopyMessages.pointInTimeRestore.title, Component: PointInTimeRestore, }, { - id: "onlineCopyEnabled", + id: SECTION_IDS.onlineCopyEnabled, title: ContainerCopyMessages.onlineCopyEnabled.title, Component: OnlineCopyEnabled, } ]; -const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => { - return [ - ...PERMISSION_SECTIONS_CONFIG, - ...(state.migrationType !== CopyJobMigrationType.Offline ? PERMISSION_SECTIONS_FOR_ONLINE_JOBS : []), - ]; + +/** + * Checks if the user has the Reader role based on role definitions. + */ +export function checkUserHasReaderRole(roleDefinitions: RoleDefinitionType[]): boolean { + return roleDefinitions?.some( + role => + role.name === "00000000-0000-0000-0000-000000000001" || + role.permissions.some( + permission => + permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") && + permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read") + ) + ); +} + +/** + * Returns the permission sections configuration for the Assign Permissions screen. + * Memoizes derived values for performance and decouples logic for testability. + */ +const usePermissionSections = ( + state: CopyJobContextState, + armToken: string, + principalId: string +): PermissionSectionConfig[] => { + const { source, target } = state; + + // Memoize identity types and backup policy + const targetAccountIdentityType = useMemo( + () => (target?.account?.identity?.type ?? "").toLowerCase(), + [target?.account?.identity?.type] + ); + const targetAccountDefaultIdentityType = useMemo( + () => (target?.account?.properties?.defaultIdentity ?? "").toLowerCase(), + [target?.account?.properties?.defaultIdentity] + ); + const sourceAccountBackupPolicy = useMemo( + () => source?.account?.properties?.backupPolicy?.type ?? "", + [source?.account?.properties?.backupPolicy?.type] + ); + + // Fetch role assignments and definitions + const roleAssigned = useRoleAssignments( + armToken, + source?.subscription?.subscriptionId, + source?.account?.resourceGroup, + source?.account?.name, + principalId + ); + + const roleDefinitions = useRoleDefinitions( + armToken, + roleAssigned ?? [] + ); + + const hasReaderRole = useMemo( + () => checkUserHasReaderRole(roleDefinitions ?? []), + [roleDefinitions] + ); + + // Decouple section state logic for testability + const getBaseSections = useMemo(() => { + return PERMISSION_SECTIONS_CONFIG.map(section => { + if ( + section.id === SECTION_IDS.addManagedIdentity && + (targetAccountIdentityType === IdentityType.SystemAssigned || + targetAccountIdentityType === IdentityType.UserAssigned) + ) { + return { + ...section, + disabled: true, + completed: true + }; + } + if ( + section.id === SECTION_IDS.defaultManagedIdentity && + targetAccountDefaultIdentityType === DefaultIdentityType.SystemAssignedIdentity + ) { + return { + ...section, + disabled: true, + completed: true + }; + } + if ( + section.id === SECTION_IDS.readPermissionAssigned && + hasReaderRole + ) { + return { + ...section, + disabled: true, + completed: true + }; + } + return section; + }); + }, [targetAccountIdentityType, targetAccountDefaultIdentityType, hasReaderRole]); + + const getOnlineSections = useMemo(() => { + if (state.migrationType !== CopyJobMigrationType.Online) return []; + return PERMISSION_SECTIONS_FOR_ONLINE_JOBS.map(section => { + if ( + section.id === SECTION_IDS.pointInTimeRestore && + sourceAccountBackupPolicy === BackupPolicyType.Continuous + ) { + return { + ...section, + disabled: true, + completed: true + }; + } + return section; + }); + }, [state.migrationType, sourceAccountBackupPolicy]); + + // Combine and memoize final sections + const permissionSections = useMemo( + () => [...getBaseSections, ...getOnlineSections], + [getBaseSections, getOnlineSections] + ); + + return permissionSections; }; export default usePermissionSections; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx index ee7934795..efcd34567 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx @@ -1,26 +1,39 @@ import { Image, Stack, Text } from "@fluentui/react"; import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components"; import React from "react"; +import CheckmarkIcon from "../../../../../../images/successfulPopup.svg"; import WarningIcon from "../../../../../../images/warning.svg"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection"; -const PermissionSection: React.FC = ({ id, title, Component }) => ( - +const PermissionSection: React.FC = ({ + id, + title, + Component, + completed, + disabled +}) => ( + {title} - Warning icon + {completed - + ); const AssignPermissions = () => { - const { copyJobState } = useCopyJobContext(); - const permissionSections = usePermissionSections(copyJobState); + const { armToken, principalId, copyJobState } = useCopyJobContext(); + const permissionSections = usePermissionSections(copyJobState, armToken, principalId); return ( diff --git a/src/Explorer/ContainerCopy/Enums/index.ts b/src/Explorer/ContainerCopy/Enums/index.ts index 97beac1ec..9a12634bb 100644 --- a/src/Explorer/ContainerCopy/Enums/index.ts +++ b/src/Explorer/ContainerCopy/Enums/index.ts @@ -3,6 +3,22 @@ export enum CopyJobMigrationType { Online = "online", } +// all checks will happen +export enum IdentityType { + SystemAssigned = "systemassigned", // "SystemAssigned" + UserAssigned = "userassigned", // "UserAssigned" + None = "none", // "None" +} + +export enum DefaultIdentityType { + SystemAssignedIdentity = "systemassignedidentity", // "SystemAssignedIdentity" +} + +export enum BackupPolicyType { + Continuous = "Continuous", + Periodic = "Periodic", +} + export enum CopyJobMigrationStatus { Pause = "Pause", Resume = "Resume", diff --git a/src/Explorer/ContainerCopy/Types/index.ts b/src/Explorer/ContainerCopy/Types/index.ts index d97eece8e..88071c021 100644 --- a/src/Explorer/ContainerCopy/Types/index.ts +++ b/src/Explorer/ContainerCopy/Types/index.ts @@ -27,18 +27,6 @@ export type DropdownOptionType = { data: any }; -export type FetchDatabasesListParams = { - armToken: string; - subscriptionId: string; - resourceGroupName: string; - accountName: string; - apiType?: ApiType; -}; - -export interface FetchDataContainersListParams extends FetchDatabasesListParams { - databaseName: string; -} - export type DatabaseParams = [ string, string | undefined, @@ -91,6 +79,7 @@ export interface CopyJobFlowType { } export interface CopyJobContextProviderType { + principalId: string; armToken: string; flow: CopyJobFlowType; setFlow: React.Dispatch>; diff --git a/src/hooks/useDataContainers.tsx b/src/hooks/useDataContainers.tsx index e8702a7d4..16ba491d9 100644 --- a/src/hooks/useDataContainers.tsx +++ b/src/hooks/useDataContainers.tsx @@ -2,10 +2,17 @@ import { DatabaseModel } from "Contracts/DataModels"; import useSWR from "swr"; import { getCollectionEndpoint, getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; import { configContext } from "../ConfigContext"; -import { FetchDataContainersListParams } from "../Explorer/ContainerCopy/Types"; import { ApiType } from "../UserContext"; const apiVersion = "2023-09-15"; +export interface FetchDataContainersListParams { + armToken: string; + subscriptionId: string; + resourceGroupName: string; + databaseName: string; + accountName: string; + apiType?: ApiType; +} const buildReadDataContainersListUrl = (params: FetchDataContainersListParams): string => { const { subscriptionId, resourceGroupName, accountName, databaseName, apiType } = params; diff --git a/src/hooks/useDatabases.tsx b/src/hooks/useDatabases.tsx index 24ca29bbd..a0d8f4823 100644 --- a/src/hooks/useDatabases.tsx +++ b/src/hooks/useDatabases.tsx @@ -2,10 +2,17 @@ import { DatabaseModel } from "Contracts/DataModels"; import useSWR from "swr"; import { getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; import { configContext } from "../ConfigContext"; -import { FetchDatabasesListParams } from "../Explorer/ContainerCopy/Types"; import { ApiType } from "../UserContext"; const apiVersion = "2023-09-15"; +export interface FetchDatabasesListParams { + armToken: string; + subscriptionId: string; + resourceGroupName: string; + accountName: string; + apiType?: ApiType; +} + const buildReadDatabasesListUrl = (params: FetchDatabasesListParams): string => { const { subscriptionId, resourceGroupName, accountName, apiType } = params; const databaseEndpoint = getDatabaseEndpoint(apiType); diff --git a/src/hooks/useRoleAssignments.tsx b/src/hooks/useRoleAssignments.tsx new file mode 100644 index 000000000..5860260f2 --- /dev/null +++ b/src/hooks/useRoleAssignments.tsx @@ -0,0 +1,93 @@ +import useSWR from "swr"; +import { configContext } from "../ConfigContext"; + +const apiVersion = "2025-04-15"; + +export type FetchAccountDetailsParams = { + armToken: string; + subscriptionId: string; + resourceGroupName: string; + accountName: string; +}; + +export type RoleAssignmentPropertiesType = { + roleDefinitionId: string; + principalId: string; + scope: string; +}; + +export type RoleAssignmentType = { + id: string; + name: string; + properties: RoleAssignmentPropertiesType; + type: string; +}; + +const buildRoleAssignmentsListUrl = (params: FetchAccountDetailsParams): string => { + const { subscriptionId, resourceGroupName, accountName } = params; + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments?api-version=${apiVersion}`; +} + +const fetchRoleAssignments = async ( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string +): Promise => { + const uri = buildRoleAssignmentsListUrl({ + armToken, + subscriptionId, + resourceGroupName, + accountName + }); + const headers = new Headers(); + const bearer = `Bearer ${armToken}`; + headers.append("Authorization", bearer); + headers.append("Content-Type", "application/json"); + + const response = await fetch(uri, { + method: "GET", + headers: headers + }); + + if (!response.ok) { + throw new Error("Failed to fetch containers"); + } + + const data = await response.json(); + const assignments = data.value; + const rolesAssignedToLoggedinUser = assignments.filter((assignment: RoleAssignmentType) => assignment?.properties?.principalId === principalId); + return rolesAssignedToLoggedinUser; +}; + +export function useRoleAssignments( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string +): RoleAssignmentType[] | undefined { + const { data } = useSWR( + () => ( + armToken && subscriptionId && resourceGroupName && accountName && principalId ? [ + "fetchRoleAssignmentsLinkedToAccount", + armToken, subscriptionId, resourceGroupName, accountName, principalId + ] : undefined + ), + (_, armToken, subscriptionId, resourceGroupName, accountName, principalId) => fetchRoleAssignments( + armToken, + subscriptionId, + resourceGroupName, + accountName, + principalId + ), + ); + + return data; +} \ No newline at end of file diff --git a/src/hooks/useRoleDefinition.tsx b/src/hooks/useRoleDefinition.tsx new file mode 100644 index 000000000..9774ed8f2 --- /dev/null +++ b/src/hooks/useRoleDefinition.tsx @@ -0,0 +1,68 @@ +import useSWR from "swr"; +import { configContext } from "../ConfigContext"; +import { RoleAssignmentType } from "./useRoleAssignments"; + +type RoleDefinitionDataActions = { + dataActions: string[]; +}; + +export type RoleDefinitionType = { + assignableScopes: string[]; + id: string; + name: string; + permissions: RoleDefinitionDataActions[]; + resourceGroup: string; + roleName: string; + type: string; + typePropertiesType: string; +}; + +const apiVersion = "2025-04-15"; +const buildRoleDefinitionUrl = (roleDefinitionId: string): string => { + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}${roleDefinitionId}?api-version=${apiVersion}`; +} + +const fetchRoleDefinitions = async ( + armToken: string, + roleAssignments: RoleAssignmentType[], +): Promise => { + const roleDefinitionIds = roleAssignments.map(assignment => assignment.properties.roleDefinitionId); + const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds)); + + const roleDefinitionUris = uniqueRoleDefinitionIds.map(roleDefinitionId => buildRoleDefinitionUrl(roleDefinitionId)); + const headers = { + Authorization: `Bearer ${armToken}`, + "Content-Type": "application/json" + }; + const promises = roleDefinitionUris.map(uri => fetch(uri, { method: "GET", headers })); + const responses = await Promise.all(promises); + for (const response of responses) { + if (!response.ok) throw new Error("Failed to fetch role definitions"); + } + const roleDefinitions = await Promise.all(responses.map(r => r.json())); + return roleDefinitions; +}; + +export function useRoleDefinitions( + armToken: string, + roleAssignments: RoleAssignmentType[], +): RoleDefinitionType[] | undefined { + const { data } = useSWR( + () => ( + armToken && roleAssignments?.length ? [ + "fetchRoleDefinitionsForTheAssignments", + armToken, roleAssignments + ] : undefined + ), + (_, armToken, roleAssignments) => fetchRoleDefinitions( + armToken, + roleAssignments + ), + ); + + return data; +} \ No newline at end of file