diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 150406fa82..6e37e9eee9 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -aff0d7ace4d92612619289b3e75c597269282166 +eef65672cf65c5585f3c0340cd0f719009acdcf2 diff --git a/app/components/RoleBadgeCell.tsx b/app/components/RoleBadgeCell.tsx new file mode 100644 index 0000000000..f8a9a0ff17 --- /dev/null +++ b/app/components/RoleBadgeCell.tsx @@ -0,0 +1,24 @@ +import type { CellContext } from '@tanstack/react-table' + +import type { OrganizationRole, ProjectRole, SiloRole } from '@oxide/api' +import { Badge } from '@oxide/ui' + +type Role = SiloRole | OrganizationRole | ProjectRole + +/** + * Highlight the "effective" role in green, others gray. + * + * Example: User has collab on org and viewer on project. Collab supersedes + * because it is the "stronger" role, i.e., it strictly includes the perms on + * viewer. So collab is highlighted as the "effective" role. + */ +export const RoleBadgeCell = ( + info: CellContext +) => { + const cellRole = info.getValue() + if (!cellRole) return null + const effectiveRole = info.row.original.effectiveRole + return ( + {cellRole} + ) +} diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index 3883f2706c..eda9994d54 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -97,11 +97,9 @@ interface ImageSelectFieldProps extends Omit { export function ImageSelectField({ images, name, ...props }: ImageSelectFieldProps) { return ( - {Object.entries(groupBy(images, (i) => i.distribution)).map( - ([distroName, distroValues]) => ( - - ) - )} + {groupBy(images, (i) => i.distribution).map(([distroName, distroValues]) => ( + + ))} ) } diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx index 3a07efdb28..663b8d2f54 100644 --- a/app/pages/OrgAccessPage.tsx +++ b/app/pages/OrgAccessPage.tsx @@ -5,18 +5,17 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, - orgRoleOrder, + getEffectiveOrgRole, setUserRole, useApiMutation, useApiQueryClient, - useUserAccessRows, + useUserRows, } from '@oxide/api' -import type { OrganizationRole, UserAccessRow } from '@oxide/api' +import type { OrganizationRole, SiloRole } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Table, getActionsCol } from '@oxide/table' import { Access24Icon, - Badge, Button, EmptyMessage, PageHeader, @@ -24,7 +23,9 @@ import { TableActions, TableEmptyBox, } from '@oxide/ui' +import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access' import { requireOrgParams, useRequiredParams } from 'app/hooks' @@ -42,13 +43,21 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { await Promise.all([ + apiQueryClient.prefetchQuery('policyView', {}), apiQueryClient.prefetchQuery('organizationPolicyView', requireOrgParams(params)), - // used in useUserAccessRows to resolve user names + // used to resolve user names apiQueryClient.prefetchQuery('userList', { limit: 200 }), ]) } -type UserRow = UserAccessRow +type UserRow = { + id: string + name: string + siloRole: SiloRole | undefined + orgRole: OrganizationRole | undefined + // all these types are the same but this is strictly more correct than using one + effectiveRole: SiloRole | OrganizationRole +} const colHelper = createColumnHelper() @@ -56,9 +65,33 @@ export function OrgAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const orgParams = useRequiredParams('orgName') - const { data: policy } = useApiQuery('organizationPolicyView', orgParams) - const rows = useUserAccessRows(policy, orgRoleOrder) + const { data: siloPolicy } = useApiQuery('policyView', {}) + const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo') + + const { data: orgPolicy } = useApiQuery('organizationPolicyView', orgParams) + const orgRows = useUserRows(orgPolicy?.roleAssignments, 'org') + + const rows = useMemo(() => { + const users = groupBy(siloRows.concat(orgRows), (u) => u.id).map( + ([userId, userAssignments]) => { + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + const orgRole = userAssignments.find((a) => a.roleSource === 'org')?.roleName + + const roles = [siloRole, orgRole].filter(isTruthy) + + return { + id: userId, + name: userAssignments[0].name, + siloRole, + orgRole, + // we know there has to be at least one + effectiveRole: getEffectiveOrgRole(roles)!, + } + } + ) + return sortBy(users, (u) => u.name) + }, [siloRows, orgRows]) const queryClient = useApiQueryClient() const updatePolicy = useApiMutation('organizationPolicyUpdate', { @@ -73,14 +106,20 @@ export function OrgAccessPage() { () => [ colHelper.accessor('id', { header: 'ID' }), colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('roleName', { - header: 'Role', - cell: (info) => {info.getValue()}, + colHelper.accessor('siloRole', { + header: 'Silo role', + cell: RoleBadgeCell, + }), + colHelper.accessor('orgRole', { + header: 'Org role', + cell: RoleBadgeCell, }), + // TODO: tooltips on disabled elements explaining why getActionsCol((row: UserRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), + disabled: !row.orgRole, }, // TODO: only show if you have permission to do this { @@ -90,13 +129,14 @@ export function OrgAccessPage() { updatePolicy.mutate({ ...orgParams, // we know policy is there, otherwise there's no row to display - body: setUserRole(row.id, null, policy!), + body: setUserRole(row.id, null, orgPolicy!), }) }, + disabled: !row.orgRole, }, ]), ], - [policy, orgParams, updatePolicy] + [orgPolicy, orgParams, updatePolicy] ) const tableInstance = useReactTable({ @@ -116,22 +156,22 @@ export function OrgAccessPage() { Add user to organization - {policy && ( + {orgPolicy && ( setAddModalOpen(false)} onSuccess={() => setAddModalOpen(false)} - policy={policy} + policy={orgPolicy} /> )} - {policy && editingUserRow && ( + {orgPolicy && editingUserRow?.orgRole && ( setEditingUserRow(null)} onSuccess={() => setEditingUserRow(null)} - policy={policy} + policy={orgPolicy} userId={editingUserRow.id} - initialValues={{ roleName: editingUserRow.roleName }} + initialValues={{ roleName: editingUserRow.orgRole }} /> )} {rows.length === 0 ? ( diff --git a/app/pages/__tests__/org-access.e2e.ts b/app/pages/__tests__/org-access.e2e.ts index 582ab1739e..c92cb87d05 100644 --- a/app/pages/__tests__/org-access.e2e.ts +++ b/app/pages/__tests__/org-access.e2e.ts @@ -7,11 +7,22 @@ test('Click through org access page', async ({ page }) => { const table = page.locator('role=table') - // page is there, we see user 1 but not 2 + // page is there, we see user 1 and 2 but not 3 await page.click('role=link[name*="Access & IAM"]') await expectVisible(page, ['role=heading[name*="Access & IAM"]']) - await expectRowVisible(table, { ID: 'user-1', Name: 'Hannah Arendt', Role: 'admin' }) - await expectNotVisible(page, ['role=cell[name="user-2"]']) + await expectRowVisible(table, { + ID: 'user-1', + Name: 'Hannah Arendt', + 'Silo role': 'admin', + 'Org role': '', + }) + await expectRowVisible(table, { + ID: 'user-2', + Name: 'Hans Jonas', + 'Silo role': '', + 'Org role': 'viewer', + }) + await expectNotVisible(page, ['role=cell[name="user-3"]']) // Add user 2 as collab await page.click('role=button[name="Add user to organization"]') @@ -19,10 +30,14 @@ test('Click through org access page', async ({ page }) => { await page.click('role=button[name="User"]') // only users not already on the org should be visible - await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) - await expectVisible(page, ['role=option[name="Hans Jonas"]']) + await expectNotVisible(page, ['role=option[name="Hans Jonas"]']) + await expectVisible(page, [ + 'role=option[name="Hannah Arendt"]', + 'role=option[name="Jacob Klein"]', + 'role=option[name="Simone de Beauvoir"]', + ]) - await page.click('role=option[name="Hans Jonas"]') + await page.click('role=option[name="Jacob Klein"]') await page.click('role=button[name="Role"]') await expectVisible(page, [ @@ -34,12 +49,17 @@ test('Click through org access page', async ({ page }) => { await page.click('role=option[name="Collaborator"]') await page.click('role=button[name="Add user"]') - // User 2 shows up in the table - await expectRowVisible(table, { ID: 'user-2', Name: 'Hans Jonas', Role: 'collaborator' }) + // User 3 shows up in the table + await expectRowVisible(table, { + ID: 'user-3', + Name: 'Jacob Klein', + 'Silo role': '', + 'Org role': 'collaborator', + }) - // now change user 2's role from collab to viewer + // now change user 3's role from collab to viewer await page - .locator('role=row', { hasText: 'user-2' }) + .locator('role=row', { hasText: 'user-3' }) .locator('role=button[name="Row actions"]') .click() await page.click('role=menuitem[name="Change role"]') @@ -51,7 +71,7 @@ test('Click through org access page', async ({ page }) => { await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { ID: 'user-2', Role: 'viewer' }) + await expectRowVisible(table, { ID: 'user-3', 'Org role': 'viewer' }) // now delete user 2 await page @@ -61,4 +81,18 @@ test('Click through org access page', async ({ page }) => { await expectVisible(page, ['role=cell[name=user-2]']) await page.click('role=menuitem[name="Delete"]') await expectNotVisible(page, ['role=cell[name=user-2]']) + + // now add an org role to user 1, who currently only has silo role + await page.click('role=button[name="Add user to organization"]') + await page.click('role=button[name="User"]') + await page.click('role=option[name="Hannah Arendt"]') + await page.click('role=button[name="Role"]') + await page.click('role=option[name="Viewer"]') + await page.click('role=button[name="Add user"]') + await expectRowVisible(table, { + ID: 'user-1', + Name: 'Hannah Arendt', + 'Silo role': 'admin', + 'Org role': 'viewer', + }) }) diff --git a/app/pages/__tests__/project-access.e2e.ts b/app/pages/__tests__/project-access.e2e.ts index 177760d283..626700a899 100644 --- a/app/pages/__tests__/project-access.e2e.ts +++ b/app/pages/__tests__/project-access.e2e.ts @@ -6,22 +6,46 @@ test('Click through project access page', async ({ page }) => { await page.goto('/orgs/maze-war/projects/mock-project') await page.click('role=link[name*="Access & IAM"]') - // page is there, we see user 1 but not 2 + // page is there, we see user 1-3 but not 4 await expectVisible(page, ['role=heading[name*="Access & IAM"]']) const table = page.locator('table') - await expectRowVisible(table, { ID: 'user-1', Name: 'Hannah Arendt', Role: 'admin' }) - await expectNotVisible(page, ['role=cell[name="user-2"]']) + await expectRowVisible(table, { + ID: 'user-1', + Name: 'Hannah Arendt', + 'Silo role': 'admin', + 'Org role': '', + 'Project role': '', + }) + await expectRowVisible(table, { + ID: 'user-2', + Name: 'Hans Jonas', + 'Silo role': '', + 'Org role': 'viewer', + 'Project role': '', + }) + await expectRowVisible(table, { + ID: 'user-3', + Name: 'Jacob Klein', + 'Silo role': '', + 'Org role': '', + 'Project role': 'collaborator', + }) + await expectNotVisible(page, ['role=cell[name="user-4"]']) - // Add user 2 as collab + // Add user 4 as collab await page.click('role=button[name="Add user to project"]') await expectVisible(page, ['role=heading[name*="Add user to project"]']) await page.click('role=button[name="User"]') // only users not already on the project should be visible - await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) - await expectVisible(page, ['role=option[name="Hans Jonas"]']) + await expectNotVisible(page, ['role=option[name="Jacob Klein"]']) + await expectVisible(page, [ + 'role=option[name="Hannah Arendt"]', + 'role=option[name="Hans Jonas"]', + 'role=option[name="Simone de Beauvoir"]', + ]) - await page.click('role=option[name="Hans Jonas"]') + await page.click('role=option[name="Simone de Beauvoir"]') await page.click('role=button[name="Role"]') await expectVisible(page, [ @@ -33,12 +57,16 @@ test('Click through project access page', async ({ page }) => { await page.click('role=option[name="Collaborator"]') await page.click('role=button[name="Add user"]') - // User 2 shows up in the table - await expectRowVisible(table, { ID: 'user-2', Name: 'Hans Jonas', Role: 'collaborator' }) + // User 4 shows up in the table + await expectRowVisible(table, { + ID: 'user-4', + Name: 'Simone de Beauvoir', + 'Project role': 'collaborator', + }) - // now change user 2 role from collab to viewer + // now change user 4 role from collab to viewer await page - .locator('role=row', { hasText: 'user-2' }) + .locator('role=row', { hasText: 'user-4' }) .locator('role=button[name="Row actions"]') .click() await page.click('role=menuitem[name="Change role"]') @@ -50,14 +78,30 @@ test('Click through project access page', async ({ page }) => { await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { ID: 'user-2', Role: 'viewer' }) + await expectRowVisible(table, { ID: 'user-4', 'Project role': 'viewer' }) - // now delete user 2 + // now delete user 3. has to be 3 or 4 because they're the only ones that come + // from the project policy await page - .locator('role=row', { hasText: 'user-2' }) + .locator('role=row', { hasText: 'user-3' }) .locator('role=button[name="Row actions"]') .click() - await expectVisible(page, ['role=cell[name=user-2]']) + await expectVisible(page, ['role=cell[name=user-3]']) await page.click('role=menuitem[name="Delete"]') - await expectNotVisible(page, ['role=cell[name=user-2]']) + await expectNotVisible(page, ['role=cell[name=user-3]']) + + // now add a project role to user 1, who currently only has silo role + await page.click('role=button[name="Add user to project"]') + await page.click('role=button[name="User"]') + await page.click('role=option[name="Hannah Arendt"]') + await page.click('role=button[name="Role"]') + await page.click('role=option[name="Viewer"]') + await page.click('role=button[name="Add user"]') + await expectRowVisible(table, { + ID: 'user-1', + Name: 'Hannah Arendt', + 'Silo role': 'admin', + 'Org role': '', + 'Project role': 'viewer', + }) }) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 0ad95afe08..cd641151c3 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,18 +5,17 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, - projectRoleOrder, + getEffectiveProjectRole, setUserRole, useApiMutation, useApiQueryClient, - useUserAccessRows, + useUserRows, } from '@oxide/api' -import type { ProjectRole, UserAccessRow } from '@oxide/api' +import type { OrganizationRole, ProjectRole, SiloRole } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Table, getActionsCol } from '@oxide/table' import { Access24Icon, - Badge, Button, EmptyMessage, PageHeader, @@ -24,7 +23,9 @@ import { TableActions, TableEmptyBox, } from '@oxide/ui' +import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, @@ -44,14 +45,25 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { + const { orgName, projectName } = requireProjectParams(params) await Promise.all([ - apiQueryClient.prefetchQuery('projectPolicyView', requireProjectParams(params)), - // used in useUserAccessRows to resolve user names + apiQueryClient.prefetchQuery('policyView', {}), + apiQueryClient.prefetchQuery('organizationPolicyView', { orgName }), + apiQueryClient.prefetchQuery('projectPolicyView', { orgName, projectName }), + // used to resolve user names apiQueryClient.prefetchQuery('userList', { limit: 200 }), ]) } -type UserRow = UserAccessRow +type UserRow = { + id: string + name: string + siloRole: SiloRole | undefined + orgRole: OrganizationRole | undefined + projectRole: ProjectRole | undefined + // all these types are the same but this is strictly more correct than using one + effectiveRole: SiloRole | OrganizationRole | ProjectRole +} const colHelper = createColumnHelper() @@ -59,9 +71,41 @@ export function ProjectAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) const projectParams = useRequiredParams('orgName', 'projectName') - const { data: policy } = useApiQuery('projectPolicyView', projectParams) + const { orgName } = projectParams + + const { data: siloPolicy } = useApiQuery('policyView', {}) + const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo') + + const { data: orgPolicy } = useApiQuery('organizationPolicyView', { orgName }) + const orgRows = useUserRows(orgPolicy?.roleAssignments, 'org') + + const { data: projectPolicy } = useApiQuery('projectPolicyView', projectParams) + const projectRows = useUserRows(projectPolicy?.roleAssignments, 'project') - const rows = useUserAccessRows(policy, projectRoleOrder) + const rows = useMemo(() => { + const users = groupBy(siloRows.concat(orgRows, projectRows), (u) => u.id).map( + ([userId, userAssignments]) => { + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + const orgRole = userAssignments.find((a) => a.roleSource === 'org')?.roleName + const projectRole = userAssignments.find( + (a) => a.roleSource === 'project' + )?.roleName + + const roles = [siloRole, orgRole, projectRole].filter(isTruthy) + + return { + id: userId, + name: userAssignments[0].name, + siloRole, + orgRole, + projectRole, + // we know there has to be at least one + effectiveRole: getEffectiveProjectRole(roles)!, + } + } + ) + return sortBy(users, (u) => u.name) + }, [siloRows, orgRows, projectRows]) const queryClient = useApiQueryClient() const updatePolicy = useApiMutation('projectPolicyUpdate', { @@ -76,14 +120,24 @@ export function ProjectAccessPage() { () => [ colHelper.accessor('id', { header: 'ID' }), colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('roleName', { - header: 'Role', - cell: (info) => {info.getValue()}, + colHelper.accessor('siloRole', { + header: 'Silo role', + cell: RoleBadgeCell, + }), + colHelper.accessor('orgRole', { + header: 'Org role', + cell: RoleBadgeCell, + }), + colHelper.accessor('projectRole', { + header: 'Project role', + cell: RoleBadgeCell, }), + // TODO: tooltips on disabled elements explaining why getActionsCol((row: UserRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), + disabled: !row.projectRole, }, // TODO: only show if you have permission to do this { @@ -93,13 +147,14 @@ export function ProjectAccessPage() { updatePolicy.mutate({ ...projectParams, // we know policy is there, otherwise there's no row to display - body: setUserRole(row.id, null, policy!), + body: setUserRole(row.id, null, projectPolicy!), }) }, + disabled: !row.projectRole, }, ]), ], - [policy, projectParams, updatePolicy] + [projectPolicy, projectParams, updatePolicy] ) const tableInstance = useReactTable({ @@ -119,20 +174,20 @@ export function ProjectAccessPage() { Add user to project - {policy && ( + {projectPolicy && ( setAddModalOpen(false)} - policy={policy} + policy={projectPolicy} /> )} - {policy && editingUserRow && ( + {projectPolicy && editingUserRow?.projectRole && ( setEditingUserRow(null)} - policy={policy} + policy={projectPolicy} userId={editingUserRow.id} - initialValues={{ roleName: editingUserRow.roleName }} + initialValues={{ roleName: editingUserRow.projectRole }} /> )} {rows.length === 0 ? ( diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index f52ca0b509..9d97ad7438 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -6,6 +6,7 @@ import { pick, sortBy } from '@oxide/util' import type { Json } from '../json-type' import { serial } from '../serial' import { sessionMe } from '../session' +import { defaultSilo } from '../silo' import type { DiskParams, GlobalImageParams, @@ -20,16 +21,16 @@ import type { VpcRouterParams, VpcSubnetParams, } from './db' -import { lookupById } from './db' -import { lookupSshKey } from './db' -import { lookupDisk } from './db' -import { lookupGlobalImage } from './db' import { db, + lookupById, + lookupDisk, + lookupGlobalImage, lookupInstance, lookupNetworkInterface, lookupOrg, lookupProject, + lookupSshKey, lookupVpc, lookupVpcRouter, lookupVpcSubnet, @@ -129,6 +130,16 @@ export const handlers = [ } ), + rest.get | GetErr>('/api/policy', (req, res) => { + // assume we're in the default silo + const siloId = defaultSilo.id + const role_assignments = db.roleAssignments + .filter((r) => r.resource_type === 'silo' && r.resource_id === siloId) + .map((r) => pick(r, 'identity_id', 'identity_type', 'role_name')) + + return res(json({ role_assignments })) + }), + rest.get>( '/api/organizations', (req, res) => res(json(paginated(req.url.search, db.orgs))) @@ -184,7 +195,7 @@ export const handlers = [ } ), - rest.get | GetErr>( + rest.get | GetErr>( '/api/organizations/:orgName/policy', (req, res) => { const [org, err] = lookupOrg(req.params) diff --git a/libs/api-mocks/role-assignment.ts b/libs/api-mocks/role-assignment.ts index 17d12da7ac..744d832543 100644 --- a/libs/api-mocks/role-assignment.ts +++ b/libs/api-mocks/role-assignment.ts @@ -1,12 +1,14 @@ import type { OrganizationRoleRoleAssignment, ProjectRoleRoleAssignment, + SiloRoleRoleAssignment, } from 'libs/api/__generated__/Api' import type { Json } from './json-type' import { org } from './org' import { project } from './project' -import { user1 } from './user' +import { defaultSilo } from './silo' +import { user1, user2, user3 } from './user' // For most other resources, we can store the API types directly in the DB. But // in this case the API response doesn't have the resource ID on it, and we need @@ -17,21 +19,29 @@ import { user1 } from './user' type DbRoleAssignment = { resource_id: string } & ( | ({ resource_type: 'project' } & Json) | ({ resource_type: 'organization' } & Json) + | ({ resource_type: 'silo' } & Json) ) export const roleAssignments: DbRoleAssignment[] = [ { - resource_type: 'organization', - resource_id: org.id, + resource_type: 'silo', + resource_id: defaultSilo.id, identity_id: user1.id, identity_type: 'silo_user', role_name: 'admin', }, + { + resource_type: 'organization', + resource_id: org.id, + identity_id: user2.id, + identity_type: 'silo_user', + role_name: 'viewer', + }, { resource_type: 'project', resource_id: project.id, - identity_id: user1.id, + identity_id: user3.id, identity_type: 'silo_user', - role_name: 'admin', + role_name: 'collaborator', }, ] diff --git a/libs/api-mocks/silo.ts b/libs/api-mocks/silo.ts new file mode 100644 index 0000000000..563aba997c --- /dev/null +++ b/libs/api-mocks/silo.ts @@ -0,0 +1,13 @@ +import type { Silo } from '@oxide/api' + +import type { Json } from './json-type' + +export const defaultSilo: Json = { + id: 'default-silo-uuid', + name: 'default-silo', + description: 'a fake default silo', + time_created: new Date(2021, 3, 1).toISOString(), + time_modified: new Date(2021, 4, 2).toISOString(), + discoverable: true, + user_provision_type: 'jit', +} diff --git a/libs/api-mocks/user.ts b/libs/api-mocks/user.ts index c77665d0d4..e657e31111 100644 --- a/libs/api-mocks/user.ts +++ b/libs/api-mocks/user.ts @@ -12,4 +12,14 @@ export const user2: Json = { display_name: 'Hans Jonas', } -export const users = [user1, user2] +export const user3: Json = { + id: 'user-3', + display_name: 'Jacob Klein', +} + +export const user4: Json = { + id: 'user-4', + display_name: 'Simone de Beauvoir', +} + +export const users = [user1, user2, user3, user4] diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index acb1da5b6b..0bab28e3d7 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -2293,6 +2293,10 @@ export interface DeviceAuthConfirmParams {} export interface DeviceAccessTokenParams {} +export interface GlobalPolicyViewParams {} + +export interface GlobalPolicyUpdateParams {} + export interface RackListParams { limit?: number | null pageToken?: string | null @@ -3321,6 +3325,31 @@ export class Api extends HttpClient { ...params, }), + /** + * Fetch the top-level IAM policy + */ + globalPolicyView: (query: GlobalPolicyViewParams, params: RequestParams = {}) => + this.request({ + path: `/global/policy`, + method: 'GET', + ...params, + }), + + /** + * Update the top-level IAM policy + */ + globalPolicyUpdate: ( + query: GlobalPolicyUpdateParams, + body: FleetRolePolicy, + params: RequestParams = {} + ) => + this.request({ + path: `/global/policy`, + method: 'PUT', + body, + ...params, + }), + /** * List racks */ @@ -4573,24 +4602,24 @@ export class Api extends HttpClient { }), /** - * Fetch the top-level IAM policy + * Fetch the current silo's IAM policy */ policyView: (query: PolicyViewParams, params: RequestParams = {}) => - this.request({ + this.request({ path: `/policy`, method: 'GET', ...params, }), /** - * Update the top-level IAM policy + * Update the current silo's IAM policy */ policyUpdate: ( query: PolicyUpdateParams, - body: FleetRolePolicy, + body: SiloRolePolicy, params: RequestParams = {} ) => - this.request({ + this.request({ path: `/policy`, method: 'PUT', body, diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 3b496e9d8c..c7338b7668 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -aff0d7ace4d92612619289b3e75c597269282166 +eef65672cf65c5585f3c0340cd0f719009acdcf2 diff --git a/libs/api/roles.spec.ts b/libs/api/roles.spec.ts index 94a7bf1609..36d691e4e9 100644 --- a/libs/api/roles.spec.ts +++ b/libs/api/roles.spec.ts @@ -1,41 +1,31 @@ import type { ProjectRolePolicy } from './__generated__/Api' import { - getMainRole, - getOrgRole, - getProjectRole, + getEffectiveOrgRole, + getEffectiveProjectRole, orgRoleOrder, projectRoleOrder, setUserRole, } from './roles' -// not strictly necessary since we're testing the other functions, but it's nice -// to show how it works -describe('getMainRole', () => { - it('uses role order to choose which role to return', () => { - expect(getMainRole({ x: 0, y: 3 })(['x', 'y'])).toEqual('x') - expect(getMainRole({ x: 4, y: 1 })(['x', 'y'])).toEqual('y') - }) -}) - -describe('getMainProjectRole', () => { - it('returns null when the list of role assignments is empty', () => { - expect(getProjectRole([])).toBeNull() - expect(getOrgRole([])).toBeNull() +describe('getEffectiveProjectRole and getEffectiveOrgRole', () => { + it('returns falsy when the list of role assignments is empty', () => { + expect(getEffectiveProjectRole([])).toBeFalsy() + expect(getEffectiveOrgRole([])).toBeFalsy() }) it('returns the strongest role when there are multiple roles, regardless of policy order', () => { - expect(getProjectRole(['admin', 'collaborator'])).toEqual('admin') - expect(getProjectRole(['collaborator', 'admin'])).toEqual('admin') + expect(getEffectiveProjectRole(['admin', 'collaborator'])).toEqual('admin') + expect(getEffectiveProjectRole(['collaborator', 'admin'])).toEqual('admin') - expect(getOrgRole(['collaborator', 'viewer'])).toEqual('collaborator') - expect(getOrgRole(['viewer', 'collaborator'])).toEqual('collaborator') + expect(getEffectiveOrgRole(['collaborator', 'viewer'])).toEqual('collaborator') + expect(getEffectiveOrgRole(['viewer', 'collaborator'])).toEqual('collaborator') }) it("type errors when passed a role that's not in the enum", () => { // @ts-expect-error - getProjectRole(['fake!']) + getEffectiveProjectRole(['fake!']) // @ts-expect-error - getOrgRole(['fake!']) + getEffectiveOrgRole(['fake!']) }) }) diff --git a/libs/api/roles.ts b/libs/api/roles.ts index c605a82ac0..4a08b68734 100644 --- a/libs/api/roles.ts +++ b/libs/api/roles.ts @@ -5,20 +5,15 @@ */ import { useMemo } from 'react' -import { groupBy, sortBy } from '@oxide/util' +import { sortBy } from '@oxide/util' import { useApiQuery } from '.' import type { IdentityType, OrganizationRole, ProjectRole } from './__generated__/Api' -/** Given a role order and a list of roles, get the one that sorts earliest */ -export const getMainRole = - (roleOrder: Record) => - (userRoles: Role[]): Role | null => - userRoles.length > 0 ? sortBy(userRoles, (r) => roleOrder[r])[0] : null - /** Turn a role order record into a sorted array of strings. */ +// used for displaying lists of roles, like in a