From 0615bcf222f178af6667b35558602c6480db69c9 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:28:52 +0100 Subject: [PATCH] refactor(base): route WS subscribe through pageAccessService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseWsService.subscribe was the last surface that didn't go through the page-permission system. It checked authorization with a bespoke canReadBaseSpace(userId, spaceId) — which queried space membership directly and accepted ANY space role — so a user with a per-base restriction (revoked access via pagePermissionRepo) could still stream live updates and presence for a base they couldn't otherwise read. Replace it with pageAccessService.validateCanView(base, user) — the same gate the HTTP endpoints (info, list, rows query, etc.) and the page collab WS already use. Bases are pages structurally (isBase=true), so reusing the page validator keeps them on a single permission code path. Drops the now-unused SpaceMemberRepo / findHighestUserSpaceRole imports; injects UserRepo + PageAccessService instead (both are globally provided modules, no DI changes needed). --- .../src/core/base/realtime/base-ws.service.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/server/src/core/base/realtime/base-ws.service.ts b/apps/server/src/core/base/realtime/base-ws.service.ts index 13f67fdeb..199da537a 100644 --- a/apps/server/src/core/base/realtime/base-ws.service.ts +++ b/apps/server/src/core/base/realtime/base-ws.service.ts @@ -1,11 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { z } from 'zod'; import type { Server, Socket } from 'socket.io'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { BaseRepo } from '@docmost/db/repos/base/base.repo'; -import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { getBaseRoomName } from '../../../ws/ws.utils'; import { BasePresenceService, PresenceEntry } from './base-presence.service'; +import { PageAccessService } from '../../page/page-access/page-access.service'; /* * Inbound shapes from untrusted socket clients. Zod-validated at the @@ -52,7 +52,8 @@ export class BaseWsService { constructor( private readonly baseRepo: BaseRepo, - private readonly spaceMemberRepo: SpaceMemberRepo, + private readonly userRepo: UserRepo, + private readonly pageAccessService: PageAccessService, private readonly presence: BasePresenceService, ) {} @@ -121,7 +122,8 @@ export class BaseWsService { private async subscribe(client: Socket, pageId: string): Promise { const userId = client.data?.userId as string | undefined; - if (!userId) { + const workspaceId = client.data?.workspaceId as string | undefined; + if (!userId || !workspaceId) { client.emit('message', { operation: 'base:subscribe:error', pageId, @@ -140,7 +142,7 @@ export class BaseWsService { return; } - const canRead = await this.canReadBaseSpace(userId, base.spaceId); + const canRead = await this.canReadBase(userId, workspaceId, base); if (!canRead) { client.emit('message', { operation: 'base:subscribe:error', @@ -215,12 +217,28 @@ export class BaseWsService { }); } - private async canReadBaseSpace( + // Bases are pages — gate the WS subscribe through the same + // pageAccessService.validateCanView check the rest of the base + // surface (HTTP endpoints, page collab) uses, so per-page + // restrictions / sharing rules apply uniformly. This used to do a + // bare "is the user a member of the space at all" check, which let + // a user with a per-base restriction stream live updates for a + // base they couldn't otherwise read. + private async canReadBase( userId: string, - spaceId: string, + workspaceId: string, + base: { id: string; spaceId: string }, ): Promise { - const roles = await this.spaceMemberRepo.getUserSpaceRoles(userId, spaceId); - return !!findHighestUserSpaceRole(roles); + const user = await this.userRepo.findById(userId, workspaceId); + if (!user) return false; + try { + // Pass the base as a Page — same shape (isBase=true), and + // validateCanView only reads `id` + `spaceId` off it. + await this.pageAccessService.validateCanView(base as any, user); + return true; + } catch { + return false; + } } private subscriptionsFor(client: Socket): Set {