Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export function resolveContext(config: ConfigFile, contextName: string): Resolve
if (ctx.elasticsearch != null) resolved.elasticsearch = ctx.elasticsearch
if (ctx.kibana != null) resolved.kibana = ctx.kibana
if (ctx.cloud != null) resolved.cloud = ctx.cloud
return { context: resolved }
const result: ResolvedConfig = { context: resolved }
if (config.commands != null) result.commands = config.commands
return result
}

/** Options accepted by {@link loadConfig}. */
Expand Down
16 changes: 16 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export const ContextSchema = z
{ 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.
*/
export const CommandPolicySchema = z
.looseObject({
allowed: z.array(z.string().min(1)).min(1).optional(),
blocked: z.array(z.string().min(1)).min(1).optional(),
})
.refine(
(p) => !(p.allowed != null && p.blocked != null),
{ error: 'commands: "allowed" and "blocked" are mutually exclusive' },
)

/** The root configuration file structure. */
export const ConfigFileSchema = z
.looseObject({
Expand All @@ -58,6 +73,7 @@ export const ConfigFileSchema = z
(map) => Object.keys(map).length > 0,
{ error: 'contexts must contain at least one entry' },
),
commands: CommandPolicySchema.optional(),
})
.refine(
(cfg) => cfg['current_context'] in cfg.contexts,
Expand Down
5 changes: 5 additions & 0 deletions src/config/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ export function setResolvedConfig(config: ResolvedConfig): void {
export function getResolvedConfig(): ResolvedConfig | undefined {
return _config
}

/** Resets the store to its initial state. Intended for test cleanup only. */
export function _testResetConfig(): void {
_config = undefined
}
6 changes: 6 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ServiceBlockSchema,
ContextSchema,
ConfigFileSchema,
CommandPolicySchema,
} from './schema.ts'

/**
Expand All @@ -35,6 +36,9 @@ export type Context = z.infer<typeof ContextSchema>
/** The root configuration file structure. */
export type ConfigFile = z.infer<typeof ConfigFileSchema>

/** Policy controlling which commands are permitted to run. */
export type CommandPolicy = z.infer<typeof CommandPolicySchema>

/** The active context after resolution — only its configured service blocks, no extras. */
export interface ResolvedContext {
elasticsearch?: ServiceBlock
Expand All @@ -45,4 +49,6 @@ export interface ResolvedContext {
/** Typed configuration object passed to command handlers after loading and context resolution. */
export interface ResolvedConfig {
context: ResolvedContext
/** Optional command allow/deny policy from the config file. */
commands?: CommandPolicy
}
52 changes: 51 additions & 1 deletion src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Command } from 'commander'
import { z } from 'zod'
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import type { ResolvedConfig } from './config/types.ts'
import type { ResolvedConfig, CommandPolicy } from './config/types.ts'
import { getResolvedConfig } from './config/store.ts'
import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts'
import type { SchemaArgDefinition } from './lib/schema-args.ts'
Expand Down Expand Up @@ -177,6 +177,35 @@ export function _testSetStdinReader(fn: () => string): () => void {
return () => { stdinReader = prev }
}

/**
* Returns true if `commandDotPath` is permitted under the given policy.
*
* Matching rules:
* - No policy (or empty policy) → always allowed
* - `allowed` list → command must match at least one entry
* - `blocked` list → command must NOT match any entry
* - Entries ending with `.*` match any command whose dot-path starts with the prefix and a `.`
* (e.g. `elasticsearch.*` matches `elasticsearch.search` and `elasticsearch.indices.get`
* but NOT `elasticsearch` itself)
* - All other entries are exact matches
*/
export function isCommandAllowed(commandDotPath: string, policy: CommandPolicy | undefined): boolean {
if (policy == null) return true

function matches(pattern: string): boolean {
if (pattern.endsWith('.*')) {
const prefix = pattern.slice(0, -2)
return commandDotPath === prefix + '.' + commandDotPath.slice(prefix.length + 1) &&
commandDotPath.startsWith(prefix + '.')
}
return commandDotPath === pattern
}

if (policy.allowed != null) return policy.allowed.some(matches)
if (policy.blocked != null) return !policy.blocked.some(matches)
return true
}

/** converts a kebab-case option name to camelCase to match Commander's opts() keys */
function camelCase(s: string): string {
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
Expand Down Expand Up @@ -511,6 +540,27 @@ export function defineCommand<T extends z.ZodType>(config: CommandConfig<T>): Op
}

const resolvedConfig = getResolvedConfig()

// enforce command policy before any other work
if (resolvedConfig?.commands != null) {
// commandPath returns e.g. "elastic elasticsearch search"; strip root program name and dot-join
const parts = commandPath(cmd).split(' ')
// if mounted under a root program (e.g. "elastic"), strip that first segment
const dotPath = (parts.length > 1 ? parts.slice(1) : parts).join('.')
if (!isCommandAllowed(dotPath, resolvedConfig.commands)) {
if (fmt === 'json') {
process.stdout.write(JSON.stringify({
error: {
code: 'command_blocked',
message: `command "${dotPath}" is not allowed by the current policy`,
},
}) + '\n')
throw Object.assign(new Error('command_blocked'), { exitCode: 1 })
}
return cmd.error(`command "${dotPath}" is not allowed by the current policy`)
}
}

const parsed: ParsedResult<z.infer<T>> = {
options,
...(resolvedConfig != null ? { config: resolvedConfig } : {}),
Expand Down
93 changes: 93 additions & 0 deletions test/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,96 @@ describe('T018: loadConfig — --config override', () => {
assert.ok(!result.ok, 'loadConfig should fail for a nonexistent explicit config path')
})
})

// ---------------------------------------------------------------------------
// T019 — commands policy threading through loadConfig and resolveContext
// ---------------------------------------------------------------------------

describe('T019: commands policy in ResolvedConfig', () => {
let tmpDir: string
after(async () => rm(tmpDir, { recursive: true }))
before(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-policy-'))
})

it('resolveContext includes commands policy when present', () => {
const config: ConfigFile = {
...VALID_CONFIG_OBJECT,
commands: { allowed: ['ping', 'elasticsearch.search'] },
}
const resolved = resolveContext(config, 'local')
assert.deepEqual(resolved.commands, { allowed: ['ping', 'elasticsearch.search'] })
})

it('resolveContext omits commands when not present in config', () => {
const resolved = resolveContext(VALID_CONFIG_OBJECT, 'local')
assert.equal(resolved.commands, undefined)
})

it('loadConfig threads allowed list into ResolvedConfig', async () => {
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: key1
commands:
allowed:
- ping
- elasticsearch.search
`.trimStart()
const configPath = join(tmpDir, 'allowed.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(result.ok)
if (!result.ok) return
assert.deepEqual(result.value.commands, { allowed: ['ping', 'elasticsearch.search'] })
})

it('loadConfig threads blocked list into ResolvedConfig', async () => {
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: key1
commands:
blocked:
- elasticsearch.bulk
- config.*
`.trimStart()
const configPath = join(tmpDir, 'blocked.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(result.ok)
if (!result.ok) return
assert.deepEqual(result.value.commands, { blocked: ['elasticsearch.bulk', 'config.*'] })
})

it('loadConfig returns error for config with both allowed and blocked', async () => {
const yaml = `
current_context: local
contexts:
local:
elasticsearch:
url: http://localhost:9200
auth:
api_key: key1
commands:
allowed:
- ping
blocked:
- elasticsearch.bulk
`.trimStart()
const configPath = join(tmpDir, 'both.yml')
await writeFile(configPath, yaml)
const result = await loadConfig({ configPath })
assert.ok(!result.ok, 'should fail when both allowed and blocked are present')
if (result.ok) return
assert.match(result.error.message, /mutually exclusive/)
})
})
95 changes: 94 additions & 1 deletion test/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { ApiKeyAuthSchema, BasicAuthSchema, AuthSchema, ServiceBlockSchema, ContextSchema, ConfigFileSchema } from '../../src/config/schema.ts'
import { ApiKeyAuthSchema, BasicAuthSchema, AuthSchema, ServiceBlockSchema, ContextSchema, ConfigFileSchema, CommandPolicySchema } from '../../src/config/schema.ts'

const esBlock = { url: 'https://es.example.com:9200', auth: { api_key: 'key1' } }
const kibanaBlock = { url: 'https://kibana.example.com:5601', auth: { username: 'u', password: 'p' } }
Expand Down Expand Up @@ -208,6 +208,69 @@ describe('ContextSchema', () => {
})
})

