Added hooks to evaluate reader role access

This commit is contained in:
Bikram Choudhury
2025-10-15 18:39:32 +05:30
parent 9bfb6aecc9
commit a23a7791d4
10 changed files with 385 additions and 36 deletions

View File

@@ -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;

View File

@@ -38,7 +38,9 @@ const getInitialCopyJobState = (): CopyJobContextState => {
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
const config = useConfig();
const { isLoggedIn, armToken } = useAADAuth(config);
const { isLoggedIn, armToken, account } = useAADAuth(config);
const principalId = account?.localAccountId ?? "";
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
@@ -52,7 +54,7 @@ const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) =>
}
return (
<CopyJobContext.Provider value={{ armToken, copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
<CopyJobContext.Provider value={{ principalId, armToken, copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
{props.children}
</CopyJobContext.Provider>
);

View File

@@ -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;

View File

@@ -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<PermissionSectionConfig> = ({ id, title, Component }) => (
<AccordionItem key={id} value={id}>
const PermissionSection: React.FC<PermissionSectionConfig> = ({
id,
title,
Component,
completed,
disabled
}) => (
<AccordionItem key={id} value={id} disabled={disabled}>
<AccordionHeader className="accordionHeader">
<Text className="accordionHeaderText" variant="medium">{title}</Text>
<Image className="statusIcon" src={WarningIcon} alt="Warning icon" width={24} height={24} />
<Image
className="statusIcon"
src={completed ? CheckmarkIcon : WarningIcon}
alt={completed ? "Checkmark icon" : "Warning icon"}
width={completed ? 20 : 24}
height={completed ? 20 : 24}
/>
</AccordionHeader>
<AccordionPanel>
<AccordionPanel aria-disabled={disabled} className="accordionPanel" >
<Component />
</AccordionPanel>
</AccordionItem>
);
const AssignPermissions = () => {
const { copyJobState } = useCopyJobContext();
const permissionSections = usePermissionSections(copyJobState);
const { armToken, principalId, copyJobState } = useCopyJobContext();
const permissionSections = usePermissionSections(copyJobState, armToken, principalId);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>

View File

@@ -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",

View File

@@ -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<React.SetStateAction<CopyJobFlowType>>;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<RoleAssignmentType[]> => {
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;
}

View File

@@ -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<RoleDefinitionType[]> => {
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;
}