toeverything/AFFiNE · error · SpaceAccessDenied

space_access_denied

space_access_denied

Error message

You do not have permission to access Space ${spaceId}.

What it means

Thrown by QuotaStateRealtimeProvider.assertWorkspace when the realtime handler cannot find an active workspaceUser membership record for the connected user and target workspace. It guards the workspace.quota-state.get live query and the workspace.quota-state.changed topic authorization, so non-members cannot subscribe to another workspace's quota feed.

Source

Thrown at packages/backend/server/src/core/quota/realtime.ts:135

    );
  }

  @OnEvent('workspace.quota_state.changed', { suppressError: true })
  async onWorkspaceQuotaStateChanged({
    workspaceId,
  }: Events['workspace.quota_state.changed']) {
    this.publisher?.publish(
      'workspace.quota-state.changed',
      { workspaceId },
      { changed: true },
      { room: realtimeWorkspaceQuotaStateRoom(workspaceId) }
    );
  }

  private async assertWorkspace(userId: string, workspaceId: string) {
    const role = await this.models.workspaceUser.getActive(workspaceId, userId);
    if (!role) {
      throw new SpaceAccessDenied({ spaceId: workspaceId });
    }
  }

  private serializeState<T extends Record<string, unknown>>(state: T) {
    return Object.fromEntries(
      Object.entries(state).map(([key, value]) => [
        key,
        typeof value === 'bigint' ? Number(value) : value,
      ])
    );
  }
}

View on GitHub (pinned to 26c515e050)

Solutions

  1. Confirm workspaceUser membership exists (models.workspaceUser.getActive) before subscribing the client.
  2. Have the client drop the subscription when it receives a workspace membership-revoked event.
  3. Validate the workspaceId in the envelope belongs to the current user's workspace set on the gateway before forwarding to the provider.

Example fix

// before
await this.assertWorkspace(user.id, payload.workspaceId);

// after
const role = await this.models.workspaceUser.getActive(payload.workspaceId, user.id);
if (!role) throw new SpaceAccessDenied({ spaceId: payload.workspaceId });
Defensive patterns

Strategy: validation

Validate before calling

const role = await models.workspaceUser.getActive(workspaceId, user.id);
if (!role) {
  // do not subscribe; tell the client to leave the workspace
  socket.emit('workspace.membership.lost', { workspaceId });
  return;
}

Type guard

// n/a — runtime membership lookup, not a type narrowing

Try / catch

try {
  await this.assertWorkspace(user.id, payload.workspaceId);
} catch (e) {
  if (e instanceof SpaceAccessDenied) {
    // unsubscribe the socket and notify the client
    await client.leave(realtimeWorkspaceQuotaStateRoom(payload.workspaceId));
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Realtime socket emits workspace.quota-state.get or subscribes to workspace.quota-state.changed with a workspaceId the authenticated user is not a member of; user was removed from the workspace while the socket was open; workspaceId is malformed or belongs to another tenant.

Common situations: Client reused a stale workspaceId after leaving a workspace; multi-workspace client subscribing to all known workspaces without verifying membership; race between revocation and the realtime subscription.

Related errors


AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12). Data as JSON: /api/errors/0ca5eb676291180e. Report an issue: GitHub.