Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/authorization/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @nestjs-cls/authorization

An "Auth" plugin for `nestjs-cls` that simplifies verifying permissions by storing the authorization object in the CLS context.

The authorization object can be retrieved in any other service and used to verify permissions without having to pass it around. It can also be queried using the handy `RequirePermission` decorator.

### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/authorization) 📖
9 changes: 9 additions & 0 deletions packages/authorization/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testRegex: '.*\\.spec\\.ts$',
preset: 'ts-jest',
collectCoverageFrom: ['src/**/*.ts'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
};
70 changes: 70 additions & 0 deletions packages/authorization/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@nestjs-cls/authorization",
"version": "0.0.1",
"description": "A nestjs-cls plugin for authorization",
"author": "papooch",
"license": "MIT",
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Papooch/nestjs-cls.git"
},
"homepage": "https://papooch.github.io/nestjs-cls/",
"keywords": [
"nest",
"nestjs",
"cls",
"continuation-local-storage",
"als",
"AsyncLocalStorage",
"async_hooks",
"request context",
"async context",
"auth",
"authorization"
],
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"files": [
"dist/src/**/!(*.spec).d.ts",
"dist/src/**/!(*.spec).js"
],
"scripts": {
"prepack": "cp ../../../LICENSE ./LICENSE",
"prebuild": "rimraf dist",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"peerDependencies": {
"@nestjs/common": ">= 10 < 12",
"@nestjs/core": ">= 10 < 12",
"nestjs-cls": "workspace:^6.0.1",
"reflect-metadata": "*",
"rxjs": ">= 7"
},
"devDependencies": {
"@nestjs/cli": "^11.0.7",
"@nestjs/common": "^11.1.3",
"@nestjs/core": "^11.1.3",
"@nestjs/testing": "^11.1.3",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.12",
"jest": "^29.7.0",
"nestjs-cls": "workspace:6.0.1",
"reflect-metadata": "^0.2.2",
"rimraf": "^6.0.1",
"rxjs": "^7.8.1",
"ts-jest": "^29.3.1",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "5.8.2"
}
}
4 changes: 4 additions & 0 deletions packages/authorization/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './lib/auth-host';
export * from './lib/plugin-authorization';
export * from './lib/require-permission.decorator';
export * from './lib/permission-denied.exception';
85 changes: 85 additions & 0 deletions packages/authorization/src/lib/auth-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Inject, Logger } from '@nestjs/common';
import { ClsServiceManager } from 'nestjs-cls';
import {
AuthHostOptions,
RequirePermissionOptions,
} from './authorization.interfaces';
import { AUTH_HOST_OPTIONS, getAuthClsKey } from './authorization.symbols';

export class AuthHost<TAuth = never> {
private readonly cls = ClsServiceManager.getClsService();
private readonly logger = new Logger(AuthHost.name);
private readonly authInstanceSymbol: symbol;

private static _instanceMap = new Map<symbol, AuthHost<any>>();

static getInstance<TAuth = never>(authName?: string): AuthHost<TAuth> {
const instanceSymbol = getAuthClsKey(authName);
const instance = this._instanceMap.get(instanceSymbol);

if (!instance) {
throw new Error(
'AuthHost not initialized, Make sure that the `ClsPluginAuth` is properly registered and that the correct `authName` is used.',
);
}
return instance;
}

constructor(
@Inject(AUTH_HOST_OPTIONS)
private readonly options: AuthHostOptions<TAuth>,
) {
this.authInstanceSymbol = getAuthClsKey();
AuthHost._instanceMap.set(this.authInstanceSymbol, this);
}

get auth(): TAuth {
if (!this.cls.isActive()) {
throw new Error(
'CLS context is not active, cannot retrieve auth instance.',
);
}
return this.cls.get(this.authInstanceSymbol) as TAuth;
}

setAuth(auth: TAuth): void {
this.cls.set(this.authInstanceSymbol, auth);
}

hasPermission(predicate: (auth: TAuth) => boolean): boolean;
hasPermission(permission: any): boolean;
hasPermission(
predicateOrPermission: ((auth: TAuth) => boolean) | any,
): boolean {
if (typeof predicateOrPermission === 'function') {
return predicateOrPermission(this.auth);
} else {
return this.options.permissionResolutionStrategy(
this.auth,
predicateOrPermission,
);
}
}

requirePermission(
permission: any,
options?: RequirePermissionOptions,
): void;
requirePermission(
predicate: (auth: TAuth) => boolean,
options?: RequirePermissionOptions,
): void;
requirePermission(
predicateOrPermission: ((auth: TAuth) => boolean) | any,
options: RequirePermissionOptions = {},
): void {
const value =
typeof predicateOrPermission === 'function'
? undefined
: predicateOrPermission;

if (!this.hasPermission(predicateOrPermission)) {
throw this.options.exceptionFactory(options, value);
}
}
}
11 changes: 11 additions & 0 deletions packages/authorization/src/lib/authorization.interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface RequirePermissionOptions {
exceptionMessage?: string;
}

export interface AuthHostOptions<TAuth = any> {
exceptionFactory: (
options: RequirePermissionOptions,
value?: any,
) => Error | string;
permissionResolutionStrategy: (authObject: TAuth, value: any) => boolean;
}
8 changes: 8 additions & 0 deletions packages/authorization/src/lib/authorization.symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const AUTH_HOST_OPTIONS = Symbol('AUTH_HOST_OPTIONS');

