Skip to content

Commit 81e0b8e

Browse files
committed
Sessions routes: post-RBAC apiBuilder shape + preserve superScopes semantics
The 6 session routes merged via PR #3417 were authored against the pre-RBAC apiBuilder API: `authorization.resource` returned shapes like `{ sessions: 'abc' }`, with a parallel `superScopes: [...]` whitelist for broad-scope bypass. Post-TRI-8719, that shape doesn't typecheck and `superScopes` is dead code. Convert each resource callback to the canonical `{ type, id? }` shape. For the two routes whose resource type is `tasks` but whose old superScopes included `<action>:sessions` (list and create), use a multi-key array `[{ type: 'tasks', id }, { type: 'sessions' }]` so a JWT scoped `<action>:sessions` (no id) still passes — preserving the exact allow-set the old superScopes mechanism granted. `*:all` and `admin*` were already handled by the JWT ability's wildcard branches. Drop the now-dead `superScopes` field from all 9 entries. Adds e2e coverage in `auth-api.e2e.full.test.ts` (34 new tests, ~9 sub-describes) that locks in: per-task narrowing, `<action>:sessions` type-only equivalence to the old superScope, `*:all` and `admin*` bypass, wrong-action / wrong-id rejection. Plus a new `seedTestApiSession` helper for inserting Session rows via Prisma — distinct from the existing `seedTestSession` (cookie-session helper for dashboard tests).
1 parent 9d601f0 commit 81e0b8e

9 files changed

Lines changed: 682 additions & 29 deletions

