feat(secrets): track key names so list/describe work for backend-stored secrets
Some checks failed
CI/CD / lint (push) Successful in 53s
CI/CD / test (push) Successful in 1m6s
CI/CD / typecheck (push) Successful in 2m11s
CI/CD / smoke (push) Failing after 1m42s
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled

Post-migration, every Secret on a non-plaintext backend had an empty `data`
column (values live in the backend; only externalRef on the row). The CLI's
\`get secrets\` showed \`KEYS: -\` and \`describe secret\` showed \`(empty)\` for
all 9 migrated secrets — useless without --show-values.

Fix: dedicated \`keyNames Json\` column on Secret that stores the sorted key
list independently from the values. Populated on every write path, lazily
backfilled on first read for pre-existing rows that pre-date the column.
Schema default \`[]\` keeps prisma db push self-healing on rolling upgrades.

- src/db/prisma/schema.prisma: add Secret.keyNames Json @default("[]")
- src/mcpd/src/repositories/secret.repository.ts: pipe keyNames through create
  + update
- src/mcpd/src/services/secret.service.ts:
  - create/update populate keyNames = sorted Object.keys(data)
  - getById lazy-backfills empty keyNames (cheap: derives from data for
    plaintext, single backend read for openbao)
- src/mcpd/src/services/secret-migrate.service.ts: migrate writes keyNames
  alongside the new backendId so freshly-migrated rows are populated without
  a follow-up read
- src/cli/src/commands/get.ts: KEYS column reads keyNames first, falls back
  to Object.keys(data) for older rows
- src/cli/src/commands/describe.ts: shows the Data section keys whenever
  keyNames OR data has entries (so backend-stored secrets render their key
  list); --show-values still resolves through the backend

After deploy, the 9 already-migrated secrets backfill their keyNames on the
next describe-by-id, with no operator action needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-24 00:57:06 +01:00
parent b1bccee50d
commit 9a808877b5
6 changed files with 89 additions and 6 deletions

View File

@@ -207,12 +207,23 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
lines.push(`${pad('Name:')}${secret.name}`);
const data = secret.data as Record<string, string> | undefined;
if (data && Object.keys(data).length > 0) {
const keyNames = Array.isArray(secret.keyNames) ? secret.keyNames as string[] : [];
// The data row carries the actual values for plaintext-backed secrets and
// is empty for backend-stored ones — fall back to keyNames so we can still
// show what KEYS exist without their values.
const knownKeys = (data && Object.keys(data).length > 0)
? Object.keys(data)
: keyNames;
if (knownKeys.length > 0) {
lines.push('');
lines.push('Data:');
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(data)) {
const display = showValues ? value : '***';
const keyW = Math.max(4, ...knownKeys.map((k) => k.length)) + 2;
for (const key of knownKeys) {
const value = data?.[key];
const display = showValues
? (value ?? '(reveal failed — backend unreachable?)')
: '***';
lines.push(` ${key.padEnd(keyW)}${display}`);
}
if (!showValues) {

View File

@@ -33,6 +33,10 @@ interface SecretRow {
id: string;
name: string;
data: Record<string, string>;
/** Sorted list of key names — populated by mcpd whether the values live in
* the row (plaintext) or in a remote backend (openbao). Falls back to
* Object.keys(data) for older rows that pre-date the column. */
keyNames?: string[];
}
interface TemplateRow {
@@ -179,7 +183,19 @@ const mcpTokenColumns: Column<McpTokenRow>[] = [
const secretColumns: Column<SecretRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
{
header: 'KEYS',
key: (r) => {
// Prefer the dedicated column. It's populated whether the secret lives
// in the row (plaintext) or behind a backend (openbao); the row's `data`
// is empty in the latter case.
const tracked = Array.isArray(r.keyNames) && r.keyNames.length > 0 ? r.keyNames : null;
const fallback = Object.keys(r.data ?? {});
const keys = tracked ?? fallback;
return keys.length > 0 ? keys.join(', ') : '-';
},
width: 40,
},
{ header: 'ID', key: 'id' },
];