const AUTH_CLS_KEY = Symbol('AUTH_CLS_KEY');

export const getAuthClsKey = (authName?: string) =>
authName
? Symbol.for(`${AUTH_CLS_KEY.description}_${authName}`)
: AUTH_CLS_KEY;
6 changes: 6 additions & 0 deletions packages/authorization/src/lib/permission-denied.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class PermissionDeniedException extends Error {
constructor(message: string) {
super(message);
this.name = 'PermissionDeniedException';
}
}
89 changes: 89 additions & 0 deletions packages/authorization/src/lib/plugin-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ClsPluginBase, ClsService } from 'nestjs-cls';
import { AuthHost } from './auth-host';
import { AUTH_HOST_OPTIONS, getAuthClsKey } from './authorization.symbols';
import {
AuthHostOptions,
RequirePermissionOptions,
} from './authorization.interfaces';
//import { UnauthorizedException } from '@nestjs/common';

const CLS_AUTHORIZATION_OPTIONS = Symbol('CLS_AUTHORIZATION_OPTIONS');

interface ClsAuthCallbacks<T> {
authObjectFactory: (cls: ClsService) => T;
permissionResolutionStrategy: (authObject: T, value: any) => boolean;
exceptionFactory?: (
options: RequirePermissionOptions,
value?: any,
) => Error | string;
}

export interface ClsPluginAuthorizationOptions<T> {
imports?: any[];
inject?: any[];
useFactory?: (...args: any[]) => ClsAuthCallbacks<T>;
}

export class ClsPluginAuthorization extends ClsPluginBase {
constructor(options: ClsPluginAuthorizationOptions<any>) {
super('cls-plugin-authorization');
this.imports.push(...(options.imports ?? []));
this.providers = [
{
provide: CLS_AUTHORIZATION_OPTIONS,
inject: options.inject,
useFactory: options.useFactory ?? (() => ({})),
},
{
provide: AUTH_HOST_OPTIONS,
inject: [CLS_AUTHORIZATION_OPTIONS],
useFactory: (callbacks: ClsAuthCallbacks<any>) =>
({
permissionResolutionStrategy:
callbacks.permissionResolutionStrategy,
exceptionFactory: (
options: RequirePermissionOptions,
value?: any,
) => {
const factory =
callbacks.exceptionFactory ??
this.defaultExceptionFactory;

const exception = factory(options, value);
if (typeof exception === 'string') {
return new Error(exception);
}
return exception;
},
}) satisfies AuthHostOptions,
},
{
provide: AuthHost,
useClass: AuthHost,
},
];

this.registerHooks({
inject: [CLS_AUTHORIZATION_OPTIONS],
useFactory: (options: ClsAuthCallbacks<any>) => ({
afterSetup: (cls: ClsService) => {
const authObject = options.authObjectFactory(cls);
cls.setIfUndefined(getAuthClsKey(), authObject);
},
}),
});

this.exports = [AuthHost];
}

private defaultExceptionFactory(
options: RequirePermissionOptions,
value?: any,
): Error | string {
let message = options.exceptionMessage ?? 'Permission denied';
if (value !== undefined) {
message += ` (${JSON.stringify(value)})`;
}
return message;
}
}
72 changes: 72 additions & 0 deletions packages/authorization/src/lib/require-permission.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { copyMethodMetadata } from 'nestjs-cls';
import { AuthHost } from './auth-host';
import { RequirePermissionOptions } from './authorization.interfaces';

export function RequirePermission<TAuth = any>(
predicate: (auth: TAuth) => boolean,
options?: RequirePermissionOptions,
): MethodDecorator;

export function RequirePermission(
permission: any,
options?: RequirePermissionOptions,
): MethodDecorator;

export function RequirePermission<TAuth = any>(
authName: string,
predicate: (auth: TAuth) => boolean,
options: RequirePermissionOptions,
): MethodDecorator;

export function RequirePermission(
authName: string,
permission: any,
options: RequirePermissionOptions,
): MethodDecorator;

export function RequirePermission<TAuth = any>(
firstParam: any,
secondParam?: any,
thirdParam?: any,
): MethodDecorator {
let authName: string | undefined;
let predicateOrPermission: ((auth: TAuth) => boolean) | any;
let options: RequirePermissionOptions | undefined;

if (arguments.length === 3) {
authName = firstParam;
predicateOrPermission = secondParam;
options = thirdParam;
} else {
authName = undefined;
predicateOrPermission = firstParam;
options = secondParam;
}

options ??= {
exceptionMessage: 'Permission denied',
};

return ((
_target: any,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<(...args: any) => any>,
) => {
const original = descriptor.value;
if (typeof original !== 'function') {
throw new Error(
`The @RequirePermission decorator can be only used on functions, but ${propertyKey.toString()} is not a function.`,
);
}
descriptor.value = new Proxy(original, {
apply: async function (_, outerThis, args: any[]) {
const authHost = AuthHost.getInstance(authName);

authHost.requirePermission(predicateOrPermission, options);

return await original.call(outerThis, ...args);
},
});
copyMethodMetadata(original, descriptor.value);
}) as MethodDecorator;
}
Loading
Loading