Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/platform-authz/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"./workflow": {
"types": "./src/workflow.ts",
"default": "./src/workflow.ts"
},
"./rooms": {
"types": "./src/rooms.ts",
"default": "./src/rooms.ts"
}
},
"scripts": {
Expand All @@ -32,6 +36,7 @@
},
"dependencies": {
"@sim/db": "workspace:*",
"@sim/realtime-protocol": "workspace:*",
"drizzle-orm": "^0.45.2"
},
"devDependencies": {
Expand Down
133 changes: 133 additions & 0 deletions packages/platform-authz/src/rooms.ts
Original file line number Diff line number Diff line change
@@ -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<RoomWorkspace | null>

async function resolveWorkspaceRoomWorkspace(workspaceId: string): Promise<RoomWorkspace | null> {
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<RoomType, RoomWorkspaceResolver> = {
[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<RoomWorkspace | null> {
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<RoomAuthorizationResult> {
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,
}
}
9 changes: 8 additions & 1 deletion packages/realtime-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -35,6 +41,7 @@
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
85 changes: 85 additions & 0 deletions packages/realtime-protocol/src/rooms.test.ts
Original file line number Diff line number Diff line change
@@ -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())
})
})
89 changes: 89 additions & 0 deletions packages/realtime-protocol/src/rooms.ts
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions packages/realtime-protocol/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['src/**/*.test.ts'],
},
})