diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 159be45294..82ba529f67 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -91a1b9dc7c5e4fbf047b4c548f2c19f5f9f8f4b4 +698ccddd1af6bbae07e854fe290984a08d369e54 diff --git a/app/components/AccessNameCell.tsx b/app/components/AccessNameCell.tsx new file mode 100644 index 0000000000..2509f882d5 --- /dev/null +++ b/app/components/AccessNameCell.tsx @@ -0,0 +1,26 @@ +import type { CellContext } from '@tanstack/react-table' + +import type { IdentityType } from '@oxide/api' +import { Badge } from '@oxide/ui' + +/** + * Display the user or group name. If the row is for a group, add a GROUP badge. + */ +export const AccessNameCell = < + RowData extends { name: string; identityType: IdentityType } +>( + info: CellContext +) => { + const name = info.getValue() + const identityType = info.row.original.identityType + return ( + <> + {name} + {identityType === 'silo_group' ? ( + + Group + + ) : null} + + ) +} diff --git a/app/components/RoleBadgeCell.tsx b/app/components/RoleBadgeCell.tsx index f8a9a0ff17..4b1dd47088 100644 --- a/app/components/RoleBadgeCell.tsx +++ b/app/components/RoleBadgeCell.tsx @@ -12,8 +12,8 @@ type Role = SiloRole | OrganizationRole | ProjectRole * 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 +export const RoleBadgeCell = ( + info: CellContext ) => { const cellRole = info.getValue() if (!cellRole) return null diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx index 20ceb07d0e..81a90aed5d 100644 --- a/app/pages/OrgAccessPage.tsx +++ b/app/pages/OrgAccessPage.tsx @@ -11,7 +11,7 @@ import { useApiQueryClient, useUserRows, } from '@oxide/api' -import type { OrganizationRole, SiloRole } from '@oxide/api' +import type { IdentityType, OrganizationRole, SiloRole } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Table, getActionsCol } from '@oxide/table' import { @@ -25,6 +25,7 @@ import { } from '@oxide/ui' import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { AccessNameCell } from 'app/components/AccessNameCell' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access' import { requireOrgParams, useRequiredParams } from 'app/hooks' @@ -52,6 +53,7 @@ OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { type UserRow = { id: string + identityType: IdentityType name: string siloRole: SiloRole | undefined orgRole: OrganizationRole | undefined @@ -80,9 +82,12 @@ export function OrgAccessPage() { const roles = [siloRole, orgRole].filter(isTruthy) + const { name, identityType } = userAssignments[0] + const row: UserRow = { id: userId, - name: userAssignments[0].name, + identityType, + name, siloRole, orgRole, // we know there has to be at least one @@ -107,7 +112,7 @@ export function OrgAccessPage() { const columns = useMemo( () => [ colHelper.accessor('id', { header: 'ID' }), - colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }), colHelper.accessor('siloRole', { header: 'Silo role', cell: RoleBadgeCell, diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index c738213478..040d76caca 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -11,7 +11,7 @@ import { useApiQueryClient, useUserRows, } from '@oxide/api' -import type { OrganizationRole, ProjectRole, SiloRole } from '@oxide/api' +import type { IdentityType, OrganizationRole, ProjectRole, SiloRole } from '@oxide/api' import { useApiQuery } from '@oxide/api' import { Table, getActionsCol } from '@oxide/table' import { @@ -25,6 +25,7 @@ import { } from '@oxide/ui' import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { AccessNameCell } from 'app/components/AccessNameCell' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' import { ProjectAccessAddUserSideModal, @@ -57,6 +58,7 @@ ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { type UserRow = { id: string + identityType: IdentityType name: string siloRole: SiloRole | undefined orgRole: OrganizationRole | undefined @@ -93,9 +95,12 @@ export function ProjectAccessPage() { const roles = [siloRole, orgRole, projectRole].filter(isTruthy) + const { name, identityType } = userAssignments[0] + const row: UserRow = { id: userId, - name: userAssignments[0].name, + identityType, + name, siloRole, orgRole, projectRole, @@ -121,7 +126,7 @@ export function ProjectAccessPage() { const columns = useMemo( () => [ colHelper.accessor('id', { header: 'ID' }), - colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }), colHelper.accessor('siloRole', { header: 'Silo role', cell: RoleBadgeCell, diff --git a/libs/api-mocks/index.ts b/libs/api-mocks/index.ts index 10f6e77b21..3f05ab4ae7 100644 --- a/libs/api-mocks/index.ts +++ b/libs/api-mocks/index.ts @@ -11,6 +11,7 @@ export * from './session' export * from './snapshot' export * from './sshKeys' export * from './user' +export * from './user-group' export * from './vpc' export { handlers } from './msw/handlers' diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 7607446b93..d2f5302bba 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -153,6 +153,7 @@ export function lookupSshKey(params: PP.SshKey): Result> { const initDb = { disks: [...mock.disks], globalImages: [...mock.globalImages], + userGroups: [...mock.userGroups], images: [...mock.images], instances: [mock.instance], networkInterfaces: [mock.networkInterface], diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 49e3928ce8..a807ce4f3b 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1139,13 +1139,14 @@ export const handlers = [ } ), - // note that in the API this is meant for system users, but that could change. - // kind of a hack to pretend it's about normal users. - // see https://github.com/oxidecomputer/omicron/issues/1235 rest.get | GetErr>('/users', (req, res) => { return res(json(paginated(req.url.search, db.users))) }), + rest.get | GetErr>('/groups', (req, res) => { + return res(json(paginated(req.url.search, db.userGroups))) + }), + rest.post, never, PostErr>( '/device/confirm', async (req, res, ctx) => { diff --git a/libs/api-mocks/role-assignment.ts b/libs/api-mocks/role-assignment.ts index 744d832543..7f008648d1 100644 --- a/libs/api-mocks/role-assignment.ts +++ b/libs/api-mocks/role-assignment.ts @@ -9,6 +9,7 @@ import { org } from './org' import { project } from './project' import { defaultSilo } from './silo' import { user1, user2, user3 } from './user' +import { userGroup1, userGroup2 } from './user-group' // 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 @@ -37,6 +38,13 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'viewer', }, + { + resource_type: 'organization', + resource_id: org.id, + identity_id: userGroup1.id, + identity_type: 'silo_group', + role_name: 'collaborator', + }, { resource_type: 'project', resource_id: project.id, @@ -44,4 +52,11 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'collaborator', }, + { + resource_type: 'project', + resource_id: project.id, + identity_id: userGroup2.id, + identity_type: 'silo_group', + role_name: 'viewer', + }, ] diff --git a/libs/api-mocks/session.ts b/libs/api-mocks/session.ts index bfd55ce766..f74941e49e 100644 --- a/libs/api-mocks/session.ts +++ b/libs/api-mocks/session.ts @@ -1,6 +1,9 @@ import type { User } from '@oxide/api' +import { defaultSilo } from './silo' + export const sessionMe: User = { id: '001de000-05e4-4000-8000-000000060001', + siloId: defaultSilo.id, displayName: 'Grace Hopper', } diff --git a/libs/api-mocks/user-group.ts b/libs/api-mocks/user-group.ts new file mode 100644 index 0000000000..ef6c0be965 --- /dev/null +++ b/libs/api-mocks/user-group.ts @@ -0,0 +1,18 @@ +import type { Group } from '@oxide/api' + +import type { Json } from './json-type' +import { defaultSilo } from './silo' + +export const userGroup1: Json = { + id: 'user-group-1', + silo_id: defaultSilo.id, + display_name: 'web-devs', +} + +export const userGroup2: Json = { + id: 'user-group-2', + silo_id: defaultSilo.id, + display_name: 'kernel-devs', +} + +export const userGroups = [userGroup1, userGroup2] diff --git a/libs/api-mocks/user.ts b/libs/api-mocks/user.ts index e657e31111..b2a6f267c5 100644 --- a/libs/api-mocks/user.ts +++ b/libs/api-mocks/user.ts @@ -1,25 +1,30 @@ import type { User } from '@oxide/api' import type { Json } from './json-type' +import { defaultSilo } from './silo' export const user1: Json = { id: 'user-1', display_name: 'Hannah Arendt', + silo_id: defaultSilo.id, } export const user2: Json = { id: 'user-2', display_name: 'Hans Jonas', + silo_id: defaultSilo.id, } export const user3: Json = { id: 'user-3', display_name: 'Jacob Klein', + silo_id: defaultSilo.id, } export const user4: Json = { id: 'user-4', display_name: 'Simone de Beauvoir', + silo_id: defaultSilo.id, } export const users = [user1, user2, user3, user4] diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index e4ffa5b178..7636c2089b 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -401,6 +401,27 @@ export type GlobalImageResultsPage = { nextPage?: string | null } +/** + * Client view of a {@link Group} + */ +export type Group = { + /** Human-readable name that can identify the group */ + displayName: string + id: string + /** Uuid of the silo to which this group belongs */ + siloId: string +} + +/** + * A single page of results + */ +export type GroupResultsPage = { + /** list of items on this page of results */ + items: Group[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + export type IdentityProviderType = 'saml' /** @@ -1380,6 +1401,8 @@ export type User = { /** Human-readable name that can identify the user */ displayName: string id: string + /** Uuid of the silo to which this user belongs */ + siloId: string } /** @@ -1683,6 +1706,13 @@ export type VpcUpdate = { name?: Name | null } +/** + * Supported set of sort modes for scanning by id only. + * + * Currently, we only support scanning in ascending order. + */ +export type IdSortMode = 'id_ascending' + /** * Supported set of sort modes for scanning by name or id */ @@ -1709,13 +1739,6 @@ export type DiskMetricName = | 'write' | 'write_bytes' -/** - * Supported set of sort modes for scanning by id only. - * - * Currently, we only support scanning in ascending order. - */ -export type IdSortMode = 'id_ascending' - export type SystemMetricName = | 'virtual_disk_space_provisioned' | 'cpus_provisioned' @@ -1771,6 +1794,12 @@ export interface DeviceAuthConfirmParams {} export interface DeviceAccessTokenParams {} +export interface GroupListParams { + limit?: number + pageToken?: string | null + sortBy?: IdSortMode +} + export interface LoginSpoofParams {} export interface LoginSamlBeginParams { @@ -2459,6 +2488,7 @@ export type ApiViewByIdMethods = Pick< export type ApiListMethods = Pick< InstanceType['methods'], + | 'groupList' | 'organizationList' | 'projectList' | 'diskList' @@ -2644,6 +2674,17 @@ export class Api extends HttpClient { ...params, }), + /** + * List groups + */ + groupList: (query: GroupListParams, params: RequestParams = {}) => + this.request({ + path: `/groups`, + method: 'GET', + query, + ...params, + }), + loginSpoof: ( query: LoginSpoofParams, body: SpoofLoginBody, diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 0b73600064..90656fc895 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 -91a1b9dc7c5e4fbf047b4c548f2c19f5f9f8f4b4 +698ccddd1af6bbae07e854fe290984a08d369e54 diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index 8752ce83fb..b962abd2cc 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -375,6 +375,23 @@ export const GlobalImageResultsPage = z.object({ nextPage: z.string().nullable().optional(), }) +/** + * Client view of a {@link Group} + */ +export const Group = z.object({ + displayName: z.string(), + id: z.string().uuid(), + siloId: z.string().uuid(), +}) + +/** + * A single page of results + */ +export const GroupResultsPage = z.object({ + items: Group.array(), + nextPage: z.string().nullable().optional(), +}) + export const IdentityProviderType = z.enum(['saml']) /** @@ -1174,7 +1191,11 @@ export const TimeseriesSchemaResultsPage = z.object({ /** * Client view of a {@link User} */ -export const User = z.object({ displayName: z.string(), id: z.string().uuid() }) +export const User = z.object({ + displayName: z.string(), + id: z.string().uuid(), + siloId: z.string().uuid(), +}) /** * Client view of a {@link UserBuiltin} @@ -1407,6 +1428,13 @@ export const VpcUpdate = z.object({ name: Name.nullable().optional(), }) +/** + * Supported set of sort modes for scanning by id only. + * + * Currently, we only support scanning in ascending order. + */ +export const IdSortMode = z.enum(['id_ascending']) + /** * Supported set of sort modes for scanning by name or id */ @@ -1432,13 +1460,6 @@ export const DiskMetricName = z.enum([ 'write_bytes', ]) -/** - * Supported set of sort modes for scanning by id only. - * - * Currently, we only support scanning in ascending order. - */ -export const IdSortMode = z.enum(['id_ascending']) - export const SystemMetricName = z.enum([ 'virtual_disk_space_provisioned', 'cpus_provisioned', @@ -1511,6 +1532,13 @@ export type DeviceAuthConfirmParams = z.infer export const DeviceAccessTokenParams = z.object({}) export type DeviceAccessTokenParams = z.infer +export const GroupListParams = z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: IdSortMode.optional(), +}) +export type GroupListParams = z.infer + export const LoginSpoofParams = z.object({}) export type LoginSpoofParams = z.infer diff --git a/libs/api/roles.ts b/libs/api/roles.ts index 9ef084231d..1a13587339 100644 --- a/libs/api/roles.ts +++ b/libs/api/roles.ts @@ -92,6 +92,7 @@ export function setUserRole( type UserAccessRow = { id: string + identityType: IdentityType name: string roleName: Role roleSource: string @@ -111,15 +112,19 @@ export function useUserRows( // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows const { data: users } = useApiQuery('userList', {}) + const { data: groups } = useApiQuery('groupList', {}) return useMemo(() => { - const usersDict = Object.fromEntries((users?.items || []).map((u) => [u.id, u])) + const userItems = users?.items || [] + const groupItems = groups?.items || [] + const usersDict = Object.fromEntries(userItems.concat(groupItems).map((u) => [u.id, u])) return (roleAssignments || []).map((ra) => ({ id: ra.identityId, + identityType: ra.identityType, name: usersDict[ra.identityId]?.displayName || '', // placeholder until we get names, obviously roleName: ra.roleName, roleSource, })) - }, [roleAssignments, roleSource, users]) + }, [roleAssignments, roleSource, users, groups]) } /**