diff --git a/ui/app/components/oidc/provider-form.hbs b/ui/app/components/oidc/provider-form.hbs index 7e39b8263b..9e96b08392 100644 --- a/ui/app/components/oidc/provider-form.hbs +++ b/ui/app/components/oidc/provider-form.hbs @@ -3,48 +3,28 @@ SPDX-License-Identifier: BUSL-1.1 }} - + <:breadcrumbs> -
+
- {{! name field }} - - {{#let (get @model.formFields "1") as |attr|}} - + {{! scopes are fetched in route and passed into select component }} + - - {{/let}} - {{! scopesSupported field }} - +
{{! RADIO CARD + SEARCH SELECT }}
@@ -70,16 +50,18 @@ />
{{#if (eq this.radioCardGroupValue "limited")}} + {{! clients are fetched in route and passed into select component }} {{/if}} @@ -87,7 +69,7 @@
diff --git a/ui/app/components/oidc/provider-form.js b/ui/app/components/oidc/provider-form.js index 2773dfbce4..c652455554 100644 --- a/ui/app/components/oidc/provider-form.js +++ b/ui/app/components/oidc/provider-form.js @@ -8,33 +8,45 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; -import parseURL from 'core/utils/parse-url'; +import { waitFor } from '@ember/test-waiters'; + /** * @module OidcProviderForm * OidcProviderForm components are used to create and update OIDC providers * * @example * ```js - * + * * ``` * @callback onCancel * @callback onSave - * @param {Object} model - oidc client model + * @param {Object} form - oidc provider form + * @param {Array} scopes - array of scopes to populate dropdown in form + * @param {Array} clients - array of clients to populate dropdown in form * @param {onCancel} onCancel - callback triggered when cancel button is clicked * @param {onSave} onSave - callback triggered on save success */ export default class OidcProviderForm extends Component { - @service store; + @service api; @service flashMessages; + @tracked modelValidations; @tracked errorBanner; @tracked invalidFormAlert; - @tracked radioCardGroupValue = + @tracked radioCardGroupValue = 'limited'; + @tracked selectedClients = []; + + constructor() { + super(...arguments); // 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'; + 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(); + } get breadcrumbs() { const crumbs = [ @@ -42,74 +54,73 @@ export default class OidcProviderForm extends Component { { label: 'OIDC provider: Providers', route: 'vault.cluster.access.oidc.providers' }, ]; - if (!this.args.model.isNew) { + if (!this.args.form.isNew) { crumbs.push({ - label: this.args.model.name, + label: this.args.form.data.name, route: 'vault.cluster.access.oidc.providers.provider.details', - model: this.args.model.name, + model: this.args.form.data.name, }); } - crumbs.push({ label: this.args.model.isNew ? 'Create provider' : 'Edit provider' }); + crumbs.push({ label: this.args.form.isNew ? 'Create provider' : 'Edit provider' }); return crumbs; } // 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.clientId === selection); + const clientExists = !!dropdownOptions.find((opt) => opt.client_id === selection); return !clientExists ? 'The application associated with this client_id no longer exists' : false; } - // fired on did-insert from render modifier - @action - setIssuer(elem, [model]) { - model.issuer = model.isNew ? '' : parseURL(model.issuer).origin; + 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 trigger 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 { 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 = ['*']; + } + const { name, ...payload } = data; + await this.api.identity.oidcWriteProvider(name, payload); + this.flashMessages.success( + `Successfully ${this.args.form.isNew ? 'created' : 'updated'} the OIDC provider ${name}.` + ); + this.args.onSave(); } - yield this.args.model.save(); - this.flashMessages.success( - `Successfully ${isNew ? 'created' : 'updated'} the OIDC provider - ${name}.` - ); - this.args.onSave(); + } 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-list.hbs b/ui/app/components/oidc/provider-list.hbs index d18b19c06b..0cdf1edb83 100644 --- a/ui/app/components/oidc/provider-list.hbs +++ b/ui/app/components/oidc/provider-list.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#each @model as |provider|}} +{{#each @model.providers as |provider|}}
- {{#if (or provider.canRead provider.canEdit)}} + {{#if (has-capability @model.capabilities "read" "update" pathKey="oidcProvider" params=provider)}} - {{#if provider.canRead}} + {{#if (has-capability @model.capabilities "read" pathKey="oidcProvider" params=provider)}} Details + > + Details + {{/if}} - {{#if provider.canEdit}} + {{#if (has-capability @model.capabilities "update" pathKey="oidcProvider" params=provider)}} Edit + > + Edit + {{/if}} {{/if}} diff --git a/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js index 18b6254589..5c4c39bf58 100644 --- a/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js +++ b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js @@ -8,18 +8,18 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; export default class OidcProviderDetailsController extends Controller { + @service api; @service router; @service flashMessages; @action async delete() { try { - await this.model.destroyRecord(); + await this.api.identity.oidcDeleteProvider(this.model.provider.name); this.flashMessages.success('Provider deleted successfully'); this.router.transitionTo('vault.cluster.access.oidc.providers'); } 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/provider.ts b/ui/app/forms/oidc/provider.ts new file mode 100644 index 0000000000..7bbf719edf --- /dev/null +++ b/ui/app/forms/oidc/provider.ts @@ -0,0 +1,47 @@ +/** + * 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 { OidcWriteProviderRequest } from '@hashicorp/vault-client-typescript'; + +type OidcProviderFormData = OidcWriteProviderRequest & { + name: string; +}; + +export default class OidcProviderForm extends Form { + formFieldGroups = [ + new FormFieldGroup('default', [ + new FormField('name', 'string', { editDisabled: true }), + new FormField('issuer', 'string', { + subText: + "The scheme, host, and optional port for your issuer. This will be used to build the URL that validates ID tokens. Defaults to a URL with Vault's api_addr.", + placeholder: 'e.g. https://example.com:8200', + docLink: '/vault/api-docs/secret/identity/oidc-provider#create-or-update-a-provider', + }), + // SearchSelect within the FormField component works in conjunction with Ember Data Models + // we can still use the component since it supports passing in an array of objects as options for the select + // yield out the field so scopes can be fetched in the route and passed directly to SearchSelect + new FormField('supported_scopes', undefined, { + label: 'Supported scopes', + subText: 'Scopes define information about a user and the OIDC service. Optional.', + editType: 'yield', + }), + ]), + ]; + + 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/clients/client/providers.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js index be493a3f31..3b5d0525a4 100644 --- a/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js +++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js @@ -5,22 +5,36 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { IdentityApiOidcListProvidersListEnum } from '@hashicorp/vault-client-typescript'; export default class OidcClientProvidersRoute extends Route { - @service store; + @service api; + @service capabilities; - model() { - const { client } = this.modelFor('vault.cluster.access.oidc.clients.client'); - return this.store - .query('oidc/provider', { - allowed_client_id: client.client_id, - }) - .catch((err) => { - if (err.httpStatus === 404) { - return []; - } else { - throw err; - } - }); + async model() { + try { + const { client } = this.modelFor('vault.cluster.access.oidc.clients.client'); + const response = await this.api.identity.oidcListProviders( + IdentityApiOidcListProvidersListEnum.TRUE, + client.client_id // use allowed_client_id query param to filter providers for this client + ); + const paths = response.keys.map((name) => this.capabilities.pathFor('oidcProvider', { name })); + const capabilities = paths ? await this.capabilities.fetch(paths) : {}; + + return { + providers: this.api.keyInfoToArray(response, 'name'), + capabilities, + }; + } catch (error) { + const { status } = await this.api.parseError(error); + if (status === 404) { + return { + providers: [], + capabilities: {}, + }; + } else { + throw error; + } + } } } diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/index.js b/ui/app/routes/vault/cluster/access/oidc/clients/index.js index d9cec48af0..daa241011a 100644 --- a/ui/app/routes/vault/cluster/access/oidc/clients/index.js +++ b/ui/app/routes/vault/cluster/access/oidc/clients/index.js @@ -25,7 +25,10 @@ export default class OidcClientsRoute extends Route { } catch (error) { const { status } = await this.api.parseError(error); if (status === 404) { - return []; + return { + clients: [], + capabilities: {}, + }; } else { throw error; } @@ -33,7 +36,7 @@ export default class OidcClientsRoute extends Route { } afterModel(model) { - if (model.length === 0) { + if (model.clients.length === 0) { this.router.transitionTo('vault.cluster.access.oidc'); } } diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/create.js b/ui/app/routes/vault/cluster/access/oidc/providers/create.js index 115724144f..1e7fded339 100644 --- a/ui/app/routes/vault/cluster/access/oidc/providers/create.js +++ b/ui/app/routes/vault/cluster/access/oidc/providers/create.js @@ -5,11 +5,30 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import OidcProviderForm from 'vault/forms/oidc/provider'; +import { + IdentityApiOidcListScopesListEnum, + IdentityApiOidcListClientsListEnum, +} from '@hashicorp/vault-client-typescript'; export default class OidcProvidersCreateRoute extends Route { - @service store; + @service api; - model() { - return this.store.createRecord('oidc/provider'); + async model() { + // fetch scopes and clients to populate dropdowns in form + const [scopesResult, clientsResult] = await Promise.allSettled([ + this.api.identity.oidcListScopes(IdentityApiOidcListScopesListEnum.TRUE), + this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE), + ]); + + // SearchSelect requires options to be objects. + const scopes = scopesResult.value?.keys?.map((key) => ({ id: key })) ?? []; + const clients = this.api.keyInfoToArray(clientsResult.value, 'name'); + + return { + form: new OidcProviderForm({}, { isNew: true }), + scopes, + clients, + }; } } diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/index.js b/ui/app/routes/vault/cluster/access/oidc/providers/index.js index fab51bf8b6..fb30275d95 100644 --- a/ui/app/routes/vault/cluster/access/oidc/providers/index.js +++ b/ui/app/routes/vault/cluster/access/oidc/providers/index.js @@ -5,17 +5,32 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { IdentityApiOidcListProvidersListEnum } from '@hashicorp/vault-client-typescript'; export default class OidcProvidersRoute extends Route { - @service store; + @service api; + @service capabilities; - model() { - return this.store.query('oidc/provider', {}).catch((err) => { - if (err.httpStatus === 404) { - return []; + async model() { + try { + const response = await this.api.identity.oidcListProviders(IdentityApiOidcListProvidersListEnum.TRUE); + const paths = response.keys.map((name) => this.capabilities.pathFor('oidcProvider', { name })); + const capabilities = paths ? await this.capabilities.fetch(paths) : {}; + + return { + providers: this.api.keyInfoToArray(response, 'name'), + capabilities, + }; + } catch (error) { + const { status } = await this.api.parseError(error); + if (status === 404) { + return { + providers: [], + capabilities: {}, + }; } else { - throw err; + throw error; } - }); + } } } diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js index 691090265b..6db57982e0 100644 --- a/ui/app/routes/vault/cluster/access/oidc/providers/provider.js +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js @@ -7,9 +7,12 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; export default class OidcProviderRoute extends Route { - @service store; + @service api; + @service capabilities; - model({ name }) { - return this.store.findRecord('oidc/provider', name); + async model({ name }) { + const { data } = await this.api.identity.oidcReadProvider(name); + const capabilities = await this.capabilities.for('oidcProvider', { name }); + return { provider: { ...data, name }, capabilities }; } } diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js index ce48f00cd5..b8aa571d9f 100644 --- a/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js @@ -12,13 +12,13 @@ export default class OidcProviderClientsRoute extends Route { @service capabilities; async model() { - const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.providers.provider'); + const { provider } = this.modelFor('vault.cluster.access.oidc.providers.provider'); 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 = provider.allowed_client_ids.includes('*') ? clients - : clients.filter((client) => allowedClientIds.includes(client.client_id)); + : clients.filter((client) => provider.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/providers/provider/edit.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js index 3b98d8435a..3a5b55391c 100644 --- a/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js @@ -4,5 +4,38 @@ */ import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import OidcProviderForm from 'vault/forms/oidc/provider'; +import { + IdentityApiOidcListScopesListEnum, + IdentityApiOidcListClientsListEnum, +} from '@hashicorp/vault-client-typescript'; +import parseURL from 'core/utils/parse-url'; -export default class OidcProviderEditRoute extends Route {} +export default class OidcProviderEditRoute extends Route { + @service api; + + async model() { + // fetch scopes and clients to populate dropdowns in form + const [scopesResult, clientsResult] = await Promise.allSettled([ + this.api.identity.oidcListScopes(IdentityApiOidcListScopesListEnum.TRUE), + this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE), + ]); + + // SearchSelect requires options to be objects. + const scopes = scopesResult.value?.keys?.map((key) => ({ id: key })) ?? []; + const clients = this.api.keyInfoToArray(clientsResult.value, 'name'); + const { provider } = this.modelFor('vault.cluster.access.oidc.providers.provider'); + // parse issuer to only include scheme, host, and port for form field + const formData = { + ...provider, + issuer: provider.issuer ? parseURL(provider.issuer).origin : '', + }; + + return { + form: new OidcProviderForm(formData), + scopes, + clients, + }; + } +} diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs index 390e82f1db..5e72196eeb 100644 --- a/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs @@ -4,7 +4,7 @@ }} -{{#if (gt this.model.length 0)}} +{{#if (gt this.model.providers.length 0)}} {{else}} diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs index d4415aa8e7..12068b2be1 100644 --- a/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs @@ -4,7 +4,9 @@ }} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs index d1abbb5fa2..d90caefc52 100644 --- a/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs @@ -4,13 +4,13 @@ }} {{#if (not-eq this.router.currentRoute.localName "edit")}} - + <:breadcrumbs> @@ -22,14 +22,14 @@ Details Applications diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs index 3ecd49862c..d06c7fe44b 100644 --- a/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs @@ -5,7 +5,7 @@ - {{#if (and (not-eq this.model.name "default") this.model.canDelete)}} + {{#if (and (not-eq this.model.provider.name "default") this.model.capabilities.canDelete)}}
{{/if}} - {{#if this.model.canEdit}} + {{#if this.model.capabilities.canUpdate}} Edit provider @@ -29,15 +29,13 @@
- - + + \ No newline at end of file diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index 92e7fb4b8b..85c78ba1a1 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -39,6 +39,7 @@ export const PATH_MAP = { ldapStaticRole: apiPath`${'backend'}/static-role/${'name'}`, ldapStaticRoleCreds: apiPath`${'backend'}/static-cred/${'name'}`, oidcClient: apiPath`identity/oidc/client/${'name'}`, + oidcProvider: apiPath`identity/oidc/provider/${'name'}`, pkiCertificates: apiPath`${'backend'}/certificates`, pkiConfigAcme: apiPath`${'backend'}/config/acme`, pkiConfigAutoTidy: apiPath`${'backend'}/config/auto-tidy`, diff --git a/ui/lib/core/addon/components/search-select.js b/ui/lib/core/addon/components/search-select.js index 2aa25d12ad..785c4b0bd3 100644 --- a/ui/lib/core/addon/components/search-select.js +++ b/ui/lib/core/addon/components/search-select.js @@ -148,13 +148,13 @@ export default class SearchSelect extends Component { ? options : [...this.addSearchText(options)]; - if (!this.args.parentManageSelected) { - // set selectedOptions and remove matches from dropdown list - this.selectedOptions = this.args.inputValue - ? this.formatInputAndUpdateDropdown(this.args.inputValue) - : []; - } + // set selectedOptions and remove matches from dropdown list + const values = this.args.parentManageSelected + ? this.args.parentManageSelected.map((opt) => opt[this.idKey]) + : this.args.inputValue; + this.selectedOptions = values ? this.formatInputAndUpdateDropdown(values) : []; } + this.shouldUseFallback = this.args.fallbackComponent && !this.args.options?.length; return; } diff --git a/ui/tests/acceptance/oidc-config/clients-test.js b/ui/tests/acceptance/oidc-config/clients-test.js index 9188e6321b..2f6cc7d97f 100644 --- a/ui/tests/acceptance/oidc-config/clients-test.js +++ b/ui/tests/acceptance/oidc-config/clients-test.js @@ -39,6 +39,7 @@ module('Acceptance | oidc-config clients', function (hooks) { hooks.beforeEach(function () { oidcConfigHandlers(this.server); this.store = this.owner.lookup('service:store'); + this.api = this.owner.lookup('service:api'); return login(); }); @@ -47,9 +48,11 @@ module('Acceptance | oidc-config clients', function (hooks) { assert.expect(21); //* start with clean test state - await clearRecord(this.store, 'oidc/client', 'client-with-test-key'); - await clearRecord(this.store, 'oidc/client', 'client-with-default-key'); - await clearRecord(this.store, 'oidc/key', 'test-key'); + await Promise.allSettled([ + this.api.identity.oidcDeleteClient('client-with-test-key'), + this.api.identity.oidcDeleteClient('client-with-default-key'), + clearRecord(this.store, 'oidc/key', 'test-key'), + ]); // create client with default key await visit(OIDC_BASE_URL + '/clients/create'); @@ -180,9 +183,11 @@ module('Acceptance | oidc-config clients', function (hooks) { ); //* clean up test state - await clearRecord(this.store, 'oidc/client', 'client-with-test-key'); - await clearRecord(this.store, 'oidc/client', 'client-with-default-key'); - await clearRecord(this.store, 'oidc/key', 'test-key'); + await Promise.allSettled([ + this.api.identity.oidcDeleteClient('client-with-test-key'), + this.api.identity.oidcDeleteClient('client-with-default-key'), + clearRecord(this.store, 'oidc/key', 'test-key'), + ]); }); test('it creates, rotates and deletes a key', async function (assert) { @@ -366,9 +371,11 @@ module('Acceptance | oidc-config clients', function (hooks) { assert.expect(21); //* clear out test state - await clearRecord(this.store, 'oidc/client', 'test-app'); - await clearRecord(this.store, 'oidc/client', 'my-webapp'); // created by oidc-provider-test - await clearRecord(this.store, 'oidc/assignment', 'assignment-inline'); + await Promise.allSettled([ + this.api.identity.oidcDeleteClient('test-app'), + this.api.identity.oidcDeleteClient('my-webapp'), + clearRecord(this.store, 'oidc/assignment', 'assignment-inline'), + ]); // create a client with allow all access await visit(OIDC_BASE_URL + '/clients/create'); diff --git a/ui/tests/acceptance/oidc-config/providers-scopes-test.js b/ui/tests/acceptance/oidc-config/providers-scopes-test.js index 99aba819bc..6dba52543a 100644 --- a/ui/tests/acceptance/oidc-config/providers-scopes-test.js +++ b/ui/tests/acceptance/oidc-config/providers-scopes-test.js @@ -26,6 +26,8 @@ import { clearRecord, } from 'vault/tests/helpers/oidc-config'; import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs'; +import sinon from 'sinon'; + const searchSelect = create(ss); const flashMessage = create(fm); @@ -38,6 +40,7 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { hooks.beforeEach(function () { oidcConfigHandlers(this.server); this.store = this.owner.lookup('service:store'); + this.api = this.owner.lookup('service:api'); // mock client list so OIDC BASE URL does not redirect to landing call-to-action image this.server.get('/identity/oidc/client', () => overrideResponse(null, { data: CLIENT_LIST_RESPONSE })); return login(); @@ -165,9 +168,13 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { test('it creates a scope, and creates a provider with that scope', async function (assert) { assert.expect(28); + const apiSpy = sinon.spy(this.owner.lookup('service:api').identity, 'oidcWriteProvider'); + //* clear out test state - await clearRecord(this.store, 'oidc/scope', 'test-scope'); - await clearRecord(this.store, 'oidc/provider', 'test-provider'); + await Promise.allSettled([ + clearRecord(this.store, 'oidc/scope', 'test-scope'), + this.api.identity.oidcDeleteProvider('test-provider'), + ]); // create a new scope await visit(OIDC_BASE_URL + '/scopes/create'); @@ -242,8 +249,8 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { 'navigates to provider create form' ); await fillIn(GENERAL.inputByAttr('name'), 'test-provider'); - await clickTrigger('#scopesSupported'); - await selectChoose('#scopesSupported', 'test-scope'); + await clickTrigger('#oidc-provider-form-scope-select'); + await selectChoose('#oidc-provider-form-scope-select', 'test-scope'); await click(SELECTORS.providerSaveButton); assert.strictEqual( flashMessage.latestMessage, @@ -282,7 +289,9 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { 'navigates to provider edit page from details' ); await click('[data-test-oidc-radio="limited"]'); - await click('[data-test-component="search-select"]#allowedClientIds .ember-basic-dropdown-trigger'); + await click( + '[data-test-component="search-select"]#oidc-provider-form-client-select .ember-basic-dropdown-trigger' + ); await fillIn(GENERAL.searchSelect.searchInput, 'test-app'); await searchSelect.options.objectAt(0).click(); await click(SELECTORS.providerSaveButton); @@ -296,9 +305,9 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { 'vault.cluster.access.oidc.providers.provider.details', 'navigates back to provider details after updating' ); - const providerModel = this.store.peekRecord('oidc/provider', 'test-provider'); + const { allowed_client_ids } = apiSpy.lastCall.args[1]; assert.propEqual( - providerModel.allowedClientIds, + allowed_client_ids, ['whaT7KB0C3iBH1l3rXhd5HPf0n6vXU0s'], 'provider saves client_id (not id or name) in allowed_client_ids param' ); @@ -390,20 +399,22 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) { // PROVIDER DELETE + EDIT PERMISSIONS test('it hides delete and edit for a provider when no permission', async function (assert) { assert.expect(3); - this.server.get('/identity/oidc/providers', () => - overrideResponse(null, { data: { providers: ['test-provider'] } }) - ); + + const provider = { + allowed_client_ids: ['*'], + issuer: 'http://127.0.0.1:8200/v1/identity/oidc/provider/test-provider', + scopes_supported: ['test-scope'], + }; + const data = { + keys: ['test-provider'], + key_info: { 'test-provider': provider }, + }; + this.server.get('/identity/oidc/provider', () => overrideResponse(null, { data })); this.server.get('/identity/oidc/provider/test-provider', () => - overrideResponse(null, { - data: { - allowed_client_ids: ['*'], - issuer: 'http://127.0.0.1:8200/v1/identity/oidc/provider/test-provider', - scopes_supported: ['test-scope'], - }, - }) + overrideResponse(null, { data: provider }) ); this.server.post('/sys/capabilities-self', () => - capabilitiesStub(OIDC_BASE_URL + '/provider/test-provider', ['read']) + capabilitiesStub('identity/oidc/provider/test-provider', ['read']) ); await visit(OIDC_BASE_URL + '/providers'); diff --git a/ui/tests/acceptance/oidc-provider-test.js b/ui/tests/acceptance/oidc-provider-test.js index 233087cff0..8e278196bb 100644 --- a/ui/tests/acceptance/oidc-provider-test.js +++ b/ui/tests/acceptance/oidc-provider-test.js @@ -11,7 +11,6 @@ import sinon from 'sinon'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import enablePage from 'vault/tests/pages/settings/auth/enable'; import { visit, settled, currentURL, waitFor, currentRouteName, fillIn, click } from '@ember/test-helpers'; -import { clearRecord } from 'vault/tests/helpers/oidc-config'; import { runCmd } from 'vault/tests/helpers/commands'; import queryParamString from 'vault/utils/query-param-string'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; @@ -121,7 +120,6 @@ module('Acceptance | oidc provider', function (hooks) { hooks.beforeEach(async function () { this.uid = uuidv4(); - this.store = this.owner.lookup('service:store'); await login(); await settled(); this.oidcSetupInformation = await setupOidc(this.uid); @@ -129,6 +127,11 @@ module('Acceptance | oidc provider', function (hooks) { }); hooks.afterEach(async function () { + const api = this.owner.lookup('service:api'); + await Promise.allSettled([ + api.identity.oidcDeleteProvider(WEB_APP_NAME), + api.identity.oidcDeleteProvider(PROVIDER_NAME), + ]); await login(); }); @@ -169,10 +172,6 @@ module('Acceptance | oidc provider', function (hooks) { .hasTextContaining(`click here to go back to app`, 'Shows link back to app'); const link = document.querySelector('[data-test-oidc-redirect]').getAttribute('href'); assert.ok(link.includes('/callback?code='), 'Redirects to correct url'); - - //* clean up test state - await clearRecord(this.store, 'oidc/client', WEB_APP_NAME); - await clearRecord(this.store, 'oidc/provider', PROVIDER_NAME); }); test('OIDC Provider redirects to auth if current token and prompt = login', async function (assert) { @@ -220,10 +219,6 @@ module('Acceptance | oidc provider', function (hooks) { ); assert.strictEqual(currentRouteName(), 'vault.cluster.oidc-provider'); assert.dom('[data-test-consent-form]').exists('Consent form exists'); - - //* clean up test state - await clearRecord(this.store, 'oidc/client', WEB_APP_NAME); - await clearRecord(this.store, 'oidc/provider', PROVIDER_NAME); }); // Error handling test coverage, see issue for more context https://github.com/hashicorp/vault/issues/27772 @@ -271,7 +266,5 @@ module('Acceptance | oidc provider', function (hooks) { //* clean up test state authStub.restore(); await login(); - await clearRecord(this.store, 'oidc/client', WEB_APP_NAME); - await clearRecord(this.store, 'oidc/provider', PROVIDER_NAME); }); }); diff --git a/ui/tests/integration/components/oidc/client-list-test.js b/ui/tests/integration/components/oidc/client-list-test.js index 15b9ee7e7d..5aee7c7256 100644 --- a/ui/tests/integration/components/oidc/client-list-test.js +++ b/ui/tests/integration/components/oidc/client-list-test.js @@ -5,14 +5,12 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; import { click, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Integration | Component | oidc/client-list', function (hooks) { setupRenderingTest(hooks); - setupMirage(hooks); hooks.beforeEach(function () { this.renderComponent = (isLimited) => { diff --git a/ui/tests/integration/components/oidc/provider-form-test.js b/ui/tests/integration/components/oidc/provider-form-test.js index fd8ba45085..b2e6b11265 100644 --- a/ui/tests/integration/components/oidc/provider-form-test.js +++ b/ui/tests/integration/components/oidc/provider-form-test.js @@ -9,11 +9,12 @@ import { render, fillIn, click, findAll } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import oidcConfigHandlers from 'vault/mirage/handlers/oidc-config'; -import { SELECTORS, OIDC_BASE_URL, CLIENT_LIST_RESPONSE } from 'vault/tests/helpers/oidc-config'; -import parseURL from 'core/utils/parse-url'; +import { SELECTORS, CLIENT_LIST_RESPONSE } from 'vault/tests/helpers/oidc-config'; import { setRunOptions } from 'ember-a11y-testing/test-support'; -import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import OidcProviderForm from 'vault/forms/oidc/provider'; +import sinon from 'sinon'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; const ISSUER_URL = 'http://127.0.0.1:8200/v1/identity/oidc/provider/test-provider'; @@ -23,22 +24,27 @@ module('Integration | Component | oidc/provider-form', function (hooks) { hooks.beforeEach(function () { oidcConfigHandlers(this.server); - this.store = this.owner.lookup('service:store'); - this.server.get('/identity/oidc/scope', () => { - return { - request_id: 'scope-list-id', - lease_id: '', - renewable: false, - lease_duration: 0, - data: { - keys: ['test-scope'], - }, - wrap_info: null, - warnings: null, - auth: null, - }; - }); - this.server.get('/identity/oidc/client', () => overrideResponse(null, { data: CLIENT_LIST_RESPONSE })); + this.api = this.owner.lookup('service:api'); + this.apiStub = sinon.stub(this.api.identity, 'oidcWriteProvider').resolves(); + + this.scopes = [{ id: 'test-scope' }]; + this.clients = this.api.keyInfoToArray(CLIENT_LIST_RESPONSE, 'name'); + this.onCancel = sinon.spy(); + this.onSave = sinon.spy(); + + this.renderComponent = (data) => { + this.form = new OidcProviderForm(data || {}, { isNew: !data }); + return render(hbs` + + `); + }; + setRunOptions({ rules: { // TODO: Fix SearchSelect component @@ -53,19 +59,8 @@ module('Integration | Component | oidc/provider-form', function (hooks) { test('it should save new provider', async function (assert) { assert.expect(13); - this.server.post('/identity/oidc/provider/test-provider', (schema, req) => { - assert.ok(true, 'Request made to save provider'); - return JSON.parse(req.requestBody); - }); - this.model = this.store.createRecord('oidc/provider'); - this.onSave = () => assert.ok(true, 'onSave callback fires on save success'); - await render(hbs` - - `); + + await this.renderComponent(); assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Create Provider', 'Form title renders correct text'); assert.dom(SELECTORS.providerSaveButton).hasText('Create', 'Save button has correct text'); @@ -73,7 +68,9 @@ module('Integration | Component | oidc/provider-form', function (hooks) { .dom('[data-test-input="issuer"]') .hasAttribute('placeholder', 'e.g. https://example.com:8200', 'issuer placeholder text is correct'); assert.strictEqual(findAll('[data-test-field]').length, 3, 'renders all input fields'); - await click('[data-test-component="search-select"]#scopesSupported .ember-basic-dropdown-trigger'); + await click( + '[data-test-component="search-select"]#oidc-provider-form-scope-select .ember-basic-dropdown-trigger' + ); assert.dom('li.ember-power-select-option').hasText('test-scope', 'dropdown renders scopes'); // check validation errors @@ -89,47 +86,37 @@ module('Integration | Component | oidc/provider-form', function (hooks) { await click('[data-test-oidc-radio="limited"]'); assert - .dom('[data-test-component="search-select"]#allowedClientIds') + .dom('[data-test-component="search-select"]#oidc-provider-form-client-select') .exists('Limited radio button shows clients search select'); - await click('[data-test-component="search-select"]#allowedClientIds .ember-basic-dropdown-trigger'); + await click( + '[data-test-component="search-select"]#oidc-provider-form-client-select .ember-basic-dropdown-trigger' + ); assert.dom('li.ember-power-select-option').hasTextContaining('test-app', 'dropdown renders client name'); assert.dom('[data-test-smaller-id]').exists('renders smaller client id in dropdown'); await click('[data-test-oidc-radio="allow-all"]'); assert - .dom('[data-test-component="search-select"]#allowedClientIds') + .dom('[data-test-component="search-select"]#oidc-provider-form-client-select') .doesNotExist('Allow all radio button hides search select'); await fillIn('[data-test-input="name"]', 'test-provider'); await click(SELECTORS.providerSaveButton); + + assert.true(this.onSave.called, 'onSave callback fires on save success'); + assert.true(this.apiStub.calledWith('test-provider'), 'Request made to save provider'); }); test('it should update provider', async function (assert) { - assert.expect(9); + assert.expect(8); - this.server.post('/identity/oidc/provider/test-provider', (schema, req) => { - assert.ok(true, 'Request made to save provider'); - return JSON.parse(req.requestBody); - }); - - this.store.pushPayload('oidc/provider', { - modelName: 'oidc/provider', + const provider = { name: 'test-provider', allowed_client_ids: ['*'], issuer: ISSUER_URL, scopes_supported: ['test-scope'], - }); + }; - this.model = this.store.peekRecord('oidc/provider', 'test-provider'); - this.onSave = () => assert.ok(true, 'onSave callback fires on save success'); - - await render(hbs` - - `); + await this.renderComponent(provider); assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Edit Provider', 'Title renders correct text'); assert.dom(SELECTORS.providerSaveButton).hasText('Update', 'Save button has correct text'); @@ -137,87 +124,49 @@ module('Integration | Component | oidc/provider-form', function (hooks) { assert .dom('[data-test-input="name"]') .hasValue('test-provider', 'Name input is populated with model value'); - assert - .dom('[data-test-input="issuer"]') - .hasValue(parseURL(ISSUER_URL).origin, 'issuer value is just scheme://host:port portion of full URL'); - assert.dom('[data-test-selected-option]').hasText('test-scope', 'model scope is selected'); assert.dom('[data-test-oidc-radio="allow-all"] input').isChecked('Allow all radio button is selected'); await click(SELECTORS.providerSaveButton); + + assert.true(this.onSave.called, 'onSave callback fires on save success'); + const { name, ...payload } = provider; + assert.true(this.apiStub.calledWith(name, payload), 'Request made to save provider'); }); - test('it should rollback attributes or unload record on cancel', async function (assert) { - assert.expect(4); - this.model = this.store.createRecord('oidc/provider'); - this.onCancel = () => assert.ok(true, 'onCancel callback fires'); - - await render(hbs` - - `); + test('it should fire callback on cancel', async function (assert) { + assert.expect(1); + await this.renderComponent(); await click(SELECTORS.providerCancelButton); - assert.true(this.model.isDestroyed, 'New model is unloaded on cancel'); - - this.store.pushPayload('oidc/provider', { - modelName: 'oidc/provider', - name: 'test-provider', - allowed_client_ids: ['*'], - issuer: ISSUER_URL, - scopes_supported: ['test-scope'], - }); - - this.model = this.store.peekRecord('oidc/provider', 'test-provider'); - - await render(hbs` - - `); - - await click('[data-test-oidc-radio="limited"]'); - await click(SELECTORS.providerCancelButton); - assert.strictEqual(this.model.allowed_client_ids, undefined, 'Model attributes rolled back on cancel'); + assert.true(this.onCancel.called, 'onCancel callback fires on cancel'); }); test('it should render fallback for search select', async function (assert) { assert.expect(2); - this.model = this.store.createRecord('oidc/provider'); - this.server.get('/identity/oidc/scope', () => overrideResponse(403)); - this.server.get('/identity/oidc/client', () => overrideResponse(403)); - await render(hbs` - - `); + + this.scopes = []; + this.clients = []; + await this.renderComponent(); assert - .dom('[data-test-component="search-select"]#scopesSupported [data-test-component="string-list"]') + .dom( + '[data-test-component="search-select"]#oidc-provider-form-scope-select [data-test-component="string-list"]' + ) .exists('renders fall back for scopes search select'); await click('[data-test-oidc-radio="limited"]'); assert - .dom('[data-test-component="search-select"]#allowedClientIds [data-test-component="string-list"]') + .dom( + '[data-test-component="search-select"]#oidc-provider-form-client-select [data-test-component="string-list"]' + ) .exists('Radio toggle shows assignments string-list input'); }); test('it should render error alerts when API returns an error', async function (assert) { assert.expect(2); - this.model = this.store.createRecord('oidc/provider'); - this.server.post('/sys/capabilities-self', () => capabilitiesStub(OIDC_BASE_URL + '/providers')); - await render(hbs` - - `); + + this.apiStub.rejects(getErrorResponse()); + await this.renderComponent(); + await fillIn('[data-test-input="name"]', 'some-provider'); await click(SELECTORS.providerSaveButton); assert diff --git a/ui/tests/integration/components/oidc/provider-list-test.js b/ui/tests/integration/components/oidc/provider-list-test.js index bd84198597..5d31f535f7 100644 --- a/ui/tests/integration/components/oidc/provider-list-test.js +++ b/ui/tests/integration/components/oidc/provider-list-test.js @@ -5,32 +5,47 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; import { click, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { allowAllCapabilitiesStub, capabilitiesStub } from 'vault/tests/helpers/stubs'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Integration | Component | oidc/provider-list', function (hooks) { setupRenderingTest(hooks); - setupMirage(hooks); hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); - this.store.createRecord('oidc/provider', { name: 'first-provider', issuer: 'foobar' }); - this.store.createRecord('oidc/provider', { name: 'second-provider', issuer: 'foobar' }); - this.model = this.store.peekAll('oidc/provider'); + this.renderComponent = (isLimited) => { + const providers = [ + { name: 'first-provider', issuer: 'foobar' }, + { name: 'second-provider', issuer: 'foobar' }, + ]; + + const capabilities = providers.reduce((capabilities, provider) => { + const path = this.owner.lookup('service:capabilities').pathFor('oidcProvider', provider); + const isFirstProvider = provider.name === 'first-provider'; + const canRead = isLimited ? isFirstProvider : true; + const canUpdate = !isLimited; + capabilities[path] = { canRead, canUpdate }; + return capabilities; + }, {}); + + this.model = { + providers, + capabilities, + }; + return render(hbs``); + }; }); test('it renders list of providers', async function (assert) { - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub(['read', 'update'])); - await render(hbs``); + await this.renderComponent(false); - assert.dom('[data-test-oidc-provider-linked-block]').exists({ count: 2 }, 'Two clients are rendered'); - assert.dom('[data-test-oidc-provider-linked-block="first-provider"]').exists('First client is rendered'); + assert.dom('[data-test-oidc-provider-linked-block]').exists({ count: 2 }, 'Two providers are rendered'); + assert + .dom('[data-test-oidc-provider-linked-block="first-provider"]') + .exists('First provider is rendered'); assert .dom('[data-test-oidc-provider-linked-block="second-provider"]') - .exists('Second client is rendered'); + .exists('Second provider is rendered'); await click('[data-test-oidc-provider-linked-block="first-provider"] [data-test-popup-menu-trigger]'); assert.dom('[data-test-oidc-provider-menu-link="details"]').exists('Details link is rendered'); @@ -38,15 +53,7 @@ module('Integration | Component | oidc/provider-list', function (hooks) { }); test('it renders popup menu based on permissions', async function (assert) { - this.server.post('/sys/capabilities-self', (schema, req) => { - const { paths } = JSON.parse(req.requestBody); - if (paths[0] === 'identity/oidc/provider/first-provider') { - return capabilitiesStub('identity/oidc/provider/first-provider', ['read']); - } else { - return capabilitiesStub('identity/oidc/provider/second-provider', ['deny']); - } - }); - await render(hbs``); + await this.renderComponent(true); assert.dom('[data-test-popup-menu-trigger]').exists({ count: 1 }, 'Only one popup menu is rendered'); await click(GENERAL.menuTrigger); assert.dom('[data-test-oidc-provider-menu-link="details"]').exists('Details link is rendered');