From ceddad4e0a447b3cd50997162bf1a2531bd18968 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 21 Nov 2024 10:03:39 +0100 Subject: [PATCH] wip --- .../api/src/modules/auth/lib/authz.ts | 50 +- .../shared/permission-picker.spec.ts | 340 ++++++ .../components/shared/permission-picker.tsx | 914 ++++++++++++++ .../shared/permission-picker/lib.ts | 223 ++++ .../shared/permission-picker/permissions.ts | 277 +++++ .../new-permissions-picker.stories.tsx | 1073 ----------------- .../src/stories/permission-picker.stories.tsx | 62 + 7 files changed, 1841 insertions(+), 1098 deletions(-) create mode 100644 packages/web/app/src/components/shared/permission-picker.spec.ts create mode 100644 packages/web/app/src/components/shared/permission-picker.tsx create mode 100644 packages/web/app/src/components/shared/permission-picker/lib.ts create mode 100644 packages/web/app/src/components/shared/permission-picker/permissions.ts delete mode 100644 packages/web/app/src/stories/new-permissions-picker.stories.tsx create mode 100644 packages/web/app/src/stories/permission-picker.stories.tsx diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index af58d3b3c81..251a591697b 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -322,35 +322,35 @@ function schemaCheckOrPublishIdentity( * This is the place to do so. */ const actionDefinitions = { - 'organization:describe': defaultOrgIdentity, - 'organization:modifySlug': defaultOrgIdentity, - 'organization:delete': defaultOrgIdentity, - 'gitHubIntegration:modify': defaultOrgIdentity, - 'slackIntegration:modify': defaultOrgIdentity, - 'oidc:modify': defaultOrgIdentity, - 'support:manageTickets': defaultOrgIdentity, - 'billing:describe': defaultOrgIdentity, - 'billing:update': defaultOrgIdentity, + 'organization:describe': defaultOrgIdentity, // done + 'organization:modifySlug': defaultOrgIdentity, // done + 'organization:delete': defaultOrgIdentity, // done + 'gitHubIntegration:modify': defaultOrgIdentity, // done + 'slackIntegration:modify': defaultOrgIdentity, // done + 'oidc:modify': defaultOrgIdentity, // done + 'support:manageTickets': defaultOrgIdentity, // done + 'billing:describe': defaultOrgIdentity, // done + 'billing:update': defaultOrgIdentity, // done 'targetAccessToken:modify': defaultTargetIdentity, 'cdnAccessToken:modify': defaultTargetIdentity, - 'member:describe': defaultOrgIdentity, - 'member:assignRole': defaultOrgIdentity, - 'member:modifyRole': defaultOrgIdentity, - 'member:removeMember': defaultOrgIdentity, - 'member:manageInvites': defaultOrgIdentity, - 'project:create': defaultOrgIdentity, - 'project:describe': defaultProjectIdentity, - 'project:delete': defaultProjectIdentity, - 'project:modifySettings': defaultProjectIdentity, + 'member:describe': defaultOrgIdentity, // done + 'member:assignRole': defaultOrgIdentity, // done + 'member:modifyRole': defaultOrgIdentity, // done + 'member:removeMember': defaultOrgIdentity, // done + 'member:manageInvites': defaultOrgIdentity, // done + 'project:create': defaultOrgIdentity, // done + 'project:describe': defaultProjectIdentity, // done + 'project:delete': defaultProjectIdentity, // done + 'project:modifySettings': defaultProjectIdentity, // done 'alert:modify': defaultProjectIdentity, - 'schemaLinting:modifyOrganizationRules': defaultOrgIdentity, + 'schemaLinting:modifyOrganizationRules': defaultOrgIdentity, // done 'schemaLinting:modifyProjectRules': defaultProjectIdentity, - 'target:create': defaultProjectIdentity, - 'target:delete': defaultTargetIdentity, - 'target:modifySettings': defaultTargetIdentity, - 'laboratory:describe': defaultTargetIdentity, - 'laboratory:modify': defaultTargetIdentity, - 'appDeployment:describe': defaultTargetIdentity, + 'target:create': defaultProjectIdentity, // done + 'target:delete': defaultTargetIdentity, // done + 'target:modifySettings': defaultTargetIdentity, // done + 'laboratory:describe': defaultTargetIdentity, // done + 'laboratory:modify': defaultTargetIdentity, // done + 'appDeployment:describe': defaultTargetIdentity, // done 'appDeployment:create': defaultAppDeploymentIdentity, 'appDeployment:publish': defaultAppDeploymentIdentity, 'appDeployment:retire': defaultAppDeploymentIdentity, diff --git a/packages/web/app/src/components/shared/permission-picker.spec.ts b/packages/web/app/src/components/shared/permission-picker.spec.ts new file mode 100644 index 00000000000..54a74215cdd --- /dev/null +++ b/packages/web/app/src/components/shared/permission-picker.spec.ts @@ -0,0 +1,340 @@ +import { + createPermissionSelectionGroup, + PermissionsSelectionGroupAccessMode, + ResolvedResourcePermissions, + resolvePermissionsFromPermissionSelectionGroup, + Resource, +} from './permission-picker/lib'; +import { ResourceLevel } from './permission-picker/permissions'; + +describe('createPermissionSelectionGroup', () => { + test('includes default permissions', () => { + const group = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + expect(group).toEqual({ + id: 'default', + level: ResourceLevel.organization, + mode: PermissionsSelectionGroupAccessMode.granular, + resourceIds: ['the-guild'], + selectedPermissions: { + 'organization:describe': 'allow', + }, + title: 'Default group', + }); + + expect(group.selectedPermissions).toEqual({ + 'organization:describe': 'allow', + }); + }); +}); + +describe('resolvePermissionsFromPermissionSelectionGroup', () => { + test('resolve permissions for default permissions on single group', () => { + const resource: Resource = { + id: 'the-guild', + level: ResourceLevel.organization, + }; + const group = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + const result = resolvePermissionsFromPermissionSelectionGroup([group], [resource]); + + expect(result).toEqual([ + { + resourceId: 'the-guild', + permissions: { + 'organization:describe': 'allow', + }, + } satisfies ResolvedResourcePermissions, + ]); + }); + + test('resolve permissions for default group on single permissions', () => { + const resource: Resource = { + id: 'the-guild', + level: ResourceLevel.organization, + }; + const group = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + group.selectedPermissions['organization:support'] = 'allow'; + + const result = resolvePermissionsFromPermissionSelectionGroup([group], [resource]); + expect(result[0].permissions['organization:support']).toEqual('allow'); + }); + + test('resolve permissions for default group on single group', () => { + const resource: Resource = { + id: 'the-guild', + level: ResourceLevel.organization, + }; + const group = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + group.selectedPermissions['organization:support'] = 'deny'; + + const result = resolvePermissionsFromPermissionSelectionGroup([group], [resource]); + expect(result[0].permissions['organization:support']).toEqual('deny'); + }); + + test('deny takes precedence over allow', () => { + const resource: Resource = { + id: 'the-guild', + level: ResourceLevel.organization, + }; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.selectedPermissions['organization:support'] = 'deny'; + + const group1 = createPermissionSelectionGroup({ + id: 'group-1', + level: ResourceLevel.organization, + title: 'Group 1', + resourceIds: ['the-guild'], + }); + + group1.selectedPermissions['organization:support'] = 'allow'; + + const result = resolvePermissionsFromPermissionSelectionGroup( + [defaultGroup, group1], + [resource], + ); + expect(result[0].permissions['organization:support']).toEqual('deny'); + }); + + test('permissions are applied on child resources depending on their level', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + childResourceIds: ['the-guild/graphql-hive'], + }, + { + id: 'the-guild/graphql-hive', + level: ResourceLevel.project, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.selectedPermissions['project:describe'] = 'allow'; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + expect(result[0].permissions['project:describe']).toEqual(undefined); + expect(result[1].permissions['project:describe']).toEqual('allow'); + }); + + test('"deny" takes precedence on child resource level', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + childResourceIds: ['the-guild/graphql-hive'], + }, + { + id: 'the-guild/graphql-hive', + level: ResourceLevel.project, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + const group1 = createPermissionSelectionGroup({ + id: 'group1', + level: ResourceLevel.project, + title: 'Group 1', + resourceIds: ['the-guild/graphql-hive'], + }); + + defaultGroup.selectedPermissions['project:describe'] = 'allow'; + group1.selectedPermissions['project:describe'] = 'deny'; + + const result = resolvePermissionsFromPermissionSelectionGroup( + [defaultGroup, group1], + resources, + ); + expect(result[0].permissions['project:describe']).toEqual(undefined); + expect(result[1].permissions['project:describe']).toEqual('deny'); + }); + + test('"PermissionsSelectionGroupAccessMode.allowAll" allow all actions on resource', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + defaultGroup.mode = PermissionsSelectionGroupAccessMode.allowAll; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + + expect(result[0].permissions['members:describe']).toEqual('allow'); + }); + + test('"PermissionsSelectionGroupAccessMode.denyAll" deny all actions on resource', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + defaultGroup.mode = PermissionsSelectionGroupAccessMode.denyAll; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + + expect(result[0].permissions['members:describe']).toEqual('deny'); + }); + + test('"PermissionsSelectionGroupAccessMode.allowAll" propagates allow all actions on child resource', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + childResourceIds: ['the-guild/graphql-hive'], + }, + { + id: 'the-guild/graphql-hive', + level: ResourceLevel.project, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.mode = PermissionsSelectionGroupAccessMode.allowAll; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + + expect(result[0].permissions['project:describe']).toEqual(undefined); + expect(result[1].permissions['project:describe']).toEqual('allow'); + }); + test('"PermissionsSelectionGroupAccessMode.denyAll" propagates deny all actions on child resource', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + childResourceIds: ['the-guild/graphql-hive'], + }, + { + id: 'the-guild/graphql-hive', + level: ResourceLevel.project, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.mode = PermissionsSelectionGroupAccessMode.denyAll; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + + expect(result[0].permissions['project:describe']).toEqual(undefined); + expect(result[1].permissions['project:describe']).toEqual('deny'); + }); + + test('"PermissionsSelectionGroupAccessMode.denyAll" and "PermissionsSelectionGroupAccessMode.allowAll" in different groups on same resource result in deny', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.mode = PermissionsSelectionGroupAccessMode.allowAll; + + const group1 = createPermissionSelectionGroup({ + id: 'group1', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + group1.mode = PermissionsSelectionGroupAccessMode.denyAll; + + const groups = [defaultGroup, group1]; + + const result = resolvePermissionsFromPermissionSelectionGroup(groups, resources); + expect(result[0].permissions['members:describe']).toEqual('deny'); + }); + + test('"PermissionsSelectionGroupAccessMode.denyAll" does not work on read only fields', () => { + const resources: Array = [ + { + id: 'the-guild', + level: ResourceLevel.organization, + }, + ]; + + const defaultGroup = createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Default group', + resourceIds: ['the-guild'], + }); + + defaultGroup.mode = PermissionsSelectionGroupAccessMode.denyAll; + + const result = resolvePermissionsFromPermissionSelectionGroup([defaultGroup], resources); + expect(result[0].permissions['organization:describe']).toEqual('allow'); + }); +}); diff --git a/packages/web/app/src/components/shared/permission-picker.tsx b/packages/web/app/src/components/shared/permission-picker.tsx new file mode 100644 index 00000000000..c7b68d2e404 --- /dev/null +++ b/packages/web/app/src/components/shared/permission-picker.tsx @@ -0,0 +1,914 @@ +import { Dispatch, useEffect, useMemo, useRef, useState } from 'react'; +import { Check, CheckIcon, ChevronDown, ChevronRight, InfoIcon, XIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { + createPermissionSelectionGroup, + GrantedPermissions, + PermissionSelectionGroup, + PermissionsSelectionGroupAccessMode, + resolvePermissionsFromPermissionSelectionGroup, + Resource, + resourceLevelToScore, +} from './permission-picker/lib'; +import { + allPermissionGroups, + PermissionGroup, + ResourceLevel, +} from './permission-picker/permissions'; + +function PermissionSelector(props: { + resourceLevel: ResourceLevel; + grantedPermissions: GrantedPermissions; + updateGrantedPermissions: (group: GrantedPermissions) => void; +}) { + const grantedPermissions = useMemo(() => { + return { + ...props.grantedPermissions, + // A lot of the rules depends on 'project:describe' + // However, if we are on resource level target or service, project:describe needs to be specified so it can be selected. + ...(props.resourceLevel === ResourceLevel.target || + props.resourceLevel === ResourceLevel.service + ? { + 'project:describe': 'allow', + } + : {}), + }; + }, [props.resourceLevel, props.grantedPermissions]); + + const [filteredGroups, permissionGroupMapping] = useMemo(() => { + const filteredGroups: Array< + PermissionGroup & { + selectedPermissionCount: number; + } + > = []; + const permissionGroupMapping = new Map(); + + for (const group of allPermissionGroups) { + let selectedPermissionCount = 0; + + const filteredGroupPermissions = group.permissions.filter(permission => { + const shouldInclude = + resourceLevelToScore(props.resourceLevel) >= resourceLevelToScore(permission.level); + + if (shouldInclude === false) { + return false; + } + + if (props.grantedPermissions[permission.id] !== undefined) { + selectedPermissionCount++; + } + + permissionGroupMapping.set(permission.id, group.title); + + return true; + }); + + if (filteredGroupPermissions.length === 0) { + continue; + } + + filteredGroups.push({ + ...group, + selectedPermissionCount, + permissions: filteredGroupPermissions, + }); + } + + return [filteredGroups, permissionGroupMapping] as const; + }, [props.resourceLevel, props.grantedPermissions]); + + const permissionRefs = useRef(new Map()); + const [focusedPermission, setFocusedPermission] = useState(null as string | null); + const [openAccordions, setOpenAccordions] = useState([] as Array); + + return ( + setOpenAccordions(values)} + > + {filteredGroups.map(group => { + const dependencyGraph = new Map>(); + for (const permission of group.permissions) { + if (!permission.dependsOn) { + continue; + } + let arr = dependencyGraph.get(permission.dependsOn); + if (!arr) { + arr = []; + dependencyGraph.set(permission.dependsOn, arr); + } + arr.push(permission.id); + } + + return ( + + + {group.title}{' '} + {group.selectedPermissionCount > 0 && ( + + {group.selectedPermissionCount} selected + + )} + + + {group.permissions.map(permission => { + const needsDependency = + !!permission.dependsOn && grantedPermissions[permission.dependsOn] !== 'allow'; + + return ( +
{ + if (ref) { + permissionRefs.current.set(permission.id, ref); + } + }} + > +
+
{permission.title}
+
{permission.description}
+
+ {!!permission.dependsOn && permissionGroupMapping.has(permission.dependsOn) && ( +
+ + + + + + +

+ This permission depends on another permission.{' '} + +

+
+
+
+
+ )} + +
+ ); + })} +
+
+ ); + })} +
+ ); +} + +function GroupTeaser(props: { + title: string; + grantedPermissions: GrantedPermissions; + onClick: () => void; + mode: PermissionsSelectionGroupAccessMode; + resourceLevel: ResourceLevel.target | ResourceLevel.project | ResourceLevel.service | null; + selectedResourceIds: Array; +}) { + const assignedPermissionsCount = Array.from(Object.values(props.grantedPermissions)).reduce( + (current, next) => { + if (!next) { + return current; + } + return current + 1; + }, + 0, + ); + + return ( + + ); +} + +function ResourceBadge(props: { name: string; onDelete: () => void }) { + return ( + + {props.name} + + + ); +} + +function ResourceSelector(props: { + onSelect: (value: string) => void; + level: ResourceLevel; + // availableValues: Array; + // selectedResourceIds: Array; +}) { + // const filteredResourceIds = props.availableValues.filter( + // value => !props.selectedResourceIds.includes(value), + // ); + + const filteredResourceIds: Array = []; + + return ( + + ); +} + +type PermissionPickerProps = { + resources: Array; +}; + +type NavigationState = + | { + type: 'confirmation'; + } + | { + type: 'group'; + groupId: string; + }; + +export function PermissionPicker(props: PermissionPickerProps) { + // TODO: should be passed via props + const [navigationState, setNavigationState] = useState(() => { + try { + return JSON.parse(localStorage.getItem('hive:prototype:permissions')!).navigationState; + } catch (err) { + return null; + } + }); + + const [dynamicGroups, setDynamicGroups] = useState>(() => { + // TODO: should be passed via props + try { + return JSON.parse(localStorage.getItem('hive:prototype:permissions')!).dynamicGroups; + } catch (err) { + return [ + createPermissionSelectionGroup({ + id: 'default', + level: ResourceLevel.organization, + title: 'Global Organization Permissions', + resourceIds: props.resources + .filter(resource => resource.level === ResourceLevel.organization) + .map(resource => resource.id), + }), + ]; + } + }); + + useEffect(() => { + localStorage.setItem( + 'hive:prototype:permissions', + JSON.stringify({ + navigationState, + dynamicGroups, + }), + ); + }, [navigationState, dynamicGroups]); + + if (navigationState?.type === 'confirmation') { + return ( + setNavigationState(null)} + groups={dynamicGroups} + resources={props.resources} + /> + ); + } + + if (navigationState?.type === 'group') { + const selectedGroup = dynamicGroups.find(group => group.id === navigationState.groupId); + + if (selectedGroup) { + return ( + setNavigationState(null)} + group={selectedGroup} + updateGroup={fn => { + setDynamicGroups(groups => + groups.map(group => { + if (group.id === selectedGroup.id) { + return fn(group); + } + + return group; + }), + ); + }} + onDelete={() => { + setDynamicGroups(groups => groups.filter(group => group.id !== selectedGroup.id)); + }} + /> + ); + } + } + + return ( + setNavigationState({ type: 'group', groupId })} + navigateToConfirmation={() => setNavigationState({ type: 'confirmation' })} + addGroup={level => { + const id = window.crypto.randomUUID(); + setDynamicGroups(groups => [ + ...groups, + createPermissionSelectionGroup({ + id, + level, + title: 'Group ' + id, + }), + ]); + setNavigationState({ type: 'group', groupId: id }); + }} + groups={dynamicGroups} + /> + ); +} + +function PermissionOverview(props: { + navigateToGroup: (groupId: string) => void; + navigateToConfirmation: () => void; + addGroup: (level: ResourceLevel) => void; + groups: Array; +}) { + const form = useForm({ + defaultValues: {}, + }); + + return ( + + + Member Role Editor + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor + invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. + + + +
+
+
+ ( + + Name + + + + + + )} + /> + ( + + Description + +