UI: PKI generate cert from role (#18300)
This commit is contained in:
parent
c9531431a4
commit
790156a07b
21
ui/app/adapters/pki/certificate/generate.js
Normal file
21
ui/app/adapters/pki/certificate/generate.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import ApplicationAdapter from '../../application';
|
||||
|
||||
export default class PkiCertificateGenerateAdapter extends ApplicationAdapter {
|
||||
namespace = 'v1';
|
||||
|
||||
deleteRecord(store, type, snapshot) {
|
||||
const { backend, serialNumber, certificate } = snapshot.record;
|
||||
// Revoke certificate requires either serial_number or certificate
|
||||
const data = serialNumber ? { serial_number: serialNumber } : { certificate };
|
||||
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/revoke`, 'POST', { data });
|
||||
}
|
||||
|
||||
urlForCreateRecord(modelName, snapshot) {
|
||||
const { name, backend } = snapshot.record;
|
||||
if (!name || !backend) {
|
||||
throw new Error('URL for create record is missing required attributes');
|
||||
}
|
||||
return `${this.buildURL()}/${encodePath(backend)}/issue/${encodePath(name)}`;
|
||||
}
|
||||
}
|
|
@ -59,4 +59,29 @@ export default class PkiRoleAdapter extends ApplicationAdapter {
|
|||
const { id, record } = snapshot;
|
||||
return this.ajax(this._urlForRole(record.backend, id), 'DELETE');
|
||||
}
|
||||
|
||||
generateCertificate(backend, roleName, data) {
|
||||
const url = `${this.buildURL()}/${encodePath(backend)}/issue/${roleName}`;
|
||||
const options = {
|
||||
data,
|
||||
};
|
||||
return this.ajax(url, 'POST', options).then((resp) => {
|
||||
return resp.data;
|
||||
});
|
||||
}
|
||||
|
||||
signCertificate(backend, roleName, data) {
|
||||
const url = `${this.buildURL()}/${encodePath(backend)}/sign/${roleName}`;
|
||||
const options = {
|
||||
data,
|
||||
};
|
||||
return this.ajax(url, 'POST', options);
|
||||
}
|
||||
|
||||
revokeCertificate(backend, data) {
|
||||
const url = `${this.buildURL()}/${encodePath(backend)}/revoke`;
|
||||
return this.ajax(url, 'POST', {
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ export default class App extends Application {
|
|||
dependencies: {
|
||||
services: [
|
||||
'auth',
|
||||
'download',
|
||||
'flash-messages',
|
||||
'namespace',
|
||||
'path-help',
|
||||
|
|
|
@ -37,7 +37,7 @@ import { inject as service } from '@ember/service';
|
|||
|
||||
export default class Attribution extends Component {
|
||||
@tracked showCSVDownloadModal = false;
|
||||
@service downloadCsv;
|
||||
@service download;
|
||||
|
||||
get hasCsvData() {
|
||||
return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
|
||||
|
@ -185,7 +185,7 @@ export default class Attribution extends Component {
|
|||
@action
|
||||
exportChartData(filename) {
|
||||
const contents = this.generateCsvData();
|
||||
this.downloadCsv.download(filename, contents);
|
||||
this.download.csv(filename, contents);
|
||||
this.showCSVDownloadModal = false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,15 +3,10 @@ import * as asn1js from 'asn1js';
|
|||
import { fromBase64, stringToArrayBuffer } from 'pvutils';
|
||||
import { Certificate } from 'pkijs';
|
||||
|
||||
export function parsePkiCert([model]) {
|
||||
// model has to be the responseJSON from PKI serializer
|
||||
// return if no certificate or if the "certificate" is actually a CRL
|
||||
if (!model.certificate || model.certificate.includes('BEGIN X509 CRL')) {
|
||||
return;
|
||||
}
|
||||
export function parseCertificate(certificateContent) {
|
||||
let cert;
|
||||
try {
|
||||
const cert_base64 = model.certificate.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
|
||||
const cert_base64 = certificateContent.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, '');
|
||||
const cert_der = fromBase64(cert_base64);
|
||||
const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der));
|
||||
cert = new Certificate({ schema: cert_asn1.result });
|
||||
|
@ -21,7 +16,6 @@ export function parsePkiCert([model]) {
|
|||
can_parse: false,
|
||||
};
|
||||
}
|
||||
|
||||
// We wish to get the CN element out of this certificate's subject. A
|
||||
// subject is a list of RDNs, where each RDN is a (type, value) tuple
|
||||
// and where a type is an OID. The OID for CN can be found here:
|
||||
|
@ -54,7 +48,18 @@ export function parsePkiCert([model]) {
|
|||
common_name: commonName,
|
||||
expiry_date: expiryDate,
|
||||
issue_date: issueDate,
|
||||
not_valid_after: expiryDate.valueOf(),
|
||||
not_valid_before: issueDate.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePkiCert([model]) {
|
||||
// model has to be the responseJSON from PKI serializer
|
||||
// return if no certificate or if the "certificate" is actually a CRL
|
||||
if (!model.certificate || model.certificate.includes('BEGIN X509 CRL')) {
|
||||
return;
|
||||
}
|
||||
return parseCertificate(model.certificate);
|
||||
}
|
||||
|
||||
export default helper(parsePkiCert);
|
||||
|
|
49
ui/app/models/pki/certificate/base.js
Normal file
49
ui/app/models/pki/certificate/base.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
import Model, { attr } from '@ember-data/model';
|
||||
import { assert } from '@ember/debug';
|
||||
import { service } from '@ember/service';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||
|
||||
/**
|
||||
* There are many ways to generate a cert, but we want to display them in a consistent way.
|
||||
* This base certificate model will set the attributes we want to display, and other
|
||||
* models under pki/certificate will extend this model and have their own required
|
||||
* attributes and adapter methods.
|
||||
*/
|
||||
|
||||
const certDisplayFields = ['certificate', 'commonName', 'serialNumber', 'notValidAfter', 'notValidBefore'];
|
||||
|
||||
@withFormFields(certDisplayFields)
|
||||
export default class PkiCertificateBaseModel extends Model {
|
||||
@service secretMountPath;
|
||||
get useOpenAPI() {
|
||||
return true;
|
||||
}
|
||||
get backend() {
|
||||
return this.secretMountPath.currentPath;
|
||||
}
|
||||
getHelpUrl() {
|
||||
assert('You must provide a helpUrl for OpenAPI', true);
|
||||
}
|
||||
|
||||
// Required input for all certificates
|
||||
@attr('string') commonName;
|
||||
|
||||
// Attrs that come back from API POST request
|
||||
@attr() caChain;
|
||||
@attr('string') certificate;
|
||||
@attr('number') expiration;
|
||||
@attr('string') issuingCa;
|
||||
@attr('string') privateKey;
|
||||
@attr('string') privateKeyType;
|
||||
@attr('string') serialNumber;
|
||||
|
||||
// Parsed from cert in serializer
|
||||
@attr('date') notValidAfter;
|
||||
@attr('date') notValidBefore;
|
||||
|
||||
@lazyCapabilities(apiPath`${'backend'}/revoke`, 'backend') revokePath;
|
||||
get canRevoke() {
|
||||
return this.revokePath.get('isLoading') || this.revokePath.get('canCreate') !== false;
|
||||
}
|
||||
}
|
29
ui/app/models/pki/certificate/generate.js
Normal file
29
ui/app/models/pki/certificate/generate.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { attr } from '@ember-data/model';
|
||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||
import PkiCertificateBaseModel from './base';
|
||||
|
||||
const generateFromRole = [
|
||||
{
|
||||
default: ['commonName'],
|
||||
},
|
||||
{
|
||||
Options: [
|
||||
'altNames',
|
||||
'ipSans',
|
||||
'uriSans',
|
||||
'otherSans',
|
||||
'ttl',
|
||||
'format',
|
||||
'privateKeyFormat',
|
||||
'excludeCnFromSans',
|
||||
'notAfter',
|
||||
],
|
||||
},
|
||||
];
|
||||
@withFormFields(null, generateFromRole)
|
||||
export default class PkiCertificateGenerateModel extends PkiCertificateBaseModel {
|
||||
getHelpUrl(backend) {
|
||||
return `/v1/${backend}/issue/example?help=1`;
|
||||
}
|
||||
@attr('string') name; // associated role
|
||||
}
|
29
ui/app/serializers/pki/certificate/generate.js
Normal file
29
ui/app/serializers/pki/certificate/generate.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { parseCertificate } from 'vault/helpers/parse-pki-cert';
|
||||
import ApplicationSerializer from '../../application';
|
||||
|
||||
export default class PkiCertificateGenerateSerializer extends ApplicationSerializer {
|
||||
primaryKey = 'serial_number';
|
||||
|
||||
serialize() {
|
||||
const json = super.serialize(...arguments);
|
||||
// role name is part of the URL, remove from payload
|
||||
delete json.name;
|
||||
return json;
|
||||
}
|
||||
|
||||
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
if (requestType === 'createRecord' && payload.data.certificate) {
|
||||
// Parse certificate back from the API and add to payload
|
||||
const parsedCert = parseCertificate(payload.data.certificate);
|
||||
const json = super.normalizeResponse(
|
||||
store,
|
||||
primaryModelClass,
|
||||
{ ...payload, ...parsedCert },
|
||||
id,
|
||||
requestType
|
||||
);
|
||||
return json;
|
||||
}
|
||||
return super.normalizeResponse(...arguments);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import Service from '@ember/service';
|
||||
|
||||
// SAMPLE CSV FORMAT ('content' argument)
|
||||
// Must be a string with each row \n separated and each column comma separated
|
||||
// 'Namespace path,Authentication method,Total clients,Entity clients,Non-entity clients\n
|
||||
// namespacelonglonglong4/,,191,171,20\n
|
||||
// namespacelonglonglong4/,auth/method/uMGBU,35,20,15\n'
|
||||
|
||||
export default class DownloadCsvService extends Service {
|
||||
download(filename, content) {
|
||||
// even though Blob type 'text/csv' is specified below, some browsers (ex. Firefox) require the filename has an explicit extension
|
||||
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.csv` || 'vault-data.csv';
|
||||
const { document, URL } = window;
|
||||
const downloadElement = document.createElement('a');
|
||||
downloadElement.download = formattedFilename;
|
||||
downloadElement.href = URL.createObjectURL(
|
||||
new Blob([content], {
|
||||
type: 'text/csv',
|
||||
})
|
||||
);
|
||||
document.body.appendChild(downloadElement);
|
||||
downloadElement.click();
|
||||
URL.revokeObjectURL(downloadElement.href);
|
||||
downloadElement.remove();
|
||||
}
|
||||
}
|
36
ui/app/services/download.ts
Normal file
36
ui/app/services/download.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import Service from '@ember/service';
|
||||
|
||||
export default class DownloadService extends Service {
|
||||
download(filename: string, mimetype: string, content: string) {
|
||||
const { document, URL } = window;
|
||||
const downloadElement = document.createElement('a');
|
||||
downloadElement.download = filename;
|
||||
downloadElement.href = URL.createObjectURL(
|
||||
new Blob([content], {
|
||||
type: mimetype,
|
||||
})
|
||||
);
|
||||
document.body.appendChild(downloadElement);
|
||||
downloadElement.click();
|
||||
URL.revokeObjectURL(downloadElement.href);
|
||||
downloadElement.remove();
|
||||
}
|
||||
|
||||
// SAMPLE CSV FORMAT ('content' argument)
|
||||
// Must be a string with each row \n separated and each column comma separated
|
||||
// 'Namespace path,Authentication method,Total clients,Entity clients,Non-entity clients\n
|
||||
// namespacelonglonglong4/,,191,171,20\n
|
||||
// namespacelonglonglong4/,auth/method/uMGBU,35,20,15\n'
|
||||
csv(filename: string, content: string) {
|
||||
// even though Blob type 'text/csv' is specified below, some browsers (ex. Firefox) require the filename has an explicit extension
|
||||
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.csv` || 'vault-data.csv';
|
||||
this.download(formattedFilename, 'text/csv', content);
|
||||
return formattedFilename;
|
||||
}
|
||||
|
||||
pem(filename: string, content: string) {
|
||||
const formattedFilename = `${filename?.replace(/\s+/g, '-')}.pem` || 'vault-cert.pem';
|
||||
this.download(formattedFilename, 'application/x-pem-file', content);
|
||||
return formattedFilename;
|
||||
}
|
||||
}
|
|
@ -3,12 +3,12 @@ import Service from '@ember/service';
|
|||
// this service tracks the path of the currently viewed secret mount
|
||||
// so that we can access that inside of engines where parent route params
|
||||
// are not accessible
|
||||
export default Service.extend({
|
||||
currentPath: null,
|
||||
export default class SecretMountPath extends Service {
|
||||
currentPath = '';
|
||||
update(path) {
|
||||
this.set('currentPath', path);
|
||||
},
|
||||
this.currentPath = path;
|
||||
}
|
||||
get() {
|
||||
return this.currentPath;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{{#each @model.fieldGroups as |fieldGroup|}}
|
||||
{{#each (get @model this.fieldGroups) as |fieldGroup|}}
|
||||
{{#each-in fieldGroup as |group fields|}}
|
||||
{{#if (eq group "default")}}
|
||||
{{#each fields as |attr|}}
|
||||
|
|
|
@ -12,6 +12,10 @@ import Component from '@glimmer/component';
|
|||
* @param {string} mode - "create" or "update" used to hide the name form field. TODO: not ideal, would prefer to disable it to follow new design patterns.
|
||||
* @param {function} [modelValidations] - Passed through to formField.
|
||||
* @param {boolean} [showHelpText] - Passed through to formField.
|
||||
* @param {string} [groupName="fieldGroups"] - option to override key on the model where groups are located
|
||||
*/
|
||||
/* eslint ember/no-empty-glimmer-component-classes: 'warn' */
|
||||
export default class FormFieldGroupsLoop extends Component {}
|
||||
export default class FormFieldGroupsLoop extends Component {
|
||||
get fieldGroups() {
|
||||
return this.args.groupName || 'fieldGroups';
|
||||
}
|
||||
}
|
||||
|
|
77
ui/lib/pki/addon/components/pki-role-generate.hbs
Normal file
77
ui/lib/pki/addon/components/pki-role-generate.hbs
Normal file
|
@ -0,0 +1,77 @@
|
|||
{{#if @model.serialNumber}}
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
<button type="button" class="toolbar-link" {{on "click" this.downloadCert}} data-test-pki-cert-download-button>
|
||||
Download
|
||||
<Chevron @direction="down" @isButton={{true}} />
|
||||
</button>
|
||||
{{#if @model.canRevoke}}
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
{{on "click" (perform this.revoke)}}
|
||||
disabled={{this.revoke.isRunning}}
|
||||
data-test-pki-cert-revoke-button
|
||||
>
|
||||
Revoke certificate
|
||||
<Chevron @direction="right" @isButton={{true}} />
|
||||
</button>
|
||||
{{/if}}
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{#each @model.formFields as |attr|}}
|
||||
{{#if (eq attr.name "certificate")}}
|
||||
<InfoTableRow @label="Certificate" @value={{@model.certificate}}>
|
||||
<MaskedInput @value={{@model.certificate}} @name="Certificate" @displayOnly={{true}} @allowCopy={{true}} />
|
||||
</InfoTableRow>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{or attr.options.label (humanize (dasherize attr.name))}}
|
||||
@value={{get @model attr.name}}
|
||||
@formatDate={{if (eq attr.type "date") "MMM d yyyy HH:mm:ss a zzzz"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
data-test-pki-generate-back
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<form {{on "submit" (perform this.save)}} data-test-pki-generate-cert-form>
|
||||
<div class="box is-bottomless is-fullwidth is-marginless">
|
||||
<MessageError @errorMessage={{this.errorBanner}} />
|
||||
<NamespaceReminder @mode={{if @model.isNew "create" "edit"}} @noun="policy" />
|
||||
<FormFieldGroupsLoop @model={{@model}} @mode="create" @groupName="formFieldGroups" />
|
||||
</div>
|
||||
<div class="field is-grouped 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-pki-generate-button
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
data-test-pki-generate-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
87
ui/lib/pki/addon/components/pki-role-generate.ts
Normal file
87
ui/lib/pki/addon/components/pki-role-generate.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { service } from '@ember/service';
|
||||
import Router from '@ember/routing/router';
|
||||
import Store from '@ember-data/store';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import FlashMessageService from 'vault/services/flash-messages';
|
||||
import DownloadService from 'vault/services/download';
|
||||
|
||||
interface Args {
|
||||
onSuccess: CallableFunction;
|
||||
model: PkiCertificateGenerateModel;
|
||||
}
|
||||
|
||||
interface PkiCertificateGenerateModel {
|
||||
name: string;
|
||||
backend: string;
|
||||
serialNumber: string;
|
||||
certificate: string;
|
||||
formFields: FormField[];
|
||||
formFieldsGroup: {
|
||||
[k: string]: FormField[];
|
||||
}[];
|
||||
save: () => void;
|
||||
rollbackAttributes: () => void;
|
||||
unloadRecord: () => void;
|
||||
destroyRecord: () => void;
|
||||
canRevoke: boolean;
|
||||
}
|
||||
interface FormField {
|
||||
name: string;
|
||||
type: string;
|
||||
options: unknown;
|
||||
}
|
||||
export default class PkiRoleGenerate extends Component<Args> {
|
||||
@service declare readonly router: Router;
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly download: DownloadService;
|
||||
|
||||
@tracked errorBanner = '';
|
||||
|
||||
transitionToRole() {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.details');
|
||||
}
|
||||
|
||||
@task
|
||||
*save(evt: Event) {
|
||||
evt.preventDefault();
|
||||
this.errorBanner = '';
|
||||
const { model, onSuccess } = this.args;
|
||||
try {
|
||||
yield model.save();
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
this.errorBanner = errorMessage(err, 'Could not generate certificate. See Vault logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
*revoke() {
|
||||
try {
|
||||
yield this.args.model.destroyRecord();
|
||||
this.flashMessages.success('The certificate has been revoked.');
|
||||
this.transitionToRole();
|
||||
} catch (err) {
|
||||
this.errorBanner = errorMessage(err, 'Could not revoke certificate. See Vault logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
@action downloadCert() {
|
||||
try {
|
||||
const formattedSerial = this.args.model.serialNumber?.replace(/(\s|:)+/g, '-');
|
||||
this.download.pem(formattedSerial, this.args.model.certificate);
|
||||
this.flashMessages.info('Your download has started.');
|
||||
} catch (err) {
|
||||
this.flashMessages.danger(errorMessage(err, 'Unable to prepare certificate for download.'));
|
||||
}
|
||||
}
|
||||
|
||||
@action cancel() {
|
||||
this.args.model.unloadRecord();
|
||||
this.transitionToRole();
|
||||
}
|
||||
}
|
12
ui/lib/pki/addon/controllers/roles/role/generate.js
Normal file
12
ui/lib/pki/addon/controllers/roles/role/generate.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class PkiRolesGenerateController extends Controller {
|
||||
@tracked hasSubmitted = false;
|
||||
|
||||
@action
|
||||
toggleTitle() {
|
||||
this.hasSubmitted = !this.hasSubmitted;
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ export default class PkiEngine extends Engine {
|
|||
dependencies = {
|
||||
services: [
|
||||
'auth',
|
||||
'download',
|
||||
'flash-messages',
|
||||
'namespace',
|
||||
'path-help',
|
||||
|
|
22
ui/lib/pki/addon/routes/error.js
Normal file
22
ui/lib/pki/addon/routes/error.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class PkiRolesErrorRoute extends Route {
|
||||
@service secretMountPath;
|
||||
|
||||
setupController(controller) {
|
||||
super.setupController(...arguments);
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.secretMountPath.currentPath || 'pki', route: 'overview' },
|
||||
];
|
||||
controller.tabs = [
|
||||
{ label: 'Overview', route: 'overview' },
|
||||
{ label: 'Roles', route: 'roles.index' },
|
||||
{ label: 'Issuers', route: 'issuers.index' },
|
||||
{ label: 'Certificates', route: 'certificates.index' },
|
||||
{ label: 'Keys', route: 'keys.index' },
|
||||
];
|
||||
controller.title = this.secretMountPath.currentPath;
|
||||
}
|
||||
}
|
|
@ -1,3 +1,35 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
export default class PkiRoleGenerateRoute extends Route {
|
||||
@service store;
|
||||
@service secretMountPath;
|
||||
@service pathHelp;
|
||||
|
||||
export default class PkiRoleGenerateRoute extends Route {}
|
||||
beforeModel() {
|
||||
// Must call this promise before the model hook otherwise
|
||||
// the model doesn't hydrate from OpenAPI correctly.
|
||||
return this.pathHelp.getNewModel('pki/certificate/generate', this.secretMountPath.currentPath);
|
||||
}
|
||||
|
||||
async model() {
|
||||
const { role } = this.paramsFor('roles/role');
|
||||
return this.store.createRecord('pki/certificate/generate', {
|
||||
name: role,
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const { role } = this.paramsFor('roles/role');
|
||||
const backend = this.secretMountPath.currentPath || 'pki';
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: backend, route: 'overview' },
|
||||
{ label: 'roles', route: 'roles.index' },
|
||||
{ label: role, route: 'roles.role.details' },
|
||||
{ label: 'generate certificate' },
|
||||
];
|
||||
// This is updated on successful generate in the controller
|
||||
controller.hasSubmitted = false;
|
||||
}
|
||||
}
|
||||
|
|
48
ui/lib/pki/addon/templates/error.hbs
Normal file
48
ui/lib/pki/addon/templates/error.hbs
Normal file
|
@ -0,0 +1,48 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
<Icon @name="file-text" @size="24" class="has-text-grey-light" />
|
||||
{{this.title}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
|
||||
<nav class="tabs" aria-label="secret tabs">
|
||||
<ul>
|
||||
{{#each this.tabs as |oTab|}}
|
||||
<LinkTo @route={{oTab.route}} data-test-tab={{oTab.route}}>
|
||||
{{oTab.label}}
|
||||
</LinkTo>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="box is-sideless has-background-white-bis has-text-grey has-text-centered has-tall-padding" data-test-pki-error>
|
||||
{{#if (eq this.model.httpStatus 404)}}
|
||||
<h1 class="title is-3 has-text-grey">
|
||||
404 Not Found
|
||||
</h1>
|
||||
<p>Sorry, we were unable to find any content at <code>{{or this.model.path this.path}}</code>.</p>
|
||||
{{else if (eq this.model.httpStatus 403)}}
|
||||
<h1 class="title is-3 has-text-grey">
|
||||
Not authorized
|
||||
</h1>
|
||||
<p>You are not authorized to access content at <code>{{or this.model.path this.path}}</code>.</p>
|
||||
{{else}}
|
||||
<h1 class="title is-3 has-text-grey">
|
||||
Error
|
||||
</h1>
|
||||
<p>
|
||||
{{#if this.model.message}}
|
||||
<p>{{this.model.message}}</p>
|
||||
{{/if}}
|
||||
{{#each this.model.errors as |error|}}
|
||||
<p>{{error}}</p>
|
||||
{{/each}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -1 +1,13 @@
|
|||
route: roles.role.generate
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-pki-role-page-title>
|
||||
<Icon @name="certificate" @size="24" class="has-text-grey-light" />
|
||||
{{if this.hasSubmitted "View generated certificate" "Generate certificate"}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<PkiRoleGenerate @onSuccess={{this.toggleTitle}} @model={{this.model}} />
|
|
@ -101,6 +101,12 @@ module('Acceptance | pki workflow', function (hooks) {
|
|||
path "${this.mountPath}/roles/*" {
|
||||
capabilities = ["read", "update"]
|
||||
},
|
||||
path "${this.mountPath}/issue/*" {
|
||||
capabilities = ["update"]
|
||||
},
|
||||
path "${this.mountPath}/sign/*" {
|
||||
capabilities = ["update"]
|
||||
},
|
||||
`;
|
||||
this.pkiRoleReader = await tokenWithPolicy('pki-reader', pki_reader_policy);
|
||||
this.pkiRoleEditor = await tokenWithPolicy('pki-editor', pki_editor_policy);
|
||||
|
@ -173,11 +179,33 @@ module('Acceptance | pki workflow', function (hooks) {
|
|||
await click('.linked-block');
|
||||
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles/some-role/details`);
|
||||
assert.dom(SELECTORS.deleteRoleButton).doesNotExist('Delete role button is not shown');
|
||||
assert.dom(SELECTORS.generateCertLink).doesNotExist('Generate cert link is not shown');
|
||||
assert.dom(SELECTORS.signCertLink).doesNotExist('Sign cert link is not shown');
|
||||
assert.dom(SELECTORS.generateCertLink).exists('Generate cert link is shown');
|
||||
assert.dom(SELECTORS.signCertLink).exists('Sign cert link is shown');
|
||||
assert.dom(SELECTORS.editRoleLink).exists('Edit link is shown');
|
||||
await click(SELECTORS.editRoleLink);
|
||||
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles/some-role/edit`);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/roles/some-role/edit`,
|
||||
'Links to edit view'
|
||||
);
|
||||
await click(SELECTORS.roleForm.roleCancelButton);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/roles/some-role/details`,
|
||||
'Cancel from edit goes to details'
|
||||
);
|
||||
await click(SELECTORS.generateCertLink);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/roles/some-role/generate`,
|
||||
'Generate cert button goes to generate page'
|
||||
);
|
||||
await click(SELECTORS.generateCertForm.cancelButton);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.mountPath}/pki/roles/some-role/details`,
|
||||
'Cancel from generate goes to details'
|
||||
);
|
||||
});
|
||||
|
||||
test('create role happy path', async function (assert) {
|
||||
|
|
11
ui/tests/helpers/pki/pki-role-generate.js
Normal file
11
ui/tests/helpers/pki/pki-role-generate.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const SELECTORS = {
|
||||
form: '[data-test-pki-generate-cert-form]',
|
||||
commonNameField: '[data-test-input="commonName"]',
|
||||
optionsToggle: '[data-test-toggle-group="Options"]',
|
||||
generateButton: '[data-test-pki-generate-button]',
|
||||
cancelButton: '[data-test-pki-generate-cancel]',
|
||||
downloadButton: '[data-test-pki-cert-download-button]',
|
||||
revokeButton: '[data-test-pki-cert-revoke-button]',
|
||||
serialNumber: '[data-test-value-div="Serial number"]',
|
||||
certificate: '[data-test-value-div="Certificate"]',
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
import { SELECTORS as ROLEFORM } from './roles/form';
|
||||
import { SELECTORS as GENERATECERT } from './pki-role-generate';
|
||||
export const SELECTORS = {
|
||||
breadcrumbContainer: '[data-test-breadcrumbs]',
|
||||
breadcrumbs: '[data-test-breadcrumbs] li',
|
||||
|
@ -19,4 +20,7 @@ export const SELECTORS = {
|
|||
roleForm: {
|
||||
...ROLEFORM,
|
||||
},
|
||||
generateCertForm: {
|
||||
...GENERATECERT,
|
||||
},
|
||||
};
|
||||
|
|
37
ui/tests/helpers/stubs.js
Normal file
37
ui/tests/helpers/stubs.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
export function capabilitiesStub(requestPath, capabilitiesArray) {
|
||||
// sample of capabilitiesArray: ['read', 'update']
|
||||
return {
|
||||
request_id: '40f7e44d-af5c-9b60-bd20-df72eb17e294',
|
||||
lease_id: '',
|
||||
renewable: false,
|
||||
lease_duration: 0,
|
||||
data: {
|
||||
capabilities: capabilitiesArray,
|
||||
[requestPath]: capabilitiesArray,
|
||||
},
|
||||
wrap_info: null,
|
||||
warnings: null,
|
||||
auth: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* allowAllCapabilitiesStub mocks the response from capabilities-self
|
||||
* that allows the user to do any action (root user)
|
||||
* EXAMPLE USAGE:
|
||||
* this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub);
|
||||
*/
|
||||
export function allowAllCapabilitiesStub() {
|
||||
return {
|
||||
request_id: '40f7e44d-af5c-9b60-bd20-df72eb17e294',
|
||||
lease_id: '',
|
||||
renewable: false,
|
||||
lease_duration: 0,
|
||||
data: {
|
||||
capabilities: ['root'],
|
||||
},
|
||||
wrap_info: null,
|
||||
warnings: null,
|
||||
auth: null,
|
||||
};
|
||||
}
|
72
ui/tests/integration/components/pki-role-generate-test.js
Normal file
72
ui/tests/integration/components/pki-role-generate-test.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, fillIn } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { setupEngine } from 'ember-engines/test-support';
|
||||
import Sinon from 'sinon';
|
||||
import { SELECTORS } from 'vault/tests/helpers/pki/pki-role-generate';
|
||||
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
|
||||
|
||||
module('Integration | Component | pki-role-generate', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupEngine(hooks, 'pki');
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
|
||||
this.secretMountPath.currentPath = 'pki-test';
|
||||
this.model = this.store.createRecord('pki/certificate/generate', {
|
||||
role: 'my-role',
|
||||
});
|
||||
this.onSuccess = Sinon.spy();
|
||||
});
|
||||
|
||||
test('it should render the component with the form by default', async function (assert) {
|
||||
assert.expect(4);
|
||||
await render(
|
||||
hbs`
|
||||
<div class="has-top-margin-xxl">
|
||||
<PkiRoleGenerate
|
||||
@model={{this.model}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(SELECTORS.form).exists('shows the cert generate form');
|
||||
assert.dom(SELECTORS.commonNameField).exists('shows the common name field');
|
||||
assert.dom(SELECTORS.optionsToggle).exists('toggle exists');
|
||||
await fillIn(SELECTORS.commonNameField, 'example.com');
|
||||
assert.strictEqual(this.model.commonName, 'example.com', 'Filling in the form updates the model');
|
||||
});
|
||||
|
||||
test('it should render the component displaying the cert', async function (assert) {
|
||||
assert.expect(5);
|
||||
const record = this.store.createRecord('pki/certificate/generate', {
|
||||
role: 'my-role',
|
||||
serialNumber: 'abcd-efgh-ijkl',
|
||||
certificate: 'my-very-cool-certificate',
|
||||
});
|
||||
this.set('model', record);
|
||||
await render(
|
||||
hbs`
|
||||
<div class="has-top-margin-xxl">
|
||||
<PkiRoleGenerate
|
||||
@model={{this.model}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(SELECTORS.form).doesNotExist('Does not show the form');
|
||||
assert.dom(SELECTORS.downloadButton).exists('shows the download button');
|
||||
assert.dom(SELECTORS.revokeButton).exists('shows the revoke button');
|
||||
assert.dom(SELECTORS.certificate).exists({ count: 1 }, 'shows certificate info row');
|
||||
assert.dom(SELECTORS.serialNumber).hasText('abcd-efgh-ijkl', 'shows serial number info row');
|
||||
});
|
||||
});
|
54
ui/tests/unit/adapters/pki/certificate/generate-test.js
Normal file
54
ui/tests/unit/adapters/pki/certificate/generate-test.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'vault/tests/helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
module('Unit | Adapter | pki/certificate/generate', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
|
||||
this.backend = 'pki-test';
|
||||
this.secretMountPath.currentPath = this.backend;
|
||||
this.data = {
|
||||
serial_number: 'my-serial-number',
|
||||
certificate: 'some-cert',
|
||||
};
|
||||
});
|
||||
|
||||
test('it should make request to correct endpoint on create', async function (assert) {
|
||||
assert.expect(1);
|
||||
const generateData = {
|
||||
name: 'my-role',
|
||||
common_name: 'example.com',
|
||||
};
|
||||
this.server.post(`${this.backend}/issue/${generateData.name}`, () => {
|
||||
assert.ok(true, 'request made to correct endpoint on create');
|
||||
return {
|
||||
data: {
|
||||
serial_number: 'this-serial-number',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const model = await this.store.createRecord('pki/certificate/generate', generateData);
|
||||
await model.save();
|
||||
});
|
||||
|
||||
test('it should make request to correct endpoint on delete', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.store.pushPayload('pki/certificate/generate', {
|
||||
modelName: 'pki/certificate/generate',
|
||||
...this.data,
|
||||
});
|
||||
this.server.post(`${this.backend}/revoke`, (schema, req) => {
|
||||
assert.deepEqual(JSON.parse(req.requestBody), { serial_number: 'my-serial-number' });
|
||||
assert.ok(true, 'request made to correct endpoint on delete');
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
const model = await this.store.peekRecord('pki/certificate/generate', this.data.serial_number);
|
||||
await model.destroyRecord();
|
||||
});
|
||||
});
|
9
ui/types/ember-data/types/registries/adapter.d.ts
vendored
Normal file
9
ui/types/ember-data/types/registries/adapter.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Adapter from 'ember-data/adapter';
|
||||
import ModelRegistry from 'ember-data/types/registries/model';
|
||||
|
||||
/**
|
||||
* Catch-all for ember-data.
|
||||
*/
|
||||
export default interface AdapterRegistry {
|
||||
[key: keyof ModelRegistry]: Adapter;
|
||||
}
|
9
ui/types/vault/adapters/pki/role.d.ts
vendored
Normal file
9
ui/types/vault/adapters/pki/role.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Store from '@ember-data/store';
|
||||
import { AdapterRegistry } from 'ember-data/adapter';
|
||||
|
||||
export default interface PkiRoleAdapter extends AdapterRegistry {
|
||||
namespace: string;
|
||||
_urlForRole(backend: string, id: string): string;
|
||||
_optionsForQuery(id: string): { data: unknown };
|
||||
generateCertificate(backend: string, roleName: string, data: unknown): unknown;
|
||||
}
|
Loading…
Reference in a new issue