diff --git a/ui/app/config/environment.d.ts b/ui/app/config/environment.d.ts index 0c5887b003..42fb0efc2d 100644 --- a/ui/app/config/environment.d.ts +++ b/ui/app/config/environment.d.ts @@ -13,7 +13,16 @@ declare const config: { podModulePrefix: string; locationType: 'history' | 'hash' | 'none'; rootURL: string; - APP: Record; + APP: { + POLLING_URLS: string[]; + NAMESPACE_ROOT_URLS: string[]; + DEFAULT_PAGE_SIZE: number; + LOG_TRANSITIONS?: boolean; + LOG_ACTIVE_GENERATION?: boolean; + LOG_VIEW_LOOKUPS?: boolean; + rootElement?: string; + autoboot?: boolean; + }; }; export default config; diff --git a/ui/app/lib/local-storage.js b/ui/app/lib/local-storage.ts similarity index 71% rename from ui/app/lib/local-storage.js rename to ui/app/lib/local-storage.ts index a259c78699..8da3e057f5 100644 --- a/ui/app/lib/local-storage.js +++ b/ui/app/lib/local-storage.ts @@ -3,31 +3,34 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import type { ApiError } from 'vault/api'; + export default { isLocalStorageSupported() { try { const key = `__storage__test`; - window.localStorage.setItem(key, null); + window.localStorage.setItem(key, ''); window.localStorage.removeItem(key); return true; } catch (e) { + const error = e as ApiError; // modify the e object so we can customize the error message. // e.message is readOnly. - e.errors = [`This is likely due to your browser's cookie settings.`]; + error.errors = [`This is likely due to your browser's cookie settings.`]; throw e; } }, - getItem(key) { + getItem(key: string) { const item = window.localStorage.getItem(key); return item && JSON.parse(item); }, - setItem(key, val) { + setItem(key: string, val: unknown) { window.localStorage.setItem(key, JSON.stringify(val)); }, - removeItem(key) { + removeItem(key: string) { return window.localStorage.removeItem(key); }, @@ -35,7 +38,7 @@ export default { return Object.keys(window.localStorage); }, - cleanupStorage(string, keyToKeep) { + cleanupStorage(string: string, keyToKeep: string) { if (!string) return; const relevantKeys = this.keys().filter((str) => str.startsWith(string)); relevantKeys?.forEach((key) => { diff --git a/ui/app/lib/memory-storage.js b/ui/app/lib/memory-storage.ts similarity index 61% rename from ui/app/lib/memory-storage.js rename to ui/app/lib/memory-storage.ts index bd1cfcc97a..188cf938be 100644 --- a/ui/app/lib/memory-storage.js +++ b/ui/app/lib/memory-storage.ts @@ -3,19 +3,19 @@ * SPDX-License-Identifier: BUSL-1.1 */ -const cache = {}; +const cache: { [key: string]: string } = {}; export default { - getItem(key) { - var item = cache[key]; + getItem(key: string) { + const item = cache[key]; return item && JSON.parse(item); }, - setItem(key, val) { + setItem(key: string, val: unknown) { cache[key] = JSON.stringify(val); }, - removeItem(key) { + removeItem(key: string) { delete cache[key]; }, diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts new file mode 100644 index 0000000000..d5c4cb5687 --- /dev/null +++ b/ui/app/services/api.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service, { service } from '@ember/service'; +import { + Configuration, + RequestContext, + ResponseContext, + AuthApi, + IdentityApi, + SecretsApi, + SystemApi, +} from '@hashicorp/vault-client-typescript'; +import config from '../config/environment'; + +import type AuthService from 'vault/services/auth'; +import type NamespaceService from 'vault/services/namespace'; +import type ControlGroupService from 'vault/services/control-group'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type { ApiError, HeaderMap, XVaultHeaders } from 'vault/api'; + +export default class ApiService extends Service { + @service('auth') declare readonly authService: AuthService; + @service('namespace') declare readonly namespaceService: NamespaceService; + @service declare readonly controlGroup: ControlGroupService; + @service declare readonly flashMessages: FlashMessageService; + + // -- Pre Request Middleware -- + setLastFetch = async (context: RequestContext) => { + const { url } = context; + const isPolling = config.APP.POLLING_URLS.some((str) => url.includes(str)); + + if (!isPolling) { + this.authService.setLastFetch(Date.now()); + } + }; + + getControlGroupToken = async (context: RequestContext) => { + const { url, init } = context; + const controlGroupToken = this.controlGroup.tokenForUrl(url); + let newUrl = url; + // if we have a Control Group token that matches the url, + // unwrap it and return the unwrapped response as if it were the initial request + // to do this, we rewrite the request + if (controlGroupToken) { + const { token } = controlGroupToken; + const { headers } = this.buildHeaders({ token }); + newUrl = '/v1/sys/wrapping/unwrap'; + init.method = 'POST'; + init.headers = headers; + init.body = JSON.stringify({ token }); + } + return { url: newUrl, init }; + }; + + setHeaders = async (context: RequestContext) => { + const { url, init } = context; + const headers = new Headers(init.headers); + // unauthenticated or clientToken requests should set the header in initOverrides + // unauthenticated value should be empty string, not undefined or null + if (!headers.has('X-Vault-Token')) { + headers.set('X-Vault-Token', this.authService.currentToken); + } + if (init.method === 'PATCH') { + headers.set('Content-Type', 'application/merge-patch+json'); + } + // use initOverrides to set the namespace header to something other than path set in the namespace service + // for requests that must be made to root namespace pass empty string as value + const namespace = this.namespaceService.path; + if (!headers.has('X-Vault-Namespace') && namespace) { + headers.set('X-Vault-Namespace', namespace); + } + + init.headers = headers; + return { url, init }; + }; + + // -- Post Request Middleware -- + showWarnings = async (context: ResponseContext) => { + const response = context.response.clone(); + const json = await response?.json(); + + if (json?.warnings) { + json.warnings.forEach((message: string) => { + this.flashMessages.info(message); + }); + } + }; + + deleteControlGroupToken = async (context: ResponseContext) => { + const { url } = context; + const controlGroupToken = this.controlGroup.tokenForUrl(url); + if (controlGroupToken) { + this.controlGroup.deleteControlGroupToken(controlGroupToken.accessor); + } + }; + + formatErrorResponse = async (context: ResponseContext) => { + const response = context.response.clone(); + const { headers, status, statusText } = response; + + // backwards compatibility with Ember Data + if (status >= 400) { + const error: ApiError = (await response?.json()) || {}; + error.httpStatus = response?.status; + error.path = context.url; + // typically the Vault API error response looks like { errors: ['some error message'] } + // but sometimes (eg RespondWithStatusCode) it's { data: { error: 'some error message' } } + if (error?.data?.error && !error.errors) { + // normalize the errors from RespondWithStatusCode + error.errors = [error.data.error]; + } + return new Response(JSON.stringify(error), { headers, status, statusText }); + } + + return; + }; + + // the responses in the OpenAPI spec don't account for the return values to be under the 'data' key + // return the data rather than the entire response + // if there is no data then return the full response + extractData = async (context: ResponseContext) => { + const response = context.response.clone(); + const { headers, ok, status, statusText } = response; + + if (ok) { + const json = await response?.json(); + if (json.data) { + return new Response(JSON.stringify(json.data), { headers, status, statusText }); + } + } + + return; + }; + // --- End Middleware --- + + configuration = new Configuration({ + basePath: '/v1', + middleware: [ + { pre: this.setLastFetch }, + { pre: this.getControlGroupToken }, + { pre: this.setHeaders }, + { post: this.showWarnings }, + { post: this.deleteControlGroupToken }, + { post: this.formatErrorResponse }, + { post: this.extractData }, + ], + }); + + auth = new AuthApi(this.configuration); + identity = new IdentityApi(this.configuration); + secrets = new SecretsApi(this.configuration); + sys = new SystemApi(this.configuration); + + // convenience method for overriding headers for given requests to ensure consistency + // eg. this.api.sys.wrap(data, { headers: { 'X-Vault-Wrap-TTL': wrap } }); + // -> this.api.sys.wrap(data, this.api.buildHeaders({ wrap })); + buildHeaders(headerMap: HeaderMap) { + const headers = {} as XVaultHeaders; + + for (const key in headerMap) { + const headerKey = { + namespace: 'X-Vault-Namespace', + token: 'X-Vault-Token', + wrap: 'X-Vault-Wrap-TTL', + }[key] as keyof XVaultHeaders; + + headers[headerKey] = headerMap[key as keyof HeaderMap]; + } + + return { headers }; + } +} diff --git a/ui/tests/unit/services/api-test.js b/ui/tests/unit/services/api-test.js new file mode 100644 index 0000000000..b37a469d3c --- /dev/null +++ b/ui/tests/unit/services/api-test.js @@ -0,0 +1,190 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import sinon from 'sinon'; + +module('Unit | Service | api', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.apiService = this.owner.lookup('service:api'); + + const authService = this.owner.lookup('service:auth'); + this.setLastFetch = sinon.spy(authService, 'setLastFetch'); + this.currentToken = sinon.stub(authService, 'currentToken').value('foobar'); + + const namespaceService = this.owner.lookup('service:namespace'); + this.namespace = sinon.stub(namespaceService, 'path').value('another-ns'); + + const controlGroupService = this.owner.lookup('service:control-group'); + this.wrapInfo = { token: 'ctrl-group', accessor: '84tfdfd5pQ5vOOEMxC2o3Ymt' }; + this.tokenForUrl = sinon.stub(controlGroupService, 'tokenForUrl').returns(this.wrapInfo); + this.deleteControlGroupToken = sinon.spy(controlGroupService, 'deleteControlGroupToken'); + + const flashMessageService = this.owner.lookup('service:flash-messages'); + this.info = sinon.spy(flashMessageService, 'info'); + + this.url = '/v1/sys/capabilities-self'; + }); + + test('it should set last fetch time', async function (assert) { + await this.apiService.setLastFetch({ url: '/v1/sys/health' }); + assert.true(this.setLastFetch.notCalled, 'Last fetch is not set for polling url'); + + await this.apiService.setLastFetch({ url: '/v1/auth/token/lookup-self' }); + assert.true(this.setLastFetch.calledOnce, 'Last fetch is set for non polling url'); + }); + + test('it should get control group token', async function (assert) { + const context = { + url: this.url, + init: { + method: 'GET', + headers: { 'X-Vault-Token': 'root' }, + }, + }; + + this.tokenForUrl.returns(undefined); + const noTokenContext = await this.apiService.getControlGroupToken(context); + + assert.true(this.tokenForUrl.calledWith(context.url), 'Url is passed to tokenForUrl method'); + assert.deepEqual(context, noTokenContext, 'Original context is returned when no token is present'); + + this.tokenForUrl.returns(this.wrapInfo); + + const { token } = this.wrapInfo; + const tokenContext = await this.apiService.getControlGroupToken(context); + const newContext = { + url: '/v1/sys/wrapping/unwrap', + init: { + method: 'POST', + headers: { 'X-Vault-Token': token }, + body: JSON.stringify({ token }), + }, + }; + + assert.deepEqual(tokenContext, newContext, 'New context is returned when token is present'); + }); + + test('it should set default headers', async function (assert) { + const { + init: { headers }, + } = await this.apiService.setHeaders({ init: { method: 'PATCH' } }); + + assert.strictEqual( + headers.get('X-Vault-Token'), + 'foobar', + 'Token header is set with value from auth service' + ); + assert.strictEqual( + headers.get('X-Vault-Namespace'), + 'another-ns', + 'Namespace header is set with value from namespace service' + ); + assert.strictEqual( + headers.get('Content-Type'), + 'application/merge-patch+json', + 'Content type header is set for PATCH method' + ); + }); + + test('it should override default headers when set on request init', async function (assert) { + const initHeaders = { + 'X-Vault-Token': 'root', + 'X-Vault-Namespace': 'ns1', + }; + + const { + init: { headers }, + } = await this.apiService.setHeaders({ init: { headers: initHeaders } }); + + assert.strictEqual(headers.get('X-Vault-Token'), 'root', 'Token header set on request init is preserved'); + assert.strictEqual( + headers.get('X-Vault-Namespace'), + 'ns1', + 'Namespace header set on request init is preserved' + ); + }); + + test('it should show warnings', async function (assert) { + const warnings = ['warning1', 'warning2']; + const response = new Response(JSON.stringify({ warnings })); + + await this.apiService.showWarnings({ response }); + + assert.true(this.info.firstCall.calledWith(warnings[0]), 'First warning message is shown'); + assert.true(this.info.secondCall.calledWith(warnings[1]), 'Second warning message is shown'); + }); + + test('it should delete control group token', async function (assert) { + await this.apiService.deleteControlGroupToken({ url: this.url }); + + assert.true(this.tokenForUrl.calledWith(this.url), 'Url is passed to tokenForUrl method'); + assert.true( + this.deleteControlGroupToken.calledWith(this.wrapInfo.accessor), + 'Control group token is deleted' + ); + }); + + test('it should format error response', async function (assert) { + const e = { data: { error: 'Something went wrong' } }; + const response = new Response(JSON.stringify(e), { status: 400 }); + + const errorResponse = await this.apiService.formatErrorResponse({ response, url: this.url }); + const error = await errorResponse.json(); + const expectedError = { + ...e, + httpStatus: 400, + path: this.url, + errors: ['Something went wrong'], + }; + + assert.deepEqual(error, expectedError, 'Error is reformated and returned'); + }); + + test('it should extract data from response', async function (assert) { + const data = { data: { foo: 'bar' } }; + const response = new Response(JSON.stringify(data), { status: 200 }); + + const extractedDataResponse = await this.apiService.extractData({ response, url: this.url }); + const json = await extractedDataResponse.json(); + + assert.deepEqual(json, { foo: 'bar' }, 'Data is extracted and returned'); + }); + + test('it should build headers', async function (assert) { + const headerMap = { + token: 'foobar', + namespace: 'ns1', + wrap: '10s', + }; + + const token = await this.apiService.buildHeaders({ token: headerMap.token }); + assert.deepEqual(token.headers, { 'X-Vault-Token': headerMap.token }, 'Token header is set'); + + const namespace = await this.apiService.buildHeaders({ namespace: headerMap.namespace }); + assert.deepEqual( + namespace.headers, + { 'X-Vault-Namespace': headerMap.namespace }, + 'Namespace header is set' + ); + + const wrapTTL = await this.apiService.buildHeaders({ wrap: headerMap.wrap }); + assert.deepEqual(wrapTTL.headers, { 'X-Vault-Wrap-TTL': '10s' }, 'Wrap TTL header is set'); + + const multi = await this.apiService.buildHeaders(headerMap); + assert.deepEqual( + multi.headers, + { + 'X-Vault-Token': 'foobar', + 'X-Vault-Namespace': 'ns1', + 'X-Vault-Wrap-TTL': '10s', + }, + 'All supported headers are set' + ); + }); +}); diff --git a/ui/types/vault/api.d.ts b/ui/types/vault/api.d.ts new file mode 100644 index 0000000000..437643252e --- /dev/null +++ b/ui/types/vault/api.d.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export interface ApiError { + httpStatus: number; + path: string; + message: string; + errors: Array; + data?: { + [key: string]: unknown; + error?: string; + }; +} + +export interface WrapInfo { + accessor: string; + creation_path: string; + creation_time: string; + wrapped_accessor: string; + token: string; + ttl: number; +} + +export interface ApiResponse { + auth: unknown; + data: unknown; + lease_duration: number; + lease_id: string; + mount_type: string; + renewable: boolean; + request_id: string; + warnings: Array | null; + wrap_info: WrapInfo | null; +} + +export type HeaderMap = + | { + namespace: string; + } + | { + token: string; + } + | { + wrap: string; + }; + +export type XVaultHeaders = + | { + 'X-Vault-Namespace': string; + } + | { + 'X-Vault-Token': string; + } + | { + 'X-Vault-Wrap-TTL': string; + }; diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index 976e8a9b21..510796c87b 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -19,4 +19,6 @@ export interface AuthData { export default class AuthService extends Service { authData: AuthData; + currentToken: string; + setLastFetch: (time: number) => void; } diff --git a/ui/types/vault/services/control-group.d.ts b/ui/types/vault/services/control-group.d.ts new file mode 100644 index 0000000000..06ca7990cc --- /dev/null +++ b/ui/types/vault/services/control-group.d.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service from '@ember/service'; + +import type localStorage from 'vault/lib/local-storage'; +import type memoryStorage from 'vault/lib/memory-storage'; +import type { ApiResponse, WrapInfo } from 'vault/api'; +import type Transition from '@ember/routing/transition'; + +export interface ControlGroupErrorLog { + type: string; + content: string; + href: string; + token: string; + accessor: string; + creation_path: string; +} + +export default class ControlGroupService extends Service { + tokenToUnwrap: WrapInfo | null; + storage(): localStorage | memoryStorage; + keyFromAccessor(accessor: string): string | null; + storeControlGroupToken(info: WrapInfo): void; + deleteControlGroupToken(accessor: string): void; + deleteTokens(): void; + wrapInfoForAccessor(accessor: string): string | null; + markTokenForUnwrap(accessor: string): void; + unmarkTokenForUnwrap(): void; + tokenForUrl(url: string): { token: string; accessor: string; creationTime: string } | null; + checkForControlGroup( + callbackArgs: unknown, + response: ApiResponse, + wasWrapTTLRequested: boolean + ): Promise; + saveTokenFromError(error: WrapInfo): void; + logFromError(error: WrapInfo): ControlGroupErrorLog; +} diff --git a/ui/types/vault/services/namespace.d.ts b/ui/types/vault/services/namespace.d.ts index b298ef74dc..3ec84aa92c 100644 --- a/ui/types/vault/services/namespace.d.ts +++ b/ui/types/vault/services/namespace.d.ts @@ -15,6 +15,7 @@ export default class NamespaceService extends Service { inRootNamespace: boolean; currentNamespace: string; relativeNamespace: string; + path: string; setNamespace: () => void; findNamespacesForUser: () => void; reset: () => void;