@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.
- package/LICENSE +21 -21
- package/README.md +5 -5
- package/dist/migrations.js +21 -21
- package/package.json +56 -56
- package/src/createVault.ts +45 -45
- package/src/hashicorp.ts +466 -466
- package/src/index.ts +9 -9
- package/src/migrations.ts +29 -29
- package/src/module.ts +6 -6
- package/src/resolveSecret.ts +96 -96
- package/src/sealed.ts +38 -38
- package/src/secretBox.ts +72 -72
- package/src/secretFields.ts +177 -177
- package/src/tables.ts +23 -23
- package/src/vaultRoutes.ts +422 -422
- package/src/vaultUse.ts +41 -41
package/src/vaultRoutes.ts
CHANGED
|
@@ -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
|
+
}
|