Skip to content

Commit ef5508b

Browse files
committed
Add auth to every route, fix linting, remove old code
1 parent 741d0e3 commit ef5508b

File tree

81 files changed

+511
-516
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+511
-516
lines changed

src/app.d.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@
33

44
// Security class type because this file cannot import anything
55
declare global {
6-
class Security {
6+
interface SecurityLike {
7+
userId: number;
8+
roles: Map<number, number[]>;
9+
}
10+
class Security implements SecurityLike {
711
public readonly isSuperAdmin: boolean;
812
public readonly userId: number;
913
public readonly organizationMemberships: number[];
1014
public readonly roles: Map<number, number[]>;
1115
requireAuthenticated(): void | never;
1216
requireSuperAdmin(): this | never;
13-
requireAdminForOrg(organizationId: number): this | never;
14-
requireAdminForOrgIn(organizationIds: number[]): this | never;
17+
requireAdminOfOrg(organizationId: number): this | never;
18+
requireAdminOfOrgIn(organizationIds: number[]): this | never;
1519
requireAdminOfAny(): this | never;
16-
requireHasRole(organizationId: number, roleId: number): this | never;
20+
requireHasRole(organizationId: number, roleId: number, orOrgAdmin = true): this | never;
1721
requireMemberOfOrg(organizationId: number): this | never;
1822
requireMemberOfOrgOrSuperAdmin(organizationId: number): this | never;
23+
requireProjectWriteAccess(project?: { OwnerId: number; OrganizationId: number }): this | never;
1924
requireMemberOfAnyOrg(): this | never;
2025
requireNothing(): this | never;
2126
}

src/auth.ts

Lines changed: 37 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,16 @@
1-
import {
2-
type DefaultSession,
3-
type Session,
4-
SvelteKitAuth,
5-
type SvelteKitAuthConfig
6-
} from '@auth/sveltekit';
1+
import { type DefaultSession, SvelteKitAuth, type SvelteKitAuthConfig } from '@auth/sveltekit';
72
import Auth0Provider from '@auth/sveltekit/providers/auth0';
83
import { trace } from '@opentelemetry/api';
9-
import { type Handle, error, isHttpError, redirect } from '@sveltejs/kit';
4+
import { type Handle, error, redirect } from '@sveltejs/kit';
105
import { env } from '$env/dynamic/private';
116
import { checkInviteErrors } from '$lib/organizationInvites';
127
import { localizeHref } from '$lib/paraglide/runtime';
138
import { RoleId } from '$lib/prisma';
14-
import { verifyCanEdit, verifyCanView } from '$lib/projects/server';
159
import { DatabaseReads, DatabaseWrites } from '$lib/server/database';
16-
import { adminOrgs } from '$lib/users/server';
17-
import { ServerStatus } from '$lib/utils';
18-
import { isAdmin, isAdminForOrg, isSuperAdmin } from '$lib/utils/roles';
1910

2011
declare module '@auth/sveltekit' {
2112
interface Session {
22-
user: {
23-
userId: number;
24-
/** [organizationId, RoleId][]*/
25-
roles: [number, RoleId][];
26-
} & DefaultSession['user'];
13+
user: SecurityLike & DefaultSession['user'];
2714
}
2815
}
2916
// Stupidly hacky way to get access to the request data from the auth callbacks
@@ -130,10 +117,15 @@ const config: SvelteKitAuthConfig = {
130117
UserId: token.userId as number
131118
}
132119
});
133-
session.user.roles = userRoles.map((role) => [role.OrganizationId, role.RoleId]);
120+
const rolesMap = new Map<number, RoleId[]>();
121+
for (const { OrganizationId, RoleId } of userRoles) {
122+
if (!rolesMap.has(OrganizationId)) rolesMap.set(OrganizationId, []);
123+
rolesMap.get(OrganizationId)!.push(RoleId);
124+
}
125+
session.user.roles = rolesMap;
134126
trace.getActiveSpan()?.addEvent('session callback completed', {
135127
'auth.session.userId': session.user.userId,
136-
'auth.session.roles': JSON.stringify(session.user.roles)
128+
'auth.session.roles': JSON.stringify([...session.user.roles.entries()])
137129
});
138130
return session;
139131
}
@@ -168,7 +160,7 @@ export class Security {
168160
return this;
169161
}
170162

