diff --git a/bun.lock b/bun.lock index 849269e0794..033da3635b8 100644 --- a/bun.lock +++ b/bun.lock @@ -446,6 +446,7 @@ "version": "0.1.0", "dependencies": { "@sim/db": "workspace:*", + "@sim/realtime-protocol": "workspace:*", "drizzle-orm": "^0.45.2", }, "devDependencies": { @@ -463,6 +464,7 @@ "devDependencies": { "@sim/tsconfig": "workspace:*", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/runtime-secrets": { diff --git a/packages/platform-authz/package.json b/packages/platform-authz/package.json index fd989954ec1..d8d0bca5b03 100644 --- a/packages/platform-authz/package.json +++ b/packages/platform-authz/package.json @@ -21,6 +21,10 @@ "./workflow": { "types": "./src/workflow.ts", "default": "./src/workflow.ts" + }, + "./rooms": { + "types": "./src/rooms.ts", + "default": "./src/rooms.ts" } }, "scripts": { @@ -32,6 +36,7 @@ }, "dependencies": { "@sim/db": "workspace:*", + "@sim/realtime-protocol": "workspace:*", "drizzle-orm": "^0.45.2" }, "devDependencies": { diff --git a/packages/platform-authz/src/rooms.ts b/packages/platform-authz/src/rooms.ts new file mode 100644 index 00000000000..1fd9949333e --- /dev/null +++ b/packages/platform-authz/src/rooms.ts @@ -0,0 +1,133 @@ +import { db, workspace } from '@sim/db' +import { ROOM_TYPES, type RoomRef, type RoomType } from '@sim/realtime-protocol/rooms' +import { and, eq, isNull } from 'drizzle-orm' +import { getActiveWorkflowContext } from './workflow' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from './workspace' + +export type { PermissionType, RoomRef, RoomType } + +/** + * The owning workspace of a room, plus the org that owns that workspace — the + * exact inputs {@link resolveEffectiveWorkspacePermission} needs. + */ +export interface RoomWorkspace { + workspaceId: string + workspaceOrganizationId: string | null +} + +/** + * Resolves a room's owning workspace from its {@link RoomRef.id}. Returns `null` + * when the underlying resource is missing/archived (→ a 404 authorization + * result). One resolver per {@link RoomType}; this is the single place a new + * room type declares its resource→workspace lookup. + */ +export type RoomWorkspaceResolver = (roomId: string) => Promise + +async function resolveWorkspaceRoomWorkspace(workspaceId: string): Promise { + const [row] = await db + .select({ id: workspace.id, organizationId: workspace.organizationId }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + return row ? { workspaceId: row.id, workspaceOrganizationId: row.organizationId } : null +} + +/** + * Single source of truth mapping each room type to its resource→workspace + * lookup. Every realtime room is workspace-scoped and authorizes through the + * same effective-permission resolver, so a room type only has to say *which* + * workspace it belongs to. Adding a room type = adding one entry here. + */ +const ROOM_WORKSPACE_RESOLVERS: Record = { + [ROOM_TYPES.WORKFLOW]: async (workflowId) => { + const context = await getActiveWorkflowContext(workflowId) + if (!context?.workspaceId) return null + return { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + } + }, + // A workspace-files room is addressed directly by its workspace id. + [ROOM_TYPES.WORKSPACE_FILES]: resolveWorkspaceRoomWorkspace, +} + +/** Resolves a room's owning workspace, or `null` if the room resource is gone. */ +export function resolveWorkspaceIdForRoom(room: RoomRef): Promise { + return ROOM_WORKSPACE_RESOLVERS[room.type](room.id) +} + +export interface RoomAuthorizationResult { + allowed: boolean + status: number + message?: string + workspaceId: string | null + workspacePermission: PermissionType | null +} + +/** + * Authorizes a user against a realtime room. Mirrors + * `authorizeWorkflowByWorkspacePermission` (the exemplary workflow authorizer) + * but generalized over room type: resolve the room's workspace, then gate on the + * user's effective workspace permission under the read < write < admin ordering. + * + * Returns a denial (never throws) for unknown room type (400), missing/archived + * resource (404), and insufficient permission (403), so realtime handlers and + * SSE routes can map the `status` to a wire error uniformly. + */ +export async function authorizeRoom(params: { + userId: string + room: RoomRef + action?: PermissionType +}): Promise { + const { userId, room, action = 'read' } = params + + const resolver = ROOM_WORKSPACE_RESOLVERS[room.type] + if (!resolver) { + return { + allowed: false, + status: 400, + message: `Unknown room type: ${room.type}`, + workspaceId: null, + workspacePermission: null, + } + } + + const roomWorkspace = await resolver(room.id) + if (!roomWorkspace) { + return { + allowed: false, + status: 404, + message: 'Room not found', + workspaceId: null, + workspacePermission: null, + } + } + + const workspacePermission = await resolveEffectiveWorkspacePermission( + userId, + roomWorkspace.workspaceId, + roomWorkspace.workspaceOrganizationId + ) + + if (!permissionSatisfies(workspacePermission, action)) { + return { + allowed: false, + status: 403, + message: `Access denied to ${action} this room`, + workspaceId: roomWorkspace.workspaceId, + workspacePermission, + } + } + + return { + allowed: true, + status: 200, + workspaceId: roomWorkspace.workspaceId, + workspacePermission, + } +} diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json index a4248404adc..4f0ea685ebe 100644 --- a/packages/realtime-protocol/package.json +++ b/packages/realtime-protocol/package.json @@ -21,10 +21,16 @@ "./schemas": { "types": "./src/schemas.ts", "default": "./src/schemas.ts" + }, + "./rooms": { + "types": "./src/rooms.ts", + "default": "./src/rooms.ts" } }, "scripts": { "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", @@ -35,6 +41,7 @@ }, "devDependencies": { "@sim/tsconfig": "workspace:*", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/packages/realtime-protocol/src/rooms.test.ts b/packages/realtime-protocol/src/rooms.test.ts new file mode 100644 index 00000000000..6ab07de542e --- /dev/null +++ b/packages/realtime-protocol/src/rooms.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_ROOM_TYPES, + isRoomType, + isSameRoom, + parseRoomName, + ROOM_TYPES, + type RoomRef, + roomName, +} from './rooms' + +describe('roomName', () => { + it('maps a workflow room to its bare id (backward compatibility)', () => { + expect(roomName({ type: ROOM_TYPES.WORKFLOW, id: 'wf-123' })).toBe('wf-123') + }) + + it('namespaces every non-workflow room type', () => { + expect(roomName({ type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-123' })).toBe( + 'workspace-files:ws-123' + ) + }) + + it('never collides a namespaced room with a bare workflow id for real ids', () => { + // Room ids in Sim are opaque tokens without a colon (UUIDs / short ids), so a + // bare workflow id can never look like a `${type}:${id}` namespaced name. + const workflow = roomName({ type: ROOM_TYPES.WORKFLOW, id: 'a1b2c3d4-uuid' }) + const files = roomName({ type: ROOM_TYPES.WORKSPACE_FILES, id: 'a1b2c3d4-uuid' }) + expect(workflow).not.toBe(files) + expect(workflow.includes(':')).toBe(false) + }) +}) + +describe('parseRoomName', () => { + it('round-trips every room type through roomName', () => { + const refs: RoomRef[] = [ + { type: ROOM_TYPES.WORKFLOW, id: 'wf-123' }, + { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-456' }, + ] + for (const ref of refs) { + expect(parseRoomName(roomName(ref))).toEqual(ref) + } + }) + + it('treats an unprefixed name as a workflow room', () => { + expect(parseRoomName('bare-uuid')).toEqual({ type: ROOM_TYPES.WORKFLOW, id: 'bare-uuid' }) + }) + + it('preserves ids that themselves contain colons', () => { + expect(parseRoomName('workspace-files:a:b:c')).toEqual({ + type: ROOM_TYPES.WORKSPACE_FILES, + id: 'a:b:c', + }) + }) + + it('does not treat an unknown prefix as a room type', () => { + expect(parseRoomName('unknown:x')).toEqual({ type: ROOM_TYPES.WORKFLOW, id: 'unknown:x' }) + }) + + it('returns null for the empty string', () => { + expect(parseRoomName('')).toBeNull() + }) +}) + +describe('isRoomType', () => { + it('accepts known types and rejects others', () => { + expect(isRoomType(ROOM_TYPES.WORKFLOW)).toBe(true) + expect(isRoomType(ROOM_TYPES.WORKSPACE_FILES)).toBe(true) + expect(isRoomType('nope')).toBe(false) + }) +}) + +describe('isSameRoom', () => { + it('compares by type and id', () => { + const a: RoomRef = { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-1' } + expect(isSameRoom(a, { ...a })).toBe(true) + expect(isSameRoom(a, { type: ROOM_TYPES.WORKFLOW, id: 'ws-1' })).toBe(false) + expect(isSameRoom(a, { type: ROOM_TYPES.WORKSPACE_FILES, id: 'ws-2' })).toBe(false) + }) +}) + +describe('ALL_ROOM_TYPES', () => { + it('contains every declared room type', () => { + expect([...ALL_ROOM_TYPES].sort()).toEqual([...Object.values(ROOM_TYPES)].sort()) + }) +}) diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts new file mode 100644 index 00000000000..994bf6b3cac --- /dev/null +++ b/packages/realtime-protocol/src/rooms.ts @@ -0,0 +1,89 @@ +/** + * Room identity for the realtime layer. + * + * A {@link RoomRef} is the universal address shared by every realtime mechanism + * in Sim — the Socket.IO presence server (`apps/realtime`), the durable SSE + * event log, and the ephemeral pub/sub fanout. Each mechanism encodes a room + * differently on the wire, but they all agree on this `{ type, id }` identity + * and authorize it through the same workspace-permission resolver + * (`@sim/platform-authz/rooms`). + * + * This module is pure (no runtime dependencies) so both `apps/sim` and + * `apps/realtime` can import it. + */ + +/** + * The kinds of realtime room. Each value is a stable wire token — changing one + * is a breaking protocol change (it renames Socket.IO rooms and Redis keys), so + * treat these like enum values that ship to clients. + */ +export const ROOM_TYPES = { + /** The collaborative workflow editor canvas (one room per workflow). */ + WORKFLOW: 'workflow', + /** The workspace file browser (one room per workspace). */ + WORKSPACE_FILES: 'workspace-files', +} as const + +export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] + +/** Every known room type, for exhaustive iteration/validation. */ +export const ALL_ROOM_TYPES = Object.values(ROOM_TYPES) as readonly RoomType[] + +/** Universal address of a realtime room. */ +export interface RoomRef { + type: RoomType + id: string +} + +/** Type guard: whether an arbitrary string is a known {@link RoomType}. */ +export function isRoomType(value: string): value is RoomType { + return (ALL_ROOM_TYPES as readonly string[]).includes(value) +} + +/** + * The Socket.IO room name (and default key segment) for a room. + * + * `WORKFLOW` maps to the **bare id** — deliberately. The workflow editor has + * ~40 existing `io.to(workflowId)` / `socket.join(workflowId)` callsites that + * pass the bare workflow id, plus stale-cleanup that cross-references + * `io.in(workflowId).fetchSockets()` against Redis presence state. Preserving + * the bare name keeps every one of those callsites correct with zero diff and + * zero presence-state migration. Every *other* room type is namespaced + * (`${type}:${id}`) so a new id space can never collide with a workflow UUID. + * + * The inverse ({@link parseRoomName}) relies on this: an unprefixed name is a + * workflow, a prefixed name splits on the first `:`. + * + * Precondition: room ids are opaque tokens that never contain `:` — satisfied by + * every id in Sim (`generateId()` UUIDs, `generateShortId()` URL-safe tokens, + * workspace ids). This is what makes a bare workflow id unambiguous against a + * `${type}:${id}` namespace and keeps {@link parseRoomName} lossless. + */ +export function roomName(room: RoomRef): string { + return room.type === ROOM_TYPES.WORKFLOW ? room.id : `${room.type}:${room.id}` +} + +/** + * Inverse of {@link roomName}. A name carrying a known `${type}:` prefix parses + * to that type; any other (unprefixed) name is a {@link ROOM_TYPES.WORKFLOW} + * room whose id is the whole string — see {@link roomName} for why workflow is + * unprefixed. Returns `null` only for the empty string. + */ +export function parseRoomName(name: string): RoomRef | null { + if (!name) return null + + const separatorIndex = name.indexOf(':') + if (separatorIndex > 0) { + const maybeType = name.slice(0, separatorIndex) + if (isRoomType(maybeType) && maybeType !== ROOM_TYPES.WORKFLOW) { + return { type: maybeType, id: name.slice(separatorIndex + 1) } + } + } + + return { type: ROOM_TYPES.WORKFLOW, id: name } +} + +/** Whether two room refs address the same room. */ +export function isSameRoom(a: RoomRef, b: RoomRef): boolean { + return a.type === b.type && a.id === b.id +} diff --git a/packages/realtime-protocol/vitest.config.ts b/packages/realtime-protocol/vitest.config.ts new file mode 100644 index 00000000000..471771e48fe --- /dev/null +++ b/packages/realtime-protocol/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +})