refactor(wizard): rename --admin-token → --setup-token
Some checks failed
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / lint (push) Has been cancelled

Any token with policy-write + auth/token admin works; root is a convenient
default but a scoped service account is fine too. The previous naming
misrepresented the permission floor as root-only.

- flag: --admin-token → --setup-token
- wizard field: adminToken → setupToken
- prompt label: "OpenBao admin / root token" → "OpenBao setup token (needs
  policy write + auth/token admin perms; root is fine)"
- file doc + one comment reworded
- tests updated for the new label
- regression test (token-absent-from-stdout) kept unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-20 17:27:09 +01:00
parent ba4129a1e4
commit 1c5301289c
3 changed files with 21 additions and 19 deletions

View File

@@ -15,7 +15,9 @@
* 10. (Optional) promotes the new backend to default. * 10. (Optional) promotes the new backend to default.
* 11. Prints the migration command for the user to run. * 11. Prints the migration command for the user to run.
* *
* Admin token is used only for steps 26 and is never persisted. * Setup token (only needs policy-write + auth/token admin on OpenBao; root is
* a convenient default but a scoped service account works too) is used only
* for steps 26 and is never persisted.
* *
* All prompts go through `ConfigSetupPrompt` (from `config-setup.ts`) so the * All prompts go through `ConfigSetupPrompt` (from `config-setup.ts`) so the
* wizard is testable without real stdin. * wizard is testable without real stdin.
@@ -46,7 +48,7 @@ export interface WizardInput {
name: string; name: string;
/** Pre-filled via flags for CI; falls back to prompt. */ /** Pre-filled via flags for CI; falls back to prompt. */
url?: string | undefined; url?: string | undefined;
adminToken?: string | undefined; setupToken?: string | undefined;
mount?: string | undefined; mount?: string | undefined;
pathPrefix?: string | undefined; pathPrefix?: string | undefined;
policyName?: string | undefined; policyName?: string | undefined;
@@ -64,15 +66,15 @@ export async function runSecretBackendOpenbaoWizard(
const log = deps.log; const log = deps.log;
const url = input.url ?? await prompt.input('OpenBao URL', 'https://bao.ad.itaz.eu'); const url = input.url ?? await prompt.input('OpenBao URL', 'https://bao.ad.itaz.eu');
const adminToken = input.adminToken ?? await prompt.password('OpenBao admin / root token'); const setupToken = input.setupToken ?? await prompt.password('OpenBao setup token (needs policy write + auth/token admin perms; root is fine)');
if (adminToken === '') throw new Error('admin token is required'); if (setupToken === '') throw new Error('setup token is required');
const vaultDeps: VaultDeps = {}; const vaultDeps: VaultDeps = {};
if (deps.fetch !== undefined) vaultDeps.fetch = deps.fetch; if (deps.fetch !== undefined) vaultDeps.fetch = deps.fetch;
// 1. Health check. // 1. Health check.
log(' → checking OpenBao health …'); log(' → checking OpenBao health …');
const health = await verifyHealth(url, adminToken, vaultDeps); const health = await verifyHealth(url, setupToken, vaultDeps);
if (!health.initialized || health.sealed) { if (!health.initialized || health.sealed) {
throw new Error(`OpenBao is not ready (initialized=${String(health.initialized)}, sealed=${String(health.sealed)})`); throw new Error(`OpenBao is not ready (initialized=${String(health.initialized)}, sealed=${String(health.sealed)})`);
} }
@@ -85,18 +87,18 @@ export async function runSecretBackendOpenbaoWizard(
// 2. Enable KV v2 if needed. // 2. Enable KV v2 if needed.
log(` → ensuring KV v2 at ${mount}/ …`); log(` → ensuring KV v2 at ${mount}/ …`);
const created = await ensureKvV2(url, adminToken, mount, vaultDeps); const created = await ensureKvV2(url, setupToken, mount, vaultDeps);
log(` ${created ? 'mounted' : 'already mounted'}`); log(` ${created ? 'mounted' : 'already mounted'}`);
// 3. Write policy. // 3. Write policy.
log(` → writing policy '${policyName}' …`); log(` → writing policy '${policyName}' …`);
const hcl = buildAppMcpdPolicyHcl({ mount, pathPrefix, tokenRole }); const hcl = buildAppMcpdPolicyHcl({ mount, pathPrefix, tokenRole });
await writePolicy(url, adminToken, policyName, hcl, vaultDeps); await writePolicy(url, setupToken, policyName, hcl, vaultDeps);
log(` written (scope: ${mount}/{data,metadata}/${pathPrefix}/* + self-rotation paths)`); log(` written (scope: ${mount}/{data,metadata}/${pathPrefix}/* + self-rotation paths)`);
// 4. Ensure token role. // 4. Ensure token role.
log(` → ensuring token role '${tokenRole}' (period=720h, renewable) …`); log(` → ensuring token role '${tokenRole}' (period=720h, renewable) …`);
await ensureTokenRole(url, adminToken, tokenRole, { await ensureTokenRole(url, setupToken, tokenRole, {
allowedPolicies: [policyName], allowedPolicies: [policyName],
period: 720 * 3600, period: 720 * 3600,
renewable: true, renewable: true,
@@ -104,9 +106,9 @@ export async function runSecretBackendOpenbaoWizard(
}, vaultDeps); }, vaultDeps);
log(' ok'); log(' ok');
// 5. Mint the first periodic token using the admin token. // 5. Mint the first periodic token using the setup token.
log(' → minting first periodic token …'); log(' → minting first periodic token …');
const minted = await mintRoleToken(url, adminToken, tokenRole, vaultDeps); const minted = await mintRoleToken(url, setupToken, tokenRole, vaultDeps);
if (!minted.renewable) { if (!minted.renewable) {
throw new Error(`minted token is not renewable — the role '${tokenRole}' config is wrong`); throw new Error(`minted token is not renewable — the role '${tokenRole}' config is wrong`);
} }

View File

@@ -326,7 +326,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')") .option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')")
.option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, []) .option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
.option('--wizard', 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration') .option('--wizard', 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration')
.option('--admin-token <token>', "openbao wizard: OpenBao admin/root token (prompted if omitted). Used only for provisioning; NEVER persisted.") .option('--setup-token <token>', "openbao wizard: OpenBao token with provisioning perms (policy write + auth/token admin). Root works; a scoped SA token works too. Prompted if omitted. Used only for provisioning; NEVER persisted.")
.option('--policy-name <name>', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')") .option('--policy-name <name>', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')")
.option('--token-role <name>', "openbao wizard: name for the token role created on OpenBao (default: 'app-mcpd-role')") .option('--token-role <name>', "openbao wizard: name for the token role created on OpenBao (default: 'app-mcpd-role')")
.option('--no-promote-default', 'openbao wizard: do not promote this backend to default after creation') .option('--no-promote-default', 'openbao wizard: do not promote this backend to default after creation')
@@ -341,7 +341,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const { runSecretBackendOpenbaoWizard } = await import('./create-secretbackend-wizard.js'); const { runSecretBackendOpenbaoWizard } = await import('./create-secretbackend-wizard.js');
const wizardInput: Parameters<typeof runSecretBackendOpenbaoWizard>[0] = { name }; const wizardInput: Parameters<typeof runSecretBackendOpenbaoWizard>[0] = { name };
if (opts.url !== undefined) wizardInput.url = opts.url as string; if (opts.url !== undefined) wizardInput.url = opts.url as string;
if (opts.adminToken !== undefined) wizardInput.adminToken = opts.adminToken as string; if (opts.setupToken !== undefined) wizardInput.setupToken = opts.setupToken as string;
if (opts.mount !== undefined) wizardInput.mount = opts.mount as string; if (opts.mount !== undefined) wizardInput.mount = opts.mount as string;
if (opts.pathPrefix !== undefined) wizardInput.pathPrefix = opts.pathPrefix as string; if (opts.pathPrefix !== undefined) wizardInput.pathPrefix = opts.pathPrefix as string;
if (opts.policyName !== undefined) wizardInput.policyName = opts.policyName as string; if (opts.policyName !== undefined) wizardInput.policyName = opts.policyName as string;

View File

@@ -83,7 +83,7 @@ describe('runSecretBackendOpenbaoWizard', () => {
'Token role name': 'app-mcpd-role', 'Token role name': 'app-mcpd-role',
}, },
password: { password: {
'OpenBao admin / root token': 'root.admin.token', 'OpenBao setup token (needs policy write + auth/token admin perms; root is fine)': 'root.admin.token',
}, },
confirm: { confirm: {
"Promote 'bao' to default backend?": true, "Promote 'bao' to default backend?": true,
@@ -99,7 +99,7 @@ describe('runSecretBackendOpenbaoWizard', () => {
const firstCallInit = fetchFn.mock.calls[0]![1] as RequestInit; const firstCallInit = fetchFn.mock.calls[0]![1] as RequestInit;
expect((firstCallInit.headers as Record<string, string>)['X-Vault-Token']).toBe('root.admin.token'); expect((firstCallInit.headers as Record<string, string>)['X-Vault-Token']).toBe('root.admin.token');
// Secret was created with the minted token value (hvs.AAA), not the admin token // Secret was created with the minted token value (hvs.AAA), not the setup token
expect(created.secret).toMatchObject({ name: 'bao-creds', data: { token: 'hvs.AAA' } }); expect(created.secret).toMatchObject({ name: 'bao-creds', data: { token: 'hvs.AAA' } });
// SecretBackend created with rotation config // SecretBackend created with rotation config
@@ -119,19 +119,19 @@ describe('runSecretBackendOpenbaoWizard', () => {
expect(fullLog).toContain("You have 2 secret(s) on 'default'"); expect(fullLog).toContain("You have 2 secret(s) on 'default'");
expect(fullLog).toContain('mcpctl --direct migrate secrets --from default --to bao'); expect(fullLog).toContain('mcpctl --direct migrate secrets --from default --to bao');
// Admin token never appears in the log (critical) // Setup token never appears in the log (critical)
expect(fullLog).not.toContain('root.admin.token'); expect(fullLog).not.toContain('root.admin.token');
}); });
it('rejects when admin token is empty', async () => { it('rejects when setup token is empty', async () => {
const prompt = scriptedPrompt({ const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' }, input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': '' }, password: { 'OpenBao setup token (needs policy write + auth/token admin perms; root is fine)': '' },
}); });
await expect(runSecretBackendOpenbaoWizard( await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' }, { name: 'bao' },
{ client: mockClient({}), log: () => {}, prompt, fetch: vi.fn() as unknown as typeof fetch }, { client: mockClient({}), log: () => {}, prompt, fetch: vi.fn() as unknown as typeof fetch },
)).rejects.toThrow(/admin token is required/); )).rejects.toThrow(/setup token is required/);
}); });
it('rejects when vault is sealed', async () => { it('rejects when vault is sealed', async () => {
@@ -140,7 +140,7 @@ describe('runSecretBackendOpenbaoWizard', () => {
]); ]);
const prompt = scriptedPrompt({ const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' }, input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': 't' }, password: { 'OpenBao setup token (needs policy write + auth/token admin perms; root is fine)': 't' },
}); });
await expect(runSecretBackendOpenbaoWizard( await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' }, { name: 'bao' },