diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f513caac83..1ada9c7760 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -bce0f5ddf7f9f3be498fa9afb1fee8d3336c1f30 +2ea34d0369fd26bc4f438ea0b3c8ab1b50314048 diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx index c3b62a01fc..5957ec503c 100644 --- a/app/pages/OrgAccessPage.tsx +++ b/app/pages/OrgAccessPage.tsx @@ -5,6 +5,7 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + byGroupThenName, getEffectiveRole, setUserRole, useApiMutation, @@ -23,7 +24,7 @@ import { TableActions, TableEmptyBox, } from '@oxide/ui' -import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { groupBy, isTruthy } from '@oxide/util' import { AccessNameCell } from 'app/components/AccessNameCell' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' @@ -50,6 +51,7 @@ OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { }), // used to resolve user names apiQueryClient.prefetchQuery('userList', {}), + apiQueryClient.prefetchQuery('groupList', {}), ]) } @@ -77,8 +79,8 @@ export function OrgAccessPage() { const orgRows = useUserRows(orgPolicy?.roleAssignments, 'org') const rows = useMemo(() => { - const users = groupBy(siloRows.concat(orgRows), (u) => u.id).map( - ([userId, userAssignments]) => { + return 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 @@ -97,9 +99,8 @@ export function OrgAccessPage() { } return row - } - ) - return sortBy(users, (u) => u.name) + }) + .sort(byGroupThenName) }, [siloRows, orgRows]) const queryClient = useApiQueryClient() diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 72f9993a49..2733d1f3bb 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from 'react' import { apiQueryClient, + byGroupThenName, getEffectiveRole, setUserRole, useApiMutation, @@ -22,7 +23,7 @@ import { TableActions, TableEmptyBox, } from '@oxide/ui' -import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { groupBy, isTruthy } from '@oxide/util' import { AccessNameCell } from 'app/components/AccessNameCell' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' @@ -48,6 +49,7 @@ SiloAccessPage.loader = async () => { apiQueryClient.prefetchQuery('policyView', {}), // used to resolve user names apiQueryClient.prefetchQuery('userList', {}), + apiQueryClient.prefetchQuery('groupList', {}), ]) } @@ -69,25 +71,26 @@ export function SiloAccessPage() { const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo') const rows = useMemo(() => { - const users = groupBy(siloRows, (u) => u.id).map(([userId, userAssignments]) => { - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - - const roles = [siloRole].filter(isTruthy) - - const { name, identityType } = userAssignments[0] - - const row: UserRow = { - id: userId, - identityType, - name, - siloRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, - } - - return row - }) - return sortBy(users, (u) => u.name) + return groupBy(siloRows, (u) => u.id) + .map(([userId, userAssignments]) => { + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + + const roles = [siloRole].filter(isTruthy) + + const { name, identityType } = userAssignments[0] + + const row: UserRow = { + id: userId, + identityType, + name, + siloRole, + // we know there has to be at least one + effectiveRole: getEffectiveRole(roles)!, + } + + return row + }) + .sort(byGroupThenName) }, [siloRows]) const queryClient = useApiQueryClient() diff --git a/app/pages/__tests__/org-access.e2e.ts b/app/pages/__tests__/org-access.e2e.ts index 581be17df0..592bc14b06 100644 --- a/app/pages/__tests__/org-access.e2e.ts +++ b/app/pages/__tests__/org-access.e2e.ts @@ -1,8 +1,14 @@ import { test } from '@playwright/test' -import { user1, user2, user3 } from '@oxide/api-mocks' +import { user1, user2, user3, userGroup1 } from '@oxide/api-mocks' +import { userGroups } from '@oxide/api-mocks' -import { expectNotVisible, expectRowVisible, expectVisible } from 'app/test/e2e' +import { + expectNotVisible, + expectRowVisible, + expectSimultaneous, + expectVisible, +} from 'app/test/e2e' test('Click through org access page', async ({ page }) => { await page.goto('/orgs/maze-war') @@ -11,7 +17,24 @@ test('Click through org access page', async ({ page }) => { // page is there, we see user 1 and 2 but not 3 await page.click('role=link[name*="Access & IAM"]') + + // has to be before anything else is checked. ensures we've prefetched + // users list and groups list properly + await expectSimultaneous(page, [ + `role=cell[name="${userGroup1.id}"]`, + 'role=cell[name="web-devs Group"]', + `role=cell[name="${user1.id}"]`, + 'role=cell[name="Hannah Arendt"]', + ]) + await expectVisible(page, ['role=heading[name*="Access & IAM"]']) + await expectRowVisible(table, { + ID: userGroups[0].id, + // no space because expectRowVisible uses textContent, not accessible name + Name: 'web-devsGroup', + 'Silo role': '', + 'Org role': 'collaborator', + }) await expectRowVisible(table, { ID: user1.id, Name: 'Hannah Arendt', diff --git a/app/pages/__tests__/project-access.e2e.ts b/app/pages/__tests__/project-access.e2e.ts index 8193f70635..87c94b2d33 100644 --- a/app/pages/__tests__/project-access.e2e.ts +++ b/app/pages/__tests__/project-access.e2e.ts @@ -1,13 +1,27 @@ import { test } from '@playwright/test' -import { user1, user2, user3, user4 } from '@oxide/api-mocks' +import { user1, user2, user3, user4, userGroup1, userGroup2 } from '@oxide/api-mocks' -import { expectNotVisible, expectRowVisible, expectVisible } from 'app/test/e2e' +import { + expectNotVisible, + expectRowVisible, + expectSimultaneous, + expectVisible, +} from 'app/test/e2e' 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"]') + // has to be before anything else is checked. ensures we've prefetched + // users list and groups list properly + await expectSimultaneous(page, [ + `role=cell[name="${userGroup1.id}"]`, + 'role=cell[name="web-devs Group"]', + `role=cell[name="${user1.id}"]`, + 'role=cell[name="Hannah Arendt"]', + ]) + // page is there, we see user 1-3 but not 4 await expectVisible(page, ['role=heading[name*="Access & IAM"]']) const table = page.locator('table') @@ -32,6 +46,22 @@ test('Click through project access page', async ({ page }) => { 'Org role': '', 'Project role': 'collaborator', }) + await expectRowVisible(table, { + ID: userGroup1.id, + // no space because expectRowVisible uses textContent, not accessible name + Name: 'web-devsGroup', + 'Silo role': '', + 'Org role': 'collaborator', + }) + await expectRowVisible(table, { + ID: userGroup2.id, + // no space because expectRowVisible uses textContent, not accessible name + Name: 'kernel-devsGroup', + 'Silo role': '', + 'Org role': '', + 'Project role': 'viewer', + }) + await expectNotVisible(page, [`role=cell[name="${user4.id}"]`]) // Add user 4 as collab diff --git a/app/pages/__tests__/silo-access.e2e.ts b/app/pages/__tests__/silo-access.e2e.ts new file mode 100644 index 0000000000..6711d14b5f --- /dev/null +++ b/app/pages/__tests__/silo-access.e2e.ts @@ -0,0 +1,99 @@ +import { test } from '@playwright/test' + +import { user1, user3, user4, userGroup3 } from '@oxide/api-mocks' + +import { + expectNotVisible, + expectRowVisible, + expectSimultaneous, + expectVisible, +} from 'app/test/e2e' + +test('Click through silo access page', async ({ page }) => { + await page.goto('/orgs') + + const table = page.locator('role=table') + + // page is there, we see user 1 and 2 but not 3 + await page.click('role=link[name*="Access & IAM"]') + + // has to be before anything else is checked. ensures we've prefetched + // users list and groups list properly + await expectSimultaneous(page, [ + `role=cell[name="${userGroup3.id}"]`, + 'role=cell[name="real-estate-devs Group"]', + `role=cell[name="${user1.id}"]`, + 'role=cell[name="Hannah Arendt"]', + ]) + + await expectVisible(page, ['role=heading[name*="Access & IAM"]']) + await expectRowVisible(table, { + ID: userGroup3.id, + // no space because expectRowVisible uses textContent, not accessible name + Name: 'real-estate-devsGroup', + 'Silo role': 'admin', + }) + await expectRowVisible(table, { + ID: user1.id, + Name: 'Hannah Arendt', + 'Silo role': 'admin', + }) + await expectNotVisible(page, [`role=cell[name="${user4.id}"]`]) + + // Add user 2 as collab + await page.click('role=button[name="Add user or group"]') + await expectVisible(page, ['role=heading[name*="Add user or group"]']) + + 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"]', + 'role=option[name="Jacob Klein"]', + 'role=option[name="Simone de Beauvoir"]', + ]) + + await page.click('role=option[name="Jacob Klein"]') + + await page.click('role=button[name="Role"]') + await expectVisible(page, [ + 'role=option[name="Admin"]', + 'role=option[name="Collaborator"]', + 'role=option[name="Viewer"]', + ]) + + await page.click('role=option[name="Collaborator"]') + await page.click('role=button[name="Add user"]') + + // User 3 shows up in the table + await expectRowVisible(table, { + ID: user3.id, + Name: 'Jacob Klein', + 'Silo role': 'collaborator', + }) + + // now change user 3's role from collab to viewer + await page + .locator('role=row', { hasText: user3.id }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') + + await expectVisible(page, ['role=heading[name*="Change user role"]']) + await expectVisible(page, ['button:has-text("Collaborator")']) + + await page.click('role=button[name="Role"]') + await page.click('role=option[name="Viewer"]') + await page.click('role=button[name="Update role"]') + + await expectRowVisible(table, { ID: user3.id, 'Silo role': 'viewer' }) + + // now delete user 3 + await page + .locator('role=row', { hasText: user3.id }) + .locator('role=button[name="Row actions"]') + .click() + await expectVisible(page, [`role=cell[name="${user3.id}"]`]) + await page.click('role=menuitem[name="Delete"]') + await expectNotVisible(page, [`role=cell[name="${user3.id}"]`]) +}) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 08b9063a52..e3e76ebefb 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,6 +5,7 @@ import type { LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + byGroupThenName, getEffectiveRole, setUserRole, useApiMutation, @@ -23,7 +24,7 @@ import { TableActions, TableEmptyBox, } from '@oxide/ui' -import { groupBy, isTruthy, sortBy } from '@oxide/util' +import { groupBy, isTruthy } from '@oxide/util' import { AccessNameCell } from 'app/components/AccessNameCell' import { RoleBadgeCell } from 'app/components/RoleBadgeCell' @@ -53,6 +54,7 @@ ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => { apiQueryClient.prefetchQuery('projectPolicyView', { path: { orgName, projectName } }), // used to resolve user names apiQueryClient.prefetchQuery('userList', {}), + apiQueryClient.prefetchQuery('groupList', {}), ]) } @@ -84,8 +86,8 @@ export function ProjectAccessPage() { const projectRows = useUserRows(projectPolicy?.roleAssignments, 'project') const rows = useMemo(() => { - const users = groupBy(siloRows.concat(orgRows, projectRows), (u) => u.id).map( - ([userId, userAssignments]) => { + return 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( @@ -108,9 +110,8 @@ export function ProjectAccessPage() { } return row - } - ) - return sortBy(users, (u) => u.name) + }) + .sort(byGroupThenName) }, [siloRows, orgRows, projectRows]) const queryClient = useApiQueryClient() diff --git a/app/test/e2e/utils.ts b/app/test/e2e/utils.ts index bf56067a69..06369ada11 100644 --- a/app/test/e2e/utils.ts +++ b/app/test/e2e/utils.ts @@ -85,9 +85,10 @@ async function timeToAppear(page: Page, selector: string): Promise { } /** - * Assert two elements appeared within 20ms of each other + * Assert a set of elements all appeared within a 20ms range */ -export async function expectSimultaneous(page: Page, selectors: [string, string]) { - const [t1, t2] = await Promise.all(selectors.map((sel) => timeToAppear(page, sel))) - expect(Math.abs(t1 - t2)).toBeLessThan(20) +export async function expectSimultaneous(page: Page, selectors: string[]) { + const times = await Promise.all(selectors.map((sel) => timeToAppear(page, sel))) + times.sort() + expect(times[times.length - 1] - times[0]).toBeLessThan(20) } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index e2a0ac6a33..566d629b96 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -703,7 +703,7 @@ export const handlers = makeHandlers({ return body }, sessionMe() { - return currentUser + return { ...currentUser, group_ids: [] as string[] } }, sessionSshkeyList(params) { const keys = db.sshKeys.filter((k) => k.silo_user_id === currentUser.id) @@ -778,52 +778,56 @@ export const handlers = makeHandlers({ diskViewById: lookupById(db.disks), imageViewById: lookupById(db.images), - instanceViewById: lookupById(db.instances), instanceNetworkInterfaceViewById: lookupById(db.networkInterfaces), + instanceViewById: lookupById(db.instances), organizationViewById: lookupById(db.orgs), projectViewById: lookupById(db.projects), + siloViewById: lookupById(db.silos), snapshotViewById: lookupById(db.snapshots), + systemImageViewById: lookupById(db.globalImages), vpcRouterRouteViewById: lookupById(db.vpcRouterRoutes), vpcRouterViewById: lookupById(db.vpcRouters), vpcSubnetViewById: lookupById(db.vpcSubnets), vpcViewById: lookupById(db.vpcs), - systemImageViewById: lookupById(db.globalImages), - siloViewById: lookupById(db.silos), instanceMigrate: NotImplemented, - loginSpoof: NotImplemented, - loginSamlBegin: NotImplemented, - loginSaml: NotImplemented, - logout: NotImplemented, - roleList: NotImplemented, - roleView: NotImplemented, - ipPoolViewById: NotImplemented, - rackList: NotImplemented, - rackView: NotImplemented, - sledList: NotImplemented, - sledView: NotImplemented, - ipPoolList: NotImplemented, ipPoolCreate: NotImplemented, - ipPoolView: NotImplemented, - ipPoolUpdate: NotImplemented, ipPoolDelete: NotImplemented, - ipPoolRangeList: NotImplemented, + ipPoolList: NotImplemented, ipPoolRangeAdd: NotImplemented, + ipPoolRangeList: NotImplemented, ipPoolRangeRemove: NotImplemented, - ipPoolServiceView: NotImplemented, - ipPoolServiceRangeList: NotImplemented, ipPoolServiceRangeAdd: NotImplemented, + ipPoolServiceRangeList: NotImplemented, ipPoolServiceRangeRemove: NotImplemented, - systemPolicyUpdate: NotImplemented, + ipPoolServiceView: NotImplemented, + ipPoolUpdate: NotImplemented, + ipPoolView: NotImplemented, + ipPoolViewById: NotImplemented, + localIdpUserCreate: NotImplemented, + localIdpUserDelete: NotImplemented, + loginSaml: NotImplemented, + loginSamlBegin: NotImplemented, + loginSpoof: NotImplemented, + logout: NotImplemented, + rackList: NotImplemented, + rackView: NotImplemented, + roleList: NotImplemented, + roleView: NotImplemented, sagaList: NotImplemented, sagaView: NotImplemented, - siloIdentityProviderList: NotImplemented, samlIdentityProviderCreate: NotImplemented, samlIdentityProviderView: NotImplemented, - siloPolicyView: NotImplemented, + siloIdentityProviderList: NotImplemented, siloPolicyUpdate: NotImplemented, - updatesRefresh: NotImplemented, + siloPolicyView: NotImplemented, + siloUsersList: NotImplemented, + siloUserView: NotImplemented, + sledList: NotImplemented, + sledView: NotImplemented, + systemPolicyUpdate: NotImplemented, systemUserList: NotImplemented, systemUserView: NotImplemented, timeseriesSchemaGet: NotImplemented, + updatesRefresh: NotImplemented, }) diff --git a/libs/api-mocks/role-assignment.ts b/libs/api-mocks/role-assignment.ts index 7969c34d40..338b7a3133 100644 --- a/libs/api-mocks/role-assignment.ts +++ b/libs/api-mocks/role-assignment.ts @@ -5,7 +5,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' +import { userGroup1, userGroup2, userGroup3 } 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 @@ -32,6 +32,13 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'admin', }, + { + resource_type: 'silo', + resource_id: defaultSilo.id, + identity_id: userGroup3.id, + identity_type: 'silo_group', + role_name: 'admin', + }, { resource_type: 'silo', resource_id: defaultSilo.id, diff --git a/libs/api-mocks/user-group.ts b/libs/api-mocks/user-group.ts index a0289207f0..436450f2e6 100644 --- a/libs/api-mocks/user-group.ts +++ b/libs/api-mocks/user-group.ts @@ -15,4 +15,10 @@ export const userGroup2: Json = { display_name: 'kernel-devs', } -export const userGroups = [userGroup1, userGroup2] +export const userGroup3: Json = { + id: '5e30797c-cae3-4402-aeb7-d5044c4bed29', + silo_id: defaultSilo.id, + display_name: 'real-estate-devs', +} + +export const userGroups = [userGroup1, userGroup2, userGroup3] diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index 052fd89545..7279932048 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -1182,6 +1182,18 @@ export type SamlIdentityProviderCreate = { technicalContactEmail: string } +/** + * Client view of a {@link User} and their groups + */ +export type SessionMe = { + /** Human-readable name that can identify the user */ + displayName: string + groupIds: string[] + id: string + /** Uuid of the silo to which this user belongs */ + siloId: string +} + /** * Describes how identities are managed and users are authenticated in this Silo */ @@ -1431,6 +1443,21 @@ export type UserBuiltinResultsPage = { nextPage?: string } +/** + * A name unique within the parent collection + * + * Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID. + */ +export type UserId = string + +/** + * Create-time parameters for a {@link User} + */ +export type UserCreate = { + /** username used to log in */ + externalId: UserId +} + /** * A single page of results */ @@ -2431,6 +2458,15 @@ export interface SiloIdentityProviderListQueryParams { sortBy?: NameSortMode } +export interface LocalIdpUserCreatePathParams { + siloName: Name +} + +export interface LocalIdpUserDeletePathParams { + siloName: Name + userId: string +} + export interface SamlIdentityProviderCreatePathParams { siloName: Name } @@ -2448,6 +2484,21 @@ export interface SiloPolicyUpdatePathParams { siloName: Name } +export interface SiloUsersListPathParams { + siloName: Name +} + +export interface SiloUsersListQueryParams { + limit?: number + pageToken?: string + sortBy?: IdSortMode +} + +export interface SiloUserViewPathParams { + siloName: Name + userId: string +} + export interface SystemUserListQueryParams { limit?: number pageToken?: string @@ -2515,6 +2566,7 @@ export type ApiListMethods = Pick< | 'sagaList' | 'siloList' | 'siloIdentityProviderList' + | 'siloUsersList' | 'systemUserList' | 'userList' > @@ -3845,7 +3897,7 @@ export class Api extends HttpClient { * Fetch the user associated with the current session */ sessionMe: (_: EmptyObj, params: RequestParams = {}) => { - return this.request({ + return this.request({ path: `/session/me`, method: 'GET', ...params, @@ -4347,6 +4399,32 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Create a user + */ + localIdpUserCreate: ( + { path, body }: { path: LocalIdpUserCreatePathParams; body: UserCreate }, + params: RequestParams = {} + ) => { + const { siloName } = path + return this.request({ + path: `/system/silos/${siloName}/identity-providers/local/users`, + method: 'POST', + body, + ...params, + }) + }, + localIdpUserDelete: ( + { path }: { path: LocalIdpUserDeletePathParams }, + params: RequestParams = {} + ) => { + const { siloName, userId } = path + return this.request({ + path: `/system/silos/${siloName}/identity-providers/local/users/${userId}`, + method: 'DELETE', + ...params, + }) + }, /** * Create a SAML IDP */ @@ -4408,6 +4486,35 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List users in a specific Silo + */ + siloUsersList: ( + { + path, + query = {}, + }: { path: SiloUsersListPathParams; query?: SiloUsersListQueryParams }, + params: RequestParams = {} + ) => { + const { siloName } = path + return this.request({ + path: `/system/silos/${siloName}/users/all`, + method: 'GET', + query, + ...params, + }) + }, + siloUserView: ( + { path }: { path: SiloUserViewPathParams }, + params: RequestParams = {} + ) => { + const { siloName, userId } = path + return this.request({ + path: `/system/silos/${siloName}/users/id/${userId}`, + method: 'GET', + ...params, + }) + }, /** * Refresh update data */ diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 4c2819ba15..06ac96fe86 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 -bce0f5ddf7f9f3be498fa9afb1fee8d3336c1f30 +2ea34d0369fd26bc4f438ea0b3c8ab1b50314048 diff --git a/libs/api/__generated__/msw-handlers.ts b/libs/api/__generated__/msw-handlers.ts index 4ad05afd6b..d0fd4f96d9 100644 --- a/libs/api/__generated__/msw-handlers.ts +++ b/libs/api/__generated__/msw-handlers.ts @@ -401,7 +401,7 @@ export interface MSWHandlers { /** `GET /roles/:roleName` */ roleView: (params: { path: Api.RoleViewPathParams }) => HandlerResult /** `GET /session/me` */ - sessionMe: () => HandlerResult + sessionMe: () => HandlerResult /** `GET /session/me/sshkeys` */ sessionSshkeyList: (params: { query: Api.SessionSshkeyListQueryParams @@ -528,6 +528,13 @@ export interface MSWHandlers { path: Api.SiloIdentityProviderListPathParams query: Api.SiloIdentityProviderListQueryParams }) => HandlerResult + /** `POST /system/silos/:siloName/identity-providers/local/users` */ + localIdpUserCreate: (params: { + path: Api.LocalIdpUserCreatePathParams + body: Json + }) => HandlerResult + /** `DELETE /system/silos/:siloName/identity-providers/local/users/:userId` */ + localIdpUserDelete: (params: { path: Api.LocalIdpUserDeletePathParams }) => StatusCode /** `POST /system/silos/:siloName/identity-providers/saml` */ samlIdentityProviderCreate: (params: { path: Api.SamlIdentityProviderCreatePathParams @@ -546,6 +553,13 @@ export interface MSWHandlers { path: Api.SiloPolicyUpdatePathParams body: Json }) => HandlerResult + /** `GET /system/silos/:siloName/users/all` */ + siloUsersList: (params: { + path: Api.SiloUsersListPathParams + query: Api.SiloUsersListQueryParams + }) => HandlerResult + /** `GET /system/silos/:siloName/users/id/:userId` */ + siloUserView: (params: { path: Api.SiloUserViewPathParams }) => HandlerResult /** `POST /system/updates/refresh` */ updatesRefresh: () => StatusCode /** `GET /system/user` */ @@ -1213,6 +1227,18 @@ export function makeHandlers(handlers: MSWHandlers): RestHandler[] { null ) ), + rest.post( + '/system/silos/:siloName/identity-providers/local/users', + handler( + handlers['localIdpUserCreate'], + schema.LocalIdpUserCreateParams, + schema.UserCreate + ) + ), + rest.delete( + '/system/silos/:siloName/identity-providers/local/users/:userId', + handler(handlers['localIdpUserDelete'], schema.LocalIdpUserDeleteParams, null) + ), rest.post( '/system/silos/:siloName/identity-providers/saml', handler( @@ -1241,6 +1267,14 @@ export function makeHandlers(handlers: MSWHandlers): RestHandler[] { schema.SiloRolePolicy ) ), + rest.get( + '/system/silos/:siloName/users/all', + handler(handlers['siloUsersList'], schema.SiloUsersListParams, null) + ), + rest.get( + '/system/silos/:siloName/users/id/:userId', + handler(handlers['siloUserView'], schema.SiloUserViewParams, null) + ), rest.post('/system/updates/refresh', handler(handlers['updatesRefresh'], null, null)), rest.get( '/system/user', diff --git a/libs/api/__generated__/validate.ts b/libs/api/__generated__/validate.ts index 3bf930b08b..748aa1b8f1 100644 --- a/libs/api/__generated__/validate.ts +++ b/libs/api/__generated__/validate.ts @@ -683,7 +683,7 @@ export const Ipv4Net = z.preprocess( z .string() .regex( - /^(10\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\/([8-9]|1[0-9]|2[0-9]|3[0-2])|172\.(1[6-9]|2[0-9]|3[0-1])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\/(1[2-9]|2[0-9]|3[0-2])|192\.168\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\/(1[6-9]|2[0-9]|3[0-2]))$/ + /^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\/([8-9]|1[0-9]|2[0-9]|3[0-2])$/ ) ) @@ -1224,6 +1224,19 @@ export const SamlIdentityProviderCreate = z.preprocess( }) ) +/** + * Client view of a {@link User} and their groups + */ +export const SessionMe = z.preprocess( + processResponseBody, + z.object({ + displayName: z.string(), + groupIds: z.string().uuid().array(), + id: z.string().uuid(), + siloId: z.string().uuid(), + }) +) + /** * Describes how identities are managed and users are authenticated in this Silo */ @@ -1459,6 +1472,29 @@ export const UserBuiltinResultsPage = z.preprocess( z.object({ items: UserBuiltin.array(), nextPage: z.string().optional() }) ) +/** + * A name unique within the parent collection + * + * Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. Names cannot be a UUID though they may contain a UUID. + */ +export const UserId = z.preprocess( + processResponseBody, + z + .string() + .max(63) + .regex( + /^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z][a-z0-9-]*[a-zA-Z0-9]$/ + ) +) + +/** + * Create-time parameters for a {@link User} + */ +export const UserCreate = z.preprocess( + processResponseBody, + z.object({ externalId: UserId }) +) + /** * A single page of results */ @@ -3210,6 +3246,27 @@ export const SiloIdentityProviderListParams = z.preprocess( }) ) +export const LocalIdpUserCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + siloName: Name, + }), + query: z.object({}), + }) +) + +export const LocalIdpUserDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + siloName: Name, + userId: z.string().uuid(), + }), + query: z.object({}), + }) +) + export const SamlIdentityProviderCreateParams = z.preprocess( processResponseBody, z.object({ @@ -3251,6 +3308,31 @@ export const SiloPolicyUpdateParams = z.preprocess( }) ) +export const SiloUsersListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + siloName: Name, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + sortBy: IdSortMode.optional(), + }), + }) +) + +export const SiloUserViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + siloName: Name, + userId: z.string().uuid(), + }), + query: z.object({}), + }) +) + export const UpdatesRefreshParams = z.preprocess( processResponseBody, z.object({ diff --git a/libs/api/roles.spec.ts b/libs/api/roles.spec.ts index 2d875377e3..735a409bdf 100644 --- a/libs/api/roles.spec.ts +++ b/libs/api/roles.spec.ts @@ -1,5 +1,11 @@ -import type { Policy, SessionMe } from './roles' -import { getEffectiveRole, roleOrder, setUserRole, userRoleFromPolicies } from './roles' +import type { Policy } from './roles' +import { + byGroupThenName, + getEffectiveRole, + roleOrder, + setUserRole, + userRoleFromPolicies, +} from './roles' describe('getEffectiveRole', () => { it('returns falsy when the list of role assignments is empty', () => { @@ -48,7 +54,7 @@ describe('setUserRole', () => { }) }) -const user1: SessionMe = { +const user1 = { id: 'hi', displayName: 'bye', siloId: 'sigh', @@ -119,3 +125,13 @@ describe('getEffectiveRole', () => { ).toEqual('admin') }) }) + +test('byGroupThenName sorts as expected', () => { + const a = { identityType: 'silo_group' as const, name: 'a' } + const b = { identityType: 'silo_group' as const, name: 'b' } + const c = { identityType: 'silo_user' as const, name: 'c' } + const d = { identityType: 'silo_user' as const, name: 'd' } + const e = { identityType: 'silo_user' as const, name: 'e' } + + expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) +}) diff --git a/libs/api/roles.ts b/libs/api/roles.ts index 472479bf3d..197f374bd5 100644 --- a/libs/api/roles.ts +++ b/libs/api/roles.ts @@ -13,8 +13,8 @@ import type { IdentityType, OrganizationRole, ProjectRole, + SessionMe, SiloRole, - User, } from './__generated__/Api' /** @@ -113,6 +113,18 @@ export function useUserRows( }, [roleAssignments, roleSource, users, groups]) } +type SortableUserRow = { identityType: IdentityType; name: string } + +/** + * Comparator for array sort. Group groups and users, then sort by name within + * groups and within users. + */ +export function byGroupThenName(a: SortableUserRow, b: SortableUserRow) { + const aGroup = Number(a.identityType === 'silo_group') + const bGroup = Number(b.identityType === 'silo_group') + return bGroup - aGroup || a.name.localeCompare(b.name) +} + /** * Fetch list of users and filter out the ones that are already in the given * policy. @@ -122,6 +134,7 @@ export function useUsersNotInPolicy( policy: Policy | undefined ) { const { data: users } = useApiQuery('userList', {}) + // const { data: groups } = useApiQuery('groupList', {}) return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const usersInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) @@ -133,11 +146,6 @@ export function useUsersNotInPolicy( }, [users, policy]) } -// temporary until we figure out how we're getting groups from the API -export type SessionMe = User & { - groupIds?: string[] -} - export function userRoleFromPolicies(user: SessionMe, policies: Policy[]): RoleKey | null { const myIds = new Set([user.id, ...(user.groupIds || [])]) const myRoles = policies