JitDbAccess.utils.ts334 lines · main
1import dayjs from 'dayjs'
2import { IPv4CidrRange, IPv6CidrRange } from 'ip-num'
3
4import type {
5 JitExpiryMode,
6 JitIpRangeDraft,
7 JitMemberOption,
8 JitRoleGrantDraft,
9 JitRoleOption,
10 JitStatus,
11 JitStatusBadge,
12 JitUserRule,
13 JitUserRuleDraft,
14} from './JitDbAccess.types'
15import { type DatabaseRolesData, type PgRole } from '@/data/database-roles/database-roles-query'
16import type { JitDbAccessMembersData } from '@/data/jit-db-access/jit-db-access-members-query'
17import type { OrganizationMembersData } from '@/data/organizations/organization-members-query'
18import type { ProjectMembersData } from '@/data/projects/project-members-query'
19
20export function getRelativeDatetimeByMode(mode: JitExpiryMode) {
21 if (mode === '1h') return dayjs().add(1, 'hour').toISOString()
22 if (mode === '1d') return dayjs().add(1, 'day').toISOString()
23 if (mode === '7d') return dayjs().add(7, 'day').toISOString()
24 if (mode === '30d') return dayjs().add(30, 'day').toISOString()
25 return ''
26}
27
28function inferExpiryMode(grant: Pick<JitRoleGrantDraft, 'hasExpiry'>): JitExpiryMode {
29 if (!grant.hasExpiry) return 'never'
30 return 'custom'
31}
32
33export function createEmptyGrant(roleId: string): JitRoleGrantDraft {
34 return {
35 roleId,
36 enabled: false,
37 branchesOnly: false,
38 expiryMode: '1h',
39 hasExpiry: true,
40 expiry: getRelativeDatetimeByMode('1h'),
41 ipRanges: [createEmptyIpRange()],
42 }
43}
44
45export function createEmptyIpRange(): JitIpRangeDraft {
46 return { value: '' }
47}
48
49function parseIpRangeRows(value: JitIpRangeDraft[]) {
50 return value.map((item) => item.value.trim()).filter((item) => item.length > 0)
51}
52
53function cloneIpRanges(ipRanges: JitIpRangeDraft[]) {
54 return ipRanges.map((ipRange) => ({ ...ipRange }))
55}
56
57function cloneGrants(grants: JitRoleGrantDraft[]) {
58 return grants.map((grant) => ({ ...grant, ipRanges: cloneIpRanges(grant.ipRanges) }))
59}
60
61export function createDraft(roleIds: string[]): JitUserRuleDraft {
62 return { memberId: '', grants: roleIds.map((roleId) => createEmptyGrant(roleId)) }
63}
64
65function mergeRoleIds(baseRoleIds: string[], extraRoleIds: string[]) {
66 const seen = new Set<string>()
67 const merged: string[] = []
68
69 for (const roleId of [...baseRoleIds, ...extraRoleIds]) {
70 if (seen.has(roleId)) continue
71 seen.add(roleId)
72 merged.push(roleId)
73 }
74
75 return merged
76}
77
78export function draftFromRule(rule: JitUserRule, baseRoleIds: string[]): JitUserRuleDraft {
79 const byRoleId = new Map(rule.grants.map((grant) => [grant.roleId, grant]))
80 const mergedRoleIds = mergeRoleIds(
81 baseRoleIds,
82 rule.grants.map((grant) => grant.roleId)
83 )
84
85 return {
86 memberId: rule.memberId,
87 grants: mergedRoleIds.map((roleId) => {
88 const nextGrant = {
89 ...createEmptyGrant(roleId),
90 ...(byRoleId.get(roleId) ?? {}),
91 }
92
93 return {
94 ...nextGrant,
95 expiryMode: inferExpiryMode(nextGrant),
96 ipRanges: cloneIpRanges(nextGrant.ipRanges),
97 }
98 }),
99 }
100}
101
102export function computeStatusFromGrants(grants: JitRoleGrantDraft[]): JitStatus {
103 const enabledGrants = grants.filter((grant) => grant.enabled)
104
105 let active = 0
106 let expired = 0
107 let activeIp = 0
108 let expiredIp = 0
109
110 enabledGrants.forEach((grant) => {
111 const hasIp = parseIpRangeRows(grant.ipRanges).length > 0
112
113 if (!grant.hasExpiry || !grant.expiry) {
114 active += 1
115 if (hasIp) activeIp += 1
116 return
117 }
118
119 const isExpired = dayjs(grant.expiry).isValid() && dayjs(grant.expiry).isBefore(dayjs())
120
121 if (isExpired) {
122 expired += 1
123 if (hasIp) expiredIp += 1
124 return
125 }
126
127 active += 1
128 if (hasIp) activeIp += 1
129 })
130
131 return { active, expired, activeIp, expiredIp }
132}
133
134function formatBadgeLabel(raw: string, showCount: boolean): string {
135 if (showCount) return raw
136 // If only one in count:
137 // Strip leading "N " and "· N " count segments, then capitalize first letter
138 return raw
139 .replace(/^\d+\s/, '')
140 .replace(/·\s*\d+\s/g, '· ')
141 .replace(/^./, (c) => c.toUpperCase())
142}
143
144export function getJitStatusDisplay(status: JitStatus): { badges: JitStatusBadge[] } {
145 const { active, expired, activeIp } = status
146 const badges: JitStatusBadge[] = []
147 const showCount = (active > 0 ? 1 : 0) + (expired > 0 ? 1 : 0) > 1
148
149 if (active > 0) {
150 const raw = activeIp > 0 ? `${active} active · ${activeIp} IP` : `${active} active`
151 badges.push({ label: formatBadgeLabel(raw, showCount), variant: 'success' })
152 }
153
154 if (expired > 0) {
155 const raw = `${expired} expired`
156 badges.push({ label: formatBadgeLabel(raw, showCount), variant: 'default' })
157 }
158
159 return { badges }
160}
161
162function toUnixSeconds(datetimeIso: string) {
163 const value = dayjs(datetimeIso)
164 if (!value.isValid()) return undefined
165 return value.unix()
166}
167
168function isValidCidr(value: string) {
169 try {
170 if (value.includes(':')) {
171 IPv6CidrRange.fromCidr(value)
172 return true
173 }
174
175 IPv4CidrRange.fromCidr(value)
176 return true
177 } catch {
178 return false
179 }
180}
181
182export function getInvalidIpRangeRows(value: JitIpRangeDraft[]) {
183 return parseIpRangeRows(value).filter((cidr) => !isValidCidr(cidr))
184}
185
186function isAssignableJitRole(role: PgRole) {
187 return (
188 role.canLogin &&
189 !role.isSuperuser &&
190 !role.name.startsWith('pg_') &&
191 (!role.name.startsWith('briven_') || role.name === 'briven_read_only_user') &&
192 !['pgbouncer', 'authenticator'].includes(role.name)
193 )
194}
195
196function serializeAllowedNetworks(roleObj: {
197 allowed_networks?: {
198 allowed_cidrs?: Array<{ cidr: string }>
199 allowed_cidrs_v6?: Array<{ cidr: string }>
200 }
201}) {
202 const cidrs = roleObj.allowed_networks?.allowed_cidrs?.map((item) => item.cidr) ?? []
203 const cidrsV6 = roleObj.allowed_networks?.allowed_cidrs_v6?.map((item) => item.cidr) ?? []
204 return [...cidrs, ...cidrsV6]
205}
206
207export function getAssignableJitRoleOptions(
208 databaseRoles?: DatabaseRolesData | null
209): JitRoleOption[] {
210 return (
211 databaseRoles
212 ?.filter(isAssignableJitRole)
213 .map((role) => ({ id: role.name, label: role.name }))
214 .sort((a, b) => a.label.localeCompare(b.label)) ?? []
215 )
216}
217
218export function getJitMemberOptions(
219 organizationMembers?: OrganizationMembersData | null,
220 projectMembers?: ProjectMembersData | null
221): JitMemberOption[] {
222 const byId = new Map<string, JitMemberOption>()
223
224 for (const member of organizationMembers ?? []) {
225 // JIT rules should only target accepted org members (invites can be expired/pending).
226 if (!member.gotrue_id) continue
227
228 const id = member.gotrue_id ?? member.primary_email
229 if (!id) continue
230
231 byId.set(id, {
232 id,
233 email: member.primary_email ?? id,
234 name: member.username || undefined,
235 })
236 }
237
238 for (const member of projectMembers ?? []) {
239 const id = member.user_id ?? member.primary_email
240 if (!id) continue
241
242 byId.set(id, {
243 id,
244 email: member.primary_email ?? byId.get(id)?.email ?? id,
245 name: member.username ?? byId.get(id)?.name,
246 })
247 }
248
249 return Array.from(byId.values()).sort((a, b) => a.email.localeCompare(b.email))
250}
251
252export function mapJitMembersToUserRules(
253 jitMembers: JitDbAccessMembersData | undefined,
254 projectMembers: ProjectMembersData | undefined,
255 roleOptions: JitRoleOption[]
256): JitUserRule[] {
257 const memberMap = new Map((projectMembers ?? []).map((member) => [member.user_id, member]))
258 const baseRoleIds = roleOptions.map((role) => role.id)
259
260 return (jitMembers ?? []).map((item) => {
261 const mappedMember = memberMap.get(item.user_id)
262 const assignedRoles: JitRoleGrantDraft[] = (item.user_roles ?? []).map((roleObj) => {
263 const roleWithBranchRestriction = roleObj as typeof roleObj & { branches_only?: boolean }
264 const expiresAt = typeof roleObj.expires_at === 'number' ? roleObj.expires_at : undefined
265 const hasExpiry = typeof expiresAt === 'number'
266 const allowedNetworks = serializeAllowedNetworks(roleObj)
267
268 return {
269 ...createEmptyGrant(roleObj.role),
270 roleId: roleObj.role,
271 enabled: true,
272 branchesOnly: roleWithBranchRestriction.branches_only ?? false,
273 hasExpiry,
274 expiryMode: hasExpiry ? 'custom' : 'never',
275 expiry: hasExpiry ? new Date(expiresAt * 1000).toISOString() : '',
276 ipRanges:
277 allowedNetworks.length > 0
278 ? allowedNetworks.map((cidr) => ({ value: cidr }))
279 : [createEmptyIpRange()],
280 }
281 })
282
283 const assignedByRoleId = new Map(assignedRoles.map((grant) => [grant.roleId, grant]))
284 const allRoleIds = mergeRoleIds(
285 baseRoleIds,
286 assignedRoles.map((grant) => grant.roleId)
287 )
288 const grants = allRoleIds.map((roleId) => ({
289 ...createEmptyGrant(roleId),
290 ...(assignedByRoleId.get(roleId) ?? {}),
291 roleId,
292 }))
293
294 const email = mappedMember?.primary_email ?? item.user_id
295 const name = mappedMember?.username ?? undefined
296
297 return {
298 id: item.user_id,
299 memberId: item.user_id,
300 email,
301 name,
302 grants: cloneGrants(grants),
303 status: computeStatusFromGrants(grants),
304 }
305 })
306}
307
308export function serializeDraftRolesForGrantMutation(draft: JitUserRuleDraft) {
309 const serializeAllowedNetworks = (value: JitIpRangeDraft[]) => {
310 const cidrs = parseIpRangeRows(value)
311 if (cidrs.length === 0) return undefined
312
313 const allowed_cidrs = cidrs.filter((cidr) => !cidr.includes(':')).map((cidr) => ({ cidr }))
314 const allowed_cidrs_v6 = cidrs.filter((cidr) => cidr.includes(':')).map((cidr) => ({ cidr }))
315
316 return {
317 ...(allowed_cidrs.length > 0 ? { allowed_cidrs } : {}),
318 ...(allowed_cidrs_v6.length > 0 ? { allowed_cidrs_v6 } : {}),
319 }
320 }
321
322 return draft.grants
323 .filter((grant) => grant.enabled)
324 .map((grant) => {
325 const expires_at = grant.hasExpiry ? toUnixSeconds(grant.expiry) : undefined
326 const allowed_networks = serializeAllowedNetworks(grant.ipRanges)
327 return {
328 role: grant.roleId,
329 ...(grant.branchesOnly ? { branches_only: true } : {}),
330 ...(typeof expires_at === 'number' ? { expires_at } : {}),
331 ...(allowed_networks ? { allowed_networks } : {}),
332 }
333 })
334}