describe('CommandPolicySchema', () => {
it('accepts an allowed list', () => {
const result = CommandPolicySchema.safeParse({ allowed: ['ping', 'elasticsearch.search'] })
assert.equal(result.success, true)
if (result.success) {
assert.deepEqual(result.data.allowed, ['ping', 'elasticsearch.search'])
assert.equal(result.data.blocked, undefined)
}
})

it('accepts a blocked list', () => {
const result = CommandPolicySchema.safeParse({ blocked: ['elasticsearch.bulk', 'config.set'] })
assert.equal(result.success, true)
if (result.success) {
assert.deepEqual(result.data.blocked, ['elasticsearch.bulk', 'config.set'])
assert.equal(result.data.allowed, undefined)
}
})

it('accepts wildcard entries in allowed list', () => {
const result = CommandPolicySchema.safeParse({ allowed: ['elasticsearch.*', 'ping'] })
assert.equal(result.success, true)
})

it('accepts wildcard entries in blocked list', () => {
const result = CommandPolicySchema.safeParse({ blocked: ['config.*'] })
assert.equal(result.success, true)
})

it('accepts neither allowed nor blocked (no-op policy)', () => {
const result = CommandPolicySchema.safeParse({})
assert.equal(result.success, true)
})

it('rejects both allowed and blocked being present', () => {
const result = CommandPolicySchema.safeParse({
allowed: ['ping'],
blocked: ['elasticsearch.bulk'],
})
assert.equal(result.success, false)
})

it('rejects an empty allowed array', () => {
const result = CommandPolicySchema.safeParse({ allowed: [] })
assert.equal(result.success, false)
})

it('rejects an empty blocked array', () => {
const result = CommandPolicySchema.safeParse({ blocked: [] })
assert.equal(result.success, false)
})

it('rejects empty strings in allowed list', () => {
const result = CommandPolicySchema.safeParse({ allowed: ['ping', ''] })
assert.equal(result.success, false)
})

it('rejects empty strings in blocked list', () => {
const result = CommandPolicySchema.safeParse({ blocked: [''] })
assert.equal(result.success, false)
})
})

describe('ConfigFileSchema', () => {
const validContexts = {
production: { elasticsearch: esBlock },
Expand Down Expand Up @@ -280,4 +343,34 @@ describe('ConfigFileSchema', () => {
})
assert.equal(result.success, true)
})

it('accepts a valid commands.allowed section', () => {
const result = ConfigFileSchema.safeParse({
'current_context': 'production',
contexts: { production: { elasticsearch: esBlock } },
commands: { allowed: ['ping', 'elasticsearch.search'] },
})
assert.equal(result.success, true)
if (result.success) {
assert.deepEqual(result.data.commands?.allowed, ['ping', 'elasticsearch.search'])
}
})

it('accepts a valid commands.blocked section', () => {
const result = ConfigFileSchema.safeParse({
'current_context': 'production',
contexts: { production: { elasticsearch: esBlock } },
commands: { blocked: ['elasticsearch.bulk'] },
})
assert.equal(result.success, true)
})

it('rejects commands with both allowed and blocked', () => {
const result = ConfigFileSchema.safeParse({
'current_context': 'production',
contexts: { production: { elasticsearch: esBlock } },
commands: { allowed: ['ping'], blocked: ['elasticsearch.bulk'] },
})
assert.equal(result.success, false)
})
})
Loading
Loading