diff --git a/changelog/21583.txt b/changelog/21583.txt
new file mode 100644
index 0000000000..f16fd383d2
--- /dev/null
+++ b/changelog/21583.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+ui: add example modal to policy form
+```
\ No newline at end of file
diff --git a/ui/app/components/modal-form/policy-template.hbs b/ui/app/components/modal-form/policy-template.hbs
index 177b7cce0f..afcc1df42b 100644
--- a/ui/app/components/modal-form/policy-template.hbs
+++ b/ui/app/components/modal-form/policy-template.hbs
@@ -27,37 +27,7 @@
{{/if}}
{{#if this.showExamplePolicy}}
-
- {{#if (eq this.policy.policyType "acl")}}
-
- ACL Policies are written in Hashicorp Configuration Language (
- HCL
- ) or JSON and describe which paths in Vault a user or machine is allowed to access. Here is an example policy:
-
- {{else}}
-
- Role Governing Policies (RGPs) are tied to client tokens or identities which is similar to
- ACL policies. They use
- Sentinel
- as a language framework to enable fine-grained policy decisions.
-
-
- Here is an example policy that uses RGP to restrict access to the
- admin
- policy such that a user named James or has the
- Team Lead
- role can manage the
- admin
- policy:
-
- {{/if}}
-
-
+
{{else}}
{{/if}}
+
+
+ You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field.
+
+ {{! Only renders button (and modal) if not already in the "create policy" modal }}
+ {{#if @renderPolicyExampleModal}}
+
+ See
+ .
+
+ {{! Only renders more information if already in the "create policy" modal }}
+ {{else}}
+
+ More information about
+ {{uppercase @model.policyType}}
+ policies can be found
+
+ here.
+
+
+ {{/if}}
+
{{#each @model.additionalAttrs as |attr|}}
{{/each}}
-
-
- More information about
- {{uppercase @model.policyType}}
- policies can be found
-
- here.
-
-
-
-
\ No newline at end of file
+
+{{! SAMPLE POLICY MODAL. Only renders modal if not already in create policy modal }}
+{{#if @renderPolicyExampleModal}}
+
+
+ {{! code-mirror modifier does not render value initially until focus event fires }}
+ {{! wait until the Modal is rendered and then show the PolicyExample (contains JsonEditor) }}
+ {{#if this.showTemplateModal}}
+
+ {{/if}}
+
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/policy-form.js b/ui/app/components/policy-form.js
index 64f11c7197..2658a5b362 100644
--- a/ui/app/components/policy-form.js
+++ b/ui/app/components/policy-form.js
@@ -19,11 +19,13 @@ import { tracked } from '@glimmer/tracking';
* @model={{this.model}}
* @onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
* @onCancel={{transition-to "vault.cluster.policies.index"}}
+ * @renderPolicyExampleModal={{true}}
* />
* ```
* @callback onCancel - callback triggered when cancel button is clicked
* @callback onSave - callback triggered when save button is clicked. Passes saved model
* @param {object} model - ember data model from createRecord
+ * @param {boolean} renderPolicyExampleModal - whether or not the policy form should render the modal containing the policy example
*/
export default class PolicyFormComponent extends Component {
@@ -31,6 +33,7 @@ export default class PolicyFormComponent extends Component {
@tracked errorBanner = '';
@tracked showFileUpload = false;
+ @tracked showTemplateModal = false;
@task
*save(event) {
diff --git a/ui/app/templates/vault/cluster/policies/create.hbs b/ui/app/templates/vault/cluster/policies/create.hbs
index 8212800bd9..a820a2bdf6 100644
--- a/ui/app/templates/vault/cluster/policies/create.hbs
+++ b/ui/app/templates/vault/cluster/policies/create.hbs
@@ -21,4 +21,5 @@
@model={{this.model}}
@onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
@onCancel={{transition-to "vault.cluster.policies.index"}}
+ @renderPolicyExampleModal={{true}}
/>
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/policy/edit.hbs b/ui/app/templates/vault/cluster/policy/edit.hbs
index bd057e4e9f..5cb88041ca 100644
--- a/ui/app/templates/vault/cluster/policy/edit.hbs
+++ b/ui/app/templates/vault/cluster/policy/edit.hbs
@@ -46,4 +46,5 @@
@model={{this.model}}
@onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
@onCancel={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
+ @renderPolicyExampleModal={{true}}
/>
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/json-editor.hbs b/ui/lib/core/addon/components/json-editor.hbs
index 620057ff8b..3f430acb20 100644
--- a/ui/lib/core/addon/components/json-editor.hbs
+++ b/ui/lib/core/addon/components/json-editor.hbs
@@ -38,6 +38,7 @@
}}
class={{if @readOnly "readonly-codemirror"}}
data-test-component="code-mirror-modifier"
+ data-test-example-modal-json-text
>
{{#if @helpText}}
diff --git a/ui/lib/core/addon/components/policy-example.hbs b/ui/lib/core/addon/components/policy-example.hbs
new file mode 100644
index 0000000000..b46716647a
--- /dev/null
+++ b/ui/lib/core/addon/components/policy-example.hbs
@@ -0,0 +1,50 @@
+
+ {{#if (eq @policyType "acl")}}
+
+ ACL Policies are written in Hashicorp Configuration Language (
+ HCL
+ ) or JSON and describe which paths in Vault a user or machine is allowed to access. Here is an example policy:
+
+ {{else if (eq @policyType "rgp")}}
+
+ Role Governing Policies (RGPs) are tied to client tokens or identities which is similar to
+ ACL policies. They use
+ Sentinel
+ as a language framework to enable fine-grained policy decisions.
+
+
+ Here is an example policy that uses RGP to restrict access to the
+ admin
+ policy such that a user named "James Thomas" or has the
+ Team Lead
+ role can manage the
+ admin
+ policy:
+
+ {{else}}
+
+ Endpoint Governing Policies (EGPs) are tied to particular paths (e.g.
+ aws/creds/
+ ) instead of tokens. They use
+ Sentinel
+ as a language to access
+ properties
+ of the incoming requests.
+
+
+ Here is an example policy that fulfills the requirement of an incoming request to be performed during the business
+ hours 7:00am to 6:00pm on work days:
+
+ {{/if}}
+
+
+
+
+ More information about
+ {{uppercase @policyType}}
+ policies can be found
+
+ here.
+
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/policy-example.js b/ui/lib/core/addon/components/policy-example.js
new file mode 100644
index 0000000000..64bf2229d3
--- /dev/null
+++ b/ui/lib/core/addon/components/policy-example.js
@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+
+/**
+ * @module PolicyExample
+ * The PolicyExample component receives a policy type ('acl', 'rgp', or 'egp') and renders a copyable policy example of
+ * that type using the component. Inside a modal, the PolicyExample component must be wrapped in a conditional
+ * (example below), otherwise the value won't render until it's focused.
+ *
+ * @example
+ *
+ *
+ * @example (in modal)
+ *
+ *
+ * {{! code-mirror modifier does not render value initially until focus event fires }}
+ * {{! wait until the Modal is rendered and then show the PolicyExample (contains JsonEditor) }}
+ * {{#if this.showTemplateModal}}
+ *
+ * {{/if}}
+ *
+ *
+ *
+ *
+ *
+ * ```
+ * @param {string} policyType - policy type to decide which template to render; can either be "acl" or "rgp"
+ */
+
+export default class PolicyExampleComponent extends Component {
+ // formatting here is purposeful so that whitespace renders correctly in JsonEditor
+ policyTemplates = {
+ acl: `
+# Grant 'create', 'read' , 'update', and ‘list’ permission
+# to paths prefixed by 'secret/*'
+path "secret/*" {
+ capabilities = [ "create", "read", "update", "list" ]
+}
+
+# Even though we allowed secret/*, this line explicitly denies
+# secret/super-secret. This takes precedence.
+path "secret/super-secret" {
+ capabilities = ["deny"]
+}
+`,
+ rgp: `
+# Import strings library that exposes common string operations
+import "strings"
+
+# Conditional rule (precond) checks the incoming request endpoint
+# targeted to sys/policies/acl/admin
+precond = rule {
+ strings.has_prefix(request.path, "sys/policies/admin")
+}
+
+# Vault checks to see if the request was made by an entity
+# named James Thomas or Team Lead role defined as its metadata
+main = rule when precond {
+ identity.entity.metadata.role is "Team Lead" or
+ identity.entity.name is "James Thomas"
+}
+`,
+ egp: `
+import "time"
+
+# Expect requests to only happen during work days (Monday
+# through Friday) 0 for Sunday and 6 for Saturday
+workdays = rule {
+ time.now.weekday > 0 and time.now.weekday < 6
+}
+
+# Expect requests to only happen during work hours (7:00 am -
+# 6:00 pm)
+workhours = rule {
+ time.now.hour > 7 and time.now.hour < 18
+}
+main = rule {
+ workdays and workhours
+}
+`,
+ };
+ moreInformationLinks = {
+ acl: '/vault/docs/concepts/policies#capabilities',
+ rgp: '/vault/tutorials/policies/sentinel#role-governing-policies-rgps',
+ egp: '/vault/docs/enterprise/sentinel#endpoint-governing-policies-egps',
+ };
+}
diff --git a/ui/lib/core/app/components/policy-example.js b/ui/lib/core/app/components/policy-example.js
new file mode 100644
index 0000000000..4c7cb1a725
--- /dev/null
+++ b/ui/lib/core/app/components/policy-example.js
@@ -0,0 +1 @@
+export { default } from 'core/components/policy-example';
diff --git a/ui/tests/integration/components/policy-example-test.js b/ui/tests/integration/components/policy-example-test.js
new file mode 100644
index 0000000000..14705221d0
--- /dev/null
+++ b/ui/tests/integration/components/policy-example-test.js
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'vault/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+const SELECTORS = {
+ policyText: '[data-test-modal-title]',
+ policyDescription: (type) => `[data-test-example-modal-text=${type}]`,
+ jsonText: '[data-test-example-modal-json-text]',
+ informationLink: '[data-test-example-modal-information-link]',
+};
+
+module('Integration | Component | policy-example', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders the correct paragraph for ACL policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert
+ .dom(SELECTORS.policyDescription('acl'))
+ .hasText(
+ 'ACL Policies are written in Hashicorp Configuration Language ( HCL ) or JSON and describe which paths in Vault a user or machine is allowed to access. Here is an example policy:'
+ );
+ });
+
+ test('it renders the correct paragraph for RGP policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert
+ .dom(SELECTORS.policyDescription('rgp'))
+ .hasText(
+ 'Role Governing Policies (RGPs) are tied to client tokens or identities which is similar to ACL policies . They use Sentinel as a language framework to enable fine-grained policy decisions.'
+ );
+ });
+
+ test('it renders the correct paragraph for EGP policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert
+ .dom(SELECTORS.policyDescription('egp'))
+ .hasText(
+ `Endpoint Governing Policies (EGPs) are tied to particular paths (e.g. aws/creds/ ) instead of tokens. They use Sentinel as a language to access properties of the incoming requests.`
+ );
+ });
+
+ test('it renders the correct JSON editor text for ACL policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert.dom(SELECTORS.jsonText).includesText(`# Grant 'create', 'read' , 'update', and ‘list’ permission`);
+ });
+
+ test('it renders the correct JSON editor text for RGP policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert
+ .dom(SELECTORS.jsonText)
+ .includesText(`# Import strings library that exposes common string operations`);
+ });
+
+ test('it renders the correct JSON editor text for EGP policy', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert.dom(SELECTORS.jsonText).includesText(`# Expect requests to only happen during work days (Monday `);
+ });
+});
diff --git a/ui/tests/integration/components/policy-form-test.js b/ui/tests/integration/components/policy-form-test.js
index d75713cea1..2b5b8a3b40 100644
--- a/ui/tests/integration/components/policy-form-test.js
+++ b/ui/tests/integration/components/policy-form-test.js
@@ -18,6 +18,14 @@ const SELECTORS = {
saveButton: '[data-test-policy-save]',
cancelButton: '[data-test-policy-cancel]',
error: '[data-test-message-error]',
+ // For example modal:
+ exampleButton: '[data-test-policy-example-button]',
+ exampleModal: '[data-test-policy-example-modal]',
+ exampleModalTitle: '[data-test-modal-title]',
+ exampleModalClose: '[data-test-modal-close-button]',
+ // For additional fields for EGP policy:
+ fields: (name) => `[data-test-field=${name}]`,
+ pathsInput: (index) => `[data-test-string-list-input="${index}"]`,
};
module('Integration | Component | policy-form', function (hooks) {
@@ -42,6 +50,9 @@ module('Integration | Component | policy-form', function (hooks) {
this.put('/v1/sys/policies/rgp/**', () => {
return [204, { 'Content-Type': 'application/json' }];
});
+ this.put('/v1/sys/policies/egp/**', () => {
+ return [204, { 'Content-Type': 'application/json' }];
+ });
});
});
hooks.afterEach(function () {
@@ -102,6 +113,37 @@ module('Integration | Component | policy-form', function (hooks) {
assert.ok(this.onSave.calledOnceWith(this.model));
});
+ test('it renders the form for new EGP policy', async function (assert) {
+ const model = this.store.createRecord('policy/egp');
+ const policy = `
+ path "secret/*" {
+ capabilities = [ "create", "read", "update", "list" ]
+ }
+ `;
+ this.set('model', model);
+ await render(hbs`
+
+ `);
+ assert.dom(SELECTORS.nameInput).exists({ count: 1 }, 'Name input exists');
+ assert.dom(SELECTORS.nameInput).hasNoText('Name field is not filled');
+ assert.dom(SELECTORS.uploadFileToggle).exists({ count: 1 }, 'Upload file toggle exists');
+ await fillIn(SELECTORS.nameInput, 'Foo');
+ assert.strictEqual(this.model.name, 'foo', 'Input sets name on model to lowercase input');
+ await fillIn(`${SELECTORS.policyEditor} textarea`, policy);
+ assert.strictEqual(this.model.policy, policy, 'Policy editor sets policy on model');
+ assert.dom(SELECTORS.fields('paths')).exists('Paths field exists');
+ assert.dom(SELECTORS.pathsInput('0')).exists('0 field exists');
+ await fillIn(SELECTORS.pathsInput('0'), 'my path');
+ assert.ok(this.onSave.notCalled);
+ assert.dom(SELECTORS.saveButton).hasText('Create policy');
+ await click(SELECTORS.saveButton);
+ assert.ok(this.onSave.calledOnceWith(this.model));
+ });
+
test('it toggles to upload a new policy and uploads file', async function (assert) {
const policy = `
path "auth/token/lookup-self" {
@@ -156,6 +198,7 @@ module('Integration | Component | policy-form', function (hooks) {
await click(SELECTORS.saveButton);
assert.ok(this.onSave.calledOnceWith(this.model));
});
+
test('it renders the form to edit existing RGP policy', async function (assert) {
const model = this.store.createRecord('policy/rgp', {
name: 'bar',
@@ -185,6 +228,43 @@ module('Integration | Component | policy-form', function (hooks) {
await click(SELECTORS.saveButton);
assert.ok(this.onSave.calledOnceWith(this.model));
});
+
+ test('it renders the form to edit existing EGP policy', async function (assert) {
+ const model = this.store.createRecord('policy/egp', {
+ name: 'bar',
+ policy: 'some policy content',
+ paths: ['first path'],
+ });
+ model.save();
+
+ this.set('model', model);
+ await render(hbs`
+
+ `);
+ assert.dom(SELECTORS.nameInput).doesNotExist('Name input is not rendered');
+ assert.dom(SELECTORS.uploadFileToggle).doesNotExist('Upload file toggle does not exist');
+ await fillIn(`${SELECTORS.policyEditor} textarea`, 'updated-');
+ assert.strictEqual(
+ this.model.policy,
+ 'updated-some policy content',
+ 'Policy editor updates policy value on model'
+ );
+ await fillIn(SELECTORS.pathsInput('1'), 'second path');
+ assert.strictEqual(
+ JSON.stringify(this.model.paths),
+ '["first path","second path"]',
+ 'Second path field is updated on model'
+ );
+ assert.ok(this.onSave.notCalled);
+ assert.dom(SELECTORS.saveButton).hasText('Save', 'Save button text is correct');
+ await click(SELECTORS.saveButton);
+ assert.ok(this.onSave.calledOnceWith(this.model));
+ });
+
test('it shows the error message on form when save fails', async function (assert) {
const model = this.store.createRecord('policy/acl', {
name: 'bad-policy',
@@ -203,4 +283,138 @@ module('Integration | Component | policy-form', function (hooks) {
assert.ok(this.onSave.notCalled);
assert.dom(SELECTORS.error).includesText('An error occurred');
});
+
+ test('it does not create a new policy when the cancel button is clicked', async function (assert) {
+ const policy = `
+ path "secret/*" {
+ capabilities = [ "create", "read", "update", "list" ]
+ }
+ `;
+ await render(hbs`
+
+ `);
+ await fillIn(SELECTORS.nameInput, 'Foo');
+ assert.strictEqual(this.model.name, 'foo', 'Input sets name on model to lowercase input');
+ await fillIn(`${SELECTORS.policyEditor} textarea`, policy);
+ assert.strictEqual(this.model.policy, policy, 'Policy editor sets policy on model');
+
+ await click(SELECTORS.cancelButton);
+ assert.ok(this.onSave.notCalled);
+ assert.ok(this.onCancel.calledOnce, 'Form calls onCancel');
+ });
+
+ test('it does not save edits when the cancel button is clicked', async function (assert) {
+ const model = this.store.createRecord('policy/acl', {
+ name: 'foo',
+ policy: 'some policy content',
+ });
+ model.save();
+
+ this.set('model', model);
+ await render(hbs`
+
+ `);
+ await fillIn(`${SELECTORS.policyEditor} textarea`, 'updated-');
+ assert.strictEqual(
+ this.model.policy,
+ 'updated-some policy content',
+ 'Policy editor updates policy value on model'
+ );
+ await click(SELECTORS.cancelButton);
+ assert.ok(this.onSave.notCalled);
+ assert.ok(this.onCancel.calledOnce, 'Form calls onCancel');
+
+ await render(hbs`
+
+ `);
+ assert.strictEqual(
+ this.model.policy,
+ 'some policy content',
+ 'Policy editor shows original policy content, meaning that onCancel worked successfully'
+ );
+ });
+
+ test('it does not render the button and modal for the policy example if not specified to', async function (assert) {
+ await render(hbs`
+
+ `);
+ assert.dom(SELECTORS.exampleModal).doesNotExist('Modal for the policy example does not exist');
+ assert.dom(SELECTORS.exampleButton).doesNotExist('Button for the policy example modal does not exist');
+ });
+
+ test('it renders the button and modal for the policy example when specified to', async function (assert) {
+ await render(hbs`
+
+
+ `);
+ assert.dom(SELECTORS.exampleButton).exists({ count: 1 }, 'Modal for the policy example exists');
+ assert.dom(SELECTORS.exampleButton).exists({ count: 1 }, 'Button for the policy example modal exists');
+ });
+
+ test('it renders the correct title for ACL example for the policy example modal', async function (assert) {
+ await render(hbs`
+
+
+ `);
+ await click(SELECTORS.exampleButton);
+ assert.dom(SELECTORS.exampleModalTitle).hasText('Example ACL Policy');
+ });
+
+ test('it renders the correct title for RGP example for the policy example modal', async function (assert) {
+ const model = this.store.createRecord('policy/rgp');
+ this.set('model', model);
+ await render(hbs`
+
+
+ `);
+ await click(SELECTORS.exampleButton);
+ assert.dom(SELECTORS.exampleModalTitle).hasText('Example RGP Policy');
+ });
+
+ test('it renders the correct title for EGP example for the policy example modal', async function (assert) {
+ const model = this.store.createRecord('policy/egp');
+ this.set('model', model);
+ await render(hbs`
+
+
+ `);
+ await click(SELECTORS.exampleButton);
+ assert.dom(SELECTORS.exampleModalTitle).hasText('Example EGP Policy');
+ });
});