fix(apps): confine the SDK token's users scope to the viewer's identity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
Diego Imbert
2026-07-28 11:50:42 +02:00
co-authored by Claude Fable 5
parent 96387e312c
commit 429c91001f
4 changed files with 46 additions and 4 deletions
+12 -2
View File
@@ -489,8 +489,12 @@ pub fn check_route_access(
}
}
// Raw-app SDK tokens (sentinel) hold `jobs:run` only to run the deployed
// runnables the viewer already can — never request-supplied code.
// Raw-app SDK tokens (sentinel). The viewer approved a named list of
// permissions, so each must grant what its prompt says and no more:
// `jobs:run` runs the deployed runnables the viewer already can — never
// request-supplied code — and `users:read` reads their own identity, not the
// workspace member directory (`users/list`, `users/list_usage`) the domain
// scope would otherwise reach.
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
if let Some(suffix) = route_suffix.as_deref() {
if is_request_supplied_code_route(suffix) {
@@ -499,6 +503,12 @@ pub fn check_route_access(
.to_string(),
));
}
if required_domain == ScopeDomain::Users && suffix != "users/whoami" {
return Err(Error::PermissionDenied(
"Access denied. A raw app frontend SDK token can only read the viewer's own identity."
.to_string(),
));
}
}
}
+5
View File
@@ -4974,6 +4974,11 @@ mod embed_token_tests {
("/api/w/test/jobs/run/dependencies_async", "POST"),
("/api/w/test/jobs/run/flow_dependencies", "POST"),
("/api/w/test/jobs/run/flow_dependencies_async", "POST"),
// `users:read` is presented to the viewer as "read your identity", so
// the workspace member directory must stay out of reach.
("/api/w/test/users/list", "GET"),
("/api/w/test/users/list_usage", "GET"),
("/api/w/test/users/username_to_email/admin", "GET"),
];
for (path, method) in denied {
assert!(
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { hasStoredSdkConsent, storeSdkConsent } from './sdkScopes'
describe('stored frontend SDK consent', () => {
beforeEach(() => localStorage.clear())
it('only covers scopes the viewer actually approved', () => {
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true)
// The app added a scope after the viewer consented: it must ask again
// rather than silently minting a broader token.
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false)
})
it('does not leak one viewer or app to another', () => {
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false)
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false)
expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false)
})
it('treats unreadable storage as no consent', () => {
localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json')
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false)
})
})
@@ -12,12 +12,12 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s
{
value: 'jobs:read',
label: 'Read jobs and results',
description: 'Poll jobs by id and read their results'
description: 'Read jobs and their results, and list the runs the viewer can see'
},
{
value: 'users:read',
label: 'Read your identity',
description: 'Read the viewer username and email (whoami)'
description: 'Read the viewer username and email — not the workspace member list'
},
{
value: 'resources:read',