UI: Build policy generator (#10985) (#11209)

* wip policy stanza builder

* Implement add and delete new stanza functionality

* refactor to use Set()

* make copy updates, add callback functionality to pass policy to parent

* move policy formatter to util, add test coverage

* =separate acl-policy component into two smaller components, add automation snippets

* reorganize utils, add test coverage

* finish rename

* reduce scope of builder

* fix spacing

* add a ns test, remove unused spacing var

* rename arg

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-12-05 17:28:39 -05:00 committed by GitHub
parent c34e25fb76
commit 63bbbd163b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1204 additions and 9 deletions

View file

@ -38,6 +38,11 @@
padding-right: size_variables.$spacing-12;
}
.side-padding-24 {
padding-left: size_variables.$spacing-24;
padding-right: size_variables.$spacing-24;
}
.has-padding-8 {
padding: size_variables.$spacing-8;
}

View file

@ -0,0 +1,48 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#each this.stanzas as |stanza idx|}}
<CodeGenerator::Policy::Stanza
@index={{idx}}
@onChange={{this.handleChange}}
@onDelete={{fn this.deleteStanza stanza}}
@stanza={{stanza}}
class="has-top-bottom-margin-12"
/>
{{/each}}
<div class="has-top-padding-m">
<Hds::Button @icon="plus" @text="Add rule" @color="secondary" {{on "click" this.addStanza}} data-test-button="Add rule" />
</div>
<Hds::Separator />
<Hds::Card::Container @hasBorder={{true}} class="has-top-padding-m has-bottom-padding-m side-padding-24">
<Hds::Reveal @text="Automation snippets" class="is-fullwidth" data-test-reveal="Automation snippets">
<Hds::Layout::Flex @gap="24" class="has-top-bottom-margin-12">
{{#each-in this.snippetTypes as |value label|}}
<Hds::Form::Radio::Field
name="snippetType"
@value={{value}}
checked={{eq this.snippetType value}}
{{on "change" this.handleRadio}}
data-test-input={{value}}
as |F|
>
<F.Label>{{label}}</F.Label>
</Hds::Form::Radio::Field>
{{/each-in}}
</Hds::Layout::Flex>
<Hds::CodeBlock
@language="hcl"
@value={{this.snippet}}
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
data-test-field="snippets"
/>
</Hds::Reveal>
</Hds::Card::Container>

View file

@ -0,0 +1,87 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { formatCli, writePolicy } from 'core/utils/code-generators/cli';
import { formatEot } from 'core/utils/code-generators/formatters';
import { PolicyStanza } from 'core/utils/code-generators/policy';
import { terraformTemplate } from 'core/utils/code-generators/terraform';
import type { HTMLElementEvent } from 'vault/forms';
import type NamespaceService from 'vault/services/namespace';
interface Args {
// Callback to pass the generated policy back to the parent
onPolicyChange: ((policy: string) => void) | undefined;
policyName?: 'string';
}
export default class CodeGeneratorPolicyBuilder extends Component<Args> {
@service declare readonly namespace: NamespaceService;
snippetTypes = { terraform: 'Terraform Vault Provider', cli: 'CLI' };
@tracked snippetType = 'terraform';
@tracked stanzas = [new PolicyStanza()];
get formattedPolicy() {
return this.stanzas.map((s) => s.preview).join('\n');
}
get snippet() {
const policyName = this.args.policyName || '<policy name>';
const policy = formatEot(this.formattedPolicy);
const options = {
// only add namespace if we're not in root (when namespace is '')
...(!this.namespace.inRootNamespace ? { namespace: `"${this.namespace.path}"` } : null),
name: `"${policyName}"`,
policy,
};
switch (this.snippetType) {
case 'terraform':
return terraformTemplate({ resource: 'vault_policy', options });
case 'cli':
return formatCli({ command: writePolicy(policyName), content: `- ${policy}` });
default:
return '';
}
}
@action
addStanza() {
const stanzas = [...this.stanzas, new PolicyStanza()];
this.updateStanzas(stanzas);
}
@action
deleteStanza(stanza: PolicyStanza) {
const remaining = [...this.stanzas.filter((s) => s !== stanza)];
// Create an empty template if the only stanza was deleted
const stanzas = remaining.length ? [...remaining] : [new PolicyStanza()];
this.updateStanzas(stanzas);
}
@action
handleChange() {
if (this.args.onPolicyChange) {
this.args.onPolicyChange(this.formattedPolicy);
}
}
@action
handleRadio(event: HTMLElementEvent<HTMLInputElement>) {
const { value } = event.target;
this.snippetType = value;
}
updateStanzas(stanzas: PolicyStanza[]) {
// Trigger an update by reassigning tracked variable
this.stanzas = stanzas;
this.handleChange();
}
}

View file

@ -0,0 +1,68 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container
class="has-top-padding-m has-bottom-padding-m side-padding-24"
@hasBorder={{true}}
@level="mid"
...attributes
data-test-card-container={{@index}}
>
<Hds::Layout::Flex @justify="space-between" class="has-bottom-margin-xs">
<Hds::Text::Body @tag="p" @weight="semibold" @color="strong">
Rule
</Hds::Text::Body>
<Hds::Form::Toggle::Field {{on "input" this.togglePreview}} data-test-toggle-input="preview" as |F|>
<F.Label data-test-form-field-label="preview">{{if this.showPreview "Hide" "Show"}} preview</F.Label>
</Hds::Form::Toggle::Field>
</Hds::Layout::Flex>
{{#if this.showPreview}}
<Hds::CodeBlock
@language="hcl"
@value={{@stanza.preview}}
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
data-test-field="preview"
/>
{{else}}
<Hds::Layout::Flex @gap="8">
<Hds::Form::TextInput::Base
@type="text"
@value={{@stanza.path}}
aria-label="Resource path"
name="path-{{@index}}"
{{on "input" this.setPath}}
autocomplete="off"
placeholder="Enter a resource path"
data-test-input="path"
/>
<Hds::Button
@icon="trash"
@isIconOnly={{true}}
@text="Delete"
@color="critical"
{{on "click" @onDelete}}
data-test-button="Delete"
/>
</Hds::Layout::Flex>
<Hds::Form::Checkbox::Group @layout="horizontal" as |G|>
<G.Legend class="has-top-padding-m">Capabilities</G.Legend>
{{#each this.permissions as |capability|}}
<G.CheckboxField
checked={{this.hasCapability capability}}
@value={{capability}}
{{on "input" this.setPermissions}}
data-test-checkbox={{capability}}
as |F|
>
<F.Label data-test-form-field-label={{capability}}>{{capability}}</F.Label>
</G.CheckboxField>
{{/each}}
</Hds::Form::Checkbox::Group>
{{/if}}
</Hds::Card::Container>

View file

@ -0,0 +1,47 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { ACL_CAPABILITIES, isAclCapability } from 'core/utils/code-generators/policy';
import type { HTMLElementEvent } from 'vault/forms';
import type { AclCapability, PolicyStanza } from 'core/utils/code-generators/policy';
interface Args {
stanza: PolicyStanza;
onChange: (() => void) | undefined;
}
export default class CodeGeneratorPolicyStanza extends Component<Args> {
@tracked showPreview = false;
readonly permissions = ACL_CAPABILITIES;
hasCapability = (c: AclCapability) => this.args.stanza.capabilities.has(c);
@action
togglePreview() {
this.showPreview = !this.showPreview;
}
@action
setPath(event: HTMLElementEvent<HTMLInputElement>) {
this.args.stanza.path = event.target.value;
this.args.onChange?.();
}
@action
setPermissions(event: HTMLElementEvent<HTMLInputElement>) {
const { value, checked } = event.target;
if (isAclCapability(value)) {
const capabilities = new Set(this.args.stanza.capabilities);
checked ? capabilities.add(value) : capabilities.delete(value);
// Update stanza with list of capabilities
this.args.stanza.capabilities = capabilities;
this.args.onChange?.();
}
}
}

View file

@ -0,0 +1,15 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export interface FormatCliArgs {
command: string; // The CLI command (e.g., "policy write my-policy")
content: string; // The content/body to pass to the command
}
export const formatCli = ({ command, content }: FormatCliArgs) => {
return `vault ${command} ${content}`.trim();
};
export const writePolicy = (name: string) => `policy write ${name}`;

View file

@ -0,0 +1,15 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
/**
* Wraps content in a heredoc (EOT - End of Text) block.
* @param content - The content to wrap in the heredoc block
* @returns A formatted heredoc string with EOT delimiters
*/
export const formatEot = (content: string) => {
return `<<EOT
${content}
EOT`;
};

View file

@ -0,0 +1,43 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { tracked } from '@glimmer/tracking';
export const ACL_CAPABILITIES = ['create', 'read', 'update', 'delete', 'list', 'patch', 'sudo'] as const;
export type AclCapability = (typeof ACL_CAPABILITIES)[number]; // 'create' | 'read' | 'update' | 'delete' | 'list' | 'patch' | 'sudo'
export class PolicyStanza {
@tracked capabilities: Set<AclCapability> = new Set();
@tracked path = '';
get preview() {
return aclTemplate(this.path, Array.from(this.capabilities));
}
}
/**
* Formats an ACL policy stanza in HCL
* @param path - The Vault API path the policy applies to (e.g., "secret/data/*")
* @param capabilities - Array of capabilities (e.g., '"read", "list"')
* @returns A formatted HCL policy string
*/
export const aclTemplate = (path: string, capabilities: AclCapability[]) => {
const formatted = formatCapabilities(capabilities);
// Indentions below are intentional so policy renders prettily in code editor
return `path "${path}" {
capabilities = [${formatted}]
}`;
};
// returns a string with each capability wrapped in double quotes => ["create", "read"]
export const formatCapabilities = (capabilities: AclCapability[]) => {
// Filter from ACL_CAPABILITIES to list capabilities in consistent order
const allowed = ACL_CAPABILITIES.filter((p) => capabilities.includes(p));
return allowed.length ? allowed.map((c) => `"${c}"`).join(', ') : '';
};
// Type Guards
export const isAclCapability = (value: string): value is AclCapability =>
ACL_CAPABILITIES.includes(value as AclCapability);

View file

@ -0,0 +1,34 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export interface TerraformTemplateArgs {
resource: string; // The Terraform resource type (e.g., "vault_auth_backend", "vault_mount")
localId?: string; // The local identifier/label for this resource instance. Used in Terraform commands like `terraform import vault_auth_backend.example github` (<- is "example" here)
options: TerraformOptions; // Key/value pairs that build the terraform configuration
}
/**
* Generates a Terraform resource block for Vault providers.
* @see https://registry.terraform.io/providers/hashicorp/vault/latest/docs
*/
export const terraformTemplate = ({
resource = '',
localId = '<local identifier>',
options,
}: TerraformTemplateArgs) => {
const formattedContent = formatTerraformOptions(options);
return `resource "${resource}" "${localId}" {
${formattedContent.join('\n\n')}
}`;
};
export const formatTerraformOptions = (options: TerraformOptions) => {
const argReferences = [];
for (const [key, value] of Object.entries(options)) {
// Additional spaces before "key" are so argument references are indented over
argReferences.push(` ${key} = ${value}`);
}
return argReferences;
};

View file

@ -0,0 +1,6 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/code-generator/policy/builder';

View file

@ -0,0 +1,6 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/code-generator/policy/stanza';

View file

@ -34,6 +34,7 @@ export const GENERAL = {
confirmButton: '[data-test-confirm-button]', // used most often on modal or confirm popups
confirmTrigger: '[data-test-confirm-action-trigger]',
copyButton: '[data-test-copy-button]',
revealButton: (label: string) => `[data-test-reveal="${label}"] button`, // intended for Hds::Reveal components
// there should only be one submit button per view (e.g. one per form) so this does not need to be dynamic
// this button should be used for any kind of "submit" on a form or "save" action.
submitButton: '[data-test-submit]',
@ -169,7 +170,8 @@ export const GENERAL = {
},
/* ────── Cards ────── */
cardContainer: (title: string) => `[data-test-card-container="${title}"]`,
cardContainer: (title: string) =>
title ? `[data-test-card-container="${title}"]` : '[data-test-card-container]',
/* ────── Modals & Flyouts ────── */
flyout: '[data-test-flyout]',

View file

@ -3,15 +3,14 @@
* SPDX-License-Identifier: BUSL-1.1
*/
const root = ['create', 'read', 'update', 'delete', 'list', 'patch'];
import { ACL_CAPABILITIES, formatCapabilities } from 'core/utils/code-generators/policy';
// returns a string with each capability wrapped in double quotes => ["create", "read"]
const format = (array) => array.map((c) => `"${c}"`).join(', ');
const root = ACL_CAPABILITIES;
export const adminPolicy = (backend) => {
return `
path "${backend}/*" {
capabilities = [${format(root)}]
capabilities = [${formatCapabilities(root)}]
},
`;
};
@ -20,7 +19,7 @@ export const dataPolicy = ({ backend, secretPath = '*', capabilities = root }) =
// "delete" capability on this path can delete latest version
return `
path "${backend}/data/${secretPath}" {
capabilities = [${format(capabilities)}]
capabilities = [${formatCapabilities(capabilities)}]
}
`;
};
@ -36,7 +35,7 @@ export const subkeysPolicy = ({ backend, secretPath = '*' }) => {
export const dataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => {
return `
path "${backend}/data/app/${secretPath}" {
capabilities = [${format(capabilities)}]
capabilities = [${formatCapabilities(capabilities)}]
}
`;
};
@ -45,7 +44,7 @@ export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root
// "delete" capability on this path can destroy all versions
return `
path "${backend}/metadata/${secretPath}" {
capabilities = [${format(capabilities)}]
capabilities = [${formatCapabilities(capabilities)}]
}
`;
};
@ -53,7 +52,7 @@ export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root
export const metadataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => {
return `
path "${backend}/metadata/app/${secretPath}" {
capabilities = [${format(capabilities)}]
capabilities = [${formatCapabilities(capabilities)}]
}
`;
};

View file

@ -0,0 +1,301 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { ACL_CAPABILITIES } from 'core/utils/code-generators/policy';
module('Integration | Component | code-generator/policy/builder', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.onPolicyChange = Sinon.spy();
this.policyName = undefined;
this.renderComponent = () => {
return render(hbs`
<CodeGenerator::Policy::Builder @onPolicyChange={{this.onPolicyChange}} @policyName={{this.policyName}} />`);
};
this.assertPolicyUpdate = (assert, expected, message) => {
const [policy] = this.onPolicyChange.lastCall.args;
assert.strictEqual(policy, expected, `onPolicyChange is called ${message}`);
};
this.assertEmptyTemplate = async (assert, { index } = {}) => {
const container = index ? GENERAL.cardContainer(index) : '';
assert.dom(`${container} ${GENERAL.inputByAttr('path')}`).hasValue('');
assert.dom(`${container} ${GENERAL.toggleInput('preview')}`).isNotChecked();
ACL_CAPABILITIES.forEach((capability) => {
assert.dom(`${container} ${GENERAL.checkboxByAttr(capability)}`).isNotChecked();
});
// check empty preview state
await click(`${container} ${GENERAL.toggleInput('preview')}`);
const expectedPreview = `path "" {
capabilities = []
}`;
assert
.dom(GENERAL.fieldByAttr('preview'))
.exists('it renders preview')
.hasText(expectedPreview, 'preview is empty');
};
});
test('it renders', async function (assert) {
await this.renderComponent();
await this.assertEmptyTemplate(assert);
assert.dom(GENERAL.button('Add rule')).exists({ count: 1 });
assert.dom(GENERAL.revealButton('Automation snippets')).hasAttribute('aria-expanded', 'false');
await click(GENERAL.revealButton('Automation snippets'));
assert.dom(GENERAL.revealButton('Automation snippets')).hasAttribute('aria-expanded', 'true');
assert.dom(GENERAL.inputByAttr('terraform')).isChecked();
assert.dom(GENERAL.inputByAttr('cli')).isNotChecked();
});
test('it renders default snippets', async function (assert) {
await this.renderComponent();
await click(GENERAL.revealButton('Automation snippets'));
let expectedSnippet = `resource "vault_policy" "<local identifier>" {
name = "<policy name>"
policy = <<EOT
path "" {
capabilities = []
}
EOT
}`;
assert
.dom(GENERAL.fieldByAttr('snippets'))
.hasText(expectedSnippet, 'it renders empty terraform snippet');
expectedSnippet = `vault policy write <policy name> - <<EOT
path "" {
capabilities = []
}
EOT`;
await click(GENERAL.inputByAttr('cli'));
assert.dom(GENERAL.inputByAttr('cli')).isChecked();
assert.dom(GENERAL.inputByAttr('terraform')).isNotChecked();
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders empty cli snippet');
});
test('it includes namespace in snippet for non-root namespaces', async function (assert) {
const namespace = this.owner.lookup('service:namespace');
namespace.path = 'admin';
await this.renderComponent();
await click(GENERAL.revealButton('Automation snippets'));
const expectedSnippet = `resource "vault_policy" "<local identifier>" {
namespace = "admin"
name = "<policy name>"
policy = <<EOT
path "" {
capabilities = []
}
EOT
}`;
assert
.dom(GENERAL.fieldByAttr('snippets'))
.hasText(expectedSnippet, 'it renders empty terraform snippet');
});
test('it does not call onPolicyChange when callback is not provided', async function (assert) {
this.onPolicyChange = undefined;
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
await click(GENERAL.checkboxByAttr('read'));
await click(GENERAL.button('Add rule'));
assert.true(true, 'no errors are thrown when callback is undefined');
});
test('it adds a rule', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Add rule'));
assert
.dom(GENERAL.cardContainer())
.exists({ count: 2 }, 'two templates render after clicking "Add rule"');
await this.assertEmptyTemplate(assert, { index: '1' });
});
test('it deletes a rule', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
// Fill in template
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
await click(GENERAL.checkboxByAttr('patch'));
// Delete the only rendered template
await click(GENERAL.button('Delete'));
// One template renders but content should reset
assert
.dom(GENERAL.cardContainer())
.exists({ count: 1 }, 'it still renders one rule after deleting the only rule');
await this.assertEmptyTemplate(assert);
});
test('it maintains state across multiple rules', async function (assert) {
await this.renderComponent();
// Set up first rule
await fillIn(GENERAL.inputByAttr('path'), 'first/path');
await click(GENERAL.checkboxByAttr('read'));
// Add second rule
await click(GENERAL.button('Add rule'));
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'second/path');
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.inputByAttr('path')}`).hasValue('first/path');
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('read')}`).isChecked();
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('update')}`).isNotChecked();
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`).hasValue('second/path');
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`).isChecked();
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`).isNotChecked();
});
test('it deletes the correct rule when multiple exist', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'first/path');
await click(GENERAL.checkboxByAttr('read'));
// Second rule
await click(GENERAL.button('Add rule'));
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'second/path');
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
// Third rule
await click(GENERAL.button('Add rule'));
await fillIn(`${GENERAL.cardContainer('2')} ${GENERAL.inputByAttr('path')}`, 'third/path');
await click(`${GENERAL.cardContainer('2')} ${GENERAL.checkboxByAttr('list')}`);
assert.dom(GENERAL.cardContainer()).exists({ count: 3 });
// Delete middle rule
await click(`${GENERAL.cardContainer('1')} ${GENERAL.button('Delete')}`);
assert.dom(GENERAL.cardContainer()).exists({ count: 2 });
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.inputByAttr('path')}`).hasValue('first/path');
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('read')}`).isChecked();
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`).hasValue('third/path');
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('list')}`).isChecked();
});
test('it updates snippets', async function (assert) {
this.policyName = 'my-secure-policy';
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
await click(GENERAL.checkboxByAttr('patch'));
await click(GENERAL.revealButton('Automation snippets'));
// Check terraform snippet
let expectedSnippet = `resource "vault_policy" "<local identifier>" {
name = "my-secure-policy"
policy = <<EOT
path "my/super/secret/*" {
capabilities = ["patch"]
}
EOT
}`;
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders terraform snippet');
// Check CLI snippet
expectedSnippet = `vault policy write my-secure-policy - <<EOT
path "my/super/secret/*" {
capabilities = ["patch"]
}
EOT`;
await click(GENERAL.inputByAttr('cli'));
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders cli snippet');
});
test('it passes policy updates as changes are made', async function (assert) {
await this.renderComponent();
// Inputting path triggers callback
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
let expectedPolicy = `path "my/super/secret/*" {
capabilities = []
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when path changes');
// Clicking checkbox triggers callback
await click(GENERAL.checkboxByAttr('update'));
expectedPolicy = `path "my/super/secret/*" {
capabilities = ["update"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when a capability is selected');
// Adding a rule triggers callback
await click(GENERAL.button('Add rule'));
expectedPolicy = `path "my/super/secret/*" {
capabilities = ["update"]
}
path "" {
capabilities = []
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when a rule is added');
// Updating added rule triggers callback
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'prod/');
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('list')}`);
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`);
expectedPolicy = `path "my/super/secret/*" {
capabilities = ["update"]
}
path "prod/" {
capabilities = ["read", "list"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when an additional rule updates');
// Unchecking box triggers callback
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`);
expectedPolicy = `path "my/super/secret/*" {
capabilities = ["update"]
}
path "prod/" {
capabilities = ["list"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when checkbox is unselected');
// Deleting a rule triggers callback
await click(GENERAL.button('Delete'));
expectedPolicy = `path "prod/" {
capabilities = ["list"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when a rule is deleted');
});
// These tests ensure paths are never used as input identifiers.
// The policy generator may not render in a form and needs to be flexible so it intentionally supports
// multiple templates with the same or no path.
test('it supports multiple rules with the same path', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
await click(GENERAL.checkboxByAttr('patch'));
await click(GENERAL.button('Add rule'));
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'test/path');
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
const expectedPolicy = `path "test/path" {
capabilities = ["patch"]
}
path "test/path" {
capabilities = ["update"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when rules have the same path');
});
test('it supports multiple rules with an empty path', async function (assert) {
await this.renderComponent();
await click(GENERAL.checkboxByAttr('list'));
await click(GENERAL.button('Add rule'));
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('delete')}`);
const expectedPolicy = `path "" {
capabilities = ["list"]
}
path "" {
capabilities = ["delete"]
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when rules do have an empty path');
});
});

View file

@ -0,0 +1,180 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn, typeIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { ACL_CAPABILITIES, PolicyStanza } from 'core/utils/code-generators/policy';
module('Integration | Component | code-generator/policy/stanza', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.stanza = new PolicyStanza();
this.onDelete = Sinon.spy();
this.onChange = Sinon.spy();
this.renderComponent = () => {
return render(hbs`
<CodeGenerator::Policy::Stanza
@index="0"
@onChange={{this.onChange}}
@onDelete={{this.onDelete}}
@stanza={{this.stanza}}
/>`);
};
});
test('it renders', async function (assert) {
await this.renderComponent();
assert
.dom(GENERAL.inputByAttr('path'))
.hasValue('')
.hasAttribute('placeholder', 'Enter a resource path')
.hasAttribute('aria-label', 'Resource path')
.hasAttribute('autocomplete', 'off');
assert.dom(GENERAL.inputByAttr('path')).hasValue('');
assert.dom(GENERAL.button('Delete')).exists({ count: 1 });
// Assert checkboxes
assert.dom('fieldset input[type="checkbox"]').exists({ count: 7 }, 'it renders 7 checkboxes');
ACL_CAPABILITIES.forEach((capability) => {
assert.dom(GENERAL.fieldLabel(capability)).hasText(capability);
assert.dom(GENERAL.checkboxByAttr(capability)).isNotChecked();
});
// Assert preview toggle
assert.dom(GENERAL.toggleInput('preview')).exists().isNotChecked();
assert.dom(GENERAL.fieldLabel('preview')).hasText('Show preview');
// Check empty preview state
await click(GENERAL.toggleInput('preview'));
assert.dom(GENERAL.toggleInput('preview')).isChecked();
assert.dom(GENERAL.fieldLabel('preview')).hasText('Hide preview');
const expectedPreview = `path "" {
capabilities = []
}`;
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview);
});
test('it renders policy preview', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
await click(GENERAL.checkboxByAttr('update'));
await click(GENERAL.checkboxByAttr('patch'));
await click(GENERAL.toggleInput('preview'));
let expectedPreview = `path "some/api/path" {
capabilities = ["update", "patch"]
}`;
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview, 'it renders initial preview');
// Toggle back to add more capabilities then check preview again
await click(GENERAL.toggleInput('preview'));
await typeIn(GENERAL.inputByAttr('path'), '/*');
await click(GENERAL.checkboxByAttr('patch')); // uncheck
await click(GENERAL.checkboxByAttr('list')); // check new
// Confirm policy preview updated
await click(GENERAL.toggleInput('preview'));
expectedPreview = `path "some/api/path/*" {
capabilities = ["update", "list"]
}`;
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview, 'it updates preview');
});
test('it maintains checkbox state when toggling to show and hide preview', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
await click(GENERAL.checkboxByAttr('update'));
assert.dom(GENERAL.checkboxByAttr('update')).isChecked();
// Toggle to show preview
await click(GENERAL.toggleInput('preview'));
assert.dom(GENERAL.toggleInput('preview')).isChecked();
// Toggle back to checkboxes
await click(GENERAL.toggleInput('preview'));
assert.dom(GENERAL.toggleInput('preview')).isNotChecked();
assert.dom(GENERAL.checkboxByAttr('update')).isChecked('update is still checked after viewing preview');
});
test('it selects and unselects capabilities', async function (assert) {
await this.renderComponent();
await click(GENERAL.checkboxByAttr('update')); // first onChange call
assert.dom(GENERAL.checkboxByAttr('update')).isChecked();
let expectedSet = new Set(['update']);
assert.deepEqual(
this.stanza.capabilities,
expectedSet,
`has expected capabilities: ${[...expectedSet].join(', ')}`
);
// Check "delete"
await click(GENERAL.checkboxByAttr('delete')); // second onChange call
assert.dom(GENERAL.checkboxByAttr('delete')).isChecked();
expectedSet = new Set(['update', 'delete']);
assert.deepEqual(
this.stanza.capabilities,
expectedSet,
`has expected capabilities: ${[...expectedSet].join(', ')}`
);
// Uncheck "delete"
await click(GENERAL.checkboxByAttr('delete')); // third onChange call
assert.dom(GENERAL.checkboxByAttr('delete')).isNotChecked();
expectedSet = new Set(['update']);
assert.deepEqual(
this.stanza.capabilities,
expectedSet,
`has expected capabilities: ${[...expectedSet].join(', ')}`
);
assert.strictEqual(this.onChange.callCount, 3, 'onChange is called every time a capability is selected');
});
test('it selects all capabilities and updates @stanza', async function (assert) {
await this.renderComponent();
// check in random order to assert generator orders them
for (const capability of ['list', 'read', 'sudo', 'create', 'delete', 'patch', 'update']) {
await click(GENERAL.checkboxByAttr(capability));
}
await click(GENERAL.toggleInput('preview'));
const expectedPreview = `path "" {
capabilities = ["create", "read", "update", "delete", "list", "patch", "sudo"]
}`;
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview);
assert.deepEqual(
this.stanza.capabilities,
new Set(['create', 'read', 'update', 'delete', 'list', 'patch', 'sudo']),
'stanza includes every capability, in order'
);
});
test('it updates @stanza when path changes', async function (assert) {
await this.renderComponent();
await typeIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
assert.strictEqual(this.stanza.path, 'my/super/secret/*', '"path" is updated');
});
// This whole test will fail if conditionals around @onChange are problematically refactored
test('it does not call onChange when callback is not provided', async function (assert) {
this.onChange = undefined;
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
await click(GENERAL.checkboxByAttr('read'));
assert.true(true, 'no errors are thrown when callback is undefined');
});
test('it calls onChange when path changes', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
assert.true(this.onChange.calledOnce, 'onChange is called');
});
test('it calls onChange when a checkbox is selected', async function (assert) {
await this.renderComponent();
await click(GENERAL.checkboxByAttr('update'));
assert.true(this.onChange.calledOnce, 'onChange is called');
});
test('it calls onDelete', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Delete'));
assert.true(this.onDelete.calledOnce, 'onDelete is called');
});
});

