Skip to content

Commit f57d373

Browse files
committed
refactor: implement libnest v8
1 parent 38eb059 commit f57d373

49 files changed

Lines changed: 706 additions & 197 deletions

Some content is hidden

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

apps/api/libnest.config.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
/* eslint-disable @typescript-eslint/no-namespace */
21
/* eslint-disable @typescript-eslint/no-empty-object-type */
32
/* eslint-disable @typescript-eslint/consistent-type-definitions */
3+
/* eslint-disable @typescript-eslint/no-namespace */
44

55
import * as fs from 'node:fs/promises';
66
import * as path from 'node:path';
77
import * as url from 'node:url';
88

99
import { defineUserConfig } from '@douglasneuroinformatics/libnest/user-config';
10-
import type { InferUserConfig } from '@douglasneuroinformatics/libnest/user-config';
1110
import { getReleaseInfo } from '@opendatacapture/release-info';
12-
import type { TokenPayload } from '@opendatacapture/schemas/auth';
13-
import type { Permissions } from '@opendatacapture/schemas/core';
11+
12+
import type { RuntimePrismaClient } from '@/core/prisma.client.js';
13+
import type { $Env } from '@/core/schemas/env.schema.js';
1414

1515
declare module '@douglasneuroinformatics/libnest/user-config' {
16-
export interface UserConfig extends InferUserConfig<typeof config> {}
1716
export namespace UserTypes {
18-
export interface JwtPayload extends TokenPayload {}
19-
export interface UserQueryMetadata {
20-
additionalPermissions?: Permissions;
21-
}
17+
export interface Env extends $Env {}
18+
export interface PrismaClient extends RuntimePrismaClient {}
2219
}
2320
}
2421

apps/api/src/assignments/assignments.controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { CurrentUser, RouteAccess } from '@douglasneuroinformatics/libnest';
2-
import type { AppAbility } from '@douglasneuroinformatics/libnest';
1+
import { CurrentUser } from '@douglasneuroinformatics/libnest';
32
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common/decorators';
43
import { ApiOperation } from '@nestjs/swagger';
54
import type { Assignment } from '@opendatacapture/schemas/assignment';
65

6+
import type { AppAbility } from '@/auth/auth.types';
7+
import { RouteAccess } from '@/core/decorators/route-access.decorator';
8+
79
import { AssignmentsService } from './assignments.service';
810
import { CreateAssignmentDto } from './dto/create-assignment.dto';
911
import { UpdateAssignmentDto } from './dto/update-assignment.dto';

apps/api/src/assignments/assignments.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import crypto from 'node:crypto';
22

33
import { HybridCrypto } from '@douglasneuroinformatics/libcrypto';
4-
import { accessibleQuery, ConfigService, InjectModel } from '@douglasneuroinformatics/libnest';
4+
import { ConfigService, InjectModel } from '@douglasneuroinformatics/libnest';
55
import type { Model } from '@douglasneuroinformatics/libnest';
66
import { Injectable, NotFoundException } from '@nestjs/common';
77
import type { Assignment, UpdateAssignmentData } from '@opendatacapture/schemas/assignment';
88

