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">
{{@attr.options.subText}}
{{#if @attr.options.docLink}}
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer">
<DocLink @path={{@attr.options.docLink}}>
See our documentation
</a>
</DocLink>
for help.
{{/if}}
</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
* const { isValid, errors } = state[propertyKeyName];
* 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
*
*** basic example
@ -30,7 +30,8 @@ import { get } from '@ember/object';
* import Model from '@ember-data/model';
* 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)
* class SomeModel extends Model { foo = null; }
*
@ -42,7 +43,7 @@ import { get } from '@ember/object';
*
*** 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)
* class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; }
*
@ -50,7 +51,11 @@ import { get } from '@ember/object';
* const { isValid, state } = model.validate();
* -> 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) {

View File

@ -54,7 +54,7 @@ export default Model.extend({
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.',
defaultShown: 'Default',
docLink: 'https://www.vaultproject.io/docs/concepts/password-policies',
docLink: '/docs/concepts/password-policies',
}),
// common fields
@ -106,7 +106,7 @@ export default Model.extend({
subText: 'Enter the custom username template to use.',
defaultSubText:
'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',
}),
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('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('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: {
methods: 'sys/auth',
mfa: 'identity/mfa/method',
oidc: 'identity/oidc/client',
entities: 'identity/entity/id',
groups: 'identity/group/id',
leases: 'sys/leases/lookup',
@ -44,6 +45,7 @@ const API_PATHS_TO_ROUTE_PARAMS = {
'sys/namespaces': { route: 'vault.cluster.access.namespaces', models: [] },
'sys/control-group/': { route: 'vault.cluster.access.control-groups', 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-right: auto;
}
&.is-disabled {
opacity: 0.5;
}
}
a.list-item-row,

View File

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

View File

@ -272,3 +272,13 @@ a.button.disabled {
border: none;
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;
}
.dropdown-has-error-border > div.ember-basic-dropdown-trigger {
border: 1px solid $red-500;
}
.autocomplete-input {
background: $white !important;
border: 1px solid $grey-light;

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@
spellcheck="false"
value={{@model.name}}
disabled={{not @model.isNew}}
class="input field"
class="input field {{if this.errors.name.errors 'has-error-border'}}"
data-test-mlef-input="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,6 +3,8 @@
class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}"
...attributes
>
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
<T.Trigger tabindex="-1">
{{#if (has-block)}}
{{yield}}
{{else}}
@ -25,11 +27,20 @@
id={{dasherize @value}}
name="config-mode"
class="radio"
disabled={{@disabled}}
@disabled={{@disabled}}
@value={{@value}}
@groupValue={{@groupValue}}
@onChange={{@onChange}}
/>
<span class="dot"></span>
</div>
</T.Trigger>
{{#if (and @disabled @disabledTooltipMessage)}}
<T.Content @defaultClass="tool-tip smaller-font">
<div class="box">
{{@disabledTooltipMessage}}
</div>
</T.Content>
{{/if}}
</ToolTip>
</label>

View File

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

View File

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

View File

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