feat(secrets): track key names so list/describe work for backend-stored secrets
Some checks failed
Some checks failed
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:
@@ -6,12 +6,14 @@ export interface SecretRepoCreateInput {
|
||||
backendId: string;
|
||||
data?: Record<string, string>;
|
||||
externalRef?: string;
|
||||
keyNames?: string[];
|
||||
}
|
||||
|
||||
export interface SecretRepoUpdateInput {
|
||||
data?: Record<string, string>;
|
||||
externalRef?: string;
|
||||
backendId?: string;
|
||||
keyNames?: string[];
|
||||
}
|
||||
|
||||
export class SecretRepository implements ISecretRepository {
|
||||
@@ -40,6 +42,7 @@ export class SecretRepository implements ISecretRepository {
|
||||
backendId: data.backendId,
|
||||
data: (data.data ?? {}) as Prisma.InputJsonValue,
|
||||
externalRef: data.externalRef ?? '',
|
||||
keyNames: (data.keyNames ?? []) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,6 +54,7 @@ export class SecretRepository implements ISecretRepository {
|
||||
if (data.backendId !== undefined) {
|
||||
updateData.backend = { connect: { id: data.backendId } };
|
||||
}
|
||||
if (data.keyNames !== undefined) updateData.keyNames = data.keyNames as Prisma.InputJsonValue;
|
||||
return this.prisma.secret.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ export class SecretMigrateService {
|
||||
backendId: dest.id,
|
||||
data: written.storedData,
|
||||
externalRef: written.externalRef,
|
||||
// Keys travel with the secret — populate now so list/describe
|
||||
// doesn't need a per-row backend read just to display key names.
|
||||
keyNames: Object.keys(data).sort(),
|
||||
});
|
||||
|
||||
if (opts.keepSource !== true) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export class SecretService implements SecretRefResolver {
|
||||
if (secret === null) {
|
||||
throw new NotFoundError(`Secret not found: ${id}`);
|
||||
}
|
||||
return secret;
|
||||
return this.maybeBackfillKeyNames(secret);
|
||||
}
|
||||
|
||||
async getByName(name: string): Promise<Secret> {
|
||||
@@ -75,6 +75,7 @@ export class SecretService implements SecretRefResolver {
|
||||
backendId: backend.id,
|
||||
data: written.storedData,
|
||||
externalRef: written.externalRef,
|
||||
keyNames: keysOf(data.data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +88,7 @@ export class SecretService implements SecretRefResolver {
|
||||
return this.repo.update(id, {
|
||||
data: written.storedData,
|
||||
externalRef: written.externalRef,
|
||||
keyNames: keysOf(data.data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,4 +116,45 @@ export class SecretService implements SecretRefResolver {
|
||||
if (existing === null) return;
|
||||
await this.delete(existing.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* If a row's `keyNames` is empty (pre-Phase-5 migrated rows), fetch the
|
||||
* data from the backend once and persist the keys back to the row. Means
|
||||
* the next read of this secret takes the cheap path. We never store values
|
||||
* here — only the sorted list of key names.
|
||||
*
|
||||
* Best-effort: if the backend is unreachable we just return the row as-is;
|
||||
* the next read will retry.
|
||||
*/
|
||||
private async maybeBackfillKeyNames(secret: Secret): Promise<Secret> {
|
||||
const existingKeys = secret.keyNames as unknown;
|
||||
if (Array.isArray(existingKeys) && existingKeys.length > 0) return secret;
|
||||
|
||||
// Plaintext secrets carry their values directly — derive keys from `data`
|
||||
// without touching the backend.
|
||||
const data = secret.data as Record<string, unknown>;
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const keys = keysOf(data as Record<string, string>);
|
||||
const updated = await this.repo.update(secret.id, { keyNames: keys })
|
||||
.catch(() => secret);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Non-plaintext + empty data: ask the backend for the actual values just
|
||||
// to learn the keys. Cheap (single backend read) and only happens once
|
||||
// per row.
|
||||
try {
|
||||
const resolved = await this.resolveData(secret);
|
||||
const keys = keysOf(resolved);
|
||||
if (keys.length === 0) return secret;
|
||||
return await this.repo.update(secret.id, { keyNames: keys });
|
||||
} catch {
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sorted list of keys from a data object — stable serialization for diffing. */
|
||||
function keysOf(data: Record<string, string>): string[] {
|
||||
return Object.keys(data).sort();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user