9+
import { accessibleQuery } from '@/auth/ability.utils';
910
import type { EntityOperationOptions } from '@/core/types';
1011
import { GatewayService } from '@/gateway/gateway.service';
1112

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { accessibleQuery } from '../ability.utils';
4+
5+
const accessibleBy = vi.hoisted(() => vi.fn());
6+
7+
vi.mock('@casl/prisma/runtime', () => ({
8+
createAccessibleByFactory: () => accessibleBy
9+
}));
10+
11+
describe('accessibleQuery', () => {
12+
it('should return an empty object if ability is undefined', () => {
13+
expect(accessibleQuery(undefined, 'manage', 'User')).toStrictEqual({});
14+
expect(accessibleBy).not.toHaveBeenCalled();
15+
});
16+
it('should call accessibleBy with the correct parameters and return the result of accessibleBy for the model', () => {
17+
accessibleBy.mockReturnValueOnce({
18+
User: 'QUERY'
19+
});
20+
const ability = vi.fn();
21+
expect(accessibleQuery(ability as any, 'manage', 'User')).toStrictEqual('QUERY');
22+
expect(accessibleBy).toHaveBeenCalledExactlyOnceWith(ability, 'manage');
23+
});
24+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { AbilityBuilder } from '@casl/ability';
2+
import { createPrismaAbility } from '@casl/prisma';
3+
import { LoggingService } from '@douglasneuroinformatics/libnest';
4+
import { Injectable } from '@nestjs/common';
5+
import type { TokenPayload } from '@opendatacapture/schemas/auth';
6+
7+
import { createAppAbility, detectAppSubject } from './ability.utils';
8+
9+
import type { AppAbility, Permission } from './auth.types';
10+
11+
@Injectable()
12+
export class AbilityFactory {
13+
constructor(private readonly loggingService: LoggingService) {}
14+
15+
createForPayload(payload: Omit<TokenPayload, 'permissions'>): AppAbility {
16+
this.loggingService.verbose({
17+
message: 'Creating Ability From Payload',
18+
payload
19+
});
20+
const ability = new AbilityBuilder<AppAbility>(createPrismaAbility);
21+
const groupIds = payload.groups.map((group) => group.id);
22+
switch (payload.basePermissionLevel) {
23+
case 'ADMIN':
24+
ability.can('manage', 'all');
25+
break;
26+
case 'GROUP_MANAGER':
27+
ability.can('manage', 'Assignment', { groupId: { in: groupIds } });
28+
ability.can('manage', 'Group', { id: { in: groupIds } });
29+
ability.can('read', 'Instrument');
30+
ability.can('create', 'InstrumentRecord');
31+
ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } });
32+
ability.can('create', 'Session');
33+
ability.can('read', 'Session', { groupId: { in: groupIds } });
34+
ability.can('create', 'Subject');
35+
ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } });
36+
ability.can('read', 'User', { groupIds: { hasSome: groupIds } });
37+
break;
38+
case 'STANDARD':
39+
ability.can('read', 'Group', { id: { in: groupIds } });
40+
ability.can('read', 'Instrument');
41+
ability.can('create', 'InstrumentRecord');
42+
ability.can('read', 'Session', { groupId: { in: groupIds } });
43+
ability.can('create', 'Session');
44+
ability.can('create', 'Subject');
45+
ability.can('read', 'Subject', { groupIds: { hasSome: groupIds } });
46+
break;
47+
}
48+
payload.additionalPermissions?.forEach(({ action, subject }) => {
49+
ability.can(action, subject);
50+
});
51+
return ability.build({
52+
detectSubjectType: detectAppSubject
53+
});
54+
}
55+
56+
createForPermissions(permissions: Permission[]): AppAbility {
57+
this.loggingService.verbose({
58+
message: 'Creating Ability From Permissions',
59+
permissions
60+
});
61+
return createAppAbility(permissions);
62+
}
63+
}

apps/api/src/auth/ability.utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { detectSubjectType } from '@casl/ability';
2+
import { createPrismaAbility } from '@casl/prisma';
3+
import type { PrismaQuery } from '@casl/prisma';
4+
import { createAccessibleByFactory } from '@casl/prisma/runtime';
5+
import type { AppSubject, Prisma } from '@prisma/client';
6+
7+
import type { PrismaModelWhereInputMap } from '@/core/prisma.client';
8+
9+
import type { AppAbilities, AppAbility, AppAction, Permission } from './auth.types';
10+
11+
const accessibleBy = createAccessibleByFactory<PrismaModelWhereInputMap, PrismaQuery>();
12+
13+
export function detectAppSubject(obj: { [key: string]: any }) {
14+
if (typeof obj.__modelName === 'string') {
15+
return obj.__modelName as AppSubject;
16+
}
17+
return detectSubjectType(obj) as AppSubject;
18+
}
19+
20+
export function createAppAbility(permissions: Permission[]): AppAbility {
21+
return createPrismaAbility<AppAbilities>(permissions, {
22+
detectSubjectType: detectAppSubject
23+
});
24+
}
25+
26+
export function accessibleQuery<T extends Prisma.ModelName>(
27+
ability: AppAbility | undefined,
28+
action: AppAction,
29+
modelName: T
30+
): NonNullable<PrismaModelWhereInputMap[T]> {
31+
if (!ability) {
32+
return {};
33+
}
34+
return accessibleBy(ability, action)[modelName]!;
35+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
2+
import { ApiOperation } from '@nestjs/swagger';
3+
import { $LoginCredentials } from '@opendatacapture/schemas/auth';
4+
5+
import { RouteAccess } from '@/core/decorators/route-access.decorator.js';
6+
7+
import { AuthService } from './auth.service.js';
8+
9+
@Controller({ path: 'auth' })
10+
export class AuthController {
11+
constructor(private readonly authService: AuthService) {}
12+
13+
@ApiOperation({ summary: 'Login' })
14+
@HttpCode(HttpStatus.OK)
15+
@Post('login')
16+
@RouteAccess('public')
17+
async login(@Body() credentials: $LoginCredentials): Promise<{ accessToken: string }> {
18+
return this.authService.login(credentials);
19+
}
20+
}

apps/api/src/auth/auth.module.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ConfigService } from '@douglasneuroinformatics/libnest';
2+
import { Module } from '@nestjs/common';
3+
import { APP_GUARD } from '@nestjs/core';
4+
import { JwtModule } from '@nestjs/jwt';
5+
6+
import { UsersModule } from '@/users/users.module';
7+
8+
import { AbilityFactory } from './ability.factory';
9+
import { AuthController } from './auth.controller';
10+
import { AuthService } from './auth.service';
11+
import { JwtAuthGuard } from './guards/jwt-auth.guard';
12+
import { JwtStrategy } from './strategies/jwt.strategy';
13+
14+
@Module({
15+
controllers: [AuthController],
16+
imports: [
17+
JwtModule.registerAsync({
18+
inject: [ConfigService],
19+
useFactory: (configService: ConfigService) => ({
20+
secret: configService.get('SECRET_KEY')
21+
})
22+
}),
23+
UsersModule
24+
],
25+
providers: [
26+
AbilityFactory,
27+
AuthService,
28+
JwtStrategy,
29+
{
30+
provide: APP_GUARD,
31+
useClass: JwtAuthGuard
32+
}
33+
]
34+
})
35+
export class AuthModule {}

