diff --git a/.env.dist b/.env.dist index eea390e..e55ee74 100644 --- a/.env.dist +++ b/.env.dist @@ -13,3 +13,4 @@ POSTGRES_IMAGE=postgres:15.0 # OPA OPA_IMAGE=openpolicyagent/opa:0.48.0-rootless +OPA_URL=http://v3-opa:8181/v1/data/ diff --git a/.env.test b/.env.test index a23fa4c..00231b0 100644 --- a/.env.test +++ b/.env.test @@ -13,3 +13,4 @@ POSTGRES_IMAGE=postgres:15.0 # OPA OPA_IMAGE=openpolicyagent/opa:0.48.0-rootless +OPA_URL=http://v3-opa:8181/v1/data/ diff --git a/docker-compose.yml b/docker-compose.yml index ac43020..eafe3e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,7 +61,6 @@ services: - "--server" - "--log-format=json-pretty" - "--set=decision_logs.console=true" - - "--set=default_decision=example/allow" - "./policies/" volumes: - ./opa:/policies diff --git a/opa/user/user1.rego b/opa/user/user1.rego index d2a0b36..15fd6ea 100644 --- a/opa/user/user1.rego +++ b/opa/user/user1.rego @@ -1,7 +1,7 @@ -package user.me +package user.read default allow := false allow := true { - input.user == "jean" + input.uuid == "96d99d44-e0a6-458e-a656-de2a400d60a8" } diff --git a/opa/user/user2.rego b/opa/user/user2.rego index 980065b..f643c93 100644 --- a/opa/user/user2.rego +++ b/opa/user/user2.rego @@ -3,5 +3,5 @@ package user.list default allow := false allow := true { - input.user == "pierre" + input.uuid == "96d99d44-e0a6-458e-a656-de2a400d60a9" } diff --git a/package-lock.json b/package-lock.json index 0943a31..9130bf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@golevelup/nestjs-rabbitmq": "^3.4.0", "@grpc/grpc-js": "^1.8.0", "@grpc/proto-loader": "^0.7.4", + "@nestjs/axios": "^1.0.1", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", @@ -22,6 +23,7 @@ "@nestjs/microservices": "^9.2.1", "@nestjs/platform-express": "^9.0.0", "@prisma/client": "^4.7.1", + "axios": "^1.2.2", "bcrypt": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -1642,6 +1644,29 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@nestjs/axios": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-1.0.1.tgz", + "integrity": "sha512-TpoZM/0ZJ9xiC04qkRDFod93LCZ12TQARRU3ejDvBK2E8emvzM4HThOs5ePklVxce4Q1ZsnrIWqnImvoDmJYnQ==", + "dependencies": { + "axios": "1.2.1" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@nestjs/axios/node_modules/axios": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", + "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/@nestjs/cli": { "version": "9.1.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.1.5.tgz", @@ -3173,8 +3198,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz", + "integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/babel-jest": { "version": "28.1.3", @@ -3813,7 +3847,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -4015,7 +4048,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -4854,6 +4886,25 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "7.2.13", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz", @@ -4908,7 +4959,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -7548,6 +7598,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -10487,6 +10542,26 @@ "tar": "^6.1.11" } }, + "@nestjs/axios": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-1.0.1.tgz", + "integrity": "sha512-TpoZM/0ZJ9xiC04qkRDFod93LCZ12TQARRU3ejDvBK2E8emvzM4HThOs5ePklVxce4Q1ZsnrIWqnImvoDmJYnQ==", + "requires": { + "axios": "1.2.1" + }, + "dependencies": { + "axios": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", + "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + } + } + }, "@nestjs/cli": { "version": "9.1.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.1.5.tgz", @@ -11648,8 +11723,17 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.2.tgz", + "integrity": "sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "babel-jest": { "version": "28.1.3", @@ -12118,7 +12202,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -12278,8 +12361,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "delegates": { "version": "1.0.0", @@ -12926,6 +13008,11 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, "fork-ts-checker-webpack-plugin": { "version": "7.2.13", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-7.2.13.tgz", @@ -12962,7 +13049,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -14917,6 +15003,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index bdcf790..5c02f7d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@golevelup/nestjs-rabbitmq": "^3.4.0", "@grpc/grpc-js": "^1.8.0", "@grpc/proto-loader": "^0.7.4", + "@nestjs/axios": "^1.0.1", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", @@ -39,6 +40,7 @@ "@nestjs/microservices": "^9.2.1", "@nestjs/platform-express": "^9.0.0", "@prisma/client": "^4.7.1", + "axios": "^1.2.2", "bcrypt": "^5.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -80,7 +82,11 @@ "json", "ts" ], - "modulePathIgnorePatterns": [".controller.ts",".module.ts","main.ts"], + "modulePathIgnorePatterns": [ + ".controller.ts", + ".module.ts", + "main.ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { diff --git a/src/main.ts b/src/main.ts index 711a7a3..88dd40c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,11 +9,17 @@ async function bootstrap() { { transport: Transport.GRPC, options: { - package: 'authentication', - protoPath: join( - __dirname, - 'modules/authentication/adapters/primaries/authentication.proto', - ), + package: ['authentication', 'authorization'], + protoPath: [ + join( + __dirname, + 'modules/authentication/adapters/primaries/authentication.proto', + ), + join( + __dirname, + 'modules/authorization/adapters/primaries/authorization.proto', + ), + ], url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, loader: { keepCase: true, enums: String }, }, diff --git a/src/modules/authorization/adapters/primaries/authorization.controller.ts b/src/modules/authorization/adapters/primaries/authorization.controller.ts new file mode 100644 index 0000000..bee6de5 --- /dev/null +++ b/src/modules/authorization/adapters/primaries/authorization.controller.ts @@ -0,0 +1,28 @@ +import { Controller } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { DecisionRequest } from '../../domain/dtos/decision.request'; +import { DecisionQuery } from '../../queries/decision.query'; +import { DecisionResult } from '../secondaries/decision-result'; + +@Controller() +export class AuthorizationController { + constructor(private readonly _queryBus: QueryBus) {} + + @GrpcMethod('AuthorizationService', 'Decide') + async decide(data: DecisionRequest): Promise { + try { + const decision: boolean = await this._queryBus.execute( + new DecisionQuery(data.uuid, data.domain, data.action, data.context), + ); + return { + allow: decision, + }; + } catch (e) { + throw new RpcException({ + code: 7, + message: 'Permission denied', + }); + } + } +} diff --git a/src/modules/authorization/adapters/primaries/authorization.proto b/src/modules/authorization/adapters/primaries/authorization.proto new file mode 100644 index 0000000..6e9fc61 --- /dev/null +++ b/src/modules/authorization/adapters/primaries/authorization.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package authorization; + +service AuthorizationService { + rpc Decide(AuthorizationRequest) returns (Decision); +} + +message AuthorizationRequest { + string uuid = 1; + Domain domain = 2; + Action action = 3; + repeated Item context = 4; +} + +enum Domain { + user = 0; +} + +enum Action { + create = 0; + read = 1; + update = 2; + delete = 3; + list = 4; +} + +message Item { + string name = 1; + string value = 2; +} + +message Decision { + bool allow = 1; +} diff --git a/src/modules/authorization/adapters/secondaries/decision-result.ts b/src/modules/authorization/adapters/secondaries/decision-result.ts new file mode 100644 index 0000000..547205b --- /dev/null +++ b/src/modules/authorization/adapters/secondaries/decision-result.ts @@ -0,0 +1,3 @@ +export class DecisionResult { + allow: boolean; +} diff --git a/src/modules/authorization/adapters/secondaries/decision.ts b/src/modules/authorization/adapters/secondaries/decision.ts new file mode 100644 index 0000000..ccc5e92 --- /dev/null +++ b/src/modules/authorization/adapters/secondaries/decision.ts @@ -0,0 +1,6 @@ +import { DecisionResult } from './decision-result'; + +export class Decision { + decision_id: string; + result: DecisionResult; +} diff --git a/src/modules/authorization/adapters/secondaries/opa.decision-maker.ts b/src/modules/authorization/adapters/secondaries/opa.decision-maker.ts new file mode 100644 index 0000000..73d93c0 --- /dev/null +++ b/src/modules/authorization/adapters/secondaries/opa.decision-maker.ts @@ -0,0 +1,38 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { lastValueFrom } from 'rxjs'; +import { Action } from '../../domain/dtos/action.enum'; +import { Domain } from '../../domain/dtos/domain.enum'; +import { IMakeDecision } from '../../domain/interfaces/decision-maker'; +import { Decision } from './decision'; + +@Injectable() +export class OpaDecisionMaker extends IMakeDecision { + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + ) { + super(); + } + + async decide( + uuid: string, + domain: Domain, + action: Action, + context: Array<{ name: string; value: string }>, + ): Promise { + const { data } = await lastValueFrom( + this.httpService.post( + this.configService.get('OPA_URL') + domain + '/' + action, + { + input: { + uuid, + ...context, + }, + }, + ), + ); + return data.result.allow; + } +} diff --git a/src/modules/authorization/authorization.module.ts b/src/modules/authorization/authorization.module.ts index 13dcf88..a324556 100644 --- a/src/modules/authorization/authorization.module.ts +++ b/src/modules/authorization/authorization.module.ts @@ -1,11 +1,15 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; -import { ValidateAuthenticationUseCase } from '../authentication/domain/usecases/validate-authentication.usecase'; import { DatabaseModule } from '../database/database.module'; +import { AuthorizationController } from './adapters/primaries/authorization.controller'; +import { OpaDecisionMaker } from './adapters/secondaries/opa.decision-maker'; +import { DecisionUseCase } from './domain/usecases/decision.usecase'; @Module({ - imports: [DatabaseModule, CqrsModule], + imports: [DatabaseModule, CqrsModule, HttpModule], exports: [], - providers: [ValidateAuthenticationUseCase], + controllers: [AuthorizationController], + providers: [OpaDecisionMaker, DecisionUseCase], }) export class AuthorizationModule {} diff --git a/src/modules/authorization/domain/dtos/action.enum.ts b/src/modules/authorization/domain/dtos/action.enum.ts new file mode 100644 index 0000000..4a3955b --- /dev/null +++ b/src/modules/authorization/domain/dtos/action.enum.ts @@ -0,0 +1,7 @@ +export enum Action { + create = 'create', + read = 'read', + update = 'update', + delete = 'delete', + list = 'list', +} diff --git a/src/modules/authorization/domain/dtos/decision.request.ts b/src/modules/authorization/domain/dtos/decision.request.ts new file mode 100644 index 0000000..16633a6 --- /dev/null +++ b/src/modules/authorization/domain/dtos/decision.request.ts @@ -0,0 +1,20 @@ +import { IsArray, IsNotEmpty, IsString } from 'class-validator'; +import { Action } from './action.enum'; +import { Domain } from './domain.enum'; + +export class DecisionRequest { + @IsString() + @IsNotEmpty() + uuid: string; + + @IsString() + @IsNotEmpty() + domain: Domain; + + @IsString() + @IsNotEmpty() + action: Action; + + @IsArray() + context?: Array<{ name: string; value: string }>; +} diff --git a/src/modules/authorization/domain/dtos/domain.enum.ts b/src/modules/authorization/domain/dtos/domain.enum.ts new file mode 100644 index 0000000..6cb9b92 --- /dev/null +++ b/src/modules/authorization/domain/dtos/domain.enum.ts @@ -0,0 +1,3 @@ +export enum Domain { + user = 'user', +} diff --git a/src/modules/authorization/domain/dtos/validate-authorization.request.ts b/src/modules/authorization/domain/dtos/validate-authorization.request.ts deleted file mode 100644 index 8bfbf72..0000000 --- a/src/modules/authorization/domain/dtos/validate-authorization.request.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsNotEmpty, IsString } from 'class-validator'; - -export class ValidateAuthorizationRequest { - @IsString() - @IsNotEmpty() - uuid: string; - - @IsString() - @IsNotEmpty() - action: string; -} diff --git a/src/modules/authorization/domain/entities/authorization.ts b/src/modules/authorization/domain/entities/authorization.ts deleted file mode 100644 index bcf96ea..0000000 --- a/src/modules/authorization/domain/entities/authorization.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class Authorization { - uuid: string; - action: string; -} diff --git a/src/modules/authorization/domain/interfaces/decision-maker.ts b/src/modules/authorization/domain/interfaces/decision-maker.ts new file mode 100644 index 0000000..9f15363 --- /dev/null +++ b/src/modules/authorization/domain/interfaces/decision-maker.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { Action } from '../dtos/action.enum'; +import { Domain } from '../dtos/domain.enum'; + +@Injectable() +export abstract class IMakeDecision { + abstract decide( + uuid: string, + domain: Domain, + action: Action, + context: Array<{ name: string; value: string }>, + ): Promise; +} diff --git a/src/modules/authorization/domain/usecases/decision.usecase.ts b/src/modules/authorization/domain/usecases/decision.usecase.ts new file mode 100644 index 0000000..8107a1d --- /dev/null +++ b/src/modules/authorization/domain/usecases/decision.usecase.ts @@ -0,0 +1,17 @@ +import { QueryHandler } from '@nestjs/cqrs'; +import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker'; +import { DecisionQuery } from '../../queries/decision.query'; + +@QueryHandler(DecisionQuery) +export class DecisionUseCase { + constructor(private readonly _decisionMaker: OpaDecisionMaker) {} + + async execute(decisionQuery: DecisionQuery): Promise { + return this._decisionMaker.decide( + decisionQuery.uuid, + decisionQuery.domain, + decisionQuery.action, + decisionQuery.context, + ); + } +} diff --git a/src/modules/authorization/domain/usecases/validate-authorization.usecase.ts b/src/modules/authorization/domain/usecases/validate-authorization.usecase.ts deleted file mode 100644 index 6551c40..0000000 --- a/src/modules/authorization/domain/usecases/validate-authorization.usecase.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { QueryHandler } from '@nestjs/cqrs'; -import { ValidateAuthorizationQuery } from '../../queries/validate-authorization.query'; - -@QueryHandler(ValidateAuthorizationQuery) -export class ValidateAuthorizationUseCase { - async execute(validate: ValidateAuthorizationQuery): Promise { - return Promise.resolve(validate.action == 'authorized'); - } -} diff --git a/src/modules/authorization/queries/decision.query.ts b/src/modules/authorization/queries/decision.query.ts new file mode 100644 index 0000000..58cc1f3 --- /dev/null +++ b/src/modules/authorization/queries/decision.query.ts @@ -0,0 +1,21 @@ +import { Action } from '../domain/dtos/action.enum'; +import { Domain } from '../domain/dtos/domain.enum'; + +export class DecisionQuery { + readonly uuid: string; + readonly domain: Domain; + readonly action: Action; + readonly context: Array<{ name: string; value: string }>; + + constructor( + uuid: string, + domain: Domain, + action: Action, + context?: Array<{ name: string; value: string }>, + ) { + this.uuid = uuid; + this.domain = domain; + this.action = action; + this.context = context; + } +} diff --git a/src/modules/authorization/queries/validate-authorization.query.ts b/src/modules/authorization/queries/validate-authorization.query.ts deleted file mode 100644 index 15c4e6d..0000000 --- a/src/modules/authorization/queries/validate-authorization.query.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class ValidateAuthorizationQuery { - readonly uuid: string; - readonly action: string; - - constructor(uuid: string, action: string) { - this.uuid = uuid; - this.action = action; - } -} diff --git a/src/modules/authorization/tests/unit/decision.usecase.spec.ts b/src/modules/authorization/tests/unit/decision.usecase.spec.ts new file mode 100644 index 0000000..3d3a022 --- /dev/null +++ b/src/modules/authorization/tests/unit/decision.usecase.spec.ts @@ -0,0 +1,62 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker'; +import { Action } from '../../domain/dtos/action.enum'; +import { DecisionRequest } from '../../domain/dtos/decision.request'; +import { Domain } from '../../domain/dtos/domain.enum'; +import { DecisionUseCase } from '../../domain/usecases/decision.usecase'; +import { DecisionQuery } from '../../queries/decision.query'; + +const mockOpaDecisionMaker = { + decide: jest.fn().mockResolvedValue(Promise.resolve(true)), +}; + +describe('DecisionUseCase', () => { + let decisionUseCase: DecisionUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], + providers: [ + { + provide: OpaDecisionMaker, + useValue: mockOpaDecisionMaker, + }, + DecisionUseCase, + ], + }).compile(); + + decisionUseCase = module.get(DecisionUseCase); + }); + + it('should be defined', () => { + expect(decisionUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should validate an authorization', async () => { + const decisionRequest: DecisionRequest = new DecisionRequest(); + decisionRequest.uuid = 'bb281075-1b98-4456-89d6-c643d3044a91'; + decisionRequest.domain = Domain.user; + decisionRequest.action = Action.create; + decisionRequest.context = [ + { + name: 'context1', + value: 'value1', + }, + ]; + + expect( + decisionUseCase.execute( + new DecisionQuery( + decisionRequest.uuid, + decisionRequest.domain, + decisionRequest.action, + decisionRequest.context, + ), + ), + ).toBeTruthy(); + }); + }); +}); diff --git a/src/modules/authorization/tests/unit/opa.decision-maker.spec.ts b/src/modules/authorization/tests/unit/opa.decision-maker.spec.ts new file mode 100644 index 0000000..97ac740 --- /dev/null +++ b/src/modules/authorization/tests/unit/opa.decision-maker.spec.ts @@ -0,0 +1,88 @@ +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { of } from 'rxjs'; +import { OpaDecisionMaker } from '../../adapters/secondaries/opa.decision-maker'; +import { Action } from '../../domain/dtos/action.enum'; +import { Domain } from '../../domain/dtos/domain.enum'; + +const mockHttpService = { + post: jest + .fn() + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + decision_id: '96d99d44-e0a6-458e-a656-de2a400d60a8', + result: { + allow: true, + }, + }, + }); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + decision_id: '96d99d44-e0a6-458e-a656-de2a400d60a9', + result: { + allow: false, + }, + }, + }); + }), +}; + +const mockConfigService = { + get: jest.fn().mockResolvedValue({ + OPA_URL: 'http://url/', + }), +}; + +describe('OpaDecisionMaker', () => { + let opaDecisionMaker: OpaDecisionMaker; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + { + provide: HttpService, + useValue: mockHttpService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + OpaDecisionMaker, + ], + }).compile(); + + opaDecisionMaker = module.get(OpaDecisionMaker); + }); + + it('should be defined', () => { + expect(opaDecisionMaker).toBeDefined(); + }); + + describe('execute', () => { + it('should return a truthy decision', async () => { + const decision = await opaDecisionMaker.decide( + 'bb281075-1b98-4456-89d6-c643d3044a91', + Domain.user, + Action.read, + [], + ); + expect(decision).toBeTruthy(); + }); + it('should return a falsy decision', async () => { + const decision = await opaDecisionMaker.decide( + 'bb281075-1b98-4456-89d6-c643d3044a91', + Domain.user, + Action.read, + [], + ); + expect(decision).toBeFalsy(); + }); + }); +}); diff --git a/src/modules/authorization/tests/unit/validate-authorization.usecase.spec.ts b/src/modules/authorization/tests/unit/validate-authorization.usecase.spec.ts deleted file mode 100644 index 31b6173..0000000 --- a/src/modules/authorization/tests/unit/validate-authorization.usecase.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { classes } from '@automapper/classes'; -import { AutomapperModule } from '@automapper/nestjs'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ValidateAuthorizationRequest } from '../../domain/dtos/validate-authorization.request'; -import { ValidateAuthorizationUseCase } from '../../domain/usecases/validate-authorization.usecase'; -import { ValidateAuthorizationQuery } from '../../queries/validate-authorization.query'; - -describe('ValidateAuthorizationUseCase', () => { - let validateAuthorizationUseCase: ValidateAuthorizationUseCase; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], - providers: [ValidateAuthorizationUseCase], - }).compile(); - - validateAuthorizationUseCase = module.get( - ValidateAuthorizationUseCase, - ); - }); - - it('should be defined', () => { - expect(validateAuthorizationUseCase).toBeDefined(); - }); - - describe('execute', () => { - it('should validate an authorization', async () => { - const validateAuthorizationRequest: ValidateAuthorizationRequest = - new ValidateAuthorizationRequest(); - validateAuthorizationRequest.uuid = - 'bb281075-1b98-4456-89d6-c643d3044a91'; - validateAuthorizationRequest.action = 'authorized'; - - expect( - validateAuthorizationUseCase.execute( - new ValidateAuthorizationQuery( - validateAuthorizationRequest.uuid, - validateAuthorizationRequest.action, - ), - ), - ).toBeTruthy(); - }); - }); -});