From c113e1d2c881f81b24e89b153f8d35797b28fb52 Mon Sep 17 00:00:00 2001 From: Makito Date: Wed, 24 Jul 2024 15:51:24 +0800 Subject: [PATCH 1/4] feat(*): vault secret picker --- .../forms/src/components/FormGenerator.vue | 23 +- .../src/components/fields/FieldArray.vue | 60 ++- .../src/components/fields/FieldArrayItem.vue | 23 +- .../src/components/fields/FieldInput.vue | 21 +- .../components/fields/FieldKeyValuePairs.vue | 72 ++- .../components/fields/FieldObjectAdvanced.vue | 15 + .../src/components/fields/FieldTextArea.vue | 53 ++- .../forms/src/components/forms/ACMEForm.vue | 7 +- .../src/components/forms/ExitTransformer.vue | 9 +- .../forms/src/components/forms/OIDCForm.vue | 153 +++++-- .../src/components/forms/PostFunction.vue | 7 +- .../forms/src/components/forms/RLAForm.vue | 7 +- .../components/forms/schemas/OIDCAdvanced.js | 219 --------- .../src/components/forms/schemas/OIDCAuth.js | 75 --- .../components/forms/schemas/OIDCCommon.js | 24 - packages/core/forms/src/const.ts | 6 + packages/core/forms/src/types.ts | 10 + .../entities-plugins/docs/plugin-form.md | 8 + .../entities-plugins/fixtures/mockData.ts | 79 ++++ .../entities/entities-plugins/package.json | 1 + .../entities-plugins/sandbox/index.ts | 10 + .../sandbox/pages/HomePage.vue | 14 + .../sandbox/pages/PluginFormPage.vue | 8 +- .../sandbox/pages/VaultSecretPickerPage.vue | 114 +++++ .../src/components/PluginEntityForm.vue | 60 ++- .../src/components/PluginForm.vue | 26 +- .../src/components/VaultSecretPicker.cy.ts | 429 ++++++++++++++++++ .../src/components/VaultSecretPicker.vue | 424 +++++++++++++++++ .../components/VaultSecretPickerProvider.vue | 45 ++ .../entities-plugins/src/locales/en.json | 33 +- .../entities-plugins/src/vaults-utils.ts | 80 ++++ .../src/composables/useDebouncedFilter.ts | 33 +- .../entities-shared/src/types/utils.ts | 7 + .../entities/entities-vaults/src/index.ts | 5 + pnpm-lock.yaml | 3 + 35 files changed, 1700 insertions(+), 463 deletions(-) delete mode 100644 packages/core/forms/src/components/forms/schemas/OIDCAdvanced.js delete mode 100644 packages/core/forms/src/components/forms/schemas/OIDCAuth.js delete mode 100644 packages/core/forms/src/components/forms/schemas/OIDCCommon.js create mode 100644 packages/entities/entities-plugins/sandbox/pages/HomePage.vue create mode 100644 packages/entities/entities-plugins/sandbox/pages/VaultSecretPickerPage.vue create mode 100644 packages/entities/entities-plugins/src/components/VaultSecretPicker.cy.ts create mode 100644 packages/entities/entities-plugins/src/components/VaultSecretPicker.vue create mode 100644 packages/entities/entities-plugins/src/components/VaultSecretPickerProvider.vue create mode 100644 packages/entities/entities-plugins/src/vaults-utils.ts diff --git a/packages/core/forms/src/components/FormGenerator.vue b/packages/core/forms/src/components/FormGenerator.vue index 5897ba79cc..c1c191954e 100644 --- a/packages/core/forms/src/components/FormGenerator.vue +++ b/packages/core/forms/src/components/FormGenerator.vue @@ -148,18 +148,37 @@ * @typedef {Record & PartialGroup} Group */ -import { ref } from 'vue' import forEach from 'lodash-es/forEach' import objGet from 'lodash-es/get' import isFunction from 'lodash-es/isFunction' import isNil from 'lodash-es/isNil' -import formMixin from './FormMixin.vue' +import { ref } from 'vue' +import { AUTOFILL_SLOT, AUTOFILL_SLOT_NAME } from '../const' import formGroup from './FormGroup.vue' +import formMixin from './FormMixin.vue' export default { name: 'FormGenerator', components: { formGroup }, mixins: [formMixin], + + inject: { + // Inject AUTOFILL_SLOT for provide() + autofillSlot: { + from: AUTOFILL_SLOT, + default: undefined, + }, + }, + + provide() { + return { + // Provide AUTOFILL_SLOT only if it is not already provided + ...!this.autofillSlot && { + [AUTOFILL_SLOT]: this.$slots?.[AUTOFILL_SLOT_NAME], + }, + } + }, + props: { schema: { type: Object, diff --git a/packages/core/forms/src/components/fields/FieldArray.vue b/packages/core/forms/src/components/fields/FieldArray.vue index c4c3a4698b..abf0cea0af 100644 --- a/packages/core/forms/src/components/fields/FieldArray.vue +++ b/packages/core/forms/src/components/fields/FieldArray.vue @@ -24,6 +24,7 @@ @model-updated="modelUpdated" /> + + + + + + + + diff --git a/packages/core/forms/src/components/forms/ACMEForm.vue b/packages/core/forms/src/components/forms/ACMEForm.vue index 61b1f4fb5e..435932e9c8 100644 --- a/packages/core/forms/src/components/forms/ACMEForm.vue +++ b/packages/core/forms/src/components/forms/ACMEForm.vue @@ -133,11 +133,16 @@ diff --git a/packages/entities/entities-plugins/sandbox/pages/VaultSecretPickerPage.vue b/packages/entities/entities-plugins/sandbox/pages/VaultSecretPickerPage.vue new file mode 100644 index 0000000000..97359917b0 --- /dev/null +++ b/packages/entities/entities-plugins/sandbox/pages/VaultSecretPickerPage.vue @@ -0,0 +1,114 @@ + + + + + + diff --git a/packages/entities/entities-plugins/src/components/PluginEntityForm.vue b/packages/entities/entities-plugins/src/components/PluginEntityForm.vue index b69444454e..6af9947c54 100644 --- a/packages/entities/entities-plugins/src/components/PluginEntityForm.vue +++ b/packages/entities/entities-plugins/src/components/PluginEntityForm.vue @@ -17,7 +17,18 @@ :form-schema="formSchema" :is-editing="editing" :on-model-updated="onModelUpdated" - /> + > + + + + + + + + + + diff --git a/packages/entities/entities-plugins/src/components/VaultSecretPickerProvider.vue b/packages/entities/entities-plugins/src/components/VaultSecretPickerProvider.vue new file mode 100644 index 0000000000..7458bc34b0 --- /dev/null +++ b/packages/entities/entities-plugins/src/components/VaultSecretPickerProvider.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/packages/entities/entities-plugins/src/locales/en.json b/packages/entities/entities-plugins/src/locales/en.json index a9ca215236..1d1bf20147 100644 --- a/packages/entities/entities-plugins/src/locales/en.json +++ b/packages/entities/entities-plugins/src/locales/en.json @@ -14,7 +14,8 @@ "view": "View Details", "configure_dynamic_ordering": "Configure Dynamic Ordering", "go_to_plugins": "Go to Plugins", - "save": "Save" + "save": "Save", + "loading": "Loading..." }, "view_configuration": { "title": "Configuration", @@ -579,5 +580,35 @@ }, "glossary": { "plugin": "plugin" + }, + "vault_secret_picker": { + "title": "Look up Key on Vault", + "vault": { + "label": "Vault", + "placeholder": "Select a vault" + }, + "secret_id": { + "label": "Secret ID", + "input_placeholder": "Enter a secret ID", + "select_placeholder": "Select or enter a complete secret ID" + }, + "optional_secret_key": { + "label": "Secret Key", + "placeholder": "Enter the key or key path to access a nested secret value", + "help": "Optional. e.g. tokens, tokens/refresh_token" + }, + "fetch_error": { + "vaults": "Could not fetch available vaults", + "secrets": "Could not fetch available secrets", + "vaults_and_secrets": "Could not fetch available vaults and secrets" + }, + "no_results": "No results found", + "actions": { + "use_key": "Use Key" + }, + "provider": { + "complete_action": "Look up {cta}", + "cta": "Key on Vault" + } } } diff --git a/packages/entities/entities-plugins/src/vaults-utils.ts b/packages/entities/entities-plugins/src/vaults-utils.ts new file mode 100644 index 0000000000..269a01e02c --- /dev/null +++ b/packages/entities/entities-plugins/src/vaults-utils.ts @@ -0,0 +1,80 @@ +/** + * Example: {vault://my-vault/secret-id/tokens/refresh_token} + */ +export interface ParsedSecretRef { + /** + * Vault prefix (non-empty string) + * + * Example: my-vault + */ + vaultPrefix: string + + /** + * Secret ID (non-empty string or undefined) + * + * A secret reference without a secret ID is invalid, but we should allow + * references in this format being parsed and built. + * + * Example: secret-id + */ + secretId?: string + + /** + * Secret key (non-empty string or undefined) + * + * Example: tokens/refresh_token + */ + optionalSecretKey?: string +} + +/** + * Parses a secret reference like {vault://vault-name/secret-id[/secret-key]} + * + * THIS FUNC MAY THROW ERRORS. USE IN TRY/CATCH BLOCK + * + * @param secretRef the secret reference to parse + * @returns + */ +export const parseSecretRef = (secretRef: string): ParsedSecretRef => { + let r = secretRef.trim() + if (!r.startsWith('{') || !r.endsWith('}')) { + throw new Error('Invalid secret reference: must be enclosed in curly braces') + } + + r = r.substring(1, r.length - 1).trim() + if (!r.startsWith('vault://')) { + throw new Error('Invalid secret reference: must start with vault://') + } + + // Workaround to parse the reference as a URL + const parsed = new URL(`http://${r.substring(8)}`) + if (!parsed) { + throw new Error('Invalid secret reference: must have a vault prefix') + } + + const parsedVaultPrefix = parsed.host // Everything before the first slash + const [, parsedSecretId, ...parsedOptionalSecretKey] = parsed.pathname.split('/') + if (!parsedVaultPrefix) { + throw new Error('Invalid secret reference: must have a vault prefix') + } + + return { + vaultPrefix: parsedVaultPrefix, + secretId: parsedSecretId || undefined, // Non-empty string or undefined + optionalSecretKey: parsedOptionalSecretKey?.join('/'), // Non-empty string or undefined + } +} + +export const buildSecretRef = (parsedSecretRef: ParsedSecretRef): string => { + if (!parsedSecretRef.vaultPrefix) { + throw new Error('Invalid secret reference: must have a vault prefix') + } + let ref = `vault://${parsedSecretRef.vaultPrefix}` + if (parsedSecretRef.secretId) { + ref = `${ref}/${parsedSecretRef.secretId}` + } + if (parsedSecretRef.optionalSecretKey) { + ref = `${ref}/${parsedSecretRef.optionalSecretKey}` + } + return `{${ref}}` +} diff --git a/packages/entities/entities-shared/src/composables/useDebouncedFilter.ts b/packages/entities/entities-shared/src/composables/useDebouncedFilter.ts index 7c55cf1dff..9c24fb551e 100644 --- a/packages/entities/entities-shared/src/composables/useDebouncedFilter.ts +++ b/packages/entities/entities-shared/src/composables/useDebouncedFilter.ts @@ -1,4 +1,4 @@ -import { ref, unref } from 'vue' +import { computed, ref, unref } from 'vue' import { useDebounce } from '@kong-ui-public/core' import type { KongManagerBaseTableConfig, @@ -41,14 +41,17 @@ export default function useDebouncedFilter( const resultsCache = ref[]>([]) const allRecords = ref[] | undefined>(undefined) - const _baseUrl = unref(baseUrl) - let url = `${config.apiBaseUrl}${_baseUrl}` + const url = computed(() => { + const url = `${config.apiBaseUrl}${unref(baseUrl)}` - if (config.app === 'konnect') { - url = url.replace(/{controlPlaneId}/gi, config?.controlPlaneId || '') - } else if (config.app === 'kongManager') { - url = url.replace(/\/{workspace}/gi, config?.workspace ? `/${config.workspace}` : '') - } + if (config.app === 'konnect') { + return url.replace(/{controlPlaneId}/gi, config?.controlPlaneId || '') + } else if (config.app === 'kongManager') { + return url.replace(/\/{workspace}/gi, config?.workspace ? `/${config.workspace}` : '') + } + + return url + }) const { isValidUuid } = useHelpers() @@ -58,7 +61,7 @@ export default function useDebouncedFilter( // Trigger the loading state loading.value = true - const { data }: Record = await axiosInstance.get(`${url}?size=${size}`) + const { data }: Record = await axiosInstance.get(`${url.value}?size=${size}`) // determine if we've got all of the available records or not // to determine how we handle filtering @@ -83,6 +86,10 @@ export default function useDebouncedFilter( // using this to skip unnecessary fetch fired on focus if (previousQuery.value === query) { return + } else if (query === '') { + // use cached results if query is empty + results.value = resultsCache.value + return } else { previousQuery.value = query || '' } @@ -97,7 +104,7 @@ export default function useDebouncedFilter( if (config.app === 'konnect') { // KoKo only supports exact match // If user has typed info in the query field - let currUrl = url + '' // clone + let currUrl = url.value + '' // clone if (query) { currUrl += `/${query}` } @@ -106,7 +113,7 @@ export default function useDebouncedFilter( if (keys.fetchedItemsKey in data) { results.value = data[keys.fetchedItemsKey] - } else if (data?.id) { // exact match + } else if (data?.[keys.exactMatchKey ?? 'id']) { // exact match results.value = [data] } else { results.value = [] @@ -117,7 +124,7 @@ export default function useDebouncedFilter( if (isValidUuid(query) && keys.searchKeys.includes('id')) { // If query is a valid UUID, do the exact search promises.push((async () => { - const { data } = await axiosInstance.get(`${url}/${query}`) + const { data } = await axiosInstance.get(`${url.value}/${query}`) return [data[keys.fetchedItemsKey] ?? data] })()) } else { @@ -126,7 +133,7 @@ export default function useDebouncedFilter( ...keys.searchKeys .filter(key => key !== 'id') .map(async key => { - const { data } = await axiosInstance.get(`${url}?${key}=${query}`) + const { data } = await axiosInstance.get(`${url.value}?${key}=${query}`) return data[keys.fetchedItemsKey] }), ) diff --git a/packages/entities/entities-shared/src/types/utils.ts b/packages/entities/entities-shared/src/types/utils.ts index b04cab26ae..ce93de4d83 100644 --- a/packages/entities/entities-shared/src/types/utils.ts +++ b/packages/entities/entities-shared/src/types/utils.ts @@ -5,4 +5,11 @@ export type MaybeRef = T | Ref export interface FilterKeys { fetchedItemsKey: string searchKeys: string[] + + /** + * The key to look for while doing exact match + * + * @default 'id' + */ + exactMatchKey?: string } diff --git a/packages/entities/entities-vaults/src/index.ts b/packages/entities/entities-vaults/src/index.ts index 08246743f3..694db77140 100644 --- a/packages/entities/entities-vaults/src/index.ts +++ b/packages/entities/entities-vaults/src/index.ts @@ -4,6 +4,11 @@ import VaultConfigCard from './components/VaultConfigCard.vue' import SecretList from './components/SecretList.vue' import SecretForm from './components/SecretForm.vue' +import vaultsEndpoints from './vaults-endpoints' +import secretsEndpoints from './secrets-endpoints' + export { VaultList, VaultForm, VaultConfigCard, SecretList, SecretForm } +export { vaultsEndpoints, secretsEndpoints } + export * from './types' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dee0712ef1..7c3f2ea00a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,9 @@ importers: specifier: ^12.0.2 version: 12.0.2 devDependencies: + '@kong-ui-public/entities-vaults': + specifier: workspace:^ + version: link:../entities-vaults '@kong-ui-public/i18n': specifier: workspace:^ version: link:../../core/i18n From 368e1cd494d094bedc9491cb5d91c199e5104033 Mon Sep 17 00:00:00 2001 From: Makito Date: Wed, 7 Aug 2024 11:57:23 +0800 Subject: [PATCH 2/4] fix(forms): suggestion: remove wrapper --- .../src/components/fields/FieldArrayItem.vue | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/core/forms/src/components/fields/FieldArrayItem.vue b/packages/core/forms/src/components/fields/FieldArrayItem.vue index 1bb10dd80b..e15699d070 100644 --- a/packages/core/forms/src/components/fields/FieldArrayItem.vue +++ b/packages/core/forms/src/components/fields/FieldArrayItem.vue @@ -1,18 +1,17 @@ From 77f9de0295bd56a137948b7df114a619b1fc6b63 Mon Sep 17 00:00:00 2001 From: Makito Date: Wed, 7 Aug 2024 12:10:54 +0800 Subject: [PATCH 3/4] fix(entities-plugins): improve readability --- .../entities/entities-plugins/src/components/PluginForm.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/entities/entities-plugins/src/components/PluginForm.vue b/packages/entities/entities-plugins/src/components/PluginForm.vue index a47bd5d504..011eb0df70 100644 --- a/packages/entities/entities-plugins/src/components/PluginForm.vue +++ b/packages/entities/entities-plugins/src/components/PluginForm.vue @@ -555,7 +555,6 @@ const buildFormSchema = (parentKey: string, response: Record, initi initialFormSchema[field] = { id: field, model: key } // each field's key will be set as the id initialFormSchema[field].type = scheme.type === 'boolean' ? 'checkbox' : 'input' initialFormSchema[field].required = scheme.required - initialFormSchema[field].values = scheme.values initialFormSchema[field].referenceable = scheme.referenceable if (field.startsWith('config-')) { @@ -613,6 +612,10 @@ const buildFormSchema = (parentKey: string, response: Record, initi if (scheme.type === 'map') { initialFormSchema[field].type = 'object-advanced' + // Passing `values` to this field in the generated schema for autofill providers + // Note: `values` may contain `referenceable` flag. + initialFormSchema[field].values = scheme.values + if (scheme.values.type === 'array') { const { type: elementsType } = scheme.values.elements || {} From 725f7d768ae98f96b446ef95823a178d7a09172d Mon Sep 17 00:00:00 2001 From: Makito Date: Wed, 7 Aug 2024 15:29:05 +0800 Subject: [PATCH 4/4] fix(forms): add back the wrapper --- .../src/components/fields/FieldArrayItem.vue | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/core/forms/src/components/fields/FieldArrayItem.vue b/packages/core/forms/src/components/fields/FieldArrayItem.vue index e15699d070..1bb10dd80b 100644 --- a/packages/core/forms/src/components/fields/FieldArrayItem.vue +++ b/packages/core/forms/src/components/fields/FieldArrayItem.vue @@ -1,17 +1,18 @@