fix(mcpd): skip bootstrap tokens on migrate + back-fill ops on existing admins
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / typecheck (push) Successful in 1m45s
CI/CD / test (push) Successful in 1m2s
CI/CD / build (push) Successful in 2m9s
CI/CD / smoke (push) Has started running
CI/CD / publish (push) Has been cancelled

Two production issues caught running the wizard end-to-end:

1. `mcpctl migrate secrets --from default --to bao` listed `bao-creds` as a
   candidate — the very token that lets mcpd reach bao. Moving it would
   brick the auth chain (destination backend needs its own bootstrap token
   to read its own bootstrap token). Fix: SecretMigrateService now calls
   backends.list() and filters out any Secret whose name matches ANY
   SecretBackend's `config.tokenSecretRef.name`. dryRun mirrors the same
   filter so candidates match reality. `--names` explicitly bypasses the
   filter for operators who really mean it.

2. Initial rotation in the wizard 403'd because the global RBAC hook
   demands the `rotate-secretbackend` operation, which wasn't in
   bootstrap-admin — migrateAdminRole only added ops when processing a
   legacy `role: admin` entry, so already-migrated admin rows missed every
   new op added after their initial migration. Fix: migrateAdminRole now
   also runs a back-fill pass on rows that look admin-equivalent (have both
   `edit:*` and `run:*`), appending any missing op from ADMIN_OPS. Writes
   only when something actually changed, so restarts stay quiet. Same path
   also retroactively grants `migrate-secrets` which had the same problem
   yesterday.

Tests: 4 new migrate-service cases (bootstrap filter on/off, dryRun parity,
--names bypass). Full suite 1889/1889.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-20 20:56:00 +01:00
parent 56a4ff7f17
commit 72e49f719f
3 changed files with 229 additions and 12 deletions

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import { SecretMigrateService } from '../src/services/secret-migrate.service.js';
import type { Secret, SecretBackend } from '@prisma/client';
function makeSecret(overrides: Partial<Secret>): Secret {
return {
id: 'sec',
name: 'sec',
backendId: 'b-plain',
data: {} as Secret['data'],
externalRef: '',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeBackend(overrides: Partial<SecretBackend>): SecretBackend {
return {
id: overrides.id ?? 'b',
name: overrides.name ?? 'b',
type: overrides.type ?? 'plaintext',
config: overrides.config ?? ({} as SecretBackend['config']),
tokenMeta: overrides.tokenMeta ?? ({} as SecretBackend['tokenMeta']),
isDefault: overrides.isDefault ?? false,
description: overrides.description ?? '',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
}
/**
* Build a service with in-memory state. Mocks the secret repo + backend
* service enough to exercise the bootstrap-token filter.
*/
function makeService(state: {
sourceName: string;
destName: string;
sourceBackend: SecretBackend;
destBackend: SecretBackend;
allBackends: SecretBackend[];
secretsOnSource: Secret[];
}) {
const secretRepo = {
findByBackend: vi.fn(async (backendId: string) => {
return state.secretsOnSource.filter((s) => s.backendId === backendId);
}),
update: vi.fn(async () => ({}) as Secret),
};
const backends = {
list: vi.fn(async () => state.allBackends),
getByName: vi.fn(async (name: string) => {
if (name === state.sourceBackend.name) return state.sourceBackend;
if (name === state.destBackend.name) return state.destBackend;
throw new Error(`unknown backend: ${name}`);
}),
driverFor: vi.fn(() => ({
read: vi.fn(async () => ({ k: 'v' })),
write: vi.fn(async () => ({ externalRef: 'ext', storedData: {} })),
delete: vi.fn(async () => {}),
})),
};
return new SecretMigrateService(
secretRepo as never,
backends as never,
);
}
describe('SecretMigrateService — bootstrap-token protection', () => {
const sourceBackend = makeBackend({ id: 'b-plain', name: 'default', type: 'plaintext' });
const destBackend = makeBackend({
id: 'b-bao',
name: 'bao',
type: 'openbao',
config: { tokenSecretRef: { name: 'bao-creds', key: 'token' } } as unknown as SecretBackend['config'],
});
const secrets = [
makeSecret({ id: 's1', name: 'grafana-creds' }),
makeSecret({ id: 's2', name: 'bao-creds' }), // ← bootstrap token for `bao`
makeSecret({ id: 's3', name: 'ha-creds' }),
];
it('migrate() without --names skips the bootstrap-token secret', async () => {
const svc = makeService({
sourceName: 'default',
destName: 'bao',
sourceBackend, destBackend,
allBackends: [sourceBackend, destBackend],
secretsOnSource: secrets,
});
const result = await svc.migrate({ from: 'default', to: 'bao' });
const migratedNames = result.migrated.map((m) => m.name).sort();
expect(migratedNames).toEqual(['grafana-creds', 'ha-creds']);
const skippedBootstrap = result.skipped.find((s) => s.name === 'bao-creds');
expect(skippedBootstrap, 'bao-creds must be in skipped with a bootstrap-token reason').toBeDefined();
expect(skippedBootstrap!.reason).toMatch(/bootstrap token/i);
});
it('migrate() WITH --names including the bootstrap token still migrates it (explicit override)', async () => {
const svc = makeService({
sourceName: 'default',
destName: 'bao',
sourceBackend, destBackend,
allBackends: [sourceBackend, destBackend],
secretsOnSource: secrets,
});
const result = await svc.migrate({ from: 'default', to: 'bao', names: ['bao-creds'] });
const migratedNames = result.migrated.map((m) => m.name);
expect(migratedNames).toEqual(['bao-creds']);
// No bootstrap-skip entry when the user explicitly asked for it
expect(result.skipped.find((s) => s.reason.includes('bootstrap'))).toBeUndefined();
});
it('dryRun() matches migrate() — also filters bootstrap tokens by default', async () => {
const svc = makeService({
sourceName: 'default',
destName: 'bao',
sourceBackend, destBackend,
allBackends: [sourceBackend, destBackend],
secretsOnSource: secrets,
});
const candidates = await svc.dryRun({ from: 'default', to: 'bao' });
const names = candidates.map((c) => c.name).sort();
expect(names).toEqual(['grafana-creds', 'ha-creds']);
expect(names).not.toContain('bao-creds');
});
it('dryRun() with --names reports exactly the named secrets', async () => {
const svc = makeService({
sourceName: 'default',
destName: 'bao',
sourceBackend, destBackend,
allBackends: [sourceBackend, destBackend],
secretsOnSource: secrets,
});
const candidates = await svc.dryRun({ from: 'default', to: 'bao', names: ['bao-creds', 'grafana-creds'] });
const names = candidates.map((c) => c.name).sort();
expect(names).toEqual(['bao-creds', 'grafana-creds']);
});
});