apps/api/src/auth/auth.service.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { CryptoService } from '@douglasneuroinformatics/libnest';
2+
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
3+
import { JwtService } from '@nestjs/jwt';
4+
import type { $LoginCredentials, TokenPayload } from '@opendatacapture/schemas/auth';
5+
import type { Group, User } from '@prisma/client';
6+
7+
import { UsersService } from '@/users/users.service';
8+
9+
import { AbilityFactory } from './ability.factory';
10+
11+
@Injectable()
12+
export class AuthService {
13+
constructor(
14+
private readonly abilityFactory: AbilityFactory,
15+
private readonly cryptoService: CryptoService,
16+
private readonly jwtService: JwtService,
17+
private readonly usersService: UsersService
18+
) {}
19+
20+
async login(credentials: $LoginCredentials): Promise<{ accessToken: string }> {
21+
let user: User & {
22+
groups: Group[];
23+
};
24+
try {
25+
user = await this.usersService.findByUsername(credentials.username, { includeHashedPassword: true });
26+
} catch (err) {
27+
if (err instanceof NotFoundException) {
28+
throw new UnauthorizedException('Invalid Credentials');
29+
}
30+
throw err;
31+
}
32+
const isCorrectPassword = await this.cryptoService.comparePassword(credentials.password, user.hashedPassword);
33+
if (isCorrectPassword !== true) {
34+
throw new UnauthorizedException('Invalid Credentials');
35+
}
36+
37+
const tokenPayload: Omit<TokenPayload, 'permissions'> = {
38+
additionalPermissions: user.additionalPermissions,
39+
basePermissionLevel: user.basePermissionLevel,
40+
firstName: user.firstName,
41+
groups: user.groups,
42+
lastName: user.lastName,
43+
username: user.username
44+
};
45+
46+
const ability = this.abilityFactory.createForPayload(tokenPayload);
47+
48+
return {
49+
accessToken: await this.jwtService.signAsync(
50+
{
51+
...tokenPayload,
52+
permissions: ability.rules
53+
},
54+
{
55+
expiresIn: '1h'
56+
}
57+
)
58+
};
59+
}
60+
}

apps/api/src/auth/auth.types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { PureAbility } from '@casl/ability';
2+
import type { RawRuleOf } from '@casl/ability';
3+
import type { PrismaQuery, Subjects } from '@casl/prisma';
4+
import { Prisma } from '@prisma/client';
5+
import type { DefaultSelection } from '@prisma/client/runtime/library';
6+
7+
type AppAction = 'create' | 'delete' | 'manage' | 'read' | 'update';
8+
9+
type AppSubjects =
10+
| 'all'
11+
| Subjects<{
12+
[K in keyof Prisma.TypeMap['model']]: DefaultSelection<Prisma.TypeMap['model'][K]['payload']>;
13+
}>;
14+
15+
type AppSubjectName = Extract<AppSubjects, string>;
16+
17+
type AppAbilities = [AppAction, AppSubjects];
18+
19+
type AppAbility = PureAbility<AppAbilities, PrismaQuery>;
20+
21+
type Permission = RawRuleOf<PureAbility<[AppAction, AppSubjectName], PrismaQuery>>;
22+
23+
export type { AppAbilities, AppAbility, AppAction, AppSubjectName, Permission };

0 commit comments

Comments
 (0)