diff --git a/ui/app/components/oidc/key-form.hbs b/ui/app/components/oidc/key-form.hbs index 161c28b31f..4168e7236a 100644 --- a/ui/app/components/oidc/key-form.hbs +++ b/ui/app/components/oidc/key-form.hbs @@ -6,9 +6,7 @@
- {{#each @model.formFields as |attr|}} - - {{/each}} +
{{#unless @isModalForm}} {{! RADIO CARD + SEARCH SELECT }} @@ -32,26 +30,28 @@ @value="limited" @groupValue={{this.radioCardGroupValue}} @onChange={{this.handleClientSelection}} - @disabled={{@model.isNew}} + @disabled={{@form.isNew}} @tooltipMessage={{if - @model.isNew + @form.isNew "This option has been disabled for now. To limit access, you must first create an application that references this key." }} /> {{#if (eq this.radioCardGroupValue "limited")}} + {{! clients are fetched in route and passed into select component }} {{/if}} @@ -59,7 +59,7 @@
diff --git a/ui/app/components/oidc/key-form.js b/ui/app/components/oidc/key-form.js index b00e5e96b7..e1cd7cfe24 100644 --- a/ui/app/components/oidc/key-form.js +++ b/ui/app/components/oidc/key-form.js @@ -8,6 +8,7 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; /** * @module OidcKeyForm @@ -26,72 +27,87 @@ import { task } from 'ember-concurrency'; */ export default class OidcKeyForm extends Component { - @service store; + @service api; @service flashMessages; + @tracked errorBanner; @tracked invalidFormAlert; @tracked modelValidations; - @tracked radioCardGroupValue = - // If "*" is provided, all clients are allowed: https://developer.hashicorp.com/vault/api-docs/secret/identity/oidc-provider#parameters - !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*') - ? 'allow_all' - : 'limited'; + @tracked radioCardGroupValue = 'limited'; + @tracked selectedClients = []; - get filterDropdownOptions() { - // query object sent to search-select so only clients that reference this key appear in dropdown - return { paramKey: 'key', filterFor: [this.args.model.name] }; + constructor() { + super(...arguments); + // If "*" is provided, all clients are allowed: https://developer.hashicorp.com/vault/api-docs/secret/identity/oidc-provider#parameters + const { allowed_client_ids } = this.args.form.data; + if (!allowed_client_ids || allowed_client_ids.includes('*')) { + this.radioCardGroupValue = 'allow_all'; + } + // initialize selectedClients for SearchSelect component with allowed_client_ids from form data + this.updateSelectedClients(); + } + + // function passed to search select + renderTooltip(selection, dropdownOptions) { + // if a client has been deleted it will not exist in dropdownOptions (response from search select's query) + const clientExists = !!dropdownOptions.find((opt) => opt.client_id === selection); + return !clientExists ? 'The application associated with this client_id no longer exists' : false; + } + + updateSelectedClients() { + const { data } = this.args.form; + this.selectedClients = data.allowed_client_ids?.map((clientId) => + this.args.clients.find((client) => client.client_id === clientId) + ); } @action handleClientSelection(selection) { - // if array then coming from search-select component, set selection as model clients + const { data } = this.args.form; + // when triggered from search-select component an array is passed + // set selection as clients if (Array.isArray(selection)) { - this.args.model.allowedClientIds = selection.map((client) => client.clientId); + data.allowed_client_ids = selection.map((client) => client.client_id); } else { // otherwise update radio button value and reset clients so // UI always reflects a user's selection (including when no clients are selected) this.radioCardGroupValue = selection; - this.args.model.allowedClientIds = []; + data.allowed_client_ids = []; } + // update selectedClients which appear in SearchSelect + this.updateSelectedClients(); } - @action - cancel() { - const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; - this.args.model[method](); - this.args.onCancel(); - } + save = task( + waitFor(async (event) => { + event.preventDefault(); + try { + const { isNew } = this.args.form; + const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = invalidFormMessage; - @task - *save(event) { - event.preventDefault(); - try { - const { isValid, state, invalidFormMessage } = this.args.model.validate(); - this.modelValidations = isValid ? null : state; - this.invalidFormAlert = invalidFormMessage; - if (isValid) { - const { isNew, name } = this.args.model; - if (this.radioCardGroupValue === 'allow_all') { - this.args.model.allowedClientIds = ['*']; + if (isValid) { + if (this.radioCardGroupValue === 'allow_all') { + data.allowed_client_ids = ['*']; + } + // if TTL components are toggled off, set to default lease duration + const { rotation_period, verification_ttl } = data; + // value returned from API is a number, and string when from form action + if (Number(rotation_period) === 0) data.rotation_period = '24h'; + if (Number(verification_ttl) === 0) data.verification_ttl = '24h'; + + const { name, ...payload } = data; + await this.api.identity.oidcWriteKey(name, payload); + this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the key ${name}.`); + // this form is sometimes used in a modal, passing the form notifies the parent the save was successful + this.args.onSave(this.args.form); } - // if TTL components are toggled off, set to default lease duration - const { rotationPeriod, verificationTtl } = this.args.model; - // value returned from API is a number, and string when from form action - if (Number(rotationPeriod) === 0) this.args.model.rotationPeriod = '24h'; - if (Number(verificationTtl) === 0) this.args.model.verificationTtl = '24h'; - yield this.args.model.save(); - this.flashMessages.success( - `Successfully ${isNew ? 'created' : 'updated'} the key - ${name}.` - ); - // this form is sometimes used in a modal, passing the model notifies - // the parent if the save was successful - this.args.onSave(this.args.model); + } catch (error) { + const { message } = await this.api.parseError(error); + this.errorBanner = message; + this.invalidFormAlert = 'There was an error submitting this form.'; } - } catch (error) { - const message = error.errors ? error.errors.join('. ') : error.message; - this.errorBanner = message; - this.invalidFormAlert = 'There was an error submitting this form.'; - } - } + }) + ); } diff --git a/ui/app/components/oidc/provider-form.js b/ui/app/components/oidc/provider-form.js index c652455554..2bfab4c1b4 100644 --- a/ui/app/components/oidc/provider-form.js +++ b/ui/app/components/oidc/provider-form.js @@ -83,7 +83,7 @@ export default class OidcProviderForm extends Component { @action handleClientSelection(selection) { const { data } = this.args.form; - // when trigger from search-select component an array is passed + // when triggered from search-select component an array is passed // set selection as clients if (Array.isArray(selection)) { data.allowed_client_ids = selection.map((client) => client.client_id); diff --git a/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js index 8e8514a6de..7d06f1e84c 100644 --- a/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js +++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js @@ -10,32 +10,31 @@ import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; export default class OidcKeyDetailsController extends Controller { - @service store; + @service api; @service router; @service flashMessages; - @task - @waitFor - *rotateKey() { - const adapter = this.store.adapterFor('oidc/key'); - yield adapter - .rotate(this.model.name, this.model.verificationTtl) - .then(() => { - this.flashMessages.success(`Success: ${this.model.name} connection was rotated.`); - }) - .catch((e) => { - this.flashMessages.danger(e.errors); - }); - } + rotateKey = task( + waitFor(async () => { + try { + const { name, verification_ttl } = this.model.key; + await this.api.identity.oidcRotateKey(name, { verification_ttl }); + this.flashMessages.success(`Success: ${name} connection was rotated.`); + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.danger(message); + } + }) + ); + @action async delete() { try { - await this.model.destroyRecord(); + await this.api.identity.oidcDeleteKey(this.model.key.name); this.flashMessages.success('Key deleted successfully'); this.router.transitionTo('vault.cluster.access.oidc.keys'); } catch (error) { - this.model.rollbackAttributes(); - const message = error.errors ? error.errors.join('. ') : error.message; + const { message } = await this.api.parseError(error); this.flashMessages.danger(message); } } diff --git a/ui/app/forms/oidc/key.ts b/ui/app/forms/oidc/key.ts new file mode 100644 index 0000000000..034e8bb12a --- /dev/null +++ b/ui/app/forms/oidc/key.ts @@ -0,0 +1,43 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import FormFieldGroup from 'vault/utils/forms/field-group'; + +import type { Validations } from 'vault/app-types'; +import type { OidcWriteKeyRequest } from '@hashicorp/vault-client-typescript'; + +type OidcKeyFormData = OidcWriteKeyRequest & { + name: string; +}; + +export default class OidcKeyForm extends Form { + formFieldGroups = [ + new FormFieldGroup('default', [ + new FormField('name', 'string', { editDisabled: true }), + new FormField('algorithm', 'string', { + possibleValues: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'], + }), + new FormField('rotation_period', undefined, { + editType: 'ttl', + }), + new FormField('verification_ttl', undefined, { + label: 'Verification TTL', + editType: 'ttl', + }), + ]), + ]; + + validations: Validations = { + name: [ + { type: 'presence', message: 'Name is required.' }, + { + type: 'containsWhiteSpace', + message: 'Name cannot contain whitespace.', + }, + ], + }; +} diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/create.js b/ui/app/routes/vault/cluster/access/oidc/keys/create.js index fd277ee688..1632b1f444 100644 --- a/ui/app/routes/vault/cluster/access/oidc/keys/create.js +++ b/ui/app/routes/vault/cluster/access/oidc/keys/create.js @@ -5,11 +5,17 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import OidcKeyForm from 'vault/forms/oidc/key'; export default class OidcKeysCreateRoute extends Route { - @service store; + @service api; model() { - return this.store.createRecord('oidc/key'); + const defaultValues = { + algorithm: 'RS256', + rotation_period: '24h', + verification_ttl: '24h', + }; + return new OidcKeyForm(defaultValues, { isNew: true }); } } diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/index.js b/ui/app/routes/vault/cluster/access/oidc/keys/index.js index d0ea016332..796bc78efa 100644 --- a/ui/app/routes/vault/cluster/access/oidc/keys/index.js +++ b/ui/app/routes/vault/cluster/access/oidc/keys/index.js @@ -5,17 +5,32 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { IdentityApiOidcListKeysListEnum } from '@hashicorp/vault-client-typescript'; export default class OidcKeysRoute extends Route { - @service store; + @service api; + @service capabilities; - model() { - return this.store.query('oidc/key', {}).catch((err) => { - if (err.httpStatus === 404) { - return []; + async model() { + try { + const { keys } = await this.api.identity.oidcListKeys(IdentityApiOidcListKeysListEnum.TRUE); + const paths = keys.map((name) => this.capabilities.pathFor('oidcKey', { name })); + const capabilities = paths ? await this.capabilities.fetch(paths) : {}; + + return { + keys, + capabilities, + }; + } catch (error) { + const { status } = await this.api.parseError(error); + if (status === 404) { + return { + keys: [], + capabilities: {}, + }; } else { - throw err; + throw error; } - }); + } } } diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key.js b/ui/app/routes/vault/cluster/access/oidc/keys/key.js index c3e183636b..9a85bf6178 100644 --- a/ui/app/routes/vault/cluster/access/oidc/keys/key.js +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key.js @@ -7,9 +7,20 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; export default class OidcKeyRoute extends Route { - @service store; + @service api; + @service capabilities; - model({ name }) { - return this.store.findRecord('oidc/key', name); + async model({ name }) { + const { data } = await this.api.identity.oidcReadKey(name); + const { pathFor } = this.capabilities; + const paths = { + key: pathFor('oidcKey', { name }), + rotate: pathFor('oidcKeyRotate', { name }), + }; + const capabilities = await this.capabilities.fetch(Object.values(paths)); + return { + key: { ...data, name }, + capabilities: { ...capabilities[paths.key], canRotate: capabilities[paths.rotate].canUpdate }, + }; } } diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js index 78f3346ca4..1ae5b12394 100644 --- a/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js @@ -12,13 +12,13 @@ export default class OidcKeyClientsRoute extends Route { @service capabilities; async model() { - const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.keys.key'); + const { key } = this.modelFor('vault.cluster.access.oidc.keys.key'); const response = await this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE); const clients = this.api.keyInfoToArray(response, 'name'); // filter clients based on allowed_client_ids of provider - const filteredClients = allowedClientIds.includes('*') + const filteredClients = key.allowed_client_ids.includes('*') ? clients - : clients.filter((client) => allowedClientIds.includes(client.client_id)); + : clients.filter((client) => key.allowed_client_ids.includes(client.client_id)); // fetch capabilities for filtered clients const paths = filteredClients.map(({ name }) => this.capabilities.pathFor('oidcClient', { name })); const capabilities = paths ? await this.capabilities.fetch(paths) : {}; diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js index 6306d2b7b4..99cc3dd2d2 100644 --- a/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js @@ -4,5 +4,24 @@ */ import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { IdentityApiOidcListClientsListEnum } from '@hashicorp/vault-client-typescript'; +import OidcKeyForm from 'vault/forms/oidc/key'; -export default class OidcKeyEditRoute extends Route {} +export default class OidcKeyEditRoute extends Route { + @service api; + + async model() { + const { key } = this.modelFor('vault.cluster.access.oidc.keys.key'); + // fetch clients to populate dropdown in form + const response = await this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE); + const clients = this.api.keyInfoToArray(response, 'name'); + // filter clients that are associated with this key + const filteredClients = clients.filter((client) => client.key === key.name); + + return { + clients: filteredClients, + form: new OidcKeyForm(key), + }; + } +} diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs index 646f78a56c..3d331146cb 100644 --- a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs @@ -16,7 +16,7 @@ \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs index 74541c31a9..ad221ea8e8 100644 --- a/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs @@ -11,18 +11,18 @@ -{{#each this.model as |model|}} +{{#each this.model.keys as |key|}}
- {{model.name}} + {{key}}
@@ -37,16 +37,20 @@ /> Details + > + Details + Edit + > + Edit +
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs index 2e426b4725..01e3b909c5 100644 --- a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs @@ -4,13 +4,13 @@ }} {{#if (not-eq this.router.currentRoute.localName "edit")}} - + <:breadcrumbs> @@ -19,10 +19,18 @@