feat(ui/ingest): ingestion form for Okta and AzureAD (#9829)

This commit is contained in:
gaurav2733 2024-03-19 20:50:46 +05:30 committed by GitHub
parent 64cb5d1cb2
commit 11f6ab64c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 428 additions and 16 deletions

View File

@ -0,0 +1,26 @@
import { validateURL } from '../../../utils';
describe('validateURL function', () => {
it('should resolve if the URL is valid', async () => {
const validator = validateURL('test');
await expect(validator.validator(null, 'https://example.com')).resolves.toBeUndefined();
await expect(validator.validator(null, 'http://example.com')).resolves.toBeUndefined();
await expect(validator.validator(null, 'http://subdomain.example.com/path')).resolves.toBeUndefined();
});
it('should reject if the URL is invalid', async () => {
const validator = validateURL('test url');
await expect(validator.validator(null, 'http://example')).rejects.toThrowError('A valid test url is required.');
await expect(validator.validator(null, 'example')).rejects.toThrowError('A valid test url is required.');
await expect(validator.validator(null, 'http://example')).rejects.toThrowError(
'A valid test url is required.',
);
});
it('should resolve if the value is empty', async () => {
const validator = validateURL('test');
await expect(validator.validator(null, '')).resolves.toBeUndefined();
await expect(validator.validator(null, undefined)).resolves.toBeUndefined();
await expect(validator.validator(null, null)).resolves.toBeUndefined();
});
});

View File

@ -0,0 +1,171 @@
import { validateURL } from '../../utils';
import { RecipeField, FieldType, setListValuesOnRecipe } from './common';
export const AZURE_CLIENT_ID: RecipeField = {
name: 'client_id',
label: 'Client ID',
tooltip: 'Application ID. Found in your app registration on Azure AD Portal',
type: FieldType.TEXT,
fieldPath: 'source.config.client_id',
placeholder: '00000000-0000-0000-0000-000000000000',
required: true,
rules: null,
};
export const AZURE_TENANT_ID: RecipeField = {
name: 'tenant_id',
label: 'Tenant ID',
tooltip: 'Directory ID. Found in your app registration on Azure AD Portal',
type: FieldType.TEXT,
fieldPath: 'source.config.tenant_id',
placeholder: '00000000-0000-0000-0000-000000000000',
required: true,
rules: null,
};
export const AZURE_CLIENT_SECRET: RecipeField = {
name: 'client_secret',
label: 'Client Secret',
tooltip: 'The Azure client secret.',
type: FieldType.SECRET,
fieldPath: 'source.config.client_secret',
placeholder: '00000000-0000-0000-0000-000000000000',
required: true,
rules: null,
};
export const AZURE_REDIRECT_URL: RecipeField = {
name: 'redirect',
label: 'Redirect URL',
tooltip: 'Redirect URL. Found in your app registration on Azure AD Portal.',
type: FieldType.TEXT,
fieldPath: 'source.config.redirect',
placeholder: 'https://login.microsoftonline.com/common/oauth2/nativeclient',
required: true,
rules: [() => validateURL('Redirect URI')],
};
export const AZURE_AUTHORITY_URL: RecipeField = {
name: 'authority',
label: 'Authority URL',
tooltip: 'Is a URL that indicates a directory that MSAL can request tokens from..',
type: FieldType.TEXT,
fieldPath: 'source.config.authority',
placeholder: 'https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000',
required: true,
rules: [() => validateURL('Azure authority URL')],
};
export const AZURE_TOKEN_URL: RecipeField = {
name: 'token_url',
label: 'Token URL',
tooltip:
'The token URL that acquires a token from Azure AD for authorizing requests. This source will only work with v1.0 endpoint.',
type: FieldType.TEXT,
fieldPath: 'source.config.token_url',
placeholder: 'https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/token',
required: true,
rules: [() => validateURL('Azure token URL')],
};
export const AZURE_GRAPH_URL: RecipeField = {
name: 'graph_url',
label: 'Graph URL',
tooltip: 'Microsoft Graph API endpoint',
type: FieldType.TEXT,
fieldPath: 'source.config.graph_url',
placeholder: 'https://graph.microsoft.com/v1.0',
required: true,
rules: [() => validateURL('Graph url URL')],
};
export const AZURE_INGEST_USERS: RecipeField = {
name: 'ingest_users',
label: 'Ingest Users',
tooltip: 'Flag to determine whether to ingest users from Azure AD or not.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.ingest_users',
rules: null,
};
export const AZURE_INGEST_GROUPS: RecipeField = {
name: 'ingest_groups',
label: 'Ingest Groups',
tooltip: 'Flag to determine whether to ingest groups from Azure AD or not.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.ingest_groups',
rules: null,
};
const schemaAllowFieldPathGroup = 'source.config.groups_pattern.allow';
export const GROUP_ALLOW: RecipeField = {
name: 'groups.allow',
label: 'Allow Patterns',
tooltip:
'Only include specific schemas by providing the name of a schema, or a regular expression (regex) to include specific schemas. If not provided, all schemas inside allowed databases will be included.',
placeholder: 'group_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaAllowFieldPathGroup,
rules: null,
section: 'Group',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaAllowFieldPathGroup),
};
const schemaDenyFieldPathGroup = 'source.config.groups_pattern.deny';
export const GROUP_DENY: RecipeField = {
name: 'groups.deny',
label: 'Deny Patterns',
tooltip:
'Exclude specific schemas by providing the name of a schema, or a regular expression (regex). If not provided, all schemas inside allowed databases will be included. Deny patterns always take precedence over allow patterns.',
placeholder: 'user_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaDenyFieldPathGroup,
rules: null,
section: 'Group',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaDenyFieldPathGroup),
};
const schemaAllowFieldPathUser = 'source.config.users_pattern.allow';
export const USER_ALLOW: RecipeField = {
name: 'user.allow',
label: 'Allow Patterns',
tooltip:
'Exclude specific schemas by providing the name of a schema, or a regular expression (regex). If not provided, all schemas inside allowed databases will be included. Deny patterns always take precedence over allow patterns.',
placeholder: 'user_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaAllowFieldPathUser,
rules: null,
section: 'User',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaAllowFieldPathUser),
};
const schemaDenyFieldPathUser = 'source.config.users_pattern.deny';
export const USER_DENY: RecipeField = {
name: 'user.deny',
label: 'Deny Patterns',
tooltip:
'Exclude specific schemas by providing the name of a schema, or a regular expression (regex). If not provided, all schemas inside allowed databases will be included. Deny patterns always take precedence over allow patterns.',
placeholder: 'user_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaDenyFieldPathUser,
rules: null,
section: 'User',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaDenyFieldPathUser),
};
export const SKIP_USERS_WITHOUT_GROUP: RecipeField = {
name: 'skip_users_without_a_group',
label: 'Skip users without group',
tooltip: 'Whether to skip users without group from Okta.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.skip_users_without_a_group',
rules: null,
};

