Skip to content

Commit db1b076

Browse files
Ripwordsclaude
andcommitted
feat(auth): add requireProjectRoleByUser for non-H3 callers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0384931 commit db1b076

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

apps/dashboard/server/lib/permissions.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, test } from "bun:test"
2-
import { compareRole, type ProjectRoleName } from "./permissions"
2+
import { randomBytes } from "node:crypto"
3+
import { sql } from "drizzle-orm"
4+
import { db } from "../db"
5+
import { projects, projectMembers, user } from "../db/schema"
6+
import { compareRole, requireProjectRoleByUser, type ProjectRoleName } from "./permissions"
37

48
describe("compareRole", () => {
59
const roles: ProjectRoleName[] = ["viewer", "manager", "developer", "owner"]
@@ -31,3 +35,65 @@ describe("compareRole", () => {
3135
expect(compareRole("viewer", "owner")).toBe(false)
3236
})
3337
})
38+
39+
test("requireProjectRoleByUser — returns role when member meets minimum", async () => {
40+
await db.execute(sql`TRUNCATE project_members, projects, "user" RESTART IDENTITY CASCADE`)
41+
const userId = randomBytes(16).toString("hex")
42+
const projectId = crypto.randomUUID()
43+
await db.insert(user).values({
44+
id: userId,
45+
email: `t-${userId}@example.com`,
46+
name: "t",
47+
emailVerified: true,
48+
role: "member",
49+
status: "active",
50+
createdAt: new Date(),
51+
updatedAt: new Date(),
52+
})
53+
await db.insert(projects).values({ id: projectId, name: "p", createdBy: userId })
54+
await db.insert(projectMembers).values({ projectId, userId, role: "developer" })
55+
56+
const role = await requireProjectRoleByUser(userId, projectId, "manager")
57+
expect(role).toBe("developer")
58+
})
59+
60+
test("requireProjectRoleByUser — throws 403 when role too low", async () => {
61+
await db.execute(sql`TRUNCATE project_members, projects, "user" RESTART IDENTITY CASCADE`)
62+
const userId = randomBytes(16).toString("hex")
63+
const projectId = crypto.randomUUID()
64+
await db.insert(user).values({
65+
id: userId,
66+
email: `t-${userId}@example.com`,
67+
name: "t",
68+
emailVerified: true,
69+
role: "member",
70+
status: "active",
71+
createdAt: new Date(),
72+
updatedAt: new Date(),
73+
})
74+
await db.insert(projects).values({ id: projectId, name: "p", createdBy: userId })
75+
await db.insert(projectMembers).values({ projectId, userId, role: "viewer" })
76+
77+
await expect(requireProjectRoleByUser(userId, projectId, "developer")).rejects.toThrow(
78+
/insufficient/i,
79+
)
80+
})
81+
82+
test("requireProjectRoleByUser — throws 404 when not a member", async () => {
83+
await db.execute(sql`TRUNCATE project_members, projects, "user" RESTART IDENTITY CASCADE`)
84+
const userId = randomBytes(16).toString("hex")
85+
const projectId = crypto.randomUUID()
86+
await db.insert(user).values({
87+
id: userId,
88+
email: `t-${userId}@example.com`,
89+
name: "t",
90+
emailVerified: true,
91+
role: "member",
92+
status: "active",
93+
createdAt: new Date(),
94+
updatedAt: new Date(),
95+
})
96+
await db.insert(projects).values({ id: projectId, name: "p", createdBy: userId })
97+
98+
await expect(requireProjectRoleByUser(userId, projectId, "viewer")).rejects.toThrow(/not found/i)
99+
})

apps/dashboard/server/lib/permissions.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"
22
import type { H3Event } from "h3"
33
import { createError } from "h3"
44
import { db } from "../db"
5-
import { projectMembers } from "../db/schema"
5+
import { projectMembers, user } from "../db/schema"
66
import { auth } from "./auth"
77

88
export type ProjectRoleName = "viewer" | "manager" | "developer" | "owner"
@@ -81,3 +81,48 @@ export async function requireProjectRole(
8181
}
8282
return { session, effectiveRole: member.role as ProjectRoleName }
8383
}
84+
85+
/**
86+
* Non-event variant of requireProjectRole for callers without an H3Event
87+
* (notably MCP tool handlers, which have a verified userId from the JWT
88+
* but no incoming H3 request to read a session from).
89+
*
90+
* Returns the user's effective role on the project. Throws via h3's
91+
* createError on the same conditions as requireProjectRole:
92+
* - 401 if the user record doesn't exist or is disabled (defense in depth;
93+
* the JWT was issued from this same auth system, so this is rare)
94+
* - 404 if the user has no project_members row for this project
95+
* - 403 if the user's role rank is below `min`
96+
*
97+
* Install admins are still treated as effective owners on every project,
98+
* matching the event-aware variant's behavior.
99+
*/
100+
export async function requireProjectRoleByUser(
101+
userId: string,
102+
projectId: string,
103+
min: ProjectRoleName,
104+
): Promise<ProjectRoleName> {
105+
const [userRow] = await db
106+
.select({ role: user.role, status: user.status })
107+
.from(user)
108+
.where(eq(user.id, userId))
109+
.limit(1)
110+
if (!userRow || userRow.status === "disabled") {
111+
throw createError({ statusCode: 401, statusMessage: "Unauthenticated" })
112+
}
113+
if (userRow.role === "admin") return "owner"
114+
115+
const [member] = await db
116+
.select({ role: projectMembers.role })
117+
.from(projectMembers)
118+
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
119+
.limit(1)
120+
if (!member) {
121+
throw createError({ statusCode: 404, statusMessage: "Project not found" })
122+
}
123+
const role = member.role as ProjectRoleName
124+
if (!compareRole(role, min)) {
125+
throw createError({ statusCode: 403, statusMessage: "Insufficient role" })
126+
}
127+
return role
128+
}

0 commit comments

Comments
 (0)