diff --git a/src/cli.ts b/src/cli.ts index 9383d20c..106c03a8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { Command } from 'commander' import { defineCommand, defineGroup, hideBlockedCommands } from './factory.js' import type { OpaqueCommandHandle } from './factory.js' import { loadConfig, type LoadConfigResult } from './config/loader.ts' +import { BUILT_IN_PROFILES, type BuiltInProfile } from './config/profiles.ts' import { setResolvedConfig } from './config/store.ts' import { renderLogo } from './lib/logo.ts' @@ -22,6 +23,7 @@ program .description('Interface with the Elastic Stack and Elastic Cloud from the command line.') .option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') .option('--use-context ', 'override the active context from the config file') + .option(`--command-profile `, `restrict available commands to a deployment profile (${BUILT_IN_PROFILES.join(', ')})`) .option('--json', 'output as JSON') .option('--output-fields ', 'comma-separated list of fields to include in output (dot-notation supported)') .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') @@ -43,16 +45,18 @@ program.hook('preAction', async (thisCommand, actionCommand) => { for (let c = actionCommand.parent; c != null; c = c.parent) { if (c.name() === 'config') return } - const { configFile: configPath, useContext: contextName } = thisCommand.opts() + const { configFile: configPath, useContext: contextName, commandProfile: profileName } = thisCommand.opts() + const typedProfileName = profileName as BuiltInProfile | undefined - if (configPath == null && contextName == null && earlyConfig?.ok === true) { + if (configPath == null && contextName == null && profileName == null && earlyConfig?.ok === true) { setResolvedConfig(earlyConfig.value) return } const result = await loadConfig({ ...(configPath != null && { configPath }), - ...(contextName != null && { contextName }) + ...(contextName != null && { contextName }), + ...(typedProfileName != null && { profileName: typedProfileName }), }) if (result.ok) { setResolvedConfig(result.value) @@ -177,7 +181,14 @@ if (firstArg === 'sanitize') { // to avoid unnecessary file I/O and a confusing "no config found" path. // The result is cached in earlyConfig so the preAction hook can reuse it. if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { - earlyConfig = await loadConfig({}) + // Parse --profile early (before Commander's full parse) so the early config load + // and hideBlockedCommands can apply the correct profile-based allow-list to --help. + const profileArgIdx = process.argv.indexOf('--command-profile') + const earlyProfile = profileArgIdx !== -1 ? process.argv[profileArgIdx + 1] as BuiltInProfile | undefined : undefined + + earlyConfig = await loadConfig({ + ...(earlyProfile != null && { profileName: earlyProfile }), + }) if (earlyConfig.ok) { setResolvedConfig(earlyConfig.value) hideBlockedCommands(program, earlyConfig.value.commands) diff --git a/src/config/loader.ts b/src/config/loader.ts index c6bd5262..fea96835 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -34,6 +34,7 @@ import { ContextSchema, CommandPolicySchema, StructuralConfigSchema } from './sc import { resolveExpressions } from '@elastic/config-resolver' import { hasInlineSecrets, type RawConfig } from './writer.ts' import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts' +import { BUILT_IN_PROFILES, type BuiltInProfile } from './profiles.ts' /** Extensions that are rejected to prevent arbitrary code execution. */ const EXECUTABLE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs']) @@ -103,7 +104,60 @@ export async function loadConfigFile (filePath: string): Promise { * @param contextName - The key of the context to resolve. * @returns A `ResolvedConfig` wrapping only that context's service blocks. */ -export function resolveContext (config: ConfigFile, contextName: string): ResolvedConfig { +/** + * Computes the effective command policy from the per-context policy, the root policy, + * a `default_profile` fallback, and an optional `--profile` flag override. + * + * Precedence (highest to lowest): + * 1. `profileOverride` — the `--profile` CLI flag + * 2. Per-context `commands.profile` / `commands.allowed` + * 3. Root `commands.profile` / `commands.allowed` + * 4. `defaultProfile` — `default_profile` from the root config + * + * `blocked` lists from context and root are unioned (further restriction always applies). + * `profile` and `allowed` are mutually exclusive; passing `--profile` with a context + * that declares `commands.allowed` is an error. + * + * Returns `undefined` when no filtering is active (allow everything). + * Returns `{ error }` on an invalid combination. + */ +export function resolveEffectiveCommands ( + contextCommands: ConfigFile['commands'], + rootCommands: ConfigFile['commands'], + defaultProfile: BuiltInProfile | undefined, + profileOverride: BuiltInProfile | undefined, +): { commands: ConfigFile['commands'] } | { error: string } { + // Effective allowed/blocked from config (context overrides root) + const effectiveAllowed = contextCommands?.allowed ?? rootCommands?.allowed + const contextBlocked = contextCommands?.blocked ?? [] + const rootBlocked = rootCommands?.blocked ?? [] + const effectiveBlocked = [...contextBlocked, ...rootBlocked.filter(p => !contextBlocked.includes(p))] + + // Effective profile from precedence chain + const configProfile = contextCommands?.profile ?? rootCommands?.profile ?? defaultProfile + const effectiveProfile = profileOverride ?? configProfile + + // Validate: --profile flag cannot be used when allowed is set + if (profileOverride != null && effectiveAllowed != null) { + return { error: '--profile flag cannot be used with a context that has commands.allowed set' } + } + + const hasProfile = effectiveProfile != null + const hasAllowed = effectiveAllowed != null + const hasBlocked = effectiveBlocked.length > 0 + + if (!hasProfile && !hasAllowed && !hasBlocked) return { commands: undefined } + + return { + commands: { + ...(hasProfile && { profile: effectiveProfile }), + ...(hasAllowed && { allowed: effectiveAllowed }), + ...(hasBlocked && { blocked: effectiveBlocked }), + }, + } +} + +export function resolveContext (config: ConfigFile, contextName: string, profileOverride?: BuiltInProfile): ResolvedConfig { // non-null: caller (loadConfig) guarantees contextName exists in config.contexts const ctx = config.contexts[contextName]! const resolved: ResolvedContext = {} @@ -111,7 +165,19 @@ export function resolveContext (config: ConfigFile, contextName: string): Resolv if (ctx.kibana != null) resolved.kibana = ctx.kibana if (ctx.cloud != null) resolved.cloud = ctx.cloud const result: ResolvedConfig = { context: resolved } - if (config.commands != null) result.commands = config.commands + + const effectiveCommandsResult = resolveEffectiveCommands( + ctx.commands, + config.commands, + config.default_profile, + profileOverride, + ) + if ('error' in effectiveCommandsResult) { + // Surface the error; callers (loadConfig) catch this and return LoadConfigErr + throw new Error(effectiveCommandsResult.error) + } + if (effectiveCommandsResult.commands != null) result.commands = effectiveCommandsResult.commands + if (config.banner != null) result.banner = config.banner return result } @@ -151,6 +217,8 @@ export interface LoadConfigOptions { configPath?: string /** Context name override (`--use-context` flag). Overrides `current_context` in the file. */ contextName?: string + /** Profile name override (`--profile` flag). Overrides any profile set in the config file. */ + profileName?: BuiltInProfile } /** Successful result from {@link loadConfig}. */ @@ -183,7 +251,13 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr * @returns A `LoadConfigResult` discriminated union. */ export async function loadConfig (options: LoadConfigOptions = {}): Promise { - const { configPath, contextName } = options + const { configPath, contextName, profileName } = options + + // Validate profileName early (before any I/O) so the error is immediate and clear + if (profileName != null && !(BUILT_IN_PROFILES as readonly string[]).includes(profileName)) { + const valid = BUILT_IN_PROFILES.join(', ') + return { ok: false, error: { message: `Unknown profile "${profileName}". Valid profiles: ${valid}` } } + } // Step 1: load raw config // Precedence: --config-file flag > ELASTIC_CLI_CONFIG_FILE env var > home-directory discovery @@ -223,7 +297,7 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null, - { error: 'at least one service block (elasticsearch, kibana, or cloud) is required' } - ) - /** * Policy controlling which commands are permitted to run. - * Only one of `allowed` or `blocked` may be present. - * Entries may use a trailing wildcard (e.g. `elasticsearch.*`) to match a namespace. + * + * Mutually exclusive combinations: + * - `profile` and `allowed` cannot both be set (profile replaces the allow-list) + * - `allowed` and `blocked` cannot both be set + * + * Valid combinations: + * - `profile` alone — use a built-in allow-list + * - `profile` + `blocked` — built-in allow-list with additional restrictions + * - `allowed` alone — explicit allow-list + * - `blocked` alone — explicit deny-list (everything else is allowed) + * + * Entries may use a trailing wildcard (e.g. `stack.es.*`) to match a namespace. */ export const CommandPolicySchema = z .object({ + profile: z.enum(BUILT_IN_PROFILES).optional(), allowed: z.array(z.string().min(1)).min(1).optional(), blocked: z.array(z.string().min(1)).min(1).optional(), }) + .refine( + (p) => !(p.profile != null && p.allowed != null), + { error: 'commands: "profile" and "allowed" are mutually exclusive' }, + ) .refine( (p) => !(p.allowed != null && p.blocked != null), { error: 'commands: "allowed" and "blocked" are mutually exclusive' }, ) -/** The root configuration file structure. */ +/** + * A context value: optional service blocks with at least one present, plus + * an optional per-context command policy that overrides the root-level policy. + */ +export const ContextSchema = z + .object({ + elasticsearch: ServiceBlockSchema.optional(), + kibana: ServiceBlockSchema.optional(), + cloud: ServiceBlockSchema.optional(), + commands: CommandPolicySchema.optional(), + }) + .refine( + (ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null, + { error: 'at least one service block (elasticsearch, kibana, or cloud) is required' } + ) + +/** + * The root configuration file structure. + * + * `default_profile` sets a fallback profile for all contexts that don't + * specify their own `commands.profile`. It is overridden by a per-context + * `commands.profile` and by the `--profile` CLI flag. + * + * `commands` is the root-level policy; per-context `commands` takes precedence. + */ export const ConfigFileSchema = z .object({ current_context: z.string().min(1), @@ -77,6 +105,7 @@ export const ConfigFileSchema = z { error: 'contexts must contain at least one entry' }, ), commands: CommandPolicySchema.optional(), + default_profile: z.enum(BUILT_IN_PROFILES).optional(), banner: z.boolean().optional(), }) .refine( @@ -97,5 +126,6 @@ export const StructuralConfigSchema = z { error: 'contexts must contain at least one entry' }, ), commands: z.unknown().optional(), + default_profile: z.unknown().optional(), banner: z.boolean().optional(), }) diff --git a/src/config/types.ts b/src/config/types.ts index 25a02203..88a3d32f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -11,6 +11,7 @@ import type { ConfigFileSchema, CommandPolicySchema, } from './schema.ts' +export type { BuiltInProfile } from './profiles.ts' /** * TypeScript types exported from Zod schemas for the configuration system. diff --git a/src/factory.ts b/src/factory.ts index 144fff19..67802728 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { readFileSync } from 'node:fs' import assert from 'node:assert/strict' import type { ResolvedConfig, CommandPolicy } from './config/types.ts' +import { resolveBuiltinProfile } from './config/profiles.ts' import { getResolvedConfig } from './config/store.ts' import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts' import type { SchemaArgDefinition } from './lib/schema-args.ts' @@ -221,6 +222,19 @@ export function isCommandAllowed(commandDotPath: string, policy: CommandPolicy | return commandDotPath === pattern } + // Profile-based filtering: resolve the named profile to its allow-list and + // check against it first, then apply any additional `blocked` restriction. + if (policy.profile != null) { + const profilePolicy = resolveBuiltinProfile(policy.profile) + if (profilePolicy != null) { + // Profile acts as an allow-list; if the command is not in it, deny. + if (!profilePolicy.allowed.some(matches)) return false + } + // `blocked` further restricts on top of the profile (always allowed to restrict more). + if (policy.blocked != null) return !policy.blocked.some(matches) + return true + } + if (policy.allowed != null) return policy.allowed.some(matches) if (policy.blocked != null) return !policy.blocked.some(matches) return true @@ -233,9 +247,25 @@ function setHidden(cmd: OpaqueCommandHandle, value: boolean): void { (cmd as unk // eslint-disable-next-line @typescript-eslint/no-explicit-any function isHidden(cmd: OpaqueCommandHandle): boolean { return (cmd as unknown as any)._hidden === true } +/** + * Returns true if `cmd` is a stub group — a group with no children that was + * registered in cli.ts as a lazy-loading placeholder. + * + * Stub groups should never be hidden by policy because their children have not + * been loaded yet; we cannot determine whether any child would be allowed. + * When the user navigates into the group its children are loaded and filtered + * correctly at that level. + */ +function isStubGroup (cmd: OpaqueCommandHandle): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = cmd as unknown as any + return c._isGroup === true && (c.commands == null || c.commands.length === 0) +} + /** * Walk the command tree and hide any commands the policy blocks. * Groups where every child is hidden are hidden too. + * Stub groups (unloaded lazy namespaces) are never hidden. * Call on the root program so dot-paths like `es.cat.health` are built correctly. */ export function hideBlockedCommands(root: OpaqueCommandHandle, policy: CommandPolicy | undefined, prefix = ''): void { @@ -246,6 +276,8 @@ export function hideBlockedCommands(root: OpaqueCommandHandle, policy: CommandPo if (subs.length > 0) { hideBlockedCommands(child, policy, path) if (subs.every(isHidden)) setHidden(child, true) + } else if (isStubGroup(child)) { + // Unloaded lazy namespace: leave visible. Children are filtered when loaded. } else { setHidden(child, !isCommandAllowed(path, policy)) } @@ -827,6 +859,9 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand group.description(config.description) group.allowExcessArguments(true) configureErrorOutput(group) + // Mark as a group so hideBlockedCommands can distinguish groups from leaf commands. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(group as unknown as any)._isGroup = true for (const cmd of commands) { group.addCommand(cmd) } diff --git a/src/lib/schema-args.ts b/src/lib/schema-args.ts index f3de6e46..c9f02e48 100644 --- a/src/lib/schema-args.ts +++ b/src/lib/schema-args.ts @@ -199,7 +199,7 @@ export function buildFlagKeyMap (args: SchemaArgDefinition[]): FlagKeyMap { } /** Reserved CLI flag names that schema keys must not collide with. */ -const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'input-file']) +const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'command-profile', 'input-file']) /** * Validates schema arguments for naming conflicts. diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index ab7c1d67..583a38a3 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -8,7 +8,7 @@ import assert from 'node:assert/strict' import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { loadConfigFile, discoverConfigFile, resolveContext, loadConfig } from '../../src/config/loader.ts' +import { loadConfigFile, discoverConfigFile, resolveContext, resolveEffectiveCommands, loadConfig } from '../../src/config/loader.ts' import type { ConfigFile, ResolvedConfig } from '../../src/config/types.ts' // --------------------------------------------------------------------------- @@ -447,6 +447,106 @@ commands: if (result.ok) return assert.match(result.error.message, /mutually exclusive/) }) + + it('loadConfig threads commands.profile into ResolvedConfig', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +commands: + profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig applies default_profile when no context-level profile is set', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +default_profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'default-profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig profileName option overrides config profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +commands: + profile: stack +`.trimStart() + const configPath = join(tmpDir, 'profile-override.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath, profileName: 'serverless' }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig profileName option rejects unknown profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +`.trimStart() + const configPath = join(tmpDir, 'profile-unknown.yml') + await writeFile(configPath, yaml) + // @ts-expect-error — testing runtime validation of invalid profile + const result = await loadConfig({ configPath, profileName: 'not-a-profile' }) + assert.ok(!result.ok) + if (result.ok) return + assert.match(result.error.message, /Unknown profile/) + }) + + it('loadConfig per-context commands.profile overrides root commands.profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 + commands: + profile: stack +commands: + profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'context-profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'stack') + }) }) // --------------------------------------------------------------------------- @@ -683,6 +783,80 @@ describe('security: executable config formats are rejected', () => { } }) + describe('resolveEffectiveCommands', () => { + it('returns undefined when no policy is active', () => { + const result = resolveEffectiveCommands(undefined, undefined, undefined, undefined) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands, undefined) + }) + + it('profile override takes highest precedence', () => { + const result = resolveEffectiveCommands( + { profile: 'stack' }, // context says stack + undefined, + undefined, + 'serverless', // --command-profile flag says serverless → wins + ) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'serverless') + }) + + it('context commands.profile overrides root commands.profile', () => { + const result = resolveEffectiveCommands( + { profile: 'stack' }, + { profile: 'serverless' }, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'stack') + }) + + it('default_profile is used as fallback when no profile elsewhere', () => { + const result = resolveEffectiveCommands(undefined, undefined, 'serverless', undefined) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'serverless') + }) + + it('blocked lists from context and root are unioned', () => { + const result = resolveEffectiveCommands( + { blocked: ['stack.es.ml.*'] }, + { blocked: ['cloud.hosted.*'] }, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) { + assert.ok(result.commands?.blocked?.includes('stack.es.ml.*')) + assert.ok(result.commands?.blocked?.includes('cloud.hosted.*')) + } + }) + + it('returns error when --command-profile flag is combined with context allowed list', () => { + const result = resolveEffectiveCommands( + { allowed: ['stack.es.*'] }, + undefined, + undefined, + 'serverless', + ) + assert.ok('error' in result) + }) + + it('profile + context blocked: both are preserved', () => { + const result = resolveEffectiveCommands( + { profile: 'serverless', blocked: ['stack.es.ml.*'] }, + undefined, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) { + assert.equal(result.commands?.profile, 'serverless') + assert.deepEqual(result.commands?.blocked, ['stack.es.ml.*']) + } + }) + }) + describe('discoverConfigFile ignores executable file names', () => { for (const name of ['.elasticrc.js', '.elasticrc.ts', '.elasticrc.mjs', '.elasticrc.cjs']) { it(`does not discover ${name}`, async () => { diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts index db6e6a52..7b34825e 100644 --- a/test/config/schema.test.ts +++ b/test/config/schema.test.ts @@ -240,6 +240,31 @@ describe('ContextSchema', () => { if (!result.success) return assert.equal('extra' in result.data, false) }) + + it('accepts a per-context commands policy', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless' }, + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.commands?.profile, 'serverless') + }) + + it('accepts a per-context commands.blocked on top of a profile', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless', blocked: ['stack.es.ml.*'] }, + }) + assert.equal(result.success, true) + }) + + it('rejects per-context commands with profile + allowed', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless', allowed: ['stack.es.*'] }, + }) + assert.equal(result.success, false) + }) }) describe('CommandPolicySchema', () => { @@ -303,6 +328,33 @@ describe('CommandPolicySchema', () => { const result = CommandPolicySchema.safeParse({ blocked: [''] }) assert.equal(result.success, false) }) + + it('accepts a valid profile name alone', () => { + for (const profile of ['serverless', 'stack', 'default'] as const) { + const result = CommandPolicySchema.safeParse({ profile }) + assert.equal(result.success, true, `expected profile "${profile}" to be accepted`) + if (result.success) assert.equal(result.data.profile, profile) + } + }) + + it('accepts profile + blocked (composition)', () => { + const result = CommandPolicySchema.safeParse({ profile: 'serverless', blocked: ['stack.es.ml.*'] }) + assert.equal(result.success, true) + if (result.success) { + assert.equal(result.data.profile, 'serverless') + assert.deepEqual(result.data.blocked, ['stack.es.ml.*']) + } + }) + + it('rejects profile + allowed (mutually exclusive)', () => { + const result = CommandPolicySchema.safeParse({ profile: 'serverless', allowed: ['stack.es.*'] }) + assert.equal(result.success, false) + }) + + it('rejects an unknown profile name', () => { + const result = CommandPolicySchema.safeParse({ profile: 'unknown-profile' }) + assert.equal(result.success, false) + }) }) describe('ConfigFileSchema', () => { @@ -409,4 +461,46 @@ describe('ConfigFileSchema', () => { }) assert.equal(result.success, false) }) + + it('accepts commands.profile at root level', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + commands: { profile: 'serverless' }, + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.commands?.profile, 'serverless') + }) + + it('accepts default_profile at root level', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + default_profile: 'serverless', + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.default_profile, 'serverless') + }) + + it('rejects an invalid default_profile value', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + default_profile: 'not-a-profile', + }) + assert.equal(result.success, false) + }) + + it('accepts per-context commands.profile', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { + production: { elasticsearch: esBlock, commands: { profile: 'stack' } }, + }, + }) + assert.equal(result.success, true) + if (result.success) { + assert.equal(result.data.contexts['production']?.commands?.profile, 'stack') + } + }) }) diff --git a/test/factory.test.ts b/test/factory.test.ts index b0c7d163..f6e3d07b 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -2466,6 +2466,59 @@ describe('isCommandAllowed', () => { it('blocked list: returns true for commands outside the blocked namespace', () => { assert.equal(isCommandAllowed('elasticsearch.search', { blocked: ['config.*'] }), true) }) + + // --- profile-based filtering --- + + it('profile serverless: allows stack.es.indices.list', () => { + assert.equal(isCommandAllowed('stack.es.indices.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows stack.kb.data-views.list', () => { + assert.equal(isCommandAllowed('stack.kb.data-views.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows cloud.serverless.projects.search.list', () => { + assert.equal(isCommandAllowed('cloud.serverless.projects.search.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows cloud cross-cutting namespaces', () => { + for (const path of ['cloud.trust.get', 'cloud.auth.list', 'cloud.orgs.list', 'cloud.users.add', 'cloud.billing.get']) { + assert.equal(isCommandAllowed(path, { profile: 'serverless' }), true, `expected "${path}" to be allowed`) + } + }) + + it('profile serverless: blocks cloud.hosted commands', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'serverless' }), false) + assert.equal(isCommandAllowed('cloud.hosted.traffic-filters.list', { profile: 'serverless' }), false) + }) + + it('profile serverless: allows version and config commands', () => { + assert.equal(isCommandAllowed('version', { profile: 'serverless' }), true) + assert.equal(isCommandAllowed('config.context.list', { profile: 'serverless' }), true) + }) + + it('profile default: behaves the same as serverless', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'default' }), false) + assert.equal(isCommandAllowed('stack.es.search', { profile: 'default' }), true) + }) + + it('profile stack: allows all commands (no restriction)', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'stack' }), true) + assert.equal(isCommandAllowed('cloud.serverless.projects.search.list', { profile: 'stack' }), true) + assert.equal(isCommandAllowed('anything.at.all', { profile: 'stack' }), true) + }) + + it('profile + blocked: profile allows but blocked further restricts', () => { + const policy = { profile: 'serverless' as const, blocked: ['stack.es.ml.*'] } + assert.equal(isCommandAllowed('stack.es.search', policy), true) + assert.equal(isCommandAllowed('stack.es.ml.get-records', policy), false) + }) + + it('profile + blocked: blocked does not affect commands already excluded by profile', () => { + const policy = { profile: 'serverless' as const, blocked: ['stack.es.ml.*'] } + // cloud.hosted is already blocked by the serverless profile; the extra blocked entry doesn't matter + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', policy), false) + }) }) describe('command policy enforcement', () => {