-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathaccess-control.ts
More file actions
217 lines (186 loc) · 6.3 KB
/
Copy pathaccess-control.ts
File metadata and controls
217 lines (186 loc) · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { PERMISSIONS, Permission, AVAILABLE_PERMISSIONS } from "@/lib/auth/permissions";
import prisma from "@/lib/prisma";
import { logger } from "@/lib/logging/logger";
import { AuthenticationError, PermissionError, wrapError } from "@/lib/logging/errors";
import { apiKeyService } from "@/services/auth/api-key-service";
const log = logger.child({ module: "AccessControl" });
// ============================================================================
// Auth Context Types
// ============================================================================
export interface AuthContext {
userId: string;
permissions: string[];
isSuperAdmin: boolean;
authMethod: "session" | "apikey";
apiKeyId?: string;
}
// ============================================================================
// Unified Auth Context (Session OR API Key)
// ============================================================================
/**
* Extract Bearer token from Authorization header.
* Returns null if no valid Bearer token is present.
*/
function extractBearerToken(headersObj: Headers): string | null {
const authHeader = headersObj.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return null;
}
return authHeader.slice(7).trim();
}
/**
* Get authentication context from either a session cookie or an API key.
* Tries session first, then falls back to API key from Authorization header.
*
* @param headersObj - The request headers object
* @returns AuthContext if authenticated, null if no auth present
* @throws ApiKeyError if API key is found but invalid/disabled/expired
*/
export async function getAuthContext(headersObj: Headers): Promise<AuthContext | null> {
// 1. Try session-based auth first
try {
const session = await auth.api.getSession({ headers: headersObj });
if (session?.user) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { group: true },
});
if (user?.group) {
const isSuperAdmin = user.group.name === "SuperAdmin";
const permissions = isSuperAdmin
? AVAILABLE_PERMISSIONS.map((p) => p.id)
: (() => {
try {
return JSON.parse(user.group.permissions) as string[];
} catch {
return [];
}
})();
return {
userId: user.id,
permissions,
isSuperAdmin,
authMethod: "session",
};
}
}
} catch (_error) {
log.debug("Session auth check failed, trying API key");
}
// 2. Try API key auth
const bearerToken = extractBearerToken(headersObj);
if (!bearerToken) {
return null;
}
// validate() throws ApiKeyError for disabled/expired keys (re-thrown to caller)
const validated = await apiKeyService.validate(bearerToken);
if (!validated) {
return null;
}
return {
userId: validated.userId,
permissions: validated.permissions,
isSuperAdmin: false, // API keys never get SuperAdmin bypass
authMethod: "apikey",
apiKeyId: validated.id,
};
}
/**
* Check a specific permission against an AuthContext.
* Throws PermissionError if the context does not have the required permission.
*/
export function checkPermissionWithContext(ctx: AuthContext, permission: Permission): void {
if (ctx.isSuperAdmin) {
return; // SuperAdmin bypass (session only, API keys never have isSuperAdmin)
}
if (!ctx.permissions.includes(permission)) {
throw new PermissionError(permission);
}
}
export async function getCurrentUserWithGroup() {
// Wrap to prevent crash if headers/session fails significantly
let session;
try {
session = await auth.api.getSession({
headers: await headers()
});
} catch (error) {
log.error("Session check failed", {}, wrapError(error));
return null;
}
if (!session?.user) {
return null;
}
let user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { group: true }
});
// Auto-promote first user logic (Self-Healing)
if (user && !user.groupId) {
const userCount = await prisma.user.count();
if (userCount === 1) {
log.info("Auto-promoting first user to SuperAdmin");
const allPermissions = Object.values(PERMISSIONS).flatMap(group => Object.values(group));
const group = await prisma.group.upsert({
where: { name: "SuperAdmin" },
update: { permissions: JSON.stringify(allPermissions) },
create: {
name: "SuperAdmin",
permissions: JSON.stringify(allPermissions)
}
});
user = await prisma.user.update({
where: { id: user.id },
data: { groupId: group.id },
include: { group: true }
});
}
}
return user;
}
export async function checkPermission(permission: Permission) {
const user = await getCurrentUserWithGroup();
if (!user) {
throw new AuthenticationError();
}
if (!user.group) {
throw new PermissionError(permission, { context: { reason: "No group assigned" } });
}
// SuperAdmin always has all permissions
if (user.group.name === "SuperAdmin") {
return user;
}
let permissions: string[] = [];
try {
permissions = JSON.parse(user.group.permissions);
} catch (error) {
log.error("Failed to parse group permissions", { groupId: user.group.id }, wrapError(error));
}
if (!permissions.includes(permission)) {
throw new PermissionError(permission);
}
return user;
}
export async function getUserPermissions(): Promise<string[]> {
const user = await getCurrentUserWithGroup();
if (!user || !user.group) return [];
// SuperAdmin always has all permissions
if (user.group.name === "SuperAdmin") {
return AVAILABLE_PERMISSIONS.map(p => p.id);
}
try {
return JSON.parse(user.group.permissions);
} catch {
return [];
}
}
export async function hasPermission(permission: Permission): Promise<boolean> {
try {
await checkPermission(permission);
return true;
} catch {
return false;
}
}