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