View File

@ -83,7 +83,7 @@ import {
PROJECT_NAME,
} from './lookml';
import { PRESTO, PRESTO_HOST_PORT, PRESTO_DATABASE, PRESTO_USERNAME, PRESTO_PASSWORD } from './presto';
import { BIGQUERY_BETA, CSV, DBT_CLOUD, MYSQL, POWER_BI, UNITY_CATALOG, VERTICA } from '../constants';
import { AZURE, BIGQUERY_BETA, CSV, DBT_CLOUD, MYSQL, OKTA, POWER_BI, UNITY_CATALOG, VERTICA } from '../constants';
import { BIGQUERY_BETA_PROJECT_ID, DATASET_ALLOW, DATASET_DENY, PROJECT_ALLOW, PROJECT_DENY } from './bigqueryBeta';
import { MYSQL_HOST_PORT, MYSQL_PASSWORD, MYSQL_USERNAME } from './mysql';
import { MSSQL, MSSQL_DATABASE, MSSQL_HOST_PORT, MSSQL_PASSWORD, MSSQL_USERNAME } from './mssql';
@ -141,6 +141,36 @@ import {
INCLUDE_PROJECTIONS_LINEAGE,
} from './vertica';
import { CSV_ARRAY_DELIMITER, CSV_DELIMITER, CSV_FILE_URL, CSV_WRITE_SEMANTICS } from './csv';
import {
INCLUDE_DEPROVISIONED_USERS,
INCLUDE_SUSPENDED_USERS,
INGEST_GROUPS,
INGEST_USERS,
OKTA_API_TOKEN,
OKTA_DOMAIN_URL,
POFILE_TO_GROUP,
POFILE_TO_GROUP_REGX_ALLOW,
POFILE_TO_GROUP_REGX_DENY,
POFILE_TO_USER,
POFILE_TO_USER_REGX_ALLOW,
POFILE_TO_USER_REGX_DENY,
SKIP_USERS_WITHOUT_GROUP,
} from './okta';
import {
AZURE_AUTHORITY_URL,
AZURE_CLIENT_ID,
AZURE_CLIENT_SECRET,
AZURE_GRAPH_URL,
AZURE_INGEST_GROUPS,
AZURE_INGEST_USERS,
AZURE_REDIRECT_URL,
AZURE_TENANT_ID,
AZURE_TOKEN_URL,
GROUP_ALLOW,
GROUP_DENY,
USER_ALLOW,
USER_DENY,
} from './azure';
export enum RecipeSections {
Connection = 0,
@ -459,6 +489,36 @@ export const RECIPE_FIELDS: RecipeFields = {
filterFields: [],
advancedFields: [CSV_ARRAY_DELIMITER, CSV_DELIMITER, CSV_WRITE_SEMANTICS],
},
[OKTA]: {
fields: [OKTA_DOMAIN_URL, OKTA_API_TOKEN, POFILE_TO_USER, POFILE_TO_GROUP],
filterFields: [
POFILE_TO_USER_REGX_ALLOW,
POFILE_TO_USER_REGX_DENY,
POFILE_TO_GROUP_REGX_ALLOW,
POFILE_TO_GROUP_REGX_DENY,
],
advancedFields: [
INGEST_USERS,
INGEST_GROUPS,
INCLUDE_DEPROVISIONED_USERS,
INCLUDE_SUSPENDED_USERS,
STATEFUL_INGESTION_ENABLED,
SKIP_USERS_WITHOUT_GROUP,
],
},
[AZURE]: {
fields: [
AZURE_CLIENT_ID,
AZURE_TENANT_ID,
AZURE_CLIENT_SECRET,
AZURE_REDIRECT_URL,
AZURE_AUTHORITY_URL,
AZURE_TOKEN_URL,
AZURE_GRAPH_URL,
],
filterFields: [GROUP_ALLOW, GROUP_DENY, USER_ALLOW, USER_DENY],
advancedFields: [AZURE_INGEST_USERS, AZURE_INGEST_GROUPS, STATEFUL_INGESTION_ENABLED, SKIP_USERS_WITHOUT_GROUP],
},
};
export const CONNECTORS_WITH_FORM = new Set(Object.keys(RECIPE_FIELDS));

