-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathpermissions.ts
More file actions
246 lines (225 loc) · 8.02 KB
/
Copy pathpermissions.ts
File metadata and controls
246 lines (225 loc) · 8.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
EDGE_OPERATIONS,
EDGES_OPERATIONS,
SUBBLOCK_OPERATIONS,
SUBFLOW_OPERATIONS,
VARIABLE_OPERATIONS,
WORKFLOW_OPERATIONS,
} from '@sim/realtime-protocol/constants'
import { and, eq, isNull } from 'drizzle-orm'
const logger = createLogger('SocketPermissions')
// Admin-only operations (require admin role)
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
// Write operations (admin and write roles both have these permissions)
const WRITE_OPERATIONS: string[] = [
// Block operations
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCK_OPERATIONS.UPDATE_NAME,
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
// Batch block operations
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS,
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,
// Batch edge operations
EDGES_OPERATIONS.BATCH_ADD_EDGES,
EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
// Subflow operations
SUBFLOW_OPERATIONS.UPDATE,
// Subblock operations
SUBBLOCK_OPERATIONS.UPDATE,
SUBBLOCK_OPERATIONS.BATCH_UPDATE,
// Variable operations
VARIABLE_OPERATIONS.UPDATE,
// Workflow operations
WORKFLOW_OPERATIONS.REPLACE_STATE,
]
// Read role can only update positions (for cursor sync, etc.)
const READ_OPERATIONS: string[] = [
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
]
// Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
write: WRITE_OPERATIONS,
read: READ_OPERATIONS,
}
// Check if a role allows a specific operation (no DB query, pure logic)
export function checkRolePermission(
role: string,
operation: string
): { allowed: boolean; reason?: string } {
const allowedOperations = ROLE_PERMISSIONS[role] || []
if (!allowedOperations.includes(operation)) {
return {
allowed: false,
reason: `Role '${role}' not permitted to perform '${operation}'`,
}
}
return { allowed: true }
}
/**
* TTL for the per-pod role cache backing live re-validation of mutating operations.
* Bounds how long a revoked or downgraded collaborator can retain write access on an
* already-connected socket.
*/
const ROLE_REVALIDATION_TTL_MS = 30_000
/** Soft cap on cached entries before an opportunistic purge of expired ones runs. */
const MAX_ROLE_CACHE_ENTRIES = 5_000
interface CachedRole {
/** Authoritative workspace role, or `null` when the user has no access. */
role: string | null
expiresAt: number
}
/**
* Per-pod cache of authoritative workspace roles, keyed by `${userId}:${workflowId}`.
*
* Socket connections are sticky to a single pod, so a socket's mutating operations are
* always gated by the same pod's cache. We rely on TTL expiry (not cross-pod
* invalidation) to bound stale authorization to {@link ROLE_REVALIDATION_TTL_MS}, which
* keeps this correct under a multi-pod deployment without any shared state.
*/
const roleCache = new Map<string, CachedRole>()
function purgeExpiredRoles(now: number): void {
for (const [key, entry] of roleCache) {
if (entry.expiresAt <= now) {
roleCache.delete(key)
}
}
}
/**
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
*
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
* resurrects already-revoked access.
*/
export async function resolveCurrentWorkflowRole(
userId: string,
workflowId: string,
fallbackRole: string
): Promise<string | null> {
const now = Date.now()
const key = `${userId}:${workflowId}`
const cached = roleCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.role
}
try {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
purgeExpiredRoles(now)
}
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
return role
} catch (error) {
logger.warn(
`Failed to re-validate role for user ${userId} on workflow ${workflowId}; using last known role`,
error
)
// Prefer the last recorded decision — even if expired, and even if it is `null` for an
// already-revoked user — so a recorded revocation survives a transient DB failure
// instead of reverting to the stale join-time role. Only trust `fallbackRole` when
// nothing has been recorded for this (user, workflow) yet.
const lastKnown = roleCache.get(key)
return lastKnown !== undefined ? lastKnown.role : fallbackRole
}
}
/**
* Live permission gate for mutating socket operations. Re-validates the user's workspace
* role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that
* revoked or downgraded collaborators lose write access on an open connection without
* needing to rejoin the workflow.
*/
export async function checkWorkflowOperationPermission(
userId: string,
workflowId: string,
operation: string,
fallbackRole: string
): Promise<{ allowed: boolean; reason?: string; role: string | null }> {
const role = await resolveCurrentWorkflowRole(userId, workflowId, fallbackRole)
if (!role) {
return {
allowed: false,
reason: 'Access to this workflow has been revoked',
role: null,
}
}
return { ...checkRolePermission(role, operation), role }
}
/**
* Verifies a user's access to a workflow via workspace permissions.
*
* Returns `hasAccess: false` only for genuine denials (workflow missing/archived
* or no workspace permission). Transient failures (DB errors) are rethrown so the
* caller can report them as retryable instead of a permanent access denial.
*/
export async function verifyWorkflowAccess(
userId: string,
workflowId: string
): Promise<{ hasAccess: boolean; role?: string; workspaceId?: string }> {
try {
const workflowData = await db
.select({
workspaceId: workflow.workspaceId,
name: workflow.name,
})
.from(workflow)
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
.limit(1)
if (!workflowData.length) {
logger.warn(`Workflow ${workflowId} not found`)
return { hasAccess: false }
}
const { workspaceId, name: workflowName } = workflowData[0]
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workspacePermission) {
logger.warn(
`User ${userId} is not permitted to access workflow ${workflowId}: ${authorization.message}`
)
return { hasAccess: false }
}
logger.debug(
`User ${userId} has ${authorization.workspacePermission} access to workflow ${workflowId} (${workflowName}) via workspace ${workspaceId}`
)
return {
hasAccess: true,
role: authorization.workspacePermission,
workspaceId: workspaceId || undefined,
}
} catch (error) {
logger.error(
`Error verifying workflow access for user ${userId}, workflow ${workflowId}:`,
error
)
throw error
}
}