Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
c59c55a
Add access tabs showing all users and groups
charliepark Mar 5, 2026
2df92c6
Add role and member count to users and groups tabs
charliepark Mar 5, 2026
74708fc
add docs link, better microcopy
charliepark Mar 5, 2026
f0dfa91
Roles tab is redundant, with Users / Groups present
charliepark Mar 5, 2026
f8785c8
Add tooltip to show source of role when it comes from group
charliepark Mar 5, 2026
1a6461c
Update tests
charliepark Mar 6, 2026
2b61971
Updated styling on sidebars
charliepark Mar 6, 2026
b959e59
Use consistent subtitle style for group sidebars
charliepark Mar 6, 2026
ff357a6
Merge branch 'main' into full_user_group_lists
charliepark Mar 7, 2026
7f05526
Add time_created column
charliepark Mar 7, 2026
7d966e2
stub out user sidebars for more info, tabs
charliepark Mar 7, 2026
c18f2d7
Merge main and resolve conflict
charliepark Mar 11, 2026
c83f6ea
Add additional data to User and Group sidebars
charliepark Mar 11, 2026
d40e4c0
Updates to sidebar and roles table
charliepark Mar 12, 2026
35c79bd
Refactor / consolidate
charliepark Mar 12, 2026
7de7785
More refactoring, plus remeda
charliepark Mar 12, 2026
f0cadbf
Add button to copy IDs of Users, Groups, though will try to set up li…
charliepark Mar 12, 2026
997b892
Merge branch 'main' into full_user_group_lists
charliepark Mar 16, 2026
f663d9d
Add Users & Groups nav
charliepark Mar 16, 2026
e852345
Refactor
charliepark Mar 16, 2026
8ca63fd
Fix broken access form
charliepark Mar 16, 2026
6423d4b
merge main and resolve conflicts
charliepark Mar 18, 2026
e6304ed
merge main and resolve conflicts (again)
charliepark Mar 18, 2026
7ccc0fc
Remove Users & Groups from Project page / nav
charliepark Mar 19, 2026
9b0710d
Don't show all users on access pages; only assigned, inherited from s…
charliepark Mar 19, 2026
a14f499
Merge branch 'main' into full_user_group_lists
charliepark Mar 19, 2026
ae4f2c3
Merge branch 'main' into full_user_group_lists
charliepark Mar 23, 2026
5fa4800
Merge main and resolve conflicts
charliepark Apr 24, 2026
e16bf02
Merge branch 'main' into full_user_group_lists
charliepark Apr 30, 2026
7b881e7
Patch up several small issues
charliepark Apr 30, 2026
bdd4a75
Add e2e tests
charliepark Apr 30, 2026
0db4102
Consolidate Users and Groups into Silo Access page
charliepark May 4, 2026
7196089
Align Project Access page design with Silo Access; put Groups as firs…
charliepark May 5, 2026
fecb287
Merge main and resolve conflicts
charliepark May 5, 2026
a7815db
Merge branch 'main' into full_user_group_lists_consolidated
charliepark Jun 26, 2026
20066c2
Fetch all-ish and sort client side
charliepark Jun 30, 2026
bfb58d2
test revisions
charliepark Jun 30, 2026
c3a742f
Merge branch 'main' into full_user_group_lists_consolidated
charliepark Jul 2, 2026
1ad9c31
Refactor API calls; update tests; remove dead code
charliepark Jul 3, 2026
f46e988
More refactoring and shared code extracting
charliepark Jul 3, 2026
2fefc69
Users and Groups have own page on silo view
charliepark Jul 9, 2026
9561020
sort users and groups by display name in dropdown
charliepark Jul 10, 2026
b9dad64
Add sidebars to silo access page
charliepark Jul 10, 2026
54ada75
Fix a few issues with memoization, inherited roles, etc.
charliepark Jul 11, 2026
6122195
Merge branch 'main' into full_user_group_lists_consolidated
charliepark Jul 14, 2026
5cf57ed
Pull in logic and copy from PR 3263
charliepark Jul 14, 2026
97ede69
Add to Fleet roles page and refactor out duplicated code
charliepark Jul 15, 2026
3fd38e2
add note to AGENTS.md about top-level routes so we don't forget about it
david-crespo Jul 22, 2026
02f17df
Un-abstract users/groups tabs to silo-only (#3300)
david-crespo Jul 22, 2026
e52a6e1
delete unused effectiveScopedRole
david-crespo Jul 23, 2026
33f3cd9
AccessRolesTable: replace local roleIn with rolesByIdFromPolicy
david-crespo Jul 23, 2026
09fbc17
remove role disable tooltip copy tweak
david-crespo Jul 23, 2026
289c20d
Add modal to show all roles on hover on Users and Groups page
charliepark Jul 23, 2026
374ae04
drop ScopedPolicy[] in favor of explicit siloPolicy/projectPolicy args
david-crespo Jul 23, 2026
81ef513
simplify role actions
david-crespo Jul 23, 2026
58601cb
gray out disabled remove-role actions
david-crespo Jul 23, 2026
cc4f35f
Revert to remove +N affordance on Silo Access; use group icon
charliepark Jul 23, 2026
08a8adf
replace membership query version key with combine
david-crespo Jul 23, 2026
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
- Breadcrumbs come from route `handle.crumb`; use `makeCrumb`/`titleCrumb` and provide a `path` when the parent route redirects (`app/hooks/use-crumbs.ts:21-64`). Use `titleCrumb` for side modal forms that should appear in page title but not nav breadcrumbs (check `Crumb.titleOnly` flag).
- When adding tabs or redirects, wire the canonical link in the path builder (e.g., point to the default tab) and update the sidebar/quick actions as needed.
- For tabs synced with query params, use `QueryParamTabs` component which manages `?tab=` param and removes it when default tab is selected (`app/components/QueryParamTabs.tsx`).
- Adding a new _top-level_ path segment (e.g., `/users`) requires a matching console page endpoint in Nexus, or direct navigation and refresh at that URL will 404. Nexus can't use a catchall route (it would overlap API routes), so each top-level console prefix is registered as an `unpublished` endpoint serving the console index: definitions in [`nexus/external-api/src/lib.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/external-api/src/lib.rs#L8921-L8928), handlers in [`nexus/src/external_api/http_entrypoints.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/src/external_api/http_entrypoints.rs#L8765-L8769). Routes nested under existing prefixes (`/projects/*`, `/system/*`, `/settings/*`, `/lookup/*`) need no Nexus change.

# Forms

Expand Down
1 change: 0 additions & 1 deletion app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ it('mock-api is only referenced in test files', () => {
"test/e2e/ip-pool-silo-config.e2e.ts",
"test/e2e/profile.e2e.ts",
"test/e2e/project-access.e2e.ts",
"test/e2e/silo-access.e2e.ts",
"tsconfig.json",
]
`)
Expand Down
128 changes: 59 additions & 69 deletions app/api/roles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {
deleteRole,
getEffectiveRole,
roleOrder,
rolesByIdFromPolicy,
updateRole,
userRoleFromPolicies,
userScopedRoleEntries,
type Policy,
} from './roles'

Expand Down Expand Up @@ -75,87 +76,76 @@ describe('deleteRole', () => {
})
})

const user1 = {
id: 'user1',
}

const groups = [{ id: 'group1' }, { id: 'group2' }]
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' }

describe('getEffectiveRole', () => {
it('returns null when there are no policies', () => {
expect(userRoleFromPolicies(user1, groups, [])).toBe(null)
})
expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e])
})

it('returns null when there are no roles', () => {
expect(userRoleFromPolicies(user1, groups, [{ roleAssignments: [] }])).toBe(null)
describe('rolesByIdFromPolicy', () => {
it('maps each identity to its role', () => {
expect(rolesByIdFromPolicy(abcAdminPolicy)).toEqual(new Map([['abc', 'admin']]))
})

it('returns role if user matches directly', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'admin' },
],
},
])
).toEqual('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']]))
})
})

it('returns strongest role if both group and user match', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' },
],
},
])
).toEqual('collaborator')
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
const policy: Policy = {
roleAssignments: [
{ identityId: 'u', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' },
],
}
expect(userScopedRoleEntries('u', [], policy)).toEqual([
{ roleName: 'admin', scope: 'silo', source: { type: 'direct' } },
])
})

it('ignores groups and users that do not match', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'other', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' },
],
},
])
).toEqual(null)
it('emits one entry per direct assignment and per group, tagged by scope', () => {
const group = { id: 'g', displayName: 'g' }
const silo: Policy = {
roleAssignments: [
{ identityId: 'g', identityType: 'silo_group', roleName: 'viewer' },
],
}
const project: Policy = {
roleAssignments: [{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' }],
}
expect(userScopedRoleEntries('u', [group], silo, project)).toEqual([
{ roleName: 'viewer', scope: 'silo', source: { type: 'group', group } },
{ roleName: 'admin', scope: 'project', source: { type: 'direct' } },
])
})

it('resolves multiple policies', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' },
],
},
{
roleAssignments: [
{ identityId: 'group1', identityType: 'silo_group', roleName: 'admin' },
],
},
])
).toEqual('admin')
it('keeps a separate entry per group even when the role is identical', () => {
const groupA = { id: 'a', displayName: 'a' }
const groupB = { id: 'b', displayName: 'b' }
const silo: Policy = {
roleAssignments: [
{ identityId: 'a', identityType: 'silo_group', roleName: 'collaborator' },
{ identityId: 'b', identityType: 'silo_group', roleName: 'collaborator' },
],
}
// same role via two groups must not collapse — each source is shown separately
expect(userScopedRoleEntries('u', [groupA, groupB], silo)).toEqual([
{ roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupA } },
{ roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupB } },
])
})
})

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])
})

test('allRoles', () => {
expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer'])
})
183 changes: 118 additions & 65 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@
* layer and not in app/ because we are experimenting with it to decide whether
* it belongs in the API proper.
*/
import { useQueries } from '@tanstack/react-query'
import { useMemo } from 'react'
import * as R from 'remeda'

import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api'
import { ALL_ISH } from '~/util/consts'

import type {
FleetRole,
Group,
IdentityType,
ProjectRole,
SiloRole,
} from './__generated__/Api'
import { api, q, usePrefetchedQuery } from './client'

/**
Expand Down Expand Up @@ -76,6 +85,22 @@ export function updateRole<Role extends RoleKey>(
return { roleAssignments }
}

/**
* 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> {
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
}

/**
* Delete any role assignments for user or group ID. Returns a new updated
* policy. Does not modify the passed-in policy.
Expand All @@ -90,47 +115,6 @@ export function deleteRole<Role extends RoleKey>(
return { roleAssignments }
}

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

/**
* Role assignments come from the API in (user, role) pairs without display
* names and without info about which resource the role came from. This tags
* each row with that info. It has to be a hook because it depends on the result
* 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<Role extends RoleKey = RoleKey>(
roleAssignments: RoleAssignment<Role>[],
roleSource: string
): 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, {}))
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
return useMemo(() => {
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,
// A user might not appear here if they are not in the current user's
// silo. This could happen in a fleet policy, which might have users from
// different silos. Hence the ID fallback. The code that displays this
// detects when we've fallen back and includes an explanatory tooltip.
name: usersDict[ra.identityId]?.displayName || ra.identityId,
roleName: ra.roleName,
roleSource,
}))
}, [roleAssignments, roleSource, users, groups])
}

type SortableUserRow = { identityType: IdentityType; name: string }

/**
Expand All @@ -156,33 +140,102 @@ export type 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, {}))
const { data: users } = usePrefetchedQuery(q(api.userList, { query: { limit: ALL_ISH } }))
const { data: groups } = usePrefetchedQuery(
q(api.groupList, { query: { limit: ALL_ISH } })
)
return useMemo(() => {
// IDs are UUIDs, so no need to include identity type in set value to disambiguate
const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || [])
const allGroups = groups.items.map((g) => ({
...g,
identityType: 'silo_group' as IdentityType,
}))
const allUsers = users.items.map((u) => ({
...u,
identityType: 'silo_user' as IdentityType,
}))
// groups go before users
return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || []
// groups first, then users; each sorted alphabetically by display name
const allGroups = R.sortBy(
groups.items.map((g) => ({ ...g, identityType: 'silo_group' as IdentityType })),
(g) => g.displayName.toLowerCase()
)
const allUsers = R.sortBy(
users.items.map((u) => ({ ...u, identityType: 'silo_user' as IdentityType })),
(u) => u.displayName.toLowerCase()
)
return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id))
}, [users, groups, policy])
}

export function userRoleFromPolicies(
user: { id: string },
groups: { id: string }[],
policies: Policy[]
): RoleKey | null {
const myIds = new Set([user.id, ...groups.map((g) => g.id)])
const myRoles = policies
.flatMap((p) => p.roleAssignments) // concat all the role assignments together
.filter((ra) => myIds.has(ra.identityId))
.map((ra) => ra.roleName)
return getEffectiveRole(myRoles) || null
export type AccessScope = 'silo' | 'project'

export type ScopedRoleEntry = {
roleName: RoleKey
scope: AccessScope
source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } }
}

/**
* Enumerate all role assignments relevant to a user — one entry per direct
* assignment and one per group assignment — across the given policies. Each
* entry is tagged with the scope of the policy it came from. Since the API
* permits multiple assignments for the same identity in one policy, each entry
* collapses those to the strongest role (see `getEffectiveRole`).
* Callers are responsible for sorting and any display-layer merging.
*/
export function userScopedRoleEntries(
userId: string,
userGroups: { id: string; displayName: string }[],
siloPolicy: Policy,
projectPolicy?: Policy
): ScopedRoleEntry[] {
const entries = policyRoleEntries('silo', siloPolicy, userId, userGroups)
if (projectPolicy) {
entries.push(...policyRoleEntries('project', projectPolicy, userId, userGroups))
}
return entries
}

function policyRoleEntries(
scope: AccessScope,
policy: Policy,
userId: string,
userGroups: { id: string; displayName: string }[]
): ScopedRoleEntry[] {
const roleById = rolesByIdFromPolicy(policy)
const entries: ScopedRoleEntry[] = []
const direct = roleById.get(userId)
if (direct) {
entries.push({ roleName: direct, scope, source: { type: 'direct' } })
}
for (const group of userGroups) {
const via = roleById.get(group.id)
if (via) {
entries.push({ roleName: via, scope, source: { type: 'group', group } })
}
}
return entries
}

/** Sort role entries strongest first (see `roleOrder`), so the effective role leads. */
export const sortRoleEntries = (entries: ScopedRoleEntry[]) =>
R.sortBy(entries, (e) => roleOrder[e.roleName])

/**
* Builds a map from user ID to the list of groups that user belongs to,
* firing one query per group to fetch members. Shared between user tabs.
*/
export function useGroupsByUserId(groups: Group[]): Map<string, Group[]> {
const groupMemberPages = useQueries({
queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })),
// useQueries returns a new array each render; combine structurally shares
// the selected data so the Map only changes with groups or memberships
combine: (results) => results.map((result) => result.data),
})

return useMemo(() => {
const map = new Map<string, Group[]>()
groups.forEach((group, i) => {
const members = groupMemberPages[i]?.items ?? []
members.forEach((member) => {
const existing = map.get(member.id)
if (existing) existing.push(group)
else map.set(member.id, [group])
})
})
return map
}, [groups, groupMemberPages])
}
Loading
Loading