@@ -344,6 +344,15 @@ export async function resolveCapabilityRefusal(
344344 * - A personal key is rejected when the workspace has disabled personal API
345345 * keys (`allowPersonalApiKeys = false`). Other surfaces enforcing the same
346346 * policy share `PERSONAL_KEY_DENIED`.
347+ *
348+ * Both are properties of the workspace rather than of any group, so both run
349+ * ahead of the role check, exactly as `authorizeWorkspaceOperation` runs the
350+ * `allowPersonalApiKeys` column ahead of `requireCurrentHumanRole`: they need
351+ * no group to resolve, and refusing a key the workspace has switched off is the
352+ * answer whatever the caller's role turns out to be.
353+ *
354+ * The group half of the same policy is NOT here — see
355+ * {@link resolvePersonalKeyGroupRefusal}.
347356 */
348357export async function resolveWorkspaceScope (
349358 rateLimit : RateLimitResult ,
@@ -370,40 +379,68 @@ export async function resolveWorkspaceScope(
370379 message : PERSONAL_KEY_DENIED ,
371380 }
372381 }
373-
374- /**
375- * permission-group-enforced: personal_api_key.use — v1 authorizes in this
376- * middleware rather than through the application funnel, so the group check
377- * the funnel applies has to be repeated here or the same key that v2
378- * refuses would still work against v1.
379- */
380- const governedUserId = capabilityGovernedUserId ( rateLimit )
381- if ( governedUserId ) {
382- const withheld = await isWorkspaceCapabilityWithheld (
383- governedUserId ,
384- requestedWorkspaceId ,
385- 'personal_api_key.use'
386- )
387- if ( withheld ) {
388- return {
389- status : 403 ,
390- code : 'FORBIDDEN' ,
391- message : PERSONAL_KEY_DENIED ,
392- }
393- }
394- }
395382 }
396383
397384 return null
398385}
399386
400387/**
401- * Core workspace-access check: key scope, then the user's workspace permission
402- * level, then the permission-group capability the route declares. Returns a
403- * structured failure or null on success.
388+ * The group half of the personal-key policy: `personal_api_key.use`, repeated
389+ * here because v1 authorizes in this middleware rather than through the
390+ * application funnel, and without it the same key that v2 refuses would still
391+ * work against v1.
392+ *
393+ * It answers only AFTER the caller's workspace role has been verified, which is
394+ * the ordering `authorizeWorkspaceOperation` uses and the reason
395+ * {@link resolveCapabilityRefusal}'s contract says never to run a group key
396+ * ahead of the role: the refusal names how an organization configured one
397+ * cohort, and handing that to a caller with no reach into the workspace tells a
398+ * stranger about the organization's configuration. The column check above may
399+ * stay early precisely because it names no group.
404400 *
405- * Capability comes last, matching `authorizeWorkspaceOperation` — see
406- * {@link resolveCapabilityRefusal} for why the ordering is load-bearing.
401+ * `roleVerifiedFor` is the user id a caller has already checked, not a boolean,
402+ * so a caller that verified some OTHER subject's role cannot vouch for this
403+ * one. When it does not match, the role is resolved here instead, and a caller
404+ * with no read access is handed back `null` so the surface's own role failure —
405+ * the concealed one — is what it answers with. That second lookup is free:
406+ * `getUserEntityPermissions` for a workspace goes through the request-scoped
407+ * memo the role check itself uses.
408+ */
409+ async function resolvePersonalKeyGroupRefusal (
410+ rateLimit : RateLimitResult ,
411+ workspaceId : string ,
412+ roleVerifiedFor : string | null
413+ ) : Promise < WorkspaceAccessError | null > {
414+ const governedUserId = capabilityGovernedUserId ( rateLimit )
415+ if ( ! governedUserId ) return null
416+
417+ if ( roleVerifiedFor !== governedUserId ) {
418+ const permission = await getUserEntityPermissions ( governedUserId , 'workspace' , workspaceId )
419+ if ( ! permissionSatisfies ( permission , 'read' ) ) return null
420+ }
421+
422+ // permission-group-enforced: personal_api_key.use — v1 authorizes in this middleware, not through the funnel
423+ if ( ! ( await isWorkspaceCapabilityWithheld ( governedUserId , workspaceId , 'personal_api_key.use' ) ) ) {
424+ return null
425+ }
426+
427+ return {
428+ status : 403 ,
429+ code : 'FORBIDDEN' ,
430+ message : PERSONAL_KEY_DENIED ,
431+ }
432+ }
433+
434+ /**
435+ * Core workspace-access check: key scope and the workspace's own columns, then
436+ * the user's workspace permission level, then the two permission-group
437+ * decisions — the personal-key refusal, then the capability the route declares.
438+ * Returns a structured failure or null on success.
439+ *
440+ * Both group keys come after the role, matching `authorizeWorkspaceOperation` —
441+ * see {@link resolveCapabilityRefusal} for why the ordering is load-bearing.
442+ * The personal-key refusal sits first of the two for the reason the funnel
443+ * gives: the remedies differ, and the narrower one is worth naming first.
407444 */
408445export async function resolveWorkspaceAccess (
409446 rateLimit : RateLimitResult ,
@@ -420,22 +457,33 @@ export async function resolveWorkspaceAccess(
420457 return { status : 403 , code : 'FORBIDDEN' , message : 'Access denied' }
421458 }
422459
460+ const personalKeyRefusal = await resolvePersonalKeyGroupRefusal ( rateLimit , workspaceId , userId )
461+ if ( personalKeyRefusal ) return personalKeyRefusal
462+
423463 return resolveCapabilityRefusal ( rateLimit , workspaceId , capability )
424464}
425465
426466/**
427- * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body.
467+ * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body,
468+ * plus the personal-key group refusal that belongs with it.
469+ *
470+ * It deliberately gates no MODULE capability: it runs before the route's role
471+ * check, and a route using it authorizes its resource through a domain helper
472+ * afterwards (the table routes call `checkAccess`, which applies `tables.use`
473+ * itself), so the capability is declared there.
428474 *
429- * Scope only — it deliberately gates no module capability, because it runs
430- * before the route's role check. A route using it authorizes its resource
431- * through a domain helper afterwards ( the table routes call `checkAccess`,
432- * which applies `tables.use` itself), so the capability is declared there .
475+ * `personal_api_key.use` cannot wait for that helper — `checkAccess` gates the
476+ * module, not the key kind — so it is asked here, and
477+ * { @link resolvePersonalKeyGroupRefusal} resolves the caller's role itself
478+ * before answering rather than relying on a role check this wrapper never runs .
433479 */
434480export async function checkWorkspaceScope (
435481 rateLimit : RateLimitResult ,
436482 requestedWorkspaceId : string
437483) : Promise < NextResponse | null > {
438- const failure = await resolveWorkspaceScope ( rateLimit , requestedWorkspaceId )
484+ const failure =
485+ ( await resolveWorkspaceScope ( rateLimit , requestedWorkspaceId ) ) ??
486+ ( await resolvePersonalKeyGroupRefusal ( rateLimit , requestedWorkspaceId , null ) )
439487 return failure ? workspaceAccessErrorResponse ( failure ) : null
440488}
441489
0 commit comments