mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 16:19:43 +08:00
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:
@@ -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))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user