godcrm/backend/routes/v3/secrets.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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 };