From 6e851b2fc9366ca261ccee09823dc3b057846181 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Wed, 20 Mar 2024 17:56:52 -0400 Subject: [PATCH 1/5] adding development debug sourcemap config --- nx.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nx.json b/nx.json index f90c0b2bc..40cf3f756 100644 --- a/nx.json +++ b/nx.json @@ -4,7 +4,17 @@ "build": { "cache": true, "dependsOn": ["^build"], - "inputs": ["production", "^production"] + "inputs": ["production", "^production"], + "configurations": { + "development": { + "optimization": false, + "sourceMap": true, + "extractLicenses": false, + "vendorChunk": false, + "commonChunk": false, + "buildOptimizer": false + } + } }, "lint": { "cache": true, From fb7318007aff80899fb8b234a38a2ed34b7e7267 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:47:10 +0100 Subject: [PATCH 2/5] Bump date-fns from 3.3.1 to 3.6.0 (#172) Bumps [date-fns](https://github.com/date-fns/date-fns) from 3.3.1 to 3.6.0. - [Release notes](https://github.com/date-fns/date-fns/releases) - [Changelog](https://github.com/date-fns/date-fns/blob/main/CHANGELOG.md) - [Commits](https://github.com/date-fns/date-fns/compare/v3.3.1...v3.6.0) --- updated-dependencies: - dependency-name: date-fns dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2416a7db2..9fc399290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "clsx": "^1.2.1", - "date-fns": "^3.3.1", + "date-fns": "^3.6.0", "ethers": "^5.7.2", "handlebars": "^4.7.8", "jose": "^5.2.2", @@ -21670,9 +21670,9 @@ } }, "node_modules/date-fns": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", - "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" diff --git a/package.json b/package.json index 73cafb5f8..923e5e47a 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "clsx": "^1.2.1", - "date-fns": "^3.3.1", + "date-fns": "^3.6.0", "ethers": "^5.7.2", "handlebars": "^4.7.8", "jose": "^5.2.2", From 4703b62b19c349a9af027d67d3ae579b26aa2c9b Mon Sep 17 00:00:00 2001 From: Ptroger <44851272+Ptroger@users.noreply.github.com> Date: Thu, 21 Mar 2024 07:53:32 -0400 Subject: [PATCH 3/5] add error management for failed Validation Pipes (#169) * add error management for failed Validation Pipes * format * test exception on ping controller * registered exception filters to policy-engine * format * Update apps/armory/src/shared/filter/application-exception.filter.ts Co-authored-by: William Calderipe * ported filter test file --------- Co-authored-by: William Calderipe --- apps/armory/src/main.ts | 7 +- .../shared/filter/http-exception.filter.ts | 36 ++++++++ .../src/engine/app.controller.ts | 1 - apps/policy-engine/src/main.ts | 19 +++++ .../unit/application-exception.filter.spec.ts | 82 +++++++++++++++++++ .../filter/application-exception.filter.ts | 52 ++++++++++++ .../shared/filter/http-exception.filter.ts | 41 ++++++++++ 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 apps/armory/src/shared/filter/http-exception.filter.ts create mode 100644 apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts create mode 100644 apps/policy-engine/src/shared/filter/application-exception.filter.ts create mode 100644 apps/policy-engine/src/shared/filter/http-exception.filter.ts diff --git a/apps/armory/src/main.ts b/apps/armory/src/main.ts index 1fa31cfa1..f8bf31edb 100644 --- a/apps/armory/src/main.ts +++ b/apps/armory/src/main.ts @@ -6,6 +6,7 @@ import { lastValueFrom, map, of, switchMap } from 'rxjs' import { Config } from './armory.config' import { ArmoryModule } from './armory.module' import { ApplicationExceptionFilter } from './shared/filter/application-exception.filter' +import { HttpExceptionFilter } from './shared/filter/http-exception.filter' import { ZodExceptionFilter } from './shared/filter/zod-exception.filter' /** @@ -64,7 +65,11 @@ const withGlobalInterceptors = (app: INestApplication): INestApplication => { const withGlobalFilters = (configService: ConfigService) => (app: INestApplication): INestApplication => { - app.useGlobalFilters(new ApplicationExceptionFilter(configService), new ZodExceptionFilter(configService)) + app.useGlobalFilters( + new ApplicationExceptionFilter(configService), + new ZodExceptionFilter(configService), + new HttpExceptionFilter(configService) + ) return app } diff --git a/apps/armory/src/shared/filter/http-exception.filter.ts b/apps/armory/src/shared/filter/http-exception.filter.ts new file mode 100644 index 000000000..ef32f857d --- /dev/null +++ b/apps/armory/src/shared/filter/http-exception.filter.ts @@ -0,0 +1,36 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { Config, Env } from '../../armory.config' + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private logger = new Logger(HttpExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = exception.getStatus() + + const isProduction = this.configService.get('env') === Env.PRODUCTION + + this.logger.error(exception) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: exception.message, + response: exception.getResponse() + } + : { + statusCode: status, + message: exception.message, + response: exception.getResponse(), + stack: exception.stack + } + ) + } +} diff --git a/apps/policy-engine/src/engine/app.controller.ts b/apps/policy-engine/src/engine/app.controller.ts index b663b0adc..562973e4a 100644 --- a/apps/policy-engine/src/engine/app.controller.ts +++ b/apps/policy-engine/src/engine/app.controller.ts @@ -20,7 +20,6 @@ export class AppController { this.logger.log({ message: 'Received ping' }) - return 'pong' } diff --git a/apps/policy-engine/src/main.ts b/apps/policy-engine/src/main.ts index 724cf85c6..c368c635b 100644 --- a/apps/policy-engine/src/main.ts +++ b/apps/policy-engine/src/main.ts @@ -3,7 +3,10 @@ import { ConfigService } from '@nestjs/config' import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import { lastValueFrom, map, of, switchMap } from 'rxjs' +import { Config } from './policy-engine.config' import { PolicyEngineModule } from './policy-engine.module' +import { ApplicationExceptionFilter } from './shared/filter/application-exception.filter' +import { HttpExceptionFilter } from './shared/filter/http-exception.filter' /** * Adds Swagger documentation to the application. @@ -37,6 +40,21 @@ const withGlobalPipes = (app: INestApplication): INestApplication => { return app } +/** + * Adds a global exception filter to the application. + * + * @param app - The Nest application instance. + * @param configService - The configuration service instance. + * @returns The modified Nest application instance. + */ +const withGlobalFilters = + (configService: ConfigService) => + (app: INestApplication): INestApplication => { + app.useGlobalFilters(new HttpExceptionFilter(configService), new ApplicationExceptionFilter(configService)) + + return app + } + async function bootstrap() { const logger = new Logger('PolicyEngineBootstrap') const application = await NestFactory.create(PolicyEngineModule, { bodyParser: true }) @@ -51,6 +69,7 @@ async function bootstrap() { of(application).pipe( map(withSwagger), map(withGlobalPipes), + map(withGlobalFilters(configService)), switchMap((app) => app.listen(port)) ) ) diff --git a/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts b/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts new file mode 100644 index 000000000..f440e4e79 --- /dev/null +++ b/apps/policy-engine/src/shared/filter/__test__/unit/application-exception.filter.spec.ts @@ -0,0 +1,82 @@ +import { ArgumentsHost, HttpStatus } from '@nestjs/common' +import { HttpArgumentsHost } from '@nestjs/common/interfaces' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { mock } from 'jest-mock-extended' +import { Config, Env } from '../../../../policy-engine.config' +import { ApplicationException } from '../../../exception/application.exception' +import { ApplicationExceptionFilter } from '../../application-exception.filter' + +describe(ApplicationExceptionFilter.name, () => { + const exception = new ApplicationException({ + message: 'Test application exception filter', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR, + context: { + additional: 'information', + to: 'debug' + } + }) + + const buildArgumentsHostMock = (): [ArgumentsHost, jest.Mock, jest.Mock] => { + const jsonMock = jest.fn() + const statusMock = jest.fn().mockReturnValue( + mock({ + json: jsonMock + }) + ) + + const host = mock({ + switchToHttp: jest.fn().mockReturnValue( + mock({ + getResponse: jest.fn().mockReturnValue( + mock({ + status: statusMock + }) + ) + }) + ) + }) + + return [host, statusMock, jsonMock] + } + + const buildConfigServiceMock = (env: Env) => + mock>({ + get: jest.fn().mockReturnValue(env) + }) + + describe('catch', () => { + describe('when environment is production', () => { + it('responds with exception status and short message', () => { + const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.PRODUCTION)) + const [host, statusMock, jsonMock] = buildArgumentsHostMock() + + filter.catch(exception, host) + + expect(statusMock).toHaveBeenCalledWith(exception.getStatus()) + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: exception.getStatus(), + message: exception.message, + context: exception.context + }) + }) + }) + + describe('when environment is not production', () => { + it('responds with exception status and complete message', () => { + const filter = new ApplicationExceptionFilter(buildConfigServiceMock(Env.DEVELOPMENT)) + const [host, statusMock, jsonMock] = buildArgumentsHostMock() + + filter.catch(exception, host) + + expect(statusMock).toHaveBeenCalledWith(exception.getStatus()) + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: exception.getStatus(), + message: exception.message, + context: exception.context, + stack: exception.stack + }) + }) + }) + }) +}) diff --git a/apps/policy-engine/src/shared/filter/application-exception.filter.ts b/apps/policy-engine/src/shared/filter/application-exception.filter.ts new file mode 100644 index 000000000..f94f56b2d --- /dev/null +++ b/apps/policy-engine/src/shared/filter/application-exception.filter.ts @@ -0,0 +1,52 @@ +import { ArgumentsHost, Catch, ExceptionFilter, LogLevel, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { Config, Env } from '../../policy-engine.config' +import { ApplicationException } from '../../shared/exception/application.exception' + +@Catch(ApplicationException) +export class ApplicationExceptionFilter implements ExceptionFilter { + private logger = new Logger(ApplicationExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: ApplicationException, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = exception.getStatus() + const isProduction = this.configService.get('env') === Env.PRODUCTION + + this.log(exception) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: exception.message, + context: exception.context + } + : { + statusCode: status, + message: exception.message, + context: exception.context, + stack: exception.stack, + ...(exception.origin && { origin: exception.origin }) + } + ) + } + + // TODO (@wcalderipe, 16/01/24): Unit test the logging logic. For that, we + // must inject the logger in the constructor via dependency injection. + private log(exception: ApplicationException) { + const level: LogLevel = exception.getStatus() >= 500 ? 'error' : 'warn' + + if (this.logger[level]) { + this.logger[level](exception.message, { + status: exception.getStatus(), + context: exception.context, + stacktrace: exception.stack, + origin: exception.origin + }) + } + } +} diff --git a/apps/policy-engine/src/shared/filter/http-exception.filter.ts b/apps/policy-engine/src/shared/filter/http-exception.filter.ts new file mode 100644 index 000000000..6fe3ee39a --- /dev/null +++ b/apps/policy-engine/src/shared/filter/http-exception.filter.ts @@ -0,0 +1,41 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { Config, Env } from '../../policy-engine.config' + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private logger = new Logger(HttpExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = exception.getStatus() + + const isProduction = this.configService.get('env') === Env.PRODUCTION + + this.logger.error({ + message: exception.message, + stack: exception.stack, + response: exception.getResponse(), + statusCode: status + }) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: exception.message, + response: exception.getResponse() + } + : { + statusCode: status, + message: exception.message, + response: exception.getResponse(), + stack: exception.stack + } + ) + } +} From 286ce66d6e754094ac31dddb31ffaa043e5e5579 Mon Sep 17 00:00:00 2001 From: William Calderipe Date: Thu, 21 Mar 2024 16:00:14 +0100 Subject: [PATCH 4/5] Open Policy Agent engine evaluation per tenant (#170) Note: - Engine signing is currently a proof of concept. It's unclear how it will evolve with remote signing, so I'd rather not modify it at this point. - Building OPA targeting WASM involves a process with many steps that interact with the OS and its file system. The current implementation addresses the happy path and a few obvious errors. Therefore, error handling for edge cases is not yet implemented, and I expect these to be addressed as we encounter issues in the building process. - The whole implementation is in a single place (see `wasm-build.util.ts`). Changelog: - Added a core Engine interface along with an Open Policy Agent implementation of it, effectively hiding OPA as an implementation detail. Everything outside the `apps/policy-engine/src/open-policy-agent` directory SHOULD NOT have knowledge of OPA and Rego. - Added temporary code in the BootstrapService to add a development tenant pointing to the devtool stores. - Added a `resource` directory for the server to access files from the disk. - Added a `RESOURCE_PATH` environment variable because we can't depend on `__dirname` to resolve the path. Its value changes based on how the application is built. With webpack, it minimizes, and `__dirname` always points to the lowest level of the directory tree. By contrast, in tests, the directory tree is preserved because files are transformed on the fly. - Added the resource directory as a NestJS assets directory in `project.json`. - Changed the Rego core directory to `apps/policy-engine/src/resource`. - Removed most of the legacy OPA code. - Changed the tenant module location to the engine module due to numerous circular dependency issues in the DI container. - Note: it felt like a good example of unclear module boundaries, and I'd rather roll it back and see how it evolves than trying to address problems we SHOULD NOT have. Co-authored-by: samuel --- .github/workflows/policy-engine.yml | 7 +- apps/policy-engine/.env.default | 2 + apps/policy-engine/.env.test.default | 2 + apps/policy-engine/Makefile | 31 +- apps/policy-engine/project.json | 1 + .../__test__/e2e/tenant.spec.ts | 88 +++- .../engine/__test__/unit/app.service.spec.ts | 148 ------ .../src/engine/app.controller.ts | 41 +- apps/policy-engine/src/engine/app.service.ts | 236 --------- .../core/exception/data-store.exception.ts | 0 .../factory/data-store-repository.factory.ts | 0 .../core/repository/data-store.repository.ts | 0 .../integration/data-store.service.spec.ts | 0 .../__test__/unit/bootstrap.service.spec.ts | 24 + .../__test__/unit/tenant.service.spec.ts | 0 .../engine/core/service/bootstrap.service.ts | 72 +++ .../core/service/data-store.service.ts | 0 .../engine/core/service/evaluation.service.ts | 52 ++ .../core/service/tenant.service.ts | 0 .../policy-engine/src/engine/engine.module.ts | 29 +- .../http/rest/controller/tenant.controller.ts | 19 +- .../http/rest/dto/create-tenant.dto.ts | 0 .../opa/__test__/unit/opa.service.spec.ts | 104 ---- .../src/engine/opa/opa.service.ts | 137 ------ .../file-system-data-store.repository.spec.ts | 0 .../http-data-store.repository.spec.ts | 0 .../__test__/unit/tenant.repository.spec.ts | 0 .../repository/entity.repository.ts | 23 - .../file-system-data-store.repository.ts | 0 .../repository/http-data-store.repository.ts | 0 .../repository/tenant.repository.ts | 11 + apps/policy-engine/src/opa/rego/data.json | 82 ---- apps/policy-engine/src/opa/rego/input.json | 119 ----- .../script/evaluate-legacy-policy.script.ts | 150 ------ .../src/opa/script/evaluation.script.ts | 26 - .../script/translate-legacy-policy.script.ts | 462 ------------------ .../src/opa/template/meta-permissions.data.ts | 62 --- .../unit/open-policy-agent.engine.spec.ts | 375 ++++++++++++++ .../exception/open-policy-agent.exception.ts | 3 + .../core/open-policy-agent.engine.ts | 333 +++++++++++++ .../core/schema/open-policy-agent.schema.ts | 18 + .../core/type/open-policy-agent.type.ts | 82 ++++ .../__test__/unit/evaluation.util.spec.ts | 113 +++++ .../unit/rego-transpiler.util.spec.ts} | 54 +- .../__test__/unit/wasm-build.util.spec.ts | 152 ++++++ .../core/util/evaluation.util.ts | 79 +++ .../core/util/rego-transpiler.util.ts} | 26 +- .../core/util/wasm-build.util.ts | 111 +++++ .../open-policy-agent.constant.ts | 1 + .../open-policy-agent.module.ts | 0 .../policy-engine/src/policy-engine.config.ts | 2 + .../src/policy-engine.constant.ts | 2 + .../policy-engine/src/policy-engine.module.ts | 15 +- .../rego/__test__/criteria/approval_test.rego | 0 .../__test__/criteria/intent/amount_test.rego | 0 .../criteria/intent/contractCall_test.rego | 0 .../criteria/intent/contractDeploy_test.rego | 0 .../criteria/intent/destination_test.rego | 0 .../__test__/criteria/intent/permit_test.rego | 0 .../criteria/intent/signMessage_test.rego | 0 .../criteria/intent/tokenAllowance_test.rego | 0 .../criteria/intent/transferNft_test.rego | 0 .../criteria/intent/transferToken_test.rego | 0 .../__test__/criteria/principal_test.rego | 0 .../rego/__test__/criteria/resource_test.rego | 0 .../__test__/criteria/spendingLimit_test.rego | 0 .../criteria/transactionRequest/gas_test.rego | 0 .../transactionRequest/nonce_test.rego | 0 .../rego/__test__/main_test.rego | 0 .../rego/__test__}/policies/approvals.rego | 23 + .../__test__/policies/approvals_test.rego | 19 + .../rego/__test__}/policies/e2e.rego | 0 .../rego/__test__/policies/e2e_test.rego | 0 .../__test__}/policies/missing-rules.rego | 0 .../rego/__test__}/policies/spendings.rego | 0 .../__test__/policies/spendings_test.rego | 0 .../rego}/criteria/action.rego | 0 .../rego}/criteria/approval.rego | 0 .../rego}/criteria/intent/amount.rego | 0 .../rego}/criteria/intent/destination.rego | 0 .../rego}/criteria/intent/intent.rego | 0 .../rego}/criteria/intent/permit.rego | 0 .../rego}/criteria/intent/signMessage.rego | 0 .../rego}/criteria/intent/transferNft.rego | 0 .../rego}/criteria/principal.rego | 0 .../rego}/criteria/resource.rego | 0 .../rego}/criteria/spendingLimit.rego | 0 .../criteria/transactionRequest/chainId.rego | 0 .../criteria/transactionRequest/gas.rego | 0 .../criteria/transactionRequest/nonce.rego | 0 .../open-policy-agent/rego}/main.rego | 0 .../rego}/policies/meta-permission.rego | 0 .../rego/rules.template.hbs} | 0 .../__test__/unit/client-id.decorator.spec.ts | 34 ++ .../shared/decorator/client-id.decorator.ts | 15 + .../__test__/unit/client-secret.guard.spec.ts | 82 ++++ .../src/shared/guard/client-secret.guard.ts | 31 ++ .../testing/evaluation.testing.ts} | 15 +- .../testing/with-temp-directory.testing.ts | 19 + .../src/shared/type/domain.type.ts | 39 -- .../src/shared/type/entities.types.ts | 54 -- apps/policy-engine/src/shared/type/rego.ts | 32 -- .../tenant/core/service/bootstrap.service.ts | 28 -- .../policy-engine/src/tenant/tenant.module.ts | 41 -- .../src/tenant/core/service/tenant.service.ts | 4 +- packages/policy-engine-shared/src/index.ts | 14 +- .../src/lib/schema/domain.schema.ts | 15 + .../src/lib/schema/entity.schema.ts | 2 +- .../src/lib/type/domain.type.ts | 28 +- .../src/lib/type/engine.type.ts | 12 + .../src/lib/__test__/unit/mocks.ts | 12 - .../src/lib/validators.ts | 6 +- 112 files changed, 1925 insertions(+), 1894 deletions(-) rename apps/policy-engine/src/{tenant => engine}/__test__/e2e/tenant.spec.ts (67%) delete mode 100644 apps/policy-engine/src/engine/__test__/unit/app.service.spec.ts delete mode 100644 apps/policy-engine/src/engine/app.service.ts rename apps/policy-engine/src/{tenant => engine}/core/exception/data-store.exception.ts (100%) rename apps/policy-engine/src/{tenant => engine}/core/factory/data-store-repository.factory.ts (100%) rename apps/policy-engine/src/{tenant => engine}/core/repository/data-store.repository.ts (100%) rename apps/policy-engine/src/{tenant => engine}/core/service/__test__/integration/data-store.service.spec.ts (100%) rename apps/policy-engine/src/{tenant => engine}/core/service/__test__/unit/bootstrap.service.spec.ts (73%) rename apps/policy-engine/src/{tenant => engine}/core/service/__test__/unit/tenant.service.spec.ts (100%) create mode 100644 apps/policy-engine/src/engine/core/service/bootstrap.service.ts rename apps/policy-engine/src/{tenant => engine}/core/service/data-store.service.ts (100%) create mode 100644 apps/policy-engine/src/engine/core/service/evaluation.service.ts rename apps/policy-engine/src/{tenant => engine}/core/service/tenant.service.ts (100%) rename apps/policy-engine/src/{tenant => engine}/http/rest/controller/tenant.controller.ts (61%) rename apps/policy-engine/src/{tenant => engine}/http/rest/dto/create-tenant.dto.ts (100%) delete mode 100644 apps/policy-engine/src/engine/opa/__test__/unit/opa.service.spec.ts delete mode 100644 apps/policy-engine/src/engine/opa/opa.service.ts rename apps/policy-engine/src/{tenant => engine}/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts (100%) rename apps/policy-engine/src/{tenant => engine}/persistence/repository/__test__/integration/http-data-store.repository.spec.ts (100%) rename apps/policy-engine/src/{tenant => engine}/persistence/repository/__test__/unit/tenant.repository.spec.ts (100%) delete mode 100644 apps/policy-engine/src/engine/persistence/repository/entity.repository.ts rename apps/policy-engine/src/{tenant => engine}/persistence/repository/file-system-data-store.repository.ts (100%) rename apps/policy-engine/src/{tenant => engine}/persistence/repository/http-data-store.repository.ts (100%) rename apps/policy-engine/src/{tenant => engine}/persistence/repository/tenant.repository.ts (94%) delete mode 100644 apps/policy-engine/src/opa/rego/data.json delete mode 100644 apps/policy-engine/src/opa/rego/input.json delete mode 100644 apps/policy-engine/src/opa/script/evaluate-legacy-policy.script.ts delete mode 100644 apps/policy-engine/src/opa/script/evaluation.script.ts delete mode 100644 apps/policy-engine/src/opa/script/translate-legacy-policy.script.ts delete mode 100644 apps/policy-engine/src/opa/template/meta-permissions.data.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/exception/open-policy-agent.exception.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/schema/open-policy-agent.schema.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts rename apps/policy-engine/src/{shared/__test__/unit/opa.utils.spec.ts => open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts} (63%) create mode 100644 apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/wasm-build.util.spec.ts create mode 100644 apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts rename apps/policy-engine/src/{shared/utils/opa.utils.ts => open-policy-agent/core/util/rego-transpiler.util.ts} (61%) create mode 100644 apps/policy-engine/src/open-policy-agent/core/util/wasm-build.util.ts create mode 100644 apps/policy-engine/src/open-policy-agent/open-policy-agent.constant.ts create mode 100644 apps/policy-engine/src/open-policy-agent/open-policy-agent.module.ts rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/approval_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/amount_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/contractCall_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/contractDeploy_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/destination_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/permit_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/signMessage_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/tokenAllowance_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/transferNft_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/intent/transferToken_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/principal_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/resource_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/spendingLimit_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/transactionRequest/gas_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/criteria/transactionRequest/nonce_test.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/main_test.rego (100%) rename apps/policy-engine/src/{opa/rego => resource/open-policy-agent/rego/__test__}/policies/approvals.rego (80%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/policies/approvals_test.rego (78%) rename apps/policy-engine/src/{opa/rego => resource/open-policy-agent/rego/__test__}/policies/e2e.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/policies/e2e_test.rego (100%) rename apps/policy-engine/src/{opa/rego => resource/open-policy-agent/rego/__test__}/policies/missing-rules.rego (100%) rename apps/policy-engine/src/{opa/rego => resource/open-policy-agent/rego/__test__}/policies/spendings.rego (100%) rename apps/policy-engine/src/{opa => resource/open-policy-agent}/rego/__test__/policies/spendings_test.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/action.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/approval.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/amount.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/destination.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/intent.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/permit.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/signMessage.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/intent/transferNft.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/principal.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/resource.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/spendingLimit.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/transactionRequest/chainId.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/transactionRequest/gas.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/criteria/transactionRequest/nonce.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/main.rego (100%) rename apps/policy-engine/src/{opa/rego/lib => resource/open-policy-agent/rego}/policies/meta-permission.rego (100%) rename apps/policy-engine/src/{opa/template/template.hbs => resource/open-policy-agent/rego/rules.template.hbs} (100%) create mode 100644 apps/policy-engine/src/shared/decorator/__test__/unit/client-id.decorator.spec.ts create mode 100644 apps/policy-engine/src/shared/decorator/client-id.decorator.ts create mode 100644 apps/policy-engine/src/shared/guard/__test__/unit/client-secret.guard.spec.ts create mode 100644 apps/policy-engine/src/shared/guard/client-secret.guard.ts rename apps/policy-engine/src/{engine/persistence/repository/mock_data.ts => shared/testing/evaluation.testing.ts} (73%) create mode 100644 apps/policy-engine/src/shared/testing/with-temp-directory.testing.ts delete mode 100644 apps/policy-engine/src/shared/type/entities.types.ts delete mode 100644 apps/policy-engine/src/shared/type/rego.ts delete mode 100644 apps/policy-engine/src/tenant/core/service/bootstrap.service.ts delete mode 100644 apps/policy-engine/src/tenant/tenant.module.ts create mode 100644 packages/policy-engine-shared/src/lib/schema/domain.schema.ts create mode 100644 packages/policy-engine-shared/src/lib/type/engine.type.ts diff --git a/.github/workflows/policy-engine.yml b/.github/workflows/policy-engine.yml index 9b65386f5..40865ca36 100644 --- a/.github/workflows/policy-engine.yml +++ b/.github/workflows/policy-engine.yml @@ -41,6 +41,11 @@ jobs: with: node-version: '20.4.0' + - name: Install Open Policy Agent CLI + uses: open-policy-agent/setup-opa@v2 + with: + version: latest + - name: Install dependencies run: | make install/ci @@ -103,5 +108,5 @@ jobs: with: version: latest - - name: Run OPA Tests + - name: Test rego run: make policy-engine/rego/test diff --git a/apps/policy-engine/.env.default b/apps/policy-engine/.env.default index 802cc00ae..ff1bdf5e1 100644 --- a/apps/policy-engine/.env.default +++ b/apps/policy-engine/.env.default @@ -8,6 +8,8 @@ ENGINE_UID="local-dev-engine-instance-1" MASTER_PASSWORD="unsafe-local-dev-master-password" +RESOURCE_PATH=./apps/policy-engine/src/resource + KEYRING_TYPE="raw" # MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1" diff --git a/apps/policy-engine/.env.test.default b/apps/policy-engine/.env.test.default index fde033c19..159af7875 100644 --- a/apps/policy-engine/.env.test.default +++ b/apps/policy-engine/.env.test.default @@ -10,4 +10,6 @@ MASTER_PASSWORD="unsafe-local-test-master-password" KEYRING_TYPE="raw" +RESOURCE_PATH=./apps/policy-engine/src/resource + # MASTER_AWS_KMS_ARN="arn:aws:kms:us-east-2:728783560968:key/f6aa3ddb-47c3-4f31-977d-b93205bb23d1" diff --git a/apps/policy-engine/Makefile b/apps/policy-engine/Makefile index d242aff36..d2138982c 100644 --- a/apps/policy-engine/Makefile +++ b/apps/policy-engine/Makefile @@ -1,6 +1,7 @@ POLICY_ENGINE_PROJECT_NAME := policy-engine POLICY_ENGINE_PROJECT_DIR := ./apps/policy-engine POLICY_ENGINE_DATABASE_SCHEMA := ${POLICY_ENGINE_PROJECT_DIR}/src/shared/module/persistence/schema/schema.prisma +POLICY_ENGINE_REGO_DIST = ./dist/rego # === Start === @@ -80,7 +81,6 @@ policy-engine/db/seed: npx dotenv -e ${POLICY_ENGINE_PROJECT_DIR}/.env -- \ ts-node -r tsconfig-paths/register --project ${POLICY_ENGINE_PROJECT_DIR}/tsconfig.app.json ${POLICY_ENGINE_PROJECT_DIR}/src/shared/module/persistence/seed.ts - # === Testing === policy-engine/test/db/setup: @@ -90,7 +90,6 @@ policy-engine/test/db/setup: --skip-seed \ --force - policy-engine/test/type: make policy-engine/db/generate-types npx tsc \ @@ -131,37 +130,21 @@ policy-engine/cli: # === Open Policy Agent & Rego === policy-engine/rego/build: - rm -rf ./rego-build - mkdir -p ./rego-build + rm -rf ${POLICY_ENGINE_REGO_DIST} + mkdir -p ${POLICY_ENGINE_REGO_DIST} opa build \ --target wasm \ --entrypoint main/evaluate \ - --bundle ${POLICY_ENGINE_PROJECT_DIR}/src/opa/rego \ + --bundle ${POLICY_ENGINE_PROJECT_DIR}/src/resource/open-policy-agent/rego \ --ignore "__test__" \ --ignore "policies" \ - --output ./rego-build/policies.gz - tar -xzf ./rego-build/policies.gz -C ./rego-build/ - -policy-engine/rego/eval: - npx ts-node \ - --compiler-options "{\"module\":\"CommonJS\"}" \ - ${POLICY_ENGINE_PROJECT_DIR}/src/opa/script/evaluation.script.ts - -policy-engine/rego/translate: - npx dotenv -e ${POLICY_ENGINE_PROJECT_DIR}/.env -- \ - ts-node -r tsconfig-paths/register \ - --project ${POLICY_ENGINE_PROJECT_DIR}/tsconfig.app.json ${POLICY_ENGINE_PROJECT_DIR}/src/opa/script/translate-legacy-policy.script.ts - -policy-engine/rego/evaluation: - npx dotenv -e ${POLICY_ENGINE_PROJECT_DIR}/.env -- \ - ts-node -r tsconfig-paths/register \ - --project ${POLICY_ENGINE_PROJECT_DIR}/tsconfig.app.json ${POLICY_ENGINE_PROJECT_DIR}/src/opa/script/evaluate-legacy-policy.script.ts + --output ${POLICY_ENGINE_REGO_DIST}/bundle.tar.gz + tar -xzf ${POLICY_ENGINE_REGO_DIST}/bundle.tar.gz -C ${POLICY_ENGINE_REGO_DIST} policy-engine/rego/test: opa test \ --format="pretty" \ - ${POLICY_ENGINE_PROJECT_DIR}/src/opa/rego \ - --ignore "generated" \ + ${POLICY_ENGINE_PROJECT_DIR}/src/resource/open-policy-agent/rego \ --verbose \ ${ARGS} diff --git a/apps/policy-engine/project.json b/apps/policy-engine/project.json index 70f6ce085..1321676f7 100644 --- a/apps/policy-engine/project.json +++ b/apps/policy-engine/project.json @@ -13,6 +13,7 @@ "compiler": "tsc", "outputPath": "dist/apps/policy-engine", "main": "apps/policy-engine/src/main.ts", + "assets": ["apps/policy-engine/src/resource"], "tsConfig": "apps/policy-engine/tsconfig.app.json", "isolatedConfig": true, "webpackConfig": "apps/policy-engine/webpack.config.js" diff --git a/apps/policy-engine/src/tenant/__test__/e2e/tenant.spec.ts b/apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts similarity index 67% rename from apps/policy-engine/src/tenant/__test__/e2e/tenant.spec.ts rename to apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts index 68be5f219..780656915 100644 --- a/apps/policy-engine/src/tenant/__test__/e2e/tenant.spec.ts +++ b/apps/policy-engine/src/engine/__test__/e2e/tenant.spec.ts @@ -6,25 +6,47 @@ import request from 'supertest' import { v4 as uuid } from 'uuid' import { EngineService } from '../../../engine/core/service/engine.service' import { Config, load } from '../../../policy-engine.config' -import { REQUEST_HEADER_API_KEY } from '../../../policy-engine.constant' +import { + REQUEST_HEADER_API_KEY, + REQUEST_HEADER_CLIENT_ID, + REQUEST_HEADER_CLIENT_SECRET +} from '../../../policy-engine.constant' import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository' import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' -import { CreateTenantDto } from '../../../tenant/http/rest/dto/create-tenant.dto' +import { Tenant } from '../../../shared/type/domain.type' +import { TenantService } from '../../core/service/tenant.service' +import { EngineModule } from '../../engine.module' +import { CreateTenantDto } from '../../http/rest/dto/create-tenant.dto' import { TenantRepository } from '../../persistence/repository/tenant.repository' -import { TenantModule } from '../../tenant.module' describe('Tenant', () => { let app: INestApplication let module: TestingModule let testPrismaService: TestPrismaService let tenantRepository: TenantRepository + let tenantService: TenantService let engineService: EngineService let configService: ConfigService const adminApiKey = 'test-admin-api-key' + const clientId = uuid() + + const dataStoreUrl = 'http://127.0.0.1:9999/test-data-store' + + const dataStoreConfiguration = { + dataUrl: dataStoreUrl, + signatureUrl: dataStoreUrl + } + + const createTenantPayload: CreateTenantDto = { + clientId, + entityDataStore: dataStoreConfiguration, + policyDataStore: dataStoreConfiguration + } + beforeAll(async () => { module = await Test.createTestingModule({ imports: [ @@ -32,7 +54,7 @@ describe('Tenant', () => { load: [load], isGlobal: true }), - TenantModule + EngineModule ] }) .overrideProvider(KeyValueRepository) @@ -46,6 +68,7 @@ describe('Tenant', () => { app = module.createNestApplication() engineService = module.get(EngineService) + tenantService = module.get(TenantService) tenantRepository = module.get(TenantRepository) testPrismaService = module.get(TestPrismaService) configService = module.get>(ConfigService) @@ -67,25 +90,16 @@ describe('Tenant', () => { await app.close() }) - describe('POST /tenants', () => { - const clientId = uuid() - - const dataStoreConfiguration = { - dataUrl: 'http://some.host', - signatureUrl: 'http://some.host' - } - - const payload: CreateTenantDto = { - clientId, - entityDataStore: dataStoreConfiguration, - policyDataStore: dataStoreConfiguration - } + beforeEach(() => { + jest.spyOn(tenantService, 'syncDataStore').mockResolvedValue(true) + }) + describe('POST /tenants', () => { it('creates a new tenant', async () => { const { status, body } = await request(app.getHttpServer()) .post('/tenants') .set(REQUEST_HEADER_API_KEY, adminApiKey) - .send(payload) + .send(createTenantPayload) const actualTenant = await tenantRepository.findByClientId(clientId) expect(body).toMatchObject({ @@ -113,12 +127,15 @@ describe('Tenant', () => { }) it('responds with an error when clientId already exist', async () => { - await request(app.getHttpServer()).post('/tenants').set(REQUEST_HEADER_API_KEY, adminApiKey).send(payload) + await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send(createTenantPayload) const { status, body } = await request(app.getHttpServer()) .post('/tenants') .set(REQUEST_HEADER_API_KEY, adminApiKey) - .send(payload) + .send(createTenantPayload) expect(body).toEqual({ message: 'Tenant already exist', @@ -131,7 +148,7 @@ describe('Tenant', () => { const { status, body } = await request(app.getHttpServer()) .post('/tenants') .set(REQUEST_HEADER_API_KEY, 'invalid-api-key') - .send(payload) + .send(createTenantPayload) expect(body).toMatchObject({ message: 'Forbidden resource', @@ -140,4 +157,33 @@ describe('Tenant', () => { expect(status).toEqual(HttpStatus.FORBIDDEN) }) }) + + describe('POST /tenants/sync', () => { + let tenant: Tenant + + beforeEach(async () => { + jest.spyOn(tenantService, 'syncDataStore').mockResolvedValue(true) + + const { body } = await request(app.getHttpServer()) + .post('/tenants') + .set(REQUEST_HEADER_API_KEY, adminApiKey) + .send({ + ...createTenantPayload, + clientId: uuid() + }) + + tenant = body + }) + + it('calls the tenant data store sync', async () => { + const { status, body } = await request(app.getHttpServer()) + .post('/tenants/sync') + .set(REQUEST_HEADER_CLIENT_ID, tenant.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, tenant.clientSecret) + .send(createTenantPayload) + + expect(body).toEqual({ ok: true }) + expect(status).toEqual(HttpStatus.OK) + }) + }) }) diff --git a/apps/policy-engine/src/engine/__test__/unit/app.service.spec.ts b/apps/policy-engine/src/engine/__test__/unit/app.service.spec.ts deleted file mode 100644 index 32eea709a..000000000 --- a/apps/policy-engine/src/engine/__test__/unit/app.service.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Decision, EntityType } from '@narval/policy-engine-shared' -import { OpaResult } from '../../../shared/type/rego' -import { finalizeDecision } from '../../app.service' - -describe('finalizeDecision', () => { - it('returns Forbid if any of the reasons is Forbid', () => { - const response: OpaResult[] = [ - { - permit: false, - reasons: [ - { - policyId: 'forbid-rule-id', - policyName: 'Forbid Rule', - type: 'forbid', - approvalsMissing: [], - approvalsSatisfied: [] - }, - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [], - approvalsSatisfied: [] - } - ] - } - ] - const result = finalizeDecision(response) - expect(result.decision).toEqual(Decision.FORBID) - }) - - it('returns Permit if all of the reasons are Permit', () => { - const response: OpaResult[] = [ - { - permit: true, - reasons: [ - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [], - approvalsSatisfied: [] - }, - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [], - approvalsSatisfied: [] - } - ] - } - ] - const result = finalizeDecision(response) - expect(result.decision).toEqual(Decision.PERMIT) - }) - - it('returns Confirm if any of the reasons are Forbid for a Permit type rule where approvals are missing', () => { - const response: OpaResult[] = [ - { - permit: false, - reasons: [ - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [ - { - approvalCount: 1, - approvalEntityType: EntityType.User, - entityIds: ['user-id'], - countPrincipal: true - } - ], - approvalsSatisfied: [] - } - ] - } - ] - const result = finalizeDecision(response) - expect(result.decision).toEqual(Decision.CONFIRM) - }) - - it('returns all missing, satisfied, and total approvals', () => { - const missingApproval = { - policyId: 'permit-rule-id', - approvalCount: 1, - approvalEntityType: EntityType.User, - entityIds: ['user-id'], - countPrincipal: true - } - const missingApproval2 = { - policyId: 'permit-rule-id-4', - approvalCount: 1, - approvalEntityType: EntityType.User, - entityIds: ['user-id'], - countPrincipal: true - } - const satisfiedApproval = { - policyId: 'permit-rule-id-2', - approvalCount: 1, - approvalEntityType: EntityType.User, - entityIds: ['user-id'], - countPrincipal: true - } - const satisfiedApproval2 = { - policyId: 'permit-rule-id-3', - approvalCount: 1, - approvalEntityType: EntityType.User, - entityIds: ['user-id'], - countPrincipal: true - } - const response: OpaResult[] = [ - { - permit: false, - reasons: [ - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [missingApproval], - approvalsSatisfied: [satisfiedApproval] - } - ] - }, - { - permit: false, - reasons: [ - { - policyId: 'permit-rule-id', - policyName: 'Permit Rule', - type: 'permit', - approvalsMissing: [missingApproval2], - approvalsSatisfied: [satisfiedApproval2] - } - ] - } - ] - const result = finalizeDecision(response) - expect(result).toEqual({ - originalResponse: response, - decision: Decision.CONFIRM, - totalApprovalsRequired: [missingApproval, missingApproval2, satisfiedApproval, satisfiedApproval2], - approvalsMissing: [missingApproval, missingApproval2], - approvalsSatisfied: [satisfiedApproval, satisfiedApproval2] - }) - }) -}) diff --git a/apps/policy-engine/src/engine/app.controller.ts b/apps/policy-engine/src/engine/app.controller.ts index 562973e4a..449a55923 100644 --- a/apps/policy-engine/src/engine/app.controller.ts +++ b/apps/policy-engine/src/engine/app.controller.ts @@ -1,14 +1,14 @@ -import { EvaluationRequest } from '@narval/policy-engine-shared' +import { FIXTURE } from '@narval/policy-engine-shared' import { Body, Controller, Get, Logger, Post } from '@nestjs/common' -import { generateInboundRequest } from '../engine/persistence/repository/mock_data' -import { AppService } from './app.service' +import { generateInboundEvaluationRequest } from '../shared/testing/evaluation.testing' +import { EvaluationService } from './core/service/evaluation.service' import { EvaluationRequestDto } from './evaluation-request.dto' @Controller() export class AppController { private logger = new Logger(AppController.name) - constructor(private readonly appService: AppService) {} + constructor(private readonly evaluationService: EvaluationService) {} @Get() healthcheck() { @@ -30,39 +30,30 @@ export class AppController { body }) - // Map the DTO into the TS type because it's nicer to deal with. - const payload: EvaluationRequest = body - - const result = await this.appService.runEvaluation(payload) - this.logger.log({ - message: 'Evaluation Result', - result - }) - - return result + return this.evaluationService.evaluate(FIXTURE.ORGANIZATION.id, body) } @Post('/evaluation-demo') async evaluateDemo() { - const fakeRequest = await generateInboundRequest() - this.logger.log({ - message: 'Received evaluation', - body: fakeRequest + const evaluation = await generateInboundEvaluationRequest() + this.logger.log('Received evaluation', { + evaluation }) - const result = await this.appService.runEvaluation(fakeRequest) - this.logger.log({ - message: 'Evaluation Result', - result + + const response = await this.evaluationService.evaluate(FIXTURE.ORGANIZATION.id, evaluation) + + this.logger.log('Evaluation respone', { + response }) return { - request: fakeRequest, - result + request: evaluation, + response } } @Get('/generate-inbound-request') generateInboundRequest() { - return generateInboundRequest() + return generateInboundEvaluationRequest() } } diff --git a/apps/policy-engine/src/engine/app.service.ts b/apps/policy-engine/src/engine/app.service.ts deleted file mode 100644 index 575dbf98b..000000000 --- a/apps/policy-engine/src/engine/app.service.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { - Action, - CredentialEntity, - Decision, - EvaluationRequest, - EvaluationResponse, - HistoricalTransfer, - JsonWebKey, - JwtString, - Request -} from '@narval/policy-engine-shared' -import { Payload, SigningAlg, decode, hash, privateKeyToJwk, publicKeyToJwk, verifyJwt } from '@narval/signature' -import { safeDecode } from '@narval/transaction-request-intent' -import { - BadRequestException, - Injectable, - InternalServerErrorException, - NotFoundException, - UnprocessableEntityException -} from '@nestjs/common' -import { InputType } from 'packages/transaction-request-intent/src/lib/domain' -import { Intent } from 'packages/transaction-request-intent/src/lib/intent.types' -import { Hex } from 'viem' -import { OpaResult, RegoInput } from '../shared/type/domain.type' -import { SigningService } from './core/service/signing.service' -import { OpaService } from './opa/opa.service' -import { EntityRepository } from './persistence/repository/entity.repository' - -const ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' - -export const finalizeDecision = (response: OpaResult[]) => { - // Implicit Forbid - not root user and no rules matching - const implicitForbid = response.some((r) => r?.default === true && r.permit === false && r.reasons?.length === 0) - - // Explicit Forbid - a Forbid rule type that matches & decides Forbid - const anyExplicitForbid = response.some((r) => r.permit === false && r.reasons?.some((rr) => rr.type === 'forbid')) - - const allPermit = response.every((r) => r.permit === true && r.reasons?.every((rr) => rr.type === 'permit')) - - const anyPermitWithMissingApprovals = response.some((r) => - r.reasons?.some((rr) => rr.type === 'permit' && rr.approvalsMissing.length > 0) - ) - - if (implicitForbid || anyExplicitForbid) { - return { - originalResponse: response, - decision: Decision.FORBID, - approvalsMissing: [], - approvalsSatisfied: [] - } - } - // Collect all the approvalsMissing & approvalsSatisfied using functional map/flat operators - const approvalsSatisfied = response - .flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsSatisfied)) - .filter((v) => !!v) - const approvalsMissing = response.flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsMissing)).filter((v) => !!v) - const totalApprovalsRequired = approvalsMissing.concat(approvalsSatisfied) - - const decision = allPermit && !anyPermitWithMissingApprovals ? Decision.PERMIT : Decision.CONFIRM - return { - originalResponse: response, - decision, - totalApprovalsRequired, - approvalsMissing, - approvalsSatisfied - } -} - -@Injectable() -export class AppService { - constructor( - private opaService: OpaService, - private entityRepository: EntityRepository, - private signingService: SigningService - ) {} - - async #verifySignature(requestSignature: JwtString, verificationMessage: string): Promise { - const { header } = await decode(requestSignature) - - const credential = await this.entityRepository.getCredential(header.kid) - - if (!credential) { - throw new NotFoundException('Credential not found') - } - - const jwk = publicKeyToJwk(credential.pubKey as Hex) - - const validJwt = await verifyJwt(requestSignature, jwk) - // Check the data is the same - if (validJwt.payload.requestHash !== verificationMessage) { - throw new BadRequestException('Invalid signature') - } - - return credential - } - - async #populateApprovals( - approvals: JwtString[] | undefined, - verificationMessage: string - ): Promise { - if (!approvals) return null - const approvalSigs = await Promise.all( - approvals.map(async (jwt) => { - const credential = await this.#verifySignature(jwt, verificationMessage) - return credential - }) - ) - return approvalSigs - } - - #buildRegoInput({ - principal, - request, - approvals, - intent, - transfers - }: { - principal: CredentialEntity - request: Request - approvals: CredentialEntity[] | null - intent?: Intent - transfers?: HistoricalTransfer[] - }): RegoInput { - if (request.action === Action.SIGN_TRANSACTION) { - return { - action: Action.SIGN_TRANSACTION, - intent, - transactionRequest: request.transactionRequest, - principal, - resource: request.resourceId - ? { - uid: request.resourceId - } - : undefined, - approvals: approvals || [], - transfers: transfers || [] - } - } - - throw new InternalServerErrorException(`Unsupported action ${request.action}`) - } - - /** - * Actual Eval Flow - */ - async runEvaluation({ - request, - authentication, - approvals, - transfers - }: EvaluationRequest): Promise { - // Pre-Process - // verify the signatures of the Principal and any Approvals - const verificationMessage = hash(request) - - const principalCredential = await this.#verifySignature(authentication, verificationMessage) - if (!principalCredential) throw new Error(`Could not find principal`) - const populatedApprovals = await this.#populateApprovals(approvals, verificationMessage) - - // Decode the intent - const intentResult = - request.action === Action.SIGN_TRANSACTION - ? safeDecode({ - input: { - type: InputType.TRANSACTION_REQUEST, - txRequest: request.transactionRequest - } - }) - : undefined - - if (intentResult?.success === false) { - throw new UnprocessableEntityException(`Could not decode intent: ${intentResult.error.message}`) - } - - const intent = intentResult?.intent - - const input = this.#buildRegoInput({ - principal: principalCredential, - request, - approvals: populatedApprovals, - intent, - transfers - }) - - // Actual Rego Evaluation - const resultSet: OpaResult[] = await this.opaService.evaluate(input) - - console.log('OPA Result Set', JSON.stringify(resultSet, null, 2)) - - // Post-processing to evaluate multisigs - const finalDecision = finalizeDecision(resultSet) - - const authzResponse: EvaluationResponse = { - decision: finalDecision.decision, - request, - // transactionRequestIntent: intent, - approvals: finalDecision.totalApprovalsRequired?.length - ? { - required: finalDecision.totalApprovalsRequired, - satisfied: finalDecision.approvalsSatisfied, - missing: finalDecision.approvalsMissing - } - : undefined - } - - // If we are allowing, then the ENGINE signs the verification too - if (finalDecision.decision === Decision.PERMIT) { - const tenantSigningKey: JsonWebKey = privateKeyToJwk(ENGINE_PRIVATE_KEY) - - const clientJwk = publicKeyToJwk(principalCredential.pubKey as Hex) - - const jwtPayload: Payload = { - requestHash: verificationMessage, - sub: principalCredential.userId, - // TODO: iat & exp values must be arguments, cannot generate timestamps because of cluster mis-match - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 60 * 10, // 10 minutes - iss: 'https://armory.narval.xyz', // TODO: allow client-specific; should come from config - // aud: TODO - // jti: TODO - cnf: clientJwk - } - - // TODO: signing alg should come from the tenant config - const permitJwt = await this.signingService.sign(jwtPayload, tenantSigningKey, { alg: SigningAlg.EIP191 }) - - authzResponse.accessToken = { - value: permitJwt - } - } - - console.log('End') - - return authzResponse - } -} diff --git a/apps/policy-engine/src/tenant/core/exception/data-store.exception.ts b/apps/policy-engine/src/engine/core/exception/data-store.exception.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/exception/data-store.exception.ts rename to apps/policy-engine/src/engine/core/exception/data-store.exception.ts diff --git a/apps/policy-engine/src/tenant/core/factory/data-store-repository.factory.ts b/apps/policy-engine/src/engine/core/factory/data-store-repository.factory.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/factory/data-store-repository.factory.ts rename to apps/policy-engine/src/engine/core/factory/data-store-repository.factory.ts diff --git a/apps/policy-engine/src/tenant/core/repository/data-store.repository.ts b/apps/policy-engine/src/engine/core/repository/data-store.repository.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/repository/data-store.repository.ts rename to apps/policy-engine/src/engine/core/repository/data-store.repository.ts diff --git a/apps/policy-engine/src/tenant/core/service/__test__/integration/data-store.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/integration/data-store.service.spec.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/service/__test__/integration/data-store.service.spec.ts rename to apps/policy-engine/src/engine/core/service/__test__/integration/data-store.service.spec.ts diff --git a/apps/policy-engine/src/tenant/core/service/__test__/unit/bootstrap.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts similarity index 73% rename from apps/policy-engine/src/tenant/core/service/__test__/unit/bootstrap.service.spec.ts rename to apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts index f397e42dc..ccf38f26f 100644 --- a/apps/policy-engine/src/tenant/core/service/__test__/unit/bootstrap.service.spec.ts +++ b/apps/policy-engine/src/engine/core/service/__test__/unit/bootstrap.service.spec.ts @@ -1,3 +1,4 @@ +import { EncryptionException, EncryptionService } from '@narval/encryption-module' import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { MockProxy, mock } from 'jest-mock-extended' @@ -7,12 +8,14 @@ import { load } from '../../../../../policy-engine.config' import { KeyValueRepository } from '../../../../../shared/module/key-value/core/repository/key-value.repository' import { KeyValueService } from '../../../../../shared/module/key-value/core/service/key-value.service' import { InMemoryKeyValueRepository } from '../../../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository' +import { getTestRawAesKeyring } from '../../../../../shared/testing/encryption.testing' import { BootstrapService } from '../../bootstrap.service' import { TenantService } from '../../tenant.service' describe(BootstrapService.name, () => { let bootstrapService: BootstrapService let tenantServiceMock: MockProxy + let encryptionServiceMock: MockProxy const dataStore = { entity: { @@ -47,6 +50,9 @@ describe(BootstrapService.name, () => { tenantServiceMock = mock() tenantServiceMock.findAll.mockResolvedValue([tenantOne, tenantTwo]) + encryptionServiceMock = mock() + encryptionServiceMock.getKeyring.mockReturnValue(getTestRawAesKeyring()) + const module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ @@ -66,6 +72,10 @@ describe(BootstrapService.name, () => { { provide: TenantService, useValue: tenantServiceMock + }, + { + provide: EncryptionService, + useValue: encryptionServiceMock } ] }).compile() @@ -80,5 +90,19 @@ describe(BootstrapService.name, () => { expect(tenantServiceMock.syncDataStore).toHaveBeenNthCalledWith(1, tenantOne.clientId) expect(tenantServiceMock.syncDataStore).toHaveBeenNthCalledWith(2, tenantTwo.clientId) }) + + it('checks if the encryption keyring is configured', async () => { + await bootstrapService.boot() + + expect(encryptionServiceMock.getKeyring).toHaveBeenCalledTimes(1) + }) + + it('throws when encryption keyring is not configure', async () => { + encryptionServiceMock.getKeyring.mockImplementation(() => { + throw new EncryptionException('Something went wrong') + }) + + await expect(() => bootstrapService.boot()).rejects.toThrow('Something went wrong') + }) }) }) diff --git a/apps/policy-engine/src/tenant/core/service/__test__/unit/tenant.service.spec.ts b/apps/policy-engine/src/engine/core/service/__test__/unit/tenant.service.spec.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/service/__test__/unit/tenant.service.spec.ts rename to apps/policy-engine/src/engine/core/service/__test__/unit/tenant.service.spec.ts diff --git a/apps/policy-engine/src/engine/core/service/bootstrap.service.ts b/apps/policy-engine/src/engine/core/service/bootstrap.service.ts new file mode 100644 index 000000000..d412aeb71 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/bootstrap.service.ts @@ -0,0 +1,72 @@ +import { EncryptionService } from '@narval/encryption-module' +import { FIXTURE } from '@narval/policy-engine-shared' +import { Injectable, Logger } from '@nestjs/common' +import { randomBytes } from 'crypto' +import { TenantService } from './tenant.service' + +@Injectable() +export class BootstrapService { + private logger = new Logger(BootstrapService.name) + + constructor( + private tenantService: TenantService, + private encryptionService: EncryptionService + ) {} + + async boot(): Promise { + this.logger.log('Start engine bootstrap') + + await this.checkEncryptionConfiguration() + + if (!(await this.tenantService.findByClientId(FIXTURE.ORGANIZATION.id))) { + await this.tenantService.onboard({ + clientId: FIXTURE.ORGANIZATION.id, + clientSecret: randomBytes(42).toString('hex'), + dataStore: { + entity: { + dataUrl: 'http://127.0.0.1:3001/storage/2/entity', + signatureUrl: 'http://127.0.0.1:3001/storage/2/entity', + keys: [] + }, + policy: { + dataUrl: 'http://127.0.0.1:3001/storage/2/policy', + signatureUrl: 'http://127.0.0.1:3001/storage/2/policy', + keys: [] + } + }, + createdAt: new Date(), + updatedAt: new Date() + }) + } + + await this.syncTenants() + } + + private async checkEncryptionConfiguration(): Promise { + this.logger.log('Check encryption configuration') + + try { + this.encryptionService.getKeyring() + this.logger.log('Encryption keyring configured') + } catch (error) { + this.logger.error( + 'Missing encryption keyring. Please provision the application with "make policy-engine/cli CMD=provision"' + ) + + throw error + } + } + + private async syncTenants(): Promise { + const tenants = await this.tenantService.findAll() + + this.logger.log('Start syncing tenants data stores', { + tenantsCount: tenants.length + }) + + // TODO: (@wcalderipe, 07/03/24) maybe change the execution to parallel? + for (const tenant of tenants) { + await this.tenantService.syncDataStore(tenant.clientId) + } + } +} diff --git a/apps/policy-engine/src/tenant/core/service/data-store.service.ts b/apps/policy-engine/src/engine/core/service/data-store.service.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/service/data-store.service.ts rename to apps/policy-engine/src/engine/core/service/data-store.service.ts diff --git a/apps/policy-engine/src/engine/core/service/evaluation.service.ts b/apps/policy-engine/src/engine/core/service/evaluation.service.ts new file mode 100644 index 000000000..174e84779 --- /dev/null +++ b/apps/policy-engine/src/engine/core/service/evaluation.service.ts @@ -0,0 +1,52 @@ +import { EvaluationRequest, EvaluationResponse } from '@narval/policy-engine-shared' +import { HttpStatus, Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { resolve } from 'path' +import { OpenPolicyAgentEngine } from '../../../open-policy-agent/core/open-policy-agent.engine' +import { Config } from '../../../policy-engine.config' +import { ApplicationException } from '../../../shared/exception/application.exception' +import { TenantService } from './tenant.service' + +const UNSAFE_ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + +@Injectable() +export class EvaluationService { + constructor( + private configService: ConfigService, + private tenantService: TenantService + ) {} + + async evaluate(clientId: string, evaluation: EvaluationRequest): Promise { + const [entityStore, policyStore] = await Promise.all([ + this.tenantService.findEntityStore(clientId), + this.tenantService.findPolicyStore(clientId) + ]) + + if (!entityStore) { + throw new ApplicationException({ + message: 'Missing client entity store', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { clientId } + }) + } + + if (!policyStore) { + throw new ApplicationException({ + message: 'Missing client entity store', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { clientId } + }) + } + + // WARN: Loading a new engine is an IO bounded process due to the Rego + // transpilation and WASM build. + const engine = await new OpenPolicyAgentEngine({ + entities: entityStore.data, + policies: policyStore.data, + privateKey: UNSAFE_ENGINE_PRIVATE_KEY, + resourcePath: resolve(this.configService.get('resourcePath', { infer: true })) + }).load() + + return engine.evaluate(evaluation) + } +} diff --git a/apps/policy-engine/src/tenant/core/service/tenant.service.ts b/apps/policy-engine/src/engine/core/service/tenant.service.ts similarity index 100% rename from apps/policy-engine/src/tenant/core/service/tenant.service.ts rename to apps/policy-engine/src/engine/core/service/tenant.service.ts diff --git a/apps/policy-engine/src/engine/engine.module.ts b/apps/policy-engine/src/engine/engine.module.ts index b0f77ed30..4553373eb 100644 --- a/apps/policy-engine/src/engine/engine.module.ts +++ b/apps/policy-engine/src/engine/engine.module.ts @@ -5,15 +5,22 @@ import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' import { load } from '../policy-engine.config' import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' +import { AdminApiKeyGuard } from '../shared/guard/admin-api-key.guard' import { KeyValueModule } from '../shared/module/key-value/key-value.module' import { AppController } from './app.controller' -import { AppService } from './app.service' +import { DataStoreRepositoryFactory } from './core/factory/data-store-repository.factory' +import { BootstrapService } from './core/service/bootstrap.service' +import { DataStoreService } from './core/service/data-store.service' import { EngineService } from './core/service/engine.service' +import { EvaluationService } from './core/service/evaluation.service' import { ProvisionService } from './core/service/provision.service' import { SigningService } from './core/service/signing.service' -import { OpaService } from './opa/opa.service' +import { TenantService } from './core/service/tenant.service' +import { TenantController } from './http/rest/controller/tenant.controller' import { EngineRepository } from './persistence/repository/engine.repository' -import { EntityRepository } from './persistence/repository/entity.repository' +import { FileSystemDataStoreRepository } from './persistence/repository/file-system-data-store.repository' +import { HttpDataStoreRepository } from './persistence/repository/http-data-store.repository' +import { TenantRepository } from './persistence/repository/tenant.repository' @Module({ imports: [ @@ -29,20 +36,26 @@ import { EntityRepository } from './persistence/repository/entity.repository' useClass: EncryptionModuleOptionFactory }) ], - controllers: [AppController], + controllers: [AppController, TenantController], providers: [ - AppService, + AdminApiKeyGuard, EngineRepository, EngineService, - EntityRepository, - OpaService, ProvisionService, SigningService, + BootstrapService, + DataStoreRepositoryFactory, + DataStoreService, + FileSystemDataStoreRepository, + HttpDataStoreRepository, + TenantRepository, + TenantService, + EvaluationService, { provide: APP_PIPE, useClass: ValidationPipe } ], - exports: [EngineService, ProvisionService] + exports: [EngineService, ProvisionService, BootstrapService] }) export class EngineModule {} diff --git a/apps/policy-engine/src/tenant/http/rest/controller/tenant.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts similarity index 61% rename from apps/policy-engine/src/tenant/http/rest/controller/tenant.controller.ts rename to apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts index dc96ba555..0d430410f 100644 --- a/apps/policy-engine/src/tenant/http/rest/controller/tenant.controller.ts +++ b/apps/policy-engine/src/engine/http/rest/controller/tenant.controller.ts @@ -1,16 +1,18 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common' import { randomBytes } from 'crypto' import { v4 as uuid } from 'uuid' +import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { AdminApiKeyGuard } from '../../../../shared/guard/admin-api-key.guard' +import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' import { TenantService } from '../../../core/service/tenant.service' import { CreateTenantDto } from '../dto/create-tenant.dto' @Controller('/tenants') -@UseGuards(AdminApiKeyGuard) export class TenantController { constructor(private tenantService: TenantService) {} @Post() + @UseGuards(AdminApiKeyGuard) async create(@Body() body: CreateTenantDto) { const now = new Date() @@ -33,4 +35,17 @@ export class TenantController { return tenant } + + @Post('/sync') + @HttpCode(HttpStatus.OK) + @UseGuards(ClientSecretGuard) + async sync(@ClientId() clientId: string) { + try { + const ok = await this.tenantService.syncDataStore(clientId) + + return { ok } + } catch (error) { + return { ok: false } + } + } } diff --git a/apps/policy-engine/src/tenant/http/rest/dto/create-tenant.dto.ts b/apps/policy-engine/src/engine/http/rest/dto/create-tenant.dto.ts similarity index 100% rename from apps/policy-engine/src/tenant/http/rest/dto/create-tenant.dto.ts rename to apps/policy-engine/src/engine/http/rest/dto/create-tenant.dto.ts diff --git a/apps/policy-engine/src/engine/opa/__test__/unit/opa.service.spec.ts b/apps/policy-engine/src/engine/opa/__test__/unit/opa.service.spec.ts deleted file mode 100644 index ed3976f82..000000000 --- a/apps/policy-engine/src/engine/opa/__test__/unit/opa.service.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { FIXTURE } from '@narval/policy-engine-shared' -import { Test } from '@nestjs/testing' -import { mock } from 'jest-mock-extended' -import { EntityRepository } from '../../../persistence/repository/entity.repository' -import { OpaService } from '../../opa.service' - -describe(OpaService.name, () => { - let service: OpaService - - const addressBookAccountOne = FIXTURE.ADDRESS_BOOK[0] - const addressBookAccountTwo = FIXTURE.ADDRESS_BOOK[1] - - const tokenOne = FIXTURE.TOKEN.usdc1 - const tokenTwo = FIXTURE.TOKEN.usdc137 - - const userGroupOne = FIXTURE.USER_GROUP.Engineering - const userGroupTwo = FIXTURE.USER_GROUP.Treasury - - const userOne = FIXTURE.USER.Alice - const userTwo = FIXTURE.USER.Bob - - const walletOne = FIXTURE.WALLET.Engineering - const walletTwo = FIXTURE.WALLET.Testing - - const walletGroupOne = FIXTURE.WALLET_GROUP.Engineering - const walletGroupTwo = FIXTURE.WALLET_GROUP.Treasury - - beforeEach(async () => { - const entityRepositoryMock = mock() - entityRepositoryMock.fetch.mockResolvedValue({ - addressBook: [addressBookAccountOne, addressBookAccountTwo], - credentials: [], - tokens: [tokenOne, tokenTwo], - userGroupMembers: [ - { - userId: userOne.id, - groupId: userGroupOne.id - } - ], - userGroups: [userGroupOne, userGroupTwo], - userWallets: [], - users: [userOne, userTwo], - walletGroupMembers: [ - { - walletId: walletOne.id, - groupId: walletGroupOne.id - } - ], - walletGroups: [walletGroupOne, walletGroupTwo], - wallets: [walletOne, walletTwo] - }) - - const module = await Test.createTestingModule({ - providers: [ - OpaService, - { - provide: EntityRepository, - useValue: entityRepositoryMock - } - ] - }).compile() - - service = module.get(OpaService) - }) - - describe('fetchEntityData', () => { - it('resolves with data formated for the engine', async () => { - const data = await service.fetchEntityData() - - expect(data).toEqual({ - entities: { - addressBook: { - [addressBookAccountOne.id]: addressBookAccountOne, - [addressBookAccountTwo.id]: addressBookAccountTwo - }, - tokens: { - [tokenOne.id]: tokenOne, - [tokenTwo.id]: tokenTwo - }, - userGroups: { - [userGroupOne.id]: { - id: userGroupOne.id, - users: [userOne.id] - } - }, - users: { - [userOne.id]: userOne, - [userTwo.id]: userTwo - }, - walletGroups: { - [walletGroupOne.id]: { - id: walletGroupOne.id, - wallets: [walletOne.id] - } - }, - wallets: { - [walletOne.id]: walletOne, - [walletTwo.id]: walletTwo - } - } - }) - }) - }) -}) diff --git a/apps/policy-engine/src/engine/opa/opa.service.ts b/apps/policy-engine/src/engine/opa/opa.service.ts deleted file mode 100644 index 338a7ddb1..000000000 --- a/apps/policy-engine/src/engine/opa/opa.service.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { FIXTURE, Policy } from '@narval/policy-engine-shared' -import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' -import { loadPolicy } from '@open-policy-agent/opa-wasm' -import { mkdirSync, readFileSync, writeFileSync } from 'fs' -import Handlebars from 'handlebars' -import { indexBy } from 'lodash/fp' -import path from 'path' -import { v4 as uuid } from 'uuid' -import { RegoData, UserGroup, WalletGroup } from '../../shared/type/entities.types' -import { OpaResult, RegoInput } from '../../shared/type/rego' -import { criterionToString, reasonToString } from '../../shared/utils/opa.utils' -import { EntityRepository } from '../persistence/repository/entity.repository' - -type PromiseType> = T extends Promise ? U : never -type OpaEngine = PromiseType> - -const OPA_WASM_PATH = path.join(process.cwd(), './rego-build/policy.wasm') - -@Injectable() -export class OpaService implements OnApplicationBootstrap { - private logger = new Logger(OpaService.name) - private opaEngine: OpaEngine | undefined - - constructor(private entityRepository: EntityRepository) {} - - async onApplicationBootstrap(): Promise { - this.logger.log('OPA Service boot') - try { - const policyWasm = readFileSync(OPA_WASM_PATH) - const opaEngine = await loadPolicy(policyWasm, undefined, { - 'time.now_ns': () => new Date().getTime() * 1000000 // TODO: @sam this happens on app bootstrap one time; if you need a timestamp per-request then this needs to be passed in w/ Entity data not into the Policy. - }) - this.opaEngine = opaEngine - await this.reloadEntityData() - } catch (err) { - if (err.code === 'ENOENT') { - this.logger.error(`Policy wasm not found at ${OPA_WASM_PATH}`) - } else { - throw err - } - } - } - - async evaluate(input: RegoInput): Promise { - this.opaEngine = await this.getOpaEngine() - const evalResult: { result: OpaResult }[] = await this.opaEngine.evaluate(input, 'main/evaluate') - return evalResult.map(({ result }) => result) - } - - generateRegoPolicies(payload: Policy[]): { fileId: string; policies: Policy[] } { - Handlebars.registerHelper('criterion', criterionToString) - - Handlebars.registerHelper('reason', reasonToString) - - const templateSource = readFileSync('./apps/policy-engine/src/opa/template/template.hbs', 'utf-8') - - const template = Handlebars.compile(templateSource) - - const policies = payload.map((p) => ({ ...p, id: uuid() })) - - const regoContent = template({ policies }) - - const fileId = uuid() - - const basePath = './apps/policy-engine/src/opa/rego/generated' - - mkdirSync(basePath, { recursive: true }) - - writeFileSync(`${basePath}/${fileId}.rego`, regoContent, 'utf-8') - - this.logger.log('Policy .rego file generated successfully.') - - return { fileId, policies } - } - - async fetchEntityData(): Promise { - const entities = await this.entityRepository.fetch(FIXTURE.ORGANIZATION.id) - - const userGroups = entities.userGroupMembers.reduce((groups, { userId, groupId }) => { - const group = groups.get(groupId) - - if (group) { - return groups.set(groupId, { - id: groupId, - users: group.users.concat(userId) - }) - } else { - return groups.set(groupId, { id: groupId, users: [userId] }) - } - }, new Map()) - - const walletGroups = entities.walletGroupMembers.reduce((groups, { walletId, groupId }) => { - const group = groups.get(groupId) - - if (group) { - return groups.set(groupId, { - id: groupId, - wallets: group.wallets.concat(walletId) - }) - } else { - return groups.set(groupId, { id: groupId, wallets: [walletId] }) - } - }, new Map()) - - const data: RegoData = { - entities: { - addressBook: indexBy('id', entities.addressBook), - tokens: indexBy('id', entities.tokens), - users: indexBy('id', entities.users), - userGroups: Object.fromEntries(userGroups), - wallets: indexBy('id', entities.wallets), - walletGroups: Object.fromEntries(walletGroups) - } - } - - return data - } - - async reloadEntityData() { - if (!this.opaEngine) throw new Error('OPA Engine not initialized') - - try { - const data = await this.fetchEntityData() - this.opaEngine.setData(data) - this.logger.log('Reloaded OPA Engine data') - } catch (error) { - this.logger.error('Failed to bootstrap OPA service') - } - } - - private async getOpaEngine(): Promise { - // Attempt to initialize it if it for some reason isn't. - if (!this.opaEngine) await this.onApplicationBootstrap() - if (!this.opaEngine) throw new Error('OPA Engine not initialized') - return this.opaEngine - } -} diff --git a/apps/policy-engine/src/tenant/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts b/apps/policy-engine/src/engine/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts similarity index 100% rename from apps/policy-engine/src/tenant/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts rename to apps/policy-engine/src/engine/persistence/repository/__test__/integration/file-system-data-store.repository.spec.ts diff --git a/apps/policy-engine/src/tenant/persistence/repository/__test__/integration/http-data-store.repository.spec.ts b/apps/policy-engine/src/engine/persistence/repository/__test__/integration/http-data-store.repository.spec.ts similarity index 100% rename from apps/policy-engine/src/tenant/persistence/repository/__test__/integration/http-data-store.repository.spec.ts rename to apps/policy-engine/src/engine/persistence/repository/__test__/integration/http-data-store.repository.spec.ts diff --git a/apps/policy-engine/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts b/apps/policy-engine/src/engine/persistence/repository/__test__/unit/tenant.repository.spec.ts similarity index 100% rename from apps/policy-engine/src/tenant/persistence/repository/__test__/unit/tenant.repository.spec.ts rename to apps/policy-engine/src/engine/persistence/repository/__test__/unit/tenant.repository.spec.ts diff --git a/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts b/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts deleted file mode 100644 index cd132f3a4..000000000 --- a/apps/policy-engine/src/engine/persistence/repository/entity.repository.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CredentialEntity, Entities, FIXTURE } from '@narval/policy-engine-shared' -import { HttpService } from '@nestjs/axios' -import { Injectable, Logger } from '@nestjs/common' - -@Injectable() -export class EntityRepository { - private logger = new Logger(EntityRepository.name) - - constructor(private httpService: HttpService) {} - - async fetch(orgId: string): Promise { - this.logger.log('Fetch organization entities', { orgId }) - - return FIXTURE.ENTITIES - } - - getCredentialForPubKey(pubKey: string): CredentialEntity | null { - return FIXTURE.ENTITIES.credentials.find((cred) => cred.pubKey === pubKey) || null - } - getCredential(id: string): CredentialEntity | null { - return FIXTURE.ENTITIES.credentials.find((cred) => cred.id === id) || null - } -} diff --git a/apps/policy-engine/src/tenant/persistence/repository/file-system-data-store.repository.ts b/apps/policy-engine/src/engine/persistence/repository/file-system-data-store.repository.ts similarity index 100% rename from apps/policy-engine/src/tenant/persistence/repository/file-system-data-store.repository.ts rename to apps/policy-engine/src/engine/persistence/repository/file-system-data-store.repository.ts diff --git a/apps/policy-engine/src/tenant/persistence/repository/http-data-store.repository.ts b/apps/policy-engine/src/engine/persistence/repository/http-data-store.repository.ts similarity index 100% rename from apps/policy-engine/src/tenant/persistence/repository/http-data-store.repository.ts rename to apps/policy-engine/src/engine/persistence/repository/http-data-store.repository.ts diff --git a/apps/policy-engine/src/tenant/persistence/repository/tenant.repository.ts b/apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts similarity index 94% rename from apps/policy-engine/src/tenant/persistence/repository/tenant.repository.ts rename to apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts index 5f655da14..7e93b1e36 100644 --- a/apps/policy-engine/src/tenant/persistence/repository/tenant.repository.ts +++ b/apps/policy-engine/src/engine/persistence/repository/tenant.repository.ts @@ -78,6 +78,17 @@ export class TenantRepository { return compact(tenants) } + async clear(): Promise { + try { + const ids = await this.getTenantIndex() + await Promise.all(ids.map((id) => this.encryptKeyValueService.delete(id))) + + return true + } catch { + return false + } + } + getKey(clientId: string): string { return `tenant:${clientId}` } diff --git a/apps/policy-engine/src/opa/rego/data.json b/apps/policy-engine/src/opa/rego/data.json deleted file mode 100644 index 1ab2f4871..000000000 --- a/apps/policy-engine/src/opa/rego/data.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "entities": { - "users": { - "u:root_user": { "uid": "u:root_user", "role": "root" }, - "matt@narval.xyz": { "uid": "matt@narval.xyz", "role": "admin" }, - "aa@narval.xyz": { "uid": "aa@narval.xyz", "role": "admin" }, - "bb@narval.xyz": { "uid": "bb@narval.xyz", "role": "admin" } - }, - "userGroups": { - "ug:dev-group": { "uid": "ug:dev-group", "name": "Dev", "users": ["matt@narval.xyz"] }, - "ug:treasury-group": { - "uid": "ug:treasury-group", - "name": "Treasury", - "users": ["bb@narval.xyz", "matt@narval.xyz"] - } - }, - "wallets": { - "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { - "uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "accountType": "eoa", - "assignees": ["matt@narval.xyz"] - }, - "eip155:eoa:0x22228d0504d4f3363a5b7fda1f5fff1c7bca8ad4": { - "uid": "eip155:eoa:0x22228d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "address": "0x22228d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "accountType": "eoa" - }, - "eip155:eoa:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4": { - "uid": "eip155:eoa:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "address": "0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "accountType": "eoa", - "assignees": ["matt@narval.xyz"] - }, - "eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b": { - "uid": "eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "address": "0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "accountType": "eoa", - "assignees": ["matt@narval.xyz"] - } - }, - "walletGroups": { - "wg:dev-group": { - "uid": "wg:dev-group", - "name": "Dev", - "wallets": ["eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"] - }, - "wg:treasury-group": { - "uid": "wg:treasury-group", - "name": "Treasury", - "wallets": ["eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b"] - } - }, - "addressBook": { - "eip155:137:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { - "uid": "eip155:137:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "chainId": 137, - "classification": "wallet" - }, - "eip155:1:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { - "uid": "eip155:1:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", - "chainId": 1, - "classification": "wallet" - }, - "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3": { - "uid": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", - "address": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", - "chainId": 137, - "classification": "internal" - }, - "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4": { - "uid": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "address": "0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "classification": "wallet" - } - }, - "tokens": {} - } -} diff --git a/apps/policy-engine/src/opa/rego/input.json b/apps/policy-engine/src/opa/rego/input.json deleted file mode 100644 index be6327a53..000000000 --- a/apps/policy-engine/src/opa/rego/input.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "action": "signTransaction", - "intent": { - "to": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "from": "eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "type": "transferNative", - "amount": "1000000000000000000", - "token": "eip155:137/slip44:966" - }, - "transactionRequest": { - "from": "0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "to": "0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "maxFeePerGas": "20000000000", - "maxPriorityFeePerGas": "3000000000", - "gas": "21000", - "value": "0xde0b6b3a7640000", - "data": "0x00000000", - "nonce": 192, - "type": "2" - }, - "principal": { - "id": "credentialId1", - "alg": "ES256K", - "userId": "matt@narval.xyz", - "pubKey": "0xd75D626a116D4a1959fE3bB938B2e7c116A05890" - }, - "resource": { "uid": "eip155:eoa:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b" }, - "approvals": [ - { - "userId": "matt@narval.xyz", - "id": "credentialId1", - "alg": "ES256K", - "pubKey": "0xd75D626a116D4a1959fE3bB938B2e7c116A05890" - }, - { - "userId": "aa@narval.xyz", - "id": "credentialId2", - "alg": "ES256K", - "pubKey": "0x501D5c2Ce1EF208aadf9131a98BAa593258CfA06" - }, - { - "userId": "bb@narval.xyz", - "id": "credentialId3", - "alg": "ES256K", - "pubKey": "0xab88c8785D0C00082dE75D801Fcb1d5066a6311e" - } - ], - "feeds": [ - { - "source": "armory/price-feed", - "sig": {}, - "data": { - "eip155:137/slip44:966": { - "fiat:usd": "0.99", - "fiat:eur": "1.10" - } - } - }, - { - "source": "armory/historical-transfer-feed", - "sig": {}, - "data": [ - { - "amount": "3000000000", - "from": "eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "to": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "token": "eip155:137/slip44:966", - "rates": { - "fiat:usd": "0.99", - "fiat:eur": "1.10" - }, - "initiatedBy": "matt@narval.xyz", - "timestamp": 1705934992613 - }, - { - "amount": "2000000000", - "from": "eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "to": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "token": "eip155:137/slip44:966", - "rates": { - "fiat:usd": "0.99", - "fiat:eur": "1.10" - }, - "initiatedBy": "matt@narval.xyz", - "timestamp": 1705934992613 - }, - { - "amount": "1500000000", - "from": "eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "to": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "token": "eip155:137/slip44:966", - "rates": { - "fiat:usd": "0.99", - "fiat:eur": "1.10" - }, - "initiatedBy": "matt@narval.xyz", - "timestamp": 1705934992613 - }, - { - "amount": "1000000000", - "from": "eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b", - "to": "eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4", - "chainId": 137, - "token": "eip155:137/slip44:966", - "rates": { - "fiat:usd": "0.99", - "fiat:eur": "1.10" - }, - "initiatedBy": "matt@narval.xyz", - "timestamp": 1705934992613 - } - ] - } - ] -} diff --git a/apps/policy-engine/src/opa/script/evaluate-legacy-policy.script.ts b/apps/policy-engine/src/opa/script/evaluate-legacy-policy.script.ts deleted file mode 100644 index 4fffa6fe8..000000000 --- a/apps/policy-engine/src/opa/script/evaluate-legacy-policy.script.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Action, Request, UserRole } from '@narval/policy-engine-shared' -import { InputType, safeDecode } from '@narval/transaction-request-intent' -import { loadPolicy } from '@open-policy-agent/opa-wasm' -import { readFileSync } from 'fs' -import path from 'path' -import { OpaResult, RegoInput } from '../../shared/type/domain.type' - -export const evaluate = async (users: any[], wallets: any[], legacyActivityRequests: any[]) => { - const entities: { [key: string]: any } = { users: {}, wallets: {} } - - for (const user of users) { - let role = user.guildUserRole - - if (['SCHOLAR'].includes(role)) { - role = UserRole.MEMBER - } else if (['ADMIN', 'API'].includes(role)) { - role = UserRole.ADMIN - } - - entities.users[user.id] = { id: user.id, role } - } - - for (const wallet of wallets) { - const uid = `eip155:${wallet.accountType}:${wallet.address}` - - entities.wallets[uid] = { - uid, - address: wallet.address, - accountType: wallet.accountType, - assignees: wallet.assignees?.map((assignee: any) => assignee.userId) || [] - } - } - - const requests = legacyActivityRequests.filter( - ({ initiator_user_id }) => undefined !== entities.users[initiator_user_id] - ) as { - status: string - initiator_user_id: string - request: Request - }[] - - for (const { status, initiator_user_id, request } of requests) { - let input = {} as RegoInput - - if (request.action === Action.SIGN_TRANSACTION) { - const intentResult = safeDecode({ - input: { - type: InputType.TRANSACTION_REQUEST, - txRequest: request.transactionRequest - } - }) - - if (intentResult?.success === false) { - console.log( - `Could not decode intent: ${intentResult.error.message}`, - JSON.stringify(request.transactionRequest, null, 2) - ) - continue - } - - input = { - action: request.action, - intent: intentResult?.intent, - transactionRequest: request.transactionRequest, - principal: { - id: initiator_user_id, - userId: initiator_user_id, - alg: 'ES256K', - pubKey: '' - }, - resource: request.resourceId ? { uid: request.resourceId } : undefined, - approvals: [] - } - } - - if (request.action === Action.SIGN_MESSAGE) { - const intentResult = safeDecode({ - input: { - type: InputType.MESSAGE, - payload: request.message - } - }) - - if (intentResult?.success === false) { - console.log(`Could not decode intent: ${intentResult.error.message}`, JSON.stringify(request.message, null, 2)) - continue - } - - input = { - action: request.action, - intent: intentResult?.intent, - principal: { - id: initiator_user_id, - userId: initiator_user_id, - alg: 'ES256K', - pubKey: '' - }, - resource: request.resourceId ? { uid: request.resourceId } : undefined, - approvals: [] - } - } - - if (request.action === Action.SIGN_TYPED_DATA) { - const intentResult = safeDecode({ - input: { - type: InputType.TYPED_DATA, - typedData: JSON.parse(request.typedData) - } - }) - - if (intentResult?.success === false) { - console.log( - `Could not decode intent: ${intentResult.error.message}`, - JSON.stringify(request.typedData, null, 2) - ) - continue - } - - input = { - action: request.action, - intent: intentResult?.intent, - principal: { - id: initiator_user_id, - userId: initiator_user_id, - alg: 'ES256K', - pubKey: '' - }, - resource: request.resourceId ? { uid: request.resourceId } : undefined, - approvals: [] - } - } - - const OPA_WASM_PATH = path.join(process.cwd(), './rego-build/policy.wasm') - const policyWasm = readFileSync(OPA_WASM_PATH) - const opaEngine = await loadPolicy(policyWasm, undefined, { 'time.now_ns': () => new Date().getTime() * 1000000 }) - opaEngine.setData({ entities }) - - const evalResult: { result: OpaResult }[] = await opaEngine.evaluate(input, 'main/evaluate') - const results = evalResult.map(({ result }) => result) - - if (status == 'completed' && !results[0].permit) { - console.log({ id: results[0].reasons.map((reason) => reason.policyName), status, result: results[0].permit }) - } - } -} - -// evaluate(users, wallets, requests) -// .then(() => console.log('done')) -// .catch((error) => console.log('error', error)) diff --git a/apps/policy-engine/src/opa/script/evaluation.script.ts b/apps/policy-engine/src/opa/script/evaluation.script.ts deleted file mode 100644 index 05c8e541e..000000000 --- a/apps/policy-engine/src/opa/script/evaluation.script.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { loadPolicy } from '@open-policy-agent/opa-wasm' -import { readFileSync } from 'fs' -import path from 'path' -import policyData from '../rego/data.json' -import policyInput from '../rego/input.json' - -const OPA_WASM_PATH = readFileSync(path.join(process.cwd(), './rego-build/policy.wasm')) - -loadPolicy(OPA_WASM_PATH, undefined, { - 'time.now_ns': () => new Date().getTime() * 1000000 -}) - .then((policy) => { - policy.setData(policyData) - const resultSet = policy.evaluate(policyInput, 'main/evaluate') - - if (resultSet == null) { - console.error('evaluation error') - } else if (resultSet.length == 0) { - console.log('undefined') - } else { - console.dir(resultSet, { depth: null }) - } - }) - .catch((error) => { - console.error('Failed to load policy: ', error) - }) diff --git a/apps/policy-engine/src/opa/script/translate-legacy-policy.script.ts b/apps/policy-engine/src/opa/script/translate-legacy-policy.script.ts deleted file mode 100644 index f61b8d398..000000000 --- a/apps/policy-engine/src/opa/script/translate-legacy-policy.script.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { - Action, - ActionCriterion, - ApprovalCondition, - ApprovalsCriterion, - AssetType, - Criterion, - DestinationAddressCriterion, - ERC1155TokenIdCriterion, - ERC721TokenIdCriterion, - EntityType, - FiatCurrency, - IntentAmountCriterion, - IntentContractCriterion, - IntentDomainCriterion, - IntentHexSignatureCriterion, - IntentSpenderCriterion, - IntentTokenCriterion, - IntentTypeCriterion, - Policy, - PolicyCriterion, - Then, - UserRole, - ValueOperators, - toAccountId, - toAssetId -} from '@narval/policy-engine-shared' -import { Intents } from '@narval/transaction-request-intent' -import axios from 'axios' -import { Address, Hex } from 'viem' - -type LegacyPolicy = { [key: string]: string | null } - -type NewPolicy = Policy & { id: string } - -const translateActivityType = (policy: LegacyPolicy): PolicyCriterion[] => { - const { - activity_type, - chain_id, - assetType, - asset_contract_address: assetAddress, - asset_token_id: assetTokenId, - destination_address, - destination_account_type, - contract_hex_signature, - signing_type, - domain_name, - domain_version, - domain_verifying_contract - } = policy - - const actionCriteria: ActionCriterion = { - criterion: Criterion.CHECK_ACTION, - args: [] - } - - const intentCriteria: IntentTypeCriterion = { - criterion: Criterion.CHECK_INTENT_TYPE, - args: [] - } - - const intentContract: IntentContractCriterion = { - criterion: Criterion.CHECK_INTENT_CONTRACT, - args: [] - } - - const intentToken: IntentTokenCriterion = { - criterion: Criterion.CHECK_INTENT_TOKEN, - args: [] - } - - const intentSpender: IntentSpenderCriterion = { - criterion: Criterion.CHECK_INTENT_SPENDER, - args: [] - } - - const intentErc721TokenId: ERC721TokenIdCriterion = { - criterion: Criterion.CHECK_ERC721_TOKEN_ID, - args: [] - } - - const intentErc1155TokenId: ERC1155TokenIdCriterion = { - criterion: Criterion.CHECK_ERC1155_TOKEN_ID, - args: [] - } - - const intentHexSignature: IntentHexSignatureCriterion = { - criterion: Criterion.CHECK_INTENT_HEX_SIGNATURE, - args: [] - } - - const destinationAddress: DestinationAddressCriterion = { - criterion: Criterion.CHECK_DESTINATION_ADDRESS, - args: [] - } - - const intentDomain: IntentDomainCriterion = { - criterion: Criterion.CHECK_INTENT_DOMAIN, - args: {} - } - - switch (activity_type) { - case 'signMessage': - if (signing_type === '*') { - actionCriteria.args = [...actionCriteria.args, Action.SIGN_MESSAGE, Action.SIGN_TYPED_DATA] - intentCriteria.args = [...intentCriteria.args, Intents.SIGN_MESSAGE, Intents.SIGN_TYPED_DATA] - } - if (signing_type === 'personalSign') { - actionCriteria.args = [...actionCriteria.args, Action.SIGN_MESSAGE] - intentCriteria.args = [...intentCriteria.args, Intents.SIGN_MESSAGE] - } - if (signing_type === 'typedData') { - actionCriteria.args = [...actionCriteria.args, Action.SIGN_TYPED_DATA] - intentCriteria.args = [...intentCriteria.args, Intents.SIGN_TYPED_DATA] - - if (chain_id && chain_id !== '*') { - intentDomain.args['chainId'] = [chain_id] - } - - if (domain_name && domain_name !== '*') { - intentDomain.args['name'] = [domain_name] - } - - if (domain_version && domain_version !== '*') { - intentDomain.args['version'] = [domain_version] - } - - if (domain_verifying_contract && domain_verifying_contract !== '*') { - intentDomain.args['verifyingContract'] = [domain_verifying_contract as Address] - } - } - return [actionCriteria, intentCriteria, ...(Object.keys(intentDomain.args).length > 0 ? [intentDomain] : [])] - - case 'fungibleAssetTransfer': - actionCriteria.args = [...actionCriteria.args, Action.SIGN_TRANSACTION] - if (assetType === '*') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_ERC20, Intents.TRANSFER_NATIVE] - } - if (assetType === 'erc20') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_ERC20] - if (chain_id && chain_id !== '*' && assetAddress && assetAddress !== '*') { - intentToken.args = [ - ...intentToken.args, - toAssetId({ assetType: AssetType.ERC20, chainId: Number(chain_id), address: assetAddress as Address }) - ] - } - } - if (assetType === 'native') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_NATIVE] - if (chain_id && chain_id === '1') { - intentToken.args = [ - ...intentToken.args, - toAssetId({ assetType: AssetType.SLIP44, chainId: Number(chain_id), coinType: 60 }) - ] - } - if (chain_id && chain_id === '137') { - intentToken.args = [ - ...intentToken.args, - toAssetId({ assetType: AssetType.SLIP44, chainId: Number(chain_id), coinType: 966 }) - ] - } - } - if (destination_address && destination_address !== '*') { - destinationAddress.args = [...destinationAddress.args, destination_address] - } - - return [ - actionCriteria, - intentCriteria, - ...(intentToken.args.length > 0 ? [intentToken] : []), - ...(destinationAddress.args.length > 0 ? [destinationAddress] : []) - ] - - case 'nftAssetTransfer': - actionCriteria.args = [...actionCriteria.args, Action.SIGN_TRANSACTION] - if (assetType === '*') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_ERC721, Intents.TRANSFER_ERC1155] - } - if (assetType === 'erc721') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_ERC721] - if (chain_id && chain_id !== '*' && assetAddress && assetAddress !== '*') { - intentContract.args = [ - ...intentContract.args, - toAccountId({ chainId: Number(chain_id), address: assetAddress as Address }) - ] - if (assetTokenId && assetTokenId !== '*') { - intentErc721TokenId.args = [ - ...intentErc721TokenId.args, - toAssetId({ - chainId: Number(chain_id), - assetType: AssetType.ERC721, - address: assetAddress as Address, - assetId: assetTokenId - }) - ] - } - } - } - if (assetType === 'erc1155') { - intentCriteria.args = [...intentCriteria.args, Intents.TRANSFER_ERC1155] - if (chain_id && chain_id !== '*' && assetAddress && assetAddress !== '*') { - intentContract.args = [ - ...intentContract.args, - toAccountId({ chainId: Number(chain_id), address: assetAddress as Address }) - ] - if (assetTokenId && assetTokenId !== '*') { - intentErc1155TokenId.args = [ - ...intentErc1155TokenId.args, - toAssetId({ - chainId: Number(chain_id), - assetType: AssetType.ERC1155, - address: assetAddress as Address, - assetId: assetTokenId - }) - ] - } - } - } - if (destination_address && destination_address !== '*') { - destinationAddress.args = [...destinationAddress.args, destination_address] - } - - return [ - actionCriteria, - intentCriteria, - ...(intentContract.args.length > 0 ? [intentContract] : []), - ...(intentErc721TokenId.args.length > 0 ? [intentErc721TokenId] : []), - ...(intentErc1155TokenId.args.length > 0 ? [intentErc1155TokenId] : []), - ...(destinationAddress.args.length > 0 ? [destinationAddress] : []) - ] - - case 'contractCall': - actionCriteria.args = [...actionCriteria.args, Action.SIGN_TRANSACTION] - intentCriteria.args = [...intentCriteria.args, Intents.CALL_CONTRACT] - - if ( - destination_account_type === 'contract' && - chain_id && - chain_id != '*' && - destination_address && - destination_address !== '*' - ) { - intentContract.args = [ - ...intentContract.args, - toAccountId({ chainId: Number(chain_id), address: destination_address as Address }) - ] - } - - if (contract_hex_signature && contract_hex_signature !== '*') { - intentHexSignature.args = [...intentHexSignature.args, contract_hex_signature as Hex] - } - - return [ - actionCriteria, - intentCriteria, - ...(intentContract.args.length > 0 ? [intentContract] : []), - ...(intentHexSignature.args.length > 0 ? [intentHexSignature] : []) - ] - - case 'tokenApproval': - actionCriteria.args = [...actionCriteria.args, Action.SIGN_TRANSACTION] - intentCriteria.args = [...intentCriteria.args, Intents.APPROVE_TOKEN_ALLOWANCE] - - if (chain_id && chain_id !== '*') { - if (assetAddress && assetAddress !== '*') { - intentToken.args = [ - ...intentToken.args, - toAssetId({ assetType: AssetType.ERC20, chainId: Number(chain_id), address: assetAddress as Address }) - ] - } - if (destination_account_type === 'contract' && destination_address && destination_address !== '*') { - intentSpender.args = [ - ...intentSpender.args, - toAccountId({ chainId: Number(chain_id), address: destination_address as Address }) - ] - } - } - - return [ - actionCriteria, - intentCriteria, - ...(intentToken.args.length > 0 ? [intentToken] : []), - ...(intentSpender.args.length > 0 ? [intentSpender] : []) - ] - - default: - return [] - } -} - -const translateAmount = (policy: LegacyPolicy): IntentAmountCriterion[] => { - const { usd_amount, comparison_operator, amount } = policy - - const amountDefined = amount && amount !== '*' - const usdAmountDefined = usd_amount && usd_amount !== '*' - - if (!amountDefined && !usdAmountDefined) { - return [] - } - - const currency = usdAmountDefined ? FiatCurrency.USD : '*' - const value = usdAmountDefined ? `${usd_amount}` : `${amount}` - - const intentAmount: IntentAmountCriterion = { - criterion: Criterion.CHECK_INTENT_AMOUNT, - args: { currency, operator: ValueOperators.LESS_THAN, value } - } - - switch (comparison_operator) { - case '>': - intentAmount.args.operator = ValueOperators.GREATER_THAN - break - case '<': - intentAmount.args.operator = ValueOperators.LESS_THAN - break - case '=': - intentAmount.args.operator = ValueOperators.EQUAL - break - default: - break - } - - return [intentAmount] -} - -const translateApproval = (policy: LegacyPolicy): ApprovalsCriterion[] => { - const { approval_threshold, approval_user_id, approval_user_role, approval_user_group } = policy - - const approval: ApprovalCondition = { - approvalCount: 1, - countPrincipal: false, - approvalEntityType: EntityType.User, - entityIds: [] - } - - if (approval_threshold && approval_threshold !== '*') { - approval.approvalCount = Number(approval_threshold) - } - if (approval_user_id && approval_user_id !== '*') { - approval.approvalEntityType = EntityType.User - approval.entityIds = [approval_user_id] - } - if (approval_user_role && approval_user_role !== '*') { - approval.approvalEntityType = EntityType.UserRole - approval.entityIds = [approval_user_role] - } - if (approval_user_group && approval_user_group !== '*') { - approval.approvalEntityType = EntityType.UserGroup - approval.entityIds = [approval_user_group] - } - - if (approval.entityIds.length > 0) { - return [ - { - criterion: Criterion.CHECK_APPROVALS, - args: [approval] - } - ] - } - - return [] -} - -const translateLegacyPolicy = (policy: LegacyPolicy): NewPolicy | null => { - const { id, result, user_id, guild_user_role, user_group, source_address, activity_type } = policy - - if (!id || !result) { - return null - } - - const res: NewPolicy = { - id, - name: id, - when: [], - then: ['approve', 'confirm'].includes(result) ? Then.PERMIT : Then.FORBID - } - - if (user_id && user_id !== '*') { - res.when.push({ - criterion: Criterion.CHECK_PRINCIPAL_ID, - args: [user_id] - }) - } - - if (guild_user_role && guild_user_role !== '*') { - const role = ['root', 'admin', 'member', 'manager'].includes(guild_user_role) - ? (guild_user_role as UserRole) - : UserRole.MEMBER - - res.when.push({ - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [role] - }) - } - - if (user_group && user_group !== '*') { - res.when.push({ - criterion: Criterion.CHECK_PRINCIPAL_GROUP, - args: [user_group] - }) - } - - if (source_address && source_address !== '*') { - res.when.push({ - criterion: Criterion.CHECK_WALLET_ADDRESS, - args: [source_address] - }) - } - - if (activity_type && activity_type !== '*') { - res.when = res.when.concat(translateActivityType(policy)) - } - - res.when = res.when.concat(translateAmount(policy)) - - if (res.then === Then.PERMIT) { - res.when = res.when.concat(translateApproval(policy)) - } - - return res -} - -export const translate = (policies: LegacyPolicy[]) => { - const data = policies.map(translateLegacyPolicy).filter(Boolean) - - console.log(`number of policies to translate: ${data.length}.`) - - return axios.post('http://localhost:3010/admin/policies', { - authentication: { - sig: '0x746ed2e4bf7311da76bc157c7fe8c0520b6e4c27ab96abf5a8d16fecbaac98b669418b2db9da8e6d3cbd4e1eaff1a9d9e765f0470e9b86c6694145778a8d46f81c', - alg: 'ES256K', - pubKey: '0xd75D626a116D4a1959fE3bB938B2e7c116A05890' - }, - approvals: [ - { - sig: '0xe86dffd265b7a76a9de0ee9078137271cbe32bb2bb8ee28a2935cc37f023193a51cd608701b9c40fc42be69eeb45c0bb375b5898828f1af4bf12e37ff1fe697f1c', - alg: 'ES256K', - pubKey: '0x501D5c2Ce1EF208aadf9131a98BAa593258CfA06' - }, - { - sig: '0xaffbddca4f16079f86a56d58f9ebb151c353e73c11a09791eb97f01ea0046c545ea0bd765ab1dc844ee0369f9123476b6f84b00b42b7ac1a16676b9a11e1a4031c', - alg: 'ES256K', - pubKey: '0xab88c8785D0C00082dE75D801Fcb1d5066a6311e' - } - ], - request: { - action: 'setPolicyRules', - nonce: 'random-nonce-111', - data - } - }) -} - -// translate( -// policies.map((policy) => { -// const res: LegacyPolicy = omit(policy, ['guild_id', 'sequence', 'version', 'amount']) -// res.amount = policy.amount ? `${policy.amount}` : null -// return res -// }) -// ) -// .then(() => console.log('done')) -// .catch((error) => console.log('error', error)) diff --git a/apps/policy-engine/src/opa/template/meta-permissions.data.ts b/apps/policy-engine/src/opa/template/meta-permissions.data.ts deleted file mode 100644 index 257e4c65a..000000000 --- a/apps/policy-engine/src/opa/template/meta-permissions.data.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Action, Criterion, EntityType, Policy, Then, UserRole } from '@narval/policy-engine-shared' - -const metaPermissions = [ - Action.CREATE_ORGANIZATION, - Action.CREATE_USER, - Action.UPDATE_USER, - Action.CREATE_CREDENTIAL, - Action.ASSIGN_USER_GROUP, - Action.ASSIGN_WALLET_GROUP, - Action.ASSIGN_USER_WALLET, - Action.DELETE_USER, - Action.REGISTER_WALLET, - Action.CREATE_ADDRESS_BOOK_ACCOUNT, - Action.EDIT_WALLET, - Action.UNASSIGN_WALLET, - Action.REGISTER_TOKENS, - Action.EDIT_USER_GROUP, - Action.DELETE_USER_GROUP, - Action.CREATE_WALLET_GROUP, - Action.DELETE_WALLET_GROUP -] - -export const permitMetaPermission: Policy = { - name: 'permitMetaPermission', - when: [ - { - criterion: Criterion.CHECK_ACTION, - args: metaPermissions - }, - { - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [UserRole.ADMIN] - }, - { - criterion: Criterion.CHECK_APPROVALS, - args: [ - { - approvalCount: 2, - countPrincipal: false, - approvalEntityType: EntityType.UserRole, - entityIds: [UserRole.ADMIN, UserRole.ROOT] - } - ] - } - ], - then: Then.PERMIT -} - -export const forbidMetaPermission: Policy = { - name: 'forbidMetaPermission', - when: [ - { - criterion: Criterion.CHECK_ACTION, - args: metaPermissions - }, - { - criterion: Criterion.CHECK_PRINCIPAL_ROLE, - args: [UserRole.ADMIN] - } - ], - then: Then.FORBID -} diff --git a/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts new file mode 100644 index 000000000..655680d58 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts @@ -0,0 +1,375 @@ +import { + Action, + Criterion, + Decision, + EntityType, + EvaluationRequest, + FIXTURE, + Hex, + JwtString, + Policy, + Request, + Then, + toHex +} from '@narval/policy-engine-shared' +import { SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' +import { ConfigModule, ConfigService, Path, PathValue } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import { Config, load } from '../../../../policy-engine.config' +import { OpenPolicyAgentException } from '../../exception/open-policy-agent.exception' +import { OpenPolicyAgentEngine } from '../../open-policy-agent.engine' +import { Result } from '../../type/open-policy-agent.type' + +const ONE_ETH = toHex(BigInt('1000000000000000000')) + +const UNSAFE_ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' + +const getJwt = (option: { privateKey: Hex; request: Request; sub: string }): Promise => { + const jwk = privateKeyToJwk(option.privateKey) + const signer = buildSignerEip191(option.privateKey) + + return signJwt( + { + requestHash: hash(option.request), + sub: option.sub + }, + jwk, + { alg: SigningAlg.EIP191 }, + signer + ) +} + +const getConfig = async

>(propertyPath: P): Promise> => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [load] })] + }).compile() + + const service = module.get>(ConfigService) + + return service.get(propertyPath, { infer: true }) +} + +describe('OpenPolicyAgentEngine', () => { + let engine: OpenPolicyAgentEngine + + beforeEach(async () => { + engine = await OpenPolicyAgentEngine.empty({ + resourcePath: await getConfig('resourcePath'), + privateKey: UNSAFE_ENGINE_PRIVATE_KEY + }).load() + }) + + describe('empty', () => { + it('starts with an empty state', async () => { + const e = OpenPolicyAgentEngine.empty({ + resourcePath: await getConfig('resourcePath'), + privateKey: UNSAFE_ENGINE_PRIVATE_KEY + }) + + expect(e.getPolicies()).toEqual([]) + expect(e.getEntities()).toEqual({ + addressBook: [], + credentials: [], + tokens: [], + userGroupMembers: [], + userGroups: [], + userWallets: [], + users: [], + walletGroupMembers: [], + walletGroups: [], + wallets: [] + }) + }) + }) + + describe('setPolicies', () => { + it('sets policies', () => { + expect(engine.setPolicies(FIXTURE.POLICIES).getPolicies()).toEqual(FIXTURE.POLICIES) + }) + }) + + describe('setEntities', () => { + it('sets entities', () => { + expect(engine.setEntities(FIXTURE.ENTITIES).getEntities()).toEqual(FIXTURE.ENTITIES) + }) + }) + + describe('load', () => { + it('sets opa engine', async () => { + const e = await engine.setPolicies(FIXTURE.POLICIES).load() + + expect(e.getOpenPolicyAgentInstance()).toBeDefined() + }) + }) + + describe('evaluate', () => { + it('throws OpenPolicyAgentException when action is unsupported', async () => { + const request: Partial = { + request: { + action: Action.SIGN_MESSAGE, + nonce: 'test-nonce', + resourceId: 'test-resource-id', + message: 'test-message' + } + } + + await expect(() => engine.evaluate(request as EvaluationRequest)).rejects.toThrow(OpenPolicyAgentException) + }) + + it('evaluates a forbid rule', async () => { + const policies: Policy[] = [ + { + then: Then.FORBID, + name: 'test-policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ] + + const e = await new OpenPolicyAgentEngine({ + policies, + entities: FIXTURE.ENTITIES, + privateKey: UNSAFE_ENGINE_PRIVATE_KEY, + resourcePath: await getConfig('resourcePath') + }).load() + + const request = { + action: Action.SIGN_TRANSACTION, + nonce: 'test-nonce', + transactionRequest: { + from: FIXTURE.WALLET.Engineering.address, + to: FIXTURE.WALLET.Testing.address, + value: ONE_ETH, + chainId: 1 + }, + resourceId: FIXTURE.WALLET.Engineering.id + } + + const evaluation: EvaluationRequest = { + authentication: await getJwt({ + privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, + sub: FIXTURE.USER.Alice.id, + request + }), + request + } + + const response = await e.evaluate(evaluation) + + expect(response).toEqual({ + decision: Decision.FORBID, + request: evaluation.request + }) + }) + + it('adds access token on permit responses', async () => { + const policies: Policy[] = [ + { + then: Then.PERMIT, + name: 'test-policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ] + + const e = await new OpenPolicyAgentEngine({ + policies, + entities: FIXTURE.ENTITIES, + privateKey: UNSAFE_ENGINE_PRIVATE_KEY, + resourcePath: await getConfig('resourcePath') + }).load() + + const request = { + action: Action.SIGN_TRANSACTION, + nonce: 'test-nonce', + transactionRequest: { + from: FIXTURE.WALLET.Engineering.address, + to: FIXTURE.WALLET.Testing.address, + value: ONE_ETH, + chainId: 1 + }, + resourceId: FIXTURE.WALLET.Engineering.id + } + + const evaluation: EvaluationRequest = { + authentication: await getJwt({ + privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, + sub: FIXTURE.USER.Alice.id, + request + }), + request + } + + const response = await e.evaluate(evaluation) + + expect(response.decision).toEqual(Decision.PERMIT) + // TODO: (@wcalderipe, 20/03/24) Today we can't assert the signature + // because the function isn't pure due to the dependency on Date.now + expect(response.accessToken).toMatchObject({ + value: expect.any(String) + }) + }) + }) + + describe('decide', () => { + it('returns forbid when any of the reasons is forbid', () => { + const results: Result[] = [ + { + permit: false, + reasons: [ + { + policyId: 'forbid-rule-id', + policyName: 'Forbid Rule', + type: 'forbid', + approvalsMissing: [], + approvalsSatisfied: [] + }, + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + } + ] + } + ] + + const result = engine.decide(results) + + expect(result.decision).toEqual(Decision.FORBID) + }) + + it('returns permit when all of the reasons are permit', () => { + const results: Result[] = [ + { + permit: true, + reasons: [ + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + }, + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + } + ] + } + ] + + const result = engine.decide(results) + + expect(result.decision).toEqual(Decision.PERMIT) + }) + + it('returns confirm when any of the reasons are forbid for a permit type rule where approvals are missing', () => { + const results: Result[] = [ + { + permit: false, + reasons: [ + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: ['user-id'], + countPrincipal: true + } + ], + approvalsSatisfied: [] + } + ] + } + ] + + const result = engine.decide(results) + + expect(result.decision).toEqual(Decision.CONFIRM) + }) + + it('returns all missing, satisfied, and total approvals', () => { + const missingApproval = { + policyId: 'permit-rule-id', + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: ['user-id'], + countPrincipal: true + } + const missingApproval2 = { + policyId: 'permit-rule-id-4', + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: ['user-id'], + countPrincipal: true + } + const satisfiedApproval = { + policyId: 'permit-rule-id-2', + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: ['user-id'], + countPrincipal: true + } + const satisfiedApproval2 = { + policyId: 'permit-rule-id-3', + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: ['user-id'], + countPrincipal: true + } + const results: Result[] = [ + { + permit: false, + reasons: [ + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [missingApproval], + approvalsSatisfied: [satisfiedApproval] + } + ] + }, + { + permit: false, + reasons: [ + { + policyId: 'permit-rule-id', + policyName: 'Permit Rule', + type: 'permit', + approvalsMissing: [missingApproval2], + approvalsSatisfied: [satisfiedApproval2] + } + ] + } + ] + + const result = engine.decide(results) + + expect(result).toEqual({ + decision: Decision.CONFIRM, + approvals: { + required: [missingApproval, missingApproval2, satisfiedApproval, satisfiedApproval2], + missing: [missingApproval, missingApproval2], + satisfied: [satisfiedApproval, satisfiedApproval2] + } + }) + }) + }) +}) diff --git a/apps/policy-engine/src/open-policy-agent/core/exception/open-policy-agent.exception.ts b/apps/policy-engine/src/open-policy-agent/core/exception/open-policy-agent.exception.ts new file mode 100644 index 000000000..523510b7e --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/exception/open-policy-agent.exception.ts @@ -0,0 +1,3 @@ +import { ApplicationException } from '../../../shared/exception/application.exception' + +export class OpenPolicyAgentException extends ApplicationException {} diff --git a/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts new file mode 100644 index 000000000..8a985b33f --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts @@ -0,0 +1,333 @@ +import { + Action, + ApprovalRequirement, + CredentialEntity, + Decision, + Engine, + Entities, + EvaluationRequest, + EvaluationResponse, + JsonWebKey, + JwtString, + Policy +} from '@narval/policy-engine-shared' +import { + Hex, + Payload, + SigningAlg, + base64UrlToHex, + buildSignerEip191, + decode, + hash, + privateKeyToJwk, + publicKeyToJwk, + signJwt, + verifyJwt +} from '@narval/signature' +import { HttpStatus } from '@nestjs/common' +import { loadPolicy } from '@open-policy-agent/opa-wasm' +import { compact } from 'lodash/fp' +import { v4 as uuid } from 'uuid' +import { z } from 'zod' +import { POLICY_ENTRYPOINT } from '../open-policy-agent.constant' +import { OpenPolicyAgentException } from './exception/open-policy-agent.exception' +import { resultSchema } from './schema/open-policy-agent.schema' +import { Input, OpenPolicyAgentInstance, Result } from './type/open-policy-agent.type' +import { toData, toInput } from './util/evaluation.util' +import { getRegoRuleTemplatePath } from './util/rego-transpiler.util' +import { build, getRegoCorePath } from './util/wasm-build.util' + +export class OpenPolicyAgentEngine implements Engine { + private policies: Policy[] + + private entities: Entities + + private resourcePath: string + + // TODO: (@wcalderipe, 20/03/24) How we store and recover a signing key will + // change very soon because we need to support MPC signing. + // This code is here just for feature parity with the existing proof of + // concept. + private privateKey: Hex + + private opa?: OpenPolicyAgentInstance + + constructor(params: { policies: Policy[]; entities: Entities; resourcePath: string; privateKey: Hex }) { + this.entities = params.entities + this.policies = params.policies + this.privateKey = params.privateKey + this.resourcePath = params.resourcePath + } + + static empty(params: { resourcePath: string; privateKey: Hex }): OpenPolicyAgentEngine { + return new OpenPolicyAgentEngine({ + entities: { + addressBook: [], + credentials: [], + tokens: [], + userGroupMembers: [], + userGroups: [], + userWallets: [], + users: [], + walletGroupMembers: [], + walletGroups: [], + wallets: [] + }, + policies: [], + resourcePath: params.resourcePath, + privateKey: params.privateKey + }) + } + + setPolicies(policies: Policy[]): OpenPolicyAgentEngine { + this.policies = policies + + return this + } + + getPolicies(): Policy[] { + return this.policies + } + + getOpenPolicyAgentInstance(): OpenPolicyAgentInstance | undefined { + return this.opa + } + + setEntities(entities: Entities): OpenPolicyAgentEngine { + this.entities = entities + + return this + } + + getEntities(): Entities { + return this.entities + } + + async load(): Promise { + try { + const wasm = await build({ + policies: this.getPolicies(), + path: `/tmp/armory-policy-bundle-${uuid()}`, + regoCorePath: getRegoCorePath(this.resourcePath), + regoRuleTemplatePath: getRegoRuleTemplatePath(this.resourcePath) + }) + + this.opa = await loadPolicy(wasm, undefined, { + 'time.now_ns': () => new Date().getTime() * 1000000 + }) + + this.opa.setData(toData(this.getEntities())) + + return this + } catch (error) { + console.log(error) + + throw new OpenPolicyAgentException({ + message: 'Fail to load Open Policy Agent engine', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR, + origin: error + }) + } + } + + async evaluate(evaluation: EvaluationRequest): Promise { + const { action } = evaluation.request + + if (action !== Action.SIGN_TRANSACTION) { + throw new OpenPolicyAgentException({ + message: 'Open Policy Agent engine unsupported action', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { action } + }) + } + + const message = hash(evaluation.request) + const principalCredential = await this.verifySignature(evaluation.authentication, message) + + const approvalsCredential = await Promise.all( + (evaluation.approvals ? evaluation.approvals : []).map((signature) => this.verifySignature(signature, message)) + ) + + const results = await this.opaEvaluate(evaluation, { + principal: principalCredential, + approvals: approvalsCredential + }) + const decision = this.decide(results) + + const response: EvaluationResponse = { + decision: decision.decision, + approvals: decision.approvals, + request: evaluation.request + } + + if (response.decision === Decision.PERMIT) { + return { + ...response, + accessToken: { + value: await this.sign({ + principalCredential, + message + }) + } + } + } + + return response + } + + private async verifySignature(signature: JwtString, message: string) { + const { header } = decode(signature) + + const credential = this.getCredential(header.kid) + + if (!credential) { + throw new OpenPolicyAgentException({ + message: 'Signature credential not found', + suggestedHttpStatusCode: HttpStatus.NOT_FOUND + }) + } + + const jwk = publicKeyToJwk(credential.pubKey as Hex) + + const validJwt = await verifyJwt(signature, jwk) + + if (validJwt.payload.requestHash !== message) { + throw new OpenPolicyAgentException({ + message: 'Signature hash mismatch', + suggestedHttpStatusCode: HttpStatus.FORBIDDEN + }) + } + + return credential + } + + private getCredential(id: string): CredentialEntity | null { + return this.getEntities().credentials.find((cred) => cred.id === id) || null + } + + private async opaEvaluate( + evaluation: EvaluationRequest, + credentials: { principal: CredentialEntity; approvals?: CredentialEntity[] } + ): Promise { + if (!this.opa) { + throw new OpenPolicyAgentException({ + message: 'Open Policy Agent engine not loaded', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY + }) + } + + const input: Input = { + ...toInput(evaluation), + principal: credentials.principal, + // TODO: Why the EvaluationRequest specifies approvals as optional but + // the OPA input doesn't? + approvals: credentials.approvals || [] + } + + // NOTE: When we evaluate an input against the Rego policy core, it returns + // an array of results with an inner result. We perform a typecast here to + // satisfy TypeScript compiler. Later, we parse the schema a few lines + // below to ensure type-safety for data coming from external sources. + const results = (await this.opa.evaluate(input, POLICY_ENTRYPOINT)) as { result: unknown }[] + + const parse = z.array(resultSchema).safeParse(results.map(({ result }) => result)) + + if (parse.success) { + return parse.data + } + + throw new OpenPolicyAgentException({ + message: 'Invalid Open Policy Agent result schema', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR, + context: { + results, + error: parse.error.errors + } + }) + } + + /** + * Computes OPA's query results into a final decision. This step is also + * known as "post-evaluation". + */ + decide(results: Result[]): { + decision: Decision + approvals?: { + required: ApprovalRequirement[] + missing: ApprovalRequirement[] + satisfied: ApprovalRequirement[] + } + } { + const implicitForbid = results.some(this.isImplictForbid) + const anyExplicitForbid = results.some(this.isExplictForbid) + const allPermit = results.every(this.isPermit) + const permitsMissingApproval = results.some(this.isPermitMissingApproval) + + if (implicitForbid || anyExplicitForbid) { + return { decision: Decision.FORBID } + } + + const approvalsSatisfied = compact( + results.flatMap((result) => result.reasons?.flatMap((reason) => reason.approvalsSatisfied)).filter((v) => !!v) + ) + const approvalsMissing = compact( + results.flatMap((result) => result.reasons?.flatMap((reason) => reason.approvalsMissing)).filter((v) => !!v) + ) + const approvalsRequired = compact(approvalsMissing.concat(approvalsSatisfied)) + + const decision = allPermit && !permitsMissingApproval ? Decision.PERMIT : Decision.CONFIRM + + return { + decision, + approvals: { + missing: approvalsMissing, + required: approvalsRequired, + satisfied: approvalsSatisfied + } + } + } + + private isImplictForbid(result: Result): boolean { + return result.default === true && result.permit === false && result.reasons?.length === 0 + } + + private isExplictForbid(result: Result): boolean { + return Boolean(result.permit === false && result.reasons?.some((reason) => reason.type === 'forbid')) + } + + private isPermit(result: Result): boolean { + return Boolean(result.permit === true && result.reasons?.every((reason) => reason.type === 'permit')) + } + + private isPermitMissingApproval(result: Result): boolean { + return Boolean(result.reasons?.some((reason) => reason.type === 'permit' && reason.approvalsMissing.length > 0)) + } + + private async sign(params: { principalCredential: CredentialEntity; message: string }): Promise { + const engineJwk: JsonWebKey = privateKeyToJwk(this.privateKey) + const principalJwk = publicKeyToJwk(params.principalCredential.pubKey as Hex) + + const payload: Payload = { + requestHash: params.message, + sub: params.principalCredential.userId, + // TODO: iat & exp values must be arguments, cannot generate timestamps + // because of cluster mis-match + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 10, // 10 minutes + // TODO: allow client-specific; should come from config + iss: 'https://armory.narval.xyz', + // aud: TODO + // jti: TODO + cnf: principalJwk + } + + if (!engineJwk.d) { + throw new OpenPolicyAgentException({ + message: 'Missing signing private key', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR + }) + } + + return signJwt(payload, engineJwk, { alg: SigningAlg.EIP191 }, buildSignerEip191(base64UrlToHex(engineJwk.d))) + } +} diff --git a/apps/policy-engine/src/open-policy-agent/core/schema/open-policy-agent.schema.ts b/apps/policy-engine/src/open-policy-agent/core/schema/open-policy-agent.schema.ts new file mode 100644 index 000000000..e82230672 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/schema/open-policy-agent.schema.ts @@ -0,0 +1,18 @@ +import { approvalRequirementSchema } from '@narval/policy-engine-shared' +import { z } from 'zod' + +export const resultSchema = z.object({ + default: z.boolean().optional(), + permit: z.boolean(), + reasons: z + .array( + z.object({ + policyName: z.string(), + policyId: z.string(), + type: z.enum(['permit', 'forbid']), + approvalsSatisfied: z.array(approvalRequirementSchema), + approvalsMissing: z.array(approvalRequirementSchema) + }) + ) + .optional() +}) diff --git a/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts b/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts new file mode 100644 index 000000000..980f52663 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts @@ -0,0 +1,82 @@ +import { + AccountClassification, + AccountType, + Action, + Address, + CredentialEntity, + HistoricalTransfer, + TransactionRequest, + UserRole +} from '@narval/policy-engine-shared' +import { Intent } from '@narval/transaction-request-intent' +import { loadPolicy } from '@open-policy-agent/opa-wasm' +import { z } from 'zod' +import { resultSchema } from '../schema/open-policy-agent.schema' + +type PromiseType> = T extends Promise ? U : never + +export type OpenPolicyAgentInstance = PromiseType> + +export type Input = { + action: Action + intent?: Intent + transactionRequest?: TransactionRequest + principal: CredentialEntity + resource?: { uid: string } + approvals?: CredentialEntity[] + transfers?: HistoricalTransfer[] +} + +// TODO: (@wcalderipe, 18/03/24) Check with @samteb how can we replace these +// types by entities defined at @narval/policy-engine-shared. + +type User = { + id: string // Pubkey + role: UserRole +} + +export type UserGroup = { + id: string + users: string[] // userIds +} + +type Wallet = { + id: string + address: Address + accountType: AccountType + chainId?: number + assignees?: string[] // userIds +} + +export type WalletGroup = { + id: string + wallets: string[] // walletIds +} + +type AddressBookAccount = { + id: string + address: Address + chainId: number + classification: AccountClassification +} + +type Token = { + id: string + address: Address + symbol: string | null + chainId: number + decimals: number +} + +export type Data = { + entities: { + users: Record + wallets: Record + userGroups: Record + walletGroups: Record + addressBook: Record + tokens: Record + } +} + +export type Result = z.infer diff --git a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts new file mode 100644 index 000000000..7f949c586 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts @@ -0,0 +1,113 @@ +import { Action, EvaluationRequest, FIXTURE, SignTransactionAction } from '@narval/policy-engine-shared' +import { InputType, decode } from '@narval/transaction-request-intent' +import { generateInboundEvaluationRequest } from '../../../../../shared/testing/evaluation.testing' +import { OpenPolicyAgentException } from '../../../exception/open-policy-agent.exception' +import { toData, toInput } from '../../evaluation.util' + +describe('toInput', () => { + it('throws OpenPolicyAgentException when action is unsupported', () => { + const evaluation: Partial = { + request: { + action: Action.SIGN_TYPED_DATA, + nonce: 'test-nonce', + resourceId: 'test-resource-id', + typedData: 'test-typed-data' + } + } + + expect(() => toInput(evaluation as EvaluationRequest)).toThrow(OpenPolicyAgentException) + }) + + describe(`when action is ${Action.SIGN_TRANSACTION}`, () => { + let evaluation: EvaluationRequest + + beforeEach(async () => { + evaluation = await generateInboundEvaluationRequest() + }) + + it('maps the request action', () => { + const input = toInput(evaluation) + + expect(input.action).toEqual(evaluation.request.action) + }) + + it('maps the transaction request', () => { + const input = toInput(evaluation) + const request = evaluation.request as SignTransactionAction + + expect(input.transactionRequest).toEqual(request.transactionRequest) + }) + + it('maps the transfers', () => { + const input = toInput(evaluation) + + expect(input.transfers).toEqual(evaluation.transfers) + }) + + it('adds the transaction request intent', () => { + const input = toInput(evaluation) + const intent = decode({ + input: { + type: InputType.TRANSACTION_REQUEST, + txRequest: (evaluation.request as SignTransactionAction).transactionRequest + } + }) + + expect(input.intent).toEqual(intent) + }) + }) +}) + +describe('toData', () => { + describe('entities', () => { + it('indexes address book accounts by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const firstAccount = FIXTURE.ADDRESS_BOOK[0] + + expect(entities.addressBook[firstAccount.id]).toEqual(firstAccount) + }) + + it('indexes tokens by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const usdc = FIXTURE.TOKEN.usdc1 + + expect(entities.tokens[usdc.id]).toEqual(usdc) + }) + + it('indexes users by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const alice = FIXTURE.USER.Alice + + expect(entities.users[alice.id]).toEqual(alice) + }) + + it('indexes wallets by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const wallet = FIXTURE.WALLET.Testing + + expect(entities.wallets[wallet.id]).toEqual(wallet) + }) + + it('indexes user groups with members by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const group = FIXTURE.USER_GROUP.Engineering + + expect(entities.userGroups[group.id]).toEqual({ + id: group.id, + users: FIXTURE.USER_GROUP_MEMBER.filter(({ groupId }) => groupId === group.id).map(({ userId }) => userId) + }) + }) + + it('indexes wallet groups with members by id', () => { + const { entities } = toData(FIXTURE.ENTITIES) + const group = FIXTURE.WALLET_GROUP.Treasury + + expect(entities.walletGroups[group.id]).toEqual({ + id: group.id, + wallets: FIXTURE.WALLET_GROUP_MEMBER.filter(({ groupId }) => groupId === group.id).map( + ({ walletId }) => walletId + ) + }) + }) + }) +}) diff --git a/apps/policy-engine/src/shared/__test__/unit/opa.utils.spec.ts b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts similarity index 63% rename from apps/policy-engine/src/shared/__test__/unit/opa.utils.spec.ts rename to apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts index cc8846ced..3f1dce4bd 100644 --- a/apps/policy-engine/src/shared/__test__/unit/opa.utils.spec.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/rego-transpiler.util.spec.ts @@ -3,21 +3,46 @@ import { Criterion, ERC1155TransfersCriterion, EntityType, + FIXTURE, IntentAmountCriterion, NonceRequiredCriterion, Then, ValueOperators, WalletAddressCriterion } from '@narval/policy-engine-shared' -import { criterionToString, reasonToString } from '../../utils/opa.utils' +import { ConfigModule, ConfigService, Path, PathValue } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import { Config, load } from '../../../../../policy-engine.config' +import { getRegoRuleTemplatePath, transpile, transpileCriterion, transpileReason } from '../../rego-transpiler.util' -describe('criterionToString', () => { +const getConfig = async

>(propertyPath: P): Promise> => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [load] })] + }).compile() + + const service = module.get>(ConfigService) + + return service.get(propertyPath, { infer: true }) +} + +const getTemplatePath = async () => getRegoRuleTemplatePath(await getConfig('resourcePath')) + +describe('transpile', () => { + it('transpiles rego rules based on the given policies', async () => { + const rules = await transpile(FIXTURE.POLICIES, await getTemplatePath()) + + expect(rules).toContain('permit') + expect(rules).toContain('forbid') + }) +}) + +describe('transpileCriterion', () => { it('returns criterion if args are null', () => { const item: NonceRequiredCriterion = { criterion: Criterion.CHECK_NONCE_EXISTS, args: null } - expect(criterionToString(item)).toEqual(Criterion.CHECK_NONCE_EXISTS) + expect(transpileCriterion(item)).toEqual(Criterion.CHECK_NONCE_EXISTS) }) it('returns criterion if args is an array of strings', () => { @@ -25,7 +50,8 @@ describe('criterionToString', () => { criterion: Criterion.CHECK_WALLET_ADDRESS, args: ['0x123', '0x456'] } - expect(criterionToString(item)).toEqual(`${Criterion.CHECK_WALLET_ADDRESS}({"0x123", "0x456"})`) + + expect(transpileCriterion(item)).toEqual(`${Criterion.CHECK_WALLET_ADDRESS}({"0x123", "0x456"})`) }) it('returns criterion if args is an array of objects', () => { @@ -33,7 +59,8 @@ describe('criterionToString', () => { criterion: Criterion.CHECK_ERC1155_TRANSFERS, args: [{ tokenId: 'eip155:137/erc1155:0x12345/123', operator: ValueOperators.LESS_THAN_OR_EQUAL, value: '5' }] } - expect(criterionToString(item)).toEqual( + + expect(transpileCriterion(item)).toEqual( `${Criterion.CHECK_ERC1155_TRANSFERS}([${item.args.map((el) => JSON.stringify(el)).join(', ')}])` ) }) @@ -47,7 +74,8 @@ describe('criterionToString', () => { value: '1000000000000000000' } } - expect(criterionToString(item)).toEqual(`${Criterion.CHECK_INTENT_AMOUNT}(${JSON.stringify(item.args)})`) + + expect(transpileCriterion(item)).toEqual(`${Criterion.CHECK_INTENT_AMOUNT}(${JSON.stringify(item.args)})`) }) it('returns approvals criterion', () => { @@ -62,13 +90,14 @@ describe('criterionToString', () => { } ] } - expect(criterionToString(item)).toEqual( + + expect(transpileCriterion(item)).toEqual( `approvals = ${Criterion.CHECK_APPROVALS}([${item.args.map((el) => JSON.stringify(el)).join(', ')}])` ) }) }) -describe('reasonToString', () => { +describe('transpileReason', () => { it('returns reason with approvals for PERMIT rules', () => { const item = { id: '12345', @@ -81,7 +110,8 @@ describe('reasonToString', () => { } ] } - expect(reasonToString(item)).toEqual( + + expect(transpileReason(item)).toEqual( 'reason = {"type":"permit","policyId":"12345","policyName":"policyName","approvalsSatisfied":approvals.approvalsSatisfied,"approvalsMissing":approvals.approvalsMissing}' ) }) @@ -93,7 +123,8 @@ describe('reasonToString', () => { name: 'policyName', when: [] } - expect(reasonToString(item)).toEqual( + + expect(transpileReason(item)).toEqual( 'reason = {"type":"permit","policyId":"12345","policyName":"policyName","approvalsSatisfied":[],"approvalsMissing":[]}' ) }) @@ -105,7 +136,8 @@ describe('reasonToString', () => { name: 'policyName', when: [] } - expect(reasonToString(item)).toEqual( + + expect(transpileReason(item)).toEqual( 'reason = {"type":"forbid","policyId":"12345","policyName":"policyName","approvalsSatisfied":[],"approvalsMissing":[]}' ) }) diff --git a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/wasm-build.util.spec.ts b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/wasm-build.util.spec.ts new file mode 100644 index 000000000..718e9b1df --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/wasm-build.util.spec.ts @@ -0,0 +1,152 @@ +import { FIXTURE } from '@narval/policy-engine-shared' +import { ConfigModule, ConfigService, Path, PathValue } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import { loadPolicy } from '@open-policy-agent/opa-wasm' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { Config, load } from '../../../../../policy-engine.config' +import { withTempDirectory } from '../../../../../shared/testing/with-temp-directory.testing' +import { getRegoRuleTemplatePath } from '../../rego-transpiler.util' +import { + build, + buildOpaBundle, + copyRegoCore, + createDirectories, + getRegoCorePath, + unzip, + writeRegoPolicies +} from '../../wasm-build.util' + +const getConfig = async

>(propertyPath: P): Promise> => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [load] })] + }).compile() + + const service = module.get>(ConfigService) + + return service.get(propertyPath, { infer: true }) +} + +const getTemplatePath = async () => getRegoRuleTemplatePath(await getConfig('resourcePath')) + +const getCorePath = async () => getRegoCorePath(await getConfig('resourcePath')) + +describe('createDirectories', () => { + it('creates rego source, generated rego, and dist directories', async () => { + await withTempDirectory(async (path) => { + const { regoSourceDirectory, generatedRegoDirectory, distDirectory } = await createDirectories(path) + + expect(existsSync(regoSourceDirectory)).toEqual(true) + expect(existsSync(generatedRegoDirectory)).toEqual(true) + expect(existsSync(distDirectory)).toEqual(true) + }) + }) +}) + +describe('writeRegoPolicies', () => { + it('writes a file with transpiled rego policies', async () => { + await withTempDirectory(async (path) => { + const { file } = await writeRegoPolicies({ + policies: FIXTURE.POLICIES, + filename: 'policies.rego', + path, + regoRuleTemplatePath: await getTemplatePath() + }) + + const content = await readFile(file, 'utf-8') + + expect(existsSync(file)).toEqual(true) + + // NOTE: The transpilation process is covered by rego-transpile.util.ts. + // Here we only care if the file is empty or not. + expect(content).toContain('permit') + expect(content).toContain('forbid') + }) + }) +}) + +describe('copyRegoCore', () => { + it('copies the rego core files', async () => { + await withTempDirectory(async (path) => { + await copyRegoCore({ + source: await getCorePath(), + destination: path + }) + + expect(existsSync(`${path}/criteria`)).toEqual(true) + expect(existsSync(`${path}/main.rego`)).toEqual(true) + }) + }) + + it('does not copy the rego tests', async () => { + await withTempDirectory(async (path) => { + await copyRegoCore({ + source: await getCorePath(), + destination: path + }) + + expect(existsSync(`${path}/__test__`)).toEqual(false) + }) + }) +}) + +describe('bundleOpaBundle', () => { + it('writes the bundle gzip tarball', async () => { + await withTempDirectory(async (path) => { + const { regoSourceDirectory, distDirectory } = await createDirectories(path) + + await copyRegoCore({ + source: await getCorePath(), + destination: regoSourceDirectory + }) + + const { bundleFile } = await buildOpaBundle({ regoSourceDirectory, distDirectory }) + + expect(existsSync(bundleFile)).toEqual(true) + }) + }) +}) + +describe('unzip', () => { + it('unzips a gziped file', async () => { + await withTempDirectory(async (path) => { + const { regoSourceDirectory, distDirectory } = await createDirectories(path) + + await copyRegoCore({ + source: await getCorePath(), + destination: regoSourceDirectory + }) + + const { bundleFile } = await buildOpaBundle({ regoSourceDirectory, distDirectory }) + + await unzip({ source: bundleFile, destination: distDirectory }) + + expect(existsSync(`${distDirectory}/policy.wasm`)).toEqual(true) + expect(existsSync(`${distDirectory}/data.json`)).toEqual(true) + expect(existsSync(`${distDirectory}/.manifest`)).toEqual(true) + // NOTE: The OPA build includes the source code of the bundle in the + // gzip file. That's why the `path` exists within the dist directory as + // well. + // See https://www.openpolicyagent.org/docs/latest/management-bundles/#bundle-file-format + expect(existsSync(`${distDirectory}/${path}/rego/main.rego`)).toEqual(true) + }) + }) +}) + +describe('build', () => { + it('resolves with a valid wasm and correct entrypoint', async () => { + await withTempDirectory(async (path) => { + const wasm = await build({ + path, + regoCorePath: await getCorePath(), + regoRuleTemplatePath: await getTemplatePath(), + policies: FIXTURE.POLICIES, + cleanAfter: false + }) + + const opa = await loadPolicy(wasm) + + expect(opa.entrypoints).toEqual({ 'main/evaluate': 0 }) + }) + }) +}) diff --git a/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts b/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts new file mode 100644 index 000000000..e2f70b05c --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts @@ -0,0 +1,79 @@ +import { Action, Entities, EvaluationRequest } from '@narval/policy-engine-shared' +import { InputType, safeDecode } from '@narval/transaction-request-intent' +import { HttpStatus } from '@nestjs/common' +import { indexBy } from 'lodash/fp' +import { OpenPolicyAgentException } from '../exception/open-policy-agent.exception' +import { Data, Input, UserGroup, WalletGroup } from '../type/open-policy-agent.type' + +export const toInput = (evaluation: EvaluationRequest): Omit => { + const { action } = evaluation.request + + if (action === Action.SIGN_TRANSACTION) { + const result = safeDecode({ + input: { + type: InputType.TRANSACTION_REQUEST, + txRequest: evaluation.request.transactionRequest + } + }) + + if (result.success) { + return { + action, + intent: result.intent, + transactionRequest: evaluation.request.transactionRequest, + transfers: evaluation.transfers + } + } + + throw new OpenPolicyAgentException({ + message: 'Invalid transaction request intent', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST, + context: { error: result.error } + }) + } + + throw new OpenPolicyAgentException({ + message: 'Unsupported evaluation request action', + suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + context: { action } + }) +} + +export const toData = (entities: Entities): Data => { + const userGroups = entities.userGroupMembers.reduce((groups, { userId, groupId }) => { + const group = groups.get(groupId) + + if (group) { + return groups.set(groupId, { + id: groupId, + users: group.users.concat(userId) + }) + } else { + return groups.set(groupId, { id: groupId, users: [userId] }) + } + }, new Map()) + + const walletGroups = entities.walletGroupMembers.reduce((groups, { walletId, groupId }) => { + const group = groups.get(groupId) + + if (group) { + return groups.set(groupId, { + id: groupId, + wallets: group.wallets.concat(walletId) + }) + } else { + return groups.set(groupId, { id: groupId, wallets: [walletId] }) + } + }, new Map()) + + return { + entities: { + addressBook: indexBy('id', entities.addressBook), + tokens: indexBy('id', entities.tokens), + users: indexBy('id', entities.users), + userGroups: Object.fromEntries(userGroups), + wallets: indexBy('id', entities.wallets), + walletGroups: Object.fromEntries(walletGroups) + } + } +} diff --git a/apps/policy-engine/src/shared/utils/opa.utils.ts b/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts similarity index 61% rename from apps/policy-engine/src/shared/utils/opa.utils.ts rename to apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts index ebbed57d2..6405e8de8 100644 --- a/apps/policy-engine/src/shared/utils/opa.utils.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/rego-transpiler.util.ts @@ -1,7 +1,14 @@ import { Criterion, Policy, PolicyCriterion, Then } from '@narval/policy-engine-shared' +import { readFile } from 'fs/promises' +import Handlebars from 'handlebars' import { isEmpty } from 'lodash' +import { v4 as uuid } from 'uuid' -export const criterionToString = (item: PolicyCriterion) => { +export const getRegoRuleTemplatePath = (resourcePath: string) => { + return `${resourcePath}/open-policy-agent/rego/rules.template.hbs` +} + +export const transpileCriterion = (item: PolicyCriterion) => { const criterion: Criterion = item.criterion const args = item.args @@ -12,6 +19,8 @@ export const criterionToString = (item: PolicyCriterion) => { } if (criterion === Criterion.CHECK_APPROVALS) { + // TODO: (@wcalderipe, 18/03/24): Explore with team a threat model on + // string interpolation injection. return `approvals = ${criterion}([${args.map((el) => JSON.stringify(el)).join(', ')}])` } @@ -24,7 +33,7 @@ export const criterionToString = (item: PolicyCriterion) => { return `${criterion}` } -export const reasonToString = (item: Policy & { id: string }) => { +export const transpileReason = (item: Policy & { id: string }) => { if (item.then === Then.PERMIT) { const approvals = item.when.find((c) => c.criterion === Criterion.CHECK_APPROVALS) const approvalsSatisfied = approvals @@ -50,5 +59,18 @@ export const reasonToString = (item: Policy & { id: string }) => { approvalsSatisfied: [], approvalsMissing: [] } + return `reason = ${JSON.stringify(reason)}` } + +export const transpile = async (policies: Policy[], templatePath: string): Promise => { + Handlebars.registerHelper('criterion', transpileCriterion) + Handlebars.registerHelper('reason', transpileReason) + + const template = Handlebars.compile(await readFile(templatePath, 'utf-8')) + + return template({ + // TODO: Here the policy must have an ID already. + policies: policies.map((policy) => ({ ...policy, id: uuid() })) + }) +} diff --git a/apps/policy-engine/src/open-policy-agent/core/util/wasm-build.util.ts b/apps/policy-engine/src/open-policy-agent/core/util/wasm-build.util.ts new file mode 100644 index 000000000..21908dcd6 --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/core/util/wasm-build.util.ts @@ -0,0 +1,111 @@ +import { Policy } from '@narval/policy-engine-shared' +import { exec as execCommand } from 'child_process' +import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { promisify } from 'util' +import { POLICY_ENTRYPOINT } from '../../open-policy-agent.constant' +import { transpile } from './rego-transpiler.util' + +type BuildWebAssemblyOption = { + path: string + regoCorePath: string + regoRuleTemplatePath: string + policies: Policy[] + cleanAfter?: boolean +} + +const exec = promisify(execCommand) + +export const getRegoCorePath = (resourcePath: string): string => { + return `${resourcePath}/open-policy-agent/rego` +} + +export const createDirectories = async (path: string) => { + await mkdir(path, { recursive: true }) + + const regoSourceDirectory = `${path}/rego` + const generatedRegoDirectory = `${regoSourceDirectory}/generated` + const distDirectory = `${path}/dist` + + await Promise.all([mkdir(generatedRegoDirectory, { recursive: true }), mkdir(distDirectory)]) + + return { + regoSourceDirectory, + generatedRegoDirectory, + distDirectory + } +} + +export const writeRegoPolicies = async (option: { + policies: Policy[] + filename: string + path: string + regoRuleTemplatePath: string +}) => { + const policies = await transpile(option.policies, option.regoRuleTemplatePath) + const file = `${option.path}/${option.filename}` + + await writeFile(file, policies, 'utf-8') + + return { file } +} + +export const copyRegoCore = async (option: { source: string; destination: string }) => { + await cp(option.source, option.destination, { + recursive: true, + filter: (source) => !source.includes('__test__') + }) +} + +export const buildOpaBundle = async (option: { regoSourceDirectory: string; distDirectory: string }) => { + const bundleFile = `${option.distDirectory}/bundle.tar.gz` + + const cmd = [ + 'opa', + 'build', + '--target wasm', + `--entrypoint ${POLICY_ENTRYPOINT}`, + `--bundle ${option.regoSourceDirectory}`, + `--output ${bundleFile}` + ] + + await exec(cmd.join(' ')) + + return { bundleFile } +} + +export const unzip = async (option: { source: string; destination: string }) => { + await exec(`tar -xzf ${option.source} -C ${option.destination}`) +} + +export const build = async (option: BuildWebAssemblyOption): Promise => { + const cleanAfter = option.cleanAfter ?? true + + try { + const { regoSourceDirectory, generatedRegoDirectory, distDirectory } = await createDirectories(option.path) + + await copyRegoCore({ + source: option.regoCorePath, + destination: regoSourceDirectory + }) + + await writeRegoPolicies({ + policies: option.policies, + path: generatedRegoDirectory, + filename: 'policies.rego', + regoRuleTemplatePath: option.regoRuleTemplatePath + }) + + const { bundleFile } = await buildOpaBundle({ regoSourceDirectory, distDirectory }) + + await unzip({ + source: bundleFile, + destination: distDirectory + }) + + return readFile(`${distDirectory}/policy.wasm`) + } finally { + if (cleanAfter) { + await rm(option.path, { recursive: true, force: true }) + } + } +} diff --git a/apps/policy-engine/src/open-policy-agent/open-policy-agent.constant.ts b/apps/policy-engine/src/open-policy-agent/open-policy-agent.constant.ts new file mode 100644 index 000000000..901b10dbb --- /dev/null +++ b/apps/policy-engine/src/open-policy-agent/open-policy-agent.constant.ts @@ -0,0 +1 @@ +export const POLICY_ENTRYPOINT = 'main/evaluate' diff --git a/apps/policy-engine/src/open-policy-agent/open-policy-agent.module.ts b/apps/policy-engine/src/open-policy-agent/open-policy-agent.module.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/policy-engine/src/policy-engine.config.ts b/apps/policy-engine/src/policy-engine.config.ts index 2cffdeefb..e9acdcf45 100644 --- a/apps/policy-engine/src/policy-engine.config.ts +++ b/apps/policy-engine/src/policy-engine.config.ts @@ -16,6 +16,7 @@ const configSchema = z.object({ id: z.string(), masterKey: z.string().optional() }), + resourcePath: z.string(), keyring: z.union([ z.object({ type: z.literal('raw'), @@ -34,6 +35,7 @@ export const load = (): Config => { const result = configSchema.safeParse({ env: process.env.NODE_ENV, port: process.env.PORT, + resourcePath: process.env.RESOURCE_PATH, database: { url: process.env.POLICY_ENGINE_DATABASE_URL }, diff --git a/apps/policy-engine/src/policy-engine.constant.ts b/apps/policy-engine/src/policy-engine.constant.ts index 446431eba..2aaaa0fd4 100644 --- a/apps/policy-engine/src/policy-engine.constant.ts +++ b/apps/policy-engine/src/policy-engine.constant.ts @@ -1,6 +1,8 @@ import { RawAesWrappingSuiteIdentifier } from '@aws-crypto/client-node' export const REQUEST_HEADER_API_KEY = 'x-api-key' +export const REQUEST_HEADER_CLIENT_ID = 'x-client-id' +export const REQUEST_HEADER_CLIENT_SECRET = 'x-client-secret' export const ENCRYPTION_KEY_NAMESPACE = 'armory.policy-engine' export const ENCRYPTION_KEY_NAME = 'storage-encryption' diff --git a/apps/policy-engine/src/policy-engine.module.ts b/apps/policy-engine/src/policy-engine.module.ts index 8a7a7868b..02f5e6e0d 100644 --- a/apps/policy-engine/src/policy-engine.module.ts +++ b/apps/policy-engine/src/policy-engine.module.ts @@ -1,12 +1,12 @@ import { EncryptionModule } from '@narval/encryption-module' -import { Module, ValidationPipe } from '@nestjs/common' +import { Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_PIPE } from '@nestjs/core' +import { BootstrapService } from './engine/core/service/bootstrap.service' import { EngineService } from './engine/core/service/engine.service' import { EngineModule } from './engine/engine.module' import { load } from './policy-engine.config' import { EncryptionModuleOptionFactory } from './shared/factory/encryption-module-option.factory' -import { TenantModule } from './tenant/tenant.module' @Module({ imports: [ @@ -22,8 +22,7 @@ import { TenantModule } from './tenant/tenant.module' }), // Domain - EngineModule, - TenantModule + EngineModule ], providers: [ { @@ -32,4 +31,10 @@ import { TenantModule } from './tenant/tenant.module' } ] }) -export class PolicyEngineModule {} +export class PolicyEngineModule implements OnApplicationBootstrap { + constructor(private bootstrapService: BootstrapService) {} + + async onApplicationBootstrap() { + await this.bootstrapService.boot() + } +} diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/approval_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/approval_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/approval_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/approval_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/amount_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/amount_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/amount_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/amount_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/contractCall_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractCall_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/contractCall_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractCall_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/contractDeploy_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractDeploy_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/contractDeploy_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/contractDeploy_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/destination_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/destination_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/destination_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/destination_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/permit_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/permit_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/permit_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/permit_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/signMessage_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/signMessage_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/signMessage_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/signMessage_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/tokenAllowance_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/tokenAllowance_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/tokenAllowance_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/tokenAllowance_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/transferNft_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferNft_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/transferNft_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferNft_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/intent/transferToken_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferToken_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/intent/transferToken_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/intent/transferToken_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/principal_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/principal_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/principal_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/resource_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/resource_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/resource_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/resource_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/spendingLimit_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/spendingLimit_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/spendingLimit_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/spendingLimit_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/transactionRequest/gas_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/gas_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/transactionRequest/gas_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/gas_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/criteria/transactionRequest/nonce_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/nonce_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/criteria/transactionRequest/nonce_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/criteria/transactionRequest/nonce_test.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/main_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/main_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/main_test.rego diff --git a/apps/policy-engine/src/opa/rego/policies/approvals.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego similarity index 80% rename from apps/policy-engine/src/opa/rego/policies/approvals.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego index 6b87eed05..9968cf402 100644 --- a/apps/policy-engine/src/opa/rego/policies/approvals.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals.rego @@ -94,3 +94,26 @@ permit[{"policyId": "approvalByUserRoles"}] = reason { "approvalsMissing": approvals.approvalsMissing, } } + +permit[{"policyId": "withoutApprovals"}] = reason { + resources = {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"} + transferTypes = {"transferERC20"} + tokens = {"eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174"} + transferValueCondition = {"currency": "*", "operator": "lte", "value": "1000000000000000000"} + + checkResourceIntegrity + checkPrincipal + checkNonceExists + checkAction({"signTransaction"}) + checkWalletId(resources) + checkIntentType(transferTypes) + checkIntentToken(tokens) + checkIntentAmount(transferValueCondition) + + reason = { + "type": "permit", + "policyId": "withoutApprovals", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} diff --git a/apps/policy-engine/src/opa/rego/__test__/policies/approvals_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego similarity index 78% rename from apps/policy-engine/src/opa/rego/__test__/policies/approvals_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego index 081193759..30bc1c60b 100644 --- a/apps/policy-engine/src/opa/rego/__test__/policies/approvals_test.rego +++ b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/approvals_test.rego @@ -61,3 +61,22 @@ test_approvalByUserRoles { "type": "permit", } } + +test_withoutApprovals { + withoutApprovalsReq = { + "action": "signTransaction", + "transactionRequest": transactionRequestReq, + "principal": {"userId": "test-alice-uid"}, "resource": resourceReq, + "intent": intentReq, + "feeds": feedsReq, + } + + res = permit[{"policyId": "withoutApprovals"}] with input as withoutApprovalsReq with data.entities as entities + + res == { + "type": "permit", + "policyId": "withoutApprovals", + "approvalsSatisfied": [], + "approvalsMissing": [], + } +} diff --git a/apps/policy-engine/src/opa/rego/policies/e2e.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/policies/e2e.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/policies/e2e_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/policies/e2e_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/e2e_test.rego diff --git a/apps/policy-engine/src/opa/rego/policies/missing-rules.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/missing-rules.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/policies/missing-rules.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/missing-rules.rego diff --git a/apps/policy-engine/src/opa/rego/policies/spendings.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/policies/spendings.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings.rego diff --git a/apps/policy-engine/src/opa/rego/__test__/policies/spendings_test.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings_test.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/__test__/policies/spendings_test.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/__test__/policies/spendings_test.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/action.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/action.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/action.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/action.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/approval.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/approval.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/approval.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/amount.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/amount.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/amount.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/amount.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/destination.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/destination.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/destination.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/destination.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/intent.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/intent.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/intent.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/intent.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/permit.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/permit.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/permit.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/permit.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/signMessage.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/signMessage.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/signMessage.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/signMessage.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/intent/transferNft.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/transferNft.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/intent/transferNft.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/intent/transferNft.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/principal.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/principal.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/principal.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/resource.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/resource.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/resource.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/resource.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/spendingLimit.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/spendingLimit.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/spendingLimit.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/spendingLimit.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/chainId.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/chainId.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/chainId.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/chainId.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/gas.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/gas.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/gas.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/gas.rego diff --git a/apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/nonce.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/nonce.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/criteria/transactionRequest/nonce.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/criteria/transactionRequest/nonce.rego diff --git a/apps/policy-engine/src/opa/rego/lib/main.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/main.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/main.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/main.rego diff --git a/apps/policy-engine/src/opa/rego/lib/policies/meta-permission.rego b/apps/policy-engine/src/resource/open-policy-agent/rego/policies/meta-permission.rego similarity index 100% rename from apps/policy-engine/src/opa/rego/lib/policies/meta-permission.rego rename to apps/policy-engine/src/resource/open-policy-agent/rego/policies/meta-permission.rego diff --git a/apps/policy-engine/src/opa/template/template.hbs b/apps/policy-engine/src/resource/open-policy-agent/rego/rules.template.hbs similarity index 100% rename from apps/policy-engine/src/opa/template/template.hbs rename to apps/policy-engine/src/resource/open-policy-agent/rego/rules.template.hbs diff --git a/apps/policy-engine/src/shared/decorator/__test__/unit/client-id.decorator.spec.ts b/apps/policy-engine/src/shared/decorator/__test__/unit/client-id.decorator.spec.ts new file mode 100644 index 000000000..e54137903 --- /dev/null +++ b/apps/policy-engine/src/shared/decorator/__test__/unit/client-id.decorator.spec.ts @@ -0,0 +1,34 @@ +import { ExecutionContext } from '@nestjs/common' +import { REQUEST_HEADER_CLIENT_ID } from '../../../../policy-engine.constant' +import { factory } from '../../client-id.decorator' + +describe('ClientId Decorator', () => { + it(`returns ${REQUEST_HEADER_CLIENT_ID} if it exists in the headers`, () => { + const clientId = '123456' + const headers = { + [REQUEST_HEADER_CLIENT_ID]: clientId + } + const request = { headers } + const context = { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + + const result = factory(null, context) + + expect(result).toBe(clientId) + }) + + it(`throws BadRequestException if ${REQUEST_HEADER_CLIENT_ID} is missing in the headers`, () => { + const headers = {} + const request = { headers } + const context = { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + + expect(() => factory(null, context)).toThrow(`Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`) + }) +}) diff --git a/apps/policy-engine/src/shared/decorator/client-id.decorator.ts b/apps/policy-engine/src/shared/decorator/client-id.decorator.ts new file mode 100644 index 000000000..5a85133b7 --- /dev/null +++ b/apps/policy-engine/src/shared/decorator/client-id.decorator.ts @@ -0,0 +1,15 @@ +import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common' +import { REQUEST_HEADER_CLIENT_ID } from '../../policy-engine.constant' + +export const factory = (_value: unknown, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest() + const clientId = req.headers[REQUEST_HEADER_CLIENT_ID] + + if (!clientId || typeof clientId !== 'string') { + throw new BadRequestException(`Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`) + } + + return clientId +} + +export const ClientId = createParamDecorator(factory) diff --git a/apps/policy-engine/src/shared/guard/__test__/unit/client-secret.guard.spec.ts b/apps/policy-engine/src/shared/guard/__test__/unit/client-secret.guard.spec.ts new file mode 100644 index 000000000..6a49d1c21 --- /dev/null +++ b/apps/policy-engine/src/shared/guard/__test__/unit/client-secret.guard.spec.ts @@ -0,0 +1,82 @@ +import { ExecutionContext } from '@nestjs/common' +import { mock } from 'jest-mock-extended' +import { TenantService } from '../../../../engine/core/service/tenant.service' +import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET } from '../../../../policy-engine.constant' +import { ApplicationException } from '../../../exception/application.exception' +import { Tenant } from '../../../type/domain.type' +import { ClientSecretGuard } from '../../client-secret.guard' + +describe(ClientSecretGuard.name, () => { + const CLIENT_ID = 'tenant-a' + + const mockExecutionContext = ({ clientSecret, clientId }: { clientSecret?: string; clientId?: string }) => { + const headers = { + [REQUEST_HEADER_CLIENT_SECRET]: clientSecret, + [REQUEST_HEADER_CLIENT_ID]: clientId + } + const request = { headers } + + return { + switchToHttp: () => ({ + getRequest: () => request + }) + } as ExecutionContext + } + + const mockService = (clientSecret: string = 'tenant-a-secret-key') => { + const tenant: Tenant = { + clientId: CLIENT_ID, + clientSecret: clientSecret, + dataStore: { + entity: { + dataUrl: 'http://9.9.9.9:99/test-data-store', + signatureUrl: 'http://9.9.9.9:99/test-data-store', + keys: [] + }, + policy: { + dataUrl: 'http://9.9.9.9:99/test-data-store', + signatureUrl: 'http://9.9.9.9:99/test-data-store', + keys: [] + } + }, + updatedAt: new Date(), + createdAt: new Date() + } + + const serviceMock = mock() + serviceMock.findByClientId.mockResolvedValue(tenant) + + return serviceMock + } + + it(`throws an error when ${REQUEST_HEADER_CLIENT_SECRET} header is missing`, async () => { + const guard = new ClientSecretGuard(mockService()) + + await expect(guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID }))).rejects.toThrow(ApplicationException) + }) + + it(`throws an error when ${REQUEST_HEADER_CLIENT_ID} header is missing`, async () => { + const guard = new ClientSecretGuard(mockService('my-secret')) + + await expect(guard.canActivate(mockExecutionContext({ clientSecret: 'my-secret' }))).rejects.toThrow( + ApplicationException + ) + }) + + it(`returns true when ${REQUEST_HEADER_CLIENT_SECRET} matches the client secret key`, async () => { + const adminApiKey = 'test-client-api-key' + const guard = new ClientSecretGuard(mockService(adminApiKey)) + + expect(await guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID, clientSecret: adminApiKey }))).toEqual( + true + ) + }) + + it(`returns false when ${REQUEST_HEADER_CLIENT_SECRET} does not matches the client secret key`, async () => { + const guard = new ClientSecretGuard(mockService('test-admin-api-key')) + + expect( + await guard.canActivate(mockExecutionContext({ clientId: CLIENT_ID, clientSecret: 'wrong-secret' })) + ).toEqual(false) + }) +}) diff --git a/apps/policy-engine/src/shared/guard/client-secret.guard.ts b/apps/policy-engine/src/shared/guard/client-secret.guard.ts new file mode 100644 index 000000000..78d11a0ca --- /dev/null +++ b/apps/policy-engine/src/shared/guard/client-secret.guard.ts @@ -0,0 +1,31 @@ +import { CanActivate, ExecutionContext, HttpStatus, Injectable } from '@nestjs/common' +import { TenantService } from '../../engine/core/service/tenant.service' +import { REQUEST_HEADER_CLIENT_ID, REQUEST_HEADER_CLIENT_SECRET } from '../../policy-engine.constant' +import { ApplicationException } from '../exception/application.exception' + +@Injectable() +export class ClientSecretGuard implements CanActivate { + constructor(private tenantService: TenantService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest() + const clientSecret = req.headers[REQUEST_HEADER_CLIENT_SECRET] + const clientId = req.headers[REQUEST_HEADER_CLIENT_ID] + + if (!clientSecret) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_CLIENT_SECRET} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } else if (!clientId) { + throw new ApplicationException({ + message: `Missing or invalid ${REQUEST_HEADER_CLIENT_ID} header`, + suggestedHttpStatusCode: HttpStatus.UNAUTHORIZED + }) + } + + const tenant = await this.tenantService.findByClientId(clientId) + + return tenant?.clientSecret?.toLowerCase() === clientSecret.toLowerCase() + } +} diff --git a/apps/policy-engine/src/engine/persistence/repository/mock_data.ts b/apps/policy-engine/src/shared/testing/evaluation.testing.ts similarity index 73% rename from apps/policy-engine/src/engine/persistence/repository/mock_data.ts rename to apps/policy-engine/src/shared/testing/evaluation.testing.ts index b8b2aecce..ffbd599e2 100644 --- a/apps/policy-engine/src/engine/persistence/repository/mock_data.ts +++ b/apps/policy-engine/src/shared/testing/evaluation.testing.ts @@ -1,11 +1,10 @@ import { Action, EvaluationRequest, FIXTURE, Request, TransactionRequest } from '@narval/policy-engine-shared' import { Payload, SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' -import { UNSAFE_PRIVATE_KEY } from 'packages/policy-engine-shared/src/lib/dev.fixture' import { toHex } from 'viem' export const ONE_ETH = BigInt('1000000000000000000') -export const generateInboundRequest = async (): Promise => { +export const generateInboundEvaluationRequest = async (): Promise => { const txRequest: TransactionRequest = { from: FIXTURE.WALLET.Engineering.address, to: FIXTURE.WALLET.Treasury.address, @@ -31,21 +30,21 @@ export const generateInboundRequest = async (): Promise => { // const aliceSignature = await FIXTURE.ACCOUNT.Alice.signMessage({ message }) const aliceSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice), + privateKeyToJwk(FIXTURE.UNSAFE_PRIVATE_KEY.Alice), { alg: SigningAlg.EIP191 }, - buildSignerEip191(UNSAFE_PRIVATE_KEY.Alice) + buildSignerEip191(FIXTURE.UNSAFE_PRIVATE_KEY.Alice) ) const bobSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob), + privateKeyToJwk(FIXTURE.UNSAFE_PRIVATE_KEY.Bob), { alg: SigningAlg.EIP191 }, - buildSignerEip191(UNSAFE_PRIVATE_KEY.Bob) + buildSignerEip191(FIXTURE.UNSAFE_PRIVATE_KEY.Bob) ) const carolSignature = await signJwt( payload, - privateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol), + privateKeyToJwk(FIXTURE.UNSAFE_PRIVATE_KEY.Carol), { alg: SigningAlg.EIP191 }, - buildSignerEip191(UNSAFE_PRIVATE_KEY.Carol) + buildSignerEip191(FIXTURE.UNSAFE_PRIVATE_KEY.Carol) ) return { authentication: aliceSignature, diff --git a/apps/policy-engine/src/shared/testing/with-temp-directory.testing.ts b/apps/policy-engine/src/shared/testing/with-temp-directory.testing.ts new file mode 100644 index 000000000..b9b159168 --- /dev/null +++ b/apps/policy-engine/src/shared/testing/with-temp-directory.testing.ts @@ -0,0 +1,19 @@ +import { mkdir, rm } from 'fs/promises' +import { v4 as uuid } from 'uuid' + +export const withTempDirectory = async ( + thunk: (path: string) => Promise, + option?: { cleanAfter: boolean } +): Promise => { + const cleanAfter = option?.cleanAfter ?? true + const path = `/tmp/armory-temp-test-directory-${uuid()}` + + try { + await mkdir(path) + await thunk(path) + } finally { + if (cleanAfter) { + await rm(path, { recursive: true }) + } + } +} diff --git a/apps/policy-engine/src/shared/type/domain.type.ts b/apps/policy-engine/src/shared/type/domain.type.ts index afd030db9..f1f1c4972 100644 --- a/apps/policy-engine/src/shared/type/domain.type.ts +++ b/apps/policy-engine/src/shared/type/domain.type.ts @@ -1,11 +1,3 @@ -import { - Action, - ApprovalRequirement, - CredentialEntity, - HistoricalTransfer, - TransactionRequest -} from '@narval/policy-engine-shared' -import { Intent } from '@narval/transaction-request-intent' import { z } from 'zod' import { engineSchema } from '../schema/engine.schema' import { tenantSchema } from '../schema/tenant.schema' @@ -13,34 +5,3 @@ import { tenantSchema } from '../schema/tenant.schema' export type Tenant = z.infer export type Engine = z.infer - -export type RegoInput = { - action: Action - intent?: Intent - transactionRequest?: TransactionRequest - principal: CredentialEntity - resource?: { uid: string } - approvals: CredentialEntity[] - transfers?: HistoricalTransfer[] -} - -export type MatchedRule = { - policyName: string - policyId: string - type: 'permit' | 'forbid' - approvalsSatisfied: ApprovalRequirement[] - approvalsMissing: ApprovalRequirement[] -} - -export type OpaResult = { - default?: boolean - permit: boolean - reasons: MatchedRule[] -} - -export type VerifiedApproval = { - signature: string - userId: string - credentialId: string // The credential used for this approval - address?: string // Address, if the Credential is a EOA private key TODO: Do we need this? -} diff --git a/apps/policy-engine/src/shared/type/entities.types.ts b/apps/policy-engine/src/shared/type/entities.types.ts deleted file mode 100644 index c0e798c7e..000000000 --- a/apps/policy-engine/src/shared/type/entities.types.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AccountClassification, AccountType, Address, UserRole } from '@narval/policy-engine-shared' - -export type Organization = { - uid: string -} - -export type User = { - id: string // Pubkey - role: UserRole -} - -export type UserGroup = { - id: string - users: string[] // userIds -} - -export type Wallet = { - id: string - address: Address - accountType: AccountType - chainId?: number - assignees?: string[] // userIds -} - -export type WalletGroup = { - id: string - wallets: string[] // walletIds -} - -export type AddressBookAccount = { - id: string - address: Address - chainId: number - classification: AccountClassification -} - -export type Token = { - id: string - address: Address - symbol: string - chainId: number - decimals: number -} - -export type RegoData = { - entities: { - users: Record - wallets: Record - userGroups: Record - walletGroups: Record - addressBook: Record - tokens: Record - } -} diff --git a/apps/policy-engine/src/shared/type/rego.ts b/apps/policy-engine/src/shared/type/rego.ts deleted file mode 100644 index ed327b084..000000000 --- a/apps/policy-engine/src/shared/type/rego.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - Action, - ApprovalRequirement, - CredentialEntity, - HistoricalTransfer, - TransactionRequest -} from '@narval/policy-engine-shared' -import { Intent } from 'packages/transaction-request-intent/src/lib/intent.types' - -export type RegoInput = { - action: Action - intent?: Intent - transactionRequest?: TransactionRequest - principal: CredentialEntity - resource?: { uid: string } - approvals: CredentialEntity[] - transfers?: HistoricalTransfer[] -} - -type MatchedRule = { - policyName: string - policyId: string - type: 'permit' | 'forbid' - approvalsSatisfied: ApprovalRequirement[] - approvalsMissing: ApprovalRequirement[] -} - -export type OpaResult = { - default?: boolean - permit: boolean - reasons: MatchedRule[] -} diff --git a/apps/policy-engine/src/tenant/core/service/bootstrap.service.ts b/apps/policy-engine/src/tenant/core/service/bootstrap.service.ts deleted file mode 100644 index 0a7a8cf64..000000000 --- a/apps/policy-engine/src/tenant/core/service/bootstrap.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' -import { TenantService } from './tenant.service' - -@Injectable() -export class BootstrapService { - private logger = new Logger(BootstrapService.name) - - constructor(private tenantService: TenantService) {} - - async boot(): Promise { - this.logger.log('Start engine bootstrap') - - await this.syncTenants() - } - - private async syncTenants(): Promise { - const tenants = await this.tenantService.findAll() - - this.logger.log('Start syncing tenants data stores', { - tenantsCount: tenants.length - }) - - // TODO: (@wcalderipe, 07/03/24) maybe change the execution to parallel? - for (const tenant of tenants) { - await this.tenantService.syncDataStore(tenant.clientId) - } - } -} diff --git a/apps/policy-engine/src/tenant/tenant.module.ts b/apps/policy-engine/src/tenant/tenant.module.ts deleted file mode 100644 index 21719d737..000000000 --- a/apps/policy-engine/src/tenant/tenant.module.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { HttpModule } from '@nestjs/axios' -import { Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' -import { APP_PIPE } from '@nestjs/core' -import { EngineModule } from '../engine/engine.module' -import { AdminApiKeyGuard } from '../shared/guard/admin-api-key.guard' -import { KeyValueModule } from '../shared/module/key-value/key-value.module' -import { DataStoreRepositoryFactory } from './core/factory/data-store-repository.factory' -import { BootstrapService } from './core/service/bootstrap.service' -import { DataStoreService } from './core/service/data-store.service' -import { TenantService } from './core/service/tenant.service' -import { TenantController } from './http/rest/controller/tenant.controller' -import { FileSystemDataStoreRepository } from './persistence/repository/file-system-data-store.repository' -import { HttpDataStoreRepository } from './persistence/repository/http-data-store.repository' -import { TenantRepository } from './persistence/repository/tenant.repository' - -@Module({ - // NOTE: The AdminApiKeyGuard is the only reason we need the EngineModule. - imports: [HttpModule, KeyValueModule, EngineModule], - controllers: [TenantController], - providers: [ - AdminApiKeyGuard, - BootstrapService, - DataStoreRepositoryFactory, - DataStoreService, - FileSystemDataStoreRepository, - HttpDataStoreRepository, - TenantRepository, - TenantService, - { - provide: APP_PIPE, - useClass: ValidationPipe - } - ] -}) -export class TenantModule implements OnApplicationBootstrap { - constructor(private bootstrapService: BootstrapService) {} - - async onApplicationBootstrap() { - await this.bootstrapService.boot() - } -} diff --git a/apps/vault/src/tenant/core/service/tenant.service.ts b/apps/vault/src/tenant/core/service/tenant.service.ts index a721a037e..f2bcc2e6d 100644 --- a/apps/vault/src/tenant/core/service/tenant.service.ts +++ b/apps/vault/src/tenant/core/service/tenant.service.ts @@ -1,12 +1,10 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common' +import { HttpStatus, Injectable } from '@nestjs/common' import { ApplicationException } from '../../../shared/exception/application.exception' import { Tenant } from '../../../shared/type/domain.type' import { TenantRepository } from '../../persistence/repository/tenant.repository' @Injectable() export class TenantService { - private logger = new Logger(TenantService.name) - constructor(private tenantRepository: TenantRepository) {} async findByClientId(clientId: string): Promise { diff --git a/packages/policy-engine-shared/src/index.ts b/packages/policy-engine-shared/src/index.ts index 9f7030089..bc218a8d9 100644 --- a/packages/policy-engine-shared/src/index.ts +++ b/packages/policy-engine-shared/src/index.ts @@ -6,9 +6,17 @@ export * from './lib/decorators/is-not-empty-array-string.decorator' export * from './lib/dto' +export * from './lib/schema/address.schema' +export * from './lib/schema/data-store.schema' +export * from './lib/schema/domain.schema' +export * from './lib/schema/entity.schema' +export * from './lib/schema/hex.schema' +export * from './lib/schema/policy.schema' + export * from './lib/type/action.type' export * from './lib/type/data-store.type' export * from './lib/type/domain.type' +export * from './lib/type/engine.type' export * from './lib/type/entity.type' export * from './lib/type/policy.type' @@ -21,10 +29,4 @@ export * from './lib/util/evm.util' export * from './lib/util/json.util' export * from './lib/util/typeguards' -export * from './lib/schema/address.schema' -export * from './lib/schema/data-store.schema' -export * from './lib/schema/entity.schema' -export * from './lib/schema/hex.schema' -export * from './lib/schema/policy.schema' - export * as FIXTURE from './lib/dev.fixture' diff --git a/packages/policy-engine-shared/src/lib/schema/domain.schema.ts b/packages/policy-engine-shared/src/lib/schema/domain.schema.ts new file mode 100644 index 000000000..b9ed7aa4b --- /dev/null +++ b/packages/policy-engine-shared/src/lib/schema/domain.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { EntityType } from '../type/domain.type' + +export const approvalRequirementSchema = z.object({ + approvalCount: z.number().min(0), + /** + * The number of requried approvals + */ + approvalEntityType: z.nativeEnum(EntityType), + /** + * List of entities IDs that must satisfy the requirements. + */ + entityIds: z.array(z.string()), + countPrincipal: z.boolean() +}) diff --git a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts index 1981f384f..c42967c60 100644 --- a/packages/policy-engine-shared/src/lib/schema/entity.schema.ts +++ b/packages/policy-engine-shared/src/lib/schema/entity.schema.ts @@ -78,7 +78,7 @@ export const addressBookAccountEntitySchema = z.object({ export const tokenEntitySchema = z.object({ id: z.string(), address: addressSchema, - symbol: z.string(), + symbol: z.string().nullable(), chainId: z.number(), decimals: z.number() }) diff --git a/packages/policy-engine-shared/src/lib/type/domain.type.ts b/packages/policy-engine-shared/src/lib/type/domain.type.ts index 2d1aa46d6..1f7a7206f 100644 --- a/packages/policy-engine-shared/src/lib/type/domain.type.ts +++ b/packages/policy-engine-shared/src/lib/type/domain.type.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' +import { approvalRequirementSchema } from '../schema/domain.schema' import { AssetId } from '../util/caip.util' import { CreateOrganizationAction, SignMessageAction, SignTransactionAction, SignTypedDataAction } from './action.type' @@ -119,16 +121,19 @@ export type Feed = { * being authorized. This is the data that will be hashed and signed. */ export type EvaluationRequest = { - // JWT string signing the Request payload + /** + * JWT signature of the request property. + */ authentication: JwtString /** - * The authorization request of + * The authorization request. */ request: Request /** - * JWT strings signing the Request payload + * JWT signatures of the request property. */ approvals?: JwtString[] + // TODO: Delete transfers. It was replaced by `feeds`. transfers?: HistoricalTransfer[] prices?: Prices /** @@ -138,21 +143,7 @@ export type EvaluationRequest = { feeds?: Feed[] } -export type ApprovalRequirement = { - /** - * The number of requried approvals - */ - approvalCount: number // Number approvals required - /** - * The entity type required to approve. - */ - approvalEntityType: EntityType - /** - * List of entities IDs that must satisfy the requirements. - */ - entityIds: string[] - countPrincipal: boolean -} +export type ApprovalRequirement = z.infer export type AccessToken = { value: string // JWT @@ -170,7 +161,6 @@ export type EvaluationResponse = { accessToken?: AccessToken transactionRequestIntent?: unknown } -// DOMAIN export type Hex = `0x${string}` // DOMAIN diff --git a/packages/policy-engine-shared/src/lib/type/engine.type.ts b/packages/policy-engine-shared/src/lib/type/engine.type.ts new file mode 100644 index 000000000..6867bcb1c --- /dev/null +++ b/packages/policy-engine-shared/src/lib/type/engine.type.ts @@ -0,0 +1,12 @@ +import { EvaluationRequest, EvaluationResponse } from './domain.type' +import { Entities } from './entity.type' +import { Policy } from './policy.type' + +export interface Engine { + evaluate(request: EvaluationRequest): Promise + setPolicies(policies: Policy[]): Engine + getPolicies(): Policy[] + setEntities(entities: Entities): Engine + getEntities(): Entities + load(): Promise +} diff --git a/packages/transaction-request-intent/src/lib/__test__/unit/mocks.ts b/packages/transaction-request-intent/src/lib/__test__/unit/mocks.ts index 8a774ba41..6b190fdaa 100644 --- a/packages/transaction-request-intent/src/lib/__test__/unit/mocks.ts +++ b/packages/transaction-request-intent/src/lib/__test__/unit/mocks.ts @@ -12,7 +12,6 @@ export const USDC_TOKEN = { decimals: 6 } -// ENTITIES: user, user group, wallet, wallet group, and address book. export type User = { uid: string // Pubkey role: UserRoles @@ -50,17 +49,6 @@ export type RolePermission = { admin_quorum_threshold?: number } -export type RegoData = { - entities: { - users: Record - user_groups: Record - wallets: Record - wallet_groups: Record - address_book: Record - } - permissions: Record> -} - export enum AccountType { EOA = 'eoa', AA = '4337' diff --git a/packages/transaction-request-intent/src/lib/validators.ts b/packages/transaction-request-intent/src/lib/validators.ts index 918b6e0fa..b81eb4352 100644 --- a/packages/transaction-request-intent/src/lib/validators.ts +++ b/packages/transaction-request-intent/src/lib/validators.ts @@ -6,7 +6,7 @@ export const validateNativeTransferInput = (txRequest: TransactionRequest): Nati const { value, chainId, to, from, nonce } = txRequest if (!value || !chainId || !to || !from) { throw new DecoderError({ - message: 'Malformed native transfer transaction request: missing value or chainId', + message: 'Malformed native transfer transaction request: missing value, chainId, to, or from', status: 400, context: { value, @@ -22,7 +22,7 @@ export const validateContractInteractionInput = (txRequest: TransactionRequest, const { data, to, chainId, from, nonce } = txRequest if (!data || !to || !chainId) { throw new DecoderError({ - message: 'Malformed transfer transaction request: missing data || chainId || to', + message: 'Malformed transfer transaction request: missing data, chainId, or to', status: 400, context: { chainId, @@ -42,7 +42,7 @@ export const validateContractDeploymentInput = (txRequest: TransactionRequest): const { data, chainId, from, to } = txRequest if (!data || !chainId || to) { throw new DecoderError({ - message: 'Malformed contract deployment transaction request: missing data || chainId', + message: 'Malformed contract deployment transaction request: missing data, chainId, or to', status: 400, context: { chainId, From e0bc7594960f0b80116260f687b91bf62651e759 Mon Sep 17 00:00:00 2001 From: Pierre Troger Date: Thu, 21 Mar 2024 15:10:03 -0400 Subject: [PATCH 5/5] remove unecessary logging --- apps/policy-engine/src/engine/core/service/tenant.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/policy-engine/src/engine/core/service/tenant.service.ts b/apps/policy-engine/src/engine/core/service/tenant.service.ts index 7dcf38027..15a3bdf57 100644 --- a/apps/policy-engine/src/engine/core/service/tenant.service.ts +++ b/apps/policy-engine/src/engine/core/service/tenant.service.ts @@ -71,7 +71,7 @@ export class TenantService { this.tenantRepository.savePolicyStore(clientId, stores.policy) ]) - this.logger.log('Tenant data stores synced', { clientId, stores }) + this.logger.log('Tenant data stores synced', { clientId }) return true }