diff --git a/changelog/17749.txt b/changelog/17749.txt
new file mode 100644
index 000000000..54c88a800
--- /dev/null
+++ b/changelog/17749.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+ui: Add inline policy creation when creating an identity entity or group
+```
diff --git a/ui/app/components/modal-form/FYI.md b/ui/app/components/modal-form/FYI.md
new file mode 100644
index 000000000..35b65b1b4
--- /dev/null
+++ b/ui/app/components/modal-form/FYI.md
@@ -0,0 +1,3 @@
+These templates parent form components that render within a modal to create inline items via SearchSelectWithModal.
+The modal opens when a user searches for an item that doesn't exist and clicks 'No results found for "${term}". Click here to create it.'
+The templates are rendered using the {{component}} helper in 'search-select-with-modal.hbs' and are responsible for creating the model passed to the form.
diff --git a/ui/app/components/modal-form/oidc-assignment-template.hbs b/ui/app/components/modal-form/oidc-assignment-template.hbs
new file mode 100644
index 000000000..8af71a03f
--- /dev/null
+++ b/ui/app/components/modal-form/oidc-assignment-template.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ui/app/components/modal-form/oidc-assignment-template.js b/ui/app/components/modal-form/oidc-assignment-template.js
new file mode 100644
index 000000000..f99bbbd62
--- /dev/null
+++ b/ui/app/components/modal-form/oidc-assignment-template.js
@@ -0,0 +1,36 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module ModalForm::OidcAssignmentTemplate
+ * ModalForm::OidcAssignmentTemplate components render within a modal and create a model using the input from the search select. The model is passed to the oidc/assignment-form.
+ *
+ * @example
+ *
+ * ```
+ * @callback onCancel - callback triggered when cancel button is clicked
+ * @callback onSave - callback triggered when save button is clicked
+ * @param {string} nameInput - the name of the newly created assignment
+ */
+
+export default class OidcAssignmentTemplate extends Component {
+ @service store;
+ @tracked assignment = null; // model record passed to oidc/assignment-form
+
+ constructor() {
+ super(...arguments);
+ this.assignment = this.store.createRecord('oidc/assignment', { name: this.args.nameInput });
+ }
+
+ @action onSave(assignmentModel) {
+ this.args.onSave(assignmentModel);
+ // Reset component assignment for next use
+ this.assignment = null;
+ }
+}
diff --git a/ui/app/components/modal-form/policy-template.hbs b/ui/app/components/modal-form/policy-template.hbs
new file mode 100644
index 000000000..7c752a5ab
--- /dev/null
+++ b/ui/app/components/modal-form/policy-template.hbs
@@ -0,0 +1,77 @@
+{{#if this.policy.policyType}}
+
+{{/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 this.policy.policyType}}
+
+ {{else}}
+
+ {{/if}}
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/modal-form/policy-template.js b/ui/app/components/modal-form/policy-template.js
new file mode 100644
index 000000000..62736b32b
--- /dev/null
+++ b/ui/app/components/modal-form/policy-template.js
@@ -0,0 +1,82 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module ModalForm::PolicyTemplate
+ * ModalForm::PolicyTemplate components are meant to render within a modal for creating a new policy of unknown type.
+ *
+ * @example
+ *
+ * ```
+ * @callback onCancel - callback triggered when cancel button is clicked
+ * @callback onSave - callback triggered when save button is clicked
+ * @param {string} nameInput - the name of the newly created policy
+ */
+
+export default class PolicyTemplate extends Component {
+ @service store;
+ @service version;
+
+ @tracked policy = null; // model record passed to policy-form
+ @tracked showExamplePolicy = false;
+
+ get policyOptions() {
+ return [
+ { label: 'ACL Policy', value: 'acl', isDisabled: false },
+ { label: 'Role Governing Policy', value: 'rgp', isDisabled: !this.version.hasSentinel },
+ ];
+ }
+ // 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"
+}
+`,
+ };
+
+ @action
+ setPolicyType(type) {
+ if (this.policy) this.policy.unloadRecord(); // if user selects a different type, clear from store before creating a new record
+ // Create form model once type is chosen
+ this.policy = this.store.createRecord(`policy/${type}`, { name: this.args.nameInput });
+ }
+
+ @action
+ onSave(policyModel) {
+ this.args.onSave(policyModel);
+ // Reset component policy for next use
+ this.policy = null;
+ }
+}
diff --git a/ui/app/components/oidc/assignment-form.js b/ui/app/components/oidc/assignment-form.js
index d521bce93..3906d2816 100644
--- a/ui/app/components/oidc/assignment-form.js
+++ b/ui/app/components/oidc/assignment-form.js
@@ -15,11 +15,10 @@ import { tracked } from '@glimmer/tracking';
* @onSave={transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}
* />
* ```
- * @callback onCancel
- * @callback onSave
+
* @param {object} model - The parent's model
- * @param {string} onCancel - callback triggered when cancel button is clicked
- * @param {string} onSave - callback triggered when save button is clicked
+ * @callback onCancel - callback triggered when cancel button is clicked
+ * @callback onSave - callback triggered when save button is clicked*
*/
export default class OidcAssignmentFormComponent extends Component {
diff --git a/ui/app/components/oidc/client-form.js b/ui/app/components/oidc/client-form.js
index 2de6cf92a..166234277 100644
--- a/ui/app/components/oidc/client-form.js
+++ b/ui/app/components/oidc/client-form.js
@@ -11,15 +11,13 @@ import { task } from 'ember-concurrency';
* ```js
*
* ```
- * @callback onCancel
- * @callback onSave
* @param {Object} model - oidc client model
- * @param {onCancel} onCancel - callback triggered when cancel button is clicked
- * @param {onSave} onSave - callback triggered on save success
+ * @callback onCancel - callback triggered when cancel button is clicked
+ * @callback onSave - callback triggered on save success
+ * @param {boolean} [isInline=false] - true when form is rendered within a modal
*/
export default class OidcClientForm extends Component {
- @service store;
@service flashMessages;
@tracked modelValidations;
@tracked errorBanner;
diff --git a/ui/app/components/policy-form.hbs b/ui/app/components/policy-form.hbs
new file mode 100644
index 000000000..019c63af5
--- /dev/null
+++ b/ui/app/components/policy-form.hbs
@@ -0,0 +1,109 @@
+
\ No newline at end of file
diff --git a/ui/app/components/policy-form.js b/ui/app/components/policy-form.js
new file mode 100644
index 000000000..6b367cea1
--- /dev/null
+++ b/ui/app/components/policy-form.js
@@ -0,0 +1,69 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import trimRight from 'vault/utils/trim-right';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module PolicyForm
+ * PolicyForm components are the forms to create and edit all types of policies. This is only the form, not the outlying layout, and expects that the form model is passed from the parent.
+ *
+ * @example
+ *
+ * ```
+ * @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
+ */
+
+export default class PolicyFormComponent extends Component {
+ @service flashMessages;
+
+ @tracked errorBanner = '';
+ @tracked file = null;
+ @tracked showFileUpload = false;
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { name, policyType, isNew } = this.args.model;
+ yield this.args.model.save();
+ this.flashMessages.success(
+ `${policyType.toUpperCase()} policy "${name}" was successfully ${isNew ? 'created' : 'updated'}.`
+ );
+ this.args.onSave(this.args.model);
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ }
+ }
+
+ @action
+ setModelName({ target }) {
+ this.args.model.name = target.value.toLowerCase();
+ }
+
+ @action
+ setPolicyFromFile(index, fileInfo) {
+ const { value, fileName } = fileInfo;
+ this.args.model.policy = value;
+ if (!this.args.model.name) {
+ const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']);
+ this.args.model.name = trimmedFileName.toLowerCase();
+ }
+ this.showFileUpload = false;
+ }
+
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+}
diff --git a/ui/app/components/ui-wizard.js b/ui/app/components/ui-wizard.js
index 7a7341f05..009654952 100644
--- a/ui/app/components/ui-wizard.js
+++ b/ui/app/components/ui-wizard.js
@@ -7,6 +7,7 @@ export default Component.extend({
classNames: ['ui-wizard-container'],
wizard: service(),
auth: service(),
+ router: service(),
shouldRender: or('auth.currentToken', 'wizard.showWhenUnauthenticated'),
currentState: alias('wizard.currentState'),
@@ -16,6 +17,7 @@ export default Component.extend({
componentState: alias('wizard.componentState'),
nextFeature: alias('wizard.nextFeature'),
nextStep: alias('wizard.nextStep'),
+ currentRouteName: alias('router.currentRouteName'),
actions: {
dismissWizard() {
diff --git a/ui/app/controllers/vault/cluster/policies/create.js b/ui/app/controllers/vault/cluster/policies/create.js
deleted file mode 100644
index 69e22f68f..000000000
--- a/ui/app/controllers/vault/cluster/policies/create.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Controller from '@ember/controller';
-import trimRight from 'vault/utils/trim-right';
-import PolicyEditController from 'vault/mixins/policy-edit-controller';
-
-export default Controller.extend(PolicyEditController, {
- showFileUpload: false,
- file: null,
- actions: {
- setPolicyFromFile(index, fileInfo) {
- const { value, fileName } = fileInfo;
- const model = this.model;
- model.set('policy', value);
- if (!model.get('name')) {
- const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']);
- model.set('name', trimmedFileName);
- }
- this.set('showFileUpload', false);
- },
- },
-});
diff --git a/ui/app/controllers/vault/cluster/policy/edit.js b/ui/app/controllers/vault/cluster/policy/edit.js
index 8a14b3c90..927e70ea4 100644
--- a/ui/app/controllers/vault/cluster/policy/edit.js
+++ b/ui/app/controllers/vault/cluster/policy/edit.js
@@ -1,4 +1,27 @@
import Controller from '@ember/controller';
-import PolicyEditController from 'vault/mixins/policy-edit-controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
-export default Controller.extend(PolicyEditController);
+export default class PolicyEditController extends Controller {
+ @service router;
+ @service flashMessages;
+ @service wizard;
+
+ @action
+ async deletePolicy() {
+ const { policyType, name } = this.model;
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`);
+ this.router.transitionTo('vault.cluster.policies', policyType);
+ if (this.wizard.featureState === 'delete') {
+ this.wizard.transitionFeatureMachine('delete', 'CONTINUE', policyType);
+ }
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const errors = error.errors ? error.errors.join('. ') : error.message;
+ const message = `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.`;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/mixins/policy-edit-controller.js b/ui/app/mixins/policy-edit-controller.js
deleted file mode 100644
index e56d6a318..000000000
--- a/ui/app/mixins/policy-edit-controller.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { inject as service } from '@ember/service';
-import Mixin from '@ember/object/mixin';
-
-export default Mixin.create({
- flashMessages: service(),
- wizard: service(),
- actions: {
- deletePolicy(model) {
- const policyType = model.get('policyType');
- const name = model.get('name');
- const flash = this.flashMessages;
- model
- .destroyRecord()
- .then(() => {
- flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`);
- return this.transitionToRoute('vault.cluster.policies', policyType);
- })
- .catch((e) => {
- const errors = e.errors ? e.errors.join('') : e.message;
- flash.danger(
- `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.`
- );
- });
- },
-
- savePolicy(model) {
- const flash = this.flashMessages;
- const policyType = model.get('policyType');
- const name = model.get('name');
- model
- .save()
- .then((m) => {
- flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully saved.`);
- if (this.wizard.featureState === 'create') {
- this.wizard.transitionFeatureMachine('create', 'CONTINUE', policyType);
- }
- return this.transitionToRoute('vault.cluster.policy.show', m.get('policyType'), m.get('name'));
- })
- .catch(() => {
- // swallow error -- model.errors set by Adapter
- return;
- });
- },
-
- setModelName(model, e) {
- model.set('name', e.target.value.toLowerCase());
- },
- },
-});
diff --git a/ui/app/models/identity/entity.js b/ui/app/models/identity/entity.js
index 0a73af737..d56fde95e 100644
--- a/ui/app/models/identity/entity.js
+++ b/ui/app/models/identity/entity.js
@@ -4,6 +4,7 @@ import { alias } from '@ember/object/computed';
import IdentityModel from './_base';
import apiPath from 'vault/utils/api-path';
import attachCapabilities from 'vault/lib/attach-capabilities';
+import lazyCapabilities from 'vault/macros/lazy-capabilities';
const Model = IdentityModel.extend({
formFields: computed(function () {
@@ -20,11 +21,8 @@ const Model = IdentityModel.extend({
editType: 'kv',
}),
policies: attr({
- label: 'Policies',
- editType: 'searchSelect',
+ editType: 'yield',
isSectionHeader: true,
- fallbackComponent: 'string-list',
- models: ['policy/acl', 'policy/rgp'],
}),
creationTime: attr('string', {
readOnly: true,
@@ -46,6 +44,8 @@ const Model = IdentityModel.extend({
canEdit: alias('updatePath.canUpdate'),
canRead: alias('updatePath.canRead'),
canAddAlias: alias('aliasPath.canCreate'),
+ policyPath: lazyCapabilities(apiPath`sys/policies`),
+ canCreatePolicies: alias('policyPath.canCreate'),
});
export default attachCapabilities(Model, {
diff --git a/ui/app/models/identity/group.js b/ui/app/models/identity/group.js
index 9f51db140..42da7de7f 100644
--- a/ui/app/models/identity/group.js
+++ b/ui/app/models/identity/group.js
@@ -34,11 +34,8 @@ export default IdentityModel.extend({
editType: 'kv',
}),
policies: attr({
- label: 'Policies',
- editType: 'searchSelect',
+ editType: 'yield',
isSectionHeader: true,
- fallbackComponent: 'string-list',
- models: ['policy/acl', 'policy/rgp'],
}),
memberGroupIds: attr({
label: 'Member Group IDs',
@@ -73,7 +70,8 @@ export default IdentityModel.extend({
return numEntities + numGroups > 0;
}
),
-
+ policyPath: lazyCapabilities(apiPath`sys/policies`),
+ canCreatePolicies: alias('policyPath.canCreate'),
alias: belongsTo('identity/group-alias', { async: false, readOnly: true }),
updatePath: identityCapabilities(),
canDelete: alias('updatePath.canDelete'),
diff --git a/ui/app/routes/vault/cluster/policy/edit.js b/ui/app/routes/vault/cluster/policy/edit.js
index c0743fc75..4b22d1d2f 100644
--- a/ui/app/routes/vault/cluster/policy/edit.js
+++ b/ui/app/routes/vault/cluster/policy/edit.js
@@ -1,4 +1,13 @@
import UnsavedModelRoute from 'vault/mixins/unsaved-model-route';
import ShowRoute from './show';
+import { inject as service } from '@ember/service';
-export default ShowRoute.extend(UnsavedModelRoute);
+export default ShowRoute.extend(UnsavedModelRoute, {
+ wizard: service(),
+
+ activate() {
+ if (this.wizard.featureState === 'details') {
+ this.wizard.transitionFeatureMachine('details', 'CONTINUE', this.policyType());
+ }
+ },
+});
diff --git a/ui/app/routes/vault/cluster/policy/show.js b/ui/app/routes/vault/cluster/policy/show.js
index e6d1e9ac2..89c27a2b2 100644
--- a/ui/app/routes/vault/cluster/policy/show.js
+++ b/ui/app/routes/vault/cluster/policy/show.js
@@ -5,6 +5,13 @@ import { inject as service } from '@ember/service';
export default Route.extend(UnloadModelRoute, {
store: service(),
+ wizard: service(),
+
+ activate() {
+ if (this.wizard.featureState === 'create') {
+ this.wizard.transitionFeatureMachine('create', 'CONTINUE', this.policyType());
+ }
+ },
beforeModel() {
const params = this.paramsFor(this.routeName);
diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss
index 0b8aeb061..61b8ca436 100644
--- a/ui/app/styles/components/modal.scss
+++ b/ui/app/styles/components/modal.scss
@@ -7,6 +7,7 @@
border: 1px solid $grey-light;
max-height: calc(100vh - 70px);
margin-top: 60px;
+ min-width: calc(100vw * 0.3);
&-head {
border-radius: 0;
diff --git a/ui/app/templates/components/identity/edit-form.hbs b/ui/app/templates/components/identity/edit-form.hbs
index 869fc3cd7..469e8251d 100644
--- a/ui/app/templates/components/identity/edit-form.hbs
+++ b/ui/app/templates/components/identity/edit-form.hbs
@@ -24,7 +24,34 @@
/>
{{/if}}
{{#each this.model.fields as |attr|}}
-
+
+