UI: OIDC Config for Vault as a provider (#17071)

* OIDC Config Routing (#16028)

* adds oidc config routes

* renames oidc applications route to clients

* UI/vault 6646/landing page (#16069)

* add to sidebar

* add landing image and text

* add permissions

* add permissions to permissions service

* remove comment

* fix.

* UI/OIDC models (#16091)

* add models and fix routing

* add ClientsCreate route

* remove form functions from client model

* update comment

* address comments, cleanup models

* add comment

* OIDC Adapters and Serializers (#16120)

* adds named-path base adapter

* adds oidc adapters with tests

* adds oidc serializers

* fixes issue with supported_scopes relationship in oidc provider model

* make radio card size flex (#16125)

* OIDC config details routes (#16126)

* adds details routes for oidc config resources

* adds details templates for oidc config resources

* OIDC parent route and index redirection (#16139)

* adds parent oidc route with header and adds redirection if clients have been created

* updates learn link

* adds findRecord override to named-path adapter (#16145)

* OIDC Scope Create/Edit View (#16174)

* adds oidc scope-form to create and edit views

* moves oidc header set logic from route to controller

* OIDC Scope Details View (#16191)

* adds oidc scope details view

* removes disabled arg from scope delete confirm action

* updates oidc scope template params link to use DocLink and adds success message on scope create success

* updates oidc scope delete confirm action copy

* adds oidc scopes list (#16196)

* UI/vault 6655/OIDC create view (#16331)

* setup header

* wip

* wip

* wip

* validations

* error validations

* cleanup

* wip

* fix error

* clean up

* handle modelValidations

* add documentation on the decorator

* remove spread attrs

* first test and some fixes

* halfway with test

* fix error where the data object was sending param entiyIds and not entity_ids

* validations or situation

* fix test

* small nit:

* test if this fixes the test

* fix

* cleanup

* nit

* Assignments Update/Edit View  (#16412)

* wip

* fix

* render search-select after promise is fulfilled

* add test coverage

Co-authored-by: clairebontempo@gmail.com <cbontempo@hashicorp.com>

* Added list view for keys (#16454)

* Added list view for providers (#16442)

* Added list view for providers

* Removed check for model data length

* Added new line at end of file

* Fixed linting issues causing ui tests to fail

* Added list view for application (#16469)

* UI/remove has many relationship (#16470)

* remove hasMany from models

* remove relationships from assignments create form

* update tests

* Assignment list view (#16340)

* inital setup

* handle default allow all

* add learn more link

* Fixed the default allow_all for assignment list view to match Figma design

* Fixed linting

* Fixed hbs file syntax

Co-authored-by: linda9379 <linda.jiang@hashicorp.com>

* configure mirage and helper (#16482)

* UI/OIDC client form (#16131)

* WIP client form

* wip

* still WIP

* fix form!;

* remove computeds, cache form attrs instead

* update scope form component name

* add white space validation

* add validations, cleanup

* add edit form

* fix link to in edit form

* disable edit form

* fix linkto

* wip/ search select filter

* WIP/search-select bug

* fix assignment save

* delete old modal js file

* glimmerize/create new search select modal component

* component cleanup

* fix bugginess

* fix search select and radio select action

* add tests

* revert some test changes

* oops, removed test tag

* add key list to response

* fix test

* move search select component to separate PR, revert changes

* one more revert

* remove oidc helper from this pr

* remove hasMany relationship

* minor cleanup

* update assignment form to use fallback

* fix allow_all appearing in dropdown on edit (#16508)

* UI/ OIDC Application (client) details view (#16507)

* fix test

* finish details page

* finish details view

* clean u[

* fix typo

* configure oidc mirage handler for tests

* remove params, add new route instead

* fix headers

* remove console.log

* remove controller/template reliance on tracked variable

* rename variable

* UI/Client route acceptance tests - fixed branch (#16654)

* WIP client route tests

* refactor client form so clientType is not edit-able

* fix ttl in client form

* wip// more acceptance tests and tags for hbs files

* fix typo

* fix syntax error

* finish tests

* fix client form test

* resolve commits

* update form test

* OIDC Assignments Details view. (#16511)

* setup

* cleanup

* view all fix

* wip setting up tabs

* wip

* revert to no queryParam or tabs

* add the read more component and styling

* rename folder

* cleanup

* fix

* UI/OIDC providers create/edit route (#16612)

* update to use DocLink component

* provider create form

* cleaup

* add formt est

* revert label text

* update doclink test

* disallow new scopes from ss

* fix test typo

* fix provider form flash message

* add period

* test new form field attr

* refactor form input

* fix edit portion of issuer field

* add test selector to new input field

* add comment

* Cleanup OIDC Config Mirage handler (#16674)

* cleaup mirage

* change to .then

* pull out into config file

* Scope acceptance tests (#16707)

* Started writing acceptance tests

* Added some more acceptance tests

* Added tags for hbs and more tests

* Modified variable names in scope form test

* Fixed tests and linting

* UI/OIDC Provider read view  (#16632)

* add providers/provider/client route

* provider details view

* add disabled button and tooltip for default

* add toolbar separators

* revert unrelated change

* query all client records and filter by allowed client id"

* refactor adapter to filter for clientId

* cleanup adapter method

* update test

* refactor test

* fix tests to accommodate for serializer change

* update empty state message

* fix linting

* metadata for client list view (#16725)

* Added metadata for list view in clients

* Fixed linting

* Fixed failing ui test

* fix scopes and clients tests (#16768)

* Initial fix of tests

* Fixed failing scopes and clients acceptance tests

* Fixed linting

* UI: Key create/edit form (#16729)

* add route models

* add forms

* add test

* remove helperText attr

* metadata for provider list view (#16738)

* Added meta-data for provider list view

* Added comment for serializer

* Fixed import path for scopes and clients acceptance test files

* UI/Add client ids to search select (#16744)

* WIP use clientID instead of name

* add client ids to search select

* remove provider form component changes

* fix search select on edit

* cleanup comments and method

* fix adapter query method

* clean up comments

* add test

* remove destructuring so linting passes

* fix tests

* add accidentally deleted param

* add clarifying comments

* cleanup

* change how shouldRenderName is set

* cleanup tests

* address comments

* OIDC Assignment Acceptance tests (#16741)

* test and fixes

* merge stuff

* fix

* fixes

* add waituntil

* inconsistent nav issue

* fixes

* blah

* UI/Key details view (#16776)

* add details view

* reformat model file

* todo for when listing applications

* add comment

* update key form with refactored search select

* add applications list

* update test

* update test

* add names to flash messages

* add rollbackAttributes to delete catch (#16796)

* UI: Checks if records exists before creating record when URL contains :name (#16823)

* check for record existing in createRecord

* use error banner instead of flash messages for forms

* add inline form message for validations

* add error count message to inlinealert

* add test for adapter

* add tests

* remove unused vars

* UI: Disable limiting clients when creating key, filter clients when editing (#16926)

* add tooltip to disabled radio button

* pass query object to search select

* update copy

* add comment

* cleanup console log and comment

* fix tests

* revert change because addressed in other pr

* fix diff

* fix test

* UI: Add redirect when last client is deleted  (#16927)

* afterModel redirect if no models exist

* fix test

* change space

* fix incorrect text

* UI:  Add InfoTooltip to selected 'ghost' client_ids (#16942)

* return option if undefined

* add info tooltip to search select

* change word

* add test

* UI: OIDC config keys acceptance tests (#16968)

* add keys test

* update other oidc tests

* remove-search select comment

* UI: Filter Client providers list view (#17027)

* pass param to adapter

* add test

* UI: OIDC Config Acceptance Tests (#17050)

* WIP/provider acceptance tests"

* WIP/this commit breaks lots of things

* fix tests

* update test selectors

* combine key and client tests

* cleanup clients and keys test

* finish tests

* small tidying

* UI: Remove trailing comma from scopes, provider details page (#17069)

* use info table row to cleanup scope logic

* infotableitemarray cleanup

* tidying

* add changelog

* teeny little empty state

* fix wildcard string helper not working

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
Co-authored-by: Angel Garbarino <argarbarino@gmail.com>
Co-authored-by: linda9379 <57650314+linda9379@users.noreply.github.com>
Co-authored-by: linda9379 <linda.jiang@hashicorp.com>
This commit is contained in:
claire bontempo 2022-09-08 18:06:05 -07:00 committed by GitHub
parent 2c11121c19
commit 83fc61c16b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 6225 additions and 211 deletions

2
changelog/17071.txt Normal file
View File

@ -0,0 +1,2 @@
```release-note:feature
**UI OIDC Provider Config**: Adds configuration of Vault as an OIDC identity provider, and offer Vaults various authentication methods and source of identity to any client applications.

View File

@ -0,0 +1,76 @@
/**
* base adapter for resources that are saved to a path whose unique identifier is name
* save requests are made to the same endpoint and the resource is either created if not found or updated
* */
import ApplicationAdapter from './application';
import { assert } from '@ember/debug';
export default class NamedPathAdapter extends ApplicationAdapter {
namespace = 'v1';
saveMethod = 'POST'; // override when extending if PUT is used rather than POST
_saveRecord(store, { modelName }, snapshot) {
// since the response is empty return the serialized data rather than nothing
const data = store.serializerFor(modelName).serialize(snapshot);
return this.ajax(this.urlForUpdateRecord(snapshot.attr('name'), modelName, snapshot), this.saveMethod, {
data,
}).then(() => data);
}
// create does not return response similar to PUT request
createRecord() {
let [store, { modelName }, snapshot] = arguments;
let name = snapshot.attr('name');
// throw error if user attempts to create a record with same name, otherwise POST request silently overrides (updates) the existing model
if (store.hasRecordForId(modelName, name)) {
throw new Error(`A record already exists with the name: ${name}`);
} else {
return this._saveRecord(...arguments);
}
}
// update uses same endpoint and method as create
updateRecord() {
return this._saveRecord(...arguments);
}
// if backend does not return name in response Ember Data will throw an error for pushing a record with no id
// use the id (name) supplied to findRecord to set property on response data
findRecord(store, type, name) {
return super.findRecord(...arguments).then((resp) => {
if (!resp.data.name) {
resp.data.name = name;
}
return resp;
});
}
// GET request with list=true as query param
async query(store, type, query) {
const url = this.urlForQuery(query, type.modelName);
const { paramKey, filterFor, allowed_client_id } = query;
// * 'paramKey' is a string of the param name (model attr) we're filtering for, e.g. 'client_id'
// * 'filterFor' is an array of values to filter for (value type must match the attr type), e.g. array of ID strings
// * 'allowed_client_id' is a valid query param to the /provider endpoint
let queryParams = { list: true, ...(allowed_client_id && { allowed_client_id }) };
const response = await this.ajax(url, 'GET', { data: queryParams });
// filter LIST response only if key_info exists and query includes both 'paramKey' & 'filterFor'
if (filterFor) assert('filterFor must be an array', Array.isArray(filterFor));
if (response.data.key_info && filterFor && paramKey && !filterFor.includes('*')) {
const data = this.filterListResponse(paramKey, filterFor, response.data.key_info);
return { ...response, data };
}
return response;
}
filterListResponse(paramKey, matchValues, key_info) {
const keyInfoAsArray = Object.entries(key_info);
const filtered = keyInfoAsArray.filter((key) => {
const value = key[1]; // value is an object of model attributes
return matchValues.includes(value[paramKey]);
});
const filteredKeyInfo = Object.fromEntries(filtered);
const filteredKeys = Object.keys(filteredKeyInfo);
return { keys: filteredKeys, key_info: filteredKeyInfo };
}
}

View File

@ -0,0 +1,7 @@
import NamedPathAdapter from '../named-path';
export default class OidcAssignmentAdapter extends NamedPathAdapter {
pathForType() {
return 'identity/oidc/assignment';
}
}

View File

@ -0,0 +1,7 @@
import NamedPathAdapter from '../named-path';
export default class OidcClientAdapter extends NamedPathAdapter {
pathForType() {
return 'identity/oidc/client';
}
}

View File

@ -0,0 +1,11 @@
import NamedPathAdapter from '../named-path';
export default class OidcKeyAdapter extends NamedPathAdapter {
pathForType() {
return 'identity/oidc/key';
}
rotate(name, verification_ttl) {
const data = verification_ttl ? { verification_ttl } : {};
return this.ajax(`${this.urlForUpdateRecord(name, 'oidc/key')}/rotate`, 'POST', { data });
}
}

View File

@ -0,0 +1,7 @@
import NamedPathAdapter from '../named-path';
export default class OidcProviderAdapter extends NamedPathAdapter {
pathForType() {
return 'identity/oidc/provider';
}
}

View File

@ -0,0 +1,7 @@
import NamedPathAdapter from '../named-path';
export default class OidcScopeAdapter extends NamedPathAdapter {
pathForType() {
return 'identity/oidc/scope';
}
}

View File

@ -0,0 +1,72 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
/**
* @module Oidc::AssignmentForm
* Oidc::AssignmentForm components are used to display the create view for OIDC providers assignments.
*
* @example
* ```js
* <Oidc::AssignmentForm @model={this.model}
* @onCancel={transition-to "vault.cluster.access.oidc.assignment"} @param1={{param1}}
* @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
*/
export default class OidcAssignmentFormComponent extends Component {
@service store;
@service flashMessages;
@tracked modelValidations;
@tracked errorBanner;
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state } = this.args.model.validate();
this.modelValidations = isValid ? null : state;
if (isValid) {
const { isNew, name } = this.args.model;
yield this.args.model.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the assignment ${name}.`);
// this form is sometimes used in modal, passing the model notifies
// the parent if the save was successful
this.args.onSave(this.args.model);
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
@action
handleOperation({ target }) {
this.args.model.name = target.value;
}
@action
onEntitiesSelect(selectedIds) {
this.args.model.entityIds = selectedIds;
}
@action
onGroupsSelect(selectedIds) {
this.args.model.groupIds = selectedIds;
}
}

View File

@ -0,0 +1,91 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
/**
* @module OidcClientForm
* OidcClientForm components are used to create and update OIDC clients (a.k.a. applications)
*
* @example
* ```js
* <OidcClientForm @model={{this.model}} />
* ```
* @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
*/
export default class OidcClientForm extends Component {
@service store;
@service flashMessages;
@tracked modelValidations;
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked radioCardGroupValue =
!this.args.model.assignments || this.args.model.assignments.includes('allow_all')
? 'allow_all'
: 'limited';
get modelAssignments() {
const { assignments } = this.args.model;
if (assignments.includes('allow_all') && assignments.length === 1) {
return [];
} else {
return assignments;
}
}
@action
handleAssignmentSelection(selection) {
// if array then coming from search-select component, set selection as model assignments
if (Array.isArray(selection)) {
this.args.model.assignments = selection;
} else {
// otherwise update radio button value and reset assignments so
// UI always reflects a user's selection (including when no assignments are selected)
this.radioCardGroupValue = selection;
this.args.model.assignments = [];
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.model.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
if (this.radioCardGroupValue === 'allow_all') {
// the backend permits 'allow_all' AND other assignments, though 'allow_all' will take precedence
// the UI limits the config by allowing either 'allow_all' OR a list of other assignments
// note: when editing the UI removes any additional assignments previously configured via CLI
this.args.model.assignments = ['allow_all'];
}
// if TTL components are toggled off, set to default lease duration
const { idTokenTtl, accessTokenTtl } = this.args.model;
// value returned from API is a number, and string when from form action
if (Number(idTokenTtl) === 0) this.args.model.idTokenTtl = '24h';
if (Number(accessTokenTtl) === 0) this.args.model.accessTokenTtl = '24h';
const { isNew, name } = this.args.model;
yield this.args.model.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the application ${name}.`);
this.args.onSave();
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
}

View File

@ -0,0 +1,89 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
/**
* @module OidcKeyForm
* OidcKeyForm components are used to create and update OIDC providers
*
* @example
* ```js
* <OidcKeyForm @model={{this.model}} />
* ```
* @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
*/
export default class OidcKeyForm extends Component {
@service store;
@service flashMessages;
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked modelValidations;
@tracked radioCardGroupValue =
// If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters
!this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*')
? 'allow_all'
: 'limited';
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] };
}
@action
handleClientSelection(selection) {
// if array then coming from search-select component, set selection as model clients
if (Array.isArray(selection)) {
this.args.model.allowedClientIds = selection.map((client) => client.clientId);
} 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 = [];
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
@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 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.args.onSave();
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
}

View File

@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import parseURL from 'core/utils/parse-url';
/**
* @module OidcProviderForm
* OidcProviderForm components are used to create and update OIDC providers
*
* @example
* ```js
* <OidcProviderForm @model={{this.model}} />
* ```
* @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
*/
export default class OidcProviderForm extends Component {
@service store;
@service flashMessages;
@tracked modelValidations;
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked radioCardGroupValue =
// If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters
!this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*')
? 'allow_all'
: 'limited';
constructor() {
super(...arguments);
const { model } = this.args;
model.issuer = model.isNew ? '' : parseURL(model.issuer).origin;
}
@action
handleClientSelection(selection) {
// if array then coming from search-select component, set selection as model clients
if (Array.isArray(selection)) {
this.args.model.allowedClientIds = selection.map((client) => client.clientId);
} 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 = [];
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
@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 = ['*'];
}
yield this.args.model.save();
this.flashMessages.success(
`Successfully ${isNew ? 'created' : 'updated'} the OIDC provider
${name}.`
);
this.args.onSave();
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
}

View File

@ -0,0 +1,62 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { inject as service } from '@ember/service';
/**
* @module OidcScopeForm
* Oidc scope form components are used to create and edit oidc scopes
*
* @example
* ```js
* <Oidc::ScopeForm @model={{this.model}} />
* ```
* @callback onCancel
* @callback onSave
* @param {Object} model - oidc scope model
* @param {onCancel} onCancel - callback triggered when cancel button is clicked
* @param {onSave} onSave - callback triggered on save success
*/
export default class OidcScopeFormComponent extends Component {
@service flashMessages;
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked modelValidations;
// formatting here is purposeful so that whitespace renders correctly in JsonEditor
exampleTemplate = `{
"username": {{identity.entity.aliases.$MOUNT_ACCESSOR.name}},
"contact": {
"email": {{identity.entity.metadata.email}},
"phone_number": {{identity.entity.metadata.phone_number}}
},
"groups": {{identity.entity.groups.names}}
}`;
@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;
yield this.args.model.save();
this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the scope ${name}.`);
this.args.onSave();
}
} catch (error) {
const message = error.errors ? error.errors.join('. ') : error.message;
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
@action
cancel() {
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
this.args.model[method]();
this.args.onCancel();
}
}

View File

@ -16,9 +16,9 @@
<p class="sub-text"> <p class="sub-text">
{{@attr.options.subText}} {{@attr.options.subText}}
{{#if @attr.options.docLink}} {{#if @attr.options.docLink}}
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer"> <DocLink @path={{@attr.options.docLink}}>
See our documentation See our documentation
</a> </DocLink>
for help. for help.
{{/if}} {{/if}}
</p> </p>

View File

@ -0,0 +1,34 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class OidcConfigureController extends Controller {
@service router;
@tracked header = null;
constructor() {
super(...arguments);
this.router.on('routeDidChange', (transition) => this.setHeader(transition));
}
setHeader(transition) {
// set correct header state based on child route
// when no clients have been created, display create button as call to action
// list views share the same header with tabs as resource links
// the remaining routes are responsible for their own header
const routeName = transition.to.name;
if (routeName.includes('oidc.index')) {
this.header = 'cta';
} else {
const isList = ['clients', 'assignments', 'keys', 'scopes', 'providers'].find((resource) => {
return routeName.includes(`${resource}.index`);
});
this.header = isList ? 'list' : null;
}
}
get isCta() {
return this.header === 'cta';
}
}

View File

@ -0,0 +1,21 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class OidcAssignmentDetailsController extends Controller {
@service router;
@service flashMessages;
@action
async delete() {
try {
await this.model.destroyRecord();
this.flashMessages.success('Assignment deleted successfully');
this.router.transitionTo('vault.cluster.access.oidc.assignments');
} catch (error) {
this.model.rollbackAttributes();
const message = error.errors ? error.errors.join('. ') : error.message;
this.flashMessages.danger(message);
}
}
}

View File

@ -0,0 +1,21 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class OidcClientController extends Controller {
@service router;
@tracked isEditRoute;
constructor() {
super(...arguments);
this.router.on(
'routeDidChange',
({ targetName }) => (this.isEditRoute = targetName.includes('edit') ? true : false)
);
}
get showHeader() {
// hide header when rendering the edit form
return !this.isEditRoute;
}
}

View File

@ -0,0 +1,21 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class OidcClientDetailsController extends Controller {
@service router;
@service flashMessages;
@action
async delete() {
try {
await this.model.destroyRecord();
this.flashMessages.success('Application deleted successfully');
this.router.transitionTo('vault.cluster.access.oidc.clients');
} catch (error) {
this.model.rollbackAttributes();
const message = error.errors ? error.errors.join('. ') : error.message;
this.flashMessages.danger(message);
}
}
}

View File

@ -0,0 +1,20 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class OidcKeyController extends Controller {
@service router;
@tracked isEditRoute;
constructor() {
super(...arguments);
this.router.on('routeDidChange', ({ targetName }) => {
return (this.isEditRoute = targetName.includes('edit') ? true : false);
});
}
get showHeader() {
// hide header when rendering the edit form
return !this.isEditRoute;
}
}

View File

@ -0,0 +1,36 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
export default class OidcKeyDetailsController extends Controller {
@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);
});
}
@action
async delete() {
try {
await this.model.destroyRecord();
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;
this.flashMessages.danger(message);
}
}
}

View File

@ -0,0 +1,20 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class OidcProviderController extends Controller {
@service router;
@tracked isEditRoute;
constructor() {
super(...arguments);
this.router.on('routeDidChange', ({ targetName }) => {
return (this.isEditRoute = targetName.includes('edit') ? true : false);
});
}
get showHeader() {
// hide header when rendering the edit form
return !this.isEditRoute;
}
}

View File

@ -0,0 +1,21 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class OidcProviderDetailsController extends Controller {
@service router;
@service flashMessages;
@action
async delete() {
try {
await this.model.destroyRecord();
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;
this.flashMessages.danger(message);
}
}
}

View File

@ -0,0 +1,21 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class OidcScopeDetailsController extends Controller {
@service router;
@service flashMessages;
@action
async delete() {
try {
await this.model.destroyRecord();
this.flashMessages.success('Scope deleted successfully');
this.router.transitionTo('vault.cluster.access.oidc.scopes');
} catch (error) {
this.model.rollbackAttributes();
const message = error.errors ? error.errors.join('. ') : error.message;
this.flashMessages.danger(message);
}
}
}

View File

@ -22,7 +22,7 @@ import { get } from '@ember/object';
* state represents the error state of the properties defined in the validations object * state represents the error state of the properties defined in the validations object
* const { isValid, errors } = state[propertyKeyName]; * const { isValid, errors } = state[propertyKeyName];
* isValid represents the validity of the property * isValid represents the validity of the property
* errors will be populated with messages defined in the validations object when validations fail * errors will be populated with messages defined in the validations object when validations fail. message must be a complete sentence (and include punctuation)
* since a property can have multiple validations, errors is always returned as an array * since a property can have multiple validations, errors is always returned as an array
* *
*** basic example *** basic example
@ -30,7 +30,8 @@ import { get } from '@ember/object';
* import Model from '@ember-data/model'; * import Model from '@ember-data/model';
* import withModelValidations from 'vault/decorators/model-validations'; * import withModelValidations from 'vault/decorators/model-validations';
* *
* const validations = { foo: [{ type: 'presence', message: 'foo is a required field' }] }; * Notes: all messages need to have a period at the end of them.
* const validations = { foo: [{ type: 'presence', message: 'foo is a required field.' }] };
* @withModelValidations(validations) * @withModelValidations(validations)
* class SomeModel extends Model { foo = null; } * class SomeModel extends Model { foo = null; }
* *
@ -42,7 +43,7 @@ import { get } from '@ember/object';
* *
*** example using custom validator *** example using custom validator
* *
* const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test' }] }; * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test.' }] };
* @withModelValidations(validations) * @withModelValidations(validations)
* class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; } * class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; }
* *
@ -50,7 +51,11 @@ import { get } from '@ember/object';
* const { isValid, state } = model.validate(); * const { isValid, state } = model.validate();
* -> isValid = false; * -> isValid = false;
* -> state.foo.isValid = false; * -> state.foo.isValid = false;
* -> state.foo.errors = ['foo is required if bar includes test']; * -> state.foo.errors = ['foo is required if bar includes test.'];
*
* *** example adding class in hbs file
* all form-validations need to have a red border around them. Add this by adding a conditional class 'has-error-border'
* class="input field {{if this.errors.name.errors 'has-error-border'}}"
*/ */
export function withModelValidations(validations) { export function withModelValidations(validations) {

View File

@ -54,7 +54,7 @@ export default Model.extend({
defaultSubText: defaultSubText:
'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.', 'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.',
defaultShown: 'Default', defaultShown: 'Default',
docLink: 'https://www.vaultproject.io/docs/concepts/password-policies', docLink: '/docs/concepts/password-policies',
}), }),
// common fields // common fields
@ -106,7 +106,7 @@ export default Model.extend({
subText: 'Enter the custom username template to use.', subText: 'Enter the custom username template to use.',
defaultSubText: defaultSubText:
'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.', 'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.',
docLink: 'https://www.vaultproject.io/docs/concepts/username-templating', docLink: '/docs/concepts/username-templating',
defaultShown: 'Default', defaultShown: 'Default',
}), }),
max_open_connections: attr('number', { max_open_connections: attr('number', {

View File

@ -0,0 +1,59 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
import { isPresent } from '@ember/utils';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
targets: [
{
validator(model) {
return isPresent(model.entityIds) || isPresent(model.groupIds);
},
message: 'At least one entity or group is required.',
},
],
};
@withModelValidations(validations)
export default class OidcAssignmentModel extends Model {
@attr('string') name;
@attr('array') entityIds;
@attr('array') groupIds;
// CAPABILITIES
@lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
@lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
get canCreate() {
return this.assignmentPath.get('canCreate');
}
get canRead() {
return this.assignmentPath.get('canRead');
}
get canEdit() {
return this.assignmentPath.get('canUpdate');
}
get canDelete() {
return this.assignmentPath.get('canDelete');
}
get canList() {
return this.assignmentsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/entity`) entitiesPath;
get canListEntities() {
return this.entitiesPath.get('canList');
}
@lazyCapabilities(apiPath`identity/group`) groupsPath;
get canListGroups() {
return this.groupsPath.get('canList');
}
}

View File

@ -0,0 +1,134 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
key: [{ type: 'presence', message: 'Key is required.' }],
};
@withModelValidations(validations)
export default class OidcClientModel extends Model {
@attr('string', { label: 'Application name', editDisabled: true }) name;
@attr('string', {
label: 'Type',
subText:
'Specify whether the application type is confidential or public. The public type must use PKCE. This cannot be edited later.',
editType: 'radio',
editDisabled: true,
defaultValue: 'confidential',
possibleValues: ['confidential', 'public'],
})
clientType;
@attr('array', {
label: 'Redirect URIs',
subText:
'One of these values must exactly match the redirect_uri parameter value used in each authentication request.',
editType: 'stringArray',
})
redirectUris;
// >> MORE OPTIONS TOGGLE <<
@attr('string', {
label: 'Signing key',
subText: 'Add a key to sign and verify the JSON web tokens (JWT). This cannot be edited later.',
editType: 'searchSelect',
editDisabled: true,
onlyAllowExisting: true,
defaultValue() {
return ['default'];
},
fallbackComponent: 'input-search',
selectLimit: 1,
models: ['oidc/key'],
})
key;
@attr({
label: 'Access Token TTL',
editType: 'ttl',
defaultValue: '24h',
})
accessTokenTtl;
@attr({
label: 'ID Token TTL',
editType: 'ttl',
defaultValue: '24h',
})
idTokenTtl;
// >> END MORE OPTIONS TOGGLE <<
@attr('array', { label: 'Assign access' }) assignments; // no editType because does not use form-field component
@attr('string', { label: 'Client ID' }) clientId;
@attr('string') clientSecret;
// CAPABILITIES //
@lazyCapabilities(apiPath`identity/oidc/client/${'name'}`, 'name') clientPath;
@lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
get canCreate() {
return this.clientPath.get('canCreate');
}
get canRead() {
return this.clientPath.get('canRead');
}
get canEdit() {
return this.clientPath.get('canUpdate');
}
get canDelete() {
return this.clientPath.get('canDelete');
}
get canList() {
return this.clientsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
get canListKeys() {
return this.keysPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
@lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
get canCreateAssignments() {
return this.assignmentPath.get('canCreate');
}
get canListAssignments() {
return this.assignmentsPath.get('canList');
}
// API WIP
@lazyCapabilities(apiPath`identity/oidc/${'name'}/provider`, 'backend', 'name') clientProvidersPath;
get canListProviders() {
return this.clientProvidersPath.get('canList');
}
// TODO refactor when field-to-attrs util is refactored as decorator
_attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, ['name', 'clientType', 'redirectUris']);
}
return this._attributeMeta;
}
_fieldToAttrsGroups = null;
// more options fields
get fieldGroups() {
if (!this._fieldToAttrsGroups) {
this._fieldToAttrsGroups = fieldToAttrs(this, [
{ 'More options': ['key', 'idTokenTtl', 'accessTokenTtl'] },
]);
}
return this._fieldToAttrsGroups;
}
}

64
ui/app/models/oidc/key.js Normal file
View File

@ -0,0 +1,64 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
};
@withModelValidations(validations)
export default class OidcKeyModel extends Model {
@attr('string', { editDisabled: true }) name;
@attr('string', {
defaultValue: 'RS256',
possibleValues: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'],
})
algorithm;
@attr({ editType: 'ttl', defaultValue: '24h' }) rotationPeriod;
@attr({ label: 'Verification TTL', editType: 'ttl', defaultValue: '24h' }) verificationTtl;
@attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component
// TODO refactor when field-to-attrs is refactored as decorator
_attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, [
'name',
'algorithm',
'rotationPeriod',
'verificationTtl',
]);
}
return this._attributeMeta;
}
@lazyCapabilities(apiPath`identity/oidc/key/${'name'}`, 'name') keyPath;
@lazyCapabilities(apiPath`identity/oidc/key/${'name'}/rotate`, 'name') rotatePath;
@lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
get canCreate() {
return this.keyPath.get('canCreate');
}
get canRead() {
return this.keyPath.get('canRead');
}
get canEdit() {
return this.keyPath.get('canUpdate');
}
get canRotate() {
return this.rotatePath.get('canUpdate');
}
get canDelete() {
return this.keyPath.get('canDelete');
}
get canList() {
return this.keysPath.get('canList');
}
}

View File

@ -0,0 +1,74 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{
type: 'containsWhiteSpace',
message: 'Name cannot contain whitespace.',
},
],
};
@withModelValidations(validations)
export default class OidcProviderModel extends Model {
@attr('string', { editDisabled: true }) name;
@attr('string', {
subText:
'The scheme, host, and optional port for your issuer. This will be used to build the URL that validates ID tokens.',
placeholderText: 'e.g. https://example.com:8200',
docLink: '/api-docs/secret/identity/oidc-provider#create-or-update-a-provider',
helpText: `Optional. This defaults to a URL with Vault's api_addr`,
})
issuer;
@attr('array', {
label: 'Supported scopes',
subText: 'Scopes define information about a user and the OIDC service. Optional.',
editType: 'searchSelect',
models: ['oidc/scope'],
fallbackComponent: 'string-list',
onlyAllowExisting: true,
})
scopesSupported;
@attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component
// TODO refactor when field-to-attrs is refactored as decorator
_attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, ['name', 'issuer', 'scopesSupported']);
}
return this._attributeMeta;
}
@lazyCapabilities(apiPath`identity/oidc/provider/${'name'}`, 'name') providerPath;
@lazyCapabilities(apiPath`identity/oidc/provider`) providersPath;
get canCreate() {
return this.providerPath.get('canCreate');
}
get canRead() {
return this.providerPath.get('canRead');
}
get canEdit() {
return this.providerPath.get('canUpdate');
}
get canDelete() {
return this.providerPath.get('canDelete');
}
get canList() {
return this.providersPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
get canListClients() {
return this.clientsPath.get('canList');
}
@lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
get canListScopes() {
return this.scopesPath.get('canList');
}
}

View File

@ -0,0 +1,42 @@
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [{ type: 'presence', message: 'Name is required.' }],
};
@withModelValidations(validations)
export default class OidcScopeModel extends Model {
@attr('string', { editDisabled: true }) name;
@attr('string', { editType: 'textarea' }) description;
@attr('string', { label: 'JSON Template', editType: 'json', mode: 'ruby' }) template;
// TODO refactor when field-to-attrs is refactored as decorator
_attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
get formFields() {
if (!this._attributeMeta) {
this._attributeMeta = expandAttributeMeta(this, ['name', 'description', 'template']);
}
return this._attributeMeta;
}
@lazyCapabilities(apiPath`identity/oidc/scope/${'name'}`, 'name') scopePath;
@lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
get canCreate() {
return this.scopePath.get('canCreate');
}
get canRead() {
return this.scopePath.get('canRead');
}
get canEdit() {
return this.scopePath.get('canUpdate');
}
get canDelete() {
return this.scopePath.get('canDelete');
}
get canList() {
return this.scopesPath.get('canList');
}
}

View File

@ -109,6 +109,46 @@ Router.map(function () {
this.route('index', { path: '/' }); this.route('index', { path: '/' });
this.route('create'); this.route('create');
}); });
this.route('oidc', function () {
this.route('clients', function () {
this.route('create');
this.route('client', { path: '/:name' }, function () {
this.route('details');
this.route('providers');
this.route('edit');
});
});
this.route('keys', function () {
this.route('create');
this.route('key', { path: '/:name' }, function () {
this.route('details');
this.route('clients');
this.route('edit');
});
});
this.route('assignments', function () {
this.route('create');
this.route('assignment', { path: '/:name' }, function () {
this.route('details');
this.route('edit');
});
});
this.route('providers', function () {
this.route('create');
this.route('provider', { path: '/:name' }, function () {
this.route('details');
this.route('clients');
this.route('edit');
});
});
this.route('scopes', function () {
this.route('create');
this.route('scope', { path: '/:name' }, function () {
this.route('details');
this.route('edit');
});
});
});
}); });
this.route('secrets', function () { this.route('secrets', function () {
this.route('backends', { path: '/' }); this.route('backends', { path: '/' });

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcConfigureRoute extends Route {}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcAssignmentRoute extends Route {
model({ name }) {
return this.store.findRecord('oidc/assignment', name);
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcAssignmentDetailsRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcAssignmentEditRoute extends Route {}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcAssignmentsCreateRoute extends Route {
model() {
return this.store.createRecord('oidc/assignment');
}
}

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
export default class OidcAssignmentsRoute extends Route {
model() {
return this.store.query('oidc/assignment', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcClientRoute extends Route {
model({ name }) {
return this.store.findRecord('oidc/client', name);
}
}

View File

@ -0,0 +1,2 @@
import Route from '@ember/routing/route';
export default class OidcClientDetailsRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcClientEditRoute extends Route {}

View File

@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
export default class OidcClientProvidersRoute extends Route {
model() {
const model = this.modelFor('vault.cluster.access.oidc.clients.client');
return this.store
.query('oidc/provider', {
allowed_client_id: model.clientId,
})
.catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcClientsCreateRoute extends Route {
model() {
return this.store.createRecord('oidc/client');
}
}

View File

@ -0,0 +1,21 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class OidcClientsRoute extends Route {
@service router;
model() {
return this.store.query('oidc/client', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
afterModel(model) {
if (model.length === 0) {
this.router.transitionTo('vault.cluster.access.oidc');
}
}
}

View File

@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class OidcConfigureRoute extends Route {
@service router;
beforeModel() {
return this.store
.query('oidc/client', {})
.then(() => {
// transition to client list view if clients have been created
this.router.transitionTo('vault.cluster.access.oidc.clients');
})
.catch(() => {
// adapter throws error for 404 - swallow and remain on index route to show call to action
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcKeysCreateRoute extends Route {
model() {
return this.store.createRecord('oidc/key');
}
}

View File

@ -0,0 +1,12 @@
import Route from '@ember/routing/route';
export default class OidcKeysRoute extends Route {
model() {
return this.store.query('oidc/key', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcKeyRoute extends Route {
model({ name }) {
return this.store.findRecord('oidc/key', name);
}
}

View File

@ -0,0 +1,8 @@
import Route from '@ember/routing/route';
export default class OidcKeyClientsRoute extends Route {
async model() {
const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.keys.key');
return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds });
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcKeyDetailsRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcKeyEditRoute extends Route {}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcProvidersCreateRoute extends Route {
model() {
return this.store.createRecord('oidc/provider');
}
}

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
export default class OidcProvidersRoute extends Route {
model() {
return this.store.query('oidc/provider', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcProviderRoute extends Route {
model({ name }) {
return this.store.findRecord('oidc/provider', name);
}
}

View File

@ -0,0 +1,8 @@
import Route from '@ember/routing/route';
export default class OidcProviderClientsRoute extends Route {
async model() {
const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.providers.provider');
return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds });
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcProviderDetailsRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcProviderEditRoute extends Route {}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcScopesCreateRoute extends Route {
model() {
return this.store.createRecord('oidc/scope');
}
}

View File

@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
export default class OidcScopesRoute extends Route {
model() {
return this.store.query('oidc/scope', {}).catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
});
}
}

View File

@ -0,0 +1,7 @@
import Route from '@ember/routing/route';
export default class OidcScopeRoute extends Route {
model({ name }) {
return this.store.findRecord('oidc/scope', name);
}
}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcScopeDetailsRoute extends Route {}

View File

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class OidcScopeEditRoute extends Route {}

View File

@ -0,0 +1,5 @@
import ApplicationSerializer from '../application';
export default class OidcAssignmentSerializer extends ApplicationSerializer {
primaryKey = 'name';
}

View File

@ -0,0 +1,17 @@
import ApplicationSerializer from '../application';
export default class OidcClientSerializer extends ApplicationSerializer {
primaryKey = 'name';
// rehydrate each client model so all model attributes are accessible from the LIST response
normalizeItems(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] }));
}
Object.assign(payload, payload.data);
delete payload.data;
}
return payload;
}
}

View File

@ -0,0 +1,5 @@
import ApplicationSerializer from '../application';
export default class OidcKeySerializer extends ApplicationSerializer {
primaryKey = 'name';
}

View File

@ -0,0 +1,17 @@
import ApplicationSerializer from '../application';
export default class OidcProviderSerializer extends ApplicationSerializer {
primaryKey = 'name';
// need to normalize to get issuer metadata for provider's list view
normalizeItems(payload) {
if (payload.data) {
if (payload.data?.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] }));
}
Object.assign(payload, payload.data);
delete payload.data;
}
return payload;
}
}

View File

@ -0,0 +1,5 @@
import ApplicationSerializer from '../application';
export default class OidcScopeSerializer extends ApplicationSerializer {
primaryKey = 'name';
}

View File

@ -5,6 +5,7 @@ const API_PATHS = {
access: { access: {
methods: 'sys/auth', methods: 'sys/auth',
mfa: 'identity/mfa/method', mfa: 'identity/mfa/method',
oidc: 'identity/oidc/client',
entities: 'identity/entity/id', entities: 'identity/entity/id',
groups: 'identity/group/id', groups: 'identity/group/id',
leases: 'sys/leases/lookup', leases: 'sys/leases/lookup',
@ -44,6 +45,7 @@ const API_PATHS_TO_ROUTE_PARAMS = {
'sys/namespaces': { route: 'vault.cluster.access.namespaces', models: [] }, 'sys/namespaces': { route: 'vault.cluster.access.namespaces', models: [] },
'sys/control-group/': { route: 'vault.cluster.access.control-groups', models: [] }, 'sys/control-group/': { route: 'vault.cluster.access.control-groups', models: [] },
'identity/mfa/method': { route: 'vault.cluster.access.mfa', models: [] }, 'identity/mfa/method': { route: 'vault.cluster.access.mfa', models: [] },
'identity/oidc/client': { route: 'vault.cluster.access.oidc', models: [] },
}; };
/* /*

View File

@ -22,6 +22,10 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
&.is-disabled {
opacity: 0.5;
}
} }
a.list-item-row, a.list-item-row,

View File

@ -3,16 +3,15 @@
margin-bottom: $spacing-xs; margin-bottom: $spacing-xs;
} }
.radio-card { .radio-card {
width: 19rem;
box-shadow: $box-shadow-low; box-shadow: $box-shadow-low;
display: flex; flex: 1 1 25%;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
margin: $spacing-xs $spacing-m; margin: $spacing-xs $spacing-m;
border: $base-border; border: $base-border;
border-radius: $radius; border-radius: $radius;
transition: all ease-in-out $speed; transition: all ease-in-out $speed;
max-width: 60%;
input[type='radio'] { input[type='radio'] {
position: absolute; position: absolute;
z-index: 1; z-index: 1;

View File

@ -272,3 +272,13 @@ a.button.disabled {
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
.text-button {
padding: unset;
border: none;
background-color: inherit;
color: inherit;
font-size: inherit;
font-weight: inherit;
cursor: pointer;
color: $link;
}

View File

@ -331,6 +331,11 @@ select.has-error-border {
border: 1px solid $red-500; border: 1px solid $red-500;
} }
.dropdown-has-error-border > div.ember-basic-dropdown-trigger {
border: 1px solid $red-500;
}
.autocomplete-input { .autocomplete-input {
background: $white !important; background: $white !important;
border: 1px solid $grey-light; border: 1px solid $grey-light;

View File

@ -174,6 +174,9 @@
.has-top-padding-l { .has-top-padding-l {
padding-top: $spacing-l; padding-top: $spacing-l;
} }
.has-top-padding-xxl {
padding-top: $spacing-xxl;
}
.has-bottom-margin-xs { .has-bottom-margin-xs {
margin-bottom: $spacing-xs; margin-bottom: $spacing-xs;
} }

View File

@ -116,7 +116,6 @@
@value={{get this.model attr.name}} @value={{get this.model attr.name}}
@type={{attr.type}} @type={{attr.type}}
@isLink={{eq attr.name "transformations"}} @isLink={{eq attr.name "transformations"}}
@viewAll="transformations"
/> />
{{else}} {{else}}
<InfoTableRow <InfoTableRow

View File

@ -9,9 +9,9 @@
<p class="sub-text"> <p class="sub-text">
{{attr.options.helpText}} {{attr.options.helpText}}
{{#if attr.options.docLink}} {{#if attr.options.docLink}}
<a href={{attr.options.docLink}} target="_blank" rel="noopener noreferrer"> <DocLink @path={{attr.options.docLink}}>
See our documentation See our documentation
</a> </DocLink>
for help. for help.
{{/if}} {{/if}}
</p> </p>
@ -43,9 +43,9 @@
<p class="sub-text"> <p class="sub-text">
{{attr.options.subText}} {{attr.options.subText}}
{{#if attr.options.docLink}} {{#if attr.options.docLink}}
<a href={{attr.options.docLink}} target="_blank" rel="noopener noreferrer"> <DocLink @path={{attr.options.docLink}}>
See our documentation See our documentation
</a> </DocLink>
for help. for help.
{{/if}} {{/if}}
</p> </p>

View File

@ -10,7 +10,7 @@
spellcheck="false" spellcheck="false"
value={{@model.name}} value={{@model.name}}
disabled={{not @model.isNew}} disabled={{not @model.isNew}}
class="input field" class="input field {{if this.errors.name.errors 'has-error-border'}}"
data-test-mlef-input="name" data-test-mlef-input="name"
{{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}}
/> />

View File

@ -0,0 +1,95 @@
{{#unless @isInline}}
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<span class="sep">&#x0002f;</span>
{{#if @model.isNew}}
<LinkTo @route={{"vault.cluster.access.oidc.assignments"}}>
Assignments
</LinkTo>
{{else}}
{{! You're editing in this view }}
<LinkTo @route={{"vault.cluster.access.oidc.assignments.assignment.details"}} @model={{@model.name}}>
Details
</LinkTo>
{{/if}}
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-assignment-title>
{{if @model.isNew "Create" "Edit"}}
assignment
</h1>
</p.levelLeft>
</PageHeader>
{{/unless}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
<FormFieldLabel for="name" @label="Name" />
<input
autocomplete="off"
spellcheck="false"
value={{@model.name}}
disabled={{not @model.isNew}}
class="input field {{if this.modelValidations.name.errors 'has-error-border'}}"
{{on "input" this.handleOperation}}
data-test-input="name"
/>
{{#if this.modelValidations.name.errors}}
<AlertInline @type="danger" @message={{join ", " this.modelValidations.name.errors}} />
{{/if}}
<SearchSelect
@id="entities"
@label="Entities"
@labelClass="is-label"
@placeholder="Search"
@models={{array "identity/entity"}}
@inputValue={{@model.entityIds}}
@shouldRenderName={{true}}
@onChange={{this.onEntitiesSelect}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
data-test-search-select="entities"
/>
<SearchSelect
@id="groups"
@label="Groups"
@labelClass="is-label"
@placeholder="Search"
@models={{array "identity/group"}}
@inputValue={{@model.groupIds}}
@shouldRenderName={{true}}
@onChange={{this.onGroupsSelect}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
data-test-search-select="groups"
/>
</div>
<div class="has-top-padding-s">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-oidc-assignment-save
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-oidc-assignment-cancel
>
Cancel
</button>
{{#if this.modelValidations.targets.errors}}
<AlertInline @type="danger" @message={{join ", " this.modelValidations.targets.errors}} @paddingTop={{true}} />
{{/if}}
</div>
</form>

View File

@ -0,0 +1,99 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</span>
{{#if @model.isNew}}
<LinkTo @route="vault.cluster.access.oidc.clients">
Applications
</LinkTo>
{{else}}
<LinkTo @route="vault.cluster.access.oidc.clients.client.details" @model={{@model.name}}>
Details
</LinkTo>
{{/if}}
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-client-title>
{{if @model.isNew "Create" "Edit"}}
application
</h1>
</p.levelLeft>
</PageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{#each @model.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
{{! MORE OPTIONS TOGGLE }}
<FormFieldGroups @renderGroup="More options" @model={{@model}} @modelValidations={{this.modelValidations}} />
</div>
{{! RADIO CARD + SEARCH SELECT }}
<div class="box is-sideless is-fullwidth is-marginless has-top-padding-xxl">
<h4 class="title is-4">Assign access</h4>
<div class="is-flex-row">
<RadioCard
@title="Allow everyone to access existing"
@description="All Vault entities can authenticate through this application."
@icon="org"
@value="allow_all"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleAssignmentSelection}}
/>
<RadioCard
@title="Limit access to selected users"
@description="Choose or create an assignment to give access to selected entities."
@icon="users"
@value="limited"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleAssignmentSelection}}
/>
</div>
{{#if (eq this.radioCardGroupValue "limited")}}
<SearchSelectWithModal
@id="assignments"
@label="Assignment name"
@subText="Search for an existing assignment, or type a new name to create it."
@model="oidc/assignment"
@inputValue={{this.modelAssignments}}
@onChange={{this.handleAssignmentSelection}}
@excludeOptions={{array "allow_all"}}
@fallbackComponent="string-list"
@modalFormComponent="oidc/assignment-form"
@modalSubtext="Use assignment to specify which Vault entities and groups are allowed to authenticate."
/>
{{/if}}
</div>
<div class="field box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-oidc-client-save
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-oidc-client-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,52 @@
{{#each @model as |client|}}
<LinkedBlock
class="list-item-row"
@params={{array "vault.cluster.access.oidc.clients.client.details" client.name}}
data-test-oidc-client-linked-block={{client.name}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="code" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{client.name}}
</span>
<div class="has-text-grey is-size-8">
Client ID:
{{client.clientId}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo
@route="vault.cluster.access.oidc.clients.client.details"
@model={{client.name}}
@disabled={{eq client.canRead false}}
data-test-oidc-client-menu-link="details"
>
Details
</LinkTo>
</li>
<li>
<LinkTo
@route="vault.cluster.access.oidc.clients.client.edit"
@model={{client.name}}
@disabled={{eq client.canEdit false}}
data-test-oidc-client-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}

View File

@ -0,0 +1,100 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</span>
{{#if @model.isNew}}
<LinkTo @route="vault.cluster.access.oidc.keys">
Keys
</LinkTo>
{{else}}
<LinkTo @route="vault.cluster.access.oidc.keys.key.details" @model={{@model.name}}>
Details
</LinkTo>
{{/if}}
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-key-title>
{{if @model.isNew "Create" "Edit"}}
key
</h1>
</p.levelLeft>
</PageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{#each @model.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
</div>
{{! RADIO CARD + SEARCH SELECT }}
<div class="box is-sideless is-fullwidth is-marginless has-top-padding-xxl">
<h4 class="title is-4">Allowed applications</h4>
<div class="is-flex-row">
<RadioCard
@title="Allow every application to use"
@description="All applications can use this key for authentication requests."
@icon="globe"
@value="allow_all"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleClientSelection}}
/>
<RadioCard
@title="Limit access to selected application"
@description="Only selected applications can use this key for authentication requests."
@icon="globe-private"
@value="limited"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleClientSelection}}
@disabled={{@model.isNew}}
@disabledTooltipMessage="This option has been disabled for now. To limit access, you must first create an application that references this key."
/>
</div>
{{#if (eq this.radioCardGroupValue "limited")}}
<SearchSelect
@id="allowedClientIds"
@subLabel="Application name"
@subText="Select which applications are allowed to use this key. Only applications that currently reference this key will appear in the dropdown."
@models={{array "oidc/client"}}
@inputValue={{@model.allowedClientIds}}
@onChange={{this.handleClientSelection}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
@passObject={{true}}
@objectKeys={{array "clientId"}}
@queryObject={{this.filterDropdownOptions}}
/>
{{/if}}
</div>
<div class="field box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-oidc-key-save
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-oidc-key-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,127 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb">
<ul>
<li>
<span class="sep">&#x0002f;</span>
{{#if @model.isNew}}
<LinkTo @route="vault.cluster.access.oidc.providers">
Providers
</LinkTo>
{{else}}
<LinkTo @route="vault.cluster.access.oidc.providers.provider.details" @model={{@model.name}}>
Details
</LinkTo>
{{/if}}
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-provider-title>
{{if @model.isNew "Create" "Edit"}}
provider
</h1>
</p.levelLeft>
</PageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{! name field }}
<FormField
data-test-field={{true}}
@attr={{get @model.formFields "0"}}
@model={{@model}}
@modelValidations={{this.modelValidations}}
/>
{{#let (get @model.formFields "1") as |attr|}}
<FormFieldLabel
for={{attr.name}}
@label="Issuer"
@helpText={{attr.options.helpText}}
@subText={{attr.options.subText}}
@docLink={{attr.options.docLink}}
/>
<Input
data-test-field={{true}}
data-test-input={{attr.name}}
id={{attr.name}}
autocomplete="off"
spellcheck="false"
@value={{@model.issuer}}
class="input {{if this.validationError 'has-error-border'}}"
placeholder={{attr.options.placeholderText}}
/>
{{/let}}
{{! scopesSupported field }}
<FormField
data-test-field={{true}}
@attr={{get @model.formFields "2"}}
@model={{@model}}
@modelValidations={{this.modelValidations}}
@labelClass="is-label"
/>
</div>
{{! RADIO CARD + SEARCH SELECT }}
<div class="box is-sideless is-fullwidth is-marginless has-top-padding-xxl">
<h4 class="title is-4">Allowed applications</h4>
<div class="is-flex-row">
<RadioCard
@title="Allow every application to use"
@description="All applications can use this provider for authentication requests."
@icon="globe"
@value="allow_all"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleClientSelection}}
/>
<RadioCard
@title="Limit access to selected application"
@description="Only selected applications can use this provider for authentication requests."
@icon="globe-private"
@value="limited"
@groupValue={{this.radioCardGroupValue}}
@onChange={{this.handleClientSelection}}
/>
</div>
{{#if (eq this.radioCardGroupValue "limited")}}
<SearchSelect
@id="allowedClientIds"
@subLabel="Application name"
@models={{array "oidc/client"}}
@inputValue={{@model.allowedClientIds}}
@onChange={{this.handleClientSelection}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
@passObject={{true}}
@objectKeys={{array "clientId"}}
/>
{{/if}}
</div>
<div class="field box is-fullwidth is-bottomless">
<div class="control">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-oidc-provider-save
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-oidc-provider-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,52 @@
{{#each @model as |provider|}}
<LinkedBlock
class="list-item-row"
@params={{array "vault.cluster.access.oidc.providers.provider.details" provider.name}}
data-test-oidc-provider-linked-block={{provider.name}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="provider" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{provider.name}}
</span>
<div class="has-text-grey is-size-8">
Issuer:
{{provider.issuer}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo
@route="vault.cluster.access.oidc.providers.provider.details"
@model={{provider.name}}
@disabled={{not provider.canRead}}
data-test-oidc-provider-menu-link="details"
>
Details
</LinkTo>
</li>
<li>
<LinkTo
@route="vault.cluster.access.oidc.providers.provider.edit"
@model={{provider.name}}
@disabled={{not provider.canEdit}}
data-test-oidc-provider-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}

View File

@ -0,0 +1,113 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<span class="sep">&#x0002f;</span>
{{#if @model.isNew}}
<LinkTo @route={{"vault.cluster.access.oidc.scopes"}}>
Scopes
</LinkTo>
{{else}}
{{! You're editing in this view }}
<LinkTo @route={{"vault.cluster.access.oidc.scopes.scope.details"}} @model={{@model.name}}>
Details
</LinkTo>
{{/if}}
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-scope-title>
{{if @model.isNew "Create" "Edit"}}
scope
</h1>
</p.levelLeft>
</PageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<p class="has-bottom-margin-l">
Providers may reference a set of scopes to make specific identity information available as claims
</p>
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
{{#each @model.formFields as |field|}}
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
<p class="is-size-9 has-text-grey has-bottom-margin-l">
You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field. See
<button
type="button"
class="text-button"
{{on "click" (fn (mut this.showTemplateModal))}}
data-test-oidc-scope-example
>
example template
</button>.
</p>
</div>
<div class="has-top-margin-l has-bottom-margin-l">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-oidc-scope-save
>
{{if @model.isNew "Create" "Update"}}
</button>
<button
type="button"
class="button has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-oidc-scope-cancel
>
Cancel
</button>
</div>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</form>
<Modal
@title="Scope template"
@onClose={{fn (mut this.showTemplateModal) false}}
@isActive={{this.showTemplateModal}}
@showCloseButton={{true}}
>
<section class="modal-card-body">
<div class="is-flex-between is-flex-center has-bottom-margin-s">
<p data-test-modal-copy>
Example of a JSON template for scopes:
</p>
<CopyButton
class="button is-transparent"
@clipboardText={{this.exampleTemplate}}
@buttonType="button"
@success={{fn (set-flash-message "Example template copied!")}}
>
<Icon @name="clipboard-copy" aria-label="Copy" />
</CopyButton>
</div>
{{! code-mirror modifier does not render value initially in wormhole until focus event fires }}
{{! wait until the Modal is rendered and then show the JsonEditor }}
{{#if this.showTemplateModal}}
<JsonEditor @value={{this.exampleTemplate}} @mode="ruby" @readOnly={{true}} @showToolbar={{false}} />
{{/if}}
<p class="has-top-margin-m">
The full list of template parameters can be found
<DocLink @path="/docs/concepts/oidc-provider#scopes">
here.
</DocLink>
</p>
</section>
<div class="modal-card-head has-border-top-light">
<button type="button" class="button" {{on "click" (fn (mut this.showTemplateModal) false)}} data-test-close-modal>
Close
</button>
</div>
</Modal>

View File

@ -3,33 +3,44 @@
class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}" class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}"
...attributes ...attributes
> >
{{#if (has-block)}} <ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
{{yield}} <T.Trigger tabindex="-1">
{{else}} {{#if (has-block)}}
<div class="radio-card-row"> {{yield}}
<div> {{else}}
<Icon @name={{@icon}} @size="24" class="has-text-grey-light" /> <div class="radio-card-row">
<div>
<Icon @name={{@icon}} @size="24" class="has-text-grey-light" />
</div>
<div class="has-left-margin-s">
<h5 class="radio-card-message-title">
{{@title}}
</h5>
<p class="radio-card-message-body">
{{@description}}
</p>
</div>
</div>
{{/if}}
<div class="radio-card-radio-row">
<RadioButton
id={{dasherize @value}}
name="config-mode"
class="radio"
@disabled={{@disabled}}
@value={{@value}}
@groupValue={{@groupValue}}
@onChange={{@onChange}}
/>
<span class="dot"></span>
</div> </div>
<div class="has-left-margin-s"> </T.Trigger>
<h5 class="radio-card-message-title"> {{#if (and @disabled @disabledTooltipMessage)}}
{{@title}} <T.Content @defaultClass="tool-tip smaller-font">
</h5> <div class="box">
<p class="radio-card-message-body"> {{@disabledTooltipMessage}}
{{@description}} </div>
</p> </T.Content>
</div> {{/if}}
</div> </ToolTip>
{{/if}}
<div class="radio-card-radio-row">
<RadioButton
id={{dasherize @value}}
name="config-mode"
class="radio"
disabled={{@disabled}}
@value={{@value}}
@groupValue={{@groupValue}}
@onChange={{@onChange}}
/>
<span class="dot"></span>
</div>
</label> </label>

View File

@ -99,7 +99,6 @@
@value={{get this.model attr.name}} @value={{get this.model attr.name}}
@type={{attr.type}} @type={{attr.type}}
@isLink={{eq attr.name "transformations"}} @isLink={{eq attr.name "transformations"}}
@viewAll="transformations"
/> />
{{else}} {{else}}
<InfoTableRow <InfoTableRow

View File

@ -14,7 +14,6 @@
@queryParam="role" @queryParam="role"
@modelType="transform/role" @modelType="transform/role"
@wildcardLabel={{attr.options.wildcardLabel}} @wildcardLabel={{attr.options.wildcardLabel}}
@viewAll="roles"
@backend={{this.model.backend}} @backend={{this.model.backend}}
/> />
{{else}} {{else}}

View File

@ -61,6 +61,13 @@
</LinkTo> </LinkTo>
</li> </li>
{{/if}} {{/if}}
{{#if (has-permission "access" routeParams="oidc")}}
<li>
<LinkTo @route="vault.cluster.access.oidc" data-test-link="oidc">
OIDC Provider
</LinkTo>
</li>
{{/if}}
</MenuSidebar> </MenuSidebar>
<div class="column is-9"> <div class="column is-9">
{{outlet}} {{outlet}}

View File

@ -0,0 +1,59 @@
{{#if this.header}}
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
OIDC Provider
</h1>
</p.levelLeft>
</PageHeader>
<div class="box is-fullwidth is-sideless is-flex-between is-box-shadowless is-marginless" data-test-oidc-header>
<p>
Configure Vault to act as an OIDC identity provider, and offer
{{"Vaults"}}
various authentication
{{#if this.isCta}}
<br />
{{/if}}
methods and source of identity to any client applications.
<LearnLink @path="/tutorials/vault/oidc-identity-provider">
Learn more
</LearnLink>
</p>
{{#if this.isCta}}
<button
type="submit"
class="button is-primary"
{{on "click" (transition-to "vault.cluster.access.oidc.clients.create")}}
data-test-oidc-configure
>
Create your first app
</button>
{{/if}}
</div>
{{#unless this.isCta}}
{{! show tab links in list routes }}
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless" data-test-oidc-tabs>
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.access.oidc.clients" data-test-tab="clients">
Applications
</LinkTo>
<LinkTo @route="vault.cluster.access.oidc.keys" data-test-tab="keys">
Keys
</LinkTo>
<LinkTo @route="vault.cluster.access.oidc.assignments" data-test-tab="assignments">
Assignments
</LinkTo>
<LinkTo @route="vault.cluster.access.oidc.providers" data-test-tab="providers">
Providers
</LinkTo>
<LinkTo @route="vault.cluster.access.oidc.scopes" data-test-tab="scopes">
Scopes
</LinkTo>
</ul>
</nav>
</div>
{{/unless}}
{{/if}}
{{outlet}}

View File

@ -0,0 +1,85 @@
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<span class="sep">&#x0002f;</span>
<LinkTo @route="vault.cluster.access.oidc.assignments">
Assignments
</LinkTo>
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-assignment-title>
{{@model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs" aria-label="tabs">
<ul>
<LinkTo
@route="vault.cluster.access.oidc.assignments.assignment.details"
@model={{@model}}
data-test-oidc-assignment-details
>
Details
</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarActions>
{{#if @model.canDelete}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{this.delete}}
@confirmTitle="Delete assignment?"
@confirmMessage="This assignment will be permanently deleted. You will not be able to recover it."
@confirmButtonText="Delete"
data-test-oidc-assignment-delete
>
Delete assignment
</ConfirmAction>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @model.canEdit}}
<ToolbarLink
@params={{array "vault.cluster.access.oidc.assignments.assignment.edit" @model.name}}
data-test-oidc-assignment-edit
>
Edit assignment
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<InfoTableRow @label="Name" @value={{@model.name}} />
<InfoTableRow
@label="Entities"
@type="array"
@value={{@model.entityIds}}
@model={{@model}}
@isLink={{true}}
@modelType="oidc/assignment"
@itemRoute={{(array "vault.cluster.access.identity.show" "entities" "details")}}
@alwaysRender={{true}}
@toggleViewAll={{true}}
/>
<InfoTableRow
@label="Groups"
@type="array"
@value={{@model.groupIds}}
@model={{@model}}
@isLink={{true}}
@modelType="oidc/assignment"
@itemRoute={{(array "vault.cluster.access.identity.show" "groups" "details")}}
@alwaysRender={{true}}
@doNotTruncate={{true}}
/>
</div>

View File

@ -0,0 +1,5 @@
<Oidc::AssignmentForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}}
@onSave={{transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}}
/>

View File

@ -0,0 +1,5 @@
<Oidc::AssignmentForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.access.oidc.assignments"}}
@onSave={{transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}}
/>

View File

@ -0,0 +1,71 @@
<Toolbar>
<ToolbarActions>
<ToolbarLink
@type="add"
@params={{array "vault.cluster.access.oidc.assignments.create"}}
data-test-oidc-assignment-create
>
Create assignment
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#each this.model as |model|}}
<LinkedBlock
class="list-item-row {{if (eq model.name 'allow_all') 'is-disabled'}}"
@params={{array "vault.cluster.access.oidc.assignments.assignment.details" model.name}}
@disabled={{eq model.name "allow_all"}}
data-test-oidc-assignment-linked-block={{model.name}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="users" class="has-text-grey-light" />
<span class="has-text-weight-semibold {{if (not-eq model.name 'allow_all') 'is-underline'}}">
{{model.name}}
</span>
{{#if (eq model.name "allow_all")}}
<div class="is-size-8">
This is a built-in assignment that cannot be modified or deleted.
<DocLink @path="/docs/concepts/oidc-provider#assignments">
Learn more
</DocLink>
</div>
{{/if}}
</div>
</div>
{{#if (not-eq model.name "allow_all")}}
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo
@route="vault.cluster.access.oidc.assignments.assignment.details"
@model={{model.name}}
@disabled={{eq model.canRead false}}
data-test-oidc-assignment-menu-link="details"
>
Details
</LinkTo>
</li>
<li>
<LinkTo
@route="vault.cluster.access.oidc.assignments.assignment.edit"
@model={{model.name}}
@disabled={{eq model.canEdit false}}
data-test-oidc-assignment-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
{{/if}}
</div>
</LinkedBlock>
{{/each}}

View File

@ -0,0 +1,44 @@
{{#if this.showHeader}}
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<span class="sep">&#x0002f;</span>
<LinkTo @route="vault.cluster.access.oidc.clients">
Applications
</LinkTo>
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-oidc-client-header>
{{this.model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs" aria-label="tabs">
<ul>
<LinkTo
@route="vault.cluster.access.oidc.clients.client.details"
@model={{this.model}}
data-test-oidc-client-details
>
Details
</LinkTo>
<LinkTo
@route="vault.cluster.access.oidc.clients.client.providers"
@model={{this.model}}
data-test-oidc-client-providers
>
Available providers
</LinkTo>
</ul>
</nav>
</div>
{{/if}}
{{outlet}}

View File

@ -0,0 +1,43 @@
<Toolbar data-test-oidc-client-toolbar>
<ToolbarActions>
{{#if this.model.canDelete}}
<ConfirmAction
data-test-oidc-client-delete
@buttonClasses="toolbar-link"
@onConfirmAction={{this.delete}}
@confirmTitle="Delete application?"
@confirmMessage="This application will be permanently deleted. You will need to re-create it to use it again."
@confirmButtonText="Delete"
>
Delete application
</ConfirmAction>
<div class="toolbar-separator"></div>
{{/if}}
{{#if this.model.canEdit}}
<ToolbarLink
data-test-oidc-client-edit
@params={{array "vault.cluster.access.oidc.clients.client.edit" this.model.name}}
>
Edit application
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<InfoTableRow @label="Name" @value={{this.model.name}} @alwaysRender={{true}} />
<InfoTableRow @label="Type" @value={{this.model.clientType}} @alwaysRender={{true}} />
<InfoTableRow @label="Redirect URI" @value={{this.model.redirectUris}} @alwaysRender={{true}} />
<InfoTableRow @label="Assignment" @value={{this.model.assignments}} @alwaysRender={{true}} />
<InfoTableRow @label="Key" @alwaysRender={{true}}>
<LinkTo @route="vault.cluster.access.oidc.keys.key.details" @model={{this.model.key}}>
{{this.model.key}}
</LinkTo>
</InfoTableRow>
<InfoTableRow @label="Client ID" @value={{this.model.clientId}} @addCopyButton={{true}} @alwaysRender={{true}} />
<InfoTableRow @label="Client Secret">
<MaskedInput @value={{this.model.clientSecret}} @displayOnly={{true}} @allowCopy={{true}} @alwaysRender={{true}} />
</InfoTableRow>
<InfoTableRow @label="ID Token TTL" @value={{format-duration this.model.idTokenTtl}} @alwaysRender={{true}} />
<InfoTableRow @label="Access Token TTL" @value={{format-duration this.model.accessTokenTtl}} @alwaysRender={{true}} />
</div>

View File

@ -0,0 +1,5 @@
<Oidc::ClientForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.access.oidc.clients.client.details" this.model.name}}
@onSave={{transition-to "vault.cluster.access.oidc.clients.client.details" this.model.name}}
/>

View File

@ -0,0 +1,13 @@
<Toolbar />
{{#if (gt this.model.length 0)}}
<Oidc::ProviderList @model={{this.model}} />
{{else}}
<EmptyState
@title="No available providers"
@message="Edit an existing provider or create a new one to allow this application access for authentication requests."
>
<LinkTo @route="vault.cluster.access.oidc.providers">
View providers
</LinkTo>
</EmptyState>
{{/if}}

View File

@ -0,0 +1,5 @@
<Oidc::ClientForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.access.oidc.clients"}}
@onSave={{transition-to "vault.cluster.access.oidc.clients.client.details" this.model.name}}
/>

View File

@ -0,0 +1,9 @@
<Toolbar>
<ToolbarActions>
<ToolbarLink data-test-oidc-client-create @type="add" @params={{array "vault.cluster.access.oidc.clients.create"}}>
Create application
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<Oidc::ClientList @model={{this.model}} />

View File

@ -0,0 +1,14 @@
<div class="box is-fullwidth is-shadowless" data-test-oidc-landing>
<p>
<b>Step 1:</b>
Create an application, and obtain the client ID, client secret and issuer URL.
</p>
<p>
<b>Step 2:</b>
Set up a new auth method for Vault with the client application.
</p>
</div>
<div class="box is-fullwidth is-shadowless">
<img data-test-oidc-img src={{img-path "~/oidc-landing.png"}} alt="OIDC configure diagram" />
</div>

View File

@ -0,0 +1,5 @@
<Oidc::KeyForm
@model={{this.model}}
@onCancel={{transition-to "vault.cluster.access.oidc.keys"}}
@onSave={{transition-to "vault.cluster.access.oidc.keys.key.details" this.model.name}}
/>

View File

@ -0,0 +1,56 @@
<Toolbar>
<ToolbarActions>
<ToolbarLink data-test-oidc-key-create @type="add" @params={{array "vault.cluster.access.oidc.keys.create"}}>
Create key
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{#each this.model as |model|}}
<LinkedBlock
class="list-item-row"
@params={{array "vault.cluster.access.oidc.keys.key.details" model.name}}
data-test-oidc-key-linked-block={{model.name}}
>
<div class="level is-mobile">
<div class="level-left">
<div>
<Icon @name="key" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{model.name}}
</span>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo
@route="vault.cluster.access.oidc.keys.key.details"
@model={{model.name}}
@disabled={{eq model.canRead false}}
data-test-oidc-key-menu-link="details"
>
Details
</LinkTo>
</li>
<li>
<LinkTo
@route="vault.cluster.access.oidc.keys.key.edit"
@model={{model.name}}
@disabled={{eq model.canEdit false}}
data-test-oidc-key-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}

View File

@ -0,0 +1,36 @@
{{#if this.showHeader}}
<PageHeader as |p|>
<p.top>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<span class="sep">&#x0002f;</span>
<LinkTo @route="vault.cluster.access.oidc.keys" data-test-breadcrumb-link="oidc-keys">
Keys
</LinkTo>
</li>
</ul>
</nav>
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{this.model.name}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs" aria-label="tabs">
<ul>
<LinkTo data-test-oidc-key-details @route="vault.cluster.access.oidc.keys.key.details" @model={{this.model}}>
Details
</LinkTo>
<LinkTo data-test-oidc-key-clients @route="vault.cluster.access.oidc.keys.key.clients" @model={{this.model}}>
Applications
</LinkTo>
</ul>
</nav>
</div>
{{/if}}
{{outlet}}

Some files were not shown because too many files have changed in this diff Show More