View file

@ -0,0 +1,29 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { formatCli } from 'core/utils/code-generators/cli';
module('Integration | Util | code-generators/cli', function (hooks) {
setupTest(hooks);
test('formatCli: it formats CLI command with content', async function (assert) {
const content = `- <<EOT
path "secret/*" {
capabilities = ["read"]
}
EOT`;
const formatted = formatCli({ command: 'policy write my-policy', content: content });
const expected = `vault policy write my-policy ${content}`;
assert.strictEqual(formatted, expected, 'it formats CLI command with content');
});
test('formatCli: it handles empty content', async function (assert) {
const formatted = formatCli({ command: 'policy list', content: '' });
const expected = 'vault policy list';
assert.strictEqual(formatted, expected, 'it formats CLI command with empty content');
});
});

View file

@ -0,0 +1,52 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { formatEot } from 'core/utils/code-generators/formatters';
module('Integration | Util | code-generators/formatters', function (hooks) {
setupTest(hooks);
test('formatEot: it wraps content in EOT heredoc block', async function (assert) {
const content = 'path "secret/*" {\n capabilities = ["read"]\n}';
const formatted = formatEot(content);
const expected = `<<EOT
path "secret/*" {
capabilities = ["read"]
}
EOT`;
assert.strictEqual(formatted, expected, 'it wraps content with line breaks "\n"');
});
test('formatEot: it handles single line content', async function (assert) {
const formatted = formatEot('single line content');
const expected = `<<EOT
single line content
EOT`;
assert.strictEqual(formatted, expected, 'it wraps single line');
});
test('formatEot: it handles empty content', async function (assert) {
const formatted = formatEot('');
const expected = `<<EOT
EOT`;
assert.strictEqual(formatted, expected, 'it wraps empty content');
});
test('formatEot: it handles multi-line content', async function (assert) {
const content = `line 1
line 2
line 3`;
const formatted = formatEot(content);
const expected = `<<EOT
line 1
line 2
line 3
EOT`;
assert.strictEqual(formatted, expected, 'it wraps multi-line content');
});
});

