Skip to content

Commit f7c73d8

Browse files
authored
Merge pull request #1226 from joshunrau/libnest-v8
update libnest to v8. also adds new feature to playground for persistent login with custom limited scope access token.
2 parents 1388475 + edc56f6 commit f7c73d8

File tree

75 files changed

+1455
-2539
lines changed

Some content is hidden

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

75 files changed

+1455
-2539
lines changed

.github/workflows/ci.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ jobs:
1818
node-version-file: '.nvmrc'
1919
- name: Generate Environment
2020
run: ./scripts/generate-env.sh
21-
- name: Setup Mongo
22-
run: |
23-
docker run -d --name mongo -p 27017:27017 mongo:7 --replSet rs0
24-
docker exec mongo mongosh --eval "rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]});"
2521
- name: Configure Git
2622
run: |
2723
# necessary for the git commands used for release info

apps/api/libnest.config.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
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';
1211
import type { TokenPayload } from '@opendatacapture/schemas/auth';
13-
import type { Permissions } from '@opendatacapture/schemas/core';
12+
13+
import type { AppAbility } from '@/auth/auth.types.js';
14+
import type { RuntimePrismaClient } from '@/core/prisma.js';
15+
import type { $Env } from '@/core/schemas/env.schema.js';
1416

1517
declare module '@douglasneuroinformatics/libnest/user-config' {
16-
export interface UserConfig extends InferUserConfig<typeof config> {}
1718
export namespace UserTypes {
18-
export interface JwtPayload extends TokenPayload {}
19-
export interface UserQueryMetadata {
20-
additionalPermissions?: Permissions;
19+
export interface Env extends $Env {}
20+
export interface PrismaClient extends RuntimePrismaClient {}
21+
export interface RequestUser extends TokenPayload {
22+
ability: AppAbility;
2123
}
2224
}
2325
}

apps/api/package.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@
1515
"test": "env-cmd -f ../../.env vitest"
1616
},
1717
"dependencies": {
18+
"@casl/ability": "^6.7.3",
19+
"@casl/prisma": "^1.5.1",
1820
"@douglasneuroinformatics/libcrypto": "catalog:",
1921
"@douglasneuroinformatics/libjs": "catalog:",
20-
"@douglasneuroinformatics/libnest": "^7.3.3",
22+
"@douglasneuroinformatics/libnest": "^8.0.1",
2123
"@douglasneuroinformatics/libpasswd": "catalog:",
2224
"@douglasneuroinformatics/libstats": "catalog:",
2325
"@faker-js/faker": "^9.4.0",
2426
"@nestjs/axios": "^4.0.0",
2527
"@nestjs/common": "^11.0.11",
2628
"@nestjs/core": "^11.0.11",
29+
"@nestjs/jwt": "^11.0.0",
30+
"@nestjs/passport": "^11.0.5",
2731
"@nestjs/platform-express": "^11.0.11",
2832
"@nestjs/swagger": "^11.0.6",
2933
"@opendatacapture/demo": "workspace:*",
@@ -39,7 +43,9 @@
3943
"express": "^5.0.1",
4044
"lodash-es": "workspace:lodash-es__4.x@*",
4145
"mongodb": "^6.15.0",
42-
"neverthrow": "^8.2.0",
46+
"neverthrow": "catalog:",
47+
"passport": "^0.7.0",
48+
"passport-jwt": "^4.0.1",
4349
"reflect-metadata": "^0.1.14",
4450
"rxjs": "^7.8.2",
4551
"ts-pattern": "workspace:ts-pattern__5.x@*",
@@ -48,6 +54,9 @@
4854
"devDependencies": {
4955
"@nestjs/testing": "^11.0.11",
5056
"@types/express": "^5.0.0",
57+
"@types/passport": "^1.0.17",
58+
"@types/passport-jwt": "^4.0.1",
59+
"mongodb-memory-server": "^10.3.0",
5160
"prisma": "catalog:",
5261
"prisma-json-types-generator": "^3.2.2"
5362
},

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

Lines changed: 5 additions & 3 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';
3-
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common/decorators';
1+
import { CurrentUser } from '@douglasneuroinformatics/libnest';
2+
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
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';
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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { CurrentUser } from '@douglasneuroinformatics/libnest';
2+
import type { RequestUser } from '@douglasneuroinformatics/libnest';
3+
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
4+
import { ApiOperation } from '@nestjs/swagger';
5+
import { $LoginCredentials } from '@opendatacapture/schemas/auth';
6+
7+
import { RouteAccess } from '@/core/decorators/route-access.decorator.js';
8+
9+
import { AuthService } from './auth.service.js';
10+
11+
@Controller({ path: 'auth' })
12+
export class AuthController {
13+
constructor(private readonly authService: AuthService) {}
14+
15+
@Get('create-instrument-token')
16+
@HttpCode(HttpStatus.OK)
17+
@RouteAccess({ action: 'create', subject: 'Instrument' })
18+
async getCreateInstrumentToken(@CurrentUser() currentUser: RequestUser): Promise<{ accessToken: string }> {
19+
return this.authService.getCreateInstrumentToken(currentUser);
20+
}
21+
22+
@ApiOperation({ summary: 'Login' })
23+
@HttpCode(HttpStatus.OK)
24+
@Post('login')
25+
@RouteAccess('public')
26+
async login(@Body() credentials: $LoginCredentials): Promise<{ accessToken: string }> {
27+
return this.authService.login(credentials);
28+
}
29+
}

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 {}

0 commit comments

Comments
 (0)