[UI] Ember Data Migration - Auth Method List/Config (#31203)

* updates auth method list and config views to use api service

* adds capabilities checks to auth methods route

* fixes auth method config tests

* updates SecretsEngine type to Mount

* updates listingVisibility value in config test

* adds missing copyright header
This commit is contained in:
Jordan Reimer 2025-07-08 11:50:38 -06:00 committed by GitHub
parent 2f4b1c493e
commit 75e1108750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 347 additions and 162 deletions

View File

@ -8,6 +8,7 @@ import { service } from '@ember/service';
import { sanitizePath } from 'core/utils/sanitize-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { tracked } from '@glimmer/tracking';
import { getOwner } from '@ember/owner';
export default class GeneratedItemListAdapter extends ApplicationAdapter {
@service store;
@ -28,8 +29,10 @@ export default class GeneratedItemListAdapter extends ApplicationAdapter {
return this.paths.deletePath || '';
}
getDynamicApiPath(id) {
const result = this.store.peekRecord('auth-method', id);
getDynamicApiPath() {
const result = getOwner(this)
.lookup('route:vault.cluster.access.method')
.modelFor('vault.cluster.access.method');
this.apiPath = result.apiPath;
return result.apiPath;
}

View File

@ -0,0 +1,20 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#if @method.directLoginLink}}
<InfoTableRow @alwaysRender={{true}} @label="UI login link">
<Hds::Copy::Snippet @textToCopy={{@method.directLoginLink}} />
</InfoTableRow>
{{/if}}
{{#each this.displayFields as |field|}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get @method field))}}
@label={{this.label field}}
@value={{this.value field}}
@formatTtl={{this.isTtl field}}
/>
{{/each}}
</div>

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { toLabel } from 'core/helpers/to-label';
import { get } from '@ember/object';
import type AuthMethodResource from 'vault/resources/auth/method';
interface Args {
method: AuthMethodResource;
}
export default class AuthMethodConfigurationComponent extends Component<Args> {
displayFields = [
'type',
'path',
'description',
'accessor',
'local',
'sealWrap',
'config.listingVisibility',
'config.defaultLeaseTtl',
'config.maxLeaseTtl',
'config.tokenType',
'config.auditNonHmacRequestKeys',
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.pluginVersion',
];
label = (field: string) => {
const key = field.replace('config.', '');
const label = toLabel([key]);
// map specific fields to custom labels
return (
{
listingVisibility: 'Use as preferred UI login method',
defaultLeaseTtl: 'Default Lease TTL',
maxLeaseTtl: 'Max Lease TTL',
auditNonHmacRequestKeys: 'Request keys excluded from HMACing in audit',
auditNonHmacResponseKeys: 'Response keys excluded from HMACing in audit',
passthroughRequestHeaders: 'Allowed passthrough request headers',
}[key] || label
);
};
value = (field: string) => {
const { method } = this.args;
if (field === 'config.listingVisibility') {
return method.config.listingVisibility === 'unauth';
}
return get(method, field);
};
isTtl = (field: string) => {
return ['config.defaultLeaseTtl', 'config.maxLeaseTtl'].includes(field);
};
}

View File

@ -26,23 +26,24 @@ export default class VaultClusterAccessMethodsController extends Controller {
// list returned by getter is sorted in template
get authMethodList() {
const { methods } = this.model;
// return an options list to filter by engine type, ex: 'kv'
if (this.selectedAuthType) {
// check first if the user has also filtered by name.
// names are individualized across type so you can't have the same name for an aws auth method and userpass.
// this means it's fine to filter by first type and then name or just name.
if (this.selectedAuthName) {
return this.model.filter((method) => this.selectedAuthName === method.id);
return methods.filter((method) => this.selectedAuthName === method.id);
}
// otherwise filter by auth type
return this.model.filter((method) => this.selectedAuthType === method.type);
return methods.filter((method) => this.selectedAuthType === method.type);
}
// return an options list to filter by auth name, ex: 'my-userpass'
if (this.selectedAuthName) {
return this.model.filter((method) => this.selectedAuthName === method.id);
return methods.filter((method) => this.selectedAuthName === method.id);
}
// no filters, return full list
return this.model;
return methods;
}
get authMethodArrayByType() {

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { baseResourceFactory } from 'vault/resources/base-factory';
import { service } from '@ember/service';
import { supportedTypes } from 'vault/utils/supported-login-methods';
import engineDisplayData from 'vault/helpers/engines-display-data';
import type { Mount } from 'vault/mount';
import type VersionService from 'vault/services/version';
import type NamespaceService from 'vault/services/namespace';
import type { PathInfo } from 'vault/utils/openapi-helpers';
export default class AuthMethodResource extends baseResourceFactory<Mount>() {
@service declare readonly version: VersionService;
@service declare readonly namespace: NamespaceService;
id: string;
declare paths: PathInfo;
constructor(data: Mount, context: unknown) {
super(data, context);
// strip trailing slash from path for id since it is used in routing
this.id = data.path.replace(/\/$/, '');
}
// namespaces introduced types with a `ns_` prefix for built-in engines
// so we need to strip that to normalize the type
get methodType() {
return this.type.replace(/^ns_/, '');
}
get icon() {
// methodType refers to the backend type (e.g., "aws", "azure")
const engineData = engineDisplayData(this.methodType);
return engineData?.glyph || 'users';
}
get directLoginLink() {
const ns = this.namespace.path;
const nsQueryParam = ns ? `namespace=${encodeURIComponent(ns)}&` : '';
const isSupported = supportedTypes(this.version.isEnterprise).includes(this.methodType);
return isSupported
? `${window.origin}/ui/vault/auth?${nsQueryParam}with=${encodeURIComponent(this.path)}`
: '';
}
// used when the `auth` prefix is important,
// currently only when setting perf mount filtering
get apiPath() {
return `auth/${this.path}`;
}
get localDisplay() {
return this.local ? 'local' : 'replicated';
}
get supportsUserLockoutConfig() {
return ['approle', 'ldap', 'userpass'].includes(this.methodType);
}
}

View File

@ -3,18 +3,27 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { getOwner, setOwner } from '@ember/owner';
import type Owner from '@ember/owner';
abstract class BaseResource<T> {
// pass data that the resource should represent (typically from an API response) to constructor
// object properties will be assigned to class instance
// extending classes can define getters and additional properties/methods that are required widely across the app
constructor(readonly data: T) {
constructor(data: T, context?: unknown) {
Object.assign(this, data) as T;
// pass in context (this) of Ember class (route, component etc.) where the resource is being constructed
// this will be used to set the owner on the class so that services can be injected (if required)
if (context) {
setOwner(this, getOwner(context) as Owner);
}
}
}
// factory that allows for the BaseResource class to be casted to the specific type provided
// factory that allows for the BaseResource class to be cast to the specific type provided
// without this the compiler is not aware of the properties set on the class via Object.assign
// example usage -> export default class SecretsEngineResource extends baseResourceFactory<SecretsEngine>() { ... }
// example usage -> export default class SecretsEngineResource extends baseResourceFactory<Mount>() { ... }
export function baseResourceFactory<T>() {
return BaseResource as new (data: T) => T;
return BaseResource as new (data: T, context?: unknown) => T;
}

View File

@ -8,14 +8,14 @@ import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends
import { isAddonEngine } from 'vault/utils/all-engines-metadata';
import engineDisplayData from 'vault/helpers/engines-display-data';
import type { SecretsEngine } from 'vault/secrets/engine';
import type { Mount } from 'vault/mount';
export default class SecretsEngineResource extends baseResourceFactory<SecretsEngine>() {
export default class SecretsEngineResource extends baseResourceFactory<Mount>() {
id: string;
#LIST_EXCLUDED_BACKENDS = ['system', 'identity'];
constructor(data: SecretsEngine) {
constructor(data: Mount) {
super(data);
// strip trailing slash from path for id since it is used in routing
this.id = data.path.replace(/\/$/, '');

View File

@ -1,40 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import AdapterError from '@ember-data/adapter/error';
import { set } from '@ember/object';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { supportedManagedAuthBackends } from 'vault/helpers/supported-managed-auth-backends';
export default Route.extend({
store: service(),
pathHelp: service('path-help'),
model(params) {
const { path } = params;
return this.store.query('auth-method', {}).then((modelArray) => {
const model = modelArray.find((m) => m.id === path);
if (!model) {
const error = new AdapterError();
set(error, 'httpStatus', 404);
throw error;
}
const supportManaged = supportedManagedAuthBackends();
if (!supportManaged.includes(model.methodType)) {
// do not fetch path-help for unmanaged auth types
model.set('paths', {
apiPath: model.apiPath,
paths: [],
});
return model;
}
return this.pathHelp.getPaths(model.apiPath, path).then((paths) => {
model.set('paths', paths);
return model;
});
});
},
});

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { supportedManagedAuthBackends } from 'vault/helpers/supported-managed-auth-backends';
import AuthMethodResource from 'vault/resources/auth/method';
import type ApiService from 'vault/services/api';
import type PathHelpService from 'vault/services/path-help';
export default class VaultClusterAccessMethodRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly pathHelp: PathHelpService;
async model(params: { path: string }) {
const { path } = params;
const { auth } = await this.api.sys.internalUiListEnabledVisibleMounts();
const methods = this.api
.responseObjectToArray(auth, 'path')
.map((method) => new AuthMethodResource(method, this));
const method = methods.find((m) => m.id === path);
// the user could have entered a random path in the URL that doesn't correspond to an existing method
if (method) {
const supportManaged = supportedManagedAuthBackends();
// do not fetch path-help for unmanaged auth types
if (!supportManaged.includes(method.methodType)) {
method.paths = { apiPath: method.apiPath, paths: [], itemTypes: [] };
return method;
}
return this.pathHelp.getPaths(method.apiPath, path, '', '').then((pathInfo) => {
method.paths = pathInfo;
return method;
});
} else {
// throw a 404 if the path doesn't match any of the fetched methods
throw { httpStatus: 404, path };
}
}
}

View File

@ -18,14 +18,13 @@ export default Route.extend({
return this.modelFor('vault.cluster.access.method');
},
setupController(controller) {
setupController(controller, model) {
const { section_name: section } = this.paramsFor(this.routeName);
this._super(...arguments);
controller.set('section', section);
const method = this.modelFor('vault.cluster.access.method');
controller.set(
'paths',
method.paths.paths.filter((path) => path.navigation)
model.paths.paths.filter((path) => path.navigation)
);
},
});

View File

@ -1,24 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class VaultClusterAccessMethodsRoute extends Route {
@service store;
queryParams = {
page: {
refreshModel: true,
},
pageFilter: {
refreshModel: true,
},
};
model() {
return this.store.query('auth-method', {});
}
}

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import AuthMethodResource from 'vault/resources/auth/method';
import type ApiService from 'vault/services/api';
import type Capabilities from 'vault/services/capabilities';
export default class VaultClusterAccessMethodsRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly capabilities: Capabilities;
queryParams = {
page: {
refreshModel: true,
},
pageFilter: {
refreshModel: true,
},
};
async model() {
const { auth } = await this.api.sys.internalUiListEnabledVisibleMounts();
const methods = this.api
.responseObjectToArray(auth, 'path')
.map((method) => new AuthMethodResource(method, this));
const paths = methods.reduce((paths: string[], { path, methodType }) => {
paths.push(
this.capabilities.pathFor('authMethodConfig', { path }),
this.capabilities.pathFor('authMethodDelete', { path })
);
if (methodType === 'aws') {
paths.push(this.capabilities.pathFor('authMethodConfigAws', { path }));
}
return paths;
}, []);
const capabilities = this.capabilities.fetch(paths);
return { methods, capabilities };
}
}

View File

@ -1,29 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#if @model.directLoginLink}}
<InfoTableRow @alwaysRender={{true}} @label="UI login link">
<Hds::Copy::Snippet @textToCopy={{@model.directLoginLink}} />
</InfoTableRow>
{{/if}}
{{#each @model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get @model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{stringify (get @model attr.name)}}
@formatTtl={{eq attr.options.editType "ttl"}}
/>
{{else}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get @model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get @model attr.name}}
@formatTtl={{eq attr.options.editType "ttl"}}
/>
{{/if}}
{{/each}}
</div>

View File

@ -36,4 +36,4 @@
</ToolbarActions>
</Toolbar>
{{/if}}
{{component (concat "auth-method/" this.section) model=this.model}}
{{component (concat "auth-method/" this.section) method=this.model}}

View File

@ -83,16 +83,28 @@
<dd.Interactive @route="vault.cluster.access.method.section" @models={{array method.id "configuration"}}>
View configuration
</dd.Interactive>
{{#if (or method.canEdit (and (eq method.methodType "aws") method.canEditAws))}}
{{#if
(or
(has-capability this.model.capabilities "update" pathKey="authMethodConfig" params=method.id)
(and
(eq method.methodType "aws")
(has-capability this.model.capabilities "update" pathKey="authMethodConfigAws" params=method.id)
)
)
}}
<dd.Interactive @route="vault.cluster.settings.auth.configure" @model={{method.id}}>
Edit configuration
</dd.Interactive>
{{/if}}
{{#if (and (not-eq method.methodType "token") method.canDisable)}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.methodToDisable) method)}}
>Disable</dd.Interactive>
{{#if
(and
(not-eq method.methodType "token")
(has-capability this.model.capabilities "delete" pathKey="authMethodDelete" params=method.id)
)
}}
<dd.Interactive @color="critical" {{on "click" (fn (mut this.methodToDisable) method)}}>
Disable
</dd.Interactive>
{{/if}}
</Hds::Dropdown>
</div>

View File

@ -23,4 +23,7 @@ export const PATH_MAP = {
syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`,
syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`,
kvConfig: apiPath`${'path'}/config`,
authMethodConfig: apiPath`auth/${'path'}/config`,
authMethodConfigAws: apiPath`auth/${'path'}/config/client`,
authMethodDelete: apiPath`sys/auth/${'path'}`,
};

View File

@ -16,12 +16,13 @@ interface Path {
navigation: boolean;
param: string | false;
}
interface PathsInfo {
export type PathInfo = {
apiPath: string;
itemType: string;
itemTypes: string[];
paths: Path[];
}
itemTypes: string[];
itemType?: string;
itemID?: string;
};
interface OpenApiParameter {
description?: string;
@ -53,7 +54,7 @@ interface OpenApiPath {
}
// Take object entries from the OpenAPI response and consolidate them into an object which includes itemTypes, operations, and paths
export function reducePathsByPathName(pathsInfo: PathsInfo, currentPath: [string, OpenApiPath]): PathsInfo {
export function reducePathsByPathName(pathsInfo: PathInfo, currentPath: [string, OpenApiPath]): PathInfo {
const pathName = currentPath[0];
const pathDetails = currentPath[1];
const displayAttrs = pathDetails['x-vault-displayAttrs'];
@ -123,7 +124,7 @@ export function pathToHelpUrlSegment(path: string): string {
return path.replaceAll(apiPathRegex, 'example');
}
export function filterPathsByItemType(pathInfo: PathsInfo, itemType: string): Path[] {
export function filterPathsByItemType(pathInfo: PathInfo, itemType: string): Path[] {
if (!itemType) {
return pathInfo.paths;
}

View File

@ -8,34 +8,34 @@ import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import AuthMethodResource from 'vault/resources/auth/method';
module('Integration | Component | auth-method/configuration', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.createModel = (path, type) => {
this.model = this.store.createRecord('auth-method', { path, type });
this.model.set('config', this.store.createRecord('mount-config'));
this.createMethod = (path, type) => {
this.method = new AuthMethodResource({ path, type, config: { listingVisibility: 'hidden' } }, this);
};
this.renderComponent = async () => await render(hbs`<AuthMethod::Configuration @model={{this.model}} />`);
this.renderComponent = () => render(hbs`<AuthMethod::Configuration @method={{this.method}} />`);
});
test('it renders direct link for supported method', async function (assert) {
this.createModel('token/', 'token');
this.createMethod('token/', 'token');
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('UI login link')).hasText(`${window.origin}/ui/vault/auth?with=token%2F`);
});
test('it does not render direct link for unsupported method', async function (assert) {
this.createModel('my-approle/', 'approle');
this.createMethod('my-approle/', 'approle');
await this.renderComponent();
assert.dom(GENERAL.infoRowValue('UI login link')).doesNotExist();
});
test('it renders direct link if within a namespace', async function (assert) {
this.owner.lookup('service:namespace').set('path', 'foo/bar');
this.createModel('token/', 'token');
this.createMethod('token/', 'token');
await this.renderComponent();
assert
.dom(GENERAL.infoRowValue('UI login link'))

37
ui/types/vault/mount.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export type MountConfig = {
forceNoCache?: boolean;
listingVisibility?: string | boolean;
defaultLeaseTtl?: number;
maxLeaseTtl?: number;
allowedManagedKeys?: string[];
auditNonHmacRequestKeys?: string[];
auditNonHmacResponseKeys?: string[];
passthroughRequestHeaders?: string[];
allowedResponseHeaders?: string[];
identityTokenKey?: string;
};
export type MountOptions = {
version: number;
};
export type Mount = {
path: string;
accessor: string;
config: MountConfig;
description: string;
externalEntropyAccess: boolean;
local: boolean;
options?: MountOptions;
pluginVersion: string;
runningPluginVersion: string;
runningSha256: string;
sealWrap: boolean;
type: string;
uuid: string;
};

View File

@ -10,39 +10,7 @@ import type {
AzureConfigureRequest,
GoogleCloudConfigureRequest,
} from '@hashicorp/vault-client-typescript';
export type EngineConfig = {
forceNoCache?: boolean;
listingVisibility?: string | boolean;
defaultLeaseTtl?: number;
maxLeaseTtl?: number;
allowedManagedKeys?: string[];
auditNonHmacRequestKeys?: string[];
auditNonHmacResponseKeys?: string[];
passthroughRequestHeaders?: string[];
allowedResponseHeaders?: string[];
identityTokenKey?: string;
};
export type EngineOptions = {
version: number;
};
export type SecretsEngine = {
path: string;
accessor: string;
config: EngineConfig;
description: string;
externalEntropyAccess: boolean;
local: boolean;
options?: EngineOptions;
pluginVersion: string;
runningPluginVersion: string;
runningSha256: string;
sealWrap: boolean;
type: string;
uuid: string;
};
import type { MountConfig, MountOptions } from 'vault/mount';
type CommonConfigParams = {
rotationPeriod: number;

12
ui/types/vault/services/path-help.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import type { PathInfo } from 'vault/utils/openapi-helpers';
export default class PathHelpService extends Service {
getPaths(apiPath: string, backend: string, itemType?: string, itemID?: string): Promise<PathInfo>;
}