access.ts112 lines · main
1import { ForbiddenError, NotFoundError } from '@briven/shared';
2import { and, eq, isNull } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import {
6 orgMembers,
7 projectMembers,
8 projects,
9 type MemberRole,
10 type Project,
11} from '../db/schema.js';
12
13/**
14 * Single rank table for both `OrgRole` and `MemberRole` — they share an
15 * identical enum `(['owner', 'admin', 'developer', 'viewer'])` so one rank
16 * map suffices.
17 */
18export const ROLE_RANK: Record<MemberRole, number> = {
19 viewer: 0,
20 developer: 1,
21 admin: 2,
22 owner: 3,
23};
24
25/**
26 * Compute the effective role a user has on a project given their org-level
27 * membership and (optional) project-level override. Pure — no DB access.
28 *
29 * Model B (org-as-baseline + project-overrides):
30 * effective = max(org_role, project_role)
31 *
32 * Either side may be null. If both are null the user has no access.
33 * A project membership row therefore both grants visibility (if no org
34 * row exists) and can elevate the role (if a lower org role exists).
35 */
36export function effectiveRole(
37 orgRole: MemberRole | null,
38 projectRole: MemberRole | null,
39): MemberRole | null {
40 if (!orgRole && !projectRole) return null;
41 if (!orgRole) return projectRole;
42 if (!projectRole) return orgRole;
43 return ROLE_RANK[orgRole] >= ROLE_RANK[projectRole] ? orgRole : projectRole;
44}
45
46export function hasRoleAtLeast(role: MemberRole, min: MemberRole): boolean {
47 return ROLE_RANK[role] >= ROLE_RANK[min];
48}
49
50export interface ProjectAccess {
51 project: Project;
52 role: MemberRole;
53}
54
55/**
56 * Resolve a user's effective access to a project. Returns null if the user
57 * has neither an `orgMembers` row for the project's org nor a
58 * `projectMembers` row for the project. Otherwise returns the project plus
59 * the higher-rank of the two roles.
60 *
61 * Soft-deleted projects (`deletedAt IS NOT NULL`) resolve as null so
62 * existing 404 semantics are preserved.
63 */
64export async function resolveProjectAccess(
65 projectId: string,
66 userId: string,
67): Promise<ProjectAccess | null> {
68 const db = getDb();
69 const [project] = await db
70 .select()
71 .from(projects)
72 .where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
73 .limit(1);
74 if (!project) return null;
75
76 const [orgRow] = await db
77 .select({ role: orgMembers.role })
78 .from(orgMembers)
79 .where(and(eq(orgMembers.orgId, project.orgId), eq(orgMembers.userId, userId)))
80 .limit(1);
81
82 const [projRow] = await db
83 .select({ role: projectMembers.role })
84 .from(projectMembers)
85 .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
86 .limit(1);
87
88 const role = effectiveRole(orgRow?.role ?? null, projRow?.role ?? null);
89 if (!role) return null;
90 return { project, role };
91}
92
93/**
94 * Verify a session-authenticated user has at least `min` role on the
95 * project. Throws `NotFoundError` if they have no access at all (so we
96 * don't leak project existence to non-members) or `ForbiddenError` if
97 * their effective role is below the minimum. Returns the resolved
98 * `ProjectAccess` so callers can reuse the project row without a second
99 * fetch.
100 */
101export async function assertProjectRole(
102 projectId: string,
103 userId: string,
104 min: MemberRole,
105): Promise<ProjectAccess> {
106 const access = await resolveProjectAccess(projectId, userId);
107 if (!access) throw new NotFoundError('project', projectId);
108 if (!hasRoleAtLeast(access.role, min)) {
109 throw new ForbiddenError(`requires role ${min} or higher`);
110 }
111 return access;
112}