JitDbAccess.utils.ts334 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { IPv4CidrRange, IPv6CidrRange } from 'ip-num' |
| 3 | |
| 4 | import type { |
| 5 | JitExpiryMode, |
| 6 | JitIpRangeDraft, |
| 7 | JitMemberOption, |
| 8 | JitRoleGrantDraft, |
| 9 | JitRoleOption, |
| 10 | JitStatus, |
| 11 | JitStatusBadge, |
| 12 | JitUserRule, |
| 13 | JitUserRuleDraft, |
| 14 | } from './JitDbAccess.types' |
| 15 | import { type DatabaseRolesData, type PgRole } from '@/data/database-roles/database-roles-query' |
| 16 | import type { JitDbAccessMembersData } from '@/data/jit-db-access/jit-db-access-members-query' |
| 17 | import type { OrganizationMembersData } from '@/data/organizations/organization-members-query' |
| 18 | import type { ProjectMembersData } from '@/data/projects/project-members-query' |
| 19 | |
| 20 | export 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 | |
| 28 | function inferExpiryMode(grant: Pick<JitRoleGrantDraft, 'hasExpiry'>): JitExpiryMode { |
| 29 | if (!grant.hasExpiry) return 'never' |
| 30 | return 'custom' |
| 31 | } |
| 32 | |
| 33 | export 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 | |
| 45 | export function createEmptyIpRange(): JitIpRangeDraft { |
| 46 | return { value: '' } |
| 47 | } |
| 48 | |
| 49 | function parseIpRangeRows(value: JitIpRangeDraft[]) { |
| 50 | return value.map((item) => item.value.trim()).filter((item) => item.length > 0) |
| 51 | } |
| 52 | |
| 53 | function cloneIpRanges(ipRanges: JitIpRangeDraft[]) { |
| 54 | return ipRanges.map((ipRange) => ({ ...ipRange })) |
| 55 | } |
| 56 | |
| 57 | function cloneGrants(grants: JitRoleGrantDraft[]) { |
| 58 | return grants.map((grant) => ({ ...grant, ipRanges: cloneIpRanges(grant.ipRanges) })) |
| 59 | } |
| 60 | |
| 61 | export function createDraft(roleIds: string[]): JitUserRuleDraft { |
| 62 | return { memberId: '', grants: roleIds.map((roleId) => createEmptyGrant(roleId)) } |
| 63 | } |
| 64 | |
| 65 | function 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 | |
| 78 | export 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 | |
| 102 | export 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 | |
| 134 | function 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 | |
| 144 | export 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 | |
| 162 | function toUnixSeconds(datetimeIso: string) { |
| 163 | const value = dayjs(datetimeIso) |
| 164 | if (!value.isValid()) return undefined |
| 165 | return value.unix() |
| 166 | } |
| 167 | |
| 168 | function 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 | |
| 182 | export function getInvalidIpRangeRows(value: JitIpRangeDraft[]) { |
| 183 | return parseIpRangeRows(value).filter((cidr) => !isValidCidr(cidr)) |
| 184 | } |
| 185 | |
| 186 | function 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 | |
| 196 | function 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 | |
| 207 | export 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 | |
| 218 | export 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 | |
| 252 | export 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 | |
| 308 | export 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 | } |