From 427ca14294c3ea91526fe3e260960cde84d3ff66 Mon Sep 17 00:00:00 2001 From: AlexandreBrg Date: Tue, 17 Jan 2023 14:20:32 +0100 Subject: [PATCH 1/5] chore: add keto sdk to x-utils --- npm-packages/x-utils/package-lock.json | 19 +++++++++++++++++-- npm-packages/x-utils/package.json | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/npm-packages/x-utils/package-lock.json b/npm-packages/x-utils/package-lock.json index 92deced..889bb69 100644 --- a/npm-packages/x-utils/package-lock.json +++ b/npm-packages/x-utils/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/config": "^2.0.0", "@nestjs/core": "^8.4.4", "@nestjs/microservices": "^8.4.4", + "@ory/keto-client": "^0.10.0-alpha.0", "express": "^4.18.1" }, "devDependencies": { @@ -975,6 +976,14 @@ "npm": ">=5.0.0" } }, + "node_modules/@ory/keto-client": { + "version": "0.10.0-alpha.0", + "resolved": "https://registry.npmjs.org/@ory/keto-client/-/keto-client-0.10.0-alpha.0.tgz", + "integrity": "sha512-JlBZtIM7PPgLqP1vrWN8LBS0fbGoNjtzwYP4Zduy6e46mZkv2LStMnTk7c7Em1ss8J9VngvPxGGjQO5sLUnj5g==", + "dependencies": { + "axios": "^0.21.4" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -1286,7 +1295,6 @@ "version": "0.21.4", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "peer": true, "dependencies": { "follow-redirects": "^1.14.0" } @@ -5937,6 +5945,14 @@ "node-fetch": "^2.6.1" } }, + "@ory/keto-client": { + "version": "0.10.0-alpha.0", + "resolved": "https://registry.npmjs.org/@ory/keto-client/-/keto-client-0.10.0-alpha.0.tgz", + "integrity": "sha512-JlBZtIM7PPgLqP1vrWN8LBS0fbGoNjtzwYP4Zduy6e46mZkv2LStMnTk7c7Em1ss8J9VngvPxGGjQO5sLUnj5g==", + "requires": { + "axios": "^0.21.4" + } + }, "@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -6204,7 +6220,6 @@ "version": "0.21.4", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "peer": true, "requires": { "follow-redirects": "^1.14.0" } diff --git a/npm-packages/x-utils/package.json b/npm-packages/x-utils/package.json index 6e75fe0..a92f7d7 100644 --- a/npm-packages/x-utils/package.json +++ b/npm-packages/x-utils/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@nestjs/common": "^8.4.4", + "@ory/keto-client": "^0.10.0-alpha.0", "@nestjs/config": "^2.0.0", "@nestjs/core": "^8.4.4", "@nestjs/microservices": "^8.4.4", From a8990b8fa706347abcee9deefd48fd2090505e2a Mon Sep 17 00:00:00 2001 From: AlexandreBrg Date: Tue, 17 Jan 2023 14:21:03 +0100 Subject: [PATCH 2/5] feat: add keto sdk to x-utils --- .../x-utils/src/authz/authz.module.ts | 38 ++++++ .../x-utils/src/authz/authz.service.ts | 113 ++++++++++++++++++ npm-packages/x-utils/src/authz/index.ts | 2 + npm-packages/x-utils/src/index.ts | 1 + 4 files changed, 154 insertions(+) create mode 100644 npm-packages/x-utils/src/authz/authz.module.ts create mode 100644 npm-packages/x-utils/src/authz/authz.service.ts create mode 100644 npm-packages/x-utils/src/authz/index.ts diff --git a/npm-packages/x-utils/src/authz/authz.module.ts b/npm-packages/x-utils/src/authz/authz.module.ts new file mode 100644 index 0000000..fb01d54 --- /dev/null +++ b/npm-packages/x-utils/src/authz/authz.module.ts @@ -0,0 +1,38 @@ +import { DynamicModule, Global, Module } from "@nestjs/common"; +import { AuthorizationService } from "./authz.service"; +import { ConfigModule, ConfigService } from "@nestjs/config"; + +export interface AuthorizationModuleOptions { + inject?: any[]; + useFactory: (...args: any[]) => Promise | DynamicModule; +} + +@Module({ + providers: [ + ConfigService, + { + inject: [ConfigService], + useFactory: AuthorizationService.fromConfigService, + provide: AuthorizationService, + }, + ], + imports: [ConfigModule], + exports: [AuthorizationService], +}) +export class AuthorizationModule { + static register(): DynamicModule { + return { + module: AuthorizationModule, + providers: [ + ConfigService, + { + inject: [ConfigService], + useFactory: AuthorizationService.fromConfigService, + provide: AuthorizationService, + }, + ], + imports: [ConfigModule], + exports: [AuthorizationService], + }; + } +} diff --git a/npm-packages/x-utils/src/authz/authz.service.ts b/npm-packages/x-utils/src/authz/authz.service.ts new file mode 100644 index 0000000..86bd25a --- /dev/null +++ b/npm-packages/x-utils/src/authz/authz.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Configuration, + ReadApiFactory, + ReadApiInterface, +} from "@ory/keto-client"; + +export interface AuthorizationServiceConfig { + read_address: string; + write_address?: string; + api_key?: string; +} + +@Injectable() +export class AuthorizationService { + private readonly logger = new Logger(AuthorizationService.name); + + private readonly readApi: ReadApiInterface; + + constructor(cfgSvc: AuthorizationServiceConfig) { + const config = new Configuration({ + basePath: cfgSvc.read_address, + apiKey: cfgSvc.api_key, + baseOptions: { + headers: { + Authorization: `Bearer ${cfgSvc.api_key}`, + }, + }, + }); + this.readApi = ReadApiFactory(config); + this.isHealthy(); + this.canViewVideo("mySubject", "test"); + this.canCreateVideo("mySubject"); + } + + private async can( + namespace: string, + object: string, + relation: string, + subject: string + ): Promise { + return this.readApi + .getCheck(namespace, object, relation, subject) + .then((res) => { + this.logger.debug( + `Requested access to video (${object}) for subject (${subject}), result: ${res.data.allowed}` + ); + return res.data.allowed; + }) + .catch((err) => { + this.logger.error( + `Could not check access to video (${object}) for subject (${subject}), error: ${err}` + ); + return false; + }); + } + + async canVideo( + relation: string, + userId: string, + videoId: string + ): Promise { + return this.can("Video", videoId, relation, userId); + } + + async canViewVideo(userId: string, videoId: string): Promise { + return this.canVideo("view", userId, videoId); + } + + async canCreateVideo(userId: string): Promise { + return this.can("Role", "admin", "member", userId); + } + + private async isHealthy(): Promise { + const response = await this.readApi.getCheck( + "User", + "test", + "test", + "test" + ); + + if (response.status !== 200) { + this.logger.error( + `AuthorizationService is not healthy (http ${response.status}): ${response.data}` + ); + } else { + this.logger.log(`AuthorizationService is up & running`); + } + + return response.status === 200; + } + + /** + * Use the config service to load pre-defined configuration format + * It will throw if the configuration is not valid + */ + static fromConfigService(cfgSvc: ConfigService): AuthorizationService { + const getOrThrow = (key: string): T => { + const value = cfgSvc.get(key); + if (value === undefined) { + throw new Error(`Missing configuration key: ${key}`); + } + return value; + }; + + return new AuthorizationService({ + read_address: getOrThrow("authorization.read_address"), + write_address: cfgSvc.get("authorization.write_address"), + api_key: cfgSvc.get("authorization.api_key"), + }); + } +} diff --git a/npm-packages/x-utils/src/authz/index.ts b/npm-packages/x-utils/src/authz/index.ts new file mode 100644 index 0000000..7078d0a --- /dev/null +++ b/npm-packages/x-utils/src/authz/index.ts @@ -0,0 +1,2 @@ +export * from "./authz.service"; +export * from "./authz.module"; diff --git a/npm-packages/x-utils/src/index.ts b/npm-packages/x-utils/src/index.ts index 2232de5..673083a 100644 --- a/npm-packages/x-utils/src/index.ts +++ b/npm-packages/x-utils/src/index.ts @@ -2,3 +2,4 @@ export * from "./kafka"; export * from "./guards"; export * from "./types"; export * from "./decorators"; +export * from "./authz"; From 1c9c9b4ff8b2dbd1024dfbd54e64a84f6310243f Mon Sep 17 00:00:00 2001 From: AlexandreBrg Date: Tue, 17 Jan 2023 15:59:06 +0100 Subject: [PATCH 3/5] wip --- npm-packages/x-utils/package-lock.json | 9 ++++----- npm-packages/x-utils/package.json | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/npm-packages/x-utils/package-lock.json b/npm-packages/x-utils/package-lock.json index 889bb69..01a9380 100644 --- a/npm-packages/x-utils/package-lock.json +++ b/npm-packages/x-utils/package-lock.json @@ -14,7 +14,8 @@ "@nestjs/core": "^8.4.4", "@nestjs/microservices": "^8.4.4", "@ory/keto-client": "^0.10.0-alpha.0", - "express": "^4.18.1" + "express": "^4.18.1", + "reflect-metadata": "^0.1.12" }, "devDependencies": { "@types/jest": "^26.0.23", @@ -4383,8 +4384,7 @@ "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", - "peer": true + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, "node_modules/require-directory": { "version": "2.1.1", @@ -8549,8 +8549,7 @@ "reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", - "peer": true + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, "require-directory": { "version": "2.1.1", diff --git a/npm-packages/x-utils/package.json b/npm-packages/x-utils/package.json index a92f7d7..84d0303 100644 --- a/npm-packages/x-utils/package.json +++ b/npm-packages/x-utils/package.json @@ -1,7 +1,7 @@ { "name": "@polyflix/x-utils", "version": "1.8.2", - "description": "NestJS utilities for Polyflix from Polytech", + "description": "NestJS utilities for Polyflix", "main": "dist/index.js", "files": [ "dist/**/*", @@ -28,6 +28,7 @@ "typescript": "^4.3.2" }, "dependencies": { + "reflect-metadata": "^0.1.12", "@nestjs/common": "^8.4.4", "@ory/keto-client": "^0.10.0-alpha.0", "@nestjs/config": "^2.0.0", From 27a985766a9350935859e5d58e117a207692ab86 Mon Sep 17 00:00:00 2001 From: AlexandreBrg Date: Fri, 20 Jan 2023 15:28:49 +0100 Subject: [PATCH 4/5] feat: add keto sdk layer --- npm-packages/x-utils/package-lock.json | 39 +-- npm-packages/x-utils/package.json | 15 +- npm-packages/x-utils/src/authz/authz.error.ts | 6 + .../x-utils/src/authz/authz.module.ts | 61 ++-- .../x-utils/src/authz/authz.service.ts | 292 ++++++++++++------ npm-packages/x-utils/src/authz/authz.types.ts | 39 +++ npm-packages/x-utils/src/authz/index.ts | 23 ++ .../x-utils/src/authz/keto/namespaces.ts | 5 + 8 files changed, 329 insertions(+), 151 deletions(-) create mode 100644 npm-packages/x-utils/src/authz/authz.error.ts create mode 100644 npm-packages/x-utils/src/authz/authz.types.ts create mode 100644 npm-packages/x-utils/src/authz/keto/namespaces.ts diff --git a/npm-packages/x-utils/package-lock.json b/npm-packages/x-utils/package-lock.json index 01a9380..26809fc 100644 --- a/npm-packages/x-utils/package-lock.json +++ b/npm-packages/x-utils/package-lock.json @@ -9,13 +9,13 @@ "version": "1.8.2", "license": "ISC", "dependencies": { - "@nestjs/common": "^8.4.4", - "@nestjs/config": "^2.0.0", - "@nestjs/core": "^8.4.4", - "@nestjs/microservices": "^8.4.4", + "@nestjs/common": "8.4.4", + "@nestjs/config": "2.0.0", + "@nestjs/core": "8.4.4", + "@nestjs/microservices": "8.4.4", "@ory/keto-client": "^0.10.0-alpha.0", "express": "^4.18.1", - "reflect-metadata": "^0.1.12" + "reflect-metadata": "0.1.13" }, "devDependencies": { "@types/jest": "^26.0.23", @@ -25,6 +25,9 @@ "typescript": "^4.3.2" }, "peerDependencies": { + "@nestjs/common": "8.4.x", + "@nestjs/config": "^2.0.0", + "@nestjs/core": "8.4.x", "axios": "^0.21.1" } }, @@ -855,9 +858,9 @@ } }, "node_modules/@nestjs/core": { - "version": "8.4.6", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.4.6.tgz", - "integrity": "sha512-5zHpxTYV7HT3lfF7l/x0EWBfmuyuDOnGRcALf88tzDGs/7Q/VC6l65d6eFwDwI37NLtScqnmEkT9of8E3fT3mA==", + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.4.4.tgz", + "integrity": "sha512-Ef3yJPuzAttpNfehnGqIV5kHIL9SHptB5F4ERxoU7pT61H3xiYpZw6hSjx68cJO7cc6rm7/N+b4zeuJvFHtvBg==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -865,7 +868,7 @@ "iterare": "1.2.1", "object-hash": "3.0.0", "path-to-regexp": "3.2.0", - "tslib": "2.4.0", + "tslib": "2.3.1", "uuid": "8.3.2" }, "funding": { @@ -897,11 +900,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" }, - "node_modules/@nestjs/core/node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" - }, "node_modules/@nestjs/microservices": { "version": "8.4.4", "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-8.4.4.tgz", @@ -5900,16 +5898,16 @@ } }, "@nestjs/core": { - "version": "8.4.6", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.4.6.tgz", - "integrity": "sha512-5zHpxTYV7HT3lfF7l/x0EWBfmuyuDOnGRcALf88tzDGs/7Q/VC6l65d6eFwDwI37NLtScqnmEkT9of8E3fT3mA==", + "version": "8.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.4.4.tgz", + "integrity": "sha512-Ef3yJPuzAttpNfehnGqIV5kHIL9SHptB5F4ERxoU7pT61H3xiYpZw6hSjx68cJO7cc6rm7/N+b4zeuJvFHtvBg==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "object-hash": "3.0.0", "path-to-regexp": "3.2.0", - "tslib": "2.4.0", + "tslib": "2.3.1", "uuid": "8.3.2" }, "dependencies": { @@ -5917,11 +5915,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" - }, - "tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" } } }, diff --git a/npm-packages/x-utils/package.json b/npm-packages/x-utils/package.json index 84d0303..9975224 100644 --- a/npm-packages/x-utils/package.json +++ b/npm-packages/x-utils/package.json @@ -18,7 +18,10 @@ "author": "", "license": "ISC", "peerDependencies": { - "axios": "^0.21.1" + "axios": "^0.21.1", + "@nestjs/common": "8.4.x", + "@nestjs/core": "8.4.x", + "@nestjs/config": "^2.0.0" }, "devDependencies": { "@types/jest": "^26.0.23", @@ -28,12 +31,12 @@ "typescript": "^4.3.2" }, "dependencies": { - "reflect-metadata": "^0.1.12", - "@nestjs/common": "^8.4.4", + "reflect-metadata": "0.1.13", + "@nestjs/common": "8.4.4", "@ory/keto-client": "^0.10.0-alpha.0", - "@nestjs/config": "^2.0.0", - "@nestjs/core": "^8.4.4", - "@nestjs/microservices": "^8.4.4", + "@nestjs/config": "2.0.0", + "@nestjs/core": "8.4.4", + "@nestjs/microservices": "8.4.4", "express": "^4.18.1" } } diff --git a/npm-packages/x-utils/src/authz/authz.error.ts b/npm-packages/x-utils/src/authz/authz.error.ts new file mode 100644 index 0000000..7cc9540 --- /dev/null +++ b/npm-packages/x-utils/src/authz/authz.error.ts @@ -0,0 +1,6 @@ +export class AuthorizationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthorizationError"; + } +} diff --git a/npm-packages/x-utils/src/authz/authz.module.ts b/npm-packages/x-utils/src/authz/authz.module.ts index fb01d54..b2be6a1 100644 --- a/npm-packages/x-utils/src/authz/authz.module.ts +++ b/npm-packages/x-utils/src/authz/authz.module.ts @@ -1,38 +1,35 @@ -import { DynamicModule, Global, Module } from "@nestjs/common"; -import { AuthorizationService } from "./authz.service"; +import { DynamicModule, Logger, Module } from "@nestjs/common"; +import { + AuthorizationService, + AuthorizationServiceConfig +} from "./authz.service"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { configureAuthZService } from "./index"; +import { ModuleMetadata } from "@nestjs/common/interfaces"; -export interface AuthorizationModuleOptions { - inject?: any[]; - useFactory: (...args: any[]) => Promise | DynamicModule; +export interface AuthorizationModuleOptions + extends Pick { + inject?: any[]; + useFactory: ( + ...args: any[] + ) => Promise | AuthorizationService; } -@Module({ - providers: [ - ConfigService, - { - inject: [ConfigService], - useFactory: AuthorizationService.fromConfigService, - provide: AuthorizationService, - }, - ], - imports: [ConfigModule], - exports: [AuthorizationService], -}) +@Module({}) export class AuthorizationModule { - static register(): DynamicModule { - return { - module: AuthorizationModule, - providers: [ - ConfigService, - { - inject: [ConfigService], - useFactory: AuthorizationService.fromConfigService, - provide: AuthorizationService, - }, - ], - imports: [ConfigModule], - exports: [AuthorizationService], - }; - } + private static readonly logger = new Logger(AuthorizationModule.name); + static register(options: AuthorizationModuleOptions): DynamicModule { + return { + module: AuthorizationModule, + imports: options.imports, + providers: [ + { + provide: AuthorizationService, + useFactory: options.useFactory, + inject: options.inject || [] + } + ], + exports: [AuthorizationService] + }; + } } diff --git a/npm-packages/x-utils/src/authz/authz.service.ts b/npm-packages/x-utils/src/authz/authz.service.ts index 86bd25a..9334a68 100644 --- a/npm-packages/x-utils/src/authz/authz.service.ts +++ b/npm-packages/x-utils/src/authz/authz.service.ts @@ -1,113 +1,225 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { - Configuration, - ReadApiFactory, - ReadApiInterface, + Configuration, + MetadataApiFactory, + MetadataApiInterface, + ReadApiFactory, + ReadApiInterface, + SubjectSet, + WriteApiFactory, + WriteApiInterface } from "@ory/keto-client"; +import { Namespace } from "./keto/namespaces"; +import { AuthorizationError } from "./authz.error"; +import { AxiosRequestConfig } from "axios"; +import { Role } from "../types"; export interface AuthorizationServiceConfig { - read_address: string; - write_address?: string; - api_key?: string; + read_address: string; + write_address?: string; + api_key?: string; } @Injectable() export class AuthorizationService { - private readonly logger = new Logger(AuthorizationService.name); + private readonly logger = new Logger(AuthorizationService.name); - private readonly readApi: ReadApiInterface; + private readonly readApi: ReadApiInterface; - constructor(cfgSvc: AuthorizationServiceConfig) { - const config = new Configuration({ - basePath: cfgSvc.read_address, - apiKey: cfgSvc.api_key, - baseOptions: { - headers: { - Authorization: `Bearer ${cfgSvc.api_key}`, - }, - }, - }); - this.readApi = ReadApiFactory(config); - this.isHealthy(); - this.canViewVideo("mySubject", "test"); - this.canCreateVideo("mySubject"); - } + private readonly writeApi: WriteApiInterface; - private async can( - namespace: string, - object: string, - relation: string, - subject: string - ): Promise { - return this.readApi - .getCheck(namespace, object, relation, subject) - .then((res) => { - this.logger.debug( - `Requested access to video (${object}) for subject (${subject}), result: ${res.data.allowed}` - ); - return res.data.allowed; - }) - .catch((err) => { - this.logger.error( - `Could not check access to video (${object}) for subject (${subject}), error: ${err}` - ); - return false; - }); - } + private readonly metadataApi: MetadataApiInterface; + + constructor(cfgSvc: AuthorizationServiceConfig) { + const readConfig = new Configuration({ + basePath: cfgSvc.read_address, + apiKey: cfgSvc.api_key, + baseOptions: { + headers: { + Authorization: `Bearer ${cfgSvc.api_key}` + } + } + }); + const writeConfig = new Configuration({ + ...readConfig, + basePath: cfgSvc.write_address + }); + this.readApi = ReadApiFactory(readConfig); + this.writeApi = WriteApiFactory(writeConfig); + this.metadataApi = MetadataApiFactory(readConfig); + this.isHealthy(); + } - async canVideo( - relation: string, - userId: string, - videoId: string - ): Promise { - return this.can("Video", videoId, relation, userId); - } + private async canId( + namespace: string, + object: string, + relation: string, + subjectId: string, + maxDepth?: number, + options?: AxiosRequestConfig + ): Promise { + const tuple_str = `${subjectId} -> ${namespace}:${object}:${relation}`; + this.logger.debug(`can(), ${tuple_str}`); + return this.readApi + .getCheck( + namespace, + object, + relation, + subjectId, + undefined, + undefined, + undefined, + maxDepth, + options + ) + .then((res) => { + this.logger.debug(`can(), ${tuple_str} => ${res.data.allowed}`); + return res.data.allowed; + }) + .catch((err) => { + this.logger.error(`can(), ${tuple_str} => ${err}`); + return false; + }); + } - async canViewVideo(userId: string, videoId: string): Promise { - return this.canVideo("view", userId, videoId); - } + private async canSet( + namespace: string, + object: string, + relation: string, + subjectSet?: SubjectSet, + maxDepth?: number, + options?: AxiosRequestConfig + ): Promise { + const tuple_str = `${subjectSet.namespace}:${subjectSet.object}#${subjectSet.relation} -> ${namespace}:${object}#${relation}`; + this.logger.debug(`can(), ${tuple_str}`); + return this.readApi + .getCheck( + namespace, + object, + relation, + undefined, + subjectSet.namespace, + subjectSet.object, + subjectSet.relation, + maxDepth, + options + ) + .then((res) => { + this.logger.debug(`can(), ${tuple_str} => ${res.data.allowed}`); + return res.data.allowed; + }) + .catch((err) => { + this.logger.error(`can(), ${tuple_str} => ${err}`); + return false; + }); + } - async canCreateVideo(userId: string): Promise { - return this.can("Role", "admin", "member", userId); - } + async canViewVideo(userId: string, videoId: string): Promise { + return Promise.all([ + this.canSet(Namespace.Video, videoId, "view", { + namespace: Namespace.User, + object: userId, + relation: "manager" + }), + this.canId(Namespace.Video, videoId, "view", "*") + ]).then(([canView, canDoAnything]) => canView || canDoAnything); + } - private async isHealthy(): Promise { - const response = await this.readApi.getCheck( - "User", - "test", - "test", - "test" - ); + async writeOwnerToVideo(userId: string, videoId: string): Promise { + const tuple = { + namespace: Namespace.Video, + object: videoId, + relation: "owners", + subject_set: { + namespace: Namespace.User, + object: userId, + relation: "manager" + } + }; + this.logger.debug( + `writeOwnerToVideo(), tuple: ${JSON.stringify(tuple)}` + ); + this.writeApi.createRelationTuple(tuple).catch((err) => { + this.logger.error( + `Could not set video (${videoId}) owner to user (${userId}), error: ${err}` + ); + throw new AuthorizationError("Failed to set video owner"); + }); + } - if (response.status !== 200) { - this.logger.error( - `AuthorizationService is not healthy (http ${response.status}): ${response.data}` - ); - } else { - this.logger.log(`AuthorizationService is up & running`); + async writeOwnerRoleToVideo(role: Role, videoId: string): Promise { + const tuple = { + namespace: Namespace.Video, + object: videoId, + relation: "owners", + subject_set: { + namespace: Namespace.Role, + object: role, + relation: "members" + } + }; + this.logger.debug( + `writeOwnerRoleToVideo(), tuple: ${JSON.stringify(tuple)}` + ); + this.writeApi.createRelationTuple(tuple).catch((err) => { + this.logger.error( + `Could not set video (${videoId}) owner to role (${role}), error: ${err}` + ); + throw new AuthorizationError("Failed to add role owner to video"); + }); } - return response.status === 200; - } + async setVideoPublic(videoId: string): Promise { + const tuple = { + namespace: Namespace.Video, + object: videoId, + relation: "viewers", + subject_id: "*" + }; + this.writeApi + .createRelationTuple(tuple) + .then((res) => { + this.logger.log(`Video (${videoId}) is now public`); + this.logger.debug( + `setVideoPublic(), tuple: ${JSON.stringify(tuple)}` + ); + }) + .catch((err) => { + this.logger.error( + `Could not set video (${videoId}) to public, error: ${err}` + ); + throw new AuthorizationError( + "Failed to define video as public" + ); + }); + } - /** - * Use the config service to load pre-defined configuration format - * It will throw if the configuration is not valid - */ - static fromConfigService(cfgSvc: ConfigService): AuthorizationService { - const getOrThrow = (key: string): T => { - const value = cfgSvc.get(key); - if (value === undefined) { - throw new Error(`Missing configuration key: ${key}`); - } - return value; - }; + async setVideoPrivate(videoId: string): Promise { + this.writeApi + .deleteRelationTuples(Namespace.Video, videoId, "viewers", "*") + .then((res) => { + this.logger.log(`Video (${videoId}) is now private`); + }) + .catch((err) => { + this.logger.error( + `Could not set video (${videoId}) to public, error: ${err}` + ); + throw new AuthorizationError( + "Failed to define video as public" + ); + }); + } - return new AuthorizationService({ - read_address: getOrThrow("authorization.read_address"), - write_address: cfgSvc.get("authorization.write_address"), - api_key: cfgSvc.get("authorization.api_key"), - }); - } + private async isHealthy(): Promise { + return this.metadataApi + .isReady() + .then(() => true) + .catch((err) => { + this.logger.error( + `Authorization server is not healthy: ${err}` + ); + return false; + }); + } } diff --git a/npm-packages/x-utils/src/authz/authz.types.ts b/npm-packages/x-utils/src/authz/authz.types.ts new file mode 100644 index 0000000..2c87306 --- /dev/null +++ b/npm-packages/x-utils/src/authz/authz.types.ts @@ -0,0 +1,39 @@ +import { Namespace } from "./keto/namespaces"; + +export interface SubjectSet { + /** + * Namespace of the Subject Set + * @type {string} + * @memberof SubjectSet + */ + namespace: Namespace; + /** + * Object of the Subject Set + * @type {string} + * @memberof SubjectSet + */ + object: string; + /** + * Relation of the Subject Set + * @type {string} + * @memberof SubjectSet + */ + relation: string; +} + +export interface SubjectId { + /** + * ID of the Subject + * @type {string} + * @memberOf SubjectId + */ + id: string; +} + +/** + * A Subject is a user, a service, or an application that can be authenticated. + * @export + * @interface Subject + * @extends {SubjectId, SubjectSet} + */ +export type Subject = SubjectSet | SubjectId; diff --git a/npm-packages/x-utils/src/authz/index.ts b/npm-packages/x-utils/src/authz/index.ts index 7078d0a..639e696 100644 --- a/npm-packages/x-utils/src/authz/index.ts +++ b/npm-packages/x-utils/src/authz/index.ts @@ -1,2 +1,25 @@ +import { ConfigService } from "@nestjs/config"; +import { AuthorizationServiceConfig } from "./authz.service"; + export * from "./authz.service"; export * from "./authz.module"; +export * from "./authz.error"; +export * from "./authz.types"; + +export const configureAuthZService = ( + cfgSvc: ConfigService +): AuthorizationServiceConfig => { + const getOrThrow = (key: string): T => { + const value = cfgSvc.get(key); + if (value === undefined) { + throw new Error(`Missing configuration key: ${key}`); + } + return value; + }; + + return { + read_address: getOrThrow("authorization.read"), + write_address: cfgSvc.get("authorization.write"), + api_key: cfgSvc.get("authorization.apikey") + }; +}; diff --git a/npm-packages/x-utils/src/authz/keto/namespaces.ts b/npm-packages/x-utils/src/authz/keto/namespaces.ts new file mode 100644 index 0000000..47b1f29 --- /dev/null +++ b/npm-packages/x-utils/src/authz/keto/namespaces.ts @@ -0,0 +1,5 @@ +export enum Namespace { + Video = "Video", + User = "User", + Role = "Role", +} From 09a8d9843da9ef9a49d41a75f8a15589d30c02c1 Mon Sep 17 00:00:00 2001 From: AlexandreBrg Date: Fri, 20 Jan 2023 15:29:09 +0100 Subject: [PATCH 5/5] fix: package upgrades migrations --- .../src/decorators/me-admin.decorator.ts | 14 +- .../src/decorators/me-roles.decorator.ts | 16 +- .../x-utils/src/decorators/me.decorator.ts | 16 +- npm-packages/x-utils/src/guards/role.guard.ts | 142 ++++++++++-------- 4 files changed, 106 insertions(+), 82 deletions(-) diff --git a/npm-packages/x-utils/src/decorators/me-admin.decorator.ts b/npm-packages/x-utils/src/decorators/me-admin.decorator.ts index 5c4eb50..eadb6d8 100644 --- a/npm-packages/x-utils/src/decorators/me-admin.decorator.ts +++ b/npm-packages/x-utils/src/decorators/me-admin.decorator.ts @@ -9,10 +9,12 @@ const USER_ROLE_HEADER = "x-user-roles"; * */ export const IsAdmin = createParamDecorator( - (data: string, ctx: ExecutionContext): boolean => { - const request: Request = ctx.switchToHttp().getRequest(); - const x_user_roles: string = request.headers[USER_ROLE_HEADER]; - const roles = x_user_roles ? x_user_roles.split(",") : []; - return roles.findIndex((role) => role === "ADMINISTRATOR") !== -1; - } + (data: string, ctx: ExecutionContext): boolean => { + const request: Request = ctx.switchToHttp().getRequest(); + const x_user_roles: string = request.headers[ + USER_ROLE_HEADER + ] as string; + const roles = x_user_roles ? x_user_roles.split(",") : []; + return roles.findIndex((role) => role === "ADMINISTRATOR") !== -1; + } ); diff --git a/npm-packages/x-utils/src/decorators/me-roles.decorator.ts b/npm-packages/x-utils/src/decorators/me-roles.decorator.ts index ab6fe22..425c5d0 100644 --- a/npm-packages/x-utils/src/decorators/me-roles.decorator.ts +++ b/npm-packages/x-utils/src/decorators/me-roles.decorator.ts @@ -12,12 +12,14 @@ const USER_ROLE_HEADER = "x-user-roles"; * If the user if not authenticated, then @returns an empty array */ export const MeRoles = createParamDecorator( - (data: string, ctx: ExecutionContext): Role[] | [] => { - const request: Request = ctx.switchToHttp().getRequest(); - let x_user_roles: string = request.headers[USER_ROLE_HEADER]; - if (data) { - x_user_roles = x_user_roles[data]; + (data: string, ctx: ExecutionContext): Role[] | [] => { + const request: Request = ctx.switchToHttp().getRequest(); + let x_user_roles: string = request.headers[USER_ROLE_HEADER] as string; + if (data) { + x_user_roles = x_user_roles[data]; + } + return (x_user_roles ? x_user_roles.split(",") : []).map( + (r) => r as Role + ); } - return (x_user_roles ? x_user_roles.split(",") : []).map((r) => r as Role); - } ); diff --git a/npm-packages/x-utils/src/decorators/me.decorator.ts b/npm-packages/x-utils/src/decorators/me.decorator.ts index 1b00f2c..55c5595 100644 --- a/npm-packages/x-utils/src/decorators/me.decorator.ts +++ b/npm-packages/x-utils/src/decorators/me.decorator.ts @@ -1,7 +1,7 @@ import { - createParamDecorator, - ExecutionContext, - ParseUUIDPipe, + createParamDecorator, + ExecutionContext, + ParseUUIDPipe } from "@nestjs/common"; import { Request } from "express"; @@ -14,9 +14,9 @@ const USER_ID_HEADER = "x-user-id"; * If the user if not authenticated, then the is is undefined */ export const MeId = createParamDecorator( - (data: string, ctx: ExecutionContext): string | undefined => { - const request: Request = ctx.switchToHttp().getRequest(); - const x_user_id: string = request.headers[USER_ID_HEADER]; - return x_user_id; - } + (data: string, ctx: ExecutionContext): string | undefined => { + const request: Request = ctx.switchToHttp().getRequest(); + const x_user_id: string = request.headers[USER_ID_HEADER] as string; + return x_user_id; + } ); diff --git a/npm-packages/x-utils/src/guards/role.guard.ts b/npm-packages/x-utils/src/guards/role.guard.ts index 5e7afb1..41a2761 100644 --- a/npm-packages/x-utils/src/guards/role.guard.ts +++ b/npm-packages/x-utils/src/guards/role.guard.ts @@ -1,82 +1,102 @@ -import {CanActivate, ExecutionContext, Injectable, Logger, SetMetadata} from "@nestjs/common"; +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + Logger, + SetMetadata, +} from "@nestjs/common"; import { Reflector } from "@nestjs/core"; -import {Role} from "../types/roles.enum"; +import { Role } from "../types/roles.enum"; -export const ROLES_REFLECT_KEY = "roles" -export const Roles = (...roles: Role[]) => SetMetadata(ROLES_REFLECT_KEY, roles); +export const ROLES_REFLECT_KEY = "roles"; +export const Roles = (...roles: Role[]) => + SetMetadata(ROLES_REFLECT_KEY, roles); @Injectable() export class RolesGuard implements CanActivate { - private static USER_ROLES_HEADER = "x-user-roles"; - private readonly logger = new Logger(this.constructor.name) + private static USER_ROLES_HEADER = "x-user-roles"; + private readonly logger = new Logger(this.constructor.name); - constructor(private reflector: Reflector) {} + constructor(@Inject(Reflector.name) private reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_REFLECT_KEY, [ - context.getHandler(), - context.getClass() - ]) + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_REFLECT_KEY, + [context.getHandler(), context.getClass()] + ); - if (!requiredRoles || requiredRoles.length === 0) { - return true; - } - - const path = this.getRoutePath(context) - const roles = this.getRolesFromHeaders(context); - const hasRoles = this.hasRoles(roles, requiredRoles); - this.logger.debug("[" + path + "]" +"Expected roles [" + requiredRoles + "], given roles [" + roles + "], authorized: " + hasRoles) - - if (!hasRoles) { - this.logger.warn("[" + path + "] Route not authorized to user") - } - return hasRoles; + if (!requiredRoles || requiredRoles.length === 0) { + return true; } - getRoutePath(context: ExecutionContext): string { - return context.switchToHttp().getRequest().route.path + const path = this.getRoutePath(context); + const roles = this.getRolesFromHeaders(context); + const hasRoles = this.hasRoles(roles, requiredRoles); + this.logger.debug( + "[" + + path + + "]" + + "Expected roles [" + + requiredRoles + + "], given roles [" + + roles + + "], authorized: " + + hasRoles + ); + + if (!hasRoles) { + this.logger.warn("[" + path + "] Route not authorized to user"); } + return hasRoles; + } - getRolesFromHeaders(context: ExecutionContext): Role[] { - const header_content = this.fetchRoleHeader(context); + getRoutePath(context: ExecutionContext): string { + return context.switchToHttp().getRequest().route.path; + } - if (!header_content) { - return []; - } + getRolesFromHeaders(context: ExecutionContext): Role[] { + const header_content = this.fetchRoleHeader(context); - return this.parseRoles(header_content); + if (!header_content) { + return []; } - fetchRoleHeader(context: ExecutionContext): string { - try { - return context.switchToHttp().getRequest().headers[RolesGuard.USER_ROLES_HEADER]; - } catch(e) { - return "" - } - } + return this.parseRoles(header_content); + } - parseRoles(roles_str: string): Role[] { - const roles_str_list = roles_str.split(","); - const roles = [] - for (let i = 0; i < roles_str_list.length; i++) { - const e = roles_str_list[i] - switch (e) { - case 'MEMBER': - roles.push(Role.Member) - break - case 'CONTRIBUTOR': - roles.push(Role.Contributor) - break - case 'ADMINISTRATOR': - roles.push(Role.Admin) - break - } - } - - return roles; + fetchRoleHeader(context: ExecutionContext): string { + try { + return context.switchToHttp().getRequest().headers[ + RolesGuard.USER_ROLES_HEADER + ]; + } catch (e) { + return ""; } + } - hasRoles(roles: Role[], expected_roles: Role[]): boolean { - return Boolean(roles.some((role) => expected_roles.includes(role))); + parseRoles(roles_str: string): Role[] { + const roles_str_list = roles_str.split(","); + const roles = []; + for (let i = 0; i < roles_str_list.length; i++) { + const e = roles_str_list[i]; + switch (e) { + case "MEMBER": + roles.push(Role.Member); + break; + case "CONTRIBUTOR": + roles.push(Role.Contributor); + break; + case "ADMINISTRATOR": + roles.push(Role.Admin); + break; + } } + + return roles; + } + + hasRoles(roles: Role[], expected_roles: Role[]): boolean { + return Boolean(roles.some((role) => expected_roles.includes(role))); + } }