@better-forge/vault 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,422 +1,422 @@
1
- import type { FastifyInstance, FastifyRequest } from 'fastify'
2
- import { and, eq } from 'drizzle-orm'
3
- import type { AuditAction } from '@better-forge/contracts'
4
- import type { SqliteDatabase } from '@better-forge/db'
5
- import { vaults, vaultSecrets } from './tables.js'
6
- import { HashicorpVaultError, encodePath, parseConnection, publicConnection } from './hashicorp.js'
7
- import type { Hashicorp } from './hashicorp.js'
8
- import type { SecretResolver } from './resolveSecret.js'
9
- import type { SecretBox } from './secretBox.js'
10
-
11
- const VALID_SECRET_TYPES = ['userpass', 'value', 'json'] as const
12
-
13
- type SecretType = typeof VALID_SECRET_TYPES[number]
14
-
15
- interface SecretPayload {
16
- userpass?: { username: string; password: string }
17
- value?: string
18
- json?: string
19
- }
20
-
21
- const DUPLICATE_SECRET_ERROR = 'A secret with this name already exists in the vault'
22
-
23
- /** Drizzle wraps driver errors ("Failed query: …"); the SQLite constraint text lives on `cause`. */
24
- function isUniqueViolation(error: unknown): boolean {
25
- for (let current = error; current instanceof Error; current = current.cause) {
26
- if (current.message.includes('UNIQUE constraint failed')) return true
27
- }
28
- return false
29
- }
30
-
31
- interface HashicorpRefBody { path?: unknown; key?: unknown }
32
-
33
- export function createVaultRoutes(ctx: {
34
- db: SqliteDatabase
35
- secretBox: SecretBox
36
- audit: VaultAudit
37
- /** The signed-in caller of a request (the routes sit behind the application's own guards). */
38
- identity: (req: FastifyRequest) => { userId: number; email: string }
39
- }, parts: { hashicorp: Hashicorp; resolver: SecretResolver }) {
40
- const { db, audit } = ctx
41
- const { encrypt, decrypt } = ctx.secretBox
42
- const requestIdentity = ctx.identity
43
- const { loadSecretPayload } = parts.resolver
44
- const { forgetHashicorpVault, openConnection, readKvSecret, sealConnection, testConnection, testDraftConnection, verifyCredentialsRef, forgetCredentialDependents } = parts.hashicorp
45
-
46
- function serializePayload(type: SecretType, payload: SecretPayload): string {
47
- if (type === 'userpass') {
48
- if (!payload.userpass) throw new Error('userpass requires username and password')
49
- return JSON.stringify(payload.userpass)
50
- }
51
- if (type === 'value') {
52
- if (payload.value === undefined) throw new Error('value is required')
53
- return JSON.stringify({ value: payload.value })
54
- }
55
- if (type === 'json') {
56
- if (payload.json === undefined) throw new Error('json is required')
57
- // Validate it's actually JSON
58
- JSON.parse(payload.json)
59
- return JSON.stringify({ value: payload.json })
60
- }
61
- throw new Error('Unknown type')
62
- }
63
-
64
- /** Validates a HashiCorp secret reference from the request body. */
65
- function parseReference(type: SecretType, body: HashicorpRefBody): { path: string; key?: string } {
66
- if (typeof body.path !== 'string' || !body.path.trim()) throw new HashicorpVaultError('Path is required')
67
- const path = body.path.trim().replace(/^\/+|\/+$/g, '')
68
- encodePath(path, 'Path')
69
- if (type !== 'value') return { path }
70
- if (typeof body.key !== 'string' || !body.key.trim()) throw new HashicorpVaultError('Key is required for a Secure Value')
71
- return { path, key: body.key.trim() }
72
- }
73
-
74
- /** Fails when Vault cannot serve the reference, so typos surface while the admin is still in the form. */
75
- async function verifyReference(
76
- vault: typeof vaults.$inferSelect, type: SecretType, ref: { path: string; key?: string },
77
- ): Promise<void> {
78
- const kv = await readKvSecret(vault, ref.path)
79
- if (type === 'userpass' && (typeof kv['username'] !== 'string' || typeof kv['password'] !== 'string')) {
80
- throw new HashicorpVaultError('keys "username" and "password" are required at the path')
81
- }
82
- if (type === 'value' && (kv[ref.key!] === undefined || kv[ref.key!] === null)) {
83
- throw new HashicorpVaultError(`key "${ref.key}" not found`)
84
- }
85
- }
86
-
87
- /** Vault columns safe to return: never the encrypted connection. */
88
- function publicVault(row: typeof vaults.$inferSelect) {
89
- const { connectionConfig: _cc, ...safe } = row
90
- return safe
91
- }
92
-
93
- /** Audit view of a HashiCorp connection; credentials are never included. */
94
- function connectionAudit(row: typeof vaults.$inferSelect): Record<string, unknown> {
95
- if (row.type !== 'hashicorp') return { name: row.name, type: row.type }
96
- let c: ReturnType<typeof publicConnection>
97
- try {
98
- c = publicConnection(openConnection(row.connectionConfig))
99
- } catch {
100
- return { name: row.name, type: row.type } // connection undecryptable (key changed): still allow repair and delete
101
- }
102
- return { name: row.name, type: row.type, address: c.address, namespace: c.namespace, mount: c.mount, authMethod: c.authMethod, approleMount: c.approleMount, credentialsRef: c.credentialsRef }
103
- }
104
-
105
- function safeDecrypt(encryptedValue: string): unknown {
106
- try {
107
- return JSON.parse(decrypt(encryptedValue))
108
- } catch {
109
- return null
110
- }
111
- }
112
-
113
- /**
114
- * Read-only vault catalogue: vault and secret names/types, never values. Whoever may configure things that use secrets gets it too so
115
- * they can pick a secret; creating, editing and revealing secrets stays
116
- * in {@link vaultRoutes} (admin only).
117
- */
118
- async function vaultCatalogRoutes(app: FastifyInstance) {
119
- app.get('/', async () => {
120
- const rows = await db.select().from(vaults)
121
- return rows.map(publicVault)
122
- })
123
-
124
- app.get<{ Params: { id: string } }>('/:id/secrets', async (req, reply) => {
125
- const vaultId = Number(req.params.id)
126
- const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
127
- if (!vault) return reply.code(404).send({ error: 'Vault not found' })
128
- const secrets = await db
129
- .select({ id: vaultSecrets.id, vaultId: vaultSecrets.vaultId, name: vaultSecrets.name, type: vaultSecrets.type, createdAt: vaultSecrets.createdAt, updatedAt: vaultSecrets.updatedAt })
130
- .from(vaultSecrets)
131
- .where(eq(vaultSecrets.vaultId, vaultId))
132
- return secrets
133
- })
134
- }
135
-
136
- async function vaultRoutes(app: FastifyInstance) {
137
- // ── Vaults ──────────────────────────────────────────────────────────────────
138
-
139
- app.post<{ Body: { name: string; description?: string; type?: string; connection?: unknown } }>('/', async (req, reply) => {
140
- if (!req.body.name?.trim()) return reply.code(400).send({ error: 'Name is required' })
141
- const type = req.body.type ?? 'local'
142
- if (type !== 'local' && type !== 'hashicorp') return reply.code(400).send({ error: 'Type must be local or hashicorp' })
143
- let connectionConfig: string | null = null
144
- if (type === 'hashicorp') {
145
- try {
146
- const cfg = parseConnection(req.body.connection)
147
- await verifyCredentialsRef(cfg)
148
- connectionConfig = sealConnection(cfg)
149
- } catch (e) {
150
- if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
151
- throw e
152
- }
153
- }
154
- const now = Date.now()
155
- const [row] = await db.insert(vaults).values({
156
- name: req.body.name.trim(),
157
- type,
158
- description: req.body.description?.trim() ?? null,
159
- connectionConfig,
160
- createdAt: now,
161
- updatedAt: now,
162
- }).returning()
163
- const actor = requestIdentity(req)
164
- audit.write(audit.actor(actor), 'create', 'vault', row!.id, row!.name, audit.snapshot(connectionAudit(row!)))
165
- return publicVault(row!)
166
- })
167
-
168
- app.get<{ Params: { id: string } }>('/:id', async (req, reply) => {
169
- const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.params.id))))[0]
170
- if (!vault) return reply.code(404).send({ error: 'Vault not found' })
171
- if (vault.type !== 'hashicorp') return publicVault(vault)
172
- try {
173
- return { ...publicVault(vault), connection: publicConnection(openConnection(vault.connectionConfig)) }
174
- } catch (e) {
175
- if (e instanceof HashicorpVaultError) return reply.code(500).send({ error: e.message })
176
- throw e
177
- }
178
- })
179
-
180
- // Tests settings from the form before they are saved. With vaultId, blank credentials fall back to the saved ones
181
- // (parseConnection refuses that fallback when the connection target changed, so a saved secret never reaches a new server).
182
- app.post<{ Body: { vaultId?: number; connection?: unknown } }>('/test-connection', async (req, reply) => {
183
- let cfg: ReturnType<typeof parseConnection>
184
- try {
185
- let existing: ReturnType<typeof openConnection> | undefined
186
- if (req.body.vaultId !== undefined) {
187
- const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.body.vaultId))))[0]
188
- if (!vault) return reply.code(404).send({ error: 'Vault not found' })
189
- if (vault.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have a connection to test' })
190
- try { existing = openConnection(vault.connectionConfig) } catch { /* undecryptable: full credentials required */ }
191
- }
192
- cfg = parseConnection(req.body.connection, existing)
193
- await verifyCredentialsRef(cfg)
194
- } catch (e) {
195
- if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
196
- throw e
197
- }
198
- try {
199
- return { ok: true, ...(await testDraftConnection(cfg)) }
200
- } catch (e) {
201
- if (e instanceof HashicorpVaultError) return reply.code(502).send({ error: e.message })
202
- throw e
203
- }
204
- })
205
-
206
- app.post<{ Params: { id: string } }>('/:id/test', async (req, reply) => {
207
- const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.params.id))))[0]
208
- if (!vault) return reply.code(404).send({ error: 'Vault not found' })
209
- if (vault.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have a connection to test' })
210
- try {
211
- return { ok: true, ...(await testConnection(vault)) }
212
- } catch (e) {
213
- if (e instanceof HashicorpVaultError) return reply.code(502).send({ error: e.message })
214
- throw e
215
- }
216
- })
217
-
218
- app.patch<{ Params: { id: string }; Body: { name?: string; description?: string; connection?: unknown } }>(
219
- '/:id', async (req, reply) => {
220
- const id = Number(req.params.id)
221
- const existing = (await db.select().from(vaults).where(eq(vaults.id, id)))[0]
222
- if (!existing) return reply.code(404).send({ error: 'Vault not found' })
223
- const updates: Record<string, unknown> = { updatedAt: Date.now() }
224
- if (req.body.name !== undefined) updates['name'] = req.body.name.trim()
225
- if (req.body.description !== undefined) updates['description'] = req.body.description.trim() || null
226
- let credentialsChanged = false
227
- if (req.body.connection !== undefined) {
228
- if (existing.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have connection settings' })
229
- try {
230
- let old: ReturnType<typeof openConnection> | undefined
231
- try { old = openConnection(existing.connectionConfig) } catch { /* undecryptable: full credentials required */ }
232
- const next = parseConnection(req.body.connection, old)
233
- await verifyCredentialsRef(next)
234
- credentialsChanged = old?.token !== next.token || old?.roleId !== next.roleId || old?.secretId !== next.secretId
235
- updates['connectionConfig'] = sealConnection(next)
236
- } catch (e) {
237
- if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
238
- throw e
239
- }
240
- }
241
- const [row] = await db.update(vaults).set(updates).where(eq(vaults.id, id)).returning()
242
- forgetHashicorpVault(id)
243
- const actor = requestIdentity(req)
244
- const before = { ...connectionAudit(existing), description: existing.description }
245
- const after = { ...connectionAudit(row!), description: row!.description }
246
- const diff = audit.diffObjects(before, after)
247
- if (credentialsChanged) diff['credentials'] = { from: '[redacted]', to: '[redacted]' }
248
- if (Object.keys(diff).length) audit.write(audit.actor(actor), 'update', 'vault', id, existing.name, diff)
249
- return publicVault(row!)
250
- },
251
- )
252
-
253
- app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
254
- const id = Number(req.params.id)
255
- const existing = (await db.select().from(vaults).where(eq(vaults.id, id)))[0]
256
- if (!existing) return reply.code(404).send({ error: 'Vault not found' })
257
- await db.delete(vaults).where(eq(vaults.id, id))
258
- forgetHashicorpVault(id)
259
- forgetCredentialDependents(id)
260
- const actor = requestIdentity(req)
261
- audit.write(audit.actor(actor), 'delete', 'vault', id, existing.name, audit.snapshot(connectionAudit(existing)))
262
- return reply.code(204).send()
263
- })
264
-
265
- // ── Secrets ─────────────────────────────────────────────────────────────────
266
-
267
- app.post<{ Params: { id: string }; Body: { name: string; type: string } & SecretPayload & HashicorpRefBody }>(
268
- '/:id/secrets', async (req, reply) => {
269
- const vaultId = Number(req.params.id)
270
- const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
271
- if (!vault) return reply.code(404).send({ error: 'Vault not found' })
272
- if (!req.body.name?.trim()) return reply.code(400).send({ error: 'Name is required' })
273
- if (!VALID_SECRET_TYPES.includes(req.body.type as SecretType)) {
274
- return reply.code(400).send({ error: `Type must be one of: ${VALID_SECRET_TYPES.join(', ')}` })
275
- }
276
- const type = req.body.type as SecretType
277
- let plaintext: string
278
- let reference: { path: string; key?: string } | undefined
279
- try {
280
- if (vault.type === 'hashicorp') {
281
- reference = parseReference(type, req.body)
282
- await verifyReference(vault, type, reference)
283
- plaintext = JSON.stringify(reference)
284
- } else {
285
- plaintext = serializePayload(type, req.body)
286
- }
287
- } catch (e) {
288
- return reply.code(400).send({ error: (e as Error).message })
289
- }
290
- const now = Date.now()
291
- try {
292
- const [row] = await db.insert(vaultSecrets).values({
293
- vaultId,
294
- name: req.body.name.trim(),
295
- type,
296
- encryptedValue: encrypt(plaintext),
297
- createdAt: now,
298
- updatedAt: now,
299
- }).returning()
300
- const actor = requestIdentity(req)
301
- audit.write(audit.actor(actor), 'create', 'vault_secret', row!.id, `${vault.name} / ${row!.name}`,
302
- audit.snapshot({ name: row!.name, type: row!.type, vault: vault.name, ...(reference && { path: reference.path, key: reference.key }) }))
303
- // Return without encrypted value
304
- const { encryptedValue: _ev, ...safe } = row!
305
- return safe
306
- } catch (e: unknown) {
307
- if (isUniqueViolation(e)) return reply.code(409).send({ error: DUPLICATE_SECRET_ERROR })
308
- throw e
309
- }
310
- },
311
- )
312
-
313
- app.patch<{ Params: { id: string; secretId: string }; Body: { name?: string } & SecretPayload & HashicorpRefBody }>(
314
- '/:id/secrets/:secretId', async (req, reply) => {
315
- const vaultId = Number(req.params.id)
316
- const secretId = Number(req.params.secretId)
317
- const secret = (await db.select().from(vaultSecrets).where(
318
- and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
319
- ))[0]
320
- if (!secret) return reply.code(404).send({ error: 'Secret not found' })
321
-
322
- const updates: Record<string, unknown> = { updatedAt: Date.now() }
323
- if (req.body.name !== undefined) updates['name'] = req.body.name.trim()
324
-
325
- // Re-encrypt if any secret field provided
326
- const vaultForSecret = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]!
327
- const isReference = vaultForSecret.type === 'hashicorp'
328
- const hasNewValue = isReference
329
- ? req.body.path !== undefined || req.body.key !== undefined
330
- : req.body.userpass !== undefined || req.body.value !== undefined || req.body.json !== undefined
331
- if (hasNewValue) {
332
- try {
333
- if (isReference) {
334
- const old = JSON.parse(decrypt(secret.encryptedValue)) as { path: string; key?: string }
335
- const ref = parseReference(secret.type as SecretType, { path: req.body.path ?? old.path, key: req.body.key ?? old.key })
336
- await verifyReference(vaultForSecret, secret.type as SecretType, ref)
337
- updates['encryptedValue'] = encrypt(JSON.stringify(ref))
338
- } else {
339
- updates['encryptedValue'] = encrypt(serializePayload(secret.type as SecretType, req.body))
340
- }
341
- } catch (e) {
342
- return reply.code(400).send({ error: (e as Error).message })
343
- }
344
- }
345
-
346
- try {
347
- const [row] = await db.update(vaultSecrets).set(updates).where(eq(vaultSecrets.id, secretId)).returning()
348
- forgetCredentialDependents(vaultId, secretId)
349
- const actor = requestIdentity(req)
350
- const vaultRow = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
351
- const diff: Record<string, unknown> = {}
352
- if (req.body.name !== undefined && req.body.name !== secret.name) diff['name'] = { from: secret.name, to: req.body.name }
353
- if (hasNewValue) diff[isReference ? 'reference' : 'value'] = { from: '[redacted]', to: '[redacted]' }
354
- if (Object.keys(diff).length) audit.write(audit.actor(actor), 'update', 'vault_secret', secretId, `${vaultRow?.name ?? vaultId} / ${secret.name}`, diff)
355
- const { encryptedValue: _ev, ...safe } = row!
356
- return safe
357
- } catch (e: unknown) {
358
- if (isUniqueViolation(e)) return reply.code(409).send({ error: DUPLICATE_SECRET_ERROR })
359
- throw e
360
- }
361
- },
362
- )
363
-
364
- app.delete<{ Params: { id: string; secretId: string } }>(
365
- '/:id/secrets/:secretId', async (req, reply) => {
366
- const vaultId = Number(req.params.id)
367
- const secretId = Number(req.params.secretId)
368
- const secret = (await db.select().from(vaultSecrets).where(
369
- and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
370
- ))[0]
371
- if (!secret) return reply.code(404).send({ error: 'Secret not found' })
372
- await db.delete(vaultSecrets).where(eq(vaultSecrets.id, secretId))
373
- forgetCredentialDependents(vaultId, secretId)
374
- const actor = requestIdentity(req)
375
- const vaultRow = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
376
- audit.write(audit.actor(actor), 'delete', 'vault_secret', secretId, `${vaultRow?.name ?? vaultId} / ${secret.name}`,
377
- audit.snapshot({ name: secret.name, type: secret.type }))
378
- return reply.code(204).send()
379
- },
380
- )
381
-
382
- app.get<{ Params: { id: string; secretId: string } }>(
383
- '/:id/secrets/:secretId/reveal', async (req, reply) => {
384
- const vaultId = Number(req.params.id)
385
- const secretId = Number(req.params.secretId)
386
- const secret = (await db.select().from(vaultSecrets).where(
387
- and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
388
- ))[0]
389
- if (!secret) return reply.code(404).send({ error: 'Secret not found' })
390
- const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]!
391
- // The value itself never goes into the log, only the fact that it was shown and to whom.
392
- const recordReveal = () => audit.write(audit.actor(requestIdentity(req)), 'allow', 'vault_secret', secretId, `${vault.name} / ${secret.name}`,
393
- { access: 'revealed' })
394
- if (vault.type === 'hashicorp') {
395
- const ref = safeDecrypt(secret.encryptedValue) as { path: string; key?: string } | null
396
- if (!ref) return reply.code(500).send({ error: 'Failed to decrypt secret' })
397
- try {
398
- const value = await loadSecretPayload(secret, vault)
399
- await recordReveal()
400
- return { id: secret.id, name: secret.name, type: secret.type, value, source: ref }
401
- } catch (e) {
402
- return reply.code(502).send({ error: (e as Error).message })
403
- }
404
- }
405
- const value = safeDecrypt(secret.encryptedValue)
406
- if (value === null) return reply.code(500).send({ error: 'Failed to decrypt secret' })
407
- await recordReveal()
408
- return { id: secret.id, name: secret.name, type: secret.type, value }
409
- },
410
- )
411
- }
412
-
413
- return { vaultCatalogRoutes, vaultRoutes }
414
- }
415
-
416
- /** What the vault routes need to write audit entries; the audit module's instance fits. */
417
- export interface VaultAudit {
418
- write(actor: { userId: number; userEmail: string }, action: AuditAction, entityType: string, entityId: number | string | null, entityName: string, diff?: Record<string, unknown>): Promise<void>
419
- actor(identity: { userId: number; email: string }): { userId: number; userEmail: string }
420
- diffObjects(before: Record<string, unknown>, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }>
421
- snapshot(obj: Record<string, unknown>): Record<string, unknown>
422
- }
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify'
2
+ import { and, eq } from 'drizzle-orm'
3
+ import type { AuditAction } from '@better-forge/contracts'
4
+ import type { SqliteDatabase } from '@better-forge/db'
5
+ import { vaults, vaultSecrets } from './tables.js'
6
+ import { HashicorpVaultError, encodePath, parseConnection, publicConnection } from './hashicorp.js'
7
+ import type { Hashicorp } from './hashicorp.js'
8
+ import type { SecretResolver } from './resolveSecret.js'
9
+ import type { SecretBox } from './secretBox.js'
10
+
11
+ const VALID_SECRET_TYPES = ['userpass', 'value', 'json'] as const
12
+
13
+ type SecretType = typeof VALID_SECRET_TYPES[number]
14
+
15
+ interface SecretPayload {
16
+ userpass?: { username: string; password: string }
17
+ value?: string
18
+ json?: string
19
+ }
20
+
21
+ const DUPLICATE_SECRET_ERROR = 'A secret with this name already exists in the vault'
22
+
23
+ /** Drizzle wraps driver errors ("Failed query: …"); the SQLite constraint text lives on `cause`. */
24
+ function isUniqueViolation(error: unknown): boolean {
25
+ for (let current = error; current instanceof Error; current = current.cause) {
26
+ if (current.message.includes('UNIQUE constraint failed')) return true
27
+ }
28
+ return false
29
+ }
30
+
31
+ interface HashicorpRefBody { path?: unknown; key?: unknown }
32
+
33
+ export function createVaultRoutes(ctx: {
34
+ db: SqliteDatabase
35
+ secretBox: SecretBox
36
+ audit: VaultAudit
37
+ /** The signed-in caller of a request (the routes sit behind the application's own guards). */
38
+ identity: (req: FastifyRequest) => { userId: number; email: string }
39
+ }, parts: { hashicorp: Hashicorp; resolver: SecretResolver }) {
40
+ const { db, audit } = ctx
41
+ const { encrypt, decrypt } = ctx.secretBox
42
+ const requestIdentity = ctx.identity
43
+ const { loadSecretPayload } = parts.resolver
44
+ const { forgetHashicorpVault, openConnection, readKvSecret, sealConnection, testConnection, testDraftConnection, verifyCredentialsRef, forgetCredentialDependents } = parts.hashicorp
45
+
46
+ function serializePayload(type: SecretType, payload: SecretPayload): string {
47
+ if (type === 'userpass') {
48
+ if (!payload.userpass) throw new Error('userpass requires username and password')
49
+ return JSON.stringify(payload.userpass)
50
+ }
51
+ if (type === 'value') {
52
+ if (payload.value === undefined) throw new Error('value is required')
53
+ return JSON.stringify({ value: payload.value })
54
+ }
55
+ if (type === 'json') {
56
+ if (payload.json === undefined) throw new Error('json is required')
57
+ // Validate it's actually JSON
58
+ JSON.parse(payload.json)
59
+ return JSON.stringify({ value: payload.json })
60
+ }
61
+ throw new Error('Unknown type')
62
+ }
63
+
64
+ /** Validates a HashiCorp secret reference from the request body. */
65
+ function parseReference(type: SecretType, body: HashicorpRefBody): { path: string; key?: string } {
66
+ if (typeof body.path !== 'string' || !body.path.trim()) throw new HashicorpVaultError('Path is required')
67
+ const path = body.path.trim().replace(/^\/+|\/+$/g, '')
68
+ encodePath(path, 'Path')
69
+ if (type !== 'value') return { path }
70
+ if (typeof body.key !== 'string' || !body.key.trim()) throw new HashicorpVaultError('Key is required for a Secure Value')
71
+ return { path, key: body.key.trim() }
72
+ }
73
+
74
+ /** Fails when Vault cannot serve the reference, so typos surface while the admin is still in the form. */
75
+ async function verifyReference(
76
+ vault: typeof vaults.$inferSelect, type: SecretType, ref: { path: string; key?: string },
77
+ ): Promise<void> {
78
+ const kv = await readKvSecret(vault, ref.path)
79
+ if (type === 'userpass' && (typeof kv['username'] !== 'string' || typeof kv['password'] !== 'string')) {
80
+ throw new HashicorpVaultError('keys "username" and "password" are required at the path')
81
+ }
82
+ if (type === 'value' && (kv[ref.key!] === undefined || kv[ref.key!] === null)) {
83
+ throw new HashicorpVaultError(`key "${ref.key}" not found`)
84
+ }
85
+ }
86
+
87
+ /** Vault columns safe to return: never the encrypted connection. */
88
+ function publicVault(row: typeof vaults.$inferSelect) {
89
+ const { connectionConfig: _cc, ...safe } = row
90
+ return safe
91
+ }
92
+
93
+ /** Audit view of a HashiCorp connection; credentials are never included. */
94
+ function connectionAudit(row: typeof vaults.$inferSelect): Record<string, unknown> {
95
+ if (row.type !== 'hashicorp') return { name: row.name, type: row.type }
96
+ let c: ReturnType<typeof publicConnection>
97
+ try {
98
+ c = publicConnection(openConnection(row.connectionConfig))
99
+ } catch {
100
+ return { name: row.name, type: row.type } // connection undecryptable (key changed): still allow repair and delete
101
+ }
102
+ return { name: row.name, type: row.type, address: c.address, namespace: c.namespace, mount: c.mount, authMethod: c.authMethod, approleMount: c.approleMount, credentialsRef: c.credentialsRef }
103
+ }
104
+
105
+ function safeDecrypt(encryptedValue: string): unknown {
106
+ try {
107
+ return JSON.parse(decrypt(encryptedValue))
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Read-only vault catalogue: vault and secret names/types, never values. Whoever may configure things that use secrets gets it too so
115
+ * they can pick a secret; creating, editing and revealing secrets stays
116
+ * in {@link vaultRoutes} (admin only).
117
+ */
118
+ async function vaultCatalogRoutes(app: FastifyInstance) {
119
+ app.get('/', async () => {
120
+ const rows = await db.select().from(vaults)
121
+ return rows.map(publicVault)
122
+ })
123
+
124
+ app.get<{ Params: { id: string } }>('/:id/secrets', async (req, reply) => {
125
+ const vaultId = Number(req.params.id)
126
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
127
+ if (!vault) return reply.code(404).send({ error: 'Vault not found' })
128
+ const secrets = await db
129
+ .select({ id: vaultSecrets.id, vaultId: vaultSecrets.vaultId, name: vaultSecrets.name, type: vaultSecrets.type, createdAt: vaultSecrets.createdAt, updatedAt: vaultSecrets.updatedAt })
130
+ .from(vaultSecrets)
131
+ .where(eq(vaultSecrets.vaultId, vaultId))
132
+ return secrets
133
+ })
134
+ }
135
+
136
+ async function vaultRoutes(app: FastifyInstance) {
137
+ // ── Vaults ──────────────────────────────────────────────────────────────────
138
+
139
+ app.post<{ Body: { name: string; description?: string; type?: string; connection?: unknown } }>('/', async (req, reply) => {
140
+ if (!req.body.name?.trim()) return reply.code(400).send({ error: 'Name is required' })
141
+ const type = req.body.type ?? 'local'
142
+ if (type !== 'local' && type !== 'hashicorp') return reply.code(400).send({ error: 'Type must be local or hashicorp' })
143
+ let connectionConfig: string | null = null
144
+ if (type === 'hashicorp') {
145
+ try {
146
+ const cfg = parseConnection(req.body.connection)
147
+ await verifyCredentialsRef(cfg)
148
+ connectionConfig = sealConnection(cfg)
149
+ } catch (e) {
150
+ if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
151
+ throw e
152
+ }
153
+ }
154
+ const now = Date.now()
155
+ const [row] = await db.insert(vaults).values({
156
+ name: req.body.name.trim(),
157
+ type,
158
+ description: req.body.description?.trim() ?? null,
159
+ connectionConfig,
160
+ createdAt: now,
161
+ updatedAt: now,
162
+ }).returning()
163
+ const actor = requestIdentity(req)
164
+ audit.write(audit.actor(actor), 'create', 'vault', row!.id, row!.name, audit.snapshot(connectionAudit(row!)))
165
+ return publicVault(row!)
166
+ })
167
+
168
+ app.get<{ Params: { id: string } }>('/:id', async (req, reply) => {
169
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.params.id))))[0]
170
+ if (!vault) return reply.code(404).send({ error: 'Vault not found' })
171
+ if (vault.type !== 'hashicorp') return publicVault(vault)
172
+ try {
173
+ return { ...publicVault(vault), connection: publicConnection(openConnection(vault.connectionConfig)) }
174
+ } catch (e) {
175
+ if (e instanceof HashicorpVaultError) return reply.code(500).send({ error: e.message })
176
+ throw e
177
+ }
178
+ })
179
+
180
+ // Tests settings from the form before they are saved. With vaultId, blank credentials fall back to the saved ones
181
+ // (parseConnection refuses that fallback when the connection target changed, so a saved secret never reaches a new server).
182
+ app.post<{ Body: { vaultId?: number; connection?: unknown } }>('/test-connection', async (req, reply) => {
183
+ let cfg: ReturnType<typeof parseConnection>
184
+ try {
185
+ let existing: ReturnType<typeof openConnection> | undefined
186
+ if (req.body.vaultId !== undefined) {
187
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.body.vaultId))))[0]
188
+ if (!vault) return reply.code(404).send({ error: 'Vault not found' })
189
+ if (vault.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have a connection to test' })
190
+ try { existing = openConnection(vault.connectionConfig) } catch { /* undecryptable: full credentials required */ }
191
+ }
192
+ cfg = parseConnection(req.body.connection, existing)
193
+ await verifyCredentialsRef(cfg)
194
+ } catch (e) {
195
+ if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
196
+ throw e
197
+ }
198
+ try {
199
+ return { ok: true, ...(await testDraftConnection(cfg)) }
200
+ } catch (e) {
201
+ if (e instanceof HashicorpVaultError) return reply.code(502).send({ error: e.message })
202
+ throw e
203
+ }
204
+ })
205
+
206
+ app.post<{ Params: { id: string } }>('/:id/test', async (req, reply) => {
207
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, Number(req.params.id))))[0]
208
+ if (!vault) return reply.code(404).send({ error: 'Vault not found' })
209
+ if (vault.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have a connection to test' })
210
+ try {
211
+ return { ok: true, ...(await testConnection(vault)) }
212
+ } catch (e) {
213
+ if (e instanceof HashicorpVaultError) return reply.code(502).send({ error: e.message })
214
+ throw e
215
+ }
216
+ })
217
+
218
+ app.patch<{ Params: { id: string }; Body: { name?: string; description?: string; connection?: unknown } }>(
219
+ '/:id', async (req, reply) => {
220
+ const id = Number(req.params.id)
221
+ const existing = (await db.select().from(vaults).where(eq(vaults.id, id)))[0]
222
+ if (!existing) return reply.code(404).send({ error: 'Vault not found' })
223
+ const updates: Record<string, unknown> = { updatedAt: Date.now() }
224
+ if (req.body.name !== undefined) updates['name'] = req.body.name.trim()
225
+ if (req.body.description !== undefined) updates['description'] = req.body.description.trim() || null
226
+ let credentialsChanged = false
227
+ if (req.body.connection !== undefined) {
228
+ if (existing.type !== 'hashicorp') return reply.code(400).send({ error: 'Only HashiCorp vaults have connection settings' })
229
+ try {
230
+ let old: ReturnType<typeof openConnection> | undefined
231
+ try { old = openConnection(existing.connectionConfig) } catch { /* undecryptable: full credentials required */ }
232
+ const next = parseConnection(req.body.connection, old)
233
+ await verifyCredentialsRef(next)
234
+ credentialsChanged = old?.token !== next.token || old?.roleId !== next.roleId || old?.secretId !== next.secretId
235
+ updates['connectionConfig'] = sealConnection(next)
236
+ } catch (e) {
237
+ if (e instanceof HashicorpVaultError) return reply.code(400).send({ error: e.message })
238
+ throw e
239
+ }
240
+ }
241
+ const [row] = await db.update(vaults).set(updates).where(eq(vaults.id, id)).returning()
242
+ forgetHashicorpVault(id)
243
+ const actor = requestIdentity(req)
244
+ const before = { ...connectionAudit(existing), description: existing.description }
245
+ const after = { ...connectionAudit(row!), description: row!.description }
246
+ const diff = audit.diffObjects(before, after)
247
+ if (credentialsChanged) diff['credentials'] = { from: '[redacted]', to: '[redacted]' }
248
+ if (Object.keys(diff).length) audit.write(audit.actor(actor), 'update', 'vault', id, existing.name, diff)
249
+ return publicVault(row!)
250
+ },
251
+ )
252
+
253
+ app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => {
254
+ const id = Number(req.params.id)
255
+ const existing = (await db.select().from(vaults).where(eq(vaults.id, id)))[0]
256
+ if (!existing) return reply.code(404).send({ error: 'Vault not found' })
257
+ await db.delete(vaults).where(eq(vaults.id, id))
258
+ forgetHashicorpVault(id)
259
+ forgetCredentialDependents(id)
260
+ const actor = requestIdentity(req)
261
+ audit.write(audit.actor(actor), 'delete', 'vault', id, existing.name, audit.snapshot(connectionAudit(existing)))
262
+ return reply.code(204).send()
263
+ })
264
+
265
+ // ── Secrets ─────────────────────────────────────────────────────────────────
266
+
267
+ app.post<{ Params: { id: string }; Body: { name: string; type: string } & SecretPayload & HashicorpRefBody }>(
268
+ '/:id/secrets', async (req, reply) => {
269
+ const vaultId = Number(req.params.id)
270
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
271
+ if (!vault) return reply.code(404).send({ error: 'Vault not found' })
272
+ if (!req.body.name?.trim()) return reply.code(400).send({ error: 'Name is required' })
273
+ if (!VALID_SECRET_TYPES.includes(req.body.type as SecretType)) {
274
+ return reply.code(400).send({ error: `Type must be one of: ${VALID_SECRET_TYPES.join(', ')}` })
275
+ }
276
+ const type = req.body.type as SecretType
277
+ let plaintext: string
278
+ let reference: { path: string; key?: string } | undefined
279
+ try {
280
+ if (vault.type === 'hashicorp') {
281
+ reference = parseReference(type, req.body)
282
+ await verifyReference(vault, type, reference)
283
+ plaintext = JSON.stringify(reference)
284
+ } else {
285
+ plaintext = serializePayload(type, req.body)
286
+ }
287
+ } catch (e) {
288
+ return reply.code(400).send({ error: (e as Error).message })
289
+ }
290
+ const now = Date.now()
291
+ try {
292
+ const [row] = await db.insert(vaultSecrets).values({
293
+ vaultId,
294
+ name: req.body.name.trim(),
295
+ type,
296
+ encryptedValue: encrypt(plaintext),
297
+ createdAt: now,
298
+ updatedAt: now,
299
+ }).returning()
300
+ const actor = requestIdentity(req)
301
+ audit.write(audit.actor(actor), 'create', 'vault_secret', row!.id, `${vault.name} / ${row!.name}`,
302
+ audit.snapshot({ name: row!.name, type: row!.type, vault: vault.name, ...(reference && { path: reference.path, key: reference.key }) }))
303
+ // Return without encrypted value
304
+ const { encryptedValue: _ev, ...safe } = row!
305
+ return safe
306
+ } catch (e: unknown) {
307
+ if (isUniqueViolation(e)) return reply.code(409).send({ error: DUPLICATE_SECRET_ERROR })
308
+ throw e
309
+ }
310
+ },
311
+ )
312
+
313
+ app.patch<{ Params: { id: string; secretId: string }; Body: { name?: string } & SecretPayload & HashicorpRefBody }>(
314
+ '/:id/secrets/:secretId', async (req, reply) => {
315
+ const vaultId = Number(req.params.id)
316
+ const secretId = Number(req.params.secretId)
317
+ const secret = (await db.select().from(vaultSecrets).where(
318
+ and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
319
+ ))[0]
320
+ if (!secret) return reply.code(404).send({ error: 'Secret not found' })
321
+
322
+ const updates: Record<string, unknown> = { updatedAt: Date.now() }
323
+ if (req.body.name !== undefined) updates['name'] = req.body.name.trim()
324
+
325
+ // Re-encrypt if any secret field provided
326
+ const vaultForSecret = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]!
327
+ const isReference = vaultForSecret.type === 'hashicorp'
328
+ const hasNewValue = isReference
329
+ ? req.body.path !== undefined || req.body.key !== undefined
330
+ : req.body.userpass !== undefined || req.body.value !== undefined || req.body.json !== undefined
331
+ if (hasNewValue) {
332
+ try {
333
+ if (isReference) {
334
+ const old = JSON.parse(decrypt(secret.encryptedValue)) as { path: string; key?: string }
335
+ const ref = parseReference(secret.type as SecretType, { path: req.body.path ?? old.path, key: req.body.key ?? old.key })
336
+ await verifyReference(vaultForSecret, secret.type as SecretType, ref)
337
+ updates['encryptedValue'] = encrypt(JSON.stringify(ref))
338
+ } else {
339
+ updates['encryptedValue'] = encrypt(serializePayload(secret.type as SecretType, req.body))
340
+ }
341
+ } catch (e) {
342
+ return reply.code(400).send({ error: (e as Error).message })
343
+ }
344
+ }
345
+
346
+ try {
347
+ const [row] = await db.update(vaultSecrets).set(updates).where(eq(vaultSecrets.id, secretId)).returning()
348
+ forgetCredentialDependents(vaultId, secretId)
349
+ const actor = requestIdentity(req)
350
+ const vaultRow = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
351
+ const diff: Record<string, unknown> = {}
352
+ if (req.body.name !== undefined && req.body.name !== secret.name) diff['name'] = { from: secret.name, to: req.body.name }
353
+ if (hasNewValue) diff[isReference ? 'reference' : 'value'] = { from: '[redacted]', to: '[redacted]' }
354
+ if (Object.keys(diff).length) audit.write(audit.actor(actor), 'update', 'vault_secret', secretId, `${vaultRow?.name ?? vaultId} / ${secret.name}`, diff)
355
+ const { encryptedValue: _ev, ...safe } = row!
356
+ return safe
357
+ } catch (e: unknown) {
358
+ if (isUniqueViolation(e)) return reply.code(409).send({ error: DUPLICATE_SECRET_ERROR })
359
+ throw e
360
+ }
361
+ },
362
+ )
363
+
364
+ app.delete<{ Params: { id: string; secretId: string } }>(
365
+ '/:id/secrets/:secretId', async (req, reply) => {
366
+ const vaultId = Number(req.params.id)
367
+ const secretId = Number(req.params.secretId)
368
+ const secret = (await db.select().from(vaultSecrets).where(
369
+ and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
370
+ ))[0]
371
+ if (!secret) return reply.code(404).send({ error: 'Secret not found' })
372
+ await db.delete(vaultSecrets).where(eq(vaultSecrets.id, secretId))
373
+ forgetCredentialDependents(vaultId, secretId)
374
+ const actor = requestIdentity(req)
375
+ const vaultRow = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]
376
+ audit.write(audit.actor(actor), 'delete', 'vault_secret', secretId, `${vaultRow?.name ?? vaultId} / ${secret.name}`,
377
+ audit.snapshot({ name: secret.name, type: secret.type }))
378
+ return reply.code(204).send()
379
+ },
380
+ )
381
+
382
+ app.get<{ Params: { id: string; secretId: string } }>(
383
+ '/:id/secrets/:secretId/reveal', async (req, reply) => {
384
+ const vaultId = Number(req.params.id)
385
+ const secretId = Number(req.params.secretId)
386
+ const secret = (await db.select().from(vaultSecrets).where(
387
+ and(eq(vaultSecrets.id, secretId), eq(vaultSecrets.vaultId, vaultId)),
388
+ ))[0]
389
+ if (!secret) return reply.code(404).send({ error: 'Secret not found' })
390
+ const vault = (await db.select().from(vaults).where(eq(vaults.id, vaultId)))[0]!
391
+ // The value itself never goes into the log, only the fact that it was shown and to whom.
392
+ const recordReveal = () => audit.write(audit.actor(requestIdentity(req)), 'allow', 'vault_secret', secretId, `${vault.name} / ${secret.name}`,
393
+ { access: 'revealed' })
394
+ if (vault.type === 'hashicorp') {
395
+ const ref = safeDecrypt(secret.encryptedValue) as { path: string; key?: string } | null
396
+ if (!ref) return reply.code(500).send({ error: 'Failed to decrypt secret' })
397
+ try {
398
+ const value = await loadSecretPayload(secret, vault)
399
+ await recordReveal()
400
+ return { id: secret.id, name: secret.name, type: secret.type, value, source: ref }
401
+ } catch (e) {
402
+ return reply.code(502).send({ error: (e as Error).message })
403
+ }
404
+ }
405
+ const value = safeDecrypt(secret.encryptedValue)
406
+ if (value === null) return reply.code(500).send({ error: 'Failed to decrypt secret' })
407
+ await recordReveal()
408
+ return { id: secret.id, name: secret.name, type: secret.type, value }
409
+ },
410
+ )
411
+ }
412
+
413
+ return { vaultCatalogRoutes, vaultRoutes }
414
+ }
415
+
416
+ /** What the vault routes need to write audit entries; the audit module's instance fits. */
417
+ export interface VaultAudit {
418
+ write(actor: { userId: number; userEmail: string }, action: AuditAction, entityType: string, entityId: number | string | null, entityName: string, diff?: Record<string, unknown>): Promise<void>
419
+ actor(identity: { userId: number; email: string }): { userId: number; userEmail: string }
420
+ diffObjects(before: Record<string, unknown>, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }>
421
+ snapshot(obj: Record<string, unknown>): Record<string, unknown>
422
+ }