minio-admin.ts250 lines · main
| 1 | import { randomBytes } from 'node:crypto'; |
| 2 | import { mkdir, unlink, writeFile } from 'node:fs/promises'; |
| 3 | import { tmpdir } from 'node:os'; |
| 4 | import { join } from 'node:path'; |
| 5 | |
| 6 | import { env } from '../env.js'; |
| 7 | import { log } from '../lib/logger.js'; |
| 8 | |
| 9 | /** |
| 10 | * MinIO admin helper — bucket + SCOPED service-account lifecycle for per-project |
| 11 | * storage. Shells out to the official `mc` client (bundled into the api image), |
| 12 | * which correctly handles MinIO's admin-API encryption we would otherwise have |
| 13 | * to hand-roll. All calls use ARG-ARRAY spawn (no shell) so project data can't |
| 14 | * inject a command. |
| 15 | * |
| 16 | * Auth: a temporary `MC_HOST_<alias>` env var carries the ROOT credentials to the |
| 17 | * INTERNAL endpoint (http://minio:9000) — never written to disk, injected per |
| 18 | * call. mc's config dir is redirected to a writable /tmp path. |
| 19 | * |
| 20 | * The scoped key is load-bearing security: because the parent (root `briven`) |
| 21 | * has full admin, a service account created WITHOUT the inline policy silently |
| 22 | * becomes a master key over every bucket. The two-statement single-bucket policy |
| 23 | * below is what confines each customer key to its own bucket. (Verified against |
| 24 | * MinIO's canonical policy-based-access-control docs.) |
| 25 | */ |
| 26 | |
| 27 | const ALIAS = 'briven'; |
| 28 | const MC_CONFIG_DIR = join(tmpdir(), '.mc-briven'); |
| 29 | |
| 30 | export function isMinioAdminConfigured(): boolean { |
| 31 | return Boolean( |
| 32 | env.BRIVEN_MINIO_ENDPOINT && env.BRIVEN_MINIO_ACCESS_KEY && env.BRIVEN_MINIO_SECRET_KEY, |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | /** DNS-safe bucket name per project (mirrors dbNameFor's intent; hyphens, not underscores). */ |
| 37 | export function bucketNameFor(projectId: string): string { |
| 38 | const safe = projectId.toLowerCase().replace(/[^a-z0-9]/g, ''); |
| 39 | return `proj-${safe}`.slice(0, 63); |
| 40 | } |
| 41 | |
| 42 | function mcHostEnv(): Record<string, string> { |
| 43 | const endpoint = env.BRIVEN_MINIO_ENDPOINT; |
| 44 | const access = env.BRIVEN_MINIO_ACCESS_KEY; |
| 45 | const secret = env.BRIVEN_MINIO_SECRET_KEY; |
| 46 | if (!endpoint || !access || !secret) { |
| 47 | throw new Error('minio admin not configured (BRIVEN_MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY)'); |
| 48 | } |
| 49 | const u = new URL(endpoint); |
| 50 | // MC_HOST_<alias> = scheme://access:secret@host — creds URL-encoded per mc docs. |
| 51 | const hostSpec = `${u.protocol}//${encodeURIComponent(access)}:${encodeURIComponent(secret)}@${u.host}`; |
| 52 | return { |
| 53 | ...(process.env as Record<string, string>), |
| 54 | [`MC_HOST_${ALIAS}`]: hostSpec, |
| 55 | MC_CONFIG_DIR, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | async function runMc(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { |
| 60 | await mkdir(MC_CONFIG_DIR, { recursive: true }).catch(() => {}); |
| 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 62 | const proc = (globalThis as any).Bun.spawn(['mc', '--json', ...args], { |
| 63 | env: mcHostEnv(), |
| 64 | stdout: 'pipe', |
| 65 | stderr: 'pipe', |
| 66 | }); |
| 67 | const stdout = await new Response(proc.stdout).text(); |
| 68 | const stderr = await new Response(proc.stderr).text(); |
| 69 | const code: number = await proc.exited; |
| 70 | return { code, stdout, stderr }; |
| 71 | } |
| 72 | |
| 73 | /** Create the project's bucket if absent + turn on versioning. Idempotent. */ |
| 74 | export async function ensureBucket(bucket: string): Promise<void> { |
| 75 | const r = await runMc(['mb', '--ignore-existing', `${ALIAS}/${bucket}`]); |
| 76 | if (r.code !== 0) { |
| 77 | throw new Error(`minio create bucket failed: ${(r.stderr || r.stdout).slice(0, 300)}`); |
| 78 | } |
| 79 | // Versioning = the recovery safety net: a delete/overwrite keeps the old |
| 80 | // version so a file can be restored within the tier's recovery window. |
| 81 | // Idempotent; a failure just means recovery isn't active (bucket still works). |
| 82 | const v = await runMc(['version', 'enable', `${ALIAS}/${bucket}`]); |
| 83 | if (v.code !== 0) { |
| 84 | log.warn('minio_versioning_enable_failed', { |
| 85 | bucket, |
| 86 | err: (v.stderr || v.stdout).slice(0, 200), |
| 87 | }); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Expire OLD (noncurrent) versions after `days` — the tier's recovery window. |
| 93 | * Keeps deleted/overwritten copies restorable for `days`, then MinIO reclaims |
| 94 | * the space so recovery storage stays bounded (and priced into the tier cap). |
| 95 | */ |
| 96 | export async function setRecoveryLifecycle(bucket: string, days: number): Promise<void> { |
| 97 | const window = String(Math.max(1, Math.floor(days))); |
| 98 | const r = await runMc([ |
| 99 | 'ilm', |
| 100 | 'rule', |
| 101 | 'add', |
| 102 | `${ALIAS}/${bucket}`, |
| 103 | '--noncurrent-expire-days', |
| 104 | window, |
| 105 | ]); |
| 106 | if (r.code !== 0) { |
| 107 | log.warn('minio_lifecycle_set_failed', { |
| 108 | bucket, |
| 109 | days, |
| 110 | err: (r.stderr || r.stdout).slice(0, 200), |
| 111 | }); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Total bytes stored in a bucket, INCLUDING kept (noncurrent) versions so the |
| 117 | * recovery copies count toward quota. Falls back to current-only if the |
| 118 | * `--versions` flag isn't supported. Returns 0 if it can't be read. |
| 119 | */ |
| 120 | export async function bucketUsageBytes(bucket: string): Promise<number> { |
| 121 | let r = await runMc(['du', '--versions', `${ALIAS}/${bucket}`]); |
| 122 | if (r.code !== 0) r = await runMc(['du', `${ALIAS}/${bucket}`]); |
| 123 | if (r.code !== 0) return 0; |
| 124 | // mc --json du emits one JSON object per line; take the last size seen. |
| 125 | let bytes = 0; |
| 126 | for (const line of r.stdout.split('\n')) { |
| 127 | const t = line.trim(); |
| 128 | if (!t) continue; |
| 129 | try { |
| 130 | const o = JSON.parse(t) as { size?: number }; |
| 131 | if (typeof o.size === 'number') bytes = o.size; |
| 132 | } catch { |
| 133 | // skip non-JSON lines |
| 134 | } |
| 135 | } |
| 136 | return bytes; |
| 137 | } |
| 138 | |
| 139 | function singleBucketPolicy(bucket: string): string { |
| 140 | return JSON.stringify({ |
| 141 | Version: '2012-10-17', |
| 142 | Statement: [ |
| 143 | { |
| 144 | Effect: 'Allow', |
| 145 | Action: ['s3:ListBucket', 's3:GetBucketLocation', 's3:ListBucketMultipartUploads'], |
| 146 | Resource: [`arn:aws:s3:::${bucket}`], |
| 147 | }, |
| 148 | { |
| 149 | Effect: 'Allow', |
| 150 | Action: [ |
| 151 | 's3:GetObject', |
| 152 | 's3:PutObject', |
| 153 | 's3:DeleteObject', |
| 154 | 's3:AbortMultipartUpload', |
| 155 | 's3:ListMultipartUploadParts', |
| 156 | ], |
| 157 | Resource: [`arn:aws:s3:::${bucket}/*`], |
| 158 | }, |
| 159 | ], |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | export interface ScopedKey { |
| 164 | accessKey: string; |
| 165 | secretKey: string; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Mint a service-account key scoped to ONE bucket. We generate the access/secret |
| 170 | * ourselves (so no fragile JSON-lines parsing of mc output) and pin the inline |
| 171 | * policy via a temp FILE (mc's --policy takes a path, not a string). |
| 172 | */ |
| 173 | export async function createScopedKey(input: { bucket: string; name: string }): Promise<ScopedKey> { |
| 174 | const accessKey = `brvn${randomBytes(8).toString('hex')}`; // 20 chars, alphanumeric |
| 175 | const secretKey = randomBytes(30).toString('base64url'); // ~40 chars |
| 176 | const policyPath = join(MC_CONFIG_DIR, `pol-${randomBytes(6).toString('hex')}.json`); |
| 177 | await mkdir(MC_CONFIG_DIR, { recursive: true }).catch(() => {}); |
| 178 | await writeFile(policyPath, singleBucketPolicy(input.bucket), 'utf8'); |
| 179 | try { |
| 180 | const r = await runMc([ |
| 181 | 'admin', |
| 182 | 'user', |
| 183 | 'svcacct', |
| 184 | 'add', |
| 185 | ALIAS, |
| 186 | env.BRIVEN_MINIO_ACCESS_KEY ?? ALIAS, |
| 187 | '--access-key', |
| 188 | accessKey, |
| 189 | '--secret-key', |
| 190 | secretKey, |
| 191 | '--name', |
| 192 | input.name.slice(0, 32), // MinIO hard limit: service-account name <= 32 chars |
| 193 | '--policy', |
| 194 | policyPath, |
| 195 | ]); |
| 196 | if (r.code !== 0) { |
| 197 | throw new Error(`minio svcacct add failed: ${(r.stderr || r.stdout).slice(0, 300)}`); |
| 198 | } |
| 199 | } finally { |
| 200 | await unlink(policyPath).catch(() => {}); |
| 201 | } |
| 202 | log.info('minio_scoped_key_created', { bucket: input.bucket, accessKey }); |
| 203 | return { accessKey, secretKey }; |
| 204 | } |
| 205 | |
| 206 | export type RestoreResult = 'restored' | 'nothing-to-restore' | 'expired' | 'error'; |
| 207 | |
| 208 | /** |
| 209 | * "Undelete" an object in a versioned bucket by removing its latest delete |
| 210 | * marker, exposing the prior (non-current) version as current again. |
| 211 | * - 'restored' — delete marker removed, prior version live again |
| 212 | * - 'nothing-to-restore' — object isn't delete-marked (already live / absent) |
| 213 | * - 'expired' — only a delete marker survives; the data version was |
| 214 | * already reclaimed by the recovery lifecycle |
| 215 | * - 'error' — the mc call failed |
| 216 | */ |
| 217 | export async function restoreObject(bucket: string, key: string): Promise<RestoreResult> { |
| 218 | const r = await runMc(['ls', '--versions', `${ALIAS}/${bucket}/${key}`]); |
| 219 | if (r.code !== 0) return 'error'; |
| 220 | let deleteMarkerId: string | null = null; |
| 221 | let hasDataVersion = false; |
| 222 | for (const line of r.stdout.split('\n')) { |
| 223 | const t = line.trim(); |
| 224 | if (!t) continue; |
| 225 | try { |
| 226 | const o = JSON.parse(t) as { versionId?: string; isDeleteMarker?: boolean; isLatest?: boolean }; |
| 227 | if (o.isDeleteMarker) { |
| 228 | if (o.isLatest && o.versionId) deleteMarkerId = o.versionId; |
| 229 | } else if (o.versionId) { |
| 230 | hasDataVersion = true; |
| 231 | } |
| 232 | } catch { |
| 233 | // skip non-JSON lines |
| 234 | } |
| 235 | } |
| 236 | if (!deleteMarkerId) return 'nothing-to-restore'; |
| 237 | if (!hasDataVersion) return 'expired'; |
| 238 | const rm = await runMc(['rm', '--version-id', deleteMarkerId, `${ALIAS}/${bucket}/${key}`]); |
| 239 | return rm.code === 0 ? 'restored' : 'error'; |
| 240 | } |
| 241 | |
| 242 | /** Delete a scoped key (idempotent — a missing key is a no-op). */ |
| 243 | export async function removeScopedKey(accessKey: string): Promise<void> { |
| 244 | const r = await runMc(['admin', 'user', 'svcacct', 'rm', ALIAS, accessKey]); |
| 245 | const out = `${r.stderr}${r.stdout}`; |
| 246 | if (r.code !== 0 && !/does not exist|not found|no such/i.test(out)) { |
| 247 | throw new Error(`minio svcacct rm failed: ${out.slice(0, 300)}`); |
| 248 | } |
| 249 | log.info('minio_scoped_key_removed', { accessKey }); |
| 250 | } |