View file

@ -0,0 +1,140 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import {
aclTemplate,
formatCapabilities,
isAclCapability,
ACL_CAPABILITIES,
PolicyStanza,
} from 'core/utils/code-generators/policy';
module('Integration | Util | code-generators/policy', function (hooks) {
setupTest(hooks);
test('aclTemplate: it formats a policy', async function (assert) {
const formatted = aclTemplate('my-path/*', ['list', 'read', 'delete']);
const expected = `path "my-path/*" {
capabilities = ["read", "delete", "list"]
}`;
assert.strictEqual(formatted, expected, 'it formats an ACL policy');
});
test('aclTemplate: it handles empty path and capabilities', async function (assert) {
const formatted = aclTemplate('', []);
const expected = `path "" {
capabilities = []
}`;
assert.strictEqual(formatted, expected, 'it formats empty policy');
});
test('aclTemplate: it handles single capability', async function (assert) {
const formatted = aclTemplate('auth/token/lookup-self', ['read']);
const expected = `path "auth/token/lookup-self" {
capabilities = ["read"]
}`;
assert.strictEqual(formatted, expected, 'it formats policy with single capability');
});
test('formatCapabilities: it formats capabilities in consistent order', async function (assert) {
const formatted = formatCapabilities(['list', 'read', 'delete']);
const expected = '"read", "delete", "list"';
assert.strictEqual(formatted, expected, 'it formats capabilities in ACL_CAPABILITIES order');
});
test('formatCapabilities: it filters out invalid capabilities', async function (assert) {
const formatted = formatCapabilities(['read', 'invalid', 'list']);
const expected = '"read", "list"';
assert.strictEqual(formatted, expected, 'it filters out invalid capabilities');
});
test('formatCapabilities: it returns empty string for empty array', async function (assert) {
const formatted = formatCapabilities([]);
assert.strictEqual(formatted, '', 'it returns empty string for no capabilities');
});
test('formatCapabilities: it handles single capability', async function (assert) {
const formatted = formatCapabilities(['read']);
const expected = '"read"';
assert.strictEqual(formatted, expected, 'it formats single capability');
});
test('formatCapabilities: it handles all capabilities', async function (assert) {
const sorted = [...ACL_CAPABILITIES].sort(); // alphabetize so input order is different than expected output
const formatted = formatCapabilities(sorted);
const expected = '"create", "read", "update", "delete", "list", "patch", "sudo"';
assert.strictEqual(formatted, expected, 'it formats all capabilities in order');
});
test('isAclCapability: it returns true for valid capabilities', async function (assert) {
ACL_CAPABILITIES.forEach((cap) => {
assert.true(isAclCapability(cap), `${cap} is a valid capability`);
});
});
test('isAclCapability: it returns false for invalid capabilities', async function (assert) {
assert.false(isAclCapability('invalid'), 'invalid is not a valid capability');
assert.false(isAclCapability('write'), 'write is not a valid capability');
assert.false(isAclCapability(''), 'empty string is not a valid capability');
assert.false(isAclCapability('READ'), 'uppercase READ is not a valid capability');
});
test('PolicyStanza: it initializes with empty capabilities and path', async function (assert) {
const stanza = new PolicyStanza();
assert.strictEqual(stanza.path, '', 'path is empty');
assert.strictEqual(stanza.capabilities.size, 0, 'capabilities set is empty');
});
test('PolicyStanza: it generates preview for single capability', async function (assert) {
const stanza = new PolicyStanza();
stanza.path = 'secret/data/*';
stanza.capabilities.add('read');
const expected = `path "secret/data/*" {
capabilities = ["read"]
}`;
assert.strictEqual(stanza.preview, expected, 'it generates correct preview');
});
test('PolicyStanza: it generates preview for multiple capabilities', async function (assert) {
const stanza = new PolicyStanza();
stanza.path = 'auth/*';
stanza.capabilities.add('list');
stanza.capabilities.add('read');
stanza.capabilities.add('create');
const expected = `path "auth/*" {
capabilities = ["create", "read", "list"]
}`;
assert.strictEqual(stanza.preview, expected, 'it generates preview with multiple capabilities');
});
test('PolicyStanza: it generates preview without path and capabilities', async function (assert) {
const stanza = new PolicyStanza();
const expected = `path "" {
capabilities = []
}`;
assert.strictEqual(stanza.preview, expected, 'it generates preview with empty capabilities');
});
test('PolicyStanza: it updates preview when capabilities change', async function (assert) {
const stanza = new PolicyStanza();
stanza.path = 'secret/*';
stanza.capabilities.add('read');
const firstPreview = stanza.preview;
stanza.capabilities.add('list');
const secondPreview = stanza.preview;
const expected = `path "secret/*" {
capabilities = ["read", "list"]
}`;
assert.notStrictEqual(firstPreview, secondPreview, 'preview updates when capabilities change');
assert.true(secondPreview.includes('"read", "list"'), 'new preview includes both capabilities');
assert.strictEqual(secondPreview, expected, 'new preview reflects updates');
});
});

