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
@@ -176,6 +176,31 @@ function createYjsUpdateBase64() {
return Buffer.from(update).toString('base64');
}
async function createSnapshot(
db: PrismaClient,
input: {
workspaceId: string;
docId: string;
userId: string;
blob?: Buffer;
state?: Buffer;
updatedAt?: Date;
}
) {
await db.snapshot.create({
data: {
id: input.docId,
workspaceId: input.workspaceId,
blob: input.blob ?? Buffer.from([1, 1]),
state: input.state ?? Buffer.from([1, 1]),
createdAt: input.updatedAt ?? new Date(),
updatedAt: input.updatedAt ?? new Date(),
createdBy: input.userId,
updatedBy: input.userId,
},
});
}
async function ensureSyncActiveUsersTable(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
@@ -612,17 +637,10 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await db.snapshot.create({
data: {
id: docId,
workspaceId: workspace.id,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
createdAt: new Date(),
updatedAt: new Date(),
createdBy: owner.id,
updatedBy: owner.id,
},
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
@@ -657,3 +675,206 @@ test('workspace sync delete-doc should enforce doc permissions', async t => {
socket.disconnect();
}
});
test('workspace sync load-doc should enforce doc read permissions', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const docId = 'private-load-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const error = getErrorResponse(
t,
await emitWithAck(socket, 'space:load-doc', {
spaceType: 'workspace',
spaceId: workspace.id,
docId,
})
);
t.true(error.message.includes('Doc.Read'));
} finally {
socket.disconnect();
}
});
test('workspace sync push-doc-update should enforce doc update permissions', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const docId = 'readonly-push-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, docId, DocRole.None);
await models.docUser.set(
workspace.id,
docId,
collaborator.id,
DocRole.Reader
);
await createSnapshot(db, {
workspaceId: workspace.id,
docId,
userId: owner.id,
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const error = getErrorResponse(
t,
await emitWithAck(socket, 'space:push-doc-update', {
spaceType: 'workspace',
spaceId: workspace.id,
docId,
update: createYjsUpdateBase64(),
})
);
t.true(error.message.includes('Doc.Update'));
const updates = await db.update.count({
where: {
workspaceId: workspace.id,
id: docId,
},
});
t.is(updates, 0);
} finally {
socket.disconnect();
}
});
test('workspace sync load-doc-timestamps should filter unreadable docs', async t => {
const db = app.get(PrismaClient);
const models = app.get(Models);
const { user: owner } = await login(app);
const { user: collaborator, cookieHeader } = await login(app);
const workspace = await models.workspace.create(owner.id);
const privateDocId = 'private-timestamp-doc';
const readableDocId = 'readable-timestamp-doc';
await models.workspaceUser.set(
workspace.id,
collaborator.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
await models.doc.setDefaultRole(workspace.id, privateDocId, DocRole.None);
await models.doc.setDefaultRole(workspace.id, readableDocId, DocRole.None);
await models.docUser.set(
workspace.id,
readableDocId,
collaborator.id,
DocRole.Reader
);
await createSnapshot(db, {
workspaceId: workspace.id,
docId: privateDocId,
userId: owner.id,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
await createSnapshot(db, {
workspaceId: workspace.id,
docId: readableDocId,
userId: owner.id,
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
const socket = createClient(url, cookieHeader);
try {
await waitForConnect(socket);
const join = unwrapResponse(
t,
await emitWithAck<{ clientId: string; success: boolean }>(
socket,
'space:join',
{
spaceType: 'workspace',
spaceId: workspace.id,
clientVersion: '0.26.0',
}
)
);
t.true(join.success);
const timestamps = unwrapResponse(
t,
await emitWithAck<Record<string, number>>(
socket,
'space:load-doc-timestamps',
{
spaceType: 'workspace',
spaceId: workspace.id,
}
)
);
t.false(privateDocId in timestamps);
t.true(readableDocId in timestamps);
} finally {
socket.disconnect();
}
});
@@ -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))
),
};
}