diff --git a/lib/permissions/minimally-encoded-thread-permissions.js b/lib/permissions/minimally-encoded-thread-permissions.js --- a/lib/permissions/minimally-encoded-thread-permissions.js +++ b/lib/permissions/minimally-encoded-thread-permissions.js @@ -1,23 +1,40 @@ // @flow import invariant from 'invariant'; +import _isEqual from 'lodash/fp/isEqual.js'; +import _mapValues from 'lodash/fp/mapValues.js'; import { - parseThreadPermissionString, constructThreadPermissionString, + parseThreadPermissionString, } from './prefixes.js'; +import { specialRoles } from './special-roles.js'; import { + getAllThreadPermissions, + getRolePermissionBlobs, + makePermissionsBlob, + makePermissionsForChildrenBlob, +} from './thread-permissions.js'; +import type { ThreadStoreOperation } from '../ops/thread-store-ops.js'; +import { getChildThreads } from '../selectors/thread-selectors.js'; +import type { + MinimallyEncodedThickMemberInfo, + ThickRawThreadInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; +import { + assertThreadPermission, + assertThreadPermissionFilterPrefix, + assertThreadPermissionMembershipPrefix, + assertThreadPermissionPropagationPrefix, type ThreadPermission, type ThreadPermissionInfo, + threadPermissions, + type ThreadPermissionsBlob, type ThreadPermissionsInfo, type ThreadRolePermissionsBlob, - threadPermissions, - assertThreadPermission, - assertThreadPermissionPropagationPrefix, - assertThreadPermissionFilterPrefix, - assertThreadPermissionMembershipPrefix, } from '../types/thread-permission-types.js'; -import { entries, invertObjectToMap } from '../utils/objects.js'; +import type { RawThreadInfos } from '../types/thread-types.js'; +import { entries, invertObjectToMap, values } from '../utils/objects.js'; import type { TRegex } from '../utils/validation-utils.js'; import { tRegex } from '../utils/validation-utils.js'; @@ -246,6 +263,214 @@ ]), ); +function updateRolesAndPermissions( + threads: RawThreadInfos, + rolePermissionsUpdater: ThreadRolePermissionsBlob => ThreadRolePermissionsBlob, +): { + +operations: $ReadOnlyArray<ThreadStoreOperation>, +} { + const updatedThreads = { ...threads }; + + const childThreads = getChildThreads(threads); + const threadChildrenIDs = _mapValues( + threadChildren => threadChildren.map(thread => thread.id), + childThreads, + ); + function updateRoles(threadID: string) { + const threadInfo = updatedThreads[threadID]; + + const roles = { ...threadInfo.roles }; + for (const roleID in roles) { + const role = roles[roleID]; + + const rolePermissionBlobs = getRolePermissionBlobs(threadInfo.type); + let updatedPermissionsBlob; + if (threadInfo.thick) { + // Each thick thread has exactly one role - for its members. We + // don't allow managing this role, which means we can simply get the + // computed permissions from the rolePermissionBlobs. + updatedPermissionsBlob = rolePermissionBlobs.Members; + } else if ( + role.specialRole === specialRoles.ADMIN_ROLE && + rolePermissionBlobs.Admins + ) { + // We don't allow managing the admin role, so in this case we can + // also use the result of getRolePermissionBlobs that should contain + // all the required changes. + updatedPermissionsBlob = rolePermissionBlobs.Admins; + } else { + // We allow admins to manage non-admin roles, which means we can't + // simply rely on the result of getRolePermissionBlobs - it doesn't + // contain any changes made by admins. In this case, we're running the + // rolePermissionsUpdater that updates the role accordingly. It is + // similar to what we do on the keyserver during a migration in + // addNewUserSurfacedPermission inside migration-config.js. + updatedPermissionsBlob = rolePermissionsUpdater( + decodeThreadRolePermissionsBitmaskArray(role.permissions), + ); + } + const encodedUpdatedPermissions = threadRolePermissionsBlobToBitmaskArray( + updatedPermissionsBlob, + ); + roles[roleID] = { + ...role, + permissions: encodedUpdatedPermissions, + }; + updatedThreads[threadID] = { + ...threadInfo, + roles, + }; + } + } + + type MemberToThreadPermissions = { + [member: string]: ?ThreadPermissionsBlob, + }; + + function updateThickThreadMembers( + threadInfo: ThickRawThreadInfo, + memberToThreadPermissionsFromParent: ?MemberToThreadPermissions, + ): { + +members: $ReadOnlyArray<MinimallyEncodedThickMemberInfo>, + +memberToThreadPermissionsForChildren: MemberToThreadPermissions, + } { + const updatedMembers = []; + const memberToThreadPermissionsForChildren: MemberToThreadPermissions = {}; + for (const member of threadInfo.members) { + const { id, role } = member; + + const rolePermissions = role + ? decodeThreadRolePermissionsBitmaskArray( + threadInfo.roles[role].permissions, + ) + : null; + const permissionsFromParent = memberToThreadPermissionsFromParent?.[id]; + + const computedPermissions = makePermissionsBlob( + rolePermissions, + permissionsFromParent, + threadInfo.id, + threadInfo.type, + ); + + updatedMembers.push({ + ...member, + permissions: permissionsToBitmaskHex( + getAllThreadPermissions(computedPermissions, threadInfo.id), + ), + }); + + memberToThreadPermissionsForChildren[member.id] = + makePermissionsForChildrenBlob(computedPermissions); + } + return { + members: updatedMembers, + memberToThreadPermissionsForChildren, + }; + } + + function recursivelyUpdateThickThreadMemberPermissions( + threadID: string, + memberToThreadPermissionsFromParent: ?MemberToThreadPermissions, + ) { + const threadInfo = updatedThreads[threadID]; + if (!threadInfo.thick) { + // We don't update members of thin threads because we aren't storing + // their permissions inside their member info. Member permissions are + // instead computed on the fly based on the roles. We're planning to + // use the same approach for thick threads in the future + // https://linear.app/comm/issue/ENG-9404/remove-permissions-from-members-in-thickrawthreadinfo + return; + } + + const { members: updatedMembers, memberToThreadPermissionsForChildren } = + updateThickThreadMembers(threadInfo, memberToThreadPermissionsFromParent); + updatedThreads[threadID] = { + ...threadInfo, + members: updatedMembers, + }; + for (const childID of threadChildrenIDs[threadID] ?? []) { + recursivelyUpdateThickThreadMemberPermissions( + childID, + memberToThreadPermissionsForChildren, + ); + } + } + + function recursivelyUpdateCurrentUserPermissions( + threadID: string, + permissionsFromParent: ?ThreadPermissionsBlob, + ) { + const threadInfo = updatedThreads[threadID]; + const { currentUser, roles } = threadInfo; + const { role } = currentUser; + + const rolePermissions = role + ? decodeThreadRolePermissionsBitmaskArray(roles[role].permissions) + : null; + const computedPermissions = makePermissionsBlob( + rolePermissions, + permissionsFromParent, + threadInfo.id, + threadInfo.type, + ); + + updatedThreads[threadID] = { + ...threadInfo, + currentUser: { + ...currentUser, + permissions: permissionsToBitmaskHex( + getAllThreadPermissions(computedPermissions, threadInfo.id), + ), + }, + }; + + for (const childID of threadChildrenIDs[threadID] ?? []) { + recursivelyUpdateCurrentUserPermissions( + childID, + makePermissionsForChildrenBlob(computedPermissions), + ); + } + } + + for (const thread of values(threads)) { + if (!thread.parentThreadID) { + // We don't need to update these recursively because roles don't + // cascade from parents - modifying a parent thread role doesn't have + // any impact on children roles. This stays in contrast to how our + // permissions work - setting a parent role can affect which + // permissions are granted in the children threads, because we're + // propagating the permissions (based on a couple of strategies). + updateRoles(thread.id); + } + } + const rootThreadIDs = values(threads) + .filter(thread => !thread.parentThreadID) + .map(thread => thread.id); + rootThreadIDs.forEach(threadID => + recursivelyUpdateThickThreadMemberPermissions(threadID, null), + ); + rootThreadIDs.forEach(threadID => + recursivelyUpdateCurrentUserPermissions(threadID, null), + ); + + const operations = values(updatedThreads) + .filter( + updatedThread => !_isEqual(updatedThread, threads[updatedThread.id]), + ) + .map(thread => ({ + type: 'replace', + payload: { + id: thread.id, + threadInfo: thread, + }, + })); + + return { + operations, + }; +} + export { permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, @@ -254,6 +479,7 @@ decodeRolePermissionBitmask, threadRolePermissionsBlobToBitmaskArray, decodeThreadRolePermissionsBitmaskArray, + updateRolesAndPermissions, tHexEncodedRolePermission, tHexEncodedPermissionsBitmask, }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -180,28 +180,29 @@ }, ); +function getChildThreads<T: RawThreadInfo | ThreadInfo>(threadInfos: { + +[id: string]: T, +}): { +[id: string]: Array<T> } { + const result: { + [string]: Array<T>, + } = {}; + for (const id in threadInfos) { + const threadInfo = threadInfos[id]; + const { parentThreadID } = threadInfo; + if (parentThreadID === null || parentThreadID === undefined) { + continue; + } + if (result[parentThreadID] === undefined) { + result[parentThreadID] = ([]: Array<T>); + } + result[parentThreadID].push(threadInfo); + } + return result; +} + const childThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray<ThreadInfo>, -} = createSelector( - threadInfoSelector, - (threadInfos: { +[id: string]: ThreadInfo }) => { - const result: { - [string]: ThreadInfo[], - } = {}; - for (const id in threadInfos) { - const threadInfo = threadInfos[id]; - const parentThreadID = threadInfo.parentThreadID; - if (parentThreadID === null || parentThreadID === undefined) { - continue; - } - if (result[parentThreadID] === undefined) { - result[parentThreadID] = ([]: ThreadInfo[]); - } - result[parentThreadID].push(threadInfo); - } - return result; - }, -); +} = createSelector(threadInfoSelector, getChildThreads); const containedThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray<ThreadInfo>, @@ -587,4 +588,5 @@ threadInfosSelectorForThreadType, thickRawThreadInfosSelector, unreadThickThreadIDsSelector, + getChildThreads, }; diff --git a/native/redux/persist-constants.js b/native/redux/persist-constants.js --- a/native/redux/persist-constants.js +++ b/native/redux/persist-constants.js @@ -1,6 +1,6 @@ // @flow const rootKey = 'root'; -const storeVersion = 87; +const storeVersion = 88; export { rootKey, storeVersion }; diff --git a/native/redux/persist.js b/native/redux/persist.js --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; +import _keyBy from 'lodash/fp/keyBy.js'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; @@ -56,6 +57,7 @@ convertUserInfosToReplaceUserOps, userStoreOpsHandlers, } from 'lib/ops/user-store-ops.js'; +import { updateRolesAndPermissions } from 'lib/permissions/minimally-encoded-thread-permissions.js'; import { patchRawThreadInfosWithSpecialRole } from 'lib/permissions/special-roles.js'; import { filterThreadIDsInFilterList } from 'lib/reducers/calendar-filters-reducer.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; @@ -103,6 +105,10 @@ } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; +import { + userSurfacedPermissions, + type ThreadRolePermissionsBlob, +} from 'lib/types/thread-permission-types.js'; import type { ClientDBThreadInfo, LegacyRawThreadInfo, @@ -126,9 +132,11 @@ MigrationsManifest, } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; +import { toggleUserSurfacedPermission } from 'lib/utils/role-utils.js'; import { deprecatedConvertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, + convertClientDBThreadInfoToRawThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; @@ -1541,6 +1549,32 @@ ops: {}, }; }: MigrationFunction<NavInfo, AppState>), + [88]: (async (state: AppState) => { + const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); + const rawThreadInfos = clientDBThreadInfos.map( + convertClientDBThreadInfoToRawThreadInfo, + ); + const keyedRawThreadInfos = _keyBy('id')(rawThreadInfos); + + // This function also results in setting DELETE_OWN_MESSAGES and + // DELETE_ALL_MESSAGES for admins. It is added automatically based on the + // result of getRolePermissionBlobs call. + const { operations } = updateRolesAndPermissions( + keyedRawThreadInfos, + (permissions: ThreadRolePermissionsBlob) => + toggleUserSurfacedPermission( + permissions, + userSurfacedPermissions.DELETE_OWN_MESSAGES, + ), + ); + + return { + state, + ops: { + threadStoreOperations: operations, + }, + }; + }: MigrationFunction<NavInfo, AppState>), }); // NOTE: renaming this object, and especially the `version` property diff --git a/web/redux/persist-constants.js b/web/redux/persist-constants.js --- a/web/redux/persist-constants.js +++ b/web/redux/persist-constants.js @@ -3,6 +3,6 @@ const rootKey = 'root'; const rootKeyPrefix = 'persist:'; const completeRootKey = `${rootKeyPrefix}${rootKey}`; -const storeVersion = 87; +const storeVersion = 88; export { rootKey, rootKeyPrefix, completeRootKey, storeVersion }; diff --git a/web/redux/persist.js b/web/redux/persist.js --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -12,15 +12,16 @@ type ReplaceKeyserverOperation, } from 'lib/ops/keyserver-store-ops.js'; import { - messageStoreOpsHandlers, - type ReplaceMessageStoreLocalMessageInfoOperation, type ClientDBMessageStoreOperation, type MessageStoreOperation, + messageStoreOpsHandlers, + type ReplaceMessageStoreLocalMessageInfoOperation, } from 'lib/ops/message-store-ops.js'; import type { ClientDBThreadStoreOperation, ThreadStoreOperation, } from 'lib/ops/thread-store-ops.js'; +import { updateRolesAndPermissions } from 'lib/permissions/minimally-encoded-thread-permissions.js'; import { patchRawThreadInfoWithSpecialRole } from 'lib/permissions/special-roles.js'; import { createUpdateDBOpsForThreadStoreThreadInfos } from 'lib/shared/redux/client-db-utils.js'; import { deprecatedUpdateRolesAndPermissions } from 'lib/shared/redux/deprecated-update-roles-and-permissions.js'; @@ -36,6 +37,8 @@ import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import type { StoreOperations } from 'lib/types/store-ops-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; +import type { ThreadRolePermissionsBlob } from 'lib/types/thread-permission-types.js'; +import { userSurfacedPermissions } from 'lib/types/thread-permission-types.js'; import type { ClientDBThreadInfo, RawThreadInfos, @@ -45,14 +48,15 @@ import { isDev } from 'lib/utils/dev-utils.js'; import { stripMemberPermissionsFromRawThreadInfos } from 'lib/utils/member-info-utils.js'; import { - generateIDSchemaMigrationOpsForDrafts, convertDraftStoreToNewIDSchema, createAsyncMigrate, - type StorageMigrationFunction, + generateIDSchemaMigrationOpsForDrafts, type MigrationFunction, type MigrationsManifest, + type StorageMigrationFunction, } from 'lib/utils/migration-utils.js'; import { entries, values } from 'lib/utils/objects.js'; +import { toggleUserSurfacedPermission } from 'lib/utils/role-utils.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, @@ -755,6 +759,58 @@ ops: {}, }; }: MigrationFunction<WebNavInfo, AppState>), + [88]: (async (state: AppState) => { + const sharedWorker = await getCommSharedWorker(); + const isDatabaseSupported = await sharedWorker.isSupported(); + + if (!isDatabaseSupported) { + return { + state, + ops: {}, + }; + } + + const stores = await sharedWorker.schedule({ + type: workerRequestMessageTypes.GET_CLIENT_STORE, + }); + + const clientDBThreadInfos: ?$ReadOnlyArray<ClientDBThreadInfo> = + stores?.store?.threads; + + if ( + clientDBThreadInfos === null || + clientDBThreadInfos === undefined || + clientDBThreadInfos.length === 0 + ) { + return { + state, + ops: {}, + }; + } + + const rawThreadInfos = clientDBThreadInfos.map( + convertClientDBThreadInfoToRawThreadInfo, + ); + + const keyedRawThreadInfos = _keyBy('id')(rawThreadInfos); + // This function also results in setting DELETE_OWN_MESSAGES and + // DELETE_ALL_MESSAGES for admins. It is added automatically based on the + // result of getRolePermissionBlobs call. + const { operations } = updateRolesAndPermissions( + keyedRawThreadInfos, + (permissions: ThreadRolePermissionsBlob) => + toggleUserSurfacedPermission( + permissions, + userSurfacedPermissions.DELETE_OWN_MESSAGES, + ), + ); + return { + state, + ops: { + threadStoreOperations: operations, + }, + }; + }: MigrationFunction<WebNavInfo, AppState>), }; const persistConfig: PersistConfig = {