View File

@ -1,18 +1,6 @@
import { validateURL } from '../../utils';
import { RecipeField, FieldType } from './common';
const validateURL = (fieldName) => {
return {
validator(_, value) {
const URLPattern = new RegExp(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/);
const isURLValid = URLPattern.test(value);
if (!value || isURLValid) {
return Promise.resolve();
}
return Promise.reject(new Error(`A valid ${fieldName} is required.`));
},
};
};
export const CSV_FILE_URL: RecipeField = {
name: 'filename',
label: 'File URL',

View File

@ -0,0 +1,153 @@
import { validateURL } from '../../utils';
import { RecipeField, FieldType, setListValuesOnRecipe } from './common';
export const OKTA_DOMAIN_URL: RecipeField = {
name: 'okta_domain',
label: 'Okta Domain URL',
tooltip: 'The location of your Okta Domain, without a protocol.',
type: FieldType.TEXT,
fieldPath: 'source.config.okta_domain',
placeholder: 'dev-35531955.okta.com',
required: true,
rules: [() => validateURL('Okta Domain URL')],
};
export const OKTA_API_TOKEN: RecipeField = {
name: 'credential.project_id',
label: 'Token',
tooltip: 'An API token generated for the DataHub application inside your Okta Developer Console.',
type: FieldType.SECRET,
fieldPath: 'source.config.okta_api_token',
placeholder: 'd0121d0000882411234e11166c6aaa23ed5d74e0',
rules: null,
required: true,
};
export const POFILE_TO_USER: RecipeField = {
name: 'email',
label: 'Okta Email',
tooltip:
'Which Okta User Profile attribute to use as input to DataHub username mapping. Common values used are - login, email.',
type: FieldType.TEXT,
fieldPath: 'source.config.okta_profile_to_username_attr',
placeholder: 'email',
rules: null,
};
export const POFILE_TO_GROUP: RecipeField = {
name: 'okta_profile_to_group_name_attr',
label: 'Okta Profile to group name attribute',
tooltip: 'Which Okta Group Profile attribute to use as input to DataHub group name mapping.',
type: FieldType.TEXT,
fieldPath: 'source.config.okta_profile_to_group_name_attr',
placeholder: 'Group name',
rules: null,
};
const schemaAllowFieldPath = 'source.config.okta_profile_to_username_regex.allow';
export const POFILE_TO_USER_REGX_ALLOW: RecipeField = {
name: 'user.allow',
label: 'Allow Patterns',
tooltip:
'Only include specific schemas by providing the name of a schema, or a regular expression (regex) to include specific schemas. If not provided, all schemas inside allowed databases will be included.',
placeholder: 'user_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaAllowFieldPath,
rules: null,
section: 'Okta Profile To User Attribute Regex',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaAllowFieldPath),
};
const schemaDenyFieldPath = 'source.config.okta_profile_to_username_regex.deny';
export const POFILE_TO_USER_REGX_DENY: RecipeField = {
name: 'user.deny',
label: 'Deny Patterns',
tooltip:
'Only include specific schemas by providing the name of a schema, or a regular expression (regex) to include specific schemas. If not provided, all schemas inside allowed databases will be included.',
placeholder: 'user_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaDenyFieldPath,
rules: null,
section: 'Okta Profile To User Attribute Regex',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaDenyFieldPath),
};
const schemaAllowFieldPathForGroup = 'source.config.okta_profile_to_group_name_regex.allow';
export const POFILE_TO_GROUP_REGX_ALLOW: RecipeField = {
name: 'group.allow',
label: 'Allow Patterns',
tooltip:
'Only include specific schemas by providing the name of a schema, or a regular expression (regex) to include specific schemas. If not provided, all schemas inside allowed databases will be included.',
placeholder: 'group_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaAllowFieldPathForGroup,
rules: null,
section: 'Okta Profile To Group Attribute Regex',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaAllowFieldPathForGroup),
};
const schemaDenyFieldPathForGroup = 'source.config.okta_profile_to_group_name_regex.deny';
export const POFILE_TO_GROUP_REGX_DENY: RecipeField = {
name: 'group.deny',
label: 'Deny Patterns',
tooltip:
'Only include specific schemas by providing the name of a schema, or a regular expression (regex) to include specific schemas. If not provided, all schemas inside allowed databases will be included.',
placeholder: 'group_pattern',
type: FieldType.LIST,
buttonLabel: 'Add pattern',
fieldPath: schemaDenyFieldPathForGroup,
rules: null,
section: 'Okta Profile To Group Attribute Regex',
setValueOnRecipeOverride: (recipe: any, values: string[]) =>
setListValuesOnRecipe(recipe, values, schemaDenyFieldPathForGroup),
};
export const INGEST_USERS: RecipeField = {
name: 'ingest_users',
label: 'Ingest Users',
tooltip: 'Whether users should be ingested into DataHub.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.ingest_users',
rules: null,
};
export const INGEST_GROUPS: RecipeField = {
name: 'ingest_groups',
label: 'Ingest Groups',
tooltip: 'Whether groups should be ingested into DataHub.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.ingest_groups',
rules: null,
};
export const INCLUDE_DEPROVISIONED_USERS: RecipeField = {
name: 'include_deprovisioned_users',
label: 'Include deprovisioned users',
tooltip: 'Whether to ingest users in the DEPROVISIONED state from Okta.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.include_deprovisioned_users',
rules: null,
};
export const INCLUDE_SUSPENDED_USERS: RecipeField = {
name: 'include_suspended_users',
label: 'Include suspended users',
tooltip: 'Whether to ingest users in the SUSPENDED state from Okta.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.include_suspended_users',
rules: null,
};
export const SKIP_USERS_WITHOUT_GROUP: RecipeField = {
name: 'skip_users_without_a_group',
label: 'Skip users without group',
tooltip: 'Whether to skip users without group from Okta.',
type: FieldType.BOOLEAN,
fieldPath: 'source.config.skip_users_without_a_group',
rules: null,
};

