refactor(wizard): rename --admin-token → --setup-token
Some checks failed
Some checks failed
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:
@@ -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 2–6 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 2–6 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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user