diff --git a/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md new file mode 100644 index 00000000000..cfdfe96c005 --- /dev/null +++ b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893)) diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 117bbf322e9..486263c3adc 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -21,9 +21,27 @@ import type { AccountEntity, APIError, EntityType, + FirewallAdmin, + FirewallContributor, + FirewallViewer, GrantType, + ImageAdmin, + ImageContributor, + ImageViewer, + LinodeAdmin, + LinodeContributor, + LinodeViewer, + NodeBalancerAdmin, + NodeBalancerContributor, + NodeBalancerViewer, PermissionType, Profile, + VolumeAdmin, + VolumeContributor, + VolumeViewer, + VPCAdmin, + VPCContributor, + VPCViewer, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; @@ -36,16 +54,110 @@ const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [ 'create_nodebalancer', ]; +type EntityPermission = + | FirewallAdmin + | FirewallContributor + | FirewallViewer + | ImageAdmin + | ImageContributor + | ImageViewer + | LinodeAdmin + | LinodeContributor + | LinodeViewer + | NodeBalancerAdmin + | NodeBalancerContributor + | NodeBalancerViewer + | VolumeAdmin + | VolumeContributor + | VolumeViewer + | VPCAdmin + | VPCContributor + | VPCViewer; + +declare const PermissionByAccessKnown: { + account: Exclude; + database: never; // TODO: add database permissions + domain: never; // TODO: add domain permissions + firewall: FirewallAdmin | FirewallContributor | FirewallViewer; + image: ImageAdmin | ImageContributor | ImageViewer; + linode: LinodeAdmin | LinodeContributor | LinodeViewer; + lkecluster: never; // TODO: add lkecluster permissions + longview: never; // TODO: add longview permissions + nodebalancer: + | NodeBalancerAdmin + | NodeBalancerContributor + | NodeBalancerViewer; + placement_group: never; // TODO: add placement_group permissions + stackscript: never; // TODO: add stackscript permissions + volume: VolumeAdmin | VolumeContributor | VolumeViewer; + vpc: VPCAdmin | VPCContributor | VPCViewer; +}; + +type AssertNever = T; + +/** + * Compile‑time assertions only. + * + * Ensure: + * - PermissionByAccessKnown has only allowed AccessTypes. + * - All AccessTypes are handled by PermissionByAccessKnown. + */ +export type NoExtraKeys = AssertNever< + Exclude +>; +export type AllHandled = AssertNever< + Exclude +>; + +type KnownAccessKeys = keyof typeof PermissionByAccessKnown & AccessType; + +type AllowedPermissionsFor = A extends KnownAccessKeys + ? (typeof PermissionByAccessKnown)[A] + : // exhaustiveness check, no fallback + never; + export type PermissionsResult = { data: Record; } & Omit, 'data'>; -export const usePermissions = ( - accessType: AccessType, +/** + * Overload 1: account-level + */ +export function usePermissions< + A extends 'account', + T extends readonly AllowedPermissionsFor[], +>( + accessType: A, + permissionsToCheck: T, + entityId?: undefined, + enabled?: boolean +): PermissionsResult; + +/** + * Overload 2: entity-level + */ +export function usePermissions< + A extends Exclude, + T extends readonly AllowedPermissionsFor[], +>( + accessType: A, + permissionsToCheck: T, + entityId: number | string | undefined, + enabled?: boolean +): PermissionsResult; + +/** + * Implementation + */ +export function usePermissions< + A extends AccessType, + T extends readonly PermissionType[], +>( + accessType: A, permissionsToCheck: T, entityId?: number | string, enabled: boolean = true -): PermissionsResult => { +): PermissionsResult { const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); const { data: profile } = useProfile(); @@ -70,7 +182,9 @@ export const usePermissions = ( BETA_ACCESS_TYPE_SCOPE.includes(accessType) && LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some( (blacklistedPermission) => - permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet + permissionsToCheck.includes( + blacklistedPermission as AllowedPermissionsFor + ) // some of the account admin in the blacklist have not been added yet ) === false; const useLAPermissions = isIAMEnabled && !isIAMBeta; const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; @@ -113,7 +227,7 @@ export const usePermissions = ( ...restAccountPermissions, ...restEntityPermissions, } as const; -}; +} export type EntityBase = Pick; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx index 4bdd304b90d..61357db3320 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx @@ -31,8 +31,7 @@ export const MaintenancePolicy = () => { const { data: region } = useRegionQuery(selectedRegion); const { data: type } = useTypeQuery(selectedType, Boolean(selectedType)); - // Check if user has permission to update linodes (needed for maintenance policy) - const { data: permissions } = usePermissions('linode', ['update_linode']); + const { data: permissions } = usePermissions('account', ['create_linode']); const isGPUPlan = type && type.class === 'gpu'; @@ -42,7 +41,7 @@ export const MaintenancePolicy = () => { // Determine if disabled due to missing prerequisites vs permission issues const isDisabledDueToPrerequisites = !selectedRegion || !regionSupportsMaintenancePolicy; - const isDisabledDueToPermissions = !permissions?.update_linode; + const isDisabledDueToPermissions = !permissions?.create_linode; const isDisabled = isDisabledDueToPrerequisites || isDisabledDueToPermissions; return (