Skip to content

Commit df2a1bb

Browse files
committed
fix(rbac): gate user-actor membership floor on a scoped context
Only load the user and run the membership floor when organizationId or projectId is present, restoring pre-P5 behavior for unscoped tokens (no user query, no denial). The user lookup falls back to the primary before failing closed, so replica lag can't spuriously 401 a just-created member.
1 parent f4ba8d3 commit df2a1bb

2 files changed

Lines changed: 80 additions & 39 deletions

File tree

internal-packages/rbac/src/fallback.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -261,22 +261,26 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
261261
return { ok: false, status: 401, error: "Invalid user-actor token" };
262262
}
263263

264-
// Same tenant floor as authenticateSession: a non-member's delegated token is
265-
// denied here, not handed a usable ability (even for reads). Admins are exempt.
266-
const user = await this.replica.user.findFirst({
267-
where: { id: claims.userId },
268-
select: { id: true, admin: true },
269-
});
270-
if (!user) {
271-
return { ok: false, status: 401, error: "Invalid user-actor token" };
272-
}
273-
if (!user.admin) {
274-
const denied = await this.deniedByMembership(
275-
context.organizationId,
276-
context.projectId,
277-
user.id
278-
);
279-
if (denied) return { ok: false, status: 403, error: "Unauthorized" };
264+
// Same tenant floor as authenticateSession: in a scoped context a non-member's
265+
// delegated token is denied here, not handed a usable ability (even for reads).
266+
// Admins are exempt. An unscoped context is not a tenant claim — skip the lookup
267+
// entirely and keep the prior behavior (no user query, no denial).
268+
if (context.organizationId || context.projectId) {
269+
const where = { id: claims.userId };
270+
const user =
271+
(await this.replica.user.findFirst({ where, select: { id: true, admin: true } })) ??
272+
(await this.prisma.user.findFirst({ where, select: { id: true, admin: true } }));
273+
if (!user) {
274+
return { ok: false, status: 401, error: "Invalid user-actor token" };
275+
}
276+
if (!user.admin) {
277+
const denied = await this.deniedByMembership(
278+
context.organizationId,
279+
context.projectId,
280+
user.id
281+
);
282+
if (denied) return { ok: false, status: 403, error: "Unauthorized" };
283+
}
280284
}
281285

282286
return {

internal-packages/rbac/src/fallback.userActor.test.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,34 @@ async function seedUser(prisma: PrismaClient, email: string, admin = false) {
1818
});
1919
}
2020

21+
async function uat(userId: string) {
22+
return signUserActorToken(SECRET, { userId, client: "test" });
23+
}
24+
2125
postgresTest(
22-
"authenticateUserActor: member is allowed, non-member is denied",
26+
"authenticateUserActor: scoped membership floor",
2327
async ({ prisma }) => {
24-
const org = await prisma.organization.create({
28+
const p = prisma as PrismaClient;
29+
const org = await p.organization.create({
2530
data: { slug: `org-${Date.now()}`, title: "Org" },
2631
});
27-
const member = await seedUser(prisma as PrismaClient, "member@example.test");
28-
const stranger = await seedUser(prisma as PrismaClient, "stranger@example.test");
29-
await prisma.orgMember.create({
30-
data: { organizationId: org.id, userId: member.id },
32+
const project = await p.project.create({
33+
data: {
34+
slug: `proj-${Date.now()}`,
35+
name: "Project",
36+
externalRef: `ref-${Date.now()}`,
37+
organizationId: org.id,
38+
},
3139
});
40+
const member = await seedUser(p, "member@example.test");
41+
const stranger = await seedUser(p, "stranger@example.test");
42+
const admin = await seedUser(p, "admin@example.test", true);
43+
await p.orgMember.create({ data: { organizationId: org.id, userId: member.id } });
3244

33-
const controller = new RoleBaseAccessFallback(prisma as PrismaClient, {
34-
userActorSecret: SECRET,
35-
}).create();
36-
37-
const memberToken = await signUserActorToken(SECRET, {
38-
userId: member.id,
39-
client: "test",
40-
});
41-
const strangerToken = await signUserActorToken(SECRET, {
42-
userId: stranger.id,
43-
client: "test",
44-
});
45+
const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create();
4546

4647
// Member with a capless token keeps the read:all default.
47-
const memberResult = await controller.authenticateUserActor(uatRequest(memberToken), {
48+
const memberResult = await controller.authenticateUserActor(uatRequest(await uat(member.id)), {
4849
organizationId: org.id,
4950
});
5051
expect(memberResult.ok).toBe(true);
@@ -53,12 +54,48 @@ postgresTest(
5354
}
5455

5556
// Non-member is denied at the ability layer, not handed a usable ability.
56-
const strangerResult = await controller.authenticateUserActor(uatRequest(strangerToken), {
57+
const strangerResult = await controller.authenticateUserActor(
58+
uatRequest(await uat(stranger.id)),
59+
{ organizationId: org.id }
60+
);
61+
expect(strangerResult.ok).toBe(false);
62+
if (!strangerResult.ok) expect(strangerResult.status).toBe(403);
63+
64+
// A token for a user that no longer exists fails closed.
65+
const ghostResult = await controller.authenticateUserActor(uatRequest(await uat("usr_ghost")), {
5766
organizationId: org.id,
5867
});
59-
expect(strangerResult.ok).toBe(false);
60-
if (!strangerResult.ok) {
61-
expect(strangerResult.status).toBe(403);
68+
expect(ghostResult.ok).toBe(false);
69+
if (!ghostResult.ok) expect(ghostResult.status).toBe(401);
70+
71+
// A platform admin is exempt from the membership floor.
72+
const adminResult = await controller.authenticateUserActor(uatRequest(await uat(admin.id)), {
73+
organizationId: org.id,
74+
});
75+
expect(adminResult.ok).toBe(true);
76+
77+
// A project-only scope resolves through the project's org: non-member denied.
78+
const projectResult = await controller.authenticateUserActor(
79+
uatRequest(await uat(stranger.id)),
80+
{ projectId: project.id }
81+
);
82+
expect(projectResult.ok).toBe(false);
83+
if (!projectResult.ok) expect(projectResult.status).toBe(403);
84+
},
85+
120_000
86+
);
87+
88+
postgresTest(
89+
"authenticateUserActor: unscoped context skips the floor and never queries the user",
90+
async ({ prisma }) => {
91+
const p = prisma as PrismaClient;
92+
const stranger = await seedUser(p, "unscoped@example.test");
93+
const controller = new RoleBaseAccessFallback(p, { userActorSecret: SECRET }).create();
94+
95+
const result = await controller.authenticateUserActor(uatRequest(await uat(stranger.id)), {});
96+
expect(result.ok).toBe(true);
97+
if (result.ok) {
98+
expect(result.ability.can("read", { type: "runs", id: "run_x" })).toBe(true);
6299
}
63100
},
64101
120_000

0 commit comments

Comments
 (0)