-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathroles.ts
More file actions
224 lines (198 loc) · 7.14 KB
/
roles.ts
File metadata and controls
224 lines (198 loc) · 7.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/*
* 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
*/
/*
* Utilities around resource roles and policies. This logic belongs in the data
* 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, useRef } from 'react'
import * as R from 'remeda'
import { ALL_ISH } from '~/util/consts'
import type {
FleetRole,
Group,
IdentityType,
ProjectRole,
SiloRole,
} from './__generated__/Api'
import { api, q, usePrefetchedQuery } from './client'
/**
* Union of all the specific roles, which used to all be the same until we added
* limited collaborator to silo.
*/
export type RoleKey = FleetRole | SiloRole | ProjectRole
/** Turn a role order record into a sorted array of strings. */
// used for displaying lists of roles, like in a <select>
const flatRoles = (roleOrder: Record<RoleKey, number>): RoleKey[] =>
R.sortBy(Object.keys(roleOrder) as RoleKey[], (role) => roleOrder[role])
// This is a record only to ensure that all RoleKey are covered. weird order
// on purpose so allRoles test can confirm sorting works
export const roleOrder: Record<RoleKey, number> = {
collaborator: 1,
admin: 0,
viewer: 3,
limited_collaborator: 2,
}
/** `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 = <Role extends RoleKey>(roles: Role[]): Role | undefined =>
R.firstBy(roles, (role) => roleOrder[role])
////////////////////////////
// Policy helpers
////////////////////////////
type RoleAssignment<Role extends RoleKey = RoleKey> = {
identityId: string
identityType: IdentityType
roleName: Role
}
export type Policy<Role extends RoleKey = RoleKey> = {
roleAssignments: RoleAssignment<Role>[]
}
/**
* Returns a new updated policy. Does not modify the passed-in 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
)
roleAssignments.push(newAssignment)
return { roleAssignments }
}
/** Map from identity ID to role name for quick lookup. */
export function rolesByIdFromPolicy<Role extends RoleKey>(
policy: Policy<Role>
): Map<string, Role> {
return new Map(policy.roleAssignments.map((a) => [a.identityId, a.roleName]))
}
/**
* Delete any role assignments for user or group ID. Returns a new updated
* policy. Does not modify the passed-in 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 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)
}
export type Actor = {
identityType: IdentityType
displayName: string
id: string
}
/**
* Fetch lists of users and groups, filtering out the ones that are already in
* the given policy.
*/
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(() => {
// 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)) || []
}, [users, groups, policy])
}
export function userRoleFromPolicies(
user: { id: string },
groups: { id: string }[],
policy: Policy
): RoleKey | null {
const myIds = new Set([user.id, ...groups.map((g) => g.id)])
const myRoles = policy.roleAssignments
.filter((ra) => myIds.has(ra.identityId))
.map((ra) => ra.roleName)
return getEffectiveRole(myRoles) || null
}
export type ScopedRoleEntry = {
roleName: RoleKey
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 — from the silo policy.
* Callers are responsible for sorting and any display-layer merging.
*/
export function userScopedRoleEntries(
userId: string,
userGroups: { id: string; displayName: string }[],
policy: Policy
): ScopedRoleEntry[] {
const entries: ScopedRoleEntry[] = []
const direct = policy.roleAssignments.find((ra) => ra.identityId === userId)
if (direct) entries.push({ roleName: direct.roleName, source: { type: 'direct' } })
for (const group of userGroups) {
const via = policy.roleAssignments.find((ra) => ra.identityId === group.id)
if (via) entries.push({ roleName: via.roleName, source: { type: 'group', group } })
}
return entries
}
/**
* 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 groupMemberQueries = useQueries({
queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })),
})
// Use refs to return a stable Map reference when the underlying data hasn't
// changed. Without this, a new Map on every render causes downstream useMemos
// to recompute continuously, which destabilizes table rows in Playwright.
const mapRef = useRef<Map<string, Group[]>>(new Map())
const versionRef = useRef<string>('')
const version = [
groups.map((g) => g.id).join(','),
...groupMemberQueries.map((q) => q.dataUpdatedAt),
].join('|')
if (version !== versionRef.current) {
versionRef.current = version
const map = new Map<string, Group[]>()
groups.forEach((group, i) => {
const members = groupMemberQueries[i]?.data?.items ?? []
members.forEach((member) => {
const existing = map.get(member.id)
if (existing) existing.push(group)
else map.set(member.id, [group])
})
})
mapRef.current = map
}
return mapRef.current
}