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 authored and GitHub committed 2026-06-18 02:43:25 +08:00
1 parent da7781a751
commit 1256d66938
6 files changed
+666 -42

No files matched your search

@@ -33,6 +33,7 @@ import {
SpaceStorage,
} from '../storage';
import { Sync } from '../sync';
import { DocSyncPeer } from '../sync/doc/peer';
import { IndexerSyncImpl } from '../sync/indexer';
import { expectYjsEqual } from './utils';
@@ -112,6 +113,64 @@ class TestDocStorage implements DocStorage {
}
}
class PermissionDeniedRemoteDocStorage implements DocStorage {
readonly storageType = 'doc' as const;
readonly connection = new DummyConnection();
readonly isReadonly = false;
pushCount = 0;
constructor(readonly spaceId: string) {}
async getDoc(_docId: string): Promise<DocRecord | null> {
return null;
}
async getDocDiff(
_docId: string,
_state?: Uint8Array
): Promise<DocDiff | null> {
return null;
}
async pushDocUpdate(_update: DocUpdate): Promise<DocClock> {
this.pushCount++;
const error = new Error('No permission to update doc');
error.name = 'DOC_ACTION_DENIED';
throw error;
}
async getDocTimestamp(_docId: string): Promise<DocClock | null> {
return null;
}
async getDocTimestamps(): Promise<DocClocks> {
return {};
}
async deleteDoc(_docId: string): Promise<void> {
return;
}
subscribeDocUpdate(_callback: (update: DocRecord, origin?: string) => void) {
return () => {};
}
}
class PermissionDeniedConnection extends DummyConnection {
waitCount = 0;
override async waitForConnected(_signal?: AbortSignal): Promise<void> {
this.waitCount++;
const error = new Error('No permission to access space');
error.name = 'SPACE_ACCESS_DENIED';
throw error;
}
}
class PermissionDeniedConnectionDocStorage extends PermissionDeniedRemoteDocStorage {
override readonly connection = new PermissionDeniedConnection();
}
class TrackingIndexerStorage extends IndexerStorageBase {
override readonly connection = new DummyConnection();
override readonly isReadonly = false;
@@ -425,6 +484,201 @@ test('blob', async () => {
}
});
test('doc sync peer stops retrying a doc when remote denies permission', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-denied',
flavour: 'local-denied',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-denied',
flavour: 'local-denied',
type: 'workspace',
});
const remote = new PermissionDeniedRemoteDocStorage('ws-denied');
const peer = new DocSyncPeer('remote-denied', local, syncMetadata, remote);
const abort = new AbortController();
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
const doc = new YDoc();
doc.getMap('test').set('hello', 'world');
await local.pushDocUpdate({
docId: 'doc-denied',
bin: encodeStateAsUpdate(doc),
});
try {
void peer.mainLoop(abort.signal);
await vi.waitFor(() => {
expect(remote.pushCount).toBe(1);
});
await vi.waitFor(() => {
let state:
| {
syncing: boolean;
synced: boolean;
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.docState$('doc-denied').subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
syncing: false,
synced: false,
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await vi.waitFor(() => {
let state:
| {
synced: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
synced: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await new Promise(resolve => setTimeout(resolve, 1200));
expect(remote.pushCount).toBe(1);
} finally {
abort.abort();
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('doc sync peer stops retrying when remote connection denies permission', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-connection-denied',
flavour: 'local-connection-denied',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-connection-denied',
flavour: 'local-connection-denied',
type: 'workspace',
});
const remote = new PermissionDeniedConnectionDocStorage(
'ws-connection-denied'
);
const peer = new DocSyncPeer(
'remote-connection-denied',
local,
syncMetadata,
remote
);
const abort = new AbortController();
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
try {
void peer.mainLoop(abort.signal);
await vi.waitFor(() => {
expect(remote.connection.waitCount).toBe(1);
});
await vi.waitFor(() => {
let state:
| {
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
});
await new Promise(resolve => setTimeout(resolve, 1200));
expect(remote.connection.waitCount).toBe(1);
} finally {
abort.abort();
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('doc sync peer resolves on terminal permission error without abort signal', async () => {
const local = new IndexedDBDocStorage({
id: 'ws-connection-denied-no-signal',
flavour: 'local-connection-denied-no-signal',
type: 'workspace',
});
const syncMetadata = new IndexedDBDocSyncStorage({
id: 'ws-connection-denied-no-signal',
flavour: 'local-connection-denied-no-signal',
type: 'workspace',
});
const remote = new PermissionDeniedConnectionDocStorage(
'ws-connection-denied-no-signal'
);
const peer = new DocSyncPeer(
'remote-connection-denied-no-signal',
local,
syncMetadata,
remote
);
local.connection.connect();
syncMetadata.connection.connect();
await local.connection.waitForConnected();
await syncMetadata.connection.waitForConnected();
try {
await expect(peer.mainLoop()).resolves.toBeUndefined();
expect(remote.connection.waitCount).toBe(1);
let state:
| {
retrying: boolean;
errorMessage: string | null;
}
| undefined;
const dispose = peer.peerState$.subscribe(next => {
state = next;
});
dispose.unsubscribe();
expect(state).toMatchObject({
retrying: false,
errorMessage: expect.stringContaining('No permission'),
});
} finally {
local.connection.disconnect();
syncMetadata.connection.disconnect();
}
});
test('indexer defers indexed clock persistence until a refresh happens on delayed refresh storages', async () => {
const calls: string[] = [];
const docsInRootDoc = new Map([['doc1', { title: 'Doc 1' }]]);