171-
requireAdminForOrgIn(organizationIds: number[]) {
163+
requireAdminOfOrgIn(organizationIds: number[]) {
172164
this.requireAuthenticated();
173165
if (
174166
!this.isSuperAdmin &&
@@ -179,9 +171,9 @@ export class Security {
179171
return this;
180172
}
181173

182-
requireAdminForOrg(organizationId: number) {
174+
requireAdminOfOrg(organizationId: number) {
183175
this.requireAuthenticated();
184-
this.requireAdminForOrgIn([organizationId]);
176+
this.requireAdminOfOrgIn([organizationId]);
185177
return this;
186178
}
187179

@@ -192,9 +184,13 @@ export class Security {
192184
return this;
193185
}
194186

195-
requireHasRole(organizationId: number, roleId: RoleId) {
187+
requireHasRole(organizationId: number, roleId: RoleId, orOrgAdmin = true) {
196188
this.requireAuthenticated();
197-
if (!this.isSuperAdmin && !this.roles.get(organizationId)?.includes(roleId))
189+
if (
190+
!this.isSuperAdmin &&
191+
!this.roles.get(organizationId)?.includes(roleId) &&
192+
!(orOrgAdmin && this.roles.get(organizationId)?.includes(RoleId.OrgAdmin))
193+
)
198194
error(403, 'User does not have the required role ' + roleId);
199195
return this;
200196
}
@@ -212,6 +208,17 @@ export class Security {
212208
return this;
213209
}
214210

211+
requireProjectWriteAccess(project?: { OwnerId: number; OrganizationId: number }) {
212+
this.requireAuthenticated();
213+
if (!project) {
214+
error(400, 'Project is required for write access check');
215+
}
216+
if (!this.isSuperAdmin && project.OwnerId !== this.userId) {
217+
this.requireAdminOfOrg(project.OrganizationId);
218+
}
219+
return this;
220+
}
221+
215222
requireMemberOfAnyOrg() {
216223
this.requireAuthenticated();
217224
if (this.organizationMemberships.length === 0)
@@ -226,36 +233,29 @@ export class Security {
226233
}
227234

228235
export const populateSecurityInfo: Handle = async ({ event, resolve }) => {
229-
const session = (await event.locals.auth())?.user;
236+
const security = (await event.locals.auth())?.user;
230237
try {
231-
if (session) {
238+
if (security) {
232239
// If the user does not exist in the database, invalidate the login and redirect to prevent unauthorized access
233240
// This can happen when the user is deleted from the database but still has a valid session.
234241
// This should only happen when a superadmin manually deletes a user but is particularly annoying in development
235242
// The user should also be redirected if they are not a member of any organizations
236243
// Finally, the user should be redirected if they are locked
237244
const user = await DatabaseReads.users.findUnique({
238245
where: {
239-
Id: session.userId
246+
Id: security.userId
240247
},
241248
include: {
242249
OrganizationMemberships: true
243250
}
244251
});
245252

246-
// Create a map of organizationId -> RoleId[]
247-
const rolesMap = new Map<number, RoleId[]>();
248-
for (const [orgId, roleId] of session.roles) {
249-
if (!rolesMap.has(orgId)) rolesMap.set(orgId, []);
250-
rolesMap.get(orgId)!.push(roleId);
251-
}
252-
253253
trace.getActiveSpan()?.addEvent('checkUserExistsHandle completed', {
254-
'auth.userId': session?.userId,
254+
'auth.userId': security?.userId,
255255
'auth.user': user ? user.Email + ' - ' + user.Id : 'null',
256256
'auth.user.OrganizationMemberships':
257257
user?.OrganizationMemberships.map((o) => o.OrganizationId).join(', ') ?? 'null',
258-
'auth.user.roles': user ? JSON.stringify(rolesMap.entries()) : 'null',
258+
'auth.user.roles': user ? JSON.stringify([...security.roles.entries()]) : 'null',
259259
'auth.user.IsLocked': user ? user.IsLocked + '' : 'null'
260260
});
261261

@@ -272,9 +272,9 @@ export const populateSecurityInfo: Handle = async ({ event, resolve }) => {
272272
}
273273
event.locals.security = new Security(
274274
event,
275-
session.userId,
275+
security.userId,
276276
user.OrganizationMemberships.map((o) => o.OrganizationId),
277-
rolesMap
277+
security.roles
278278
);
279279
}
280280
} finally {
@@ -314,125 +314,3 @@ export const organizationInviteHandle: Handle = async ({ event, resolve }) => {
314314
tokenStatus = null;
315315
return result;
316316
};
317-
318-
// This is entirely unused now but will be referenced for setting up individual guards.
319-
// Locks down the authenticated routes by redirecting to /login
320-
// This guarantees a logged in user under (authenticated) but does not guarantee
321-
// authorization to the route. Each page must manually check in +page.server.ts or here
322-
export const localRouteHandle: Handle = async ({ event, resolve }) => {
323-
if (
324-
!event.route.id?.startsWith('/(unauthenticated)') &&
325-
event.route.id !== '/' &&
326-
event.route.id !== null
327-
) {
328-
const session = await event.locals.auth();
329-
if (!session) return redirect(302, localizeHref('/login'));
330-
const status = await validateRouteForAuthenticatedUser(
331-
session,
332-
event.route.id,
333-
event.params,
334-
event.request.method
335-
);
336-
trace.getActiveSpan()?.addEvent('localRouteHandle completed', {
337-
'auth.localRouteHandle.routeId': event.route.id ?? 'null',
338-
'auth.localRouteHandle.params': JSON.stringify(event.params),
339-
'auth.localRouteHandle.session.userId': session.user.userId,
340-
'auth.localRouteHandle.session.roles': JSON.stringify(session.user.roles),
341-
'auth.localRouteHandle.status': status
342-
});
343-
if (status !== ServerStatus.Ok) {
344-
// error.html is extremely ugly, so we use a manual error throw
345-
// event.locals.error = status;
346-
}
347-
}
348-
return resolve(event);
349-
};
350-
351-
async function validateRouteForAuthenticatedUser(
352-
session: Session,
353-
route: string,
354-
params: Partial<Record<string, string>>,
355-
method: string
356-
): Promise<ServerStatus> {
357-
const path = route.split('/').filter((r) => !!r);
358-
// Only guarding authenticated routes
359-
if (path[0] === '(authenticated)') {
360-
if (path[1] === 'admin' || path[1] === 'workflow-instances')
361-
return isSuperAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
362-
else if (path[1] === 'directory' || path[1] === 'open-source')
363-
// Always allowed. Open pages
364-
return ServerStatus.Ok;
365-
else if (path[1] === 'organizations') {
366-
// Must be org admin for some organization (or a super admin)
367-
if (!isAdmin(session?.user?.roles)) return ServerStatus.Forbidden;
368-
if (params.id) {
369-
// Must be org admin for specified organization (or a super admin)
370-
const Id = parseInt(params.id);
371-
if (isNaN(Id) || !(await DatabaseReads.organizations.findFirst({ where: { Id } }))) {
372-
return ServerStatus.NotFound;
373-
}
374-
return isAdminForOrg(Id, session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
375-
}
376-
return ServerStatus.Ok;
377-
} else if (path[1] === 'products') {
378-
try {
379-
const product = await DatabaseReads.products.findFirst({
380-
where: {
381-
Id: params.id
382-
}
383-
});
384-
if (!product) return ServerStatus.NotFound;
385-
// Must be allowed to view associated project
386-
// (this route was originally part of the project page but was moved elsewhere to improve load time)
387-
return await verifyCanView(session, product.ProjectId);
388-
} catch {
389-
return ServerStatus.NotFound;
390-
}
391-
} else if (path[1] === 'projects') {
392-
if (path[2] === '[filter=projectSelector]') return ServerStatus.Ok;
393-
else if (path[2] === '[id=idNumber]') {
394-
// prevent edits and actions without breaking SSE
395-
if (path[3] === 'edit' || (method !== 'GET' && path[3] !== 'sse')) {
396-
return await verifyCanEdit(session, parseInt(params.id!));
397-
}
398-
// A project can be viewed if the user is an admin or in the project's group
399-
return await verifyCanView(session, parseInt(params.id!));
400-
}
401-
return ServerStatus.Ok;
402-
} else if (path[1] === 'tasks') {
403-
// Own task list always allowed, and specific products checked manually
404-
return ServerStatus.Ok;
405-
} else if (path[1] === 'users') {
406-
// /(invite): admin
407-
if (path.length === 2 || path[2] === 'invite') {
408-
return isAdmin(session?.user?.roles) ? ServerStatus.Ok : ServerStatus.Forbidden;
409-
} else if (path[2] === '[id=idNumber]') {
410-
// /id: not implemented yet (ISSUE #1142)
411-
const subjectId = parseInt(params.id!);
412-
if (!(await DatabaseReads.users.findFirst({ where: { Id: subjectId } })))
413-
return ServerStatus.NotFound;
414-
const admin = !!(await DatabaseReads.organizations.findFirst({
415-
where: adminOrgs(subjectId, session.user.userId, isSuperAdmin(session.user.roles)),
416-
select: {
417-
Id: true
418-
}
419-
}));
420-
// /id/settings/(profile): self and admin
421-
if (!path.at(4) || path[4] === 'profile') {
422-
return subjectId === session.user.userId || admin
423-
? ServerStatus.Ok
424-
: ServerStatus.Forbidden;
425-
} else {
426-
// /id/settings/*: admin
427-
return admin ? ServerStatus.Ok : ServerStatus.Forbidden;
428-
}
429-
}
430-
return ServerStatus.Ok;
431-
} else {
432-
// Unknown route. We'll assume it's a legal route
433-
return ServerStatus.Ok;
434-
}
435-
} else {
436-
return ServerStatus.Ok;
437-
}
438-
}

src/lib/projects/components/ProjectActionMenu.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
<form method="POST" action="?/{endpoint}" use:enhance>
6464
<input type="hidden" name="projectId" value={project.Id} />
6565
<ul class="menu menu-compact overflow-hidden rounded-md">
66-
{#if allowActions && canArchive(project, page.data.session, parseInt(page.params.id))}
66+
{#if allowActions && canArchive(project, page.data.session.user, parseInt(page.params.id))}
6767
<li class="w-full rounded-none">
6868
<BlockIfJobsUnavailable className="text-nowrap">
6969
{#snippet altContent()}
@@ -76,7 +76,7 @@
7676
</BlockIfJobsUnavailable>
7777
</li>
7878
{/if}
79-
{#if allowReactivate && canReactivate(project, page.data.session, parseInt(page.params.id))}
79+
{#if allowReactivate && canReactivate(project, page.data.session.user, parseInt(page.params.id))}
8080
<li class="w-full rounded-none">
8181
<BlockIfJobsUnavailable className="text-nowrap">
8282
{#snippet altContent()}
@@ -94,7 +94,7 @@
9494
</BlockIfJobsUnavailable>
9595
</li>
9696
{/if}
97-
{#if canClaimProject(page.data.session, project.OwnerId, orgId, project.GroupId, userGroups)}
97+
{#if canClaimProject(page.data.session.user, project.OwnerId, orgId, project.GroupId, userGroups)}
9898
<li class="w-full rounded-none">
9999
<BlockIfJobsUnavailable className="text-nowrap">
100100
{#snippet altContent()}

0 commit comments

Comments
 (0)