apps/webapp/app/routes/api.v1.sessions.$session.close.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute(
2525
corsStrategy: "all",
2626
authorization: {
2727
action: "admin",
28-
resource: (params) => ({ sessions: params.session }),
29-
superScopes: ["admin:sessions", "admin:all", "admin"],
28+
resource: (params) => ({ type: "sessions", id: params.session }),
3029
},
3130
},
3231
async ({ authentication, params, body }) => {

apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,18 @@ const { action, loader } = createActionApiRoute(
4242
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session),
4343
authorization: {
4444
action: "write",
45+
// Multi-key: the session is addressable by URL param, friendlyId,
46+
// and externalId — a JWT scoped to any of them grants access.
47+
// Type-level `write:sessions` (no id) also matches; `write:all` /
48+
// `admin` bypass via the JWT ability's wildcard branches.
4549
resource: (params, _, __, ___, session) => {
4650
const ids = new Set<string>([params.session]);
4751
if (session) {
4852
ids.add(session.friendlyId);
4953
if (session.externalId) ids.add(session.externalId);
5054
}
51-
return { sessions: [...ids] };
55+
return [...ids].map((id) => ({ type: "sessions", id }));
5256
},
53-
superScopes: ["write:sessions", "write:all", "admin"],
5457
},
5558
},
5659
async ({ authentication, params, body, resource: session }) => {

apps/webapp/app/routes/api.v1.sessions.$session.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,17 @@ export const loader = createLoaderApiRoute(
2929
},
3030
authorization: {
3131
action: "read",
32-
resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }),
33-
superScopes: ["read:sessions", "read:all", "admin"],
32+
// Multi-key: a session is addressable by both friendlyId and (when
33+
// set) externalId. A JWT scoped to either id grants access; type-
34+
// level `read:sessions` (no id) matches both elements; `read:all`
35+
// / `admin` bypass via the JWT ability's wildcard branches.
36+
resource: (session) =>
37+
session.externalId
38+
? [
39+
{ type: "sessions", id: session.friendlyId },
40+
{ type: "sessions", id: session.externalId },
41+
]
42+
: { type: "sessions", id: session.friendlyId },
3443
},
3544
},
3645
async ({ resource: session }) => {
@@ -50,8 +59,7 @@ const { action } = createActionApiRoute(
5059
corsStrategy: "all",
5160
authorization: {
5261
action: "admin",
53-
resource: (params) => ({ sessions: params.session }),
54-
superScopes: ["admin:sessions", "admin:all", "admin"],
62+
resource: (params) => ({ type: "sessions", id: params.session }),
5563
},
5664
},
5765
async ({ authentication, params, body }) => {

apps/webapp/app/routes/api.v1.sessions.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,21 @@ export const loader = createLoaderApiRoute(
3737
corsStrategy: "all",
3838
authorization: {
3939
action: "read",
40-
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
41-
superScopes: ["read:sessions", "read:all", "admin"],
40+
// Multi-key resource preserves the pre-RBAC superScope semantics:
41+
// - Per-task scoping via `read:tasks:<id>` matches a task element
42+
// - Type-level `read:sessions` (the old superScope) matches the
43+
// sessions element (collection-level — no id)
44+
// - `read:all` / `admin` bypass via the JWT ability's wildcard branches
45+
// The taskIdentifier filter accepts a string or an array; expand to
46+
// one resource per task id so any per-task-scoped JWT among them
47+
// grants access (the array gets OR semantics).
48+
resource: (_, __, searchParams) => {
49+
const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? [];
50+
return [
51+
...taskFilter.map((id) => ({ type: "tasks" as const, id })),
52+
{ type: "sessions" as const },
53+
];
54+
},
4255
},
4356
findResource: async () => 1,
4457
},
@@ -113,21 +126,19 @@ const { action } = createActionApiRoute(
113126
// Per-task scoping via `body.taskIdentifier` (action-route resource
114127
// callbacks receive the parsed body as the 4th arg — see
115128
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
116-
// can only create sessions whose `taskIdentifier` is `"foo"`. Broad
117-
// callers (cli-v3 MCP, customer servers wrapping their own auth)
118-
// hold the `write:sessions` super-scope and bypass the per-task
119-
// check entirely.
129+
// can only create sessions whose `taskIdentifier` is `"foo"`.
120130
//
121-
// Note: the auth check is OR across resource types, so listing both
122-
// `sessions` and `tasks` here would let a `write:sessions`-scoped
123-
// JWT pass for *any* task — defeating the per-task narrowing. Keep
124-
// it task-only and let the super-scope path handle session-level
125-
// wildcard access.
131+
// Multi-key resource: pre-RBAC this route had a `superScopes:
132+
// ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
133+
// is the `{ type: "sessions" }` element below — a `write:sessions`
134+
// JWT (no id) matches it directly, deliberately bypassing the
135+
// per-task check exactly as before. `admin` / `write:all` bypass
136+
// via the JWT ability's wildcard branches.
126137
action: "write",
127-
resource: (_params, _searchParams, _headers, body) => ({
128-
tasks: body.taskIdentifier,
129-
}),
130-
superScopes: ["write:sessions", "admin"],
138+
resource: (_params, _searchParams, _headers, body) => [
139+
{ type: "tasks", id: body.taskIdentifier },
140+
{ type: "sessions" },
141+
],
131142
},
132143
corsStrategy: "all",
133144
},

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,16 @@ const { action, loader } = createActionApiRoute(
4949
action: "write",
5050
// Authorize against the union of the URL form, friendlyId, and
5151
// externalId so a JWT scoped to any form authorizes any URL.
52+
// Type-level `write:sessions` (no id) also matches; `write:all` /
53+
// `admin` bypass via the JWT ability's wildcard branches.
5254
resource: (params, _, __, ___, session) => {
5355
const ids = new Set<string>([params.session]);
5456
if (session) {
5557
ids.add(session.friendlyId);
5658
if (session.externalId) ids.add(session.externalId);
5759
}
58-
return { sessions: [...ids] };
60+
return [...ids].map((id) => ({ type: "sessions", id }));
5961
},
60-
superScopes: ["write:sessions", "write:all", "admin"],
6162
},
6263
},
6364
async ({ request, params, authentication, resource: session }) => {

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ const { action } = createActionApiRoute(
3030
corsStrategy: "all",
3131
authorization: {
3232
action: "write",
33-
resource: (params) => ({ sessions: params.session }),
34-
superScopes: ["write:sessions", "write:all", "admin"],
33+
resource: (params) => ({ type: "sessions", id: params.session }),
3534
},
3635
},
3736
async ({ params, authentication }) => {
@@ -110,15 +109,18 @@ const loader = createLoaderApiRoute(
110109
},
111110
authorization: {
112111
action: "read",
112+
// Multi-key: the channel is addressable by the URL key, the row's
113+
// friendlyId, and (if set) externalId. Type-level `read:sessions`
114+
// matches any of them; `read:all` / `admin` bypass via the JWT
115+
// ability's wildcard branches.
113116
resource: ({ row, addressingKey }) => {
114117
const ids = new Set<string>([addressingKey]);
115118
if (row) {
116119
ids.add(row.friendlyId);
117120
if (row.externalId) ids.add(row.externalId);
118121
}
119-
return { sessions: [...ids] };
122+
return [...ids].map((id) => ({ type: "sessions", id }));
120123
},
121-
superScopes: ["read:sessions", "read:all", "admin"],
122124
},
123125
},
124126
async ({ params, request, authentication, resource }) => {

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, an
8888
// identifiers (a run is addressable by friendlyId / batch / tags / task) so a
8989
// JWT scoped to *any* of them grants access to the row.
9090
//
91+
// The pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for
92+
// "broader-than-this-resource" access. Post-RBAC, that's expressed in two
93+
// ways: include a collection-level shape `{ type: "<subject>" }` (no id) in
94+
// the array so a `<action>:<subject>` JWT matches it, and rely on the JWT
95+
// ability's wildcard branches for the bypass tier (`*:all` and `admin*` —
96+
// see `internal-packages/rbac/src/ability.ts`). No code knob is needed.
97+
//
9198
// Batch operations are different: each item in the array is a *distinct*
9299
// resource and authorization must hold for every one of them. Wrapping the
93100
// array via `everyResource` flips the auth check from `some` to `every`. The

0 commit comments

Comments
 (0)