Skip to content
28 changes: 19 additions & 9 deletions packages/datatrak-web/src/sync/ClientSyncManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
dropSnapshotTable,
getModelsForPull,
getModelsForPush,
hasDescendantPermissionChangeInSnapshot,
hasSyncSnapshotRecords,
saveChangesFromMemory,
saveIncomingSnapshotChanges,
Expand Down Expand Up @@ -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);
Comment thread
jaskfla marked this conversation as resolved.

if ((await hasUserEntityPermissionChange()) || (await hasPermissionHierarchyChange())) {
await this.updatePermissionsChanged(true);
}
}
Expand Down
85 changes: 85 additions & 0 deletions packages/sync/src/utils/hasDescendantPermissionChangeInSnapshot.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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
Comment on lines +23 to +35
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of today, the maximum permission group tree height is 7

)`,
{ headroom: 10 },
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw builder object used as named binding value

High Severity

maxDepth is assigned a Knex Raw builder (the unawaited return of database.connection.raw(...)) and then passed as a value inside a named-bindings object to executeSql. While Knex supports QueryBuilder/Raw instances as positional bindings (array elements — as seen in BaseDatabase.exists()), its support for Raw instances as values in named-binding objects (:maxDepth) is undocumented and has no precedent in this codebase. If the named-binding path doesn't inline the Raw object, it would serialize as [object Object], causing a PostgreSQL type error or incorrect comparison in w.depth < :maxDepth. A safer approach is to inline the subquery SQL directly into the main query string or use a hardcoded constant.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c1a1ec2. Configure here.


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;
};
19 changes: 10 additions & 9 deletions packages/sync/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
jaskfla marked this conversation as resolved.
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';
Loading