From 83fea34fb83260d7ab615a994a01d485a8aa85d5 Mon Sep 17 00:00:00 2001 From: Sloan Sparger <sloansparger@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:07:13 -0700 Subject: [PATCH] [Permissions] Data permissions graph utils reorganization (#47023) * reorganizes data-permissions.ts to be a folder split by a file per concern, moves some logic out into other utils files where it makes sense * pr feedback and fix lints --- .../utils/graph/data-permissions.ts | 672 ------------------ .../utils/graph/data-permissions/get.ts | 195 +++++ .../utils/graph/data-permissions/has.ts | 154 ++++ .../has.unit.spec.ts} | 8 +- .../utils/graph/data-permissions/index.ts | 4 + .../utils/graph/data-permissions/update.ts | 348 +++++++++ .../utils/graph/data-permissions/utils.ts | 8 + .../admin/permissions/utils/metadata.ts | 24 + 8 files changed, 738 insertions(+), 675 deletions(-) delete mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions.ts create mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions/get.ts create mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.ts rename frontend/src/metabase/admin/permissions/utils/graph/{data-permissions.unit.spec.ts => data-permissions/has.unit.spec.ts} (97%) create mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions/index.ts create mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions/update.ts create mode 100644 frontend/src/metabase/admin/permissions/utils/graph/data-permissions/utils.ts diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions.ts deleted file mode 100644 index 063029545b7..00000000000 --- a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { getIn, setIn } from "icepick"; -import _ from "underscore"; - -import { - PLUGIN_ADVANCED_PERMISSIONS, - PLUGIN_DATA_PERMISSIONS, -} from "metabase/plugins"; -import type Database from "metabase-lib/v1/metadata/Database"; -import type Table from "metabase-lib/v1/metadata/Table"; -import type { - ConcreteTableId, - DatabasePermissions, - GroupPermissions, - GroupsPermissions, -} from "metabase-types/api"; - -import type { - DatabaseEntityId, - EntityId, - EntityWithGroupId, - SchemaEntityId, - TableEntityId, -} from "../../types"; -import { DataPermission, DataPermissionValue } from "../../types"; -import { isSchemaEntityId, isTableEntityId } from "../data-entity-id"; - -export const isRestrictivePermission = (value: DataPermissionValue) => - value === DataPermissionValue.NO || - PLUGIN_ADVANCED_PERMISSIONS.isRestrictivePermission(value); - -// permission that do not have a nested shemas/native key -const flatPermissions = new Set([ - DataPermission.DETAILS, - DataPermission.VIEW_DATA, - DataPermission.CREATE_QUERIES, -]); - -// util to ease migration of perms attributes into a flatter structure -function getPermissionPath( - groupId: number, - databaseId: number, - permission: DataPermission, - nestedPath?: Array<string | number>, -) { - const isFlatPermValue = flatPermissions.has(permission); - if (isFlatPermValue) { - return [groupId, databaseId, permission, ...(nestedPath || [])]; - } - return [groupId, databaseId, permission, "schemas", ...(nestedPath || [])]; -} - -const omittedDefaultValues: Record<DataPermission, DataPermissionValue> = { - get [DataPermission.VIEW_DATA]() { - return PLUGIN_ADVANCED_PERMISSIONS.defaultViewDataPermission; - }, - [DataPermission.CREATE_QUERIES]: DataPermissionValue.NO, - [DataPermission.DOWNLOAD]: DataPermissionValue.NONE, - [DataPermission.DATA_MODEL]: DataPermissionValue.NONE, - [DataPermission.DETAILS]: DataPermissionValue.NO, -}; - -function getOmittedPermissionValue( - permission: DataPermission, -): DataPermissionValue { - return omittedDefaultValues[permission] ?? DataPermissionValue.NO; -} - -// returns portion of the graph that might be undefined, -// purposefully does not try to determine the entity's value from its parent -function getRawPermissionsGraphValue( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - permission: DataPermission, -) { - const nestedPath = [ - entityId.schemaName === null ? "" : entityId.schemaName, - entityId.tableId, - ].filter((x): x is number | string => x !== undefined); - const path = getPermissionPath( - groupId, - entityId.databaseId, - permission, - nestedPath, - ); - return getIn(permissions, path); -} - -interface GetPermissionParams { - permissions: GroupsPermissions; - groupId: number; - databaseId: number; - permission: DataPermission; - path?: Array<number | string>; - isControlledType?: boolean; -} - -const getPermission = ({ - permissions, - groupId, - databaseId, - permission, - path, - isControlledType = false, -}: GetPermissionParams): DataPermissionValue => { - const valuePath = getPermissionPath(groupId, databaseId, permission, path); - const value = getIn(permissions, valuePath); - if (isControlledType && typeof value === "object") { - return DataPermissionValue.CONTROLLED; - } - return value ? value : getOmittedPermissionValue(permission); -}; - -export function updatePermission( - permissions: GroupsPermissions, - groupId: number, - databaseId: number, - permission: DataPermission, - path: Array<number | string>, - value: string | undefined, - entityIds?: any[], -) { - const fullPath = getPermissionPath(groupId, databaseId, permission, path); - const current = getIn(permissions, fullPath); - - if ( - current === value || - (current && - typeof current === "object" && - value === DataPermissionValue.CONTROLLED) - ) { - return permissions; - } - let newValue: any; - if (value === DataPermissionValue.CONTROLLED) { - newValue = {}; - if (entityIds) { - for (const entityId of entityIds) { - newValue[entityId] = current; - } - } - } else { - newValue = value; - } - for (let i = 0; i < fullPath.length; i++) { - if (typeof getIn(permissions, fullPath.slice(0, i)) === "string") { - permissions = setIn(permissions, fullPath.slice(0, i), {}); - } - } - return setIn(permissions, fullPath, newValue); -} - -export const getSchemasPermission = ( - permissions: GroupsPermissions, - groupId: number, - { databaseId }: DatabaseEntityId, - permission: DataPermission, -) => { - return getPermission({ - permissions, - databaseId, - groupId, - permission, - isControlledType: true, - }); -}; - -export const getTablesPermission = ( - permissions: GroupsPermissions, - groupId: number, - { databaseId, schemaName }: SchemaEntityId, - permission: DataPermission, -) => { - const schemas = getSchemasPermission( - permissions, - groupId, - { - databaseId, - }, - permission, - ); - if (schemas === DataPermissionValue.CONTROLLED) { - return getPermission({ - permissions, - databaseId, - groupId, - permission, - path: [schemaName ?? ""], - isControlledType: true, - }); - } else { - return schemas; - } -}; - -export const getFieldsPermission = ( - permissions: GroupsPermissions, - groupId: number, - { databaseId, schemaName, tableId }: TableEntityId, - permission: DataPermission, -): DataPermissionValue => { - const tables = getTablesPermission( - permissions, - groupId, - { - databaseId, - schemaName, - }, - permission, - ); - if (tables === DataPermissionValue.CONTROLLED) { - return getPermission({ - permissions, - groupId, - databaseId, - permission, - path: [schemaName || "", tableId], - isControlledType: true, - }); - } else { - return tables; - } -}; - -export const getEntityPermission = ( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - permission: DataPermission, -): DataPermissionValue => { - if (entityId.tableId !== undefined) { - return getFieldsPermission( - permissions, - groupId, - entityId as TableEntityId, - permission, - ); - } else if (entityId.schemaName !== undefined) { - return getTablesPermission( - permissions, - groupId, - entityId as SchemaEntityId, - permission, - ); - } else { - return getSchemasPermission(permissions, groupId, entityId, permission); - } -}; - -// subtypes to make testing easier and avoid using deprecated Database / Schema types -type SchemaPartial = { - name: string; - getTables: () => { id: number | string }[]; -}; -type DatabasePartial = { - schemas?: SchemaPartial[]; - schema(schemaName: string | undefined): SchemaPartial | null | undefined; -}; - -export function hasPermissionValueInSubgraph( - permissions: GroupsPermissions, - groupId: number, - entityId: DatabaseEntityId | SchemaEntityId, - database: DatabasePartial, - permission: DataPermission, - value: DataPermissionValue, -) { - const schemasToSearch = _.compact( - isSchemaEntityId(entityId) - ? [database.schema(entityId.schemaName)] - : database.schemas, - ); - - if (schemasToSearch) { - const hasSchemaWithMatchingPermission = schemasToSearch.some(schema => { - const currVal = getTablesPermission( - permissions, - groupId, - { databaseId: entityId.databaseId, schemaName: schema.name }, - permission, - ); - return value === currVal; - }); - - if (hasSchemaWithMatchingPermission) { - return true; - } - } - - return schemasToSearch.some(schema => { - return schema.getTables().some(table => { - return ( - value === - getFieldsPermission( - permissions, - groupId, - { - databaseId: entityId.databaseId, - schemaName: schema.name, - tableId: table.id as ConcreteTableId, - }, - permission, - ) - ); - }); - }); -} - -// return boolean if able to find if a value is present in all or a portion of the permissions graph -// NOTE: default values are omitted from the graph, and given the way this function was written, it won't return -// the right answer for those permissions. for now, those default values have been omitted from allowed values to avoid bugs -export function hasPermissionValueInGraph( - permissions: - | GroupsPermissions - | GroupPermissions - | DatabasePermissions - | DataPermissionValue, - permissionValue: Omit< - DataPermissionValue, - DataPermissionValue.BLOCKED | DataPermissionValue.NO // omit default values - >, -): boolean { - if (permissions === permissionValue) { - return true; - } - - function _hasPermissionValueInGraph(permissionsGraphSection: any) { - for (const key in permissionsGraphSection) { - const isMatch = permissionsGraphSection[key] === permissionValue; - if (isMatch) { - return true; - } - - const isGraphObjWithMatch = - typeof permissionsGraphSection[key] === "object" && - _hasPermissionValueInGraph(permissionsGraphSection[key]); - if (isGraphObjWithMatch) { - return true; - } - } - - return false; - } - - return _hasPermissionValueInGraph(permissions); -} - -// return boolean if able to find if a value is present in any of the specified portions of the graph -// useful for ignoring certain parts of the graphy you don't care to check -export function hasPermissionValueInEntityGraphs( - permissions: GroupsPermissions, - entityIds: EntityWithGroupId[], - permission: DataPermission, - permissionValue: DataPermissionValue, -): boolean { - return entityIds.some(entityId => { - // try to get the raw section of the graph so we can crawl it's children if it has them - const permissionPortion = getRawPermissionsGraphValue( - permissions, - entityId.groupId, - entityId, - permission, - ); - - if (permissionPortion !== undefined) { - return hasPermissionValueInGraph(permissionPortion, permissionValue); - } - - // the above may be undefined since the entity's value is determined from a parent entity in the graph, - // so we figure that out here and check if it matches what we're looking for - const entityPermission = getEntityPermission( - permissions, - entityId.groupId, - entityId, - permission, - ); - return entityPermission === permissionValue; - }); -} - -export function restrictCreateQueriesPermissionsIfNeeded( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - permission: DataPermission, - value: DataPermissionValue, - database: Database, -) { - const currDbCreateQueriesPermission = getSchemasPermission( - permissions, - groupId, - { databaseId: entityId.databaseId }, - DataPermission.CREATE_QUERIES, - ); - - const isMakingGranularCreateQueriesChange = - permission === DataPermission.CREATE_QUERIES && - value !== DataPermissionValue.QUERY_BUILDER_AND_NATIVE && - (entityId.tableId != null || entityId.schemaName != null) && - currDbCreateQueriesPermission === - DataPermissionValue.QUERY_BUILDER_AND_NATIVE; - - const shouldRestrictForSomeReason = - PLUGIN_DATA_PERMISSIONS.shouldRestrictNativeQueryPermissions( - permissions, - groupId, - entityId, - permission, - value, - database, - ); - - const shouldRestrictNative = - isMakingGranularCreateQueriesChange || shouldRestrictForSomeReason; - - if (shouldRestrictNative) { - const schemaNames = (database && database.schemaNames()) ?? [null]; - - schemaNames.forEach(schemaName => { - permissions = updateTablesPermission( - permissions, - groupId, - { - databaseId: entityId.databaseId, - schemaName, - }, - DataPermissionValue.QUERY_BUILDER, - database, - DataPermission.CREATE_QUERIES, - ); - }); - } - - if ( - isRestrictivePermission(value) || - value === DataPermissionValue.LEGACY_NO_SELF_SERVICE - ) { - permissions = updateEntityPermission( - permissions, - groupId, - entityId, - DataPermissionValue.NO, - database, - DataPermission.CREATE_QUERIES, - ); - } - - return permissions; -} - -const metadataTableToTableEntityId = (table: Table) => ({ - databaseId: table.db_id, - schemaName: table.schema_name || "", - tableId: table.id as ConcreteTableId, -}); - -// TODO Atte Keinänen 6/24/17 See if this method could be simplified -const entityIdToMetadataTableFields = (entityId: Partial<TableEntityId>) => ({ - ...(entityId.databaseId ? { db_id: entityId.databaseId } : {}), - // Because schema name can be an empty string, which means an empty schema, this check becomes a little nasty - ...(entityId.schemaName !== undefined - ? { schema_name: entityId.schemaName !== "" ? entityId.schemaName : null } - : {}), - ...(entityId.tableId ? { id: entityId.tableId } : {}), -}); - -function inferEntityPermissionValueFromChildTables( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - database: Database, - permission: DataPermission, -): DataPermissionValue { - const entityIdsForDescendantTables = _.chain(database.tables) - .filter(t => _.isMatch(t, entityIdToMetadataTableFields(entityId))) - .map(metadataTableToTableEntityId) - .value(); - - const entityIdsByPermValue = _.chain(entityIdsForDescendantTables) - .map(id => getFieldsPermission(permissions, groupId, id, permission)) - .groupBy(_.identity) - .value(); - - const keys = Object.keys(entityIdsByPermValue) as DataPermissionValue[]; - const allTablesHaveSamePermissions = keys.length === 1; - - if (allTablesHaveSamePermissions) { - return keys[0]; - } else { - return DataPermissionValue.CONTROLLED; - } -} - -// Checks the child tables of a given entityId and updates the shared table and/or schema permission values according to table permissions -// This method was added for keeping the UI in sync when modifying child permissions -export function inferAndUpdateEntityPermissions( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - database: Database, - permission: DataPermission, -) { - const { databaseId } = entityId; - const schemaName = (entityId as SchemaEntityId).schemaName ?? ""; - - if (schemaName) { - // Check all tables for current schema if their shared schema-level permission value should be updated - const tablesPermissionValue = inferEntityPermissionValueFromChildTables( - permissions, - groupId, - { databaseId, schemaName }, - database, - permission, - ); - permissions = updateTablesPermission( - permissions, - groupId, - { databaseId, schemaName }, - tablesPermissionValue, - database, - permission, - ); - } - - if (databaseId) { - // Check all tables for current database if schemas' shared database-level permission value should be updated - const schemasPermissionValue = inferEntityPermissionValueFromChildTables( - permissions, - groupId, - { databaseId }, - database, - permission, - ); - permissions = updateSchemasPermission( - permissions, - groupId, - { databaseId }, - schemasPermissionValue, - database, - permission, - ); - } - - return permissions; -} - -export function updateFieldsPermission( - permissions: GroupsPermissions, - groupId: number, - entityId: TableEntityId, - value: any, - database: Database, - permission: DataPermission, -) { - const { databaseId, tableId } = entityId; - const schemaName = entityId.schemaName || ""; - - permissions = updateTablesPermission( - permissions, - groupId, - { databaseId, schemaName }, - DataPermissionValue.CONTROLLED, - database, - permission, - ); - permissions = updatePermission( - permissions, - groupId, - databaseId, - permission, - [schemaName, tableId], - value, - ); - return permissions; -} - -export function updateTablesPermission( - permissions: GroupsPermissions, - groupId: number, - { databaseId, schemaName }: SchemaEntityId, - value: any, - database: Database, - permission: DataPermission, -) { - const schema = database.schema(schemaName); - const tableIds = schema?.getTables().map((t: Table) => t.id); - - permissions = updateSchemasPermission( - permissions, - groupId, - { databaseId }, - DataPermissionValue.CONTROLLED, - database, - permission, - ); - permissions = updatePermission( - permissions, - groupId, - databaseId, - permission, - [schemaName || ""], - value, - tableIds, - ); - - return permissions; -} - -export function updateSchemasPermission( - permissions: GroupsPermissions, - groupId: number, - { databaseId }: DatabaseEntityId, - value: DataPermissionValue, - database: Database, - permission: DataPermission, -) { - const schemaNames = database && database.schemaNames(); - const schemaNamesOrNoSchema = - schemaNames && - schemaNames.length > 0 && - !(schemaNames.length === 1 && schemaNames[0] === null) - ? schemaNames - : [""]; - - return updatePermission( - permissions, - groupId, - databaseId, - permission, - [], - value, - schemaNamesOrNoSchema, - ); -} - -export function updateEntityPermission( - permissions: GroupsPermissions, - groupId: number, - entityId: EntityId, - value: DataPermissionValue, - database: Database, - permission: DataPermission, -) { - if (isTableEntityId(entityId)) { - return updateFieldsPermission( - permissions, - groupId, - entityId, - value, - database, - permission, - ); - } else if (isSchemaEntityId(entityId)) { - return updateTablesPermission( - permissions, - groupId, - entityId, - value, - database, - permission, - ); - } else { - return updateSchemasPermission( - permissions, - groupId, - entityId, - value, - database, - permission, - ); - } -} diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/get.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/get.ts new file mode 100644 index 00000000000..c157296feab --- /dev/null +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/get.ts @@ -0,0 +1,195 @@ +import { getIn } from "icepick"; +import _ from "underscore"; + +import type { + DatabaseEntityId, + EntityId, + SchemaEntityId, + TableEntityId, +} from "metabase/admin/permissions/types"; +import { + DataPermission, + DataPermissionValue, +} from "metabase/admin/permissions/types"; +import { PLUGIN_ADVANCED_PERMISSIONS } from "metabase/plugins"; +import type { GroupsPermissions } from "metabase-types/api"; + +// permission that do not have a nested shemas/native key +const flatPermissions = new Set([ + DataPermission.DETAILS, + DataPermission.VIEW_DATA, + DataPermission.CREATE_QUERIES, +]); + +// util to ease migration of perms attributes into a flatter structure +export function getPermissionPath( + groupId: number, + databaseId: number, + permission: DataPermission, + nestedPath?: Array<string | number>, +) { + const isFlatPermValue = flatPermissions.has(permission); + if (isFlatPermValue) { + return [groupId, databaseId, permission, ...(nestedPath || [])]; + } + return [groupId, databaseId, permission, "schemas", ...(nestedPath || [])]; +} + +const omittedDefaultValues: Record<DataPermission, DataPermissionValue> = { + get [DataPermission.VIEW_DATA]() { + return PLUGIN_ADVANCED_PERMISSIONS.defaultViewDataPermission; + }, + [DataPermission.CREATE_QUERIES]: DataPermissionValue.NO, + [DataPermission.DOWNLOAD]: DataPermissionValue.NONE, + [DataPermission.DATA_MODEL]: DataPermissionValue.NONE, + [DataPermission.DETAILS]: DataPermissionValue.NO, +}; + +function getOmittedPermissionValue( + permission: DataPermission, +): DataPermissionValue { + return omittedDefaultValues[permission] ?? DataPermissionValue.NO; +} + +// returns portion of the graph that might be undefined, +// purposefully does not try to determine the entity's value from its parent +export function getRawPermissionsGraphValue( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + permission: DataPermission, +) { + const nestedPath = [ + entityId.schemaName === null ? "" : entityId.schemaName, + entityId.tableId, + ].filter((x): x is number | string => x !== undefined); + const path = getPermissionPath( + groupId, + entityId.databaseId, + permission, + nestedPath, + ); + return getIn(permissions, path); +} + +interface GetPermissionParams { + permissions: GroupsPermissions; + groupId: number; + databaseId: number; + permission: DataPermission; + path?: Array<number | string>; + isControlledType?: boolean; +} + +const getPermission = ({ + permissions, + groupId, + databaseId, + permission, + path, + isControlledType = false, +}: GetPermissionParams): DataPermissionValue => { + const valuePath = getPermissionPath(groupId, databaseId, permission, path); + const value = getIn(permissions, valuePath); + if (isControlledType && typeof value === "object") { + return DataPermissionValue.CONTROLLED; + } + return value ? value : getOmittedPermissionValue(permission); +}; + +export const getSchemasPermission = ( + permissions: GroupsPermissions, + groupId: number, + { databaseId }: DatabaseEntityId, + permission: DataPermission, +) => { + return getPermission({ + permissions, + databaseId, + groupId, + permission, + isControlledType: true, + }); +}; + +export const getTablesPermission = ( + permissions: GroupsPermissions, + groupId: number, + { databaseId, schemaName }: SchemaEntityId, + permission: DataPermission, +) => { + const schemas = getSchemasPermission( + permissions, + groupId, + { + databaseId, + }, + permission, + ); + if (schemas === DataPermissionValue.CONTROLLED) { + return getPermission({ + permissions, + databaseId, + groupId, + permission, + path: [schemaName ?? ""], + isControlledType: true, + }); + } else { + return schemas; + } +}; + +export const getFieldsPermission = ( + permissions: GroupsPermissions, + groupId: number, + { databaseId, schemaName, tableId }: TableEntityId, + permission: DataPermission, +): DataPermissionValue => { + const tables = getTablesPermission( + permissions, + groupId, + { + databaseId, + schemaName, + }, + permission, + ); + if (tables === DataPermissionValue.CONTROLLED) { + return getPermission({ + permissions, + groupId, + databaseId, + permission, + path: [schemaName || "", tableId], + isControlledType: true, + }); + } else { + return tables; + } +}; + +export const getEntityPermission = ( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + permission: DataPermission, +): DataPermissionValue => { + if (entityId.tableId !== undefined) { + return getFieldsPermission( + permissions, + groupId, + entityId as TableEntityId, + permission, + ); + } else if (entityId.schemaName !== undefined) { + return getTablesPermission( + permissions, + groupId, + entityId as SchemaEntityId, + permission, + ); + } else { + return getSchemasPermission(permissions, groupId, entityId, permission); + } +}; diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.ts new file mode 100644 index 00000000000..3e45346c227 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.ts @@ -0,0 +1,154 @@ +import _ from "underscore"; + +import type { + DataPermission, + DataPermissionValue, + DatabaseEntityId, + EntityWithGroupId, + SchemaEntityId, +} from "metabase/admin/permissions/types"; +import { isSchemaEntityId } from "metabase/admin/permissions/utils/data-entity-id"; +import type { + ConcreteTableId, + DatabasePermissions, + GroupPermissions, + GroupsPermissions, +} from "metabase-types/api"; + +import { + getEntityPermission, + getFieldsPermission, + getRawPermissionsGraphValue, + getTablesPermission, +} from "./get"; + +// subtypes to make testing easier and avoid using deprecated Database / Schema types +type SchemaPartial = { + name: string; + getTables: () => { id: number | string }[]; +}; +type DatabasePartial = { + schemas?: SchemaPartial[]; + schema(schemaName: string | undefined): SchemaPartial | null | undefined; +}; + +export function hasPermissionValueInSubgraph( + permissions: GroupsPermissions, + groupId: number, + entityId: DatabaseEntityId | SchemaEntityId, + database: DatabasePartial, + permission: DataPermission, + value: DataPermissionValue, +) { + const schemasToSearch = _.compact( + isSchemaEntityId(entityId) + ? [database.schema(entityId.schemaName)] + : database.schemas, + ); + + if (schemasToSearch) { + const hasSchemaWithMatchingPermission = schemasToSearch.some(schema => { + const currVal = getTablesPermission( + permissions, + groupId, + { databaseId: entityId.databaseId, schemaName: schema.name }, + permission, + ); + return value === currVal; + }); + + if (hasSchemaWithMatchingPermission) { + return true; + } + } + + return schemasToSearch.some(schema => { + return schema.getTables().some(table => { + return ( + value === + getFieldsPermission( + permissions, + groupId, + { + databaseId: entityId.databaseId, + schemaName: schema.name, + tableId: table.id as ConcreteTableId, + }, + permission, + ) + ); + }); + }); +} + +// return boolean if able to find if a value is present in all or a portion of the permissions graph +// NOTE: default values are omitted from the graph, and given the way this function was written, it won't return +// the right answer for those permissions. for now, those default values have been omitted from allowed values to avoid bugs +export function hasPermissionValueInGraph( + permissions: + | GroupsPermissions + | GroupPermissions + | DatabasePermissions + | DataPermissionValue, + permissionValue: Omit< + DataPermissionValue, + DataPermissionValue.BLOCKED | DataPermissionValue.NO // omit default values + >, +): boolean { + if (permissions === permissionValue) { + return true; + } + + function _hasPermissionValueInGraph(permissionsGraphSection: any) { + for (const key in permissionsGraphSection) { + const isMatch = permissionsGraphSection[key] === permissionValue; + if (isMatch) { + return true; + } + + const isGraphObjWithMatch = + typeof permissionsGraphSection[key] === "object" && + _hasPermissionValueInGraph(permissionsGraphSection[key]); + if (isGraphObjWithMatch) { + return true; + } + } + + return false; + } + + return _hasPermissionValueInGraph(permissions); +} + +// return boolean if able to find if a value is present in any of the specified portions of the graph +// useful for ignoring certain parts of the graphy you don't care to check +export function hasPermissionValueInEntityGraphs( + permissions: GroupsPermissions, + entityIds: EntityWithGroupId[], + permission: DataPermission, + permissionValue: DataPermissionValue, +): boolean { + return entityIds.some(entityId => { + // try to get the raw section of the graph so we can crawl it's children if it has them + const permissionPortion = getRawPermissionsGraphValue( + permissions, + entityId.groupId, + entityId, + permission, + ); + + if (permissionPortion !== undefined) { + return hasPermissionValueInGraph(permissionPortion, permissionValue); + } + + // the above may be undefined since the entity's value is determined from a parent entity in the graph, + // so we figure that out here and check if it matches what we're looking for + const entityPermission = getEntityPermission( + permissions, + entityId.groupId, + entityId, + permission, + ); + return entityPermission === permissionValue; + }); +} diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions.unit.spec.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.unit.spec.ts similarity index 97% rename from frontend/src/metabase/admin/permissions/utils/graph/data-permissions.unit.spec.ts rename to frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.unit.spec.ts index 697bb651ab3..8f163c3542b 100644 --- a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions.unit.spec.ts +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/has.unit.spec.ts @@ -1,11 +1,13 @@ import _ from "underscore"; +import { + DataPermission, + DataPermissionValue, +} from "metabase/admin/permissions/types"; import { PLUGIN_ADVANCED_PERMISSIONS } from "metabase/plugins"; import type { GroupsPermissions } from "metabase-types/api"; -import { DataPermission, DataPermissionValue } from "../../types"; - -import { hasPermissionValueInSubgraph } from "./data-permissions"; +import { hasPermissionValueInSubgraph } from "./has"; describe("data permissions", () => { describe("hasPermissionValueInSubgraph", () => { diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/index.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/index.ts new file mode 100644 index 00000000000..c86dd086a78 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/index.ts @@ -0,0 +1,4 @@ +export * from "./get"; +export * from "./has"; +export * from "./update"; +export * from "./utils"; diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/update.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/update.ts new file mode 100644 index 00000000000..a1472eb8249 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/update.ts @@ -0,0 +1,348 @@ +import { getIn, setIn } from "icepick"; +import _ from "underscore"; + +import type { + DatabaseEntityId, + EntityId, + SchemaEntityId, + TableEntityId, +} from "metabase/admin/permissions/types"; +import { + DataPermission, + DataPermissionValue, +} from "metabase/admin/permissions/types"; +import { + isSchemaEntityId, + isTableEntityId, +} from "metabase/admin/permissions/utils/data-entity-id"; +import { + entityIdToMetadataTableFields, + metadataTableToTableEntityId, +} from "metabase/admin/permissions/utils/metadata"; +import { PLUGIN_DATA_PERMISSIONS } from "metabase/plugins"; +import type Database from "metabase-lib/v1/metadata/Database"; +import type Table from "metabase-lib/v1/metadata/Table"; +import type { GroupsPermissions } from "metabase-types/api"; + +import { + getFieldsPermission, + getPermissionPath, + getSchemasPermission, +} from "./get"; +import { isRestrictivePermission } from "./utils"; + +export function updatePermission( + permissions: GroupsPermissions, + groupId: number, + databaseId: number, + permission: DataPermission, + path: Array<number | string>, + value: string | undefined, + entityIds?: any[], +) { + const fullPath = getPermissionPath(groupId, databaseId, permission, path); + const current = getIn(permissions, fullPath); + + if ( + current === value || + (current && + typeof current === "object" && + value === DataPermissionValue.CONTROLLED) + ) { + return permissions; + } + let newValue: any; + if (value === DataPermissionValue.CONTROLLED) { + newValue = {}; + if (entityIds) { + for (const entityId of entityIds) { + newValue[entityId] = current; + } + } + } else { + newValue = value; + } + for (let i = 0; i < fullPath.length; i++) { + if (typeof getIn(permissions, fullPath.slice(0, i)) === "string") { + permissions = setIn(permissions, fullPath.slice(0, i), {}); + } + } + return setIn(permissions, fullPath, newValue); +} + +export function updateFieldsPermission( + permissions: GroupsPermissions, + groupId: number, + entityId: TableEntityId, + value: any, + database: Database, + permission: DataPermission, +) { + const { databaseId, tableId } = entityId; + const schemaName = entityId.schemaName || ""; + + permissions = updateTablesPermission( + permissions, + groupId, + { databaseId, schemaName }, + DataPermissionValue.CONTROLLED, + database, + permission, + ); + permissions = updatePermission( + permissions, + groupId, + databaseId, + permission, + [schemaName, tableId], + value, + ); + return permissions; +} + +export function updateTablesPermission( + permissions: GroupsPermissions, + groupId: number, + { databaseId, schemaName }: SchemaEntityId, + value: any, + database: Database, + permission: DataPermission, +) { + const schema = database.schema(schemaName); + const tableIds = schema?.getTables().map((t: Table) => t.id); + + permissions = updateSchemasPermission( + permissions, + groupId, + { databaseId }, + DataPermissionValue.CONTROLLED, + database, + permission, + ); + permissions = updatePermission( + permissions, + groupId, + databaseId, + permission, + [schemaName || ""], + value, + tableIds, + ); + + return permissions; +} + +export function updateSchemasPermission( + permissions: GroupsPermissions, + groupId: number, + { databaseId }: DatabaseEntityId, + value: DataPermissionValue, + database: Database, + permission: DataPermission, +) { + const schemaNames = database && database.schemaNames(); + const schemaNamesOrNoSchema = + schemaNames && + schemaNames.length > 0 && + !(schemaNames.length === 1 && schemaNames[0] === null) + ? schemaNames + : [""]; + + return updatePermission( + permissions, + groupId, + databaseId, + permission, + [], + value, + schemaNamesOrNoSchema, + ); +} + +export function updateEntityPermission( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + value: DataPermissionValue, + database: Database, + permission: DataPermission, +) { + if (isTableEntityId(entityId)) { + return updateFieldsPermission( + permissions, + groupId, + entityId, + value, + database, + permission, + ); + } else if (isSchemaEntityId(entityId)) { + return updateTablesPermission( + permissions, + groupId, + entityId, + value, + database, + permission, + ); + } else { + return updateSchemasPermission( + permissions, + groupId, + entityId, + value, + database, + permission, + ); + } +} + +export function restrictCreateQueriesPermissionsIfNeeded( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + permission: DataPermission, + value: DataPermissionValue, + database: Database, +) { + const currDbCreateQueriesPermission = getSchemasPermission( + permissions, + groupId, + { databaseId: entityId.databaseId }, + DataPermission.CREATE_QUERIES, + ); + + const isMakingGranularCreateQueriesChange = + permission === DataPermission.CREATE_QUERIES && + value !== DataPermissionValue.QUERY_BUILDER_AND_NATIVE && + (entityId.tableId != null || entityId.schemaName != null) && + currDbCreateQueriesPermission === + DataPermissionValue.QUERY_BUILDER_AND_NATIVE; + + const shouldRestrictForSomeReason = + PLUGIN_DATA_PERMISSIONS.shouldRestrictNativeQueryPermissions( + permissions, + groupId, + entityId, + permission, + value, + database, + ); + + const shouldRestrictNative = + isMakingGranularCreateQueriesChange || shouldRestrictForSomeReason; + + if (shouldRestrictNative) { + const schemaNames = (database && database.schemaNames()) ?? [null]; + + schemaNames.forEach(schemaName => { + permissions = updateTablesPermission( + permissions, + groupId, + { + databaseId: entityId.databaseId, + schemaName, + }, + DataPermissionValue.QUERY_BUILDER, + database, + DataPermission.CREATE_QUERIES, + ); + }); + } + + if ( + isRestrictivePermission(value) || + value === DataPermissionValue.LEGACY_NO_SELF_SERVICE + ) { + permissions = updateEntityPermission( + permissions, + groupId, + entityId, + DataPermissionValue.NO, + database, + DataPermission.CREATE_QUERIES, + ); + } + + return permissions; +} + +function inferEntityPermissionValueFromChildTables( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + database: Database, + permission: DataPermission, +): DataPermissionValue { + const entityIdsForDescendantTables = _.chain(database.tables) + .filter(t => _.isMatch(t, entityIdToMetadataTableFields(entityId))) + .map(metadataTableToTableEntityId) + .value(); + + const entityIdsByPermValue = _.chain(entityIdsForDescendantTables) + .map(id => getFieldsPermission(permissions, groupId, id, permission)) + .groupBy(_.identity) + .value(); + + const keys = Object.keys(entityIdsByPermValue) as DataPermissionValue[]; + const allTablesHaveSamePermissions = keys.length === 1; + + if (allTablesHaveSamePermissions) { + return keys[0]; + } else { + return DataPermissionValue.CONTROLLED; + } +} + +// Checks the child tables of a given entityId and updates the shared table and/or schema permission values according to table permissions +// This method was added for keeping the UI in sync when modifying child permissions +export function inferAndUpdateEntityPermissions( + permissions: GroupsPermissions, + groupId: number, + entityId: EntityId, + database: Database, + permission: DataPermission, +) { + const { databaseId } = entityId; + const schemaName = (entityId as SchemaEntityId).schemaName ?? ""; + + if (schemaName) { + // Check all tables for current schema if their shared schema-level permission value should be updated + const tablesPermissionValue = inferEntityPermissionValueFromChildTables( + permissions, + groupId, + { databaseId, schemaName }, + database, + permission, + ); + permissions = updateTablesPermission( + permissions, + groupId, + { databaseId, schemaName }, + tablesPermissionValue, + database, + permission, + ); + } + + if (databaseId) { + // Check all tables for current database if schemas' shared database-level permission value should be updated + const schemasPermissionValue = inferEntityPermissionValueFromChildTables( + permissions, + groupId, + { databaseId }, + database, + permission, + ); + permissions = updateSchemasPermission( + permissions, + groupId, + { databaseId }, + schemasPermissionValue, + database, + permission, + ); + } + + return permissions; +} diff --git a/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/utils.ts b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/utils.ts new file mode 100644 index 00000000000..afb86a1f982 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/utils/graph/data-permissions/utils.ts @@ -0,0 +1,8 @@ +import _ from "underscore"; + +import { DataPermissionValue } from "metabase/admin/permissions/types"; +import { PLUGIN_ADVANCED_PERMISSIONS } from "metabase/plugins"; + +export const isRestrictivePermission = (value: DataPermissionValue) => + value === DataPermissionValue.NO || + PLUGIN_ADVANCED_PERMISSIONS.isRestrictivePermission(value); diff --git a/frontend/src/metabase/admin/permissions/utils/metadata.ts b/frontend/src/metabase/admin/permissions/utils/metadata.ts index 1e6b1e19042..227c07507d9 100644 --- a/frontend/src/metabase/admin/permissions/utils/metadata.ts +++ b/frontend/src/metabase/admin/permissions/utils/metadata.ts @@ -1,4 +1,10 @@ +import _ from "underscore"; + import type Metadata from "metabase-lib/v1/metadata/Metadata"; +import type Table from "metabase-lib/v1/metadata/Table"; +import type { ConcreteTableId } from "metabase-types/api"; + +import type { TableEntityId } from "../types"; export const getDatabase = (metadata: Metadata, databaseId: number) => { const database = metadata.database(databaseId); @@ -9,3 +15,21 @@ export const getDatabase = (metadata: Metadata, databaseId: number) => { return database; }; + +export const metadataTableToTableEntityId = (table: Table) => ({ + databaseId: table.db_id, + schemaName: table.schema_name || "", + tableId: table.id as ConcreteTableId, +}); + +// TODO Atte Keinänen 6/24/17 See if this method could be simplified +export const entityIdToMetadataTableFields = ( + entityId: Partial<TableEntityId>, +) => ({ + ...(entityId.databaseId ? { db_id: entityId.databaseId } : {}), + // Because schema name can be an empty string, which means an empty schema, this check becomes a little nasty + ...(entityId.schemaName !== undefined + ? { schema_name: entityId.schemaName !== "" ? entityId.schemaName : null } + : {}), + ...(entityId.tableId ? { id: entityId.tableId } : {}), +}); -- GitLab