Skip to content

Commit 40c5188

Browse files
committed
fix(secrets): lock the org-admin grant too, and read auth on the tx
The revocation locks covered the explicit workspace permission and the per-key credential membership, but workspace admin can also be INHERITED from an organization admin role. That grant lives in a `member` row no lock covered, so revoking it could still race the disclosure update. Locks now cover all three grants. Both authorization reads also go through the caller's transaction rather than the global client, so the decision runs on the same connection holding those locks instead of alongside them on a pooled one.
1 parent b552c94 commit 40c5188

1 file changed

Lines changed: 58 additions & 17 deletions

File tree

apps/sim/lib/credentials/environment.ts

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
credential,
44
type credentialEnvVisibilityEnum,
55
credentialMember,
6+
member,
67
permissions,
78
workspace,
89
} from '@sim/db/schema'
@@ -197,14 +198,20 @@ export async function getWorkspaceEnvKeyAdminAccess(params: {
197198
workspaceId: string
198199
envKeys: string[]
199200
userId: string
201+
/**
202+
* Runs the read on the caller's transaction. Disclosure authorization passes
203+
* one so this read happens on the same connection that holds its revocation
204+
* locks, rather than on a pooled connection alongside them.
205+
*/
206+
executor?: DbOrTx
200207
}): Promise<WorkspaceEnvKeyAdminAccess> {
201-
const { workspaceId, envKeys, userId } = params
208+
const { workspaceId, envKeys, userId, executor = db } = params
202209
const keys = Array.from(new Set(envKeys.filter(Boolean)))
203210
if (keys.length === 0) {
204211
return { adminKeys: new Set(), knownKeys: new Set(), variableKeys: new Set() }
205212
}
206213

207-
const rows = await db
214+
const rows = await executor
208215
.select({
209216
envKey: credential.envKey,
210217
envVisibility: credential.envVisibility,
@@ -563,17 +570,33 @@ async function authorizeWorkspaceEnvVisibilityChange(params: {
563570

564571
const changingKeys = changing.map((row) => row.envKey as string)
565572

566-
// Share-lock the rows that grant this caller the right to disclose, BEFORE
567-
// reading them. A concurrent revocation must either commit before the lock —
568-
// in which case the reads below observe it and deny — or block until this
569-
// transaction ends. Without the locks the permission read and the UPDATE are
570-
// two independent statements, and a revocation landing between them leaves a
571-
// former admin able to flip a secret to a workspace-visible variable.
572-
//
573-
// `executor` is the caller's transaction, which is what makes the locks span
574-
// the UPDATE. Called without one, each statement is its own implicit
575-
// transaction and the locks release immediately — harmless, but it is why
576-
// `setWorkspaceEnvVisibility` is the only supported entry point.
573+
const [workspaceRow] = await executor
574+
.select({ organizationId: workspace.organizationId })
575+
.from(workspace)
576+
.where(eq(workspace.id, workspaceId))
577+
.limit(1)
578+
const organizationId = workspaceRow?.organizationId ?? null
579+
580+
/**
581+
* Share-lock every row that can grant this caller the right to disclose,
582+
* BEFORE reading any of them, so a concurrent revocation either commits
583+
* first — and the reads below observe it and deny — or blocks until this
584+
* transaction ends. Since the UPDATE is in the same transaction, that leaves
585+
* no window between deciding and disclosing.
586+
*
587+
* All three grants must be covered or the weakest one decides:
588+
* - the explicit workspace `permissions` row,
589+
* - the `member` row, because org admin INHERITS workspace admin
590+
* (`resolveEffectiveWorkspacePermission`) — locking only `permissions`
591+
* leaves an org-admin revocation free to race,
592+
* - the `credential_member` rows for the credentials being changed, which
593+
* is the per-key grant (and whose `status` flip is also a revocation).
594+
*
595+
* `executor` must be the caller's transaction for any of this to hold.
596+
* Called without one, each statement is its own implicit transaction and the
597+
* locks release immediately — which is why both this and the apply half are
598+
* unexported and `setWorkspaceEnvVisibility` is the only entry point.
599+
*/
577600
await executor
578601
.select({ id: permissions.id })
579602
.from(permissions)
@@ -585,6 +608,13 @@ async function authorizeWorkspaceEnvVisibilityChange(params: {
585608
)
586609
)
587610
.for('share')
611+
if (organizationId) {
612+
await executor
613+
.select({ id: member.id })
614+
.from(member)
615+
.where(and(eq(member.userId, actingUserId), eq(member.organizationId, organizationId)))
616+
.for('share')
617+
}
588618
await executor
589619
.select({ id: credentialMember.id })
590620
.from(credentialMember)
@@ -599,10 +629,21 @@ async function authorizeWorkspaceEnvVisibilityChange(params: {
599629
)
600630
.for('share')
601631

602-
const [isWorkspaceAdmin, { adminKeys }] = await Promise.all([
603-
hasWorkspaceAdminAccess(actingUserId, workspaceId),
604-
getWorkspaceEnvKeyAdminAccess({ workspaceId, envKeys: changingKeys, userId: actingUserId }),
605-
])
632+
// Both reads go through `executor` — the same connection holding the locks
633+
// above. Sequential rather than Promise.all: a transaction handle is one
634+
// connection, so concurrent statements on it serialize anyway.
635+
const permission = await getEffectiveWorkspacePermission(
636+
actingUserId,
637+
{ id: workspaceId, organizationId },
638+
executor
639+
)
640+
const isWorkspaceAdmin = permissionSatisfies(permission, 'admin')
641+
const { adminKeys } = await getWorkspaceEnvKeyAdminAccess({
642+
workspaceId,
643+
envKeys: changingKeys,
644+
userId: actingUserId,
645+
executor,
646+
})
606647

607648
const forbidden = isWorkspaceAdmin ? [] : changingKeys.filter((key) => !adminKeys.has(key))
608649
if (forbidden.length > 0) {

0 commit comments

Comments
 (0)