Skip to content

Commit 1713e53

Browse files
Add Fleet Access page (#3095)
This adds a system-level access page, with a form for setting fleet-level permissions. <img width="1462" height="461" alt="Screenshot 2026-02-26 at 4 59 26 AM" src="https://github.com/user-attachments/assets/048db2d1-7847-4b3e-bcce-5d299e8dc0c4" /> One enhancement we might consider: It looks like there are a few booleans — `silo_admin` and `fleet_viewer` — on the CurrentUser object coming from Omicron, but there is not a `fleet_admin` attribute. If we add that in Omicron, we could disable the "Add User or Group" button and other controls on the System Access page for people without a fleet_admin role. Closes #2916 --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>
1 parent 6c1178e commit 1713e53

21 files changed

+767
-61
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
- Run local checks before sending PRs: `npm run lint`, `npm run tsc`, `npm test run`, and `npm run e2ec`.
2626
- You don't usually need to run all the e2e tests, so try to filter by file and tes t name like `npm run e2ec -- instance -g 'boot disk'`. CI will run the full set.
27-
- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked.
27+
- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. Avoid Playwright’s legacy string selector syntax like `page.click(‘role=button[name="..."]’)`; prefer `page.getByRole(‘button’, { name: ‘...’ }).click()` and friends.
2828
- Cover role-gated flows by logging in with `getPageAsUser`; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in `test/e2e/system-update.e2e.ts`.
2929
- Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code.
3030
- When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (`mock-api/msw/db.ts`, `mock-api/msw/handlers.ts`).

app/api/__tests__/safety.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ it('mock-api is only referenced in test files', () => {
3939
"AGENTS.md",
4040
"app/api/__tests__/client.spec.tsx",
4141
"mock-api/msw/db.ts",
42+
"test/e2e/fleet-access.e2e.ts",
4243
"test/e2e/instance-create.e2e.ts",
4344
"test/e2e/inventory.e2e.ts",
4445
"test/e2e/ip-pool-silo-config.e2e.ts",

app/api/roles.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generat
1818
import { api, q, usePrefetchedQuery } from './client'
1919

2020
/**
21-
* Union of all the specific roles, which are all the same, which makes making
22-
* our methods generic on the *Role type is pointless (until they stop being the same).
21+
* Union of all the specific roles, which used to all be the same until we added
22+
* limited collaborator to silo.
2323
*/
2424
export type RoleKey = FleetRole | SiloRole | ProjectRole
2525

@@ -40,25 +40,35 @@ export const roleOrder: Record<RoleKey, number> = {
4040
/** `roleOrder` record converted to a sorted array of roles. */
4141
export const allRoles = flatRoles(roleOrder)
4242

43+
// Fleet roles don't include limited_collaborator
44+
export const fleetRoles = allRoles.filter(
45+
(r): r is FleetRole => r !== 'limited_collaborator'
46+
)
47+
4348
/** Given a list of roles, get the most permissive one */
44-
export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined =>
49+
export const getEffectiveRole = <Role extends RoleKey>(roles: Role[]): Role | undefined =>
4550
R.firstBy(roles, (role) => roleOrder[role])
4651

4752
////////////////////////////
4853
// Policy helpers
4954
////////////////////////////
5055

51-
type RoleAssignment = {
56+
type RoleAssignment<Role extends RoleKey = RoleKey> = {
5257
identityId: string
5358
identityType: IdentityType
54-
roleName: RoleKey
59+
roleName: Role
60+
}
61+
export type Policy<Role extends RoleKey = RoleKey> = {
62+
roleAssignments: RoleAssignment<Role>[]
5563
}
56-
export type Policy = { roleAssignments: RoleAssignment[] }
5764

5865
/**
5966
* Returns a new updated policy. Does not modify the passed-in policy.
6067
*/
61-
export function updateRole(newAssignment: RoleAssignment, policy: Policy): Policy {
68+
export function updateRole<Role extends RoleKey>(
69+
newAssignment: RoleAssignment<Role>,
70+
policy: Policy<Role>
71+
): Policy<Role> {
6272
const roleAssignments = policy.roleAssignments.filter(
6373
(ra) => ra.identityId !== newAssignment.identityId
6474
)
@@ -70,18 +80,21 @@ export function updateRole(newAssignment: RoleAssignment, policy: Policy): Polic
7080
* Delete any role assignments for user or group ID. Returns a new updated
7181
* policy. Does not modify the passed-in policy.
7282
*/
73-
export function deleteRole(identityId: string, policy: Policy): Policy {
83+
export function deleteRole<Role extends RoleKey>(
84+
identityId: string,
85+
policy: Policy<Role>
86+
): Policy<Role> {
7487
const roleAssignments = policy.roleAssignments.filter(
7588
(ra) => ra.identityId !== identityId
7689
)
7790
return { roleAssignments }
7891
}
7992

80-
type UserAccessRow = {
93+
type UserAccessRow<Role extends RoleKey = RoleKey> = {
8194
id: string
8295
identityType: IdentityType
8396
name: string
84-
roleName: RoleKey
97+
roleName: Role
8598
roleSource: string
8699
}
87100

@@ -92,10 +105,10 @@ type UserAccessRow = {
92105
* of an API request for the list of users. It's a bit awkward, but the logic is
93106
* identical between projects and orgs so it is worth sharing.
94107
*/
95-
export function useUserRows(
96-
roleAssignments: RoleAssignment[],
108+
export function useUserRows<Role extends RoleKey = RoleKey>(
109+
roleAssignments: RoleAssignment<Role>[],
97110
roleSource: string
98-
): UserAccessRow[] {
111+
): UserAccessRow<Role>[] {
99112
// HACK: because the policy has no names, we are fetching ~all the users,
100113
// putting them in a dictionary, and adding the names to the rows
101114
const { data: users } = usePrefetchedQuery(q(api.userList, {}))
@@ -107,7 +120,11 @@ export function useUserRows(
107120
return roleAssignments.map((ra) => ({
108121
id: ra.identityId,
109122
identityType: ra.identityType,
110-
name: usersDict[ra.identityId]?.displayName || '', // placeholder until we get names, obviously
123+
// A user might not appear here if they are not in the current user's
124+
// silo. This could happen in a fleet policy, which might have users from
125+
// different silos. Hence the ID fallback. The code that displays this
126+
// detects when we've fallen back and includes an explanatory tooltip.
127+
name: usersDict[ra.identityId]?.displayName || ra.identityId,
111128
roleName: ra.roleName,
112129
roleSource,
113130
}))
@@ -136,7 +153,9 @@ export type Actor = {
136153
* Fetch lists of users and groups, filtering out the ones that are already in
137154
* the given policy.
138155
*/
139-
export function useActorsNotInPolicy(policy: Policy): Actor[] {
156+
export function useActorsNotInPolicy<Role extends RoleKey = RoleKey>(
157+
policy: Policy<Role>
158+
): Actor[] {
140159
const { data: users } = usePrefetchedQuery(q(api.userList, {}))
141160
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
142161
return useMemo(() => {

app/forms/access-util.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import * as R from 'remeda'
1010

1111
import {
1212
allRoles,
13+
fleetRoles,
1314
type Actor,
15+
type FleetRole,
1416
type IdentityType,
1517
type Policy,
1618
type RoleKey,
@@ -50,6 +52,13 @@ const siloRoleDescriptions: Record<RoleKey, string> = {
5052
viewer: 'View resources within the silo',
5153
}
5254

55+
// Role descriptions for fleet-level roles
56+
const fleetRoleDescriptions: Record<FleetRole, string> = {
57+
admin: 'Control all aspects of the fleet',
58+
collaborator: 'Administer silos and fleet-level resources',
59+
viewer: 'View fleet-level resources',
60+
}
61+
5362
export const actorToItem = (actor: Actor): ListboxItem => ({
5463
value: actor.id,
5564
label: (
@@ -65,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({
6574
selectedLabel: actor.displayName,
6675
})
6776

68-
export type AddRoleModalProps = {
77+
export type AddRoleModalProps<Role extends RoleKey = RoleKey> = {
6978
onDismiss: () => void
70-
policy: Policy
79+
policy: Policy<Role>
7180
}
7281

73-
export type EditRoleModalProps = AddRoleModalProps & {
82+
export type EditRoleModalProps<Role extends RoleKey = RoleKey> = AddRoleModalProps<Role> & {
7483
name?: string
7584
identityId: string
7685
identityType: IdentityType
77-
defaultValues: { roleName: RoleKey }
86+
defaultValues: { roleName: Role }
7887
}
7988

8089
const AccessDocs = () => (
@@ -92,9 +101,15 @@ export function RoleRadioField<
92101
}: {
93102
name: TName
94103
control: Control<TFieldValues>
95-
scope: 'Silo' | 'Project'
104+
scope: 'Fleet' | 'Silo' | 'Project'
96105
}) {
97-
const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions
106+
const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles)
107+
const roleDescriptions: Partial<Record<RoleKey, string>> =
108+
scope === 'Fleet'
109+
? fleetRoleDescriptions
110+
: scope === 'Silo'
111+
? siloRoleDescriptions
112+
: projectRoleDescriptions
98113
return (
99114
<>
100115
<RadioFieldDyn
@@ -105,7 +120,7 @@ export function RoleRadioField<
105120
column
106121
className="mt-2"
107122
>
108-
{R.reverse(allRoles).map((role) => (
123+
{roles.map((role) => (
109124
<Radio name="roleName" key={role} value={role}>
110125
<div className="text-sans-md text-raise">
111126
{capitalize(role).replace('_', ' ')}
@@ -117,7 +132,13 @@ export function RoleRadioField<
117132
<Message
118133
variant="info"
119134
content={
120-
scope === 'Silo' ? (
135+
scope === 'Fleet' ? (
136+
<>
137+
Fleet roles grant access to fleet-level resources and administration. To
138+
maintain tenancy separation between silos, fleet roles do not cascade into
139+
silos. Learn more in the <AccessDocs /> guide.
140+
</>
141+
) : scope === 'Silo' ? (
121142
<>
122143
Silo roles are inherited by all projects in the silo and override weaker
123144
roles. For example, a silo viewer is <em>at least</em> a viewer on all

app/forms/fleet-access.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useForm } from 'react-hook-form'
9+
10+
import {
11+
api,
12+
queryClient,
13+
updateRole,
14+
useActorsNotInPolicy,
15+
useApiMutation,
16+
type FleetRole,
17+
} from '@oxide/api'
18+
import { Access16Icon } from '@oxide/design-system/icons/react'
19+
20+
import { ListboxField } from '~/components/form/fields/ListboxField'
21+
import { SideModalForm } from '~/components/form/SideModalForm'
22+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
23+
import { ResourceLabel } from '~/ui/lib/SideModal'
24+
import { docLinks } from '~/util/links'
25+
26+
import {
27+
actorToItem,
28+
RoleRadioField,
29+
type AddRoleModalProps,
30+
type EditRoleModalProps,
31+
} from './access-util'
32+
33+
export function FleetAccessAddUserSideModal({
34+
onDismiss,
35+
policy,
36+
}: AddRoleModalProps<FleetRole>) {
37+
const actors = useActorsNotInPolicy(policy)
38+
39+
const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
40+
onSuccess: () => {
41+
queryClient.invalidateEndpoint('systemPolicyView')
42+
onDismiss()
43+
},
44+
})
45+
46+
const form = useForm<{ identityId: string; roleName: FleetRole }>({
47+
defaultValues: { identityId: '', roleName: 'viewer' },
48+
})
49+
50+
return (
51+
<SideModalForm
52+
form={form}
53+
formType="create"
54+
resourceName="role"
55+
title="Add user or group"
56+
submitLabel="Assign role"
57+
onDismiss={() => {
58+
updatePolicy.reset() // clear API error state so it doesn't persist on next open
59+
onDismiss()
60+
}}
61+
onSubmit={({ identityId, roleName }) => {
62+
// actor is guaranteed to be in the list because it came from there
63+
const identityType = actors.find((a) => a.id === identityId)!.identityType
64+
65+
updatePolicy.mutate({
66+
body: updateRole({ identityId, identityType, roleName }, policy),
67+
})
68+
}}
69+
loading={updatePolicy.isPending}
70+
submitError={updatePolicy.error}
71+
>
72+
<ListboxField
73+
name="identityId"
74+
items={actors.map(actorToItem)}
75+
label="User or group"
76+
required
77+
control={form.control}
78+
/>
79+
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
80+
<SideModalFormDocs docs={[docLinks.access]} />
81+
</SideModalForm>
82+
)
83+
}
84+
85+
export function FleetAccessEditUserSideModal({
86+
onDismiss,
87+
name,
88+
identityId,
89+
identityType,
90+
policy,
91+
defaultValues,
92+
}: EditRoleModalProps<FleetRole>) {
93+
const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
94+
onSuccess: () => {
95+
queryClient.invalidateEndpoint('systemPolicyView')
96+
onDismiss()
97+
},
98+
})
99+
const form = useForm({ defaultValues })
100+
101+
return (
102+
<SideModalForm
103+
form={form}
104+
formType="edit"
105+
resourceName="role"
106+
title="Edit role"
107+
subtitle={
108+
<ResourceLabel>
109+
<Access16Icon /> {name}
110+
</ResourceLabel>
111+
}
112+
onSubmit={({ roleName }) => {
113+
updatePolicy.mutate({
114+
body: updateRole({ identityId, identityType, roleName }, policy),
115+
})
116+
}}
117+
loading={updatePolicy.isPending}
118+
submitError={updatePolicy.error}
119+
onDismiss={() => {
120+
updatePolicy.reset() // clear API error state so it doesn't persist on next open
121+
onDismiss()
122+
}}
123+
>
124+
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
125+
<SideModalFormDocs docs={[docLinks.access]} />
126+
</SideModalForm>
127+
)
128+
}

app/forms/silo-access.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr
4949
resourceName="role"
5050
title="Add user or group"
5151
submitLabel="Assign role"
52-
onDismiss={onDismiss}
52+
onDismiss={() => {
53+
updatePolicy.reset() // clear API error state so it doesn't persist on next open
54+
onDismiss()
55+
}}
5356
onSubmit={({ identityId, roleName }) => {
5457
// TODO: DRY logic
5558
// actor is guaranteed to be in the list because it came from there
@@ -109,7 +112,10 @@ export function SiloAccessEditUserSideModal({
109112
}}
110113
loading={updatePolicy.isPending}
111114
submitError={updatePolicy.error}
112-
onDismiss={onDismiss}
115+
onDismiss={() => {
116+
updatePolicy.reset() // clear API error state so it doesn't persist on next open
117+
onDismiss()
118+
}}
113119
>
114120
<RoleRadioField name="roleName" control={form.control} scope="Silo" />
115121
<SideModalFormDocs docs={[docLinks.access]} />

app/layouts/SystemLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useLocation, useNavigate } from 'react-router'
1010

1111
import { api, q, queryClient } from '@oxide/api'
1212
import {
13+
Access16Icon,
1314
Cloud16Icon,
1415
IpGlobal16Icon,
1516
Metrics16Icon,
@@ -55,6 +56,7 @@ export default function SystemLayout() {
5556
{ value: 'Inventory', path: pb.sledInventory() },
5657
{ value: 'IP Pools', path: pb.ipPools() },
5758
{ value: 'System Update', path: pb.systemUpdate() },
59+
{ value: 'Fleet Access', path: pb.fleetAccess() },
5860
]
5961
// filter out the entry for the path we're currently on
6062
.filter((i) => i.path !== pathname)
@@ -101,6 +103,9 @@ export default function SystemLayout() {
101103
<NavLinkItem to={pb.systemUpdate()}>
102104
<SoftwareUpdate16Icon /> System Update
103105
</NavLinkItem>
106+
<NavLinkItem to={pb.fleetAccess()}>
107+
<Access16Icon /> Fleet Access
108+
</NavLinkItem>
104109
</Sidebar.Nav>
105110
</Sidebar>
106111
<ContentPane />

0 commit comments

Comments
 (0)