View File

@ -200,14 +200,14 @@
"name": "azure-ad",
"displayName": "Azure AD",
"docsUrl": "https://datahubproject.io/docs/generated/ingestion/sources/azure-ad/",
"recipe": "source:\n type: azure-ad\n config:\n client_id: # Your Azure Client ID, e.g. \"00000000-0000-0000-0000-000000000000\"\n tenant_id: # Your Azure Tenant ID, e.g. \"00000000-0000-0000-0000-000000000000\"\n # Add secret in Secrets Tab with this name\n client_secret: \"${AZURE_AD_CLIENT_SECRET}\"\n redirect: # Your Redirect URL, e.g. \"https://login.microsoftonline.com/common/oauth2/nativeclient\"\n authority: # Your Authority URL, e.g. \"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000\"\n token_url: # Your Token URL, e.g. \"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/token\"\n graph_url: # The Graph URL, e.g. \"https://graph.microsoft.com/v1.0\"\n \n # Optional flags to ingest users, groups, or both\n ingest_users: True\n ingest_groups: True\n \n # Optional Allow / Deny extraction of particular Groups\n # groups_pattern:\n # allow:\n # - \".*\"\n\n # Optional Allow / Deny extraction of particular Users.\n # users_pattern:\n # allow:\n # - \".*\""
"recipe": "source:\n type: azure-ad\n config:\n client_id: # Your Azure Client ID, e.g. \"00000000-0000-0000-0000-000000000000\"\n tenant_id: # Your Azure Tenant ID, e.g. \"00000000-0000-0000-0000-000000000000\"\n # Add secret in Secrets Tab with this name\n client_secret: \n redirect: # Your Redirect URL, e.g. \"https://login.microsoftonline.com/common/oauth2/nativeclient\"\n authority: # Your Authority URL, e.g. \"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000\"\n token_url: # Your Token URL, e.g. \"https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/token\"\n graph_url: # The Graph URL, e.g. \"https://graph.microsoft.com/v1.0\"\n \n # Optional flags to ingest users, groups, or both\n ingest_users: True\n ingest_groups: True\n \n # Optional Allow / Deny extraction of particular Groups\n # groups_pattern:\n # allow:\n # - \".*\"\n\n # Optional Allow / Deny extraction of particular Users.\n # users_pattern:\n # allow:\n # - \".*\""
},
{
"urn": "urn:li:dataPlatform:okta",
"name": "okta",
"displayName": "Okta",
"docsUrl": "https://datahubproject.io/docs/generated/ingestion/sources/okta/",
"recipe": "source:\n type: okta\n config:\n # Coordinates\n okta_domain: # Your Okta Domain, e.g. \"dev-35531955.okta.com\"\n\n # Credentials\n # Add secret in Secrets Tab with relevant names for each variable\n okta_api_token: \"${OKTA_API_TOKEN}\" # Your Okta API Token, e.g. \"11be4R_M2MzDqXawbTHfKGpKee0kuEOfX1RCQSRx99\"\n\n # Optional flags to ingest users, groups, or both\n ingest_users: True\n ingest_groups: True\n\n # Optional: Customize the mapping to DataHub Username from an attribute appearing in the Okta User\n # profile. Reference: https://developer.okta.com/docs/reference/api/users/\n # okta_profile_to_username_attr: str = \"login\"\n # okta_profile_to_username_regex: str = \"([^@]+)\"\n \n # Optional: Customize the mapping to DataHub Group from an attribute appearing in the Okta Group\n # profile. Reference: https://developer.okta.com/docs/reference/api/groups/\n # okta_profile_to_group_name_attr: str = \"name\"\n # okta_profile_to_group_name_regex: str = \"(.*)\"\n \n # Optional: Include deprovisioned or suspended Okta users in the ingestion.\n # include_deprovisioned_users = False\n # include_suspended_users = False"
"recipe": "source:\n type: okta\n config:\n # Coordinates\n okta_domain: # Your Okta Domain, e.g. \"dev-35531955.okta.com\"\n\n # Credentials\n # Add secret in Secrets Tab with relevant names for each variable\n okta_api_token: # Your Okta API Token, e.g. \"11be4R_M2MzDqXawbTHfKGpKee0kuEOfX1RCQSRx99\"\n\n # Optional flags to ingest users, groups, or both\n ingest_users: True\n ingest_groups: True\n\n # Optional: Customize the mapping to DataHub Username from an attribute appearing in the Okta User\n # profile. Reference: https://developer.okta.com/docs/reference/api/users/\n # okta_profile_to_username_attr: str = \"login\"\n # okta_profile_to_username_regex: str = \"([^@]+)\"\n \n # Optional: Customize the mapping to DataHub Group from an attribute appearing in the Okta Group\n # profile. Reference: https://developer.okta.com/docs/reference/api/groups/\n # okta_profile_to_group_name_attr: str = \"name\"\n # okta_profile_to_group_name_regex: str = \"(.*)\"\n \n # Optional: Include deprovisioned or suspended Okta users in the ingestion.\n # include_deprovisioned_users = False\n # include_suspended_users = False"
},
{
"urn": "urn:li:dataPlatform:vertica",

View File

@ -129,6 +129,20 @@ export const getExecutionRequestStatusDisplayColor = (status: string) => {
);
};
export const validateURL = (fieldName: string) => {
return {
validator(_, value) {
const URLPattern = new RegExp(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/);
const isURLValid = URLPattern.test(value);
if (!value || isURLValid) {
return Promise.resolve();
}
return Promise.reject(new Error(`A valid ${fieldName} is required.`));
},
};
};
const ENTITIES_WITH_SUBTYPES = new Set([
EntityType.Dataset.toLowerCase(),
EntityType.Container.toLowerCase(),