diff --git a/packages/datatrak-web/src/sync/ClientSyncManager.ts b/packages/datatrak-web/src/sync/ClientSyncManager.ts index 1cfefd0d93..12cd66a258 100644 --- a/packages/datatrak-web/src/sync/ClientSyncManager.ts +++ b/packages/datatrak-web/src/sync/ClientSyncManager.ts @@ -9,6 +9,7 @@ import { dropSnapshotTable, getModelsForPull, getModelsForPush, + hasDescendantPermissionChangeInSnapshot, hasSyncSnapshotRecords, saveChangesFromMemory, saveIncomingSnapshotChanges, @@ -345,21 +346,30 @@ export class ClientSyncManager { return { pulledChangesCount }; } + /** + * User’s access policy changes when: + * - a `user_entity_permission` linked to them is added/updated/deleted; or + * - a descendant `permission_group` they has access to is added/updated/deleted. + */ async checkForPermissionChanges(sessionId: string) { const currentUserId = ensure( await this.models.localSystemFact.get(SyncFact.CURRENT_USER_ID), 'Couldn’t check for permission changes. No one is logged in.', ); - const hasPermissionChange = await hasSyncSnapshotRecords( - this.database, - sessionId, - undefined, - this.models.userEntityPermission.databaseRecord, - "data->>'user_id' = :userId", - { userId: currentUserId }, - ); - if (hasPermissionChange) { + const hasUserEntityPermissionChange = async () => + await hasSyncSnapshotRecords( + this.database, + sessionId, + undefined, + this.models.userEntityPermission.databaseRecord, + "data->>'user_id' = :userId", + { userId: currentUserId }, + ); + const hasPermissionHierarchyChange = async () => + await hasDescendantPermissionChangeInSnapshot(this.database, sessionId, currentUserId); + + if ((await hasUserEntityPermissionChange()) || (await hasPermissionHierarchyChange())) { await this.updatePermissionsChanged(true); } } diff --git a/packages/sync/src/utils/hasDescendantPermissionChangeInSnapshot.ts b/packages/sync/src/utils/hasDescendantPermissionChangeInSnapshot.ts new file mode 100644 index 0000000000..cadd663f78 --- /dev/null +++ b/packages/sync/src/utils/hasDescendantPermissionChangeInSnapshot.ts @@ -0,0 +1,85 @@ +import { type BaseDatabase, RECORDS } from '@tupaia/database'; +import type { UserAccount } from '@tupaia/types'; +import { getSnapshotTableName } from './manageSnapshotTable'; + +/** + * Returns `true` if the sync snapshot for `sessionId` includes a change to a descendant of a + * permission_group the user already has access to. Does not detect new `permission_group`s granted + * to the user via `user_entity_permission`. + */ +export const hasDescendantPermissionChangeInSnapshot = async ( + database: BaseDatabase, + sessionId: string, + userId: UserAccount['id'], +): Promise => { + const snapshotTable = getSnapshotTableName(sessionId); + + /** + * @privateRemarks `max(depth)` is tree height, plus headroom to account for tree height + * increasing since last sync. Choice of 10 is arbitrary. + */ + const maxDepth = database.connection.raw( + `( + WITH RECURSIVE tree AS ( + SELECT id, parent_id, 1 AS depth + FROM permission_group + WHERE parent_id IS NULL + + UNION ALL + + SELECT pg.id, pg.parent_id, t.depth + 1 + FROM permission_group pg + INNER JOIN tree t ON pg.parent_id = t.id + ) + SELECT max(depth) + :headroom AS height + FROM tree + )`, + { headroom: 10 }, + ); + + const [{ matches }] = await database.executeSql<[{ matches: boolean }]>( + ` + WITH RECURSIVE user_perm AS ( + SELECT DISTINCT permission_group_id + FROM user_entity_permission + WHERE user_id = :userId + ), + walk AS ( + SELECT DISTINCT (s.data ->> 'id') AS current_id, 0 AS depth + FROM ${snapshotTable} s + WHERE s.record_type = :recordType + + UNION + + SELECT pl.parent_id, w.depth + 1 + FROM walk w + INNER JOIN LATERAL ( + SELECT + coalesce( + (SELECT pg.parent_id FROM permission_group pg WHERE pg.id = w.current_id), + ( + SELECT NULLIF (s2.data ->> 'parent_id', '') + FROM ${snapshotTable} s2 + WHERE s2.record_type = :recordType AND s2.data ->> 'id' = w.current_id + LIMIT 1 + ) + ) AS parent_id + ) pl ON pl.parent_id IS NOT NULL + WHERE w.depth < :maxDepth + ) + + SELECT EXISTS ( + SELECT 1 + FROM walk w + INNER JOIN user_perm u ON u.permission_group_id = w.current_id + ) AS matches + `, + { + userId, + recordType: RECORDS.PERMISSION_GROUP, + maxDepth, + }, + ); + + return matches; +}; diff --git a/packages/sync/src/utils/index.ts b/packages/sync/src/utils/index.ts index aa04f19cd4..e5583ebad8 100644 --- a/packages/sync/src/utils/index.ts +++ b/packages/sync/src/utils/index.ts @@ -1,15 +1,16 @@ -export * from './waitForPendingEditsUsingSyncTick'; +export * from './bumpSyncTickForRepull'; +export * from './completeSyncSession'; +export * from './countSyncSnapshotRecords'; +export * from './findLastSuccessfulSyncedProjects'; +export * from './getDependencyOrder'; export * from './getModelsForDirection'; export * from './getSyncTicksOfPendingEdits'; +export { hasDescendantPermissionChangeInSnapshot } from './hasDescendantPermissionChangeInSnapshot'; +export * from './incomingSyncHook'; +export * from './logMemory'; export * from './manageSnapshotTable'; -export * from './countSyncSnapshotRecords'; -export * from './getDependencyOrder'; -export * from './saveIncomingChanges'; -export * from './completeSyncSession'; export * from './sanitizeRecord'; +export * from './saveIncomingChanges'; export * from './startSnapshotWhenCapacityAvailable'; +export * from './waitForPendingEditsUsingSyncTick'; export * from './withDeferredSyncSafeguards'; -export * from './findLastSuccessfulSyncedProjects'; -export * from './incomingSyncHook'; -export * from './bumpSyncTickForRepull'; -export * from './logMemory';