Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ it('mock-api is only referenced in test files', () => {
"test/e2e/profile.e2e.ts",
"test/e2e/project-access.e2e.ts",
"test/e2e/silo-access.e2e.ts",
"test/e2e/system-access.e2e.ts",
"tsconfig.json",
]
`)
Expand Down
39 changes: 27 additions & 12 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,35 @@ export const roleOrder: Record<RoleKey, number> = {
/** `roleOrder` record converted to a sorted array of roles. */
export const allRoles = flatRoles(roleOrder)

// Fleet roles don't include limited_collaborator
export const fleetRoles = allRoles.filter(
(r): r is FleetRole => r !== 'limited_collaborator'
)

/** Given a list of roles, get the most permissive one */
export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined =>
export const getEffectiveRole = <Role extends RoleKey>(roles: Role[]): Role | undefined =>
R.firstBy(roles, (role) => roleOrder[role])

////////////////////////////
// Policy helpers
////////////////////////////

type RoleAssignment = {
type RoleAssignment<Role extends RoleKey = RoleKey> = {
identityId: string
identityType: IdentityType
roleName: RoleKey
roleName: Role
}
export type Policy<Role extends RoleKey = RoleKey> = {
roleAssignments: RoleAssignment<Role>[]
}
export type Policy = { roleAssignments: RoleAssignment[] }

/**
* Returns a new updated policy. Does not modify the passed-in policy.
*/
export function updateRole(newAssignment: RoleAssignment, policy: Policy): Policy {
export function updateRole<Role extends RoleKey>(
newAssignment: RoleAssignment<Role>,
policy: Policy<Role>
): Policy<Role> {
const roleAssignments = policy.roleAssignments.filter(
(ra) => ra.identityId !== newAssignment.identityId
)
Expand All @@ -70,18 +80,21 @@ export function updateRole(newAssignment: RoleAssignment, policy: Policy): Polic
* Delete any role assignments for user or group ID. Returns a new updated
* policy. Does not modify the passed-in policy.
*/
export function deleteRole(identityId: string, policy: Policy): Policy {
export function deleteRole<Role extends RoleKey>(
identityId: string,
policy: Policy<Role>
): Policy<Role> {
const roleAssignments = policy.roleAssignments.filter(
(ra) => ra.identityId !== identityId
)
return { roleAssignments }
}

type UserAccessRow = {
type UserAccessRow<Role extends RoleKey = RoleKey> = {
id: string
identityType: IdentityType
name: string
roleName: RoleKey
roleName: Role
roleSource: string
}

Expand All @@ -92,10 +105,10 @@ type UserAccessRow = {
* of an API request for the list of users. It's a bit awkward, but the logic is
* identical between projects and orgs so it is worth sharing.
*/
export function useUserRows(
roleAssignments: RoleAssignment[],
export function useUserRows<Role extends RoleKey = RoleKey>(
roleAssignments: RoleAssignment<Role>[],
roleSource: string
): UserAccessRow[] {
): UserAccessRow<Role>[] {
// 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 } = usePrefetchedQuery(q(api.userList, {}))
Expand Down Expand Up @@ -136,7 +149,9 @@ export type Actor = {
* Fetch lists of users and groups, filtering out the ones that are already in
* the given policy.
*/
export function useActorsNotInPolicy(policy: Policy): Actor[] {
export function useActorsNotInPolicy<Role extends RoleKey = RoleKey>(
policy: Policy<Role>
): Actor[] {
const { data: users } = usePrefetchedQuery(q(api.userList, {}))
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
return useMemo(() => {
Expand Down
37 changes: 29 additions & 8 deletions app/forms/access-util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import * as R from 'remeda'

import {
allRoles,
fleetRoles,
type Actor,
type FleetRole,
type IdentityType,
type Policy,
type RoleKey,
Expand Down Expand Up @@ -50,6 +52,13 @@ const siloRoleDescriptions: Record<RoleKey, string> = {
viewer: 'View resources within the silo',
}

// Role descriptions for fleet-level roles
const fleetRoleDescriptions: Record<FleetRole, string> = {
admin: 'Control all aspects of the fleet',
collaborator: 'Administer silos and fleet-level resources',
viewer: 'View fleet-level resources',
}

export const actorToItem = (actor: Actor): ListboxItem => ({
value: actor.id,
label: (
Expand All @@ -65,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({
selectedLabel: actor.displayName,
})

export type AddRoleModalProps = {
export type AddRoleModalProps<Role extends RoleKey = RoleKey> = {
onDismiss: () => void
policy: Policy
policy: Policy<Role>
}

export type EditRoleModalProps = AddRoleModalProps & {
export type EditRoleModalProps<Role extends RoleKey = RoleKey> = AddRoleModalProps<Role> & {
name?: string
identityId: string
identityType: IdentityType
defaultValues: { roleName: RoleKey }
defaultValues: { roleName: Role }
}

const AccessDocs = () => (
Expand All @@ -92,9 +101,15 @@ export function RoleRadioField<
}: {
name: TName
control: Control<TFieldValues>
scope: 'Silo' | 'Project'
scope: 'Fleet' | 'Silo' | 'Project'
}) {
const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions
const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles)
const roleDescriptions: Partial<Record<RoleKey, string>> =
scope === 'Fleet'
? fleetRoleDescriptions
: scope === 'Silo'
? siloRoleDescriptions
: projectRoleDescriptions
return (
<>
<RadioFieldDyn
Expand All @@ -105,7 +120,7 @@ export function RoleRadioField<
column
className="mt-2"
>
{R.reverse(allRoles).map((role) => (
{roles.map((role) => (
<Radio name="roleName" key={role} value={role} alignTop>
<div className="text-sans-md text-raise">
{capitalize(role).replace('_', ' ')}
Expand All @@ -117,7 +132,13 @@ export function RoleRadioField<
<Message
variant="info"
content={
scope === 'Silo' ? (
scope === 'Fleet' ? (
<>
Fleet roles grant access to fleet-level resources and administration. To
maintain tenancy separation between silos, fleet roles do not cascade into
silos. Learn more in the <AccessDocs /> guide.
</>
) : scope === 'Silo' ? (
<>
Silo roles are inherited by all projects in the silo and override weaker
roles. For example, a silo viewer is <em>at least</em> a viewer on all
Expand Down
10 changes: 8 additions & 2 deletions app/forms/silo-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr
resourceName="role"
title="Add user or group"
submitLabel="Assign role"
onDismiss={onDismiss}
onDismiss={() => {
updatePolicy.reset() // clear API error state so it doesn't persist on next open
onDismiss()
}}
onSubmit={({ identityId, roleName }) => {
// TODO: DRY logic
// actor is guaranteed to be in the list because it came from there
Expand Down Expand Up @@ -109,7 +112,10 @@ export function SiloAccessEditUserSideModal({
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
onDismiss={onDismiss}
onDismiss={() => {
updatePolicy.reset() // clear API error state so it doesn't persist on next open
onDismiss()
}}
>
<RoleRadioField name="roleName" control={form.control} scope="Silo" />
<SideModalFormDocs docs={[docLinks.access]} />
Expand Down
128 changes: 128 additions & 0 deletions app/forms/system-access.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'

import {
api,
queryClient,
updateRole,
useActorsNotInPolicy,
useApiMutation,
type FleetRole,
} from '@oxide/api'
import { Access16Icon } from '@oxide/design-system/icons/react'

import { ListboxField } from '~/components/form/fields/ListboxField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { ResourceLabel } from '~/ui/lib/SideModal'
import { docLinks } from '~/util/links'

import {
actorToItem,
RoleRadioField,
type AddRoleModalProps,
type EditRoleModalProps,
} from './access-util'

export function SystemAccessAddUserSideModal({
onDismiss,
policy,
}: AddRoleModalProps<FleetRole>) {
const actors = useActorsNotInPolicy(policy)

const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('systemPolicyView')
onDismiss()
},
})

const form = useForm<{ identityId: string; roleName: FleetRole }>({
defaultValues: { identityId: '', roleName: 'viewer' },
})

return (
<SideModalForm
form={form}
formType="create"
resourceName="role"
title="Add user or group"
submitLabel="Assign role"
onDismiss={() => {
updatePolicy.reset() // clear API error state so it doesn't persist on next open
onDismiss()
}}
onSubmit={({ identityId, roleName }) => {
// actor is guaranteed to be in the list because it came from there
const identityType = actors.find((a) => a.id === identityId)!.identityType

updatePolicy.mutate({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
>
<ListboxField
name="identityId"
items={actors.map(actorToItem)}
label="User or group"
required
control={form.control}
/>
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
<SideModalFormDocs docs={[docLinks.access]} />
</SideModalForm>
)
}

export function SystemAccessEditUserSideModal({
onDismiss,
name,
identityId,
identityType,
policy,
defaultValues,
}: EditRoleModalProps<FleetRole>) {
const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('systemPolicyView')
onDismiss()
},
})
const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="edit"
resourceName="role"
title="Edit role"
subtitle={
<ResourceLabel>
<Access16Icon /> {name}
</ResourceLabel>
}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
onDismiss={() => {
updatePolicy.reset() // clear API error state so it doesn't persist on next open
onDismiss()
}}
>
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
<SideModalFormDocs docs={[docLinks.access]} />
</SideModalForm>
)
}
5 changes: 5 additions & 0 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useLocation, useNavigate } from 'react-router'

import { api, q, queryClient } from '@oxide/api'
import {
Access16Icon,
Cloud16Icon,
IpGlobal16Icon,
Metrics16Icon,
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function SystemLayout() {
{ value: 'Inventory', path: pb.sledInventory() },
{ value: 'IP Pools', path: pb.ipPools() },
{ value: 'System Update', path: pb.systemUpdate() },
{ value: 'System Access', path: pb.systemAccess() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
Expand Down Expand Up @@ -101,6 +103,9 @@ export default function SystemLayout() {
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
<NavLinkItem to={pb.systemAccess()}>
<Access16Icon /> System Access
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
Expand Down
Loading
Loading