fix(server): sync permission check (#15123)

fix #15121



#### PR Dependency Tree


* **PR #15123** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Security Improvements**
* Enforced document-level `Doc.Read`/`Doc.Update` checks for key sync
websocket operations, including filtering workspace doc timestamp
results to only readable documents.
* Improved remote permission handling: once a remote denies access,
syncing stops for the affected document and retry behavior is
suppressed.
* **Improvements**
* `delete-doc` now relies on server acknowledgment and returns an
explicit `{ success: true }`.
* Websocket acknowledgment errors are now normalized for consistent
error details.
* **Tests**
* Expanded permission-denied and websocket error-handling coverage,
including timestamp filtering and no-retry behavior after permission
denial.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-18 02:43:25 +08:00
committed by GitHub
parent da7781a751
commit 1256d66938
6 changed files with 666 additions and 42 deletions
@@ -633,6 +633,7 @@ export class SpaceSyncGateway
@SubscribeMessage('space:load-doc')
async onLoadSpaceDoc(
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
{ spaceType, spaceId, docId, stateVector }: LoadDocMessage
): Promise<
@@ -641,6 +642,13 @@ export class SpaceSyncGateway
const id = new DocID(docId, spaceId);
const adapter = this.selectAdapter(client, spaceType);
adapter.assertIn(spaceId);
await this.assertDocActionAllowed(
spaceType,
user.id,
spaceId,
id.guid,
'Doc.Read'
);
const doc = await adapter.diff(
spaceId,
@@ -666,7 +674,7 @@ export class SpaceSyncGateway
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody() { spaceType, spaceId, docId }: DeleteDocMessage
) {
): Promise<EventResponse<{ success: true }>> {
const adapter = this.selectAdapter(client, spaceType);
await this.assertDocActionAllowed(
spaceType,
@@ -676,6 +684,7 @@ export class SpaceSyncGateway
'Doc.Delete'
);
await adapter.delete(spaceId, docId);
return { data: { success: true } };
}
/**
@@ -692,8 +701,13 @@ export class SpaceSyncGateway
const adapter = this.selectAdapter(client, spaceType);
// Quota recovery mode is intentionally not applied to sync in this phase.
// TODO(@forehalo): enable after frontend supporting doc revert
// await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update');
await this.assertDocActionAllowed(
spaceType,
user.id,
spaceId,
docId,
'Doc.Update'
);
const timestamp = await adapter.push(
spaceId,
docId,
@@ -740,15 +754,32 @@ export class SpaceSyncGateway
@SubscribeMessage('space:load-doc-timestamps')
async onLoadDocTimestamps(
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
{ spaceType, spaceId, timestamp }: LoadDocTimestampsMessage
): Promise<EventResponse<Record<string, number>>> {
const adapter = this.selectAdapter(client, spaceType);
const stats = await adapter.getTimestamps(spaceId, timestamp);
if (!stats || spaceType === SpaceType.Userspace) {
return {
data: stats ?? {},
};
}
const readableDocs = await this.ac
.user(user.id)
.workspace(spaceId)
.docs(
Object.keys(stats).map(docId => ({ docId })),
'Doc.Read'
);
const readableDocIds = new Set(readableDocs.map(doc => doc.docId));
return {
data: stats ?? {},
data: Object.fromEntries(
Object.entries(stats).filter(([docId]) => readableDocIds.has(docId))
),
};
}