JitDbAccess.utils.ts 9.9 KB

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