View file

@ -0,0 +1,107 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { terraformTemplate, formatTerraformOptions } from 'core/utils/code-generators/terraform';
module('Integration | Util | code-generators/terraform', function (hooks) {
setupTest(hooks);
test('formatTerraformOptions: it formats single option', async function (assert) {
const options = { type: '"github"' };
const formatted = formatTerraformOptions(options);
const expected = [' type = "github"'];
assert.propEqual(formatted, expected, 'it formats single option correctly');
});
test('formatTerraformOptions: it formats multiple options', async function (assert) {
const options = {
type: '"userpass"',
path: '"userpass"',
description: '"User password auth"',
};
const formatted = formatTerraformOptions(options);
assert.strictEqual(formatted.length, 3, 'it returns array with 3 items');
assert.true(formatted.includes(' type = "userpass"'), 'it includes type option');
assert.true(formatted.includes(' path = "userpass"'), 'it includes path option');
assert.true(formatted.includes(' description = "User password auth"'), 'it includes description option');
});
test('formatTerraformOptions: it formats options with numbers', async function (assert) {
const options = { max_ttl: 3600 };
const formatted = formatTerraformOptions(options);
const expected = [' max_ttl = 3600'];
assert.propEqual(formatted, expected, 'it formats numeric values');
});
test('formatTerraformOptions: it handles empty options', async function (assert) {
const options = {};
const formatted = formatTerraformOptions(options);
assert.propEqual(formatted, [], 'it returns empty array for empty options');
});
test('terraformTemplate: it generates basic terraform resource', async function (assert) {
const formatted = terraformTemplate({
resource: 'vault_auth_backend',
localId: 'example',
options: { type: '"github"' },
});
const expected = `resource "vault_auth_backend" "example" {
type = "github"
}`;
assert.strictEqual(formatted, expected, 'it generates terraform resource');
});
test('terraformTemplate: it generates terraform resource with multiple options', async function (assert) {
const formatted = terraformTemplate({
resource: 'vault_mount',
localId: 'kv',
options: {
path: '"secret"',
type: '"kv-v2"',
description: '"KV Version 2 secret engine"',
},
});
assert.true(formatted.includes('resource "vault_mount" "kv"'), 'it includes resource declaration');
assert.true(formatted.includes('path = "secret"'), 'it includes path option');
assert.true(formatted.includes('type = "kv-v2"'), 'it includes type option');
assert.true(formatted.includes('description = "KV Version 2 secret engine"'), 'it includes description');
});
test('terraformTemplate: it uses default localId when not provided', async function (assert) {
const formatted = terraformTemplate({
resource: 'vault_auth_backend',
options: { type: '"github"' },
});
assert.true(formatted.includes('"<local identifier>"'), 'it uses default local identifier');
});
test('terraformTemplate: it handles empty resource name', async function (assert) {
const formatted = terraformTemplate({
resource: '',
localId: 'test',
options: { key: '"value"' },
});
assert.true(formatted.includes('resource "" "test"'), 'it handles empty resource name');
});
test('terraformTemplate: it formats multiple options with proper spacing', async function (assert) {
const formatted = terraformTemplate({
resource: 'vault_auth_backend',
localId: 'userpass',
options: {
type: '"userpass"',
path: '"userpass"',
tune: '{}',
},
});
// Check that options are separated by double newlines
const lines = formatted.split('\n\n');
assert.strictEqual(lines.length, 3, 'options are separated by blank lines');
});
});

View file

@ -0,0 +1,11 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
// Argument references for a TFVP (terraform vault provider) resource
interface TerraformOptions {
name?: string;
namespace?: string;
policy?: string;
}