feat: 🎸 Add support for output fields (#3268)

* feat: 🎸 Add support for output fields

* refactor: 💡 PR feedback
This commit is contained in:
Zhihe Li committed 2026-05-14 15:21:30 -04:00
1 parent e0b80b1176
commit 7fa59bfd5d
6 files changed
+970 -254

No files matched your search

+552 -168
View File
@@ -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',
],
},
],
};
@@ -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
+70 -14
View File
@@ -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'))],
};
}
+127 -7
View File
@@ -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);
@@ -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,
+72 -1
View File
@@ -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);
}
},
);
});