Skip to content
Merged
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
14 changes: 14 additions & 0 deletions app/api/roles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
effectiveScopedRole,
getEffectiveRole,
roleOrder,
rolesByIdFromPolicy,
updateRole,
userScopedRoleEntries,
type Policy,
Expand Down Expand Up @@ -125,6 +126,19 @@ test('byGroupThenName sorts as expected', () => {
expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e])
})

describe('rolesByIdFromPolicy', () => {
it('maps each identity to its role', () => {
expect(rolesByIdFromPolicy(abcAdminPolicy)).toEqual(new Map([['abc', 'admin']]))
})

it('keeps the strongest role when an identity has multiple assignments', () => {
const policy: Policy = { roleAssignments: [abcViewer, abcAdmin] }
expect(rolesByIdFromPolicy(policy)).toEqual(new Map([['abc', 'admin']]))
const reversed: Policy = { roleAssignments: [abcAdmin, abcViewer] }
expect(rolesByIdFromPolicy(reversed)).toEqual(new Map([['abc', 'admin']]))
})
})

describe('userScopedRoleEntries', () => {
it('collapses multiple assignments for the same identity to the strongest role', () => {
// API permits multiple assignments for one identity in a single policy
Expand Down
24 changes: 14 additions & 10 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,20 @@ export function updateRole<Role extends RoleKey>(
return { roleAssignments }
}

/** Map from identity ID to role name for quick lookup. */
/**
* Map from identity ID to role name for quick lookup. If an actor has multiple
* assignments in the same policy (which the API allows) take the strongest one.
*/
export function rolesByIdFromPolicy<Role extends RoleKey>(
policy: Policy<Role>
): Map<string, Role> {
return new Map(policy.roleAssignments.map((a) => [a.identityId, a.roleName]))
const map = new Map<string, Role>()
for (const { identityId, roleName } of policy.roleAssignments) {
const existing = map.get(identityId)
// non-null: list is non-empty
map.set(identityId, getEffectiveRole(existing ? [existing, roleName] : [roleName])!)
}
return map

@david-crespo david-crespo Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fun, this was actually a bug — it was keeping whatever the last assignment in the policy was instead of the strongest one. Looking into how this is used and whether this is a better way to do it. Yeah I'm pretty sure this is right.

}

/**
Expand Down Expand Up @@ -160,12 +169,6 @@ export type ScopedRoleEntry = {
source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } }
}

/** Strongest role assigned to an identity in a policy, if any. */
const roleForId = (policy: Policy, id: string) =>
getEffectiveRole(
policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName)
)

/**
* Enumerate all role assignments relevant to a user — one entry per direct
* assignment and one per group assignment — across the given policies. Each
Expand All @@ -181,12 +184,13 @@ export function userScopedRoleEntries(
): ScopedRoleEntry[] {
const entries: ScopedRoleEntry[] = []
for (const { scope, policy } of scopedPolicies) {
const direct = roleForId(policy, userId)
const roleById = rolesByIdFromPolicy(policy)
const direct = roleById.get(userId)
if (direct) {
entries.push({ roleName: direct, scope, source: { type: 'direct' } })
}
for (const group of userGroups) {
const via = roleForId(policy, group.id)
const via = roleById.get(group.id)
if (via) {
entries.push({ roleName: via, scope, source: { type: 'group', group } })
}
Expand Down
88 changes: 30 additions & 58 deletions app/components/access/AccessGroupsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,25 @@
* Copyright Oxide Computer Company
*/
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useCallback, useMemo, useState, type ComponentType } from 'react'
import { useCallback, useMemo, useState } from 'react'
import * as R from 'remeda'

import {
api,
deleteRole,
effectiveScopedRole,
q,
queryClient,
rolesByIdFromPolicy,
useApiMutation,
usePrefetchedQuery,
userScopedRoleEntries,
type AccessScope,
type Group,
type Policy,
type RoleKey,
type ScopedPolicy,
} from '@oxide/api'
import { PersonGroup24Icon } from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'

import { type EditRoleModalProps } from '~/forms/access-util'
import { SiloAccessEditUserSideModal } from '~/forms/silo-access'
import { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { ButtonCell } from '~/table/cells/LinkCell'
import { MemberCountCell } from '~/table/cells/MemberCountCell'
Expand All @@ -40,12 +38,13 @@ import { ALL_ISH } from '~/util/consts'

import { GroupMembersSideModal } from './GroupMembersSideModal'
import { buildRoleActions } from './roleActions'
import { useCanEditPolicy } from './use-can-edit-policy'
import { useCanEditSiloPolicy } from './use-can-edit-policy'

// The API only sorts groups by id, so fetch the full set and sort by name
// client-side. ALL_ISH is the practical ceiling; a silo with more groups than
// that would have its tail dropped in (arbitrary) id order.
const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } })
const policyView = q(api.policyView, {})

const colHelper = createColumnHelper<Group>()

Expand All @@ -61,23 +60,7 @@ const GroupEmptyState = () => (

type EditingState = { group: Group; defaultRole: RoleKey | undefined }

type Props = {
/** Policies that contribute to a group's effective role on this page. */
scopedPolicies: ScopedPolicy[]
/** Scope managed by this tab — its direct roles are assignable/removable. */
managedScope: AccessScope
/** Modal for assigning/editing a role on the managed policy. */
EditModal: ComponentType<EditRoleModalProps>
/** Update the managed policy. Called when removing a role. */
updateManagedPolicy: (newPolicy: Policy) => Promise<unknown>
}

export function AccessGroupsTab({
scopedPolicies,
managedScope,
EditModal,
updateManagedPolicy,
}: Props) {
export function AccessGroupsTab() {
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null)
const [editingGroup, setEditingGroup] = useState<EditingState | null>(null)

Expand All @@ -87,31 +70,31 @@ export function AccessGroupsTab({
[groups]
)

// non-null: caller is responsible for including the managed scope
const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy
const { data: siloPolicy } = usePrefetchedQuery(policyView)
const roleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy])

const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy])
const canEdit = useCanEditSiloPolicy(siloPolicy)

const canEdit = useCanEditPolicy(scopedPolicies, managedScope)
const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('policyView')
addToast({ content: 'Role removed' })
},
})

const roleCol = useMemo(
() =>
colHelper.display({
id: 'role',
header: 'Role',
cell: ({ row }) => {
// groups never inherit, so passing no groups yields direct roles only
const entries = userScopedRoleEntries(row.original.id, [], scopedPolicies)
const effective = effectiveScopedRole(entries)
if (!effective) return <EmptyCell />
return (
<Badge color={roleColor[effective.role]}>
{effective.scope}.{effective.role}
</Badge>
)
// groups never inherit, so their only silo role is a direct one
const role = roleById.get(row.original.id)
if (!role) return <EmptyCell />
return <Badge color={roleColor[role]}>silo.{role}</Badge>
},
}),
[scopedPolicies]
[roleById]
)

const staticColumns = useMemo(
Expand All @@ -137,29 +120,18 @@ export function AccessGroupsTab({

const makeActions = useCallback(
(group: Group): MenuAction[] => {
const directManagedRole = managedRoleById.get(group.id)
const entries = userScopedRoleEntries(group.id, [], scopedPolicies)
const effective = effectiveScopedRole(entries)
const directRole = roleById.get(group.id)
return buildRoleActions({
name: group.displayName,
managedScope,
directManagedRole,
effective,
inheritedReason: 'Role is inherited from another scope; modify it there to revoke',
directRole,
effectiveRole: directRole,
canEdit,
isSelf: false, // a group is never the current user
openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }),
doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)),
doRemove: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }),
})
},
[
managedRoleById,
managedPolicy,
updateManagedPolicy,
scopedPolicies,
managedScope,
canEdit,
]
[roleById, siloPolicy, updatePolicy, canEdit]
)

const columns = useColsWithActions(staticColumns, makeActions)
Expand All @@ -175,9 +147,9 @@ export function AccessGroupsTab({
<>
{sortedGroups.length === 0 ? <GroupEmptyState /> : <Table table={table} />}
{editingGroup && (
<EditModal
<SiloAccessEditUserSideModal
onDismiss={() => setEditingGroup(null)}
policy={managedPolicy}
policy={siloPolicy}
name={editingGroup.group.displayName}
identityId={editingGroup.group.id}
identityType="silo_group"
Expand All @@ -188,7 +160,7 @@ export function AccessGroupsTab({
<GroupMembersSideModal
group={selectedGroup}
onDismiss={() => setSelectedGroup(null)}
scopedPolicies={scopedPolicies}
scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]}
/>
)}
</>
Expand Down
Loading
Loading