Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
/**
|
|
* Owner Secrets Vault API — ADR-0040 Phase 1.
|
|
*
|
|
* Owner-only CRUD over the `_secrets` registry, plus an audited reveal
|
|
* endpoint for the Settings → Secrets UI (P2).
|
|
*
|
|
* Wraps `services/secrets/SecretsVault.js` so PG NOTIFY-driven cache eviction
|
|
* stays consistent with service-to-service callers.
|
|
*
|
|
* Mount in server.js:
|
|
* app.use('/api/v3/secrets', authenticate, secretsRouter);
|
|
*
|
|
* Authorization model (mirrors ADR-0040 Tier 1):
|
|
* - JWT-authenticated (parent mount).
|
|
* - Owner-only: `req.user.id === space(11).owner_id`. Admin role is NOT
|
|
* enough — only the literal Development space owner can read/write
|
|
* vault entries. Rejects with 403 otherwise.
|
|
*
|
|
* Audit: every state-changing action AND every reveal writes a row into
|
|
* the existing `audit_log` table. The `details` payload NEVER carries
|
|
* plaintext — only the key and an optional description.
|
|
*/
|
|
|
|
import express from 'express';
|
|
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
|
|
|
import { dbGet, dbAll, dbRun } from '../../database/connection.js';
|
|
import secretsVault from '../../services/secrets/SecretsVault.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import {
|
|
success,
|
|
created,
|
|
error,
|
|
badRequest,
|
|
notFound,
|
|
forbidden,
|
|
conflict,
|
|
} from '../../utils/response.js';
|
|
|
|
const log = apiLogger.child({ module: 'secrets_api' });
|
|
|
|
const OWNER_SPACE_ID = 11; // Development — owner of this space owns the vault.
|
|
const SECRETS_TABLE = '_secrets';
|
|
|
|
const router = express.Router();
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
async function requireOwner(req, res) {
|
|
if (!req.user?.id) {
|
|
forbidden(res, 'Authentication required');
|
|
return false;
|
|
}
|
|
const space = await dbGet('SELECT id, owner_id FROM spaces WHERE id = ?', [OWNER_SPACE_ID]);
|
|
if (!space) {
|
|
// Should never happen on a real deployment — but if space 11 vanishes,
|
|
// fail closed rather than silently elevating.
|
|
error(res, 'OWNER_SPACE_MISSING', `Space ${OWNER_SPACE_ID} not found`, 500);
|
|
return false;
|
|
}
|
|
if (space.owner_id !== req.user.id) {
|
|
forbidden(res, 'Owner-only endpoint');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function auditSecret({ userId, action, key, extra, req }) {
|
|
try {
|
|
const details = JSON.stringify({ key, ...(extra || {}) });
|
|
await dbRun(
|
|
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
userId || null,
|
|
action,
|
|
'secret',
|
|
key,
|
|
details,
|
|
req?.ip || null,
|
|
req?.get?.('user-agent') || null,
|
|
]
|
|
);
|
|
} catch (err) {
|
|
// Audit failures must not break the user-facing request — but log loudly.
|
|
log.warn({ err, action, key }, 'audit_log insert failed (non-blocking)');
|
|
}
|
|
}
|
|
|
|
function scrubRow(row) {
|
|
if (!row) return row;
|
|
// Defensive: drop encrypted_payload no matter what.
|
|
// eslint-disable-next-line no-unused-vars
|
|
const { encrypted_payload, ...rest } = row;
|
|
return rest;
|
|
}
|
|
|
|
function validateKey(raw) {
|
|
if (typeof raw !== 'string') return { ok: false, error: 'key must be a string' };
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length === 0) return { ok: false, error: 'key cannot be empty' };
|
|
if (trimmed.length > 128) return { ok: false, error: 'key cannot exceed 128 chars' };
|
|
// Conservative charset — letters, digits, underscore, dash, dot. Mirrors
|
|
// typical env-var conventions so seed migration (P3) maps 1:1.
|
|
if (!/^[A-Za-z0-9_\-.]+$/.test(trimmed)) {
|
|
return { ok: false, error: 'key may contain only [A-Za-z0-9_-.]' };
|
|
}
|
|
return { ok: true, value: trimmed };
|
|
}
|
|
|
|
// ─── Reveal rate limiter ────────────────────────────────────────────
|
|
//
|
|
// 30 reveals/hour/user (ticket AC2). Per-user key so a compromised owner
|
|
// session can't be hidden by spreading reveals across IPs.
|
|
const revealLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000,
|
|
max: 30,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
keyGenerator: (req, res) =>
|
|
req.user?.id != null ? `u:${req.user.id}` : `ip:${ipKeyGenerator(req, res)}`,
|
|
message: {
|
|
success: false,
|
|
error: {
|
|
code: 'REVEAL_RATE_LIMITED',
|
|
message: 'Too many secret reveals — wait an hour.',
|
|
},
|
|
},
|
|
});
|
|
|
|
// ─── Routes ─────────────────────────────────────────────────────────
|
|
|
|
// GET /api/v3/secrets — list (no plaintext)
|
|
router.get('/', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
const rows = await dbAll(
|
|
`SELECT id, key, description, created_by, created_at, updated_at,
|
|
last_revealed_at, last_revealed_by
|
|
FROM ${SECRETS_TABLE}
|
|
ORDER BY key ASC`
|
|
);
|
|
return success(res, { secrets: rows.map(scrubRow) });
|
|
});
|
|
|
|
// POST /api/v3/secrets — create (409 on conflict)
|
|
router.post('/', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
const { key, plaintext, description } = req.body || {};
|
|
|
|
const keyCheck = validateKey(key);
|
|
if (!keyCheck.ok) return badRequest(res, keyCheck.error);
|
|
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
|
return badRequest(res, 'plaintext must be a non-empty string');
|
|
}
|
|
if (description != null && typeof description !== 'string') {
|
|
return badRequest(res, 'description must be a string when provided');
|
|
}
|
|
|
|
const existing = await dbGet(
|
|
`SELECT id FROM ${SECRETS_TABLE} WHERE key = ? LIMIT 1`,
|
|
[keyCheck.value]
|
|
);
|
|
if (existing) {
|
|
return conflict(res, `Secret "${keyCheck.value}" already exists — use PUT to update`);
|
|
}
|
|
|
|
try {
|
|
await secretsVault.putSecret(keyCheck.value, plaintext, {
|
|
actor: req.user.id,
|
|
description: description ?? null,
|
|
});
|
|
} catch (err) {
|
|
log.error({ err, key: keyCheck.value }, 'putSecret failed (create)');
|
|
if (err?.code === 'VAULT_NOT_CONFIGURED') {
|
|
return error(res, 'VAULT_NOT_CONFIGURED', err.message, 503);
|
|
}
|
|
return error(res, 'PUT_SECRET_FAILED', err?.message || 'unknown', 500);
|
|
}
|
|
|
|
await auditSecret({
|
|
userId: req.user.id,
|
|
action: 'secret.create',
|
|
key: keyCheck.value,
|
|
extra: { description: description ?? null },
|
|
req,
|
|
});
|
|
|
|
const row = await dbGet(
|
|
`SELECT id, key, description, created_by, created_at, updated_at,
|
|
last_revealed_at, last_revealed_by
|
|
FROM ${SECRETS_TABLE} WHERE key = ?`,
|
|
[keyCheck.value]
|
|
);
|
|
return created(res, { secret: scrubRow(row) });
|
|
});
|
|
|
|
// PUT /api/v3/secrets/:key — partial update (plaintext and/or description)
|
|
router.put('/:key', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
const keyCheck = validateKey(req.params.key);
|
|
if (!keyCheck.ok) return badRequest(res, keyCheck.error);
|
|
|
|
const { plaintext, description } = req.body || {};
|
|
const hasPlain = plaintext != null;
|
|
const hasDesc = description !== undefined;
|
|
|
|
if (!hasPlain && !hasDesc) {
|
|
return badRequest(res, 'At least one of {plaintext, description} required');
|
|
}
|
|
if (hasPlain && (typeof plaintext !== 'string' || plaintext.length === 0)) {
|
|
return badRequest(res, 'plaintext must be a non-empty string');
|
|
}
|
|
if (hasDesc && description !== null && typeof description !== 'string') {
|
|
return badRequest(res, 'description must be a string or null');
|
|
}
|
|
|
|
const existing = await dbGet(
|
|
`SELECT id, description FROM ${SECRETS_TABLE} WHERE key = ? LIMIT 1`,
|
|
[keyCheck.value]
|
|
);
|
|
if (!existing) return notFound(res, 'Secret');
|
|
|
|
try {
|
|
if (hasPlain) {
|
|
// putSecret upserts encrypted_payload + bumps updated_at and fires NOTIFY.
|
|
// description: pass through if provided this call, else preserve existing.
|
|
await secretsVault.putSecret(keyCheck.value, plaintext, {
|
|
actor: req.user.id,
|
|
description: hasDesc ? description : existing.description,
|
|
});
|
|
} else {
|
|
// Description-only update — no NOTIFY needed (cache value unchanged).
|
|
await dbRun(
|
|
`UPDATE ${SECRETS_TABLE}
|
|
SET description = ?, updated_at = NOW()
|
|
WHERE key = ?`,
|
|
[description, keyCheck.value]
|
|
);
|
|
}
|
|
} catch (err) {
|
|
log.error({ err, key: keyCheck.value }, 'putSecret failed (update)');
|
|
if (err?.code === 'VAULT_NOT_CONFIGURED') {
|
|
return error(res, 'VAULT_NOT_CONFIGURED', err.message, 503);
|
|
}
|
|
return error(res, 'PUT_SECRET_FAILED', err?.message || 'unknown', 500);
|
|
}
|
|
|
|
await auditSecret({
|
|
userId: req.user.id,
|
|
action: 'secret.update',
|
|
key: keyCheck.value,
|
|
extra: {
|
|
changed: [hasPlain ? 'plaintext' : null, hasDesc ? 'description' : null].filter(Boolean),
|
|
},
|
|
req,
|
|
});
|
|
|
|
const row = await dbGet(
|
|
`SELECT id, key, description, created_by, created_at, updated_at,
|
|
last_revealed_at, last_revealed_by
|
|
FROM ${SECRETS_TABLE} WHERE key = ?`,
|
|
[keyCheck.value]
|
|
);
|
|
return success(res, { secret: scrubRow(row) });
|
|
});
|
|
|
|
// POST /api/v3/secrets/:key/reveal — owner-only, rate-limited, audited.
|
|
router.post('/:key/reveal', revealLimiter, async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
const keyCheck = validateKey(req.params.key);
|
|
if (!keyCheck.ok) return badRequest(res, keyCheck.error);
|
|
|
|
let plaintext;
|
|
try {
|
|
plaintext = await secretsVault.revealSecret(keyCheck.value, { actor: req.user.id });
|
|
} catch (err) {
|
|
log.error({ err, key: keyCheck.value }, 'revealSecret failed');
|
|
if (err?.code === 'VAULT_NOT_CONFIGURED') {
|
|
return error(res, 'VAULT_NOT_CONFIGURED', err.message, 503);
|
|
}
|
|
return error(res, 'REVEAL_FAILED', err?.message || 'unknown', 500);
|
|
}
|
|
if (plaintext == null) return notFound(res, 'Secret');
|
|
|
|
// Audit row — entity_id=key, action=secret.reveal, NO plaintext in details.
|
|
await auditSecret({
|
|
userId: req.user.id,
|
|
action: 'secret.reveal',
|
|
key: keyCheck.value,
|
|
extra: null,
|
|
req,
|
|
});
|
|
|
|
return success(res, { key: keyCheck.value, plaintext });
|
|
});
|
|
|
|
// DELETE /api/v3/secrets/:key — hard delete + audit row.
|
|
router.delete('/:key', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
const keyCheck = validateKey(req.params.key);
|
|
if (!keyCheck.ok) return badRequest(res, keyCheck.error);
|
|
|
|
const existing = await dbGet(
|
|
`SELECT id, description FROM ${SECRETS_TABLE} WHERE key = ? LIMIT 1`,
|
|
[keyCheck.value]
|
|
);
|
|
if (!existing) return notFound(res, 'Secret');
|
|
|
|
try {
|
|
await secretsVault.deleteSecret(keyCheck.value, { actor: req.user.id });
|
|
} catch (err) {
|
|
log.error({ err, key: keyCheck.value }, 'deleteSecret failed');
|
|
return error(res, 'DELETE_FAILED', err?.message || 'unknown', 500);
|
|
}
|
|
|
|
await auditSecret({
|
|
userId: req.user.id,
|
|
action: 'secret.delete',
|
|
key: keyCheck.value,
|
|
extra: { description: existing.description ?? null },
|
|
req,
|
|
});
|
|
|
|
return success(res, { key: keyCheck.value, deleted: true });
|
|
});
|
|
|
|
export default router;
|
|
// Exposed for tests: lets the rate-limit suite reset the per-user window
|
|
// between cases (`revealLimiter.resetKey(\`u:${userId}\`)`).
|
|
export { revealLimiter };
|