From 7fa59bfd5d5c0bdd46f9b350660567cd108d6cdd Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Thu, 14 May 2026 11:41:08 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Add=20support=20for=20ou?= =?UTF-8?q?tput=20fields=20(#3268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 Add support for output fields * refactor: 💡 PR feedback --- addons/api/app/mirage/data/grants-schema.js | 720 ++++++++++++++---- addons/core/translations/resources/en-us.yaml | 7 + ui/admin/app/utils/grant-completions.js | 84 +- ui/admin/app/utils/grant-linter.js | 134 +++- .../unit/utils/grant-completions-test.js | 206 +++-- .../tests/unit/utils/grant-linter-test.js | 73 +- 6 files changed, 970 insertions(+), 254 deletions(-) diff --git a/addons/api/app/mirage/data/grants-schema.js b/addons/api/app/mirage/data/grants-schema.js index 84afa8ddf..923205ad2 100644 --- a/addons/api/app/mirage/data/grants-schema.js +++ b/addons/api/app/mirage/data/grants-schema.js @@ -5,147 +5,93 @@ export const grantsSchemaData = { resource_types: [ - { - type: 'auth-method', - collection_actions: ['create', 'list'], - id_actions: ['change-state', 'authenticate', 'read', 'update', 'delete'], - scopes: ['global', 'org'], - id_prefixes: ['ampw', 'amoidc', 'amldap'], - }, - { - type: 'account', - collection_actions: ['create', 'list'], - id_actions: [ - 'set-password', - 'change-password', - 'read', - 'update', - 'delete', - ], - scopes: ['global', 'org'], - id_prefixes: ['acctoidc', 'acctldap', 'apw', 'acctpw'], - parent_type: 'auth-method', - }, - { - type: 'host', - collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['project'], - id_prefixes: ['hst', 'hplg', 'h'], - parent_type: 'host-catalog', - }, - { - type: 'session', - collection_actions: ['list'], - id_actions: ['read', 'read:self', 'cancel', 'cancel:self'], - scopes: ['project'], - id_prefixes: ['s'], - }, { type: 'credential-store', collection_actions: ['create', 'list'], id_actions: ['read', 'update', 'delete'], scopes: ['project'], - id_prefixes: ['csst', 'cs', 'csvlt'], + id_prefixes: ['csvlt', 'csst', 'cs'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'vault_credential_store_attributes', + 'authorized_actions', + 'authorized_collection_actions', + ], }, { - type: 'alias', + type: 'credential', collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['global'], - id_prefixes: ['alt'], + id_actions: ['update', 'delete', 'read'], + scopes: ['project'], + id_prefixes: [ + 'cdvlt', + 'credp', + 'credup', + 'credjson', + 'cred', + 'credupd', + 'credspk', + ], + parent_type: 'credential-store', + output_fields: [ + 'id', + 'credential_store_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'username_password_attributes', + 'ssh_private_key_attributes', + 'json_attributes', + 'username_password_domain_attributes', + 'password_attributes', + 'authorized_actions', + ], }, { type: 'user', - collection_actions: ['create', 'list'], + collection_actions: ['list', 'create'], id_actions: [ + 'remove-accounts', 'list-resolvable-aliases', 'read', 'update', 'delete', 'add-accounts', 'set-accounts', - 'remove-accounts', ], scopes: ['global', 'org'], id_prefixes: ['u'], - }, - { - type: 'auth-token', - collection_actions: ['list'], - id_actions: ['read', 'read:self', 'delete', 'delete:self'], - scopes: ['global', 'org'], - id_prefixes: ['at'], - }, - { - type: 'target', - collection_actions: ['list', 'create'], - id_actions: [ - 'add-credential-sources', - 'remove-credential-sources', - 'delete', - 'set-host-sources', - 'set-credential-sources', - 'authorize-session', - 'read', - 'update', - 'add-host-sources', - 'remove-host-sources', + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'account_ids', + 'accounts', + 'authorized_actions', + 'login_name', + 'full_name', + 'email', + 'primary_account_id', ], - scopes: ['project'], - id_prefixes: ['trdp', 'ttcp', 'tssh'], - }, - { - type: 'worker', - collection_actions: [ - 'create:controller-led', - 'create:worker-led', - 'list', - 'read-certificate-authority', - 'reinitialize-certificate-authority', - ], - id_actions: [ - 'set-worker-tags', - 'remove-worker-tags', - 'read', - 'update', - 'delete', - 'add-worker-tags', - ], - scopes: ['global'], - id_prefixes: ['w'], - }, - { - type: 'managed-group', - collection_actions: ['create', 'list'], - id_actions: ['update', 'delete', 'read'], - scopes: ['global', 'org'], - id_prefixes: ['mgoidc', 'mgldap'], - parent_type: 'auth-method', - }, - { - type: 'credential-library', - collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['project'], - id_prefixes: ['clvllt', 'clvsclt', 'clvlt'], - parent_type: 'credential-store', - }, - { - type: 'credential', - collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['project'], - id_prefixes: [ - 'credupd', - 'credp', - 'credspk', - 'cdvlt', - 'credjson', - 'cred', - 'credup', - ], - parent_type: 'credential-store', }, { type: 'group', @@ -160,33 +106,417 @@ export const grantsSchemaData = { ], scopes: ['global', 'org', 'project'], id_prefixes: ['g'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'member_ids', + 'members', + 'authorized_actions', + ], + }, + { + type: 'auth-method', + collection_actions: ['create', 'list'], + id_actions: ['authenticate', 'read', 'update', 'delete', 'change-state'], + scopes: ['global', 'org'], + id_prefixes: ['amldap', 'ampw', 'amoidc'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'password_auth_method_attributes', + 'oidc_auth_methods_attributes', + 'ldap_auth_methods_attributes', + 'is_primary', + 'authorized_actions', + 'authorized_collection_actions', + ], + }, + { + type: 'account', + collection_actions: ['create', 'list'], + id_actions: [ + 'read', + 'update', + 'delete', + 'set-password', + 'change-password', + ], + scopes: ['global', 'org'], + id_prefixes: ['apw', 'acctoidc', 'acctpw', 'acctldap'], + parent_type: 'auth-method', + output_fields: [ + 'id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'auth_method_id', + 'attributes', + 'password_account_attributes', + 'oidc_account_attributes', + 'ldap_account_attributes', + 'managed_group_ids', + 'authorized_actions', + ], + }, + { + type: 'host-catalog', + collection_actions: ['create', 'list'], + id_actions: ['update', 'delete', 'read'], + scopes: ['project'], + id_prefixes: ['hcst', 'hcplg', 'hc'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'plugin_id', + 'plugin', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'secrets', + 'secrets_hmac', + 'worker_filter', + 'authorized_actions', + 'authorized_collection_actions', + ], + }, + { + type: 'target', + collection_actions: ['create', 'list'], + id_actions: [ + 'read', + 'update', + 'delete', + 'remove-host-sources', + 'remove-credential-sources', + 'add-host-sources', + 'set-host-sources', + 'add-credential-sources', + 'set-credential-sources', + 'authorize-session', + ], + scopes: ['project'], + id_prefixes: ['ttcp', 'tssh', 'trdp'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'host_source_ids', + 'host_sources', + 'session_max_seconds', + 'session_connection_limit', + 'worker_filter', + 'egress_worker_filter', + 'ingress_worker_filter', + 'brokered_credential_source_ids', + 'brokered_credential_sources', + 'injected_application_credential_source_ids', + 'injected_application_credential_sources', + 'attributes', + 'tcp_target_attributes', + 'ssh_target_attributes', + 'rdp_target_attributes', + 'authorized_actions', + 'address', + 'aliases', + 'with_aliases', + ], + }, + { + type: 'worker', + collection_actions: [ + 'create:controller-led', + 'create:worker-led', + 'list', + 'read-certificate-authority', + 'reinitialize-certificate-authority', + ], + id_actions: [ + 'read', + 'update', + 'delete', + 'add-worker-tags', + 'set-worker-tags', + 'remove-worker-tags', + ], + scopes: ['global'], + id_prefixes: ['w'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'address', + 'canonical_tags', + 'config_tags', + 'last_status_time', + 'worker_generated_auth_token', + 'controller_generated_activation_token', + 'active_connection_count', + 'type', + 'api_tags', + 'release_version', + 'directly_connected_downstream_workers', + 'authorized_actions', + 'local_storage_state', + 'remote_storage_state', + ], + }, + { + type: 'credential-library', + collection_actions: ['create', 'list'], + id_actions: ['delete', 'read', 'update'], + scopes: ['project'], + id_prefixes: ['clvllt', 'clvsclt', 'clvlt'], + parent_type: 'credential-store', + output_fields: [ + 'id', + 'credential_store_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'vault_credential_library_attributes', + 'vault_ssh_certificate_credential_library_attributes', + 'vault_generic_credential_library_attributes', + 'vault_ldap_credential_library_attributes', + 'authorized_actions', + 'credential_type', + 'credential_mapping_overrides', + ], + }, + { + type: 'billing', + collection_actions: ['monthly-active-users'], + scopes: ['global'], + }, + { + type: 'alias', + collection_actions: ['create', 'list'], + id_actions: ['read', 'update', 'delete'], + scopes: ['global'], + id_prefixes: ['alt'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'value', + 'destination_id', + 'type', + 'attributes', + 'target_alias_attributes', + 'authorized_actions', + ], + }, + { + type: 'auth-token', + collection_actions: ['list'], + id_actions: ['delete:self', 'read', 'read:self', 'delete'], + scopes: ['global', 'org'], + id_prefixes: ['at'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'token', + 'user_id', + 'auth_method_id', + 'account_id', + 'created_time', + 'updated_time', + 'approximate_last_used_time', + 'expiration_time', + 'authorized_actions', + ], + }, + { + type: 'host', + collection_actions: ['create', 'list'], + id_actions: ['read', 'update', 'delete'], + scopes: ['project'], + id_prefixes: ['hplg', 'hst', 'h'], + parent_type: 'host-catalog', + output_fields: [ + 'id', + 'host_catalog_id', + 'scope', + 'plugin', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'host_set_ids', + 'attributes', + 'static_host_attributes', + 'ip_addresses', + 'dns_names', + 'external_id', + 'external_name', + 'authorized_actions', + ], + }, + { + type: 'storage-bucket', + collection_actions: ['create', 'list'], + id_actions: ['read', 'update', 'delete'], + scopes: ['global', 'org'], + id_prefixes: ['sb'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'plugin_id', + 'plugin', + 'name', + 'description', + 'bucket_name', + 'bucket_prefix', + 'created_time', + 'updated_time', + 'version', + 'type', + 'attributes', + 'secrets', + 'secrets_hmac', + 'worker_filter', + 'storage_bucket_credential_id', + 'authorized_actions', + ], + }, + { + type: 'policy', + collection_actions: ['create', 'list'], + id_actions: ['delete', 'read', 'update'], + scopes: ['global', 'org'], + id_prefixes: ['pst'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'type', + 'version', + 'attributes', + 'storage_policy_attributes', + 'authorized_actions', + ], + }, + { + type: 'scope', + collection_actions: [ + 'list-keys', + 'rotate-keys', + 'list-key-version-destruction-jobs', + 'destroy-key-version', + 'create', + 'list', + ], + id_actions: [ + 'read', + 'update', + 'delete', + 'attach-storage-policy', + 'detach-storage-policy', + ], + scopes: ['global', 'org', 'project'], + id_prefixes: ['p', 'global', 'o'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'primary_auth_method_id', + 'authorized_actions', + 'authorized_collection_actions', + 'storage_policy_id', + ], }, { type: 'role', collection_actions: ['create', 'list'], id_actions: [ - 'add-grant-scopes', + 'read', 'update', 'add-principals', - 'add-grants', + 'remove-principals', 'set-grant-scopes', 'remove-grant-scopes', - 'read', 'delete', 'set-principals', - 'remove-principals', + 'add-grants', 'set-grants', 'remove-grants', + 'add-grant-scopes', ], scopes: ['global', 'org', 'project'], id_prefixes: ['r'], - }, - { - type: 'host-catalog', - collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['project'], - id_prefixes: ['hcst', 'hc', 'hcplg'], + output_fields: [ + 'id', + 'scope_id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'grant_scope_ids', + 'principal_ids', + 'principals', + 'grant_strings', + 'grants', + 'authorized_actions', + ], }, { type: 'host-set', @@ -200,47 +530,54 @@ export const grantsSchemaData = { 'update', ], scopes: ['project'], - id_prefixes: ['hsst', 'hsplg', 'hs'], + id_prefixes: ['hs', 'hsplg', 'hsst'], parent_type: 'host-catalog', - }, - { - type: 'storage-bucket', - collection_actions: ['list', 'create'], - id_actions: ['read', 'update', 'delete'], - scopes: ['global', 'org'], - id_prefixes: ['sb'], - }, - { - type: 'policy', - collection_actions: ['create', 'list'], - id_actions: ['read', 'update', 'delete'], - scopes: ['global', 'org'], - id_prefixes: ['pst'], - }, - { - type: 'billing', - collection_actions: ['monthly-active-users'], - scopes: ['global'], - }, - { - type: 'scope', - collection_actions: [ - 'destroy-key-version', - 'create', - 'list', - 'list-keys', - 'rotate-keys', - 'list-key-version-destruction-jobs', + output_fields: [ + 'id', + 'host_catalog_id', + 'scope', + 'plugin', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'host_ids', + 'preferred_endpoints', + 'sync_interval_seconds', + 'attributes', + 'authorized_actions', ], - id_actions: [ - 'attach-storage-policy', - 'detach-storage-policy', - 'read', - 'update', - 'delete', + }, + { + type: 'session', + collection_actions: ['list'], + id_actions: ['cancel', 'cancel:self', 'read', 'read:self'], + scopes: ['project'], + id_prefixes: ['s'], + output_fields: [ + 'id', + 'target_id', + 'scope', + 'created_time', + 'updated_time', + 'version', + 'type', + 'expiration_time', + 'auth_token_id', + 'user_id', + 'host_set_id', + 'host_id', + 'scope_id', + 'endpoint', + 'states', + 'status', + 'certificate', + 'termination_reason', + 'authorized_actions', + 'connections', ], - scopes: ['global', 'org', 'project'], - id_prefixes: ['o', 'global', 'p'], }, { type: 'session-recording', @@ -248,6 +585,53 @@ export const grantsSchemaData = { id_actions: ['read', 'download', 'delete', 'reapply-storage-policy'], scopes: ['global', 'org'], id_prefixes: ['sr'], + output_fields: [ + 'id', + 'scope', + 'session_id', + 'storage_bucket_id', + 'bytes_up', + 'bytes_down', + 'created_time', + 'updated_time', + 'start_time', + 'end_time', + 'duration', + 'type', + 'state', + 'error_details', + 'mime_types', + 'endpoint', + 'connection_recordings', + 'create_time_values', + 'authorized_actions', + 'retain_until', + 'delete_after', + ], + }, + { + type: 'managed-group', + collection_actions: ['create', 'list'], + id_actions: ['update', 'delete', 'read'], + scopes: ['global', 'org'], + id_prefixes: ['mgoidc', 'mgldap'], + parent_type: 'auth-method', + output_fields: [ + 'id', + 'scope', + 'name', + 'description', + 'created_time', + 'updated_time', + 'version', + 'type', + 'auth_method_id', + 'attributes', + 'oidc_managed_group_attributes', + 'ldap_managed_group_attributes', + 'member_ids', + 'authorized_actions', + ], }, ], }; diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 6cc9cc3f0..7e7bc8105 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -695,6 +695,7 @@ role: ids: empty-list: '"ids" field does not contain at least one id' wildcard-cannot-combine: Wildcard id "*" cannot be combined with other ids + templates-cannot-combine: Template ids cannot be combined with each other duplicate-template: Duplicate id "{primary}" and "{secondary}" duplicate-id: Duplicate id "{id}" unknown-template: Unknown template "{id}" @@ -716,6 +717,12 @@ role: remove: Remove remove-comma: Remove comma add-noop: Add "no-op" action + output-fields: + empty-list: '"output_fields" field does not contain at least one field' + wildcard-cannot-combine: Wildcard output field "*" cannot be combined with other fields + duplicate-field: Duplicate output field "{field}" + invalid-field: Invalid output field "{field}" + invalid-field-for-type: Invalid output field "{field}" for type "{type}" editor: title: Grant Editor description: Add each new grant string on a different line diff --git a/ui/admin/app/utils/grant-completions.js b/ui/admin/app/utils/grant-completions.js index 9abf0d56f..16805d073 100644 --- a/ui/admin/app/utils/grant-completions.js +++ b/ui/admin/app/utils/grant-completions.js @@ -69,6 +69,7 @@ export const normalizeGrantsSchema = (grantsSchema) => { idActions, idPrefixes, parentType: resourceType.parent_type, + outputFields: resourceType.output_fields ?? [], }; return resources; @@ -124,7 +125,7 @@ const getResourceTypesForId = (schema, id) => { .map(({ type }) => type); }; -export const getCompatibleResourceTypeForIds = (schema, idsValue) => { +const getCompatibleResourceTypeForIds = (schema, idsValue) => { const resourceTypesById = parseIds(idsValue).flatMap((id) => getResourceTypesForId(schema, id), ); @@ -152,7 +153,7 @@ const getIdActions = (schema, idsValue) => { return schema.resourcesByType[matchedType]?.idActions; }; -export const getChildResourceActions = (schema, idsValue) => { +const getChildResourceValues = (schema, idsValue, getValues) => { const parentType = getCompatibleResourceTypeForIds(schema, idsValue); if ( @@ -166,11 +167,25 @@ export const getChildResourceActions = (schema, idsValue) => { return [ ...new Set( - childTypes.flatMap((type) => schema.resourcesByType[type]?.actions ?? []), + childTypes.flatMap((type) => getValues(schema.resourcesByType[type])), ), ]; }; +const getChildResourceActions = (schema, idsValue) => + getChildResourceValues( + schema, + idsValue, + (resource) => resource?.actions ?? [], + ); + +const getChildResourceOutputFields = (schema, idsValue) => + getChildResourceValues( + schema, + idsValue, + (resource) => resource?.outputFields ?? [], + ); + const getTypeOptions = (schema, idsValue) => { // If there is no ids value, we can only suggest top-level resource types that have collection actions if (!idsValue) { @@ -326,6 +341,37 @@ export const analyzeGrantString = (grantsSchema, grantString = '') => { }; }; +const getOutputFieldOptions = (schema, typeValue, idsValue) => { + const selectedResource = schema.resourcesByType[typeValue]; + + if (typeValue && typeValue !== '*' && selectedResource) { + return selectedResource.outputFields.length + ? withWildCard(selectedResource.outputFields) + : ['*']; + } + + const hasSpecificIds = Boolean(idsValue) && !idsValue.includes('*'); + + // Pinned IDs with wildcard type + if (typeValue === '*' && hasSpecificIds) { + const childOutputFields = getChildResourceOutputFields(schema, idsValue); + return childOutputFields.length ? withWildCard(childOutputFields) : ['*']; + } + + if (!typeValue && hasSpecificIds) { + const matchedType = getCompatibleResourceTypeForIds(schema, idsValue); + if (matchedType) { + const resourceOutputFields = + schema.resourcesByType[matchedType]?.outputFields ?? []; + return resourceOutputFields.length + ? withWildCard(resourceOutputFields) + : ['*']; + } + } + + return ['*']; +}; + const getIdLookupTypes = (schema, typeValue, enteredIds) => { if (enteredIds.length > 0) { const compatibleResourceType = getCompatibleResourceTypeForIds( @@ -411,15 +457,15 @@ async function grantCompletions( const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/); if (idsValueMatch) { const enteredIds = idsValueMatch[1].split(','); - const partial = (enteredIds.pop() ?? '').trim(); + const partial = enteredIds.pop() ?? ''; const hasEnteredIds = enteredIds.length > 0; + const hasEnteredTemplateIds = enteredIds.some((id) => id.startsWith('{{')); const staticOptions = filterByPrefix(['*', ...ID_TEMPLATES], partial) .filter((value) => !hasEnteredIds || value !== '*') .filter( (value) => - !ID_TEMPLATES.includes(value) || - (!typeValue && enteredIds.every((id) => ID_TEMPLATES.includes(id))), + (!hasEnteredIds && !typeValue) || !ID_TEMPLATES.includes(value), ) .filter((value) => !enteredIds.includes(value)) .map((value) => ({ @@ -438,7 +484,9 @@ async function grantCompletions( ); const from = context.pos - partial.length; - const ids = await idLookup(partial, compatibleResourceTypes); + const ids = hasEnteredTemplateIds + ? [] + : await idLookup(partial, compatibleResourceTypes); const idOptions = ids .filter(({ id }) => !enteredIds.includes(id)) .map(({ id, name }) => ({ @@ -506,15 +554,23 @@ async function grantCompletions( const partial = enteredOutputFields.pop() ?? ''; const hasEnteredOutputFields = enteredOutputFields.length > 0; + const options = filterByPrefix( + getOutputFieldOptions(schema, typeValue, idsValue), + partial, + ) + .filter((value) => !hasEnteredOutputFields || value !== '*') + .filter((value) => !enteredOutputFields.includes(value)) + .map((value) => ({ + label: value, + type: 'constant', + info: value === '*' ? translate('all-fields') : '', + })); + return { from: context.pos - partial.length, - options: filterByPrefix(['*'], partial) - .filter((value) => !hasEnteredOutputFields || value !== '*') - .map((value) => ({ - label: value, - type: 'constant', - info: translate('all-fields'), - })), + options: options.length + ? options + : [getNoSuggestionsOption(translate('no-suggestions'))], }; } diff --git a/ui/admin/app/utils/grant-linter.js b/ui/admin/app/utils/grant-linter.js index 5f64fa9b2..e09b1c53f 100644 --- a/ui/admin/app/utils/grant-linter.js +++ b/ui/admin/app/utils/grant-linter.js @@ -43,6 +43,7 @@ const normalizeGrantsSchema = (grantsSchema) => { idActions, idPrefixes, parentType: resourceType.parent_type, + outputFields: resourceType.output_fields ?? [], }; return resources; @@ -415,6 +416,44 @@ const grantLinter = (context, schema, translate) => { ); } } + + // Validate output_fields field + if (parsedFields.output_fields) { + const pos = fieldPositions.output_fields; + const outputFieldList = validateListField( + parsedFields.output_fields, + pos, + diagnostics, + translate, + ); + if (outputFieldList.length === 0) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: translate('output-fields.empty-list'), + }); + } else if (outputFieldList.includes('*') && outputFieldList.length > 1) { + const wildcardIndex = + pos.valueStart + parsedFields.output_fields.indexOf('*'); + diagnostics.push({ + from: wildcardIndex, + to: wildcardIndex + 1, + severity: 'error', + message: translate('output-fields.wildcard-cannot-combine'), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + } else { + validateOutputFieldsField( + schema, + diagnostics, + outputFieldList, + validatedFields, + pos, + translate, + ); + } + } currentPos += line.length + 1; // +1 for newline }); @@ -606,7 +645,13 @@ const validateIdsField = ( } if (seenIdType === 'template') { if (idsList.length > 1) { - seenIdType = 'template-multiple'; + diagnostics.push({ + from: idsPos.valueStart, + to: idsPos.valueEnd, + severity: 'error', + message: translate('ids.templates-cannot-combine'), + }); + return; } else if ( idsList[0] === '{{.Account.Id}}' || idsList[0] === '{{account.id}}' @@ -621,12 +666,6 @@ const validateIdsField = ( const getIdActionsForResourceType = (schema, resourceType) => { switch (resourceType) { - case 'template-multiple': - return [ - '*', - ...schema.resourcesByType['account'].idActions, - ...schema.resourcesByType['user'].idActions, - ]; case 'account-template': return ['*', ...schema.resourcesByType['account'].idActions]; case 'user-template': @@ -761,6 +800,87 @@ const validateActionsField = ( } }; +const validateOutputFieldsField = ( + schema, + diagnostics, + outputFieldList, + validatedFields, + pos, + translate, +) => { + let validOutputFields = ['*']; + let errorMessage = (field) => + translate('output-fields.invalid-field', { field }); + + if (validatedFields.type?.value && !validatedFields.type.wildcard) { + const resourceOutputFields = + schema.resourcesByType[validatedFields.type.value]?.outputFields ?? []; + validOutputFields = ['*', ...resourceOutputFields]; + errorMessage = (field) => + translate('output-fields.invalid-field-for-type', { + field, + type: validatedFields.type.value, + }); + } else if (validatedFields.type?.wildcard && validatedFields.ids?.knownType) { + // Pinned IDs with wildcard type: validate against union of child types' output fields + const parentType = validatedFields.ids.knownType; + const childTypes = schema.childResourceTypesByParentType[parentType] ?? []; + const childOutputFields = [ + ...new Set( + childTypes.flatMap( + (type) => schema.resourcesByType[type]?.outputFields ?? [], + ), + ), + ]; + validOutputFields = ['*', ...childOutputFields]; + errorMessage = (field) => + translate('output-fields.invalid-field', { field }); + } else if (validatedFields.ids?.knownType && !validatedFields.type) { + const knownType = validatedFields.ids.knownType; + const TEMPLATE_RESOURCE_TYPES = { + 'account-template': 'account', + 'user-template': 'user', + }; + const resolvedType = TEMPLATE_RESOURCE_TYPES[knownType] ?? knownType; + const resourceOutputFields = + schema.resourcesByType[resolvedType]?.outputFields ?? []; + validOutputFields = ['*', ...resourceOutputFields]; + errorMessage = (field) => + translate('output-fields.invalid-field-for-type', { + field, + type: resolvedType, + }); + } + + let segmentStart = pos.valueStart; + for (const [i, field] of outputFieldList.entries()) { + let segmentEnd = segmentStart + field.length; + if (i < outputFieldList.length - 1) { + segmentEnd += 1; + } + if (outputFieldList.slice(i + 1).includes(field)) { + diagnostics.push({ + from: segmentStart, + to: segmentEnd, + severity: 'error', + message: translate('output-fields.duplicate-field', { field }), + actions: [createDeleteTextAction(translate('editor.remove'))], + }); + return; + } + if (!validOutputFields.includes(field)) { + diagnostics.push({ + from: pos.valueStart, + to: pos.valueEnd, + severity: 'error', + message: errorMessage(field), + }); + return; + } + segmentStart += field.length + 1; + } +}; + export const createGrantLinter = (grantsSchema, translate) => { const schema = normalizeGrantsSchema(grantsSchema); return (context) => grantLinter(context, schema, translate); diff --git a/ui/admin/tests/unit/utils/grant-completions-test.js b/ui/admin/tests/unit/utils/grant-completions-test.js index 4d6180b5f..c9e9f9af3 100644 --- a/ui/admin/tests/unit/utils/grant-completions-test.js +++ b/ui/admin/tests/unit/utils/grant-completions-test.js @@ -11,9 +11,6 @@ import { setupMirage } from 'admin/tests/helpers/mirage'; import { createGrantCompletionSource, analyzeGrantString, - getChildResourceActions, - getCompatibleResourceTypeForIds, - normalizeGrantsSchema, } from 'admin/utils/grant-completions'; const createCompletionContext = (lineText) => ({ @@ -185,7 +182,10 @@ module('Unit | Utils | grant-completions', function (hooks) { label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label, ); - assert.deepEqual(getLabels(completionResult), resolvedLabels); + assert.deepEqual( + [...getLabels(completionResult)].sort(), + [...resolvedLabels].sort(), + ); }, ); @@ -389,7 +389,10 @@ module('Unit | Utils | grant-completions', function (hooks) { label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label, ); - assert.deepEqual(getLabels(completionResult), resolvedLabels); + assert.deepEqual( + [...getLabels(completionResult)].sort(), + [...resolvedLabels].sort(), + ); }, ); @@ -404,21 +407,129 @@ module('Unit | Utils | grant-completions', function (hooks) { lineText: 'ids=', expectedLabels: ['*', '{{.User.Id}}', '{{.Account.Id}}'], }, - 'filter wildcard once an id is already entered': { - lineText: 'ids={{.User.Id}},', - expectedLabels: ['{{.Account.Id}}'], + 'filter out wildcard and templates once an id is already entered': { + lineText: 'ids=h_123,', + expectedLabels: ['NO_SUGGESTIONS'], }, - 'filter already-entered template ids when completing a partial': { - lineText: 'ids={{.User.Id}},{{', - expectedLabels: ['{{.Account.Id}}'], + 'filter out wildcard and templates once a template is already entered': { + lineText: 'ids={{.User.Id}},', + expectedLabels: ['NO_SUGGESTIONS'], }, }, async function (assert, { lineText, expectedLabels }) { const completionResult = await this.grantCompletionSource( createCompletionContext(lineText), ); + const resolvedLabels = expectedLabels.map((label) => + label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label, + ); - assert.deepEqual(getLabels(completionResult), expectedLabels); + assert.deepEqual(getLabels(completionResult), resolvedLabels); + }, + ); + + test.each( + 'output_fields suggestions', + { + 'with a specific type suggests wildcard and that type output fields': { + lineText: 'type=target;output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'target', + ); + return ['*', ...output_fields]; + }, + }, + 'with specific ids infers the type and suggests its output fields': { + lineText: 'ids=ttcp_123;output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'target', + ); + return ['*', ...output_fields]; + }, + }, + 'with a user template id suggests user output fields': { + lineText: 'ids={{.User.Id}};output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'user', + ); + return ['*', ...output_fields]; + }, + }, + 'with the user.id template suggests user output fields': { + lineText: 'ids={{user.id}};output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'user', + ); + return ['*', ...output_fields]; + }, + }, + 'with an account template id suggests account output fields': { + lineText: 'ids={{.Account.Id}};output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'account', + ); + return ['*', ...output_fields]; + }, + }, + 'with the account.id template suggests account output fields': { + lineText: 'ids={{account.id}};output_fields=', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'account', + ); + return ['*', ...output_fields]; + }, + }, + 'mixed user and account template ids suggests only wildcard': { + lineText: 'ids={{.User.Id}},{{.Account.Id}};output_fields=', + getExpected: () => ['*'], + }, + 'pinned parent id with wildcard type suggests union of child output fields': + { + lineText: 'ids=hcst_123;type=*;output_fields=', + getExpected: (schema) => { + const childFields = schema.resource_types + .filter((r) => r.parent_type === 'host-catalog') + .flatMap((r) => r.output_fields); + return ['*', ...new Set(childFields)]; + }, + }, + 'excludes already entered output fields': { + lineText: 'type=target;output_fields=id,', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'target', + ); + return output_fields.filter((f) => f !== 'id'); + }, + }, + 'partial prefix filters output fields': { + lineText: 'type=target;output_fields=sc', + getExpected: (schema) => { + const { output_fields } = schema.resource_types.find( + (r) => r.type === 'target', + ); + return output_fields.filter((f) => f.startsWith('sc')); + }, + }, + 'without type or ids suggests only wildcard': { + lineText: 'output_fields=', + getExpected: () => ['*'], + }, + }, + async function (assert, { lineText, getExpected }) { + const completionResult = await this.grantCompletionSource( + createCompletionContext(lineText), + ); + const labels = getLabels(completionResult); + const expected = getExpected(this.grantsSchemaData); + + assert.deepEqual([...labels].sort(), [...expected].sort()); }, ); @@ -484,42 +595,6 @@ module('Unit | Utils | grant-completions', function (hooks) { assert.deepEqual(result.actions, []); }); - test('getCompatibleResourceTypeForIds returns the matched type for a known id prefix', function (assert) { - const schema = normalizeGrantsSchema(this.grantsSchemaData); - - assert.strictEqual( - getCompatibleResourceTypeForIds(schema, 'hs_123'), - 'host-set', - ); - }); - - test('getCompatibleResourceTypeForIds returns null for an unknown id prefix', function (assert) { - const schema = normalizeGrantsSchema(this.grantsSchemaData); - - assert.strictEqual( - getCompatibleResourceTypeForIds(schema, 'bad_123'), - null, - ); - }); - - test('getCompatibleResourceTypeForIds returns null for mixed resource type ids', function (assert) { - const schema = normalizeGrantsSchema(this.grantsSchemaData); - - assert.strictEqual( - getCompatibleResourceTypeForIds(schema, 'hs_123,s_123'), - null, - ); - }); - - test('getCompatibleResourceTypeForIds returns the type when all ids share the same resource type', function (assert) { - const schema = normalizeGrantsSchema(this.grantsSchemaData); - - assert.strictEqual( - getCompatibleResourceTypeForIds(schema, 'hs_123,hs_456'), - 'host-set', - ); - }); - test('analyzeGrantString.actions returns the same actions as the completion source uses', function (assert) { const { actions } = analyzeGrantString( this.grantsSchemaData, @@ -583,21 +658,6 @@ module('Unit | Utils | grant-completions', function (hooks) { assert.true(result.hasInvalidType); }); - test('getChildResourceActions returns the union of child resource actions for a pinned parent id', function (assert) { - const schema = normalizeGrantsSchema(this.grantsSchemaData); - - assert.deepEqual(getChildResourceActions(schema, 'hcst_1234567890'), [ - 'create', - 'list', - 'read', - 'update', - 'delete', - 'add-hosts', - 'set-hosts', - 'remove-hosts', - ]); - }); - test.each( 'analyzeGrantString.hasInvalidType', { @@ -650,6 +710,10 @@ module('Unit | Utils | grant-completions', function (hooks) { grantString: 'ids={{.User.Id}}', expected: false, }, + 'returns false for multiple ids sharing the same resource type': { + grantString: 'ids=hs_123,hs_456', + expected: false, + }, }, function (assert, { grantString, expected }) { const { hasInvalidIds } = analyzeGrantString( @@ -692,6 +756,7 @@ module('Unit | Utils | grant-completions', function (hooks) { assert.strictEqual(hasInvalidPinnedIdTypeCombination, expected); }, ); + module('id auto completion', function () { test('idLookup results are included alongside static options', async function (assert) { const source = createGrantCompletionSource( @@ -847,9 +912,9 @@ module('Unit | Utils | grant-completions', function (hooks) { this.translate, async (partial, compatibleResourceTypes) => { assert.deepEqual(compatibleResourceTypes, [ + 'credential-store', 'auth-method', 'host-catalog', - 'credential-store', ]); return []; }, @@ -879,6 +944,19 @@ module('Unit | Utils | grant-completions', function (hooks) { assert.true(getLabels(result).includes('hcst_parentid')); }); + test('no ID suggestions are shown when a template id is already entered', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [{ id: 'u_abc123', name: 'My User' }], + () => false, + ); + + const result = await source(createCompletionContext('ids={{.User.Id}},')); + + assert.deepEqual(getLabels(result), [this.translate('no-suggestions')]); + }); + test('already-entered IDs are not offered again', async function (assert) { const source = createGrantCompletionSource( this.grantsSchemaData, diff --git a/ui/admin/tests/unit/utils/grant-linter-test.js b/ui/admin/tests/unit/utils/grant-linter-test.js index 012a3ae11..3e4fcfd1b 100644 --- a/ui/admin/tests/unit/utils/grant-linter-test.js +++ b/ui/admin/tests/unit/utils/grant-linter-test.js @@ -222,7 +222,10 @@ module('Unit | Utility | grant-linter', function (hooks) { ['ids=*;type=*;actions=read'], // valid wildcard id ['ids=hcst_123,hcplg_456;type=host-set;actions=read'], // valid pinned ids for host-set resource type ['ids={{.Account.Id}};actions=read'], // valid template id when type is not specified - ['ids={{.Account.Id}},{{.User.Id}};actions=read'], // valid template ids when type is not specified + [ + 'ids={{.Account.Id}},{{.User.Id}};actions=read', + 'Template ids cannot be combined with each other', + ], // mixed template ids is not allowed ['ids=csst_1234,csvlt_5678;actions=*'], // valid id prefixes with same type ['ids=amldap_123,amoidc_456;type=*;actions=read,list'], // valid ids with wildcard type ], @@ -236,4 +239,72 @@ module('Unit | Utility | grant-linter', function (hooks) { } }, ); + + test.each( + 'diagnostics for output_fields field values', + [ + ['type=target;output_fields=,', 'Invalid syntax'], + [ + 'type=target;output_fields=*,id', + 'Wildcard output field "*" cannot be combined with other fields', + ], + ['type=target;output_fields=id,id', 'Duplicate output field "id"'], + [ + 'type=target;output_fields=invalid_field', + 'Invalid output field "invalid_field" for type "target"', + ], + [ + 'ids=ttcp_123;output_fields=member_ids', + 'Invalid output field "member_ids" for type "target"', + ], + ['ids=hcst_123;type=*;actions=read;output_fields=host_catalog_id'], // valid: child type (host) output field + ['ids=hcst_123;type=*;actions=read;output_fields=host_set_ids'], // valid: child type (host) output field + [ + 'ids=hcst_123;type=*;actions=read;output_fields=session_max_seconds', + 'Invalid output field "session_max_seconds"', + ], // invalid: not a host or host-set field + [ + 'ids=*;type=*;actions=read;output_fields=id', + 'Invalid output field "id"', + ], // no specific type context, specific field is invalid + ['type=target;output_fields=id,name'], // valid output fields + ['type=target;output_fields=*'], // valid wildcard + ['ids=ttcp_123;output_fields=scope_id'], // valid inferred type + ['ids={{.User.Id}};output_fields=email'], // valid user template output field + ['ids={{user.id}};output_fields=email'], // valid user template alias output field + ['ids={{.Account.Id}};output_fields=auth_method_id'], // valid account template output field + ['ids={{account.id}};output_fields=auth_method_id'], // valid account template alias output field + [ + 'ids={{.User.Id}};output_fields=auth_method_id', + 'Invalid output field "auth_method_id" for type "user"', + ], // account field is invalid for user template + [ + 'ids={{.Account.Id}};output_fields=email', + 'Invalid output field "email" for type "account"', + ], // user field is invalid for account template + [ + 'ids={{.User.Id}},{{.Account.Id}};output_fields=email', + 'Template ids cannot be combined with each other', + ], // ids error fires first; mixed templates are disallowed + [ + 'ids={{.User.Id}},{{.Account.Id}};output_fields=auth_method_id', + 'Template ids cannot be combined with each other', + ], // ids error fires first; mixed templates are disallowed + [ + 'ids={{.User.Id}},{{.Account.Id}};output_fields=session_max_seconds', + 'Template ids cannot be combined with each other', + ], // ids error fires first; mixed templates are disallowed + ['type=target;output_fields=id,scope_id,name'], // valid multiple output fields + ['ids=*;type=*;actions=read;output_fields=*'], // wildcard output fields with wildcard type is valid + ], + function (assert, [grantString, errorMsg]) { + const diagnostics = this.grantLinter(createContext(grantString)); + + if (errorMsg) { + assert.strictEqual(diagnostics[0].message, errorMsg); + } else { + assert.strictEqual(diagnostics.length, 0); + } + }, + ); });