minio-admin.ts250 lines · main
1import { randomBytes } from 'node:crypto';
2import { mkdir, unlink, writeFile } from 'node:fs/promises';
3import { tmpdir } from 'node:os';
4import { join } from 'node:path';
5
6import { env } from '../env.js';
7import { 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
27const ALIAS = 'briven';
28const MC_CONFIG_DIR = join(tmpdir(), '.mc-briven');
29
30export 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). */
37export function bucketNameFor(projectId: string): string {
38 const safe = projectId.toLowerCase().replace(/[^a-z0-9]/g, '');
39 return `proj-${safe}`.slice(0, 63);
40}
41
42function 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
59async 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. */
74export 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 */
96export 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 */
120export 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
139function 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
163export 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 */
173export 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
206export 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 */
